From bbed1effdf35901da29806f8ceb9f8bc53b76d1d Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 27 May 2026 15:33:11 +0300 Subject: [PATCH 01/36] Add autoclose domain types for perpetual TP/SL modify --- .../perpetual/autoclose/AutocloseError.kt | 7 ++ .../perpetual/autoclose/AutocloseEstimator.kt | 55 +++++++++++++ .../perpetual/autoclose/AutocloseField.kt | 20 +++++ .../autoclose/AutocloseModifyBuilder.kt | 46 +++++++++++ .../perpetual/autoclose/AutocloseValidator.kt | 24 ++++++ .../autoclose/AutocloseEstimatorTest.kt | 56 +++++++++++++ .../autoclose/AutocloseModifyBuilderTest.kt | 79 +++++++++++++++++++ .../autoclose/AutocloseValidatorTest.kt | 46 +++++++++++ .../android/testkit/PerpetualMock.kt | 68 ++++++++++++++++ 9 files changed, 401 insertions(+) create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseError.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimator.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseField.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilder.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidator.kt create mode 100644 android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimatorTest.kt create mode 100644 android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilderTest.kt create mode 100644 android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidatorTest.kt diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseError.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseError.kt new file mode 100644 index 0000000000..3bbe75d7b9 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseError.kt @@ -0,0 +1,7 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +enum class AutocloseError { + InvalidAmount, + TriggerMustBeHigher, + TriggerMustBeLower, +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimator.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimator.kt new file mode 100644 index 0000000000..2abddcc0ca --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimator.kt @@ -0,0 +1,55 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.gemwallet.android.domains.price.PriceChange +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType +import kotlin.math.abs + +class AutocloseEstimator( + val entryPrice: Double, + val positionSize: Double, + val direction: PerpetualDirection, + val leverage: UByte, +) { + val hasSize: Boolean get() = positionSize != 0.0 + + val percentSuggestions: List + get() = when { + leverage <= 3u -> listOf(5, 10, 15) + leverage <= 5u -> listOf(10, 15, 25) + leverage <= 10u -> listOf(15, 25, 50) + else -> listOf(25, 50, 100) + } + + fun pnl(price: Double): Double { + val absSize = abs(positionSize) + val delta = price - entryPrice + return when (direction) { + PerpetualDirection.Long -> delta * absSize + PerpetualDirection.Short -> -delta * absSize + } + } + + fun priceChangePercent(price: Double): Double { + val raw = PriceChange.percentage(from = entryPrice, to = price) + return if (direction == PerpetualDirection.Short) -raw else raw + } + + fun roe(price: Double): Double = priceChangePercent(price) * leverage.toInt() + + fun targetPriceFromRoe(roePercent: Int, type: TpslType): Double { + val leverageInt = leverage.toInt().coerceAtLeast(1) + val fraction = roePercent.toDouble() / leverageInt.toDouble() / 100.0 + val sign = when (type) { + TpslType.TakeProfit -> when (direction) { + PerpetualDirection.Long -> 1.0 + PerpetualDirection.Short -> -1.0 + } + TpslType.StopLoss -> when (direction) { + PerpetualDirection.Long -> -1.0 + PerpetualDirection.Short -> 1.0 + } + } + return entryPrice * (1.0 + sign * fraction) + } +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseField.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseField.kt new file mode 100644 index 0000000000..1008dafc09 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseField.kt @@ -0,0 +1,20 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.TpslType + +data class AutocloseField( + val type: TpslType, + val price: Double?, + val originalPrice: Double?, + val formattedPrice: String?, + val error: AutocloseError?, + val orderId: ULong?, +) { + val isValid: Boolean get() = price != null && error == null + val hasChanged: Boolean get() = price != originalPrice + val isCleared: Boolean get() = price == null && originalPrice != null + val hasExisting: Boolean get() = originalPrice != null + val shouldSet: Boolean get() = isValid && hasChanged + val shouldUpdate: Boolean get() = shouldSet || isCleared + val shouldCancel: Boolean get() = isCleared || (shouldSet && hasExisting) +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilder.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilder.kt new file mode 100644 index 0000000000..9f059cf070 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilder.kt @@ -0,0 +1,46 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.CancelOrderData +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.PerpetualModifyPositionType +import com.wallet.core.primitives.TPSLOrderData + +class AutocloseModifyBuilder(private val direction: PerpetualDirection) { + + fun canBuild(takeProfit: AutocloseField, stopLoss: AutocloseField): Boolean { + val takeProfitOk = takeProfit.price == null || takeProfit.isValid + val stopLossOk = stopLoss.price == null || stopLoss.isValid + if (!takeProfitOk || !stopLossOk) return false + return takeProfit.shouldUpdate || stopLoss.shouldUpdate + } + + fun build( + assetIndex: Int, + takeProfit: AutocloseField, + stopLoss: AutocloseField, + ): List { + val result = mutableListOf() + + val cancels = listOf(takeProfit, stopLoss).mapNotNull { field -> + if (!field.shouldCancel) return@mapNotNull null + val orderId = field.orderId ?: return@mapNotNull null + CancelOrderData(assetIndex = assetIndex, orderId = orderId.toLong()) + } + if (cancels.isNotEmpty()) { + result += PerpetualModifyPositionType.Cancel(cancels) + } + + if (takeProfit.shouldSet || stopLoss.shouldSet) { + result += PerpetualModifyPositionType.Tpsl( + TPSLOrderData( + direction = direction, + takeProfit = takeProfit.formattedPrice?.takeIf { takeProfit.shouldSet }, + stopLoss = stopLoss.formattedPrice?.takeIf { stopLoss.shouldSet }, + size = "0", + ), + ) + } + + return result + } +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidator.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidator.kt new file mode 100644 index 0000000000..edb2ceb269 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidator.kt @@ -0,0 +1,24 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType + +class AutocloseValidator( + private val type: TpslType, + private val direction: PerpetualDirection, + private val marketPrice: Double, +) { + fun error(price: Double?): AutocloseError? { + if (price == null) return null + if (price <= 0.0) return AutocloseError.InvalidAmount + val mustBeAbove = when (type) { + TpslType.TakeProfit -> direction == PerpetualDirection.Long + TpslType.StopLoss -> direction == PerpetualDirection.Short + } + val onCorrectSide = if (mustBeAbove) price > marketPrice else price < marketPrice + if (onCorrectSide) return null + return if (mustBeAbove) AutocloseError.TriggerMustBeHigher else AutocloseError.TriggerMustBeLower + } + + fun isValid(price: Double?): Boolean = price != null && error(price) == null +} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimatorTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimatorTest.kt new file mode 100644 index 0000000000..bd831ed4a2 --- /dev/null +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimatorTest.kt @@ -0,0 +1,56 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType +import org.junit.Assert.assertEquals +import org.junit.Test + +class AutocloseEstimatorTest { + + @Test + fun pnlLong() { + val estimator = AutocloseEstimator(entryPrice = 100.0, positionSize = 10.0, direction = PerpetualDirection.Long, leverage = 5u) + assertEquals(100.0, estimator.pnl(price = 110.0), DELTA) + assertEquals(-100.0, estimator.pnl(price = 90.0), DELTA) + } + + @Test + fun pnlShort() { + val estimator = AutocloseEstimator(entryPrice = 100.0, positionSize = -10.0, direction = PerpetualDirection.Short, leverage = 5u) + assertEquals(100.0, estimator.pnl(price = 90.0), DELTA) + assertEquals(-100.0, estimator.pnl(price = 110.0), DELTA) + } + + @Test + fun targetPriceFromRoeLong() { + val estimator = AutocloseEstimator(entryPrice = 100.0, positionSize = 10.0, direction = PerpetualDirection.Long, leverage = 5u) + assertEquals(110.0, estimator.targetPriceFromRoe(roePercent = 50, type = TpslType.TakeProfit), DELTA) + assertEquals(90.0, estimator.targetPriceFromRoe(roePercent = 50, type = TpslType.StopLoss), DELTA) + } + + @Test + fun targetPriceFromRoeShort() { + val estimator = AutocloseEstimator(entryPrice = 100.0, positionSize = -10.0, direction = PerpetualDirection.Short, leverage = 5u) + assertEquals(90.0, estimator.targetPriceFromRoe(roePercent = 50, type = TpslType.TakeProfit), DELTA) + assertEquals(110.0, estimator.targetPriceFromRoe(roePercent = 50, type = TpslType.StopLoss), DELTA) + } + + @Test + fun percentSuggestionsByLeverageTier() { + assertEquals(listOf(5, 10, 15), estimator(leverage = 1u).percentSuggestions) + assertEquals(listOf(10, 15, 25), estimator(leverage = 5u).percentSuggestions) + assertEquals(listOf(15, 25, 50), estimator(leverage = 10u).percentSuggestions) + assertEquals(listOf(25, 50, 100), estimator(leverage = 20u).percentSuggestions) + } + + private fun estimator(leverage: UByte) = AutocloseEstimator( + entryPrice = 100.0, + positionSize = 10.0, + direction = PerpetualDirection.Long, + leverage = leverage, + ) + + private companion object { + const val DELTA = 1e-9 + } +} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilderTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilderTest.kt new file mode 100644 index 0000000000..49c4b4c985 --- /dev/null +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilderTest.kt @@ -0,0 +1,79 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.gemwallet.android.testkit.mockAutocloseField +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.PerpetualModifyPositionType +import com.wallet.core.primitives.TpslType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AutocloseModifyBuilderTest { + + private val builder = AutocloseModifyBuilder(direction = PerpetualDirection.Long) + + @Test + fun canBuildWithValidChange() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = 110.0, originalPrice = 100.0, error = null) + val stopLoss = mockAutocloseField(TpslType.StopLoss) + assertTrue(builder.canBuild(takeProfit, stopLoss)) + } + + @Test + fun canBuildClearingExistingField() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = null, originalPrice = 100.0) + val stopLoss = mockAutocloseField(TpslType.StopLoss) + assertTrue(builder.canBuild(takeProfit, stopLoss)) + } + + @Test + fun cannotBuildWithoutChanges() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = 100.0, originalPrice = 100.0, error = null) + val stopLoss = mockAutocloseField(TpslType.StopLoss, price = 90.0, originalPrice = 90.0, error = null) + assertFalse(builder.canBuild(takeProfit, stopLoss)) + } + + @Test + fun cannotBuildWithInvalidPrice() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = 50.0, originalPrice = 100.0, error = AutocloseError.TriggerMustBeHigher) + val stopLoss = mockAutocloseField(TpslType.StopLoss) + assertFalse(builder.canBuild(takeProfit, stopLoss)) + } + + @Test + fun buildSetBothEmitsSingleTpslWithSizeZero() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = 110.0, formattedPrice = "110.0", error = null) + val stopLoss = mockAutocloseField(TpslType.StopLoss, price = 90.0, formattedPrice = "90.0", error = null) + + val result = builder.build(assetIndex = 5, takeProfit = takeProfit, stopLoss = stopLoss) + + val tpsl = result.single() as PerpetualModifyPositionType.Tpsl + assertEquals("110.0", tpsl.content.takeProfit) + assertEquals("90.0", tpsl.content.stopLoss) + assertEquals("0", tpsl.content.size) + assertEquals(PerpetualDirection.Long, tpsl.content.direction) + } + + @Test + fun buildReplaceEmitsCancelBeforeTpsl() { + val takeProfit = mockAutocloseField( + TpslType.TakeProfit, + price = 120.0, + formattedPrice = "120.0", + originalPrice = 100.0, + error = null, + orderId = 12345uL, + ) + val stopLoss = mockAutocloseField(TpslType.StopLoss) + + val result = builder.build(assetIndex = 5, takeProfit = takeProfit, stopLoss = stopLoss) + + assertEquals(2, result.size) + val cancel = result[0] as PerpetualModifyPositionType.Cancel + assertEquals(12345L, cancel.content[0].orderId) + assertEquals(5, cancel.content[0].assetIndex) + val tpsl = result[1] as PerpetualModifyPositionType.Tpsl + assertEquals("120.0", tpsl.content.takeProfit) + } +} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidatorTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidatorTest.kt new file mode 100644 index 0000000000..00c507f578 --- /dev/null +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidatorTest.kt @@ -0,0 +1,46 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AutocloseValidatorTest { + + @Test + fun nullAndZeroPrice() { + val validator = AutocloseValidator(TpslType.TakeProfit, PerpetualDirection.Long, marketPrice = 100.0) + assertNull(validator.error(price = null)) + assertEquals(AutocloseError.InvalidAmount, validator.error(price = 0.0)) + assertEquals(AutocloseError.InvalidAmount, validator.error(price = -1.0)) + } + + @Test + fun longTakeProfitMustBeAboveMarket() { + val validator = AutocloseValidator(TpslType.TakeProfit, PerpetualDirection.Long, marketPrice = 100.0) + assertNull(validator.error(price = 110.0)) + assertEquals(AutocloseError.TriggerMustBeHigher, validator.error(price = 90.0)) + } + + @Test + fun longStopLossMustBeBelowMarket() { + val validator = AutocloseValidator(TpslType.StopLoss, PerpetualDirection.Long, marketPrice = 100.0) + assertNull(validator.error(price = 90.0)) + assertEquals(AutocloseError.TriggerMustBeLower, validator.error(price = 110.0)) + } + + @Test + fun shortTakeProfitMustBeBelowMarket() { + val validator = AutocloseValidator(TpslType.TakeProfit, PerpetualDirection.Short, marketPrice = 100.0) + assertNull(validator.error(price = 90.0)) + assertEquals(AutocloseError.TriggerMustBeLower, validator.error(price = 110.0)) + } + + @Test + fun shortStopLossMustBeAboveMarket() { + val validator = AutocloseValidator(TpslType.StopLoss, PerpetualDirection.Short, marketPrice = 100.0) + assertNull(validator.error(price = 110.0)) + assertEquals(AutocloseError.TriggerMustBeHigher, validator.error(price = 90.0)) + } +} diff --git a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualMock.kt b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualMock.kt index 5aa24e0af4..e8a9a2d24b 100644 --- a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualMock.kt +++ b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualMock.kt @@ -1,12 +1,20 @@ package com.gemwallet.android.testkit +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField import com.wallet.core.primitives.Asset import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.Perpetual import com.wallet.core.primitives.PerpetualData +import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualId +import com.wallet.core.primitives.PerpetualMarginType import com.wallet.core.primitives.PerpetualMetadata +import com.wallet.core.primitives.PerpetualPosition +import com.wallet.core.primitives.PerpetualPositionData import com.wallet.core.primitives.PerpetualProvider +import com.wallet.core.primitives.PerpetualTriggerOrder +import com.wallet.core.primitives.TpslType fun mockPerpetual( id: PerpetualId = PerpetualId(provider = PerpetualProvider.Hypercore, symbol = "TON"), @@ -45,3 +53,63 @@ fun mockPerpetualData( asset = asset, metadata = metadata, ) + +fun mockPerpetualPosition( + id: String = "pos-1", + perpetualId: PerpetualId = PerpetualId(provider = PerpetualProvider.Hypercore, symbol = "TON"), + assetId: AssetId = mockAsset().id, + size: Double = 10.0, + sizeValue: Double = 1000.0, + leverage: UByte = 5u, + entryPrice: Double = 100.0, + liquidationPrice: Double? = 50.0, + marginType: PerpetualMarginType = PerpetualMarginType.Cross, + direction: PerpetualDirection = PerpetualDirection.Long, + marginAmount: Double = 200.0, + takeProfit: PerpetualTriggerOrder? = null, + stopLoss: PerpetualTriggerOrder? = null, + pnl: Double = 0.0, + funding: Float? = null, +) = PerpetualPosition( + id = id, + perpetualId = perpetualId, + assetId = assetId, + size = size, + sizeValue = sizeValue, + leverage = leverage, + entryPrice = entryPrice, + liquidationPrice = liquidationPrice, + marginType = marginType, + direction = direction, + marginAmount = marginAmount, + takeProfit = takeProfit, + stopLoss = stopLoss, + pnl = pnl, + funding = funding, +) + +fun mockPerpetualPositionData( + perpetual: Perpetual = mockPerpetual(price = 100.0), + asset: Asset = mockAsset(), + position: PerpetualPosition = mockPerpetualPosition(assetId = asset.id, perpetualId = perpetual.id), +) = PerpetualPositionData( + perpetual = perpetual, + asset = asset, + position = position, +) + +fun mockAutocloseField( + type: TpslType = TpslType.TakeProfit, + price: Double? = null, + originalPrice: Double? = null, + formattedPrice: String? = price?.toString(), + error: AutocloseError? = null, + orderId: ULong? = null, +) = AutocloseField( + type = type, + price = price, + originalPrice = originalPrice, + formattedPrice = formattedPrice, + error = error, + orderId = orderId, +) From bfa45d19ed75223f606b2f34638905e88910b60b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 27 May 2026 16:47:55 +0300 Subject: [PATCH 02/36] Add autoclose UI model and factory --- .../perpetual/autoclose/AutocloseUIModel.kt | 26 ++++++ .../autoclose/AutocloseUIModelFactory.kt | 87 +++++++++++++++++++ .../autoclose/AutocloseUIModelFactoryTest.kt | 54 ++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModel.kt create mode 100644 android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt create mode 100644 android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModel.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModel.kt new file mode 100644 index 0000000000..96c918a774 --- /dev/null +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModel.kt @@ -0,0 +1,26 @@ +package com.gemwallet.android.ui.models.perpetual.autoclose + +import com.gemwallet.android.domains.perpetual.aggregates.PerpetualPositionDataAggregate +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.domains.price.ValueDirection +import com.wallet.core.primitives.TpslType + +data class AutocloseUIModel( + val position: PerpetualPositionDataAggregate, + val marketPriceText: String, + val entryPriceText: String, + val takeProfit: Field, + val stopLoss: Field, + val confirmEnabled: Boolean, +) { + data class Field( + val type: TpslType, + val isProfit: Boolean, + val pnlText: String, + val pnlDirection: ValueDirection, + val percentSuggestions: List, + val error: AutocloseError?, + ) { + val showError: Boolean get() = error != null + } +} diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt new file mode 100644 index 0000000000..a7f64e8517 --- /dev/null +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt @@ -0,0 +1,87 @@ +package com.gemwallet.android.ui.models.perpetual.autoclose + +import com.gemwallet.android.domains.percentage.PercentageFormatterStyle +import com.gemwallet.android.domains.percentage.formatAsPercentage +import com.gemwallet.android.domains.perpetual.aggregates.PerpetualPositionDataAggregate +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField +import com.gemwallet.android.domains.perpetual.formatPnlWithPercentage +import com.gemwallet.android.domains.price.ValueDirection +import com.gemwallet.android.domains.price.toValueDirection +import com.gemwallet.android.model.CurrencyFormatter +import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.PerpetualId +import com.wallet.core.primitives.PerpetualPositionData +import com.wallet.core.primitives.TpslType +import kotlin.math.abs + +object AutocloseUIModelFactory { + + private val currencyFormatter = CurrencyFormatter(type = CurrencyFormatter.Type.Currency, currency = Currency.USD) + private val marginFormatter = CurrencyFormatter(type = CurrencyFormatter.Type.Fiat, currency = Currency.USD) + + fun create( + position: PerpetualPositionData, + takeProfit: AutocloseField, + stopLoss: AutocloseField, + confirmEnabled: Boolean, + showErrors: Boolean = false, + ): AutocloseUIModel { + val estimator = AutocloseEstimator( + entryPrice = position.position.entryPrice, + positionSize = position.position.size, + direction = position.position.direction, + leverage = position.position.leverage, + ) + return AutocloseUIModel( + position = positionSummary(position), + marketPriceText = currencyFormatter.string(position.perpetual.price), + entryPriceText = currencyFormatter.string(position.position.entryPrice), + takeProfit = createField(takeProfit, estimator, showErrors), + stopLoss = createField(stopLoss, estimator, showErrors), + confirmEnabled = confirmEnabled, + ) + } + + fun createField( + field: AutocloseField, + estimator: AutocloseEstimator, + showErrors: Boolean = true, + ): AutocloseUIModel.Field { + val pnl = field.price?.let(estimator::pnl) + val roe = field.price?.let(estimator::roe) + val isProfit = pnl?.let { it >= 0.0 } ?: (field.type == TpslType.TakeProfit) + return AutocloseUIModel.Field( + type = field.type, + isProfit = isProfit, + pnlText = pnlText(pnl, roe, estimator.hasSize), + pnlDirection = roe?.toValueDirection() ?: ValueDirection.None, + percentSuggestions = estimator.percentSuggestions, + error = if (showErrors) field.error else null, + ) + } + + private fun positionSummary(data: PerpetualPositionData): PerpetualPositionDataAggregate = object : PerpetualPositionDataAggregate { + override val positionId: String = data.position.id + override val perpetualId: PerpetualId = data.perpetual.id + override val asset: Asset = data.asset + override val name: String = data.perpetual.name + override val direction: PerpetualDirection = data.position.direction + override val leverage: Int = data.position.leverage.toInt() + override val marginAmount: String = marginFormatter.string(data.position.marginAmount) + override val pnlWithPercentage: String = + formatPnlWithPercentage(data.position.pnl, data.position.marginAmount) + override val pnlState: ValueDirection = data.position.pnl.toValueDirection() + } + + private fun pnlText(pnl: Double?, roe: Double?, hasSize: Boolean): String { + if (pnl == null || roe == null) return "-" + val percentText = roe.formatAsPercentage(style = PercentageFormatterStyle.Percent) + if (!hasSize) return percentText + val sign = if (pnl >= 0.0) "+" else "-" + val amount = currencyFormatter.string(abs(pnl)) + return "$sign$amount ($percentText)" + } +} diff --git a/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt b/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt new file mode 100644 index 0000000000..dfa48f297a --- /dev/null +++ b/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt @@ -0,0 +1,54 @@ +package com.gemwallet.android.ui.models.perpetual.autoclose + +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.testkit.mockAutocloseField +import com.gemwallet.android.testkit.mockPerpetualPosition +import com.gemwallet.android.testkit.mockPerpetualPositionData +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class AutocloseUIModelFactoryTest { + + @Test + fun percentSuggestionsScaleWithLeverage() { + assertEquals(listOf(5, 10, 15), model(leverage = 1u).takeProfit.percentSuggestions) + assertEquals(listOf(10, 15, 25), model(leverage = 5u).takeProfit.percentSuggestions) + assertEquals(listOf(15, 25, 50), model(leverage = 10u).takeProfit.percentSuggestions) + assertEquals(listOf(25, 50, 100), model(leverage = 20u).takeProfit.percentSuggestions) + } + + @Test + fun errorSuppressedUntilShowErrorsSet() { + val invalidTakeProfit = mockAutocloseField(TpslType.TakeProfit, price = 50.0, error = AutocloseError.TriggerMustBeHigher) + val hidden = AutocloseUIModelFactory.create( + position = mockPerpetualPositionData( + position = mockPerpetualPosition(direction = PerpetualDirection.Long, entryPrice = 100.0, leverage = 5u), + ), + takeProfit = invalidTakeProfit, + stopLoss = mockAutocloseField(TpslType.StopLoss), + confirmEnabled = false, + showErrors = false, + ) + val shown = AutocloseUIModelFactory.create( + position = mockPerpetualPositionData( + position = mockPerpetualPosition(direction = PerpetualDirection.Long, entryPrice = 100.0, leverage = 5u), + ), + takeProfit = invalidTakeProfit, + stopLoss = mockAutocloseField(TpslType.StopLoss), + confirmEnabled = false, + showErrors = true, + ) + assertFalse(hidden.takeProfit.showError) + assertEquals(AutocloseError.TriggerMustBeHigher, shown.takeProfit.error) + } + + private fun model(leverage: UByte) = AutocloseUIModelFactory.create( + position = mockPerpetualPositionData(position = mockPerpetualPosition(leverage = leverage)), + takeProfit = mockAutocloseField(TpslType.TakeProfit), + stopLoss = mockAutocloseField(TpslType.StopLoss), + confirmEnabled = false, + ) +} From af8aa15b3876c0682ec80394eea643c5a09af360 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 27 May 2026 17:05:33 +0300 Subject: [PATCH 03/36] Add coordinator support for perpetual modify --- .../perpetuals/BuildPerpetualParamsImpl.kt | 23 ++++++++++++ .../coordinators/BuildPerpetualParams.kt | 7 ++++ .../domains/perpetual/PerpetualMappers.kt | 35 ++++++++++++++++++- .../android/ext/PerpetualFormatter.kt | 13 +++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/perpetuals/BuildPerpetualParamsImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/perpetuals/BuildPerpetualParamsImpl.kt index 94554ff7c0..2ebd85c394 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/perpetuals/BuildPerpetualParamsImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/perpetuals/BuildPerpetualParamsImpl.kt @@ -15,6 +15,8 @@ import com.wallet.core.primitives.PerpetualData import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualId import com.wallet.core.primitives.PerpetualMarginType +import com.wallet.core.primitives.PerpetualModifyConfirmData +import com.wallet.core.primitives.PerpetualModifyPositionType import com.wallet.core.primitives.PerpetualPosition import com.wallet.core.primitives.PerpetualType import kotlinx.coroutines.flow.firstOrNull @@ -63,6 +65,27 @@ class BuildPerpetualParamsImpl( .perpetual(PerpetualType.Close(confirmData)) } + override suspend fun modify( + perpetualId: PerpetualId, + modifyTypes: List, + takeProfitOrderId: ULong?, + stopLossOrderId: ULong?, + ): ConfirmParams.PerpetualParams? { + if (modifyTypes.isEmpty()) return null + val data = getPerpetual(perpetualId) ?: return null + val assetIndex = data.perpetual.identifier.toIntOrNull() ?: return null + val account = sessionRepository.session().value?.wallet?.hyperliquidAccount ?: return null + val confirmData = PerpetualModifyConfirmData( + baseAsset = HypercoreUSDC, + assetIndex = assetIndex, + modifyTypes = modifyTypes, + takeProfitOrderId = takeProfitOrderId?.toLong(), + stopLossOrderId = stopLossOrderId?.toLong(), + ) + return ConfirmParams.Builder(data.asset, account) + .perpetual(PerpetualType.Modify(confirmData)) + } + private suspend fun getPerpetual(perpetualId: PerpetualId): PerpetualData? = perpetualRepository.getPerpetual(perpetualId).firstOrNull() diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/perpetual/coordinators/BuildPerpetualParams.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/perpetual/coordinators/BuildPerpetualParams.kt index 5a07c171ad..4167fcf506 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/perpetual/coordinators/BuildPerpetualParams.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/perpetual/coordinators/BuildPerpetualParams.kt @@ -4,10 +4,17 @@ import com.gemwallet.android.model.AmountParams import com.gemwallet.android.model.ConfirmParams import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualId +import com.wallet.core.primitives.PerpetualModifyPositionType interface BuildPerpetualParams { suspend fun open(perpetualId: PerpetualId, direction: PerpetualDirection): AmountParams.Perpetual? suspend fun increase(perpetualId: PerpetualId): AmountParams.Perpetual? suspend fun reduce(perpetualId: PerpetualId): AmountParams.Perpetual? suspend fun close(perpetualId: PerpetualId): ConfirmParams.PerpetualParams? + suspend fun modify( + perpetualId: PerpetualId, + modifyTypes: List, + takeProfitOrderId: ULong?, + stopLossOrderId: ULong?, + ): ConfirmParams.PerpetualParams? } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualMappers.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualMappers.kt index 0aac56c809..0fecc223d5 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualMappers.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualMappers.kt @@ -1,16 +1,24 @@ package com.gemwallet.android.domains.perpetual import com.gemwallet.android.domains.asset.toGem +import com.wallet.core.primitives.CancelOrderData import com.wallet.core.primitives.PerpetualConfirmData import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualMarginType +import com.wallet.core.primitives.PerpetualModifyConfirmData +import com.wallet.core.primitives.PerpetualModifyPositionType import com.wallet.core.primitives.PerpetualReduceData import com.wallet.core.primitives.PerpetualType +import com.wallet.core.primitives.TPSLOrderData import uniffi.gemstone.GemPerpetualMarginType +import uniffi.gemstone.CancelOrderData as GemCancelOrderData import uniffi.gemstone.PerpetualConfirmData as GemPerpetualConfirmData import uniffi.gemstone.PerpetualDirection as GemPerpetualDirection +import uniffi.gemstone.PerpetualModifyConfirmData as GemPerpetualModifyConfirmData +import uniffi.gemstone.PerpetualModifyPositionType as GemPerpetualModifyPositionType import uniffi.gemstone.PerpetualReduceData as GemPerpetualReduceData import uniffi.gemstone.PerpetualType as GemPerpetualType +import uniffi.gemstone.TpslOrderData as GemTpslOrderData fun PerpetualConfirmData.toGem(): GemPerpetualConfirmData = GemPerpetualConfirmData( direction = direction.toGem(), @@ -35,6 +43,31 @@ fun PerpetualReduceData.toGem(): GemPerpetualReduceData = GemPerpetualReduceData positionDirection = positionDirection.toGem(), ) +fun PerpetualModifyConfirmData.toGem(): GemPerpetualModifyConfirmData = GemPerpetualModifyConfirmData( + baseAsset = baseAsset.toGem(), + assetIndex = assetIndex, + modifyTypes = modifyTypes.map { it.toGem() }, + takeProfitOrderId = takeProfitOrderId?.toULong(), + stopLossOrderId = stopLossOrderId?.toULong(), +) + +fun PerpetualModifyPositionType.toGem(): GemPerpetualModifyPositionType = when (this) { + is PerpetualModifyPositionType.Tpsl -> GemPerpetualModifyPositionType.Tpsl(v1 = content.toGem()) + is PerpetualModifyPositionType.Cancel -> GemPerpetualModifyPositionType.Cancel(v1 = content.map { it.toGem() }) +} + +fun TPSLOrderData.toGem(): GemTpslOrderData = GemTpslOrderData( + direction = direction.toGem(), + takeProfit = takeProfit, + stopLoss = stopLoss, + size = size, +) + +fun CancelOrderData.toGem(): GemCancelOrderData = GemCancelOrderData( + assetIndex = assetIndex, + orderId = orderId.toULong(), +) + fun PerpetualDirection.toGem(): GemPerpetualDirection = when (this) { PerpetualDirection.Long -> GemPerpetualDirection.LONG PerpetualDirection.Short -> GemPerpetualDirection.SHORT @@ -50,5 +83,5 @@ fun PerpetualType.toGem(): GemPerpetualType = when (this) { is PerpetualType.Close -> GemPerpetualType.Close(v1 = content.toGem()) is PerpetualType.Increase -> GemPerpetualType.Increase(v1 = content.toGem()) is PerpetualType.Reduce -> GemPerpetualType.Reduce(v1 = content.toGem()) - is PerpetualType.Modify -> error("PerpetualType.Modify not produced by Android app") + is PerpetualType.Modify -> GemPerpetualType.Modify(v1 = content.toGem()) } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/PerpetualFormatter.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/PerpetualFormatter.kt index fc517b87e8..0f69439080 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/PerpetualFormatter.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/PerpetualFormatter.kt @@ -2,6 +2,8 @@ package com.gemwallet.android.ext import com.wallet.core.primitives.PerpetualProvider import uniffi.gemstone.Perpetual +import java.text.DecimalFormatSymbols +import java.util.Locale import uniffi.gemstone.PerpetualProvider as GemPerpetualProvider object PerpetualFormatter { @@ -9,6 +11,17 @@ object PerpetualFormatter { fun formatPrice(provider: PerpetualProvider, price: Double, decimals: Int): String = Perpetual(provider.toGem()).use { it.formatPrice(price, decimals) } + fun formatInputPrice( + provider: PerpetualProvider, + price: Double, + decimals: Int, + locale: Locale = Locale.getDefault(), + ): String { + val formatted = formatPrice(provider, price, decimals) + val separator = DecimalFormatSymbols.getInstance(locale).decimalSeparator + return if (separator == '.') formatted else formatted.replace('.', separator) + } + fun formatSize(provider: PerpetualProvider, size: Double, decimals: Int): String = Perpetual(provider.toGem()).use { it.formatSize(size, decimals) } From bac31cc16089ba0a630f463bc0c44048c13ba23a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 27 May 2026 18:45:58 +0300 Subject: [PATCH 04/36] Add shared autoclose UI components --- .../list_item/property/PropertyItem.kt | 4 + .../perpetual/AutocloseInputSection.kt | 103 ++++++++++++++++++ .../perpetual/AutocloseSuggestionsBar.kt | 45 ++++++++ .../perpetual/AutocloseSummaryRow.kt | 38 +++++++ .../PerpetualConfirmDetailsComponents.kt | 33 ++---- .../perpetual/PerpetualTypeTitle.kt | 2 +- android/ui/src/main/res/values/strings.xml | 2 + 7 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseInputSection.kt create mode 100644 android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSuggestionsBar.kt create mode 100644 android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSummaryRow.kt diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyItem.kt index b06ae7cf03..e5fe434bc1 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyItem.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyItem.kt @@ -35,6 +35,7 @@ fun PropertyItem( @StringRes action: Int, actionIconModel: Any? = null, data: String? = null, + info: InfoSheetEntity? = null, listPosition: ListPosition = ListPosition.Middle, onClick: () -> Unit, ) { @@ -42,6 +43,7 @@ fun PropertyItem( action = stringResource(action), actionIconModel = actionIconModel, data = data, + info = info, listPosition = listPosition, onClick = onClick ) @@ -52,6 +54,7 @@ fun PropertyItem( action: String, actionIconModel: Any? = null, data: String? = null, + info: InfoSheetEntity? = null, listPosition: ListPosition = ListPosition.Middle, onClick: () -> Unit, ) { @@ -61,6 +64,7 @@ fun PropertyItem( PropertyTitleText( text = action, trailing = actionIconModel?.let { { AsyncImage(model = it, size = smallIconSize) } }, + info = info, ) }, data = { diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseInputSection.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseInputSection.kt new file mode 100644 index 0000000000..521a58abf5 --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseInputSection.kt @@ -0,0 +1,103 @@ +package com.gemwallet.android.ui.components.perpetual + +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.GemTextField +import com.gemwallet.android.ui.components.list_item.SubheaderItem +import com.gemwallet.android.ui.components.list_item.color +import com.gemwallet.android.ui.components.list_item.sectionHeaderHorizontalPadding +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel +import com.gemwallet.android.ui.theme.compactIconSize +import com.gemwallet.android.ui.theme.space4 +import com.wallet.core.primitives.TpslType + +@StringRes +private fun AutocloseError.toStringRes(): Int = when (this) { + AutocloseError.InvalidAmount -> R.string.errors_invalid_amount + AutocloseError.TriggerMustBeHigher -> R.string.perpetual_auto_close_trigger_price_higher + AutocloseError.TriggerMustBeLower -> R.string.perpetual_auto_close_trigger_price_lower +} + +@Composable +fun AutocloseInputSection( + field: AutocloseUIModel.Field, + text: String, + onTextChanged: (String) -> Unit, + onFocusChanged: (Boolean) -> Unit, +) { + SubheaderItem( + title = stringResource( + when (field.type) { + TpslType.TakeProfit -> R.string.perpetual_auto_close_take_profit + TpslType.StopLoss -> R.string.perpetual_auto_close_stop_loss + }, + ), + ) + GemTextField( + modifier = Modifier.onFocusChanged { onFocusChanged(it.isFocused) }, + value = text, + onValueChange = onTextChanged, + label = stringResource(R.string.asset_price), + error = field.error?.let { stringResource(it.toStringRes()) }.orEmpty(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + listPosition = ListPosition.Single, + trailing = if (text.isNotEmpty()) { + { + Icon( + modifier = Modifier + .size(compactIconSize) + .clickable( + interactionSource = null, + indication = null, + onClick = { onTextChanged("") }, + ), + imageVector = Icons.Default.Cancel, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + } + } else null, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = sectionHeaderHorizontalPadding, vertical = space4), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource( + if (field.isProfit) R.string.perpetual_auto_close_expected_profit + else R.string.perpetual_auto_close_expected_loss, + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.secondary, + ) + Text( + text = field.pnlText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = field.pnlDirection.color(), + ) + } +} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSuggestionsBar.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSuggestionsBar.kt new file mode 100644 index 0000000000..5a7bf58cf3 --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSuggestionsBar.kt @@ -0,0 +1,45 @@ +package com.gemwallet.android.ui.components.perpetual + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.gemwallet.android.ui.R + +@Composable +fun AutocloseSuggestionsBar( + suggestions: List, + onPercentSelected: (Int) -> Unit, + onDone: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + suggestions.forEach { percent -> + SuggestionChip( + modifier = Modifier.weight(1f), + onClick = { onPercentSelected(percent) }, + label = { + Text( + text = "$percent%", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + }, + ) + } + TextButton(onClick = onDone) { + Text(text = stringResource(R.string.common_done)) + } + } +} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSummaryRow.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSummaryRow.kt new file mode 100644 index 0000000000..fc9cdb258c --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSummaryRow.kt @@ -0,0 +1,38 @@ +package com.gemwallet.android.ui.components.perpetual + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.list_item.ListItemSupportText +import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.theme.space2 + +@Composable +fun AutocloseSummaryRow( + takeProfitText: String?, + stopLossText: String?, + listPosition: ListPosition = ListPosition.Single, +) { + val lines = listOfNotNull( + takeProfitText?.let { "${stringResource(R.string.charts_take_profit)}: $it" }, + stopLossText?.let { "${stringResource(R.string.charts_stop_loss)}: $it" }, + ) + if (lines.isEmpty()) return + PropertyItem( + title = { PropertyTitleText(stringResource(R.string.perpetual_auto_close)) }, + data = { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(space2), + ) { + lines.forEach { ListItemSupportText(it) } + } + }, + listPosition = listPosition, + ) +} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualConfirmDetailsComponents.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualConfirmDetailsComponents.kt index c659e7d40f..7db79f8934 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualConfirmDetailsComponents.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualConfirmDetailsComponents.kt @@ -1,7 +1,6 @@ package com.gemwallet.android.ui.components.perpetual import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -9,10 +8,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import com.gemwallet.android.ui.R -import com.gemwallet.android.ui.components.list_item.ListItem -import com.gemwallet.android.ui.components.list_item.ListItemDefaults -import com.gemwallet.android.ui.components.list_item.ListItemSupportText -import com.gemwallet.android.ui.components.list_item.ListItemTitleText import com.gemwallet.android.ui.components.list_item.color import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron import com.gemwallet.android.ui.components.list_item.property.PropertyDataText @@ -22,7 +17,6 @@ import com.gemwallet.android.ui.components.screen.ModalBottomSheet import com.gemwallet.android.ui.models.ListPosition import com.gemwallet.android.ui.models.perpetual.PerpetualConfirmDetailsUIModel import com.gemwallet.android.ui.models.perpetual.PerpetualConfirmDetailsUIModel.Action -import com.gemwallet.android.ui.theme.space2 @Composable fun PerpetualDetailsSummaryItem( @@ -82,9 +76,14 @@ fun PerpetualDetailsBottomSheet( data = model.sizeText, listPosition = ListPosition.Last, ) - model.autoclose?.let { AutocloseRow(it) } + model.autoclose?.let { + AutocloseSummaryRow( + takeProfitText = it.takeProfitText, + stopLossText = it.stopLossText, + ) + } PropertyItem( - title = stringResource(R.string.price_alerts_set_alert_current_price), + title = stringResource(R.string.perpetual_market_price), data = model.marketPriceText, listPosition = ListPosition.First, ) @@ -104,24 +103,6 @@ fun PerpetualDetailsBottomSheet( } } -@Composable -private fun AutocloseRow(autoclose: PerpetualConfirmDetailsUIModel.Autoclose) { - val lines = listOfNotNull( - autoclose.takeProfitText?.let { "${stringResource(R.string.charts_take_profit)}: $it" }, - autoclose.stopLossText?.let { "${stringResource(R.string.charts_stop_loss)}: $it" }, - ) - ListItem( - listPosition = ListPosition.Single, - minHeight = ListItemDefaults.defaultMinHeight, - title = { ListItemTitleText(text = stringResource(R.string.perpetual_auto_close)) }, - subtitle = { - Column(verticalArrangement = Arrangement.spacedBy(space2)) { - lines.forEach { ListItemSupportText(it) } - } - }, - ) -} - @Composable private fun PerpetualConfirmDetailsUIModel.summaryText(): String? = when (action) { Action.Open -> direction.titleAndLeverage(leverage) diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualTypeTitle.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualTypeTitle.kt index 61abf5e381..9af867b73a 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualTypeTitle.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualTypeTitle.kt @@ -12,7 +12,7 @@ fun PerpetualType.title(): String = when (this) { is PerpetualType.Increase -> stringResource(R.string.perpetual_increase_direction, directionLabel(content.direction)) is PerpetualType.Reduce -> stringResource(R.string.perpetual_reduce_direction, directionLabel(content.positionDirection)) is PerpetualType.Close -> stringResource(R.string.perpetual_close_position) - is PerpetualType.Modify -> stringResource(R.string.perpetual_modify) + is PerpetualType.Modify -> stringResource(R.string.perpetual_modify_position) } @Composable diff --git a/android/ui/src/main/res/values/strings.xml b/android/ui/src/main/res/values/strings.xml index e6ecb9ab6f..7889806091 100644 --- a/android/ui/src/main/res/values/strings.xml +++ b/android/ui/src/main/res/values/strings.xml @@ -485,6 +485,8 @@ Expected loss Modify Position Stop loss + Trigger price should be higher than market price + Trigger price should be lower than market price Suspicious Activity Screenshot Detected Screenshots may be accessible to other apps, they can put your secret phrase at risk if saved this way. From ea32401945e3e6765d9c69724a1cb17ea1b5b803 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 27 May 2026 18:58:33 +0300 Subject: [PATCH 05/36] Add autoclose modify viewmodel and scene --- .../views/autoclose/AutocloseSheet.kt | 125 +++++++++++ .../viewmodels/AutocloseViewModel.kt | 207 ++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseSheet.kt create mode 100644 android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseSheet.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseSheet.kt new file mode 100644 index 0000000000..255731d564 --- /dev/null +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseSheet.kt @@ -0,0 +1,125 @@ +package com.gemwallet.android.features.perpetual.views.autoclose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.perpetual.viewmodels.AutocloseViewModel +import com.gemwallet.android.features.perpetual.views.components.PerpetualPositionItem +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.buttons.MainActionButton +import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.perpetual.AutocloseInputSection +import com.gemwallet.android.ui.components.perpetual.AutocloseSuggestionsBar +import com.gemwallet.android.ui.components.screen.ModalBottomSheet +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction +import com.gemwallet.android.ui.theme.Spacer16 +import com.gemwallet.android.ui.theme.paddingDefault +import com.wallet.core.primitives.TpslType + +@Composable +fun AutocloseSheet( + isVisible: Boolean, + confirmAction: ConfirmTransactionAction, + onDismiss: () -> Unit, + viewModel: AutocloseViewModel = hiltViewModel(), +) { + val uiModel by viewModel.uiModel.collectAsStateWithLifecycle() + val takeProfitText by viewModel.takeProfitText.collectAsStateWithLifecycle() + val stopLossText by viewModel.stopLossText.collectAsStateWithLifecycle() + + var focusedField: TpslType? by remember { mutableStateOf(null) } + val focusManager = LocalFocusManager.current + + val focusedText = focusedField?.let { type -> + when (type) { + TpslType.TakeProfit -> takeProfitText + TpslType.StopLoss -> stopLossText + } + } + val activeField = focusedField?.let { type -> + when (type) { + TpslType.TakeProfit -> uiModel?.takeProfit + TpslType.StopLoss -> uiModel?.stopLoss + } + } + + ModalBottomSheet( + isVisible = isVisible, + onDismissRequest = onDismiss, + title = stringResource(R.string.perpetual_auto_close), + ) { + val model = uiModel ?: return@ModalBottomSheet + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = paddingDefault), + ) { + PerpetualPositionItem( + data = model.position, + listPosition = ListPosition.Single, + ) + Spacer16() + PropertyItem( + title = stringResource(R.string.perpetual_entry_price), + data = model.entryPriceText, + listPosition = ListPosition.First, + ) + PropertyItem( + title = stringResource(R.string.perpetual_market_price), + data = model.marketPriceText, + listPosition = ListPosition.Last, + ) + Spacer16() + AutocloseInputSection( + field = model.takeProfit, + text = takeProfitText, + onTextChanged = viewModel::onTakeProfitChanged, + onFocusChanged = { focused -> + if (focused) focusedField = TpslType.TakeProfit + else if (focusedField == TpslType.TakeProfit) focusedField = null + }, + ) + Spacer16() + AutocloseInputSection( + field = model.stopLoss, + text = stopLossText, + onTextChanged = viewModel::onStopLossChanged, + onFocusChanged = { focused -> + if (focused) focusedField = TpslType.StopLoss + else if (focusedField == TpslType.StopLoss) focusedField = null + }, + ) + Spacer16() + if (activeField != null && focusedText.isNullOrEmpty()) { + AutocloseSuggestionsBar( + suggestions = activeField.percentSuggestions, + onPercentSelected = { percent -> + viewModel.onPercentSelected(activeField.type, percent) + }, + onDone = { focusManager.clearFocus() }, + ) + } else { + MainActionButton( + title = stringResource(R.string.transfer_confirm), + enabled = model.confirmEnabled, + onClick = { + viewModel.onConfirm(confirmAction) + onDismiss() + }, + ) + } + Spacer16() + } + } +} diff --git a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt new file mode 100644 index 0000000000..3824a27ec6 --- /dev/null +++ b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt @@ -0,0 +1,207 @@ +package com.gemwallet.android.features.perpetual.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gemwallet.android.application.perpetual.coordinators.BuildPerpetualParams +import com.gemwallet.android.data.repositories.perpetual.PerpetualRepository +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseModifyBuilder +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseValidator +import com.gemwallet.android.ext.PerpetualFormatter +import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction +import com.gemwallet.android.ui.models.navigation.requireAssetId +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModelFactory +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.PerpetualPositionData +import com.wallet.core.primitives.TpslType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class AutocloseViewModel @Inject constructor( + private val perpetualRepository: PerpetualRepository, + private val buildPerpetualParams: BuildPerpetualParams, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val assetId: AssetId = savedStateHandle.requireAssetId() + + val position: StateFlow = perpetualRepository.getPerpetualByAssetId(assetId) + .distinctUntilChanged() + .flatMapLatest { data -> + data?.let { perpetualRepository.getPositionByPerpetualId(it.perpetual.id) } ?: flowOf(null) + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val _isVisible = MutableStateFlow(false) + val isVisible: StateFlow = _isVisible.asStateFlow() + + fun show() { _isVisible.value = true } + fun dismiss() { _isVisible.value = false } + + private val userTakeProfitText = MutableStateFlow(null) + private val userStopLossText = MutableStateFlow(null) + + val takeProfitText: StateFlow = combine(userTakeProfitText, position) { user, pos -> + user ?: initialText(pos, TpslType.TakeProfit) + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + val stopLossText: StateFlow = combine(userStopLossText, position) { user, pos -> + user ?: initialText(pos, TpslType.StopLoss) + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private val submitAttempted = MutableStateFlow(false) + + val uiModel: StateFlow = combine( + position, + takeProfitText, + stopLossText, + submitAttempted, + ) { position, takeProfit, stopLoss, attempted -> + position?.let { buildUiModel(it, takeProfit, stopLoss, attempted) } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun onTakeProfitChanged(text: String) { + submitAttempted.value = false + userTakeProfitText.value = text.filterNumeric() + } + + fun onStopLossChanged(text: String) { + submitAttempted.value = false + userStopLossText.value = text.filterNumeric() + } + + fun onPercentSelected(type: TpslType, percent: Int) { + submitAttempted.value = false + val position = position.value ?: return + val estimator = estimator(position) + val target = estimator.targetPriceFromRoe(percent, type) + val formatted = PerpetualFormatter.formatInputPrice( + provider = position.perpetual.provider, + price = target, + decimals = position.asset.decimals, + ) + when (type) { + TpslType.TakeProfit -> userTakeProfitText.value = formatted + TpslType.StopLoss -> userStopLossText.value = formatted + } + } + + fun onConfirm(confirmAction: ConfirmTransactionAction) { + submitAttempted.value = true + val position = position.value ?: return + val perpetualId = position.perpetual.id + val assetIndex = position.perpetual.identifier.toIntOrNull() ?: return + val takeProfitField = autocloseField(position, TpslType.TakeProfit, takeProfitText.value) + val stopLossField = autocloseField(position, TpslType.StopLoss, stopLossText.value) + val builder = AutocloseModifyBuilder(position.position.direction) + if (!builder.canBuild(takeProfitField, stopLossField)) return + val modifyTypes = builder.build(assetIndex, takeProfitField, stopLossField) + viewModelScope.launch { + buildPerpetualParams.modify( + perpetualId = perpetualId, + modifyTypes = modifyTypes, + takeProfitOrderId = takeProfitField.orderId, + stopLossOrderId = stopLossField.orderId, + )?.let(confirmAction::invoke) + } + } + + private fun buildUiModel( + position: PerpetualPositionData, + takeProfitText: String, + stopLossText: String, + submitAttempted: Boolean, + ): AutocloseUIModel { + val takeProfit = autocloseField(position, TpslType.TakeProfit, takeProfitText) + val stopLoss = autocloseField(position, TpslType.StopLoss, stopLossText) + val builder = AutocloseModifyBuilder(position.position.direction) + return AutocloseUIModelFactory.create( + position = position, + takeProfit = takeProfit, + stopLoss = stopLoss, + confirmEnabled = builder.canBuild(takeProfit, stopLoss), + showErrors = submitAttempted, + ) + } + + private fun autocloseField( + position: PerpetualPositionData, + type: TpslType, + text: String, + ): AutocloseField { + val price = text.parseLocaleDouble() + val original = when (type) { + TpslType.TakeProfit -> position.position.takeProfit + TpslType.StopLoss -> position.position.stopLoss + } + val validator = AutocloseValidator( + type = type, + direction = position.position.direction, + marketPrice = position.perpetual.price, + ) + return AutocloseField( + type = type, + price = price, + originalPrice = original?.price, + formattedPrice = price?.let { + PerpetualFormatter.formatPrice(position.perpetual.provider, it, position.asset.decimals) + }, + error = validator.error(price), + orderId = original?.order_id?.toULongOrNull(), + ) + } + + private fun estimator(position: PerpetualPositionData) = AutocloseEstimator( + entryPrice = position.position.entryPrice, + positionSize = position.position.size, + direction = position.position.direction, + leverage = position.position.leverage, + ) + + private fun initialText(position: PerpetualPositionData?, type: TpslType): String { + val trigger = position?.let { + when (type) { + TpslType.TakeProfit -> it.position.takeProfit + TpslType.StopLoss -> it.position.stopLoss + } + } ?: return "" + return PerpetualFormatter.formatInputPrice( + provider = position.perpetual.provider, + price = trigger.price, + decimals = position.asset.decimals, + ) + } + + private fun String.filterNumeric(locale: Locale = Locale.getDefault()): String { + val separator = DecimalFormatSymbols.getInstance(locale).decimalSeparator + return filter { it.isDigit() || it == separator || it == '.' } + } + + private fun String.parseLocaleDouble(locale: Locale = Locale.getDefault()): Double? { + val separator = DecimalFormatSymbols.getInstance(locale).decimalSeparator + val normalized = if (separator == '.') this else replace(separator, '.') + return normalized.toDoubleOrNull() + } +} From 41c171c6354e06162e4aa799d3efec6e609e2fdf Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 27 May 2026 19:07:20 +0300 Subject: [PATCH 06/36] Wire perpetual position autoclose entry point and route Also fixes pull-to-refresh indicator showing on first open by splitting fetch() (initial load, no refresh state) from refresh() (user gesture, sets refresh state). --- .../android/ui/navigation/routes/Perpetual.kt | 5 +- .../views/components/PositionProperties.kt | 64 ++++++++++++++----- .../views/market/PerpetualMarketNavScreen.kt | 4 +- .../views/position/PerpetualDetailsAction.kt | 1 + .../position/PerpetualPositionNavScreen.kt | 14 +++- .../views/position/PerpetualPositionScene.kt | 2 +- .../viewmodels/PerpetualDetailsViewModel.kt | 8 ++- 7 files changed, 73 insertions(+), 25 deletions(-) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt index f69f684d55..2ef61b478c 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt @@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey import com.gemwallet.android.features.perpetual.views.market.PerpetualMarketNavScreen import com.gemwallet.android.features.perpetual.views.position.PerpetualPositionNavScreen import com.gemwallet.android.ui.models.actions.AmountTransactionAction +import com.gemwallet.android.ui.models.actions.AssetIdAction import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction import com.gemwallet.android.ui.navigation.assetIdArgument import com.gemwallet.android.ui.navigation.routeArguments @@ -20,7 +21,7 @@ data class PerpetualPositionRoute(val assetId: AssetId) : NavKey fun EntryProviderScope.perpetualScreen( onCancel: () -> Unit, - onOpenPerpetualDetails: (AssetId) -> Unit, + onOpenPerpetualDetails: AssetIdAction, amountAction: AmountTransactionAction, confirmAction: ConfirmTransactionAction, onTransaction: (TransactionId) -> Unit, @@ -28,7 +29,7 @@ fun EntryProviderScope.perpetualScreen( entry { PerpetualMarketNavScreen( onOpenPerpetualDetails = onOpenPerpetualDetails, - onCancel = onCancel + onCancel = onCancel, ) } diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PositionProperties.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PositionProperties.kt index db5663daf4..88de276779 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PositionProperties.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PositionProperties.kt @@ -1,20 +1,33 @@ package com.gemwallet.android.features.perpetual.views.components +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.gemwallet.android.domains.perpetual.aggregates.PerpetualPositionDetailsDataAggregate import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.InfoSheetEntity +import com.gemwallet.android.ui.components.list_item.ChevronIcon +import com.gemwallet.android.ui.components.list_item.ListItemSupportText import com.gemwallet.android.ui.components.list_item.SubheaderItem import com.gemwallet.android.ui.components.list_item.color import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.theme.paddingMiddle import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PerpetualMarginType -internal fun LazyListScope.positionProperties(position: PerpetualPositionDetailsDataAggregate?) { +internal fun LazyListScope.positionProperties( + position: PerpetualPositionDetailsDataAggregate?, + onAutocloseClick: () -> Unit, +) { if (position == null) { return } @@ -29,12 +42,7 @@ internal fun LazyListScope.positionProperties(position: PerpetualPositionDetails dataColor = position.pnlState.color(), listPosition = ListPosition.Middle, ) - PropertyItem( - title = stringResource(R.string.perpetual_auto_close), - data = position.autoCloseText(), - info = InfoSheetEntity.AutoCloseInfo, - listPosition = ListPosition.Middle, - ) + AutocloseRow(position = position, onClick = onAutocloseClick) PropertyItem( title = stringResource(R.string.perpetual_size), data = position.size, @@ -69,20 +77,42 @@ internal fun LazyListScope.positionProperties(position: PerpetualPositionDetails } @Composable -private fun PerpetualPositionDetailsDataAggregate.autoCloseText(): String { - val takeProfit = takeProfit.formatTriggerOrder(stringResource(R.string.charts_take_profit)) - val stopLoss = stopLoss.formatTriggerOrder(stringResource(R.string.charts_stop_loss)) - return when { - takeProfit != null && stopLoss != null -> "$takeProfit / $stopLoss" - takeProfit != null -> takeProfit - stopLoss != null -> stopLoss - else -> "-" - } +private fun AutocloseRow( + position: PerpetualPositionDetailsDataAggregate, + onClick: () -> Unit, +) { + val takeProfitText = position.takeProfit.formatTriggerOrder(stringResource(R.string.charts_take_profit)) + val stopLossText = position.stopLoss.formatTriggerOrder(stringResource(R.string.charts_stop_loss)) + PropertyItem( + modifier = Modifier.clickable(onClick = onClick), + title = { + PropertyTitleText( + text = stringResource(R.string.perpetual_auto_close), + info = InfoSheetEntity.AutoCloseInfo, + ) + }, + data = { + Column(horizontalAlignment = Alignment.End) { + when { + takeProfitText != null && stopLossText != null -> { + ListItemSupportText(takeProfitText) + ListItemSupportText(stopLossText) + } + takeProfitText != null -> ListItemSupportText(takeProfitText) + stopLossText != null -> ListItemSupportText(stopLossText) + else -> ListItemSupportText("-") + } + } + Spacer(Modifier.width(paddingMiddle)) + ChevronIcon() + }, + listPosition = ListPosition.Middle, + ) } @Composable private fun PerpetualPositionDetailsDataAggregate.marginText(): String { - return "${marginAmount} (${marginType.title()})" + return "$marginAmount (${marginType.title()})" } @Composable diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt index dd75ba52ac..a0e8981336 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt @@ -8,12 +8,12 @@ import androidx.compose.runtime.snapshotFlow import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.features.perpetual.viewmodels.PerpetualMarketViewModel -import com.wallet.core.primitives.AssetId +import com.gemwallet.android.ui.models.actions.AssetIdAction @Composable fun PerpetualMarketNavScreen( onCancel: () -> Unit, - onOpenPerpetualDetails: (AssetId) -> Unit, + onOpenPerpetualDetails: AssetIdAction, viewModel: PerpetualMarketViewModel = hiltViewModel(), ) { val sceneState by viewModel.sceneState.collectAsStateWithLifecycle() diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualDetailsAction.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualDetailsAction.kt index 1751979c45..7eaf706495 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualDetailsAction.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualDetailsAction.kt @@ -10,6 +10,7 @@ internal sealed interface PerpetualDetailsAction { data object IncreasePosition : PerpetualDetailsAction data object ReducePosition : PerpetualDetailsAction data object ClosePosition : PerpetualDetailsAction + data object Autoclose : PerpetualDetailsAction data class OpenPosition(val direction: PerpetualDirection) : PerpetualDetailsAction data class SelectChartPeriod(val period: ChartPeriod) : PerpetualDetailsAction data class OpenTransaction(val transactionId: TransactionId) : PerpetualDetailsAction diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt index 82880e6e77..6210a83684 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt @@ -5,7 +5,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.perpetual.viewmodels.AutocloseViewModel import com.gemwallet.android.features.perpetual.viewmodels.PerpetualDetailsViewModel +import com.gemwallet.android.features.perpetual.views.autoclose.AutocloseSheet import com.gemwallet.android.ui.models.actions.AmountTransactionAction import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction import com.wallet.core.primitives.TransactionId @@ -17,6 +19,7 @@ fun PerpetualPositionNavScreen( onClose: () -> Unit, onTransaction: (TransactionId) -> Unit, viewModel: PerpetualDetailsViewModel = hiltViewModel(), + autocloseViewModel: AutocloseViewModel = hiltViewModel(), ) { LaunchedEffect(Unit) { viewModel.fetch() } @@ -27,6 +30,7 @@ fun PerpetualPositionNavScreen( val chartState by viewModel.chartState.collectAsStateWithLifecycle() val period by viewModel.period.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val autocloseVisible by autocloseViewModel.isVisible.collectAsStateWithLifecycle() PerpetualPositionScene( perpetual = perpetual, @@ -39,14 +43,22 @@ fun PerpetualPositionNavScreen( onAction = { action -> when (action) { PerpetualDetailsAction.Close -> onClose() - PerpetualDetailsAction.Refresh -> viewModel.fetch() + PerpetualDetailsAction.Refresh -> viewModel.refresh() PerpetualDetailsAction.IncreasePosition -> viewModel.increasePosition(amountAction) PerpetualDetailsAction.ReducePosition -> viewModel.reducePosition(amountAction) PerpetualDetailsAction.ClosePosition -> viewModel.closePosition(confirmAction) + PerpetualDetailsAction.Autoclose -> autocloseViewModel.show() is PerpetualDetailsAction.OpenPosition -> viewModel.openPosition(action.direction, amountAction) is PerpetualDetailsAction.SelectChartPeriod -> viewModel.period(action.period) is PerpetualDetailsAction.OpenTransaction -> onTransaction(action.transactionId) } }, ) + + AutocloseSheet( + isVisible = autocloseVisible, + confirmAction = confirmAction, + onDismiss = autocloseViewModel::dismiss, + viewModel = autocloseViewModel, + ) } diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt index 9123c2268a..96b2bdf9bb 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt @@ -88,7 +88,7 @@ internal fun PerpetualPositionScene( onPeriodSelect = { onAction(PerpetualDetailsAction.SelectChartPeriod(it)) }, ) } - positionProperties(position) + positionProperties(position, onAutocloseClick = { onAction(PerpetualDetailsAction.Autoclose) }) item { if (perpetual != null) { if (position == null) { diff --git a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt index 462762b888..71c443b494 100644 --- a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt +++ b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt @@ -58,7 +58,7 @@ class PerpetualDetailsViewModel @Inject constructor( const val SubscriptionGraceMillis = 5_000L } - private val assetId = savedStateHandle.requireAssetId() + val assetId = savedStateHandle.requireAssetId() private val transactionFilters = listOf( TransactionsRequestFilter.Asset(assetId), @@ -134,13 +134,17 @@ class PerpetualDetailsViewModel @Inject constructor( } fun fetch() { - refreshState.value = true refreshTrigger.update { it + 1 } viewModelScope.launch(Dispatchers.IO) { syncPerpetualPositions.syncPerpetualPositions() } } + fun refresh() { + refreshState.value = true + fetch() + } + fun openPosition(direction: PerpetualDirection, amountAction: AmountTransactionAction) { val perpetualId = perpetual.value?.id ?: return viewModelScope.launch { From a6aa97adc5c0bbcd145f86213eba563b9a5373cc Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 27 May 2026 19:39:45 +0300 Subject: [PATCH 07/36] Add open-time autoclose support --- .../transfer_amount/presents/AmountScreen.kt | 8 +- .../presents/ProviderExtras.kt | 67 ++++++- .../presents/dialogs/AmountAutocloseSheet.kt | 164 ++++++++++++++++++ .../providers/AmountPerpetualProvider.kt | 58 ++++++- .../providers/AmountPerpetualProviderTest.kt | 36 +++- 5 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt index 39cd2352ed..ecde234c40 100644 --- a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt @@ -78,7 +78,13 @@ fun AmountScreen( onInputTypeClick = viewModel::switchInputType, onMaxAmount = viewModel::onMaxAmount, onCancel = onCancel, - additionParams = { ProviderExtras(provider, onPickValidator = { isSelectValidator = true }) }, + additionParams = { + ProviderExtras( + provider = provider, + amount = viewModel.amount, + onPickValidator = { isSelectValidator = true }, + ) + }, ) } } diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ProviderExtras.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ProviderExtras.kt index e55c7ce926..b3e88e4a0f 100644 --- a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ProviderExtras.kt +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ProviderExtras.kt @@ -1,25 +1,33 @@ package com.gemwallet.android.features.transfer_amount.presents import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.domains.perpetual.formatLeverage +import com.gemwallet.android.features.transfer_amount.presents.dialogs.AmountAutocloseSheet import com.gemwallet.android.features.transfer_amount.presents.dialogs.SelectLeverageDialog import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountDataProvider import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountPerpetualProvider import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountStakeProvider import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountTransferProvider import com.gemwallet.android.model.AmountParams +import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.InfoSheetEntity import com.gemwallet.android.ui.components.TabsBar import com.gemwallet.android.ui.components.clickable +import com.gemwallet.android.ui.components.list_item.ChevronIcon +import com.gemwallet.android.ui.components.list_item.ListItemSupportText import com.gemwallet.android.ui.components.list_item.SubheaderItem import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron import com.gemwallet.android.ui.components.list_item.property.PropertyDataText @@ -28,17 +36,25 @@ import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText import com.gemwallet.android.ui.components.list_item.property.PropertyValidatorItem import com.gemwallet.android.ui.components.perpetual.color import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.theme.paddingMiddle +import com.wallet.core.primitives.Currency import com.wallet.core.primitives.Resource @Composable fun ProviderExtras( provider: AmountDataProvider, + amount: String, onPickValidator: () -> Unit, ) { Column { when (provider) { is AmountStakeProvider -> StakeProviderSection(provider, onPickValidator) - is AmountPerpetualProvider -> PerpetualLeverageSection(provider) + is AmountPerpetualProvider -> { + PerpetualLeverageSection(provider) + if (provider.showsAutoclose) { + PerpetualAutocloseSection(provider, amount) + } + } is AmountTransferProvider -> Unit } } @@ -108,3 +124,52 @@ private fun PerpetualLeverageSection(provider: AmountPerpetualProvider) { onSelect = provider::setLeverage, ) } + +@Composable +private fun PerpetualAutocloseSection(provider: AmountPerpetualProvider, amount: String) { + val takeProfit by provider.takeProfit.collectAsStateWithLifecycle() + val stopLoss by provider.stopLoss.collectAsStateWithLifecycle() + var sheetVisible by remember { mutableStateOf(false) } + + PropertyItem( + modifier = Modifier.clickable { sheetVisible = true }, + title = { + PropertyTitleText( + text = stringResource(R.string.perpetual_auto_close), + info = InfoSheetEntity.AutoCloseInfo, + ) + }, + data = { + AutocloseRowValue(takeProfit = takeProfit, stopLoss = stopLoss) + Spacer(Modifier.width(paddingMiddle)) + ChevronIcon() + }, + listPosition = ListPosition.Single, + ) + + AmountAutocloseSheet( + isVisible = sheetVisible, + provider = provider, + amount = amount, + onDismiss = { sheetVisible = false }, + ) +} + +@Composable +private fun AutocloseRowValue(takeProfit: String?, stopLoss: String?) { + val tpLabel = stringResource(R.string.charts_take_profit) + val slLabel = stringResource(R.string.charts_stop_loss) + val tpText = takeProfit?.toDoubleOrNull()?.let { "$tpLabel: ${CurrencyFormatter(currency = Currency.USD).string(it)}" } + val slText = stopLoss?.toDoubleOrNull()?.let { "$slLabel: ${CurrencyFormatter(currency = Currency.USD).string(it)}" } + Column(horizontalAlignment = Alignment.End) { + when { + tpText != null && slText != null -> { + ListItemSupportText(tpText) + ListItemSupportText(slText) + } + tpText != null -> ListItemSupportText(tpText) + slText != null -> ListItemSupportText(slText) + else -> ListItemSupportText("-") + } + } +} diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt new file mode 100644 index 0000000000..3df848a533 --- /dev/null +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt @@ -0,0 +1,164 @@ +package com.gemwallet.android.features.transfer_amount.presents.dialogs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseValidator +import com.gemwallet.android.ext.PerpetualFormatter +import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountPerpetualProvider +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.buttons.MainActionButton +import com.gemwallet.android.ui.components.perpetual.AutocloseInputSection +import com.gemwallet.android.ui.components.perpetual.AutocloseSuggestionsBar +import com.gemwallet.android.ui.components.screen.ModalBottomSheet +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModelFactory +import com.gemwallet.android.ui.theme.Spacer16 +import com.gemwallet.android.ui.theme.paddingDefault +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType + +@Composable +internal fun AmountAutocloseSheet( + isVisible: Boolean, + provider: AmountPerpetualProvider, + amount: String, + onDismiss: () -> Unit, +) { + if (!isVisible) return + val perpetual = provider.perpetual.collectAsStateWithLifecycle().value ?: run { + onDismiss() + return + } + val storedTakeProfit by provider.takeProfit.collectAsStateWithLifecycle() + val storedStopLoss by provider.stopLoss.collectAsStateWithLifecycle() + + val direction = provider.direction + val marketPrice = perpetual.price + val assetDecimals = perpetual.asset.decimals + val perpetualProvider = perpetual.provider + + var takeProfitText by remember { mutableStateOf(storedTakeProfit.orEmpty()) } + var stopLossText by remember { mutableStateOf(storedStopLoss.orEmpty()) } + var focused: TpslType? by remember { mutableStateOf(null) } + val focusManager = LocalFocusManager.current + + val estimator = provider.estimatorFor(amount) + val takeProfitField = buildField(TpslType.TakeProfit, takeProfitText, direction, marketPrice, estimator) + val stopLossField = buildField(TpslType.StopLoss, stopLossText, direction, marketPrice, estimator) + + val activeField = focused?.let { + when (it) { + TpslType.TakeProfit -> takeProfitField + TpslType.StopLoss -> stopLossField + } + } + val activeText = focused?.let { + when (it) { + TpslType.TakeProfit -> takeProfitText + TpslType.StopLoss -> stopLossText + } + } + val confirmEnabled = (takeProfitText.toDoubleOrNull() != null && takeProfitField.error == null) || + (stopLossText.toDoubleOrNull() != null && stopLossField.error == null) || + takeProfitText.isEmpty() && stopLossText.isEmpty() + + ModalBottomSheet( + isVisible = isVisible, + onDismissRequest = onDismiss, + title = stringResource(R.string.perpetual_auto_close), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = paddingDefault), + ) { + AutocloseInputSection( + field = takeProfitField, + text = takeProfitText, + onTextChanged = { takeProfitText = it }, + onFocusChanged = { hasFocus -> + if (hasFocus) focused = TpslType.TakeProfit + else if (focused == TpslType.TakeProfit) focused = null + }, + ) + Spacer16() + AutocloseInputSection( + field = stopLossField, + text = stopLossText, + onTextChanged = { stopLossText = it }, + onFocusChanged = { hasFocus -> + if (hasFocus) focused = TpslType.StopLoss + else if (focused == TpslType.StopLoss) focused = null + }, + ) + Spacer16() + if (activeField != null && activeText.isNullOrEmpty()) { + AutocloseSuggestionsBar( + suggestions = activeField.percentSuggestions, + onPercentSelected = { percent -> + val target = estimator.targetPriceFromRoe(percent, activeField.type) + val formatted = PerpetualFormatter.formatInputPrice( + provider = perpetualProvider, + price = target, + decimals = assetDecimals, + ) + when (activeField.type) { + TpslType.TakeProfit -> takeProfitText = formatted + TpslType.StopLoss -> stopLossText = formatted + } + }, + onDone = { focusManager.clearFocus() }, + ) + } else { + MainActionButton( + title = stringResource(R.string.common_done), + enabled = confirmEnabled, + onClick = { + provider.setTakeProfit(takeProfitText.takeIf { it.isNotEmpty() && takeProfitField.error == null }) + provider.setStopLoss(stopLossText.takeIf { it.isNotEmpty() && stopLossField.error == null }) + onDismiss() + }, + ) + } + Spacer16() + } + } + + LaunchedEffect(storedTakeProfit, storedStopLoss) { + takeProfitText = storedTakeProfit.orEmpty() + stopLossText = storedStopLoss.orEmpty() + } +} + +private fun buildField( + type: TpslType, + text: String, + direction: PerpetualDirection, + marketPrice: Double, + estimator: AutocloseEstimator, +): AutocloseUIModel.Field { + val price = text.toDoubleOrNull() + val validator = AutocloseValidator(type = type, direction = direction, marketPrice = marketPrice) + val field = AutocloseField( + type = type, + price = price, + originalPrice = null, + formattedPrice = null, + error = validator.error(price), + orderId = null, + ) + return AutocloseUIModelFactory.createField(field = field, estimator = estimator) +} diff --git a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProvider.kt b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProvider.kt index c3a2bea54c..3640fe5c98 100644 --- a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProvider.kt +++ b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProvider.kt @@ -8,6 +8,8 @@ import com.gemwallet.android.domains.perpetual.LeverageState import com.gemwallet.android.domains.perpetual.PerpetualConfig import com.gemwallet.android.domains.perpetual.PerpetualOrderFactory import com.gemwallet.android.domains.perpetual.PerpetualPositionAction +import com.gemwallet.android.domains.perpetual.aggregates.PerpetualDetailsDataAggregate +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator import com.gemwallet.android.ext.HypercoreUSDC import com.gemwallet.android.ext.PerpetualFormatter import com.gemwallet.android.features.transfer_amount.viewmodels.AmountTitle @@ -15,6 +17,7 @@ import com.gemwallet.android.model.AmountParams import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.Crypto +import com.wallet.core.primitives.PerpetualDirection import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -44,8 +47,30 @@ class AmountPerpetualProvider( private val isLeverageSelectable: Boolean = params.positionAction is PerpetualPositionAction.Open - private val perpetual = getPerpetual.getPerpetual(params.perpetualId) - .stateIn(scope, SharingStarted.Eagerly, null) + val perpetual: StateFlow = + getPerpetual.getPerpetual(params.perpetualId) + .stateIn(scope, SharingStarted.Eagerly, null) + + val direction: PerpetualDirection = params.direction + + private val takeProfitInput = MutableStateFlow(null) + private val stopLossInput = MutableStateFlow(null) + + val takeProfit: StateFlow = takeProfitInput + val stopLoss: StateFlow = stopLossInput + + fun setTakeProfit(value: String?) { + takeProfitInput.value = value?.takeIf { it.isNotEmpty() } + } + + fun setStopLoss(value: String?) { + stopLossInput.value = value?.takeIf { it.isNotEmpty() } + } + + private val isAutocloseEnabled: Boolean = + params.positionAction is PerpetualPositionAction.Open + + val showsAutoclose: Boolean = isAutocloseEnabled private val userSelectedLeverage = MutableStateFlow(null) @@ -69,6 +94,20 @@ class AmountPerpetualProvider( fun setLeverage(value: Int) { userSelectedLeverage.value = value } + fun estimatorFor(amount: String): AutocloseEstimator { + val market = perpetual.value + val leverage = (leverageState.value?.current ?: market?.maxLeverage ?: 1).coerceAtLeast(1) + val marketPrice = market?.price ?: 0.0 + val usdAmount = amount.toDoubleOrNull() ?: 0.0 + val positionSize = if (marketPrice > 0.0) (usdAmount * leverage) / marketPrice else 0.0 + return AutocloseEstimator( + entryPrice = marketPrice, + positionSize = positionSize, + direction = direction, + leverage = leverage.toUByte(), + ) + } + override val minimumValue: StateFlow = combine( perpetual.filterNotNull(), leverageState, @@ -109,8 +148,23 @@ class AmountPerpetualProvider( usdcAmount = amount.atomicValue, usdcDecimals = current.asset.decimals, leverage = leverageState.value?.current?.toUByte() ?: params.positionAction.data.leverage, + takeProfit = formatTriggerForOrder(takeProfitInput.value, perpetualMarket), + stopLoss = formatTriggerForOrder(stopLossInput.value, perpetualMarket), ) return ConfirmParams.Builder(perpetualMarket.asset, owner, amount.atomicValue, isMax) .perpetual(perpetualType) } + + private fun formatTriggerForOrder( + text: String?, + data: PerpetualDetailsDataAggregate, + ): String? { + if (!isAutocloseEnabled) return null + val price = text?.toDoubleOrNull() ?: return null + return PerpetualFormatter.formatPrice( + provider = data.provider, + price = price, + decimals = data.asset.decimals, + ) + } } diff --git a/android/features/transfer_amount/viewmodels/src/test/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProviderTest.kt b/android/features/transfer_amount/viewmodels/src/test/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProviderTest.kt index c293e0549c..365b9191fe 100644 --- a/android/features/transfer_amount/viewmodels/src/test/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProviderTest.kt +++ b/android/features/transfer_amount/viewmodels/src/test/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProviderTest.kt @@ -20,7 +20,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.flowOf import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test +import java.math.BigInteger class AmountPerpetualProviderTest { @@ -39,7 +43,34 @@ class AmountPerpetualProviderTest { assertEquals(PerpetualDirection.Short, open.data.direction) } - private fun makeProvider(direction: PerpetualDirection = PerpetualDirection.Long): AmountPerpetualProvider { + @Test + fun `setTakeProfit and setStopLoss normalize empty and null to null`() { + val provider = makeProvider() + provider.setTakeProfit("65000") + provider.setStopLoss("55000") + provider.setTakeProfit("") + provider.setStopLoss(null) + assertNull(provider.takeProfit.value) + assertNull(provider.stopLoss.value) + } + + @Test + fun `showsAutoclose is true for Open and false for Reduce`() { + assertTrue(makeProvider().showsAutoclose) + val reduce = makeProvider(positionAction = PerpetualPositionAction.Reduce( + data = mockPerpetualTransferData(direction = PerpetualDirection.Long), + available = BigInteger.TEN, + positionDirection = PerpetualDirection.Long, + )) + assertFalse(reduce.showsAutoclose) + } + + private fun makeProvider( + direction: PerpetualDirection = PerpetualDirection.Long, + positionAction: PerpetualPositionAction = PerpetualPositionAction.Open( + mockPerpetualTransferData(direction = direction), + ), + ): AmountPerpetualProvider { val asset = mockAssetCosmos() val getAssetInfo = mockk(relaxed = true) { every { this@mockk.invoke(any()) } returns flowOf(null) @@ -56,9 +87,6 @@ class AmountPerpetualProviderTest { val getPerpetualBalance = mockk(relaxed = true) { every { getBalance() } returns flowOf(null) } - val positionAction = PerpetualPositionAction.Open( - mockPerpetualTransferData(direction = direction), - ) return AmountPerpetualProvider( params = AmountParams.Perpetual(asset.id, PerpetualId(PerpetualProvider.Hypercore, "BTC-PERP"), positionAction), userConfig = userConfig, From 7379d334b3baa8053c725b1ec22346a500069a6c Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 27 May 2026 19:44:52 +0300 Subject: [PATCH 08/36] Add modify autoclose summary to confirm screen --- .../confirm/presents/ConfirmScreen.kt | 8 ++++ .../confirm/models/ConfirmDetailElement.kt | 5 ++ .../models/PerpetualModifyAutocloseFactory.kt | 39 +++++++++++++++ .../confirm/viewmodels/ConfirmViewModel.kt | 9 ++-- .../PerpetualModifyAutocloseFactoryTest.kt | 48 +++++++++++++++++++ .../testkit/PerpetualConfirmDataMock.kt | 36 ++++++++++++++ 6 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactory.kt create mode 100644 android/features/confirm/viewmodels/src/test/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactoryTest.kt diff --git a/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt b/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt index a321f09ca5..df66fdcfc7 100644 --- a/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt +++ b/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt @@ -39,6 +39,7 @@ import com.gemwallet.android.model.AuthRequest import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.ValueFormatter import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.perpetual.AutocloseSummaryRow import com.gemwallet.android.ui.components.perpetual.PerpetualDetailsBottomSheet import com.gemwallet.android.ui.components.perpetual.PerpetualDetailsSummaryItem import com.gemwallet.android.ui.components.perpetual.title @@ -317,6 +318,11 @@ private fun ConfirmDetailElementRow( onClick = onClick, listPosition = listPosition, ) + is ConfirmDetailElement.PerpetualModifyAutoclose -> AutocloseSummaryRow( + takeProfitText = item.takeProfitText, + stopLossText = item.stopLossText, + listPosition = listPosition, + ) } } @@ -340,6 +346,8 @@ private fun ConfirmDetailElementBottomSheet( onDismiss = onDismiss, ) + is ConfirmDetailElement.PerpetualModifyAutoclose -> Unit + null -> Unit } } diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/ConfirmDetailElement.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/ConfirmDetailElement.kt index d1ae3b4dfd..b6ec5352c3 100644 --- a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/ConfirmDetailElement.kt +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/ConfirmDetailElement.kt @@ -11,4 +11,9 @@ sealed interface ConfirmDetailElement { data class PerpetualDetails( val model: PerpetualConfirmDetailsUIModel, ) : ConfirmDetailElement + + data class PerpetualModifyAutoclose( + val takeProfitText: String?, + val stopLossText: String?, + ) : ConfirmDetailElement } diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactory.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactory.kt new file mode 100644 index 0000000000..9214221fae --- /dev/null +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactory.kt @@ -0,0 +1,39 @@ +package com.gemwallet.android.features.confirm.models + +import com.gemwallet.android.model.CurrencyFormatter +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualModifyConfirmData +import com.wallet.core.primitives.PerpetualModifyPositionType + +object PerpetualModifyAutocloseFactory { + + private const val ClearedPlaceholder: String = "-" + + fun create(data: PerpetualModifyConfirmData): ConfirmDetailElement.PerpetualModifyAutoclose? { + val tpsl = data.modifyTypes + .filterIsInstance() + .firstOrNull()?.content + val cancelOrderIds = data.modifyTypes + .filterIsInstance() + .flatMap { it.content } + .map { it.orderId } + .toSet() + val takeProfitCanceled = data.takeProfitOrderId != null && + data.takeProfitOrderId in cancelOrderIds + val stopLossCanceled = data.stopLossOrderId != null && + data.stopLossOrderId in cancelOrderIds + val formatter = CurrencyFormatter(currency = Currency.USD) + val takeProfitText: String? = when { + tpsl?.takeProfit != null -> tpsl.takeProfit?.toDoubleOrNull()?.let(formatter::string) + takeProfitCanceled -> ClearedPlaceholder + else -> null + } + val stopLossText: String? = when { + tpsl?.stopLoss != null -> tpsl.stopLoss?.toDoubleOrNull()?.let(formatter::string) + stopLossCanceled -> ClearedPlaceholder + else -> null + } + if (takeProfitText == null && stopLossText == null) return null + return ConfirmDetailElement.PerpetualModifyAutoclose(takeProfitText, stopLossText) + } +} diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt index d684199d0e..e1e3126e3a 100644 --- a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt @@ -27,11 +27,13 @@ import com.gemwallet.android.ui.models.swap.SwapProviderUIModelFactory import com.gemwallet.android.ui.models.actions.FinishConfirmAction import com.gemwallet.android.domains.confirm.AmountUIModel import com.gemwallet.android.features.confirm.models.ConfirmDetailElement +import com.gemwallet.android.features.confirm.models.PerpetualModifyAutocloseFactory import com.gemwallet.android.domains.confirm.ConfirmError import com.gemwallet.android.domains.confirm.ConfirmState import com.gemwallet.android.domains.confirm.FeeUIModel import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualType import com.wallet.core.primitives.FeePriority import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -323,9 +325,10 @@ class ConfirmViewModel @Inject constructor( private fun buildPerpetualDetailElement( params: ConfirmParams.PerpetualParams?, - ): ConfirmDetailElement.PerpetualDetails? { - val model = PerpetualConfirmDetailsUIModelFactory.create(params?.perpetualType ?: return null) ?: return null - return ConfirmDetailElement.PerpetualDetails(model) + ): ConfirmDetailElement? = when (val type = params?.perpetualType) { + null -> null + is PerpetualType.Modify -> PerpetualModifyAutocloseFactory.create(type.content) + else -> PerpetualConfirmDetailsUIModelFactory.create(type)?.let(ConfirmDetailElement::PerpetualDetails) } private fun buildSwapDetailElement( diff --git a/android/features/confirm/viewmodels/src/test/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactoryTest.kt b/android/features/confirm/viewmodels/src/test/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactoryTest.kt new file mode 100644 index 0000000000..096c21e907 --- /dev/null +++ b/android/features/confirm/viewmodels/src/test/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactoryTest.kt @@ -0,0 +1,48 @@ +package com.gemwallet.android.features.confirm.models + +import com.gemwallet.android.testkit.mockCancel +import com.gemwallet.android.testkit.mockPerpetualModifyConfirmData +import com.gemwallet.android.testkit.mockTpslOrder +import org.junit.Assert.assertEquals +import org.junit.Test + +class PerpetualModifyAutocloseFactoryTest { + + @Test + fun setBothPrices() { + val element = PerpetualModifyAutocloseFactory.create( + mockPerpetualModifyConfirmData( + modifyTypes = listOf(mockTpslOrder(takeProfit = "65000", stopLoss = "55000")), + ), + ) + assertEquals("$65,000.00", element?.takeProfitText) + assertEquals("$55,000.00", element?.stopLossText) + } + + @Test + fun cancelExistingShowsDash() { + val element = PerpetualModifyAutocloseFactory.create( + mockPerpetualModifyConfirmData( + modifyTypes = listOf(mockCancel(orderIds = listOf(111L, 222L))), + takeProfitOrderId = 111L, + stopLossOrderId = 222L, + ), + ) + assertEquals("-", element?.takeProfitText) + assertEquals("-", element?.stopLossText) + } + + @Test + fun cancelAndSetShowsNewPriceNotDash() { + val element = PerpetualModifyAutocloseFactory.create( + mockPerpetualModifyConfirmData( + modifyTypes = listOf( + mockCancel(orderIds = listOf(111L)), + mockTpslOrder(takeProfit = "70000"), + ), + takeProfitOrderId = 111L, + ), + ) + assertEquals("$70,000.00", element?.takeProfitText) + } +} diff --git a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualConfirmDataMock.kt b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualConfirmDataMock.kt index 12cc0a7091..8b26073092 100644 --- a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualConfirmDataMock.kt +++ b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualConfirmDataMock.kt @@ -2,11 +2,15 @@ package com.gemwallet.android.testkit import com.gemwallet.android.domains.perpetual.PerpetualTransferData import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.CancelOrderData import com.wallet.core.primitives.PerpetualConfirmData import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualMarginType +import com.wallet.core.primitives.PerpetualModifyConfirmData +import com.wallet.core.primitives.PerpetualModifyPositionType import com.wallet.core.primitives.PerpetualProvider import com.wallet.core.primitives.PerpetualReduceData +import com.wallet.core.primitives.TPSLOrderData fun mockPerpetualConfirmData( direction: PerpetualDirection = PerpetualDirection.Long, @@ -50,6 +54,38 @@ fun mockPerpetualReduceData( positionDirection = positionDirection, ) +fun mockPerpetualModifyConfirmData( + modifyTypes: List = emptyList(), + baseAsset: Asset = mockAssetHyperCoreUSDC(), + assetIndex: Int = 0, + takeProfitOrderId: Long? = null, + stopLossOrderId: Long? = null, +) = PerpetualModifyConfirmData( + baseAsset = baseAsset, + assetIndex = assetIndex, + modifyTypes = modifyTypes, + takeProfitOrderId = takeProfitOrderId, + stopLossOrderId = stopLossOrderId, +) + +fun mockTpslOrder( + direction: PerpetualDirection = PerpetualDirection.Long, + takeProfit: String? = null, + stopLoss: String? = null, + size: String = "1.0", +) = PerpetualModifyPositionType.Tpsl( + TPSLOrderData( + direction = direction, + takeProfit = takeProfit, + stopLoss = stopLoss, + size = size, + ), +) + +fun mockCancel(orderIds: List, assetIndex: Int = 0) = PerpetualModifyPositionType.Cancel( + orderIds.map { CancelOrderData(assetIndex = assetIndex, orderId = it) }, +) + fun mockPerpetualTransferData( provider: PerpetualProvider = PerpetualProvider.Hypercore, direction: PerpetualDirection = PerpetualDirection.Long, From 9d50ff479f5d0d6d1eb1324542cdfb1e38746587 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 28 May 2026 19:30:52 +0300 Subject: [PATCH 09/36] Extract NavEntryViewModelStoreOwner to shared :ui utility --- .../RouteArgumentsNavEntryDecorator.kt | 49 ++--------------- .../viewmodel/NavEntryViewModelStoreOwner.kt | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+), 46 deletions(-) create mode 100644 android/ui/src/main/kotlin/com/gemwallet/android/ui/viewmodel/NavEntryViewModelStoreOwner.kt diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RouteArgumentsNavEntryDecorator.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RouteArgumentsNavEntryDecorator.kt index 37847237dd..e6f7ccfdc8 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RouteArgumentsNavEntryDecorator.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RouteArgumentsNavEntryDecorator.kt @@ -3,18 +3,11 @@ package com.gemwallet.android.ui.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.lifecycle.DEFAULT_ARGS_KEY -import androidx.lifecycle.HasDefaultViewModelProviderFactory -import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY -import androidx.lifecycle.SavedStateViewModelFactory -import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.enableSavedStateHandles import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.MutableCreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntryDecorator @@ -23,12 +16,12 @@ import androidx.navigation3.runtime.NavMetadataKey import androidx.navigation3.runtime.get import androidx.navigation3.runtime.metadata import androidx.savedstate.SavedState -import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.savedState import com.gemwallet.android.ext.toIdentifier import com.gemwallet.android.ui.models.navigation.RouteArgument +import com.gemwallet.android.ui.viewmodel.NavEntryViewModelStoreOwner import com.wallet.core.primitives.AssetId import kotlin.reflect.KClass @@ -110,7 +103,7 @@ private fun rememberEntryViewModelStoreOwner( entryViewModelStores.store(contentKey) } return remember(parent, store, defaultArgs, savedStateRegistryOwner) { - RouteArgumentsViewModelStoreOwner( + NavEntryViewModelStoreOwner( parent = parent, store = store, savedStateRegistryOwner = checkNotNull(savedStateRegistryOwner) { @@ -158,43 +151,6 @@ private object EntryViewModelStoresFactory : ViewModelProvider.Factory { } } -private class RouteArgumentsViewModelStoreOwner( - private val parent: ViewModelStoreOwner, - private val store: ViewModelStore, - private val savedStateRegistryOwner: SavedStateRegistryOwner, - private val defaultArgs: SavedState, -) : ViewModelStoreOwner, - SavedStateRegistryOwner, - HasDefaultViewModelProviderFactory { - - init { - enableSavedStateHandles() - } - - override val viewModelStore: ViewModelStore - get() = store - - override val savedStateRegistry: SavedStateRegistry - get() = savedStateRegistryOwner.savedStateRegistry - - override val lifecycle - get() = savedStateRegistryOwner.lifecycle - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = (parent as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory - ?: SavedStateViewModelFactory() - - override val defaultViewModelCreationExtras: CreationExtras - get() = MutableCreationExtras( - (parent as? HasDefaultViewModelProviderFactory)?.defaultViewModelCreationExtras - ?: CreationExtras.Empty - ).apply { - this[SAVED_STATE_REGISTRY_OWNER_KEY] = this@RouteArgumentsViewModelStoreOwner - this[VIEW_MODEL_STORE_OWNER_KEY] = this@RouteArgumentsViewModelStoreOwner - this[DEFAULT_ARGS_KEY] = defaultArgs - } -} - internal fun NavEntry.withOccurrenceContentKey( key: NavKey, occurrence: Int, @@ -209,3 +165,4 @@ internal fun NavEntry.withOccurrenceContentKey( entry.Content() } } + diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/viewmodel/NavEntryViewModelStoreOwner.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/viewmodel/NavEntryViewModelStoreOwner.kt new file mode 100644 index 0000000000..1f31db9ee3 --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/viewmodel/NavEntryViewModelStoreOwner.kt @@ -0,0 +1,53 @@ +package com.gemwallet.android.ui.viewmodel + +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.SavedStateViewModelFactory +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.savedstate.SavedState +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryOwner + +class NavEntryViewModelStoreOwner( + private val parent: ViewModelStoreOwner, + private val store: ViewModelStore, + private val savedStateRegistryOwner: SavedStateRegistryOwner, + private val defaultArgs: SavedState, +) : ViewModelStoreOwner, + SavedStateRegistryOwner, + HasDefaultViewModelProviderFactory { + + init { + enableSavedStateHandles() + } + + override val viewModelStore: ViewModelStore + get() = store + + override val savedStateRegistry: SavedStateRegistry + get() = savedStateRegistryOwner.savedStateRegistry + + override val lifecycle + get() = savedStateRegistryOwner.lifecycle + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = (parent as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory + ?: SavedStateViewModelFactory() + + override val defaultViewModelCreationExtras: CreationExtras + get() = MutableCreationExtras( + (parent as? HasDefaultViewModelProviderFactory)?.defaultViewModelCreationExtras + ?: CreationExtras.Empty + ).apply { + this[SAVED_STATE_REGISTRY_OWNER_KEY] = this@NavEntryViewModelStoreOwner + this[VIEW_MODEL_STORE_OWNER_KEY] = this@NavEntryViewModelStoreOwner + this[DEFAULT_ARGS_KEY] = defaultArgs + } +} From 53bd0b5e320b8e4642c2131ea35437e1c9c5eb00 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 28 May 2026 19:31:05 +0300 Subject: [PATCH 10/36] Suppress autoclose PnL when trigger price is directionally invalid --- .../perpetual/autoclose/AutocloseUIModelFactory.kt | 5 +++-- .../autoclose/AutocloseUIModelFactoryTest.kt | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt index a7f64e8517..1a66a8b6a1 100644 --- a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt @@ -50,8 +50,9 @@ object AutocloseUIModelFactory { estimator: AutocloseEstimator, showErrors: Boolean = true, ): AutocloseUIModel.Field { - val pnl = field.price?.let(estimator::pnl) - val roe = field.price?.let(estimator::roe) + val priceForEstimation = field.price.takeIf { field.error == null } + val pnl = priceForEstimation?.let(estimator::pnl) + val roe = priceForEstimation?.let(estimator::roe) val isProfit = pnl?.let { it >= 0.0 } ?: (field.type == TpslType.TakeProfit) return AutocloseUIModel.Field( type = field.type, diff --git a/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt b/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt index dfa48f297a..a80b84d2ce 100644 --- a/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt +++ b/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt @@ -20,6 +20,20 @@ class AutocloseUIModelFactoryTest { assertEquals(listOf(25, 50, 100), model(leverage = 20u).takeProfit.percentSuggestions) } + @Test + fun pnlSuppressedWhenFieldHasError() { + val invalid = mockAutocloseField(TpslType.TakeProfit, price = 50.0, error = AutocloseError.TriggerMustBeHigher) + val model = AutocloseUIModelFactory.create( + position = mockPerpetualPositionData( + position = mockPerpetualPosition(direction = PerpetualDirection.Long, entryPrice = 100.0, leverage = 5u), + ), + takeProfit = invalid, + stopLoss = mockAutocloseField(TpslType.StopLoss), + confirmEnabled = false, + ) + assertEquals("-", model.takeProfit.pnlText) + } + @Test fun errorSuppressedUntilShowErrorsSet() { val invalidTakeProfit = mockAutocloseField(TpslType.TakeProfit, price = 50.0, error = AutocloseError.TriggerMustBeHigher) From 273385d772db9bb6ebb2eb194cb115ea715acf07 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 28 May 2026 19:31:10 +0300 Subject: [PATCH 11/36] Keep GemTextField caret at end on external value updates --- .../android/ui/components/GemTextField.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/GemTextField.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/GemTextField.kt index 9d839f3a50..f7afe1d393 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/GemTextField.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/GemTextField.kt @@ -14,11 +14,17 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.gemwallet.android.ui.components.list_item.listItem import com.gemwallet.android.ui.models.ListPosition @@ -43,6 +49,10 @@ fun GemTextField( listPosition: ListPosition = ListPosition.Single, ) { val hasFloatingLabel = value.isNotEmpty() && label.isNotEmpty() + var textFieldValue by remember { mutableStateOf(TextFieldValue(value, TextRange(value.length))) } + if (textFieldValue.text != value) { + textFieldValue = TextFieldValue(value, TextRange(value.length)) + } Column( modifier = modifier @@ -57,8 +67,11 @@ fun GemTextField( ) { BasicTextField( modifier = Modifier.weight(1f), - value = value, - onValueChange = onValueChange, + value = textFieldValue, + onValueChange = { next -> + textFieldValue = next + if (next.text != value) onValueChange(next.text) + }, readOnly = readOnly, singleLine = singleLine, textStyle = MaterialTheme.typography.bodyLarge.copy( From 0fc6e14ed27d2d74ecafb9d7f277ff453a189b4a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 28 May 2026 19:31:18 +0300 Subject: [PATCH 12/36] Add asset header and polish AmountAutocloseSheet open flow --- .../presents/dialogs/AmountAutocloseSheet.kt | 102 ++++++++++++------ .../presents/dialogs/OpenPositionItem.kt | 43 ++++++++ 2 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/OpenPositionItem.kt diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt index 3df848a533..790fb9c916 100644 --- a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt @@ -1,7 +1,10 @@ package com.gemwallet.android.features.transfer_amount.presents.dialogs import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -13,20 +16,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField import com.gemwallet.android.domains.perpetual.autoclose.AutocloseValidator import com.gemwallet.android.ext.PerpetualFormatter import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountPerpetualProvider +import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.buttons.MainActionButton +import com.gemwallet.android.ui.components.list_item.property.PropertyItem import com.gemwallet.android.ui.components.perpetual.AutocloseInputSection import com.gemwallet.android.ui.components.perpetual.AutocloseSuggestionsBar import com.gemwallet.android.ui.components.screen.ModalBottomSheet +import com.gemwallet.android.ui.models.ListPosition import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModelFactory import com.gemwallet.android.ui.theme.Spacer16 import com.gemwallet.android.ui.theme.paddingDefault +import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.TpslType @@ -44,20 +52,27 @@ internal fun AmountAutocloseSheet( } val storedTakeProfit by provider.takeProfit.collectAsStateWithLifecycle() val storedStopLoss by provider.stopLoss.collectAsStateWithLifecycle() + val leverageState by provider.leverageState.collectAsStateWithLifecycle() val direction = provider.direction val marketPrice = perpetual.price val assetDecimals = perpetual.asset.decimals val perpetualProvider = perpetual.provider + val leverage = leverageState?.current ?: 1 + val marketPriceText = usdFormatter.string(marketPrice) + val sizeText = usdFormatter.string((amount.toDoubleOrNull() ?: 0.0) * leverage) var takeProfitText by remember { mutableStateOf(storedTakeProfit.orEmpty()) } var stopLossText by remember { mutableStateOf(storedStopLoss.orEmpty()) } + var submitAttempted by remember { mutableStateOf(false) } var focused: TpslType? by remember { mutableStateOf(null) } val focusManager = LocalFocusManager.current val estimator = provider.estimatorFor(amount) - val takeProfitField = buildField(TpslType.TakeProfit, takeProfitText, direction, marketPrice, estimator) - val stopLossField = buildField(TpslType.StopLoss, stopLossText, direction, marketPrice, estimator) + val takeProfitRawError = AutocloseValidator(TpslType.TakeProfit, direction, marketPrice).error(takeProfitText.toDoubleOrNull()) + val stopLossRawError = AutocloseValidator(TpslType.StopLoss, direction, marketPrice).error(stopLossText.toDoubleOrNull()) + val takeProfitField = buildField(TpslType.TakeProfit, takeProfitText, takeProfitRawError, estimator, submitAttempted) + val stopLossField = buildField(TpslType.StopLoss, stopLossText, stopLossRawError, estimator, submitAttempted) val activeField = focused?.let { when (it) { @@ -65,30 +80,44 @@ internal fun AmountAutocloseSheet( TpslType.StopLoss -> stopLossField } } - val activeText = focused?.let { - when (it) { - TpslType.TakeProfit -> takeProfitText - TpslType.StopLoss -> stopLossText - } - } - val confirmEnabled = (takeProfitText.toDoubleOrNull() != null && takeProfitField.error == null) || - (stopLossText.toDoubleOrNull() != null && stopLossField.error == null) || + val confirmEnabled = (takeProfitText.toDoubleOrNull() != null && takeProfitRawError == null) || + (stopLossText.toDoubleOrNull() != null && stopLossRawError == null) || takeProfitText.isEmpty() && stopLossText.isEmpty() ModalBottomSheet( isVisible = isVisible, onDismissRequest = onDismiss, + skipPartiallyExpanded = true, title = stringResource(R.string.perpetual_auto_close), ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = paddingDefault), + .fillMaxHeight() + .padding(horizontal = paddingDefault) + .imePadding(), ) { + OpenPositionItem( + asset = perpetual.asset, + direction = direction, + leverage = leverage, + sizeText = sizeText, + listPosition = ListPosition.Single, + ) + Spacer16() + PropertyItem( + title = stringResource(R.string.perpetual_market_price), + data = marketPriceText, + listPosition = ListPosition.Single, + ) + Spacer16() AutocloseInputSection( field = takeProfitField, text = takeProfitText, - onTextChanged = { takeProfitText = it }, + onTextChanged = { + submitAttempted = false + takeProfitText = it + }, onFocusChanged = { hasFocus -> if (hasFocus) focused = TpslType.TakeProfit else if (focused == TpslType.TakeProfit) focused = null @@ -98,14 +127,17 @@ internal fun AmountAutocloseSheet( AutocloseInputSection( field = stopLossField, text = stopLossText, - onTextChanged = { stopLossText = it }, + onTextChanged = { + submitAttempted = false + stopLossText = it + }, onFocusChanged = { hasFocus -> if (hasFocus) focused = TpslType.StopLoss else if (focused == TpslType.StopLoss) focused = null }, ) - Spacer16() - if (activeField != null && activeText.isNullOrEmpty()) { + Spacer(Modifier.weight(1f)) + if (activeField != null) { AutocloseSuggestionsBar( suggestions = activeField.percentSuggestions, onPercentSelected = { percent -> @@ -115,6 +147,7 @@ internal fun AmountAutocloseSheet( price = target, decimals = assetDecimals, ) + submitAttempted = false when (activeField.type) { TpslType.TakeProfit -> takeProfitText = formatted TpslType.StopLoss -> stopLossText = formatted @@ -122,17 +155,22 @@ internal fun AmountAutocloseSheet( }, onDone = { focusManager.clearFocus() }, ) - } else { - MainActionButton( - title = stringResource(R.string.common_done), - enabled = confirmEnabled, - onClick = { - provider.setTakeProfit(takeProfitText.takeIf { it.isNotEmpty() && takeProfitField.error == null }) - provider.setStopLoss(stopLossText.takeIf { it.isNotEmpty() && stopLossField.error == null }) - onDismiss() - }, - ) + Spacer16() } + MainActionButton( + title = stringResource(R.string.common_done), + enabled = confirmEnabled, + onClick = { + submitAttempted = true + val takeProfitOk = takeProfitText.isEmpty() || takeProfitRawError == null + val stopLossOk = stopLossText.isEmpty() || stopLossRawError == null + if (takeProfitOk && stopLossOk) { + provider.setTakeProfit(takeProfitText.takeIf { it.isNotEmpty() }) + provider.setStopLoss(stopLossText.takeIf { it.isNotEmpty() }) + onDismiss() + } + }, + ) Spacer16() } } @@ -143,22 +181,22 @@ internal fun AmountAutocloseSheet( } } +private val usdFormatter = CurrencyFormatter(type = CurrencyFormatter.Type.Currency, currency = Currency.USD) + private fun buildField( type: TpslType, text: String, - direction: PerpetualDirection, - marketPrice: Double, + error: AutocloseError?, estimator: AutocloseEstimator, + showErrors: Boolean, ): AutocloseUIModel.Field { - val price = text.toDoubleOrNull() - val validator = AutocloseValidator(type = type, direction = direction, marketPrice = marketPrice) val field = AutocloseField( type = type, - price = price, + price = text.toDoubleOrNull(), originalPrice = null, formattedPrice = null, - error = validator.error(price), + error = error, orderId = null, ) - return AutocloseUIModelFactory.createField(field = field, estimator = estimator) + return AutocloseUIModelFactory.createField(field = field, estimator = estimator, showErrors = showErrors) } diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/OpenPositionItem.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/OpenPositionItem.kt new file mode 100644 index 0000000000..ae0dd4e7ad --- /dev/null +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/OpenPositionItem.kt @@ -0,0 +1,43 @@ +package com.gemwallet.android.features.transfer_amount.presents.dialogs + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.gemwallet.android.ui.components.image.AssetIcon +import com.gemwallet.android.ui.components.list_item.ListItem +import com.gemwallet.android.ui.components.list_item.ListItemDefaults +import com.gemwallet.android.ui.components.list_item.ListItemSupportText +import com.gemwallet.android.ui.components.list_item.ListItemTitleText +import com.gemwallet.android.ui.components.perpetual.color +import com.gemwallet.android.ui.components.perpetual.text +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.theme.adaptivePadding +import com.gemwallet.android.ui.theme.paddingMiddle +import com.gemwallet.android.ui.theme.space0 +import com.gemwallet.android.ui.theme.space6 +import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.PerpetualDirection + +@Composable +internal fun OpenPositionItem( + asset: Asset, + direction: PerpetualDirection, + leverage: Int, + sizeText: String, + modifier: Modifier = Modifier, + listPosition: ListPosition = ListPosition.Single, +) { + ListItem( + modifier = modifier, + listPosition = listPosition, + minHeight = ListItemDefaults.iconMinHeight, + contentPadding = adaptivePadding(default = paddingMiddle, compact = space6), + titleSubtitleSpacing = space0, + leading = { AssetIcon(asset) }, + title = { ListItemTitleText(asset.symbol) }, + subtitle = { ListItemSupportText(direction.text(leverage), color = direction.color()) }, + trailing = { + ListItemTitleText(text = sizeText, color = MaterialTheme.colorScheme.onSurface) + }, + ) +} From 8a8fd6c258e61bebed2040ef43d4657685ed5674 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 28 May 2026 19:31:26 +0300 Subject: [PATCH 13/36] Restructure autoclose modify flow as nested nav graph --- .../perpetual/presents/build.gradle.kts | 4 + .../views/autoclose/AutocloseNavGraph.kt | 216 ++++++++++++++++++ .../{AutocloseSheet.kt => AutocloseScene.kt} | 80 +++---- .../position/PerpetualPositionNavScreen.kt | 31 ++- .../viewmodels/AutocloseViewModel.kt | 16 +- 5 files changed, 284 insertions(+), 63 deletions(-) create mode 100644 android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt rename android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/{AutocloseSheet.kt => AutocloseScene.kt} (61%) diff --git a/android/features/perpetual/presents/build.gradle.kts b/android/features/perpetual/presents/build.gradle.kts index 15f1198bb6..9b3aa8e9dd 100644 --- a/android/features/perpetual/presents/build.gradle.kts +++ b/android/features/perpetual/presents/build.gradle.kts @@ -54,11 +54,15 @@ android { dependencies { implementation(project(":ui")) implementation(project(":features:perpetual:viewmodels")) + implementation(project(":features:confirm:presents")) implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.hilt.lifecycle.viewmodel.compose) + implementation(libs.navigation3.runtime) + implementation(libs.navigation3.ui) + debugImplementation(libs.androidx.ui.tooling) implementation(libs.androidx.ui.tooling.preview) diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt new file mode 100644 index 0000000000..86107e119f --- /dev/null +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt @@ -0,0 +1,216 @@ +package com.gemwallet.android.features.perpetual.views.autoclose + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.scene.Scene +import androidx.navigation3.ui.NavDisplay +import androidx.savedstate.SavedState +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.savedState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.confirm.presents.ConfirmScreen +import com.gemwallet.android.features.perpetual.viewmodels.AutocloseViewModel +import com.gemwallet.android.model.ConfirmParams +import com.gemwallet.android.ui.components.animation.navigationSlideTransition +import com.gemwallet.android.ui.models.actions.FinishConfirmAction +import com.gemwallet.android.ui.viewmodel.NavEntryViewModelStoreOwner +import kotlinx.serialization.Serializable + +@Composable +fun AutocloseNavGraph( + onDismiss: () -> Unit, + finishAction: FinishConfirmAction, +) { + val rootOwner = rememberAutocloseRootViewModelStoreOwner() + CompositionLocalProvider(LocalViewModelStoreOwner provides rootOwner) { + AutocloseNavGraphContent( + onDismiss = onDismiss, + finishAction = finishAction, + ) + } +} + +@Composable +private fun AutocloseNavGraphContent( + onDismiss: () -> Unit, + finishAction: FinishConfirmAction, +) { + val viewModel: AutocloseViewModel = hiltViewModel() + val uiModel by viewModel.uiModel.collectAsStateWithLifecycle() + val takeProfitText by viewModel.takeProfitText.collectAsStateWithLifecycle() + val stopLossText by viewModel.stopLossText.collectAsStateWithLifecycle() + + val backStack = remember { mutableStateListOf(AutocloseRoute) } + var confirmParams by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.confirmRequests.collect { params -> + confirmParams = params + if (backStack.lastOrNull() != AutocloseConfirmRoute) { + backStack.add(AutocloseConfirmRoute) + } + } + } + + val popInternal = { + if (backStack.size > 1) backStack.removeAt(backStack.lastIndex) + } + + val entryProvider = entryProvider { + entry { + val model = uiModel ?: return@entry + AutocloseScene( + model = model, + takeProfitText = takeProfitText, + stopLossText = stopLossText, + onTakeProfitChanged = viewModel::onTakeProfitChanged, + onStopLossChanged = viewModel::onStopLossChanged, + onPercentSelected = viewModel::onPercentSelected, + onConfirm = viewModel::onConfirm, + onClose = onDismiss, + ) + } + entry { + confirmParams?.let { params -> + ConfirmScreen( + params = params, + cancelAction = popInternal, + finishAction = { hash -> + finishAction(hash) + onDismiss() + }, + onBuy = {}, + handleSystemBack = true, + ) + } + } + } + + val decoratedEntries = rememberDecoratedNavEntries( + entries = backStack.map { entryProvider(it) }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberAutocloseNavEntryDecorator(), + ), + ) + + NavDisplay( + entries = decoratedEntries, + modifier = Modifier.fillMaxHeight(), + onBack = { + if (backStack.size > 1) popInternal() else onDismiss() + }, + transitionSpec = slideLeftTransition, + popTransitionSpec = slideRightTransition, + predictivePopTransitionSpec = { slideRightTransition() }, + ) +} + +@Serializable +private data object AutocloseRoute : NavKey + +@Serializable +private data object AutocloseConfirmRoute : NavKey + +private typealias AutocloseNavTransition = + AnimatedContentTransitionScope>.() -> ContentTransform + +private val slideLeftTransition: AutocloseNavTransition = { + navigationSlideTransition(AnimatedContentTransitionScope.SlideDirection.Left) +} + +private val slideRightTransition: AutocloseNavTransition = { + navigationSlideTransition(AnimatedContentTransitionScope.SlideDirection.Right) +} + +@Composable +private fun rememberAutocloseRootViewModelStoreOwner(): ViewModelStoreOwner { + val parentOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner via LocalViewModelStoreOwner" + } + val savedStateRegistryOwner = checkNotNull(parentOwner as? SavedStateRegistryOwner) { + "Parent ViewModelStoreOwner must implement SavedStateRegistryOwner" + } + val parentArgs = (parentOwner as? HasDefaultViewModelProviderFactory) + ?.defaultViewModelCreationExtras + ?.get(DEFAULT_ARGS_KEY) as? SavedState + ?: savedState() + val store = remember { ViewModelStore() } + DisposableEffect(store) { + onDispose { store.clear() } + } + return remember(parentOwner, store, savedStateRegistryOwner, parentArgs) { + NavEntryViewModelStoreOwner( + parent = parentOwner, + store = store, + savedStateRegistryOwner = savedStateRegistryOwner, + defaultArgs = parentArgs, + ) + } +} + +@Composable +private fun rememberAutocloseNavEntryDecorator(): NavEntryDecorator { + val parentOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner via LocalViewModelStoreOwner" + } + val savedStateRegistryOwner = checkNotNull(parentOwner as? SavedStateRegistryOwner) { + "Parent ViewModelStoreOwner must implement SavedStateRegistryOwner" + } + val stores = remember { mutableMapOf() } + DisposableEffect(Unit) { + onDispose { + stores.values.forEach(ViewModelStore::clear) + stores.clear() + } + } + return remember(parentOwner, savedStateRegistryOwner) { + AutocloseNavEntryDecorator(parentOwner, savedStateRegistryOwner, stores) + } +} + +private class AutocloseNavEntryDecorator( + private val parent: ViewModelStoreOwner, + private val savedStateRegistryOwner: SavedStateRegistryOwner, + private val stores: MutableMap, +) : NavEntryDecorator( + onPop = { contentKey -> stores.remove(contentKey)?.clear() }, + decorate = { entry -> + val store = remember(entry.contentKey) { + stores.getOrPut(entry.contentKey) { ViewModelStore() } + } + val owner = remember(parent, store, savedStateRegistryOwner) { + NavEntryViewModelStoreOwner( + parent = parent, + store = store, + savedStateRegistryOwner = savedStateRegistryOwner, + defaultArgs = savedState(), + ) + } + CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { + entry.Content() + } + }, +) diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseSheet.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseScene.kt similarity index 61% rename from android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseSheet.kt rename to android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseScene.kt index 255731d564..8ea333fdbb 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseSheet.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseScene.kt @@ -1,7 +1,10 @@ package com.gemwallet.android.features.perpetual.views.autoclose import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -11,58 +14,54 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.gemwallet.android.features.perpetual.viewmodels.AutocloseViewModel import com.gemwallet.android.features.perpetual.views.components.PerpetualPositionItem import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.buttons.MainActionButton +import com.gemwallet.android.ui.components.dialog.DialogBar import com.gemwallet.android.ui.components.list_item.property.PropertyItem import com.gemwallet.android.ui.components.perpetual.AutocloseInputSection import com.gemwallet.android.ui.components.perpetual.AutocloseSuggestionsBar -import com.gemwallet.android.ui.components.screen.ModalBottomSheet import com.gemwallet.android.ui.models.ListPosition -import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel import com.gemwallet.android.ui.theme.Spacer16 import com.gemwallet.android.ui.theme.paddingDefault import com.wallet.core.primitives.TpslType @Composable -fun AutocloseSheet( - isVisible: Boolean, - confirmAction: ConfirmTransactionAction, - onDismiss: () -> Unit, - viewModel: AutocloseViewModel = hiltViewModel(), +internal fun AutocloseScene( + model: AutocloseUIModel, + takeProfitText: String, + stopLossText: String, + onTakeProfitChanged: (String) -> Unit, + onStopLossChanged: (String) -> Unit, + onPercentSelected: (TpslType, Int) -> Unit, + onConfirm: () -> Unit, + onClose: () -> Unit, ) { - val uiModel by viewModel.uiModel.collectAsStateWithLifecycle() - val takeProfitText by viewModel.takeProfitText.collectAsStateWithLifecycle() - val stopLossText by viewModel.stopLossText.collectAsStateWithLifecycle() - var focusedField: TpslType? by remember { mutableStateOf(null) } val focusManager = LocalFocusManager.current - val focusedText = focusedField?.let { type -> - when (type) { - TpslType.TakeProfit -> takeProfitText - TpslType.StopLoss -> stopLossText - } - } val activeField = focusedField?.let { type -> when (type) { - TpslType.TakeProfit -> uiModel?.takeProfit - TpslType.StopLoss -> uiModel?.stopLoss + TpslType.TakeProfit -> model.takeProfit + TpslType.StopLoss -> model.stopLoss } } - ModalBottomSheet( - isVisible = isVisible, - onDismissRequest = onDismiss, - title = stringResource(R.string.perpetual_auto_close), + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .imePadding(), ) { - val model = uiModel ?: return@ModalBottomSheet + DialogBar( + onDismissRequest = onClose, + title = stringResource(R.string.perpetual_auto_close), + ) Column( modifier = Modifier .fillMaxWidth() + .weight(1f) .padding(horizontal = paddingDefault), ) { PerpetualPositionItem( @@ -84,7 +83,7 @@ fun AutocloseSheet( AutocloseInputSection( field = model.takeProfit, text = takeProfitText, - onTextChanged = viewModel::onTakeProfitChanged, + onTextChanged = onTakeProfitChanged, onFocusChanged = { focused -> if (focused) focusedField = TpslType.TakeProfit else if (focusedField == TpslType.TakeProfit) focusedField = null @@ -94,31 +93,26 @@ fun AutocloseSheet( AutocloseInputSection( field = model.stopLoss, text = stopLossText, - onTextChanged = viewModel::onStopLossChanged, + onTextChanged = onStopLossChanged, onFocusChanged = { focused -> if (focused) focusedField = TpslType.StopLoss else if (focusedField == TpslType.StopLoss) focusedField = null }, ) - Spacer16() - if (activeField != null && focusedText.isNullOrEmpty()) { + Spacer(Modifier.weight(1f)) + if (activeField != null) { AutocloseSuggestionsBar( suggestions = activeField.percentSuggestions, - onPercentSelected = { percent -> - viewModel.onPercentSelected(activeField.type, percent) - }, + onPercentSelected = { percent -> onPercentSelected(activeField.type, percent) }, onDone = { focusManager.clearFocus() }, ) - } else { - MainActionButton( - title = stringResource(R.string.transfer_confirm), - enabled = model.confirmEnabled, - onClick = { - viewModel.onConfirm(confirmAction) - onDismiss() - }, - ) + Spacer16() } + MainActionButton( + title = stringResource(R.string.transfer_confirm), + enabled = model.confirmEnabled, + onClick = onConfirm, + ) Spacer16() } } diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt index 6210a83684..8355c5d775 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt @@ -3,13 +3,17 @@ package com.gemwallet.android.features.perpetual.views.position import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.gemwallet.android.features.perpetual.viewmodels.AutocloseViewModel import com.gemwallet.android.features.perpetual.viewmodels.PerpetualDetailsViewModel -import com.gemwallet.android.features.perpetual.views.autoclose.AutocloseSheet +import com.gemwallet.android.features.perpetual.views.autoclose.AutocloseNavGraph +import com.gemwallet.android.ui.components.screen.ModalBottomSheet import com.gemwallet.android.ui.models.actions.AmountTransactionAction import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction +import com.gemwallet.android.ui.models.actions.FinishConfirmAction import com.wallet.core.primitives.TransactionId @Composable @@ -19,7 +23,6 @@ fun PerpetualPositionNavScreen( onClose: () -> Unit, onTransaction: (TransactionId) -> Unit, viewModel: PerpetualDetailsViewModel = hiltViewModel(), - autocloseViewModel: AutocloseViewModel = hiltViewModel(), ) { LaunchedEffect(Unit) { viewModel.fetch() } @@ -30,7 +33,7 @@ fun PerpetualPositionNavScreen( val chartState by viewModel.chartState.collectAsStateWithLifecycle() val period by viewModel.period.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() - val autocloseVisible by autocloseViewModel.isVisible.collectAsStateWithLifecycle() + var showAutoclose by remember { mutableStateOf(false) } PerpetualPositionScene( perpetual = perpetual, @@ -47,7 +50,7 @@ fun PerpetualPositionNavScreen( PerpetualDetailsAction.IncreasePosition -> viewModel.increasePosition(amountAction) PerpetualDetailsAction.ReducePosition -> viewModel.reducePosition(amountAction) PerpetualDetailsAction.ClosePosition -> viewModel.closePosition(confirmAction) - PerpetualDetailsAction.Autoclose -> autocloseViewModel.show() + PerpetualDetailsAction.Autoclose -> showAutoclose = true is PerpetualDetailsAction.OpenPosition -> viewModel.openPosition(action.direction, amountAction) is PerpetualDetailsAction.SelectChartPeriod -> viewModel.period(action.period) is PerpetualDetailsAction.OpenTransaction -> onTransaction(action.transactionId) @@ -55,10 +58,16 @@ fun PerpetualPositionNavScreen( }, ) - AutocloseSheet( - isVisible = autocloseVisible, - confirmAction = confirmAction, - onDismiss = autocloseViewModel::dismiss, - viewModel = autocloseViewModel, - ) + ModalBottomSheet( + isVisible = showAutoclose, + onDismissRequest = { showAutoclose = false }, + skipPartiallyExpanded = true, + title = null, + dragHandle = null, + ) { + AutocloseNavGraph( + onDismiss = { showAutoclose = false }, + finishAction = FinishConfirmAction { _ -> viewModel.fetch() }, + ) + } } diff --git a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt index 3824a27ec6..5e9acc530f 100644 --- a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt +++ b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt @@ -10,7 +10,7 @@ import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField import com.gemwallet.android.domains.perpetual.autoclose.AutocloseModifyBuilder import com.gemwallet.android.domains.perpetual.autoclose.AutocloseValidator import com.gemwallet.android.ext.PerpetualFormatter -import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction +import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.ui.models.navigation.requireAssetId import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModelFactory @@ -20,10 +20,11 @@ import com.wallet.core.primitives.TpslType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest @@ -54,11 +55,8 @@ class AutocloseViewModel @Inject constructor( .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val _isVisible = MutableStateFlow(false) - val isVisible: StateFlow = _isVisible.asStateFlow() - - fun show() { _isVisible.value = true } - fun dismiss() { _isVisible.value = false } + private val _confirmRequests = MutableSharedFlow(extraBufferCapacity = 1) + val confirmRequests: SharedFlow = _confirmRequests private val userTakeProfitText = MutableStateFlow(null) private val userStopLossText = MutableStateFlow(null) @@ -108,7 +106,7 @@ class AutocloseViewModel @Inject constructor( } } - fun onConfirm(confirmAction: ConfirmTransactionAction) { + fun onConfirm() { submitAttempted.value = true val position = position.value ?: return val perpetualId = position.perpetual.id @@ -124,7 +122,7 @@ class AutocloseViewModel @Inject constructor( modifyTypes = modifyTypes, takeProfitOrderId = takeProfitField.orderId, stopLossOrderId = stopLossField.orderId, - )?.let(confirmAction::invoke) + )?.let { _confirmRequests.tryEmit(it) } } } From cda64f8f21d07a6847a709c1f4ccbb79f0e5e911 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 28 May 2026 19:40:22 +0300 Subject: [PATCH 14/36] Match autoclose sheet height to modal-sheet convention --- .../features/perpetual/views/autoclose/AutocloseNavGraph.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt index 86107e119f..10d2f6b307 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt @@ -118,7 +118,7 @@ private fun AutocloseNavGraphContent( NavDisplay( entries = decoratedEntries, - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.fillMaxHeight(0.95f), onBack = { if (backStack.size > 1) popInternal() else onDismiss() }, From f2d1db89622c405113bfaf7e08264b27137582ba Mon Sep 17 00:00:00 2001 From: gemdev1111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:02:21 +0300 Subject: [PATCH 15/36] Replace core submodule with main's vendored core (pre-merge, local) --- core | 1 - core/.cargo/audit.toml | 6 + core/.cargo/config.toml | 3 + core/.claude/skills/review-changes/SKILL.md | 161 + core/.clippy.toml | 2 + core/.dockerignore | 8 + core/.gitattributes | 0 core/.vscode/launch.json | 18 + core/.vscode/settings.json | 27 + core/AGENTS.md | 60 + core/CLAUDE.md | 1 + core/Cargo.lock | 14335 ++++ core/Cargo.toml | 136 + core/Dockerfile | 46 + core/LICENSE | 21 + core/README.md | 93 + core/Settings.yaml | 293 + core/apps/agent/.gitignore | 8 + core/apps/agent/Cargo.toml | 32 + core/apps/agent/Dockerfile | 42 + core/apps/agent/Dockerfile.dockerignore | 8 + core/apps/agent/README.md | 35 + core/apps/agent/Settings.yaml | 28 + core/apps/agent/agents/security/agent.yaml | 32 + .../agent/agents/security/memory/.gitkeep | 0 core/apps/agent/agents/security/role.md | 38 + core/apps/agent/context/repos.md | 23 + core/apps/agent/justfile | 21 + core/apps/agent/src/agent.rs | 172 + core/apps/agent/src/bin/repl.rs | 40 + core/apps/agent/src/chatwoot/client.rs | 310 + core/apps/agent/src/chatwoot/dispatch.rs | 384 + core/apps/agent/src/chatwoot/mod.rs | 5 + core/apps/agent/src/chatwoot/server.rs | 46 + .../chatwoot_bot_message_incoming.json | 105 + .../chatwoot_conversation_updated.json | 124 + .../testdata/chatwoot_message_created.json | 105 + core/apps/agent/src/config.rs | 313 + core/apps/agent/src/images.rs | 31 + core/apps/agent/src/lib.rs | 92 + core/apps/agent/src/main.rs | 47 + core/apps/agent/src/mcp/mod.rs | 81 + core/apps/agent/src/preamble.rs | 92 + core/apps/agent/src/replies.rs | 187 + core/apps/agent/src/scheduler/format.rs | 60 + core/apps/agent/src/scheduler/loader.rs | 59 + core/apps/agent/src/scheduler/mod.rs | 26 + core/apps/agent/src/scheduler/runner.rs | 50 + core/apps/agent/src/slack/client.rs | 120 + core/apps/agent/src/slack/dispatch.rs | 276 + core/apps/agent/src/slack/mod.rs | 11 + core/apps/agent/src/slack/mrkdwn.rs | 88 + core/apps/agent/src/slack/socket.rs | 98 + core/apps/agent/src/store.rs | 122 + core/apps/agent/src/tools/chatwoot_account.rs | 121 + .../agent/src/tools/chatwoot_conversation.rs | 139 + .../agent/src/tools/chatwoot_review_reply.rs | 153 + core/apps/agent/src/tools/fetch.rs | 145 + core/apps/agent/src/tools/gem_api.rs | 156 + core/apps/agent/src/tools/gem_docs.rs | 98 + core/apps/agent/src/tools/memory.rs | 109 + core/apps/agent/src/tools/mod.rs | 164 + core/apps/agent/src/tools/plausible.rs | 107 + core/apps/agent/src/tools/shell.rs | 107 + core/apps/agent/src/tools/slack_history.rs | 95 + core/apps/agent/src/tools/slack_post.rs | 103 + core/apps/agent/src/tools/telegram_post.rs | 125 + core/apps/api/Cargo.toml | 52 + core/apps/api/src/admin/assets.rs | 13 + core/apps/api/src/admin/mod.rs | 104 + core/apps/api/src/admin/nft.rs | 17 + core/apps/api/src/admin/prices.rs | 12 + core/apps/api/src/admin/transactions.rs | 28 + core/apps/api/src/assets/cilent.rs | 116 + core/apps/api/src/assets/filter.rs | 73 + core/apps/api/src/assets/mod.rs | 69 + core/apps/api/src/assets/model.rs | 72 + core/apps/api/src/auth/guard.rs | 125 + core/apps/api/src/auth/mod.rs | 3 + core/apps/api/src/catchers.rs | 20 + core/apps/api/src/chain/address.rs | 34 + core/apps/api/src/chain/block.rs | 38 + core/apps/api/src/chain/client.rs | 92 + core/apps/api/src/chain/mod.rs | 10 + core/apps/api/src/chain/nft.rs | 21 + core/apps/api/src/chain/staking.rs | 17 + core/apps/api/src/chain/swap.rs | 48 + core/apps/api/src/chain/token.rs | 12 + core/apps/api/src/chain/transaction.rs | 17 + core/apps/api/src/config/client.rs | 36 + core/apps/api/src/config/mod.rs | 10 + core/apps/api/src/devices/auth_config.rs | 18 + core/apps/api/src/devices/client.rs | 50 + .../api/src/devices/clients/address_names.rs | 92 + core/apps/api/src/devices/clients/fiat.rs | 54 + core/apps/api/src/devices/clients/mod.rs | 22 + .../api/src/devices/clients/notifications.rs | 29 + .../apps/api/src/devices/clients/portfolio.rs | 1 + core/apps/api/src/devices/clients/rewards.rs | 446 + .../src/devices/clients/rewards_redemption.rs | 68 + core/apps/api/src/devices/clients/scan.rs | 110 + .../api/src/devices/clients/transactions.rs | 50 + .../devices/clients/wallet_configuration.rs | 87 + core/apps/api/src/devices/clients/wallets.rs | 130 + core/apps/api/src/devices/constants.rs | 11 + core/apps/api/src/devices/error.rs | 31 + core/apps/api/src/devices/guard/auth.rs | 126 + .../src/devices/guard/authenticated_device.rs | 30 + .../guard/authenticated_device_wallet.rs | 44 + core/apps/api/src/devices/guard/mod.rs | 8 + .../src/devices/guard/verified_device_id.rs | 20 + core/apps/api/src/devices/mod.rs | 453 + core/apps/api/src/devices/signature.rs | 64 + core/apps/api/src/fiat.rs | 8 + core/apps/api/src/lib.rs | 4 + core/apps/api/src/main.rs | 371 + core/apps/api/src/markets/mod.rs | 9 + core/apps/api/src/model.rs | 9 + core/apps/api/src/nft.rs | 23 + core/apps/api/src/params.rs | 228 + core/apps/api/src/prices/mod.rs | 49 + core/apps/api/src/referral.rs | 10 + core/apps/api/src/responders.rs | 206 + core/apps/api/src/status.rs | 25 + core/apps/api/src/swap/client.rs | 25 + core/apps/api/src/swap/mod.rs | 21 + core/apps/api/src/swap/near_intents.rs | 33 + core/apps/api/src/swap/okx.rs | 13 + core/apps/api/src/webhooks.rs | 88 + core/apps/api/src/websocket.rs | 30 + core/apps/api/src/websocket_prices/client.rs | 129 + core/apps/api/src/websocket_prices/mod.rs | 31 + core/apps/api/src/websocket_prices/stream.rs | 53 + core/apps/api/src/websocket_stream/client.rs | 92 + core/apps/api/src/websocket_stream/mod.rs | 35 + .../api/src/websocket_stream/price_handler.rs | 116 + core/apps/api/src/websocket_stream/stream.rs | 76 + core/apps/daemon/Cargo.toml | 47 + core/apps/daemon/src/client/mod.rs | 3 + core/apps/daemon/src/client/vault_address.rs | 42 + .../consumers/fiat/fiat_webhook_consumer.rs | 136 + core/apps/daemon/src/consumers/fiat/mod.rs | 22 + .../fetch_address_transactions_consumer.rs | 48 + .../indexer/fetch_assets_consumer.rs | 38 + .../indexer/fetch_blocks_consumer.rs | 30 + .../indexer/fetch_coin_addresses_consumer.rs | 48 + .../indexer/fetch_nft_asset_consumer.rs | 25 + .../fetch_nft_assets_addresses_consumer.rs | 72 + .../indexer/fetch_prices_consumer.rs | 28 + .../indexer/fetch_token_addresses_consumer.rs | 148 + core/apps/daemon/src/consumers/indexer/mod.rs | 291 + core/apps/daemon/src/consumers/mod.rs | 58 + .../in_app_notifications_consumer.rs | 104 + .../daemon/src/consumers/notifications/mod.rs | 141 + .../notifications/notifications_consumer.rs | 56 + .../notifications_failed_consumer.rs | 41 + core/apps/daemon/src/consumers/rewards/mod.rs | 107 + .../src/consumers/rewards/rewards_consumer.rs | 70 + .../rewards/rewards_redemption_consumer.rs | 139 + core/apps/daemon/src/consumers/runner.rs | 109 + core/apps/daemon/src/consumers/store/mod.rs | 118 + .../store_pending_transactions_consumer.rs | 37 + .../consumers/store/store_prices_consumer.rs | 57 + .../store/store_transactions_consumer.rs | 444 + .../store_transactions_consumer_config.rs | 169 + .../consumers/store/wallet_stream_consumer.rs | 57 + core/apps/daemon/src/consumers/support/mod.rs | 29 + .../support/support_webhook_consumer.rs | 65 + core/apps/daemon/src/health.rs | 54 + core/apps/daemon/src/main.rs | 251 + core/apps/daemon/src/metrics/consumer.rs | 68 + core/apps/daemon/src/metrics/job.rs | 76 + core/apps/daemon/src/metrics/mod.rs | 44 + core/apps/daemon/src/metrics/parser.rs | 92 + core/apps/daemon/src/model.rs | 141 + core/apps/daemon/src/parser/mod.rs | 309 + core/apps/daemon/src/parser/parser_options.rs | 10 + core/apps/daemon/src/parser/parser_state.rs | 29 + core/apps/daemon/src/parser/plan.rs | 161 + core/apps/daemon/src/pusher/mod.rs | 3 + core/apps/daemon/src/pusher/pusher.rs | 195 + core/apps/daemon/src/reporters/consumer.rs | 23 + core/apps/daemon/src/reporters/job.rs | 23 + core/apps/daemon/src/reporters/mod.rs | 3 + core/apps/daemon/src/reporters/parser.rs | 30 + core/apps/daemon/src/setup/mod.rs | 525 + core/apps/daemon/src/setup/scan_addresses.rs | 86 + core/apps/daemon/src/shutdown.rs | 49 + core/apps/daemon/src/worker/alerter/mod.rs | 66 + .../src/worker/alerter/price_alerts_sender.rs | 44 + .../alerter/staking_rewards_notifier.rs | 99 + .../src/worker/assets/asset_rank_updater.rs | 59 + .../worker/assets/assets_has_price_updater.rs | 41 + .../worker/assets/assets_images_updater.rs | 45 + core/apps/daemon/src/worker/assets/mod.rs | 116 + .../src/worker/assets/perpetual_updater.rs | 49 + .../src/worker/assets/staking_apy_updater.rs | 26 + .../src/worker/assets/usage_rank_updater.rs | 126 + .../src/worker/assets/validator_scanner.rs | 35 + core/apps/daemon/src/worker/context.rs | 38 + .../src/worker/fiat/fiat_assets_updater.rs | 160 + .../src/worker/fiat/fiat_rates_updater.rs | 19 + core/apps/daemon/src/worker/fiat/mod.rs | 97 + core/apps/daemon/src/worker/job_schedule.rs | 52 + core/apps/daemon/src/worker/jobs.rs | 275 + core/apps/daemon/src/worker/mod.rs | 38 + core/apps/daemon/src/worker/perpetuals/mod.rs | 71 + .../perpetuals/perpetual_address_refresher.rs | 46 + .../worker/perpetuals/perpetual_classifier.rs | 192 + .../worker/perpetuals/perpetual_observer.rs | 90 + core/apps/daemon/src/worker/plan.rs | 157 + .../src/worker/prices/charts_updater.rs | 181 + .../src/worker/prices/markets_updater.rs | 76 + .../worker/prices/missing_prices_publisher.rs | 61 + core/apps/daemon/src/worker/prices/mod.rs | 382 + .../worker/prices/observed_prices_updater.rs | 81 + .../worker/prices/prices_cleanup_updater.rs | 43 + .../worker/prices/prices_metrics_updater.rs | 53 + .../src/worker/prices/prices_updater.rs | 192 + core/apps/daemon/src/worker/rewards/mod.rs | 50 + .../worker/rewards/rewards_abuse_checker.rs | 550 + .../rewards/rewards_eligibility_checker.rs | 87 + core/apps/daemon/src/worker/runtime.rs | 18 + .../src/worker/search/assets_index_updater.rs | 70 + core/apps/daemon/src/worker/search/mod.rs | 51 + .../src/worker/search/nfts_index_updater.rs | 33 + .../worker/search/perpetuals_index_updater.rs | 47 + core/apps/daemon/src/worker/search/sync.rs | 94 + .../src/worker/system/device_updater.rs | 16 + core/apps/daemon/src/worker/system/mod.rs | 73 + core/apps/daemon/src/worker/system/model.rs | 44 + .../observers/inactive_devices_observer.rs | 45 + .../daemon/src/worker/system/observers/mod.rs | 2 + .../src/worker/system/transaction_cleanup.rs | 63 + .../src/worker/system/version_updater.rs | 99 + .../worker/transactions/in_transit_updater.rs | 194 + .../daemon/src/worker/transactions/mod.rs | 81 + .../pending_transactions_updater.rs | 157 + .../transactions/vault_addresses_updater.rs | 27 + core/apps/dynode/CLAUDE.md | 43 + core/apps/dynode/Cargo.toml | 26 + core/apps/dynode/chains.yml | 81 + core/apps/dynode/config.yml | 91 + core/apps/dynode/justfile | 49 + core/apps/dynode/src/auth.rs | 40 + core/apps/dynode/src/cache/memory.rs | 349 + core/apps/dynode/src/cache/mod.rs | 21 + core/apps/dynode/src/cache/types.rs | 63 + core/apps/dynode/src/config/cache.rs | 161 + core/apps/dynode/src/config/domain.rs | 180 + core/apps/dynode/src/config/metrics.rs | 7 + core/apps/dynode/src/config/mod.rs | 301 + core/apps/dynode/src/config/url.rs | 56 + core/apps/dynode/src/jsonrpc_types.rs | 383 + core/apps/dynode/src/lib.rs | 11 + core/apps/dynode/src/main.rs | 191 + core/apps/dynode/src/metrics/mod.rs | 269 + .../dynode/src/monitoring/chain_client.rs | 29 + core/apps/dynode/src/monitoring/mod.rs | 11 + core/apps/dynode/src/monitoring/service.rs | 386 + .../dynode/src/monitoring/switch_reason.rs | 28 + core/apps/dynode/src/monitoring/sync.rs | 358 + core/apps/dynode/src/monitoring/telemetry.rs | 167 + core/apps/dynode/src/monitoring/worker.rs | 132 + core/apps/dynode/src/proxy/constants.rs | 4 + core/apps/dynode/src/proxy/jsonrpc.rs | 361 + core/apps/dynode/src/proxy/mod.rs | 15 + core/apps/dynode/src/proxy/proxy_builder.rs | 61 + core/apps/dynode/src/proxy/proxy_request.rs | 113 + .../dynode/src/proxy/proxy_request_builder.rs | 76 + core/apps/dynode/src/proxy/request_builder.rs | 87 + core/apps/dynode/src/proxy/request_url.rs | 56 + .../apps/dynode/src/proxy/response_builder.rs | 75 + core/apps/dynode/src/proxy/service.rs | 490 + core/apps/dynode/src/proxy/types.rs | 66 + core/apps/dynode/src/response.rs | 59 + core/apps/dynode/src/testkit/config.rs | 33 + core/apps/dynode/src/testkit/mod.rs | 2 + core/apps/dynode/src/testkit/sync.rs | 23 + core/apps/dynode/src/webhook.rs | 160 + core/bin/cli/Cargo.toml | 12 + core/bin/cli/src/commands/asset.rs | 41 + core/bin/cli/src/commands/balance.rs | 39 + core/bin/cli/src/commands/mod.rs | 2 + core/bin/cli/src/main.rs | 39 + core/bin/gas-bench/Cargo.toml | 21 + core/bin/gas-bench/src/client.rs | 71 + core/bin/gas-bench/src/etherscan.rs | 91 + core/bin/gas-bench/src/gasflow.rs | 77 + core/bin/gas-bench/src/helius/client.rs | 39 + core/bin/gas-bench/src/helius/mod.rs | 5 + core/bin/gas-bench/src/helius/model.rs | 60 + core/bin/gas-bench/src/jito/client.rs | 22 + core/bin/gas-bench/src/jito/mod.rs | 5 + core/bin/gas-bench/src/jito/model.rs | 57 + core/bin/gas-bench/src/main.rs | 416 + core/bin/gas-bench/src/solana_client.rs | 139 + core/bin/generate/Cargo.toml | 7 + core/bin/generate/src/main.rs | 222 + core/bin/img-downloader/Cargo.toml | 14 + core/bin/img-downloader/src/cli_args.rs | 41 + core/bin/img-downloader/src/main.rs | 166 + core/bin/uniffi-bindgen/Cargo.toml | 7 + core/bin/uniffi-bindgen/src/main.rs | 3 + core/crates/api_connector/Cargo.toml | 13 + .../src/app_store_client/client.rs | 44 + .../api_connector/src/app_store_client/mod.rs | 2 + .../src/app_store_client/models.rs | 75 + core/crates/api_connector/src/lib.rs | 7 + .../crates/api_connector/src/pusher/client.rs | 60 + core/crates/api_connector/src/pusher/mod.rs | 2 + core/crates/api_connector/src/pusher/model.rs | 106 + .../src/static_assets_client/client.rs | 34 + .../src/static_assets_client/mod.rs | 2 + .../src/static_assets_client/models.rs | 11 + core/crates/cacher/Cargo.toml | 9 + core/crates/cacher/src/error.rs | 48 + core/crates/cacher/src/keys.rs | 149 + core/crates/cacher/src/lib.rs | 321 + core/crates/chain_primitives/Cargo.toml | 9 + .../chain_primitives/src/balance_diff.rs | 207 + core/crates/chain_primitives/src/lib.rs | 5 + core/crates/chain_primitives/src/token_id.rs | 150 + core/crates/chain_traits/Cargo.toml | 9 + core/crates/chain_traits/src/lib.rs | 221 + core/crates/coingecko/Cargo.toml | 14 + core/crates/coingecko/src/client.rs | 193 + core/crates/coingecko/src/lib.rs | 12 + core/crates/coingecko/src/mapper.rs | 130 + core/crates/coingecko/src/model.rs | 220 + core/crates/coingecko/src/testkit.rs | 32 + core/crates/fiat/Cargo.toml | 38 + core/crates/fiat/src/client.rs | 441 + core/crates/fiat/src/error.rs | 32 + core/crates/fiat/src/fiat_cacher_client.rs | 35 + core/crates/fiat/src/hmac_signature.rs | 52 + core/crates/fiat/src/ip_check_client.rs | 32 + core/crates/fiat/src/lib.rs | 57 + core/crates/fiat/src/model.rs | 80 + core/crates/fiat/src/provider.rs | 84 + .../crates/fiat/src/providers/banxa/client.rs | 86 + .../crates/fiat/src/providers/banxa/mapper.rs | 215 + core/crates/fiat/src/providers/banxa/mod.rs | 4 + .../fiat/src/providers/banxa/models/asset.rs | 32 + .../src/providers/banxa/models/country.rs | 7 + .../providers/banxa/models/create_order.rs | 40 + .../providers/banxa/models/fiat_currencies.rs | 19 + .../fiat/src/providers/banxa/models/mod.rs | 15 + .../fiat/src/providers/banxa/models/order.rs | 15 + .../fiat/src/providers/banxa/models/quote.rs | 9 + .../src/providers/banxa/models/webhook.rs | 6 + .../fiat/src/providers/banxa/provider.rs | 136 + .../fiat/src/providers/flashnet/client.rs | 62 + .../fiat/src/providers/flashnet/mapper.rs | 399 + .../crates/fiat/src/providers/flashnet/mod.rs | 4 + .../fiat/src/providers/flashnet/model.rs | 110 + .../fiat/src/providers/flashnet/provider.rs | 125 + .../fiat/src/providers/mercuryo/client.rs | 77 + .../fiat/src/providers/mercuryo/mapper.rs | 295 + .../crates/fiat/src/providers/mercuryo/mod.rs | 5 + .../src/providers/mercuryo/models/asset.rs | 41 + .../src/providers/mercuryo/models/limits.rs | 10 + .../fiat/src/providers/mercuryo/models/mod.rs | 11 + .../src/providers/mercuryo/models/quote.rs | 31 + .../src/providers/mercuryo/models/response.rs | 27 + .../src/providers/mercuryo/models/webhook.rs | 23 + .../fiat/src/providers/mercuryo/provider.rs | 168 + .../fiat/src/providers/mercuryo/widget.rs | 183 + core/crates/fiat/src/providers/mod.rs | 17 + .../fiat/src/providers/moonpay/client.rs | 192 + .../fiat/src/providers/moonpay/mapper.rs | 254 + core/crates/fiat/src/providers/moonpay/mod.rs | 7 + .../src/providers/moonpay/models/assets.rs | 54 + .../src/providers/moonpay/models/common.rs | 28 + .../src/providers/moonpay/models/countries.rs | 18 + .../fiat/src/providers/moonpay/models/mod.rs | 11 + .../src/providers/moonpay/models/quotes.rs | 21 + .../providers/moonpay/models/transactions.rs | 19 + .../fiat/src/providers/moonpay/provider.rs | 131 + .../fiat/src/providers/moonpay/testkit.rs | 22 + .../fiat/src/providers/paybis/client.rs | 143 + .../fiat/src/providers/paybis/mapper.rs | 500 + core/crates/fiat/src/providers/paybis/mod.rs | 4 + .../fiat/src/providers/paybis/models/asset.rs | 51 + .../src/providers/paybis/models/country.rs | 30 + .../src/providers/paybis/models/limits.rs | 34 + .../fiat/src/providers/paybis/models/mod.rs | 13 + .../fiat/src/providers/paybis/models/quote.rs | 35 + .../src/providers/paybis/models/request.rs | 134 + .../src/providers/paybis/models/webhook.rs | 42 + .../fiat/src/providers/paybis/provider.rs | 252 + .../fiat/src/providers/transak/client.rs | 198 + .../fiat/src/providers/transak/mapper.rs | 210 + core/crates/fiat/src/providers/transak/mod.rs | 4 + .../src/providers/transak/models/assets.rs | 28 + .../fiat/src/providers/transak/models/auth.rs | 42 + .../src/providers/transak/models/common.rs | 37 + .../src/providers/transak/models/countries.rs | 8 + .../transak/models/fiat_currencies.rs | 30 + .../fiat/src/providers/transak/models/mod.rs | 15 + .../src/providers/transak/models/quotes.rs | 42 + .../providers/transak/models/transactions.rs | 12 + .../fiat/src/providers/transak/provider.rs | 201 + core/crates/fiat/src/rsa_signature.rs | 73 + core/crates/fiat/src/testkit.rs | 67 + .../fiat/src/transaction_info_mapper.rs | 118 + .../fiat/testdata/banxa/fiat_currencies.json | 46 + .../banxa/transaction_sell_failed.json | 9 + .../fiat/testdata/flashnet/estimate.json | 23 + .../testdata/flashnet/onramp_response.json | 6 + .../testdata/flashnet/order_completed.json | 8 + .../flashnet/order_completed_eth.json | 9 + .../crates/fiat/testdata/flashnet/routes.json | 49 + .../testdata/flashnet/webhook_completed.json | 11 + .../testdata/flashnet/webhook_pending.json | 10 + .../crates/fiat/testdata/mercuryo/assets.json | 3579 + .../mercuryo/webhook_buy_complete.json | 11 + .../mercuryo/webhook_mobile_pay_complete.json | 11 + .../mercuryo/webhook_sell_complete.json | 14 + .../mercuryo/webhook_withdraw_complete.json | 12 + .../webhook_withdraw_same_order_complete.json | 13 + core/crates/fiat/testdata/moonpay/assets.json | 129 + .../moonpay/sell_transaction_complete.json | 34 + .../moonpay/transaction_sell_failed.json | 36 + .../moonpay/webhook_buy_complete.json | 27 + .../moonpay/webhook_sell_complete_.json | 36 + core/crates/fiat/testdata/paybis/assets.json | 71588 ++++++++++++++++ .../testdata/paybis/assets_with_limits.json | 1113 + .../fiat/testdata/paybis/quote_bitcoin.json | 298 + .../paybis/webhook_transaction_completed.json | 25 + .../webhook_transaction_no_changes.json | 4 + .../paybis/webhook_transaction_started.json | 22 + ...ebhook_transaction_started_no_payment.json | 22 + .../testdata/transak/fiat_currencies.json | 111 + .../transak/transaction_buy_error.json | 10 + core/crates/gem_algorand/Cargo.toml | 35 + core/crates/gem_algorand/src/address.rs | 75 + core/crates/gem_algorand/src/constants.rs | 1 + core/crates/gem_algorand/src/lib.rs | 17 + .../crates/gem_algorand/src/models/account.rs | 16 + core/crates/gem_algorand/src/models/asset.rs | 22 + core/crates/gem_algorand/src/models/block.rs | 20 + .../crates/gem_algorand/src/models/indexer.rs | 8 + core/crates/gem_algorand/src/models/mod.rs | 13 + .../gem_algorand/src/models/signing/mod.rs | 5 + .../src/models/signing/operation.rs | 41 + .../src/models/signing/transaction.rs | 66 + .../gem_algorand/src/models/transaction.rs | 88 + .../gem_algorand/src/provider/balances.rs | 90 + .../src/provider/balances_mapper.rs | 58 + core/crates/gem_algorand/src/provider/mod.rs | 19 + .../gem_algorand/src/provider/preload.rs | 36 + .../src/provider/request_classifier.rs | 14 + .../crates/gem_algorand/src/provider/state.rs | 42 + .../gem_algorand/src/provider/state_mapper.rs | 30 + .../gem_algorand/src/provider/testkit.rs | 25 + .../crates/gem_algorand/src/provider/token.rs | 39 + .../gem_algorand/src/provider/token_mapper.rs | 31 + .../src/provider/transaction_broadcast.rs | 25 + .../provider/transaction_broadcast_mapper.rs | 9 + .../src/provider/transaction_state.rs | 16 + .../src/provider/transaction_state_mapper.rs | 55 + .../gem_algorand/src/provider/transactions.rs | 65 + .../src/provider/transactions_mapper.rs | 87 + core/crates/gem_algorand/src/rpc/client.rs | 84 + .../gem_algorand/src/rpc/client_indexer.rs | 31 + core/crates/gem_algorand/src/rpc/mod.rs | 4 + .../gem_algorand/src/signer/chain_signer.rs | 100 + core/crates/gem_algorand/src/signer/mod.rs | 5 + .../gem_algorand/src/signer/serialization.rs | 151 + .../crates/gem_algorand/src/signer/signing.rs | 18 + .../crates/gem_algorand/testdata/account.json | 29 + .../testdata/transaction_broadcast_error.json | 3 + .../transaction_broadcast_success.json | 3 + .../testdata/transaction_by_hash.json | 14 + .../transaction_transfer_pending.json | 17 + .../transaction_transfer_success.json | 18 + core/crates/gem_aptos/Cargo.toml | 46 + core/crates/gem_aptos/src/address.rs | 85 + core/crates/gem_aptos/src/constants.rs | 25 + core/crates/gem_aptos/src/lib.rs | 20 + core/crates/gem_aptos/src/models/account.rs | 22 + core/crates/gem_aptos/src/models/coin.rs | 27 + core/crates/gem_aptos/src/models/fee.rs | 8 + core/crates/gem_aptos/src/models/ledger.rs | 21 + core/crates/gem_aptos/src/models/mod.rs | 15 + .../src/models/signer_transaction.rs | 50 + core/crates/gem_aptos/src/models/staking.rs | 43 + .../gem_aptos/src/models/transaction.rs | 128 + core/crates/gem_aptos/src/move/mod.rs | 6 + core/crates/gem_aptos/src/move/parser.rs | 360 + core/crates/gem_aptos/src/move/types.rs | 39 + core/crates/gem_aptos/src/move/values.rs | 43 + .../crates/gem_aptos/src/provider/balances.rs | 111 + .../gem_aptos/src/provider/balances_mapper.rs | 105 + core/crates/gem_aptos/src/provider/mod.rs | 36 + .../gem_aptos/src/provider/payload_builder.rs | 129 + core/crates/gem_aptos/src/provider/preload.rs | 102 + .../gem_aptos/src/provider/preload_mapper.rs | 27 + .../src/provider/request_classifier.rs | 14 + core/crates/gem_aptos/src/provider/staking.rs | 90 + .../gem_aptos/src/provider/staking_mapper.rs | 150 + core/crates/gem_aptos/src/provider/state.rs | 61 + .../gem_aptos/src/provider/state_mapper.rs | 27 + core/crates/gem_aptos/src/provider/testkit.rs | 21 + core/crates/gem_aptos/src/provider/token.rs | 43 + .../gem_aptos/src/provider/token_mapper.rs | 48 + .../src/provider/transaction_broadcast.rs | 30 + .../provider/transaction_broadcast_mapper.rs | 16 + .../src/provider/transaction_state.rs | 15 + .../src/provider/transaction_state_mapper.rs | 61 + .../gem_aptos/src/provider/transactions.rs | 59 + .../src/provider/transactions_mapper.rs | 369 + core/crates/gem_aptos/src/rpc/client.rs | 241 + core/crates/gem_aptos/src/rpc/mod.rs | 3 + core/crates/gem_aptos/src/signer/abi.rs | 24 + .../gem_aptos/src/signer/chain_signer.rs | 234 + core/crates/gem_aptos/src/signer/mod.rs | 10 + core/crates/gem_aptos/src/signer/payload.rs | 57 + .../gem_aptos/src/signer/transaction.rs | 157 + core/crates/gem_aptos/src/token_id.rs | 24 + .../invalid_transaction_response.json | 5 + .../transaction_near_intent_transfer.json | 365 + .../testdata/transaction_stake_delegate.json | 142 + .../transaction_stake_undelegate.json | 552 + .../testdata/transaction_swap_panora.json | 26083 ++++++ core/crates/gem_auth/Cargo.toml | 27 + core/crates/gem_auth/src/client.rs | 44 + core/crates/gem_auth/src/device_signature.rs | 187 + core/crates/gem_auth/src/jwt.rs | 68 + core/crates/gem_auth/src/lib.rs | 13 + core/crates/gem_auth/src/signature.rs | 92 + core/crates/gem_bitcoin/Cargo.toml | 36 + core/crates/gem_bitcoin/src/lib.rs | 19 + core/crates/gem_bitcoin/src/models/account.rs | 33 + core/crates/gem_bitcoin/src/models/address.rs | 59 + core/crates/gem_bitcoin/src/models/block.rs | 59 + core/crates/gem_bitcoin/src/models/fee.rs | 6 + core/crates/gem_bitcoin/src/models/mod.rs | 13 + .../gem_bitcoin/src/models/transaction.rs | 116 + .../gem_bitcoin/src/provider/balances.rs | 57 + .../src/provider/balances_mapper.rs | 38 + core/crates/gem_bitcoin/src/provider/mod.rs | 26 + .../gem_bitcoin/src/provider/preload.rs | 123 + .../src/provider/preload_mapper.rs | 32 + .../src/provider/request_classifier.rs | 14 + core/crates/gem_bitcoin/src/provider/state.rs | 69 + .../gem_bitcoin/src/provider/state_mapper.rs | 59 + .../gem_bitcoin/src/provider/testkit.rs | 20 + .../src/provider/transaction_broadcast.rs | 28 + .../provider/transaction_broadcast_mapper.rs | 16 + .../src/provider/transaction_state.rs | 21 + .../gem_bitcoin/src/provider/transactions.rs | 102 + .../src/provider/transactions_mapper.rs | 219 + core/crates/gem_bitcoin/src/rpc/client.rs | 84 + core/crates/gem_bitcoin/src/rpc/mod.rs | 3 + .../crates/gem_bitcoin/src/signer/encoding.rs | 36 + core/crates/gem_bitcoin/src/signer/mod.rs | 6 + .../gem_bitcoin/src/signer/signature.rs | 51 + core/crates/gem_bitcoin/src/signer/types.rs | 84 + core/crates/gem_bitcoin/src/testkit/mod.rs | 1 + .../src/testkit/transaction_mock.rs | 41 + .../testdata/transaction_by_hash.json | 41 + core/crates/gem_bsc/Cargo.toml | 10 + core/crates/gem_bsc/src/lib.rs | 2 + core/crates/gem_bsc/src/stake_hub.rs | 277 + core/crates/gem_cardano/Cargo.toml | 57 + core/crates/gem_cardano/src/address.rs | 86 + core/crates/gem_cardano/src/cbor.rs | 65 + core/crates/gem_cardano/src/lib.rs | 26 + core/crates/gem_cardano/src/models/account.rs | 33 + core/crates/gem_cardano/src/models/block.rs | 42 + core/crates/gem_cardano/src/models/mod.rs | 13 + core/crates/gem_cardano/src/models/rpc.rs | 40 + .../gem_cardano/src/models/transaction.rs | 22 + core/crates/gem_cardano/src/models/utxo.rs | 27 + core/crates/gem_cardano/src/planner.rs | 334 + .../gem_cardano/src/provider/balances.rs | 76 + .../src/provider/balances_mapper.rs | 21 + core/crates/gem_cardano/src/provider/mod.rs | 27 + .../gem_cardano/src/provider/preload.rs | 35 + .../src/provider/preload_mapper.rs | 17 + .../src/provider/request_classifier.rs | 22 + core/crates/gem_cardano/src/provider/state.rs | 18 + .../gem_cardano/src/provider/testkit.rs | 21 + core/crates/gem_cardano/src/provider/token.rs | 15 + .../src/provider/transaction_broadcast.rs | 28 + .../provider/transaction_broadcast_mapper.rs | 25 + .../src/provider/transaction_state.rs | 15 + .../gem_cardano/src/provider/transactions.rs | 27 + .../src/provider/transactions_mapper.rs | 93 + core/crates/gem_cardano/src/rpc/client.rs | 156 + core/crates/gem_cardano/src/rpc/mod.rs | 3 + .../gem_cardano/src/signer/chain_signer.rs | 108 + .../gem_cardano/src/signer/extended_key.rs | 102 + core/crates/gem_cardano/src/signer/mod.rs | 4 + core/crates/gem_cardano/src/transaction.rs | 128 + core/crates/gem_client/Cargo.toml | 18 + core/crates/gem_client/src/client_config.rs | 13 + core/crates/gem_client/src/content_type.rs | 44 + core/crates/gem_client/src/lib.rs | 94 + core/crates/gem_client/src/query.rs | 39 + core/crates/gem_client/src/reqwest_client.rs | 169 + core/crates/gem_client/src/retry.rs | 83 + core/crates/gem_client/src/testkit.rs | 83 + core/crates/gem_client/src/types.rs | 89 + core/crates/gem_cosmos/Cargo.toml | 48 + core/crates/gem_cosmos/src/address.rs | 86 + core/crates/gem_cosmos/src/constants.rs | 34 + core/crates/gem_cosmos/src/lib.rs | 15 + core/crates/gem_cosmos/src/models/account.rs | 30 + core/crates/gem_cosmos/src/models/block.rs | 77 + core/crates/gem_cosmos/src/models/contract.rs | 11 + core/crates/gem_cosmos/src/models/ibc.rs | 16 + core/crates/gem_cosmos/src/models/long.rs | 70 + core/crates/gem_cosmos/src/models/message.rs | 288 + core/crates/gem_cosmos/src/models/mod.rs | 24 + core/crates/gem_cosmos/src/models/staking.rs | 123 + .../gem_cosmos/src/models/staking_osmosis.rs | 25 + .../gem_cosmos/src/models/transaction.rs | 127 + .../gem_cosmos/src/provider/balances.rs | 105 + .../src/provider/balances_mapper.rs | 62 + core/crates/gem_cosmos/src/provider/mod.rs | 19 + .../crates/gem_cosmos/src/provider/preload.rs | 44 + .../gem_cosmos/src/provider/preload_mapper.rs | 105 + .../src/provider/request_classifier.rs | 14 + .../crates/gem_cosmos/src/provider/staking.rs | 133 + .../gem_cosmos/src/provider/staking_mapper.rs | 297 + core/crates/gem_cosmos/src/provider/state.rs | 42 + .../gem_cosmos/src/provider/state_mapper.rs | 36 + .../crates/gem_cosmos/src/provider/testkit.rs | 41 + core/crates/gem_cosmos/src/provider/token.rs | 19 + .../src/provider/transaction_broadcast.rs | 25 + .../provider/transaction_broadcast_mapper.rs | 9 + .../src/provider/transaction_state.rs | 17 + .../src/provider/transaction_state_mapper.rs | 51 + .../gem_cosmos/src/provider/transactions.rs | 60 + .../src/provider/transactions_mapper.rs | 345 + core/crates/gem_cosmos/src/rpc/client.rs | 174 + core/crates/gem_cosmos/src/rpc/mod.rs | 3 + .../gem_cosmos/src/signer/chain_signer.rs | 342 + core/crates/gem_cosmos/src/signer/mod.rs | 4 + .../gem_cosmos/src/signer/transaction.rs | 273 + core/crates/gem_cosmos/testdata/delegate.json | 483 + .../testdata/reverted_transfer_spam.json | 148 + core/crates/gem_cosmos/testdata/rewards.json | 405 + .../testdata/staking_delegations.json | 19 + .../gem_cosmos/testdata/staking_rewards.json | 107 + .../testdata/staking_validators.json | 71 + .../testdata/swap_execute_contract.json | 14 + .../testdata/swap_ibc_transfer.json | 15 + .../transaction_broadcast_failed.json | 17 + .../transaction_broadcast_success.json | 17 + core/crates/gem_cosmos/testdata/transfer.json | 392 + .../gem_cosmos/testdata/transfer_ibc.json | 121 + .../testdata/transfer_thorchain.json | 301 + core/crates/gem_encoding/Cargo.toml | 16 + core/crates/gem_encoding/src/base32.rs | 10 + core/crates/gem_encoding/src/base58.rs | 9 + core/crates/gem_encoding/src/base64.rs | 26 + core/crates/gem_encoding/src/error.rs | 46 + core/crates/gem_encoding/src/lib.rs | 19 + .../gem_encoding/src/protobuf/decode.rs | 223 + .../gem_encoding/src/protobuf/encode.rs | 160 + .../gem_encoding/src/protobuf/field_codec.rs | 202 + core/crates/gem_encoding/src/protobuf/grpc.rs | 46 + .../gem_encoding/src/protobuf/message.rs | 15 + core/crates/gem_encoding/src/protobuf/mod.rs | 15 + core/crates/gem_encoding/src/protobuf/wire.rs | 4 + core/crates/gem_evm/Cargo.toml | 52 + .../src/across/contracts/config_store.rs | 9 + .../gem_evm/src/across/contracts/hub_pool.rs | 36 + .../gem_evm/src/across/contracts/mod.rs | 8 + .../src/across/contracts/multicall_handler.rs | 18 + .../src/across/contracts/spoke_pool.rs | 115 + core/crates/gem_evm/src/across/deployment.rs | 265 + core/crates/gem_evm/src/across/fees/lp.rs | 144 + core/crates/gem_evm/src/across/fees/mod.rs | 33 + .../crates/gem_evm/src/across/fees/relayer.rs | 159 + core/crates/gem_evm/src/across/mod.rs | 3 + core/crates/gem_evm/src/address.rs | 64 + .../gem_evm/src/address_deserializer.rs | 9 + core/crates/gem_evm/src/call_decoder.rs | 219 + core/crates/gem_evm/src/chainlink/contract.rs | 9 + core/crates/gem_evm/src/chainlink/mod.rs | 1 + core/crates/gem_evm/src/constants.rs | 1 + core/crates/gem_evm/src/contracts/erc1155.rs | 9 + core/crates/gem_evm/src/contracts/erc20.rs | 59 + core/crates/gem_evm/src/contracts/erc4626.rs | 9 + core/crates/gem_evm/src/contracts/erc721.rs | 10 + core/crates/gem_evm/src/contracts/mod.rs | 9 + core/crates/gem_evm/src/domain.rs | 78 + core/crates/gem_evm/src/eip712.rs | 377 + core/crates/gem_evm/src/encode.rs | 58 + core/crates/gem_evm/src/ether_conv.rs | 46 + core/crates/gem_evm/src/everstake/client.rs | 106 + .../crates/gem_evm/src/everstake/constants.rs | 5 + .../crates/gem_evm/src/everstake/contracts.rs | 26 + core/crates/gem_evm/src/everstake/mapper.rs | 86 + core/crates/gem_evm/src/everstake/mod.rs | 13 + core/crates/gem_evm/src/everstake/models.rs | 28 + core/crates/gem_evm/src/fee_calculator.rs | 207 + core/crates/gem_evm/src/jsonrpc.rs | 177 + core/crates/gem_evm/src/lib.rs | 56 + core/crates/gem_evm/src/message.rs | 18 + .../gem_evm/src/models/block_parameter.rs | 11 + core/crates/gem_evm/src/models/fee.rs | 14 + core/crates/gem_evm/src/models/mod.rs | 7 + core/crates/gem_evm/src/models/transaction.rs | 11 + core/crates/gem_evm/src/monad/constants.rs | 9 + core/crates/gem_evm/src/monad/contracts.rs | 50 + core/crates/gem_evm/src/monad/mapper.rs | 233 + core/crates/gem_evm/src/monad/mod.rs | 7 + core/crates/gem_evm/src/multicall3.rs | 97 + core/crates/gem_evm/src/permit2.rs | 102 + core/crates/gem_evm/src/provider/accounts.rs | 25 + core/crates/gem_evm/src/provider/balances.rs | 131 + .../gem_evm/src/provider/balances_mapper.rs | 55 + .../src/provider/balances_smartchain.rs | 28 + core/crates/gem_evm/src/provider/mod.rs | 25 + core/crates/gem_evm/src/provider/preload.rs | 313 + .../gem_evm/src/provider/preload_mapper.rs | 655 + .../gem_evm/src/provider/preload_optimism.rs | 155 + .../src/provider/request_classifier.rs | 14 + core/crates/gem_evm/src/provider/staking.rs | 177 + .../gem_evm/src/provider/staking_ethereum.rs | 121 + .../gem_evm/src/provider/staking_monad.rs | 172 + .../src/provider/staking_smartchain.rs | 217 + core/crates/gem_evm/src/provider/state.rs | 99 + .../gem_evm/src/provider/state_mapper.rs | 50 + core/crates/gem_evm/src/provider/testkit.rs | 64 + core/crates/gem_evm/src/provider/token.rs | 93 + .../gem_evm/src/provider/token_mapper.rs | 78 + .../src/provider/transaction_broadcast.rs | 33 + .../provider/transaction_broadcast_mapper.rs | 22 + .../gem_evm/src/provider/transaction_state.rs | 65 + .../src/provider/transaction_state_mapper.rs | 85 + .../gem_evm/src/provider/transactions.rs | 167 + core/crates/gem_evm/src/registry.rs | 318 + core/crates/gem_evm/src/rpc/ankr/client.rs | 54 + core/crates/gem_evm/src/rpc/ankr/mapper.rs | 9 + core/crates/gem_evm/src/rpc/ankr/mod.rs | 7 + core/crates/gem_evm/src/rpc/ankr/model.rs | 65 + core/crates/gem_evm/src/rpc/balance_differ.rs | 206 + core/crates/gem_evm/src/rpc/client.rs | 301 + core/crates/gem_evm/src/rpc/mapper.rs | 527 + core/crates/gem_evm/src/rpc/mod.rs | 9 + core/crates/gem_evm/src/rpc/model.rs | 171 + core/crates/gem_evm/src/rpc/parsers/dex.rs | 30 + core/crates/gem_evm/src/rpc/parsers/mod.rs | 130 + core/crates/gem_evm/src/rpc/parsers/okx.rs | 384 + .../gem_evm/src/rpc/parsers/pancakeswap.rs | 168 + .../src/rpc/parsers/staking/everstake.rs | 108 + .../gem_evm/src/rpc/parsers/staking/mod.rs | 41 + .../gem_evm/src/rpc/parsers/staking/monad.rs | 127 + .../src/rpc/parsers/staking/smartchain.rs | 171 + .../src/rpc/parsers/staking/transaction.rs | 27 + .../src/rpc/parsers/universal_router.rs | 472 + core/crates/gem_evm/src/rpc/parsers/yo.rs | 65 + .../crates/gem_evm/src/signer/chain_signer.rs | 416 + core/crates/gem_evm/src/signer/eip1559.rs | 13 + core/crates/gem_evm/src/signer/mod.rs | 10 + core/crates/gem_evm/src/signer/model.rs | 22 + core/crates/gem_evm/src/signer/transaction.rs | 91 + core/crates/gem_evm/src/siwe.rs | 198 + core/crates/gem_evm/src/slippage.rs | 49 + .../crates/gem_evm/src/testkit/eip712_mock.rs | 77 + core/crates/gem_evm/src/testkit/mod.rs | 11 + core/crates/gem_evm/src/testkit/siwe_mock.rs | 17 + .../crates/gem_evm/src/thorchain/contracts.rs | 7 + core/crates/gem_evm/src/thorchain/mod.rs | 1 + core/crates/gem_evm/src/u256.rs | 15 + core/crates/gem_evm/src/uniswap/actions.rs | 184 + core/crates/gem_evm/src/uniswap/command.rs | 455 + .../gem_evm/src/uniswap/contracts/mod.rs | 17 + .../gem_evm/src/uniswap/contracts/v3.rs | 100 + .../gem_evm/src/uniswap/contracts/v4.rs | 132 + .../gem_evm/src/uniswap/deployment/mod.rs | 67 + .../gem_evm/src/uniswap/deployment/v3.rs | 199 + .../gem_evm/src/uniswap/deployment/v4.rs | 98 + core/crates/gem_evm/src/uniswap/mod.rs | 64 + core/crates/gem_evm/src/uniswap/path.rs | 281 + core/crates/gem_evm/src/weth.rs | 8 + .../crates/gem_evm/testdata/1inch_permit.json | 31 + core/crates/gem_evm/testdata/approve.json | 26 + .../gem_evm/testdata/approve_receipt.json | 36 + .../testdata/claim_rewards_receipt.json | 50 + .../gem_evm/testdata/claim_rewards_tx.json | 21 + .../gem_evm/testdata/contract_call_tx.json | 128 + .../testdata/contract_call_tx_receipt.json | 82 + .../testdata/contract_erc20_receipt.json | 53 + .../gem_evm/testdata/contract_erc20_tx.json | 22 + .../eip712_domain_chain_id_null_value.json | 18 + .../eip712_domain_chain_id_schema_string.json | 17 + ..._domain_chain_id_without_schema_field.json | 18 + .../eip712_int8_account_registration.json | 19 + ..._schema_chain_id_without_domain_value.json | 17 + .../gem_evm/testdata/ens_upload_avatar.json | 25 + .../testdata/everstake/transaction_stake.json | 26 + .../everstake/transaction_stake_receipt.json | 77 + .../everstake/transaction_unstake.json | 26 + .../transaction_unstake_receipt.json | 49 + .../everstake/transaction_withdraw.json | 26 + .../transaction_withdraw_receipt.json | 35 + .../everstake/transaction_withdraw_trace.json | 68 + .../testdata/mayan_native_swap_tx.json | 14 + .../mayan_native_swap_tx_receipt.json | 46 + .../gem_evm/testdata/mayan_token_swap_tx.json | 14 + .../testdata/mayan_token_swap_tx_receipt.json | 54 + .../transaction_staking_claim_rewards.json | 26 + ...saction_staking_claim_rewards_receipt.json | 37 + .../monad/transaction_staking_delegate.json | 26 + .../transaction_staking_delegate_receipt.json | 37 + .../monad/transaction_staking_undelegate.json | 26 + ...ransaction_staking_undelegate_receipt.json | 37 + .../monad/transaction_staking_withdraw.json | 26 + .../transaction_staking_withdraw_receipt.json | 37 + .../gem_evm/testdata/okx_base_swap_tx.json | 13 + .../testdata/okx_base_swap_tx_receipt.json | 22 + .../gem_evm/testdata/okx_bsc_swap_tx.json | 13 + .../testdata/okx_bsc_swap_tx_receipt.json | 39 + .../testdata/okx_bsc_swap_tx_trace.json | 18 + .../testdata/pancakeswap_bsc_bnb_cake_tx.json | 13 + .../pancakeswap_bsc_bnb_cake_tx_receipt.json | 23 + .../pancakeswap_bsc_native_swap_tx.json | 13 + ...ancakeswap_bsc_native_swap_tx_receipt.json | 23 + .../pancakeswap_bsc_native_swap_tx_trace.json | 18 + .../testdata/pancakeswap_bsc_swap_tx.json | 13 + .../pancakeswap_bsc_swap_tx_receipt.json | 43 + .../transaction_staking_claim_rewards.json | 13 + ...saction_staking_claim_rewards_receipt.json | 23 + .../transaction_staking_delegate.json | 13 + .../transaction_staking_delegate_receipt.json | 23 + .../transaction_staking_redelegate.json | 13 + ...ransaction_staking_redelegate_receipt.json | 24 + .../transaction_staking_undelegate.json | 13 + ...ransaction_staking_undelegate_receipt.json | 23 + .../gem_evm/testdata/trace_replay_tx.json | 22 + .../testdata/trace_replay_tx_receipt.json | 156 + .../testdata/trace_replay_tx_trace.json | 107 + .../gem_evm/testdata/transfer_erc20.json | 25 + .../testdata/transfer_erc20_receipt.json | 39 + .../testdata/transfer_high_gas_limit.json | 22 + .../transfer_high_gas_limit_receipt.json | 20 + .../testdata/transfer_nft_eip1155.json | 26 + .../transfer_nft_eip1155_receipt.json | 37 + .../gem_evm/testdata/transfer_nft_eip721.json | 26 + .../testdata/transfer_nft_eip721_receipt.json | 53 + .../gem_evm/testdata/uniswap_permit2.json | 66 + .../gem_evm/testdata/v2_token_eth_tx.json | 26 + .../testdata/v2_token_eth_tx_receipt.json | 108 + .../testdata/v2_token_eth_tx_trace.json | 107 + .../gem_evm/testdata/v3_eth_token_tx.json | 26 + .../testdata/v3_eth_token_tx_receipt.json | 95 + .../gem_evm/testdata/v3_pol_usdt_tx.json | 25 + .../testdata/v3_pol_usdt_tx_receipt.json | 158 + .../gem_evm/testdata/v3_token_eth_tx.json | 26 + .../testdata/v3_token_eth_tx_receipt.json | 117 + .../testdata/v3_usdc_paxg_receipt.json | 112 + .../gem_evm/testdata/v3_usdc_paxg_tx.json | 26 + .../gem_evm/testdata/v4_eth_dai_tx.json | 26 + .../testdata/v4_eth_dai_tx_receipt.json | 57 + .../gem_evm/testdata/v4_usdc_eth_tx.json | 26 + .../testdata/v4_usdc_eth_tx_receipt.json | 73 + .../gem_evm/testdata/yo_deposit_receipt.json | 127 + .../gem_evm/testdata/yo_deposit_tx.json | 24 + .../gem_evm/testdata/yo_withdraw_receipt.json | 114 + .../gem_evm/testdata/yo_withdraw_tx.json | 24 + core/crates/gem_hash/Cargo.toml | 10 + core/crates/gem_hash/src/blake2.rs | 25 + core/crates/gem_hash/src/keccak.rs | 16 + core/crates/gem_hash/src/lib.rs | 5 + core/crates/gem_hash/src/message.rs | 6 + core/crates/gem_hash/src/sha2.rs | 31 + core/crates/gem_hash/src/sha3.rs | 11 + core/crates/gem_hypercore/Cargo.toml | 39 + core/crates/gem_hypercore/src/agent.rs | 175 + core/crates/gem_hypercore/src/config.rs | 18 + .../src/core/actions/agent/mod.rs | 7 + .../src/core/actions/agent/order.rs | 215 + .../src/core/actions/agent/set_referrer.rs | 19 + .../src/core/actions/agent/update_leverage.rs | 45 + .../gem_hypercore/src/core/actions/mod.rs | 8 + .../src/core/actions/user/approve_agent.rs | 29 + .../core/actions/user/approve_builder_fee.rs | 28 + .../src/core/actions/user/c_deposit.rs | 25 + .../src/core/actions/user/c_withdraw.rs | 25 + .../src/core/actions/user/cancel_order.rs | 33 + .../src/core/actions/user/mod.rs | 21 + .../src/core/actions/user/spot_send.rs | 29 + .../src/core/actions/user/token_delegate.rs | 30 + .../core/actions/user/usd_class_transfer.rs | 28 + .../src/core/actions/user/usd_send.rs | 27 + .../src/core/actions/user/withdrawal.rs | 27 + core/crates/gem_hypercore/src/core/eip712.rs | 266 + core/crates/gem_hypercore/src/core/hahser.rs | 32 + .../gem_hypercore/src/core/hypercore.rs | 408 + core/crates/gem_hypercore/src/core/mod.rs | 5 + core/crates/gem_hypercore/src/core/models.rs | 17 + core/crates/gem_hypercore/src/lib.rs | 23 + .../crates/gem_hypercore/src/models/action.rs | 44 + .../gem_hypercore/src/models/balance.rs | 89 + .../gem_hypercore/src/models/candlestick.rs | 40 + .../gem_hypercore/src/models/metadata.rs | 74 + core/crates/gem_hypercore/src/models/mod.rs | 18 + core/crates/gem_hypercore/src/models/order.rs | 88 + .../gem_hypercore/src/models/perp_dex.rs | 8 + .../gem_hypercore/src/models/portfolio.rs | 46 + .../gem_hypercore/src/models/position.rs | 70 + .../gem_hypercore/src/models/referral.rs | 57 + .../gem_hypercore/src/models/response.rs | 193 + .../gem_hypercore/src/models/spot/mod.rs | 5 + .../src/models/spot/orderbook.rs | 12 + .../src/models/spot/spot_market.rs | 15 + .../gem_hypercore/src/models/timestamp.rs | 8 + core/crates/gem_hypercore/src/models/token.rs | 45 + .../src/models/transaction_id.rs | 158 + core/crates/gem_hypercore/src/models/user.rs | 100 + .../gem_hypercore/src/models/websocket.rs | 183 + .../gem_hypercore/src/perpetual_formatter.rs | 138 + .../gem_hypercore/src/provider/balances.rs | 106 + .../src/provider/balances_mapper.rs | 150 + .../src/provider/fee_calculator.rs | 100 + core/crates/gem_hypercore/src/provider/mod.rs | 31 + .../gem_hypercore/src/provider/perpetual.rs | 503 + .../src/provider/perpetual_mapper.rs | 907 + .../gem_hypercore/src/provider/preload.rs | 121 + .../src/provider/preload_cache.rs | 150 + .../src/provider/preload_mapper.rs | 25 + .../src/provider/request_classifier.rs | 14 + .../gem_hypercore/src/provider/staking.rs | 28 + .../src/provider/staking_mapper.rs | 147 + .../gem_hypercore/src/provider/state.rs | 18 + .../gem_hypercore/src/provider/testkit.rs | 32 + .../gem_hypercore/src/provider/token.rs | 51 + .../src/provider/transaction_broadcast.rs | 148 + .../src/provider/transaction_state.rs | 97 + .../src/provider/transaction_state_mapper.rs | 539 + .../src/provider/transactions.rs | 151 + .../src/provider/transactions_mapper.rs | 346 + .../src/provider/websocket_mapper.rs | 230 + core/crates/gem_hypercore/src/rpc/client.rs | 331 + core/crates/gem_hypercore/src/rpc/mod.rs | 1 + .../gem_hypercore/src/signer/core_signer.rs | 525 + core/crates/gem_hypercore/src/signer/mod.rs | 3 + core/crates/gem_hypercore/src/testkit.rs | 100 + .../delegator_history_staking_actions.json | 43 + .../testdata/hl_action_c_withdraw.json | 16 + .../testdata/hl_action_cancel_orders.json | 22 + .../testdata/hl_action_core_to_evm.json | 18 + .../hl_action_market_short_tp_sl.json | 55 + .../testdata/hl_action_open_long_order.json | 27 + .../testdata/hl_action_open_short_order.json | 27 + .../testdata/hl_action_perp_to_spot.json | 17 + .../testdata/hl_action_set_referrer.json | 13 + .../testdata/hl_action_spot_send_l1.json | 18 + .../testdata/hl_action_spot_to_perps.json | 17 + .../testdata/hl_action_spot_to_stake.json | 16 + .../hl_action_stake_to_validator.json | 18 + .../hl_action_update_position_tp_sl.json | 43 + .../testdata/hl_eip712_approve_agent.json | 55 + .../testdata/hl_eip712_c_withdraw.json | 50 + .../testdata/hl_eip712_core_to_evm.json | 60 + .../testdata/hl_eip712_perp_send_l1.json | 55 + .../testdata/hl_eip712_perp_to_spot.json | 55 + .../testdata/hl_eip712_spot_send_l1.json | 60 + .../hl_eip712_spot_to_stake_balance.json | 50 + .../hl_eip712_stake_to_validator.json | 60 + .../testdata/hl_eip712_withdraw.json | 55 + .../testdata/order_broadcast_error.json | 13 + .../testdata/order_broadcast_filled.json | 17 + .../testdata/order_broadcast_resting.json | 14 + .../order_broadcast_simple_error.json | 3 + ...positions_request_clearinghouse_state.json | 4 + ...ions_request_clearinghouse_state_dex1.json | 5 + ...ions_request_clearinghouse_state_dex2.json | 5 + ...rpetual_positions_request_open_orders.json | 4 + ...al_positions_request_open_orders_dex1.json | 5 + ...al_positions_request_open_orders_dex2.json | 5 + ...perpetual_positions_request_perp_dexs.json | 3 + ...ositions_response_clearinghouse_state.json | 40 + ...ons_response_clearinghouse_state_dex1.json | 40 + ...ons_response_clearinghouse_state_dex2.json | 17 + ...petual_positions_response_open_orders.json | 10 + ...l_positions_response_open_orders_dex1.json | 10 + ...erpetual_positions_response_perp_dexs.json | 11 + ...ons_response_user_abstraction_default.json | 1 + .../referral_need_to_create_code.json | 14 + .../testdata/referral_need_to_trade.json | 10 + .../testdata/spot_meta_spot_swap.json | 29 + .../testdata/staking_delegations.json | 12 + ...ansaction_broadcast_error_extra_agent.json | 4 + .../testdata/user_fills_liquidation.json | 24 + .../testdata/user_fills_multiple.json | 56 + .../testdata/user_fills_shared_hash.json | 36 + .../testdata/user_fills_spot_swap.json | 20 + .../testdata/user_fills_spot_swap_buy.json | 19 + ...on_funding_ledger_updates_action_hash.json | 10 + ...ing_ledger_updates_c_staking_transfer.json | 12 + ..._funding_ledger_updates_spot_transfer.json | 18 + .../testdata/ws_active_asset_ctx.json | 15 + .../gem_hypercore/testdata/ws_all_mids.json | 1 + .../gem_hypercore/testdata/ws_candle.json | 1 + .../testdata/ws_clearinghouse_state.json | 1 + .../testdata/ws_open_orders.json | 1 + core/crates/gem_jsonrpc/Cargo.toml | 24 + core/crates/gem_jsonrpc/src/alien.rs | 64 + core/crates/gem_jsonrpc/src/client.rs | 145 + core/crates/gem_jsonrpc/src/grpc.rs | 108 + core/crates/gem_jsonrpc/src/lib.rs | 17 + core/crates/gem_jsonrpc/src/rpc.rs | 200 + core/crates/gem_jsonrpc/src/testkit.rs | 66 + core/crates/gem_jsonrpc/src/types.rs | 258 + core/crates/gem_near/Cargo.toml | 38 + core/crates/gem_near/src/address.rs | 38 + core/crates/gem_near/src/constants.rs | 3 + core/crates/gem_near/src/lib.rs | 17 + core/crates/gem_near/src/models/account.rs | 14 + core/crates/gem_near/src/models/block.rs | 24 + core/crates/gem_near/src/models/fee.rs | 8 + core/crates/gem_near/src/models/mod.rs | 10 + core/crates/gem_near/src/models/rpc.rs | 48 + .../crates/gem_near/src/models/transaction.rs | 40 + core/crates/gem_near/src/provider/balances.rs | 66 + .../gem_near/src/provider/balances_mapper.rs | 26 + core/crates/gem_near/src/provider/mod.rs | 22 + core/crates/gem_near/src/provider/preload.rs | 37 + .../gem_near/src/provider/preload_mapper.rs | 51 + .../src/provider/request_classifier.rs | 14 + core/crates/gem_near/src/provider/state.rs | 66 + .../gem_near/src/provider/state_mapper.rs | 63 + core/crates/gem_near/src/provider/testkit.rs | 18 + .../src/provider/transaction_broadcast.rs | 25 + .../provider/transaction_broadcast_mapper.rs | 11 + .../src/provider/transaction_state.rs | 16 + .../src/provider/transaction_state_mapper.rs | 78 + .../gem_near/src/provider/transactions.rs | 19 + .../src/provider/transactions_mapper.rs | 100 + core/crates/gem_near/src/rpc/client.rs | 79 + core/crates/gem_near/src/rpc/mod.rs | 3 + .../gem_near/src/signer/chain_signer.rs | 39 + core/crates/gem_near/src/signer/mod.rs | 6 + core/crates/gem_near/src/signer/models.rs | 27 + .../gem_near/src/signer/serialization.rs | 24 + core/crates/gem_near/src/signer/signing.rs | 18 + .../gem_near/testdata/balance_coin.json | 13 + .../testdata/successful_transaction.json | 164 + .../testdata/transaction_transfer_error.json | 18 + .../transaction_transfer_success.json | 102 + core/crates/gem_polkadot/Cargo.toml | 40 + core/crates/gem_polkadot/src/address.rs | 98 + core/crates/gem_polkadot/src/constants.rs | 2 + core/crates/gem_polkadot/src/lib.rs | 16 + .../crates/gem_polkadot/src/models/account.rs | 13 + core/crates/gem_polkadot/src/models/block.rs | 18 + core/crates/gem_polkadot/src/models/fee.rs | 22 + core/crates/gem_polkadot/src/models/mod.rs | 11 + core/crates/gem_polkadot/src/models/rpc.rs | 92 + .../gem_polkadot/src/models/transaction.rs | 36 + .../gem_polkadot/src/provider/balances.rs | 55 + .../src/provider/balances_mapper.rs | 32 + core/crates/gem_polkadot/src/provider/mod.rs | 21 + .../gem_polkadot/src/provider/preload.rs | 45 + .../src/provider/request_classifier.rs | 14 + .../gem_polkadot/src/provider/staking.rs | 23 + .../crates/gem_polkadot/src/provider/state.rs | 42 + .../gem_polkadot/src/provider/testkit.rs | 16 + .../crates/gem_polkadot/src/provider/token.rs | 19 + .../src/provider/transaction_broadcast.rs | 28 + .../provider/transaction_broadcast_mapper.rs | 17 + .../src/provider/transaction_state.rs | 43 + .../src/provider/transaction_state_mapper.rs | 71 + .../gem_polkadot/src/provider/transactions.rs | 41 + .../src/provider/transactions_mapper.rs | 119 + core/crates/gem_polkadot/src/rpc/client.rs | 70 + core/crates/gem_polkadot/src/rpc/mod.rs | 3 + .../gem_polkadot/src/signer/chain_signer.rs | 93 + core/crates/gem_polkadot/src/signer/mod.rs | 3 + .../src/testdata/balance_coin.json | 15 + core/crates/gem_polkadot/src/transfer.rs | 330 + core/crates/gem_rewards/Cargo.toml | 24 + .../gem_rewards/src/abuseipdb/client.rs | 47 + core/crates/gem_rewards/src/abuseipdb/mod.rs | 4 + .../crates/gem_rewards/src/abuseipdb/model.rs | 39 + core/crates/gem_rewards/src/error.rs | 154 + .../gem_rewards/src/ip_check_provider.rs | 11 + .../gem_rewards/src/ip_security_client.rs | 85 + core/crates/gem_rewards/src/ipapi/client.rs | 45 + core/crates/gem_rewards/src/ipapi/mod.rs | 4 + core/crates/gem_rewards/src/ipapi/model.rs | 181 + core/crates/gem_rewards/src/lib.rs | 24 + core/crates/gem_rewards/src/model.rs | 13 + core/crates/gem_rewards/src/redemption.rs | 11 + .../gem_rewards/src/redemption_service.rs | 22 + .../gem_rewards/src/risk_scoring/client.rs | 163 + .../gem_rewards/src/risk_scoring/mod.rs | 6 + .../gem_rewards/src/risk_scoring/model.rs | 258 + .../gem_rewards/src/risk_scoring/scoring.rs | 1110 + .../src/transfer_provider/evm/mod.rs | 3 + .../src/transfer_provider/evm/provider.rs | 114 + .../gem_rewards/src/transfer_provider/mod.rs | 10 + .../src/transfer_redemption_service.rs | 22 + core/crates/gem_solana/Cargo.toml | 47 + core/crates/gem_solana/src/address.rs | 52 + core/crates/gem_solana/src/constants.rs | 1 + core/crates/gem_solana/src/hash.rs | 125 + core/crates/gem_solana/src/jsonrpc.rs | 48 + core/crates/gem_solana/src/lib.rs | 91 + .../gem_solana/src/metaplex/collection.rs | 14 + core/crates/gem_solana/src/metaplex/data.rs | 23 + .../gem_solana/src/metaplex/metadata.rs | 89 + core/crates/gem_solana/src/metaplex/mod.rs | 79 + core/crates/gem_solana/src/metaplex/uses.rs | 16 + core/crates/gem_solana/src/metaplex_core.rs | 69 + core/crates/gem_solana/src/models/balances.rs | 12 + core/crates/gem_solana/src/models/block.rs | 74 + .../crates/gem_solana/src/models/blockhash.rs | 13 + core/crates/gem_solana/src/models/jito.rs | 36 + core/crates/gem_solana/src/models/mod.rs | 20 + .../src/models/prioritization_fee.rs | 9 + core/crates/gem_solana/src/models/rpc.rs | 75 + core/crates/gem_solana/src/models/stake.rs | 25 + core/crates/gem_solana/src/models/token.rs | 127 + .../gem_solana/src/models/token_account.rs | 79 + .../gem_solana/src/models/transaction.rs | 259 + core/crates/gem_solana/src/models/value.rs | 3 + .../gem_solana/src/provider/balances.rs | 119 + .../src/provider/balances_mapper.rs | 112 + core/crates/gem_solana/src/provider/mod.rs | 33 + .../crates/gem_solana/src/provider/preload.rs | 222 + .../gem_solana/src/provider/preload_mapper.rs | 378 + .../src/provider/request_classifier.rs | 14 + .../crates/gem_solana/src/provider/staking.rs | 70 + .../gem_solana/src/provider/staking_mapper.rs | 153 + core/crates/gem_solana/src/provider/state.rs | 65 + .../gem_solana/src/provider/state_mapper.rs | 21 + .../crates/gem_solana/src/provider/testkit.rs | 21 + core/crates/gem_solana/src/provider/token.rs | 76 + .../gem_solana/src/provider/token_mapper.rs | 62 + .../src/provider/transaction_broadcast.rs | 25 + .../provider/transaction_broadcast_mapper.rs | 7 + .../src/provider/transaction_mapper.rs | 548 + .../src/provider/transaction_state.rs | 62 + .../gem_solana/src/provider/transactions.rs | 86 + core/crates/gem_solana/src/rpc/client.rs | 282 + core/crates/gem_solana/src/rpc/constants.rs | 6 + core/crates/gem_solana/src/rpc/mod.rs | 5 + .../gem_solana/src/signer/chain_signer.rs | 156 + .../gem_solana/src/signer/instructions/mod.rs | 10 + .../src/signer/instructions/nft_transfer.rs | 332 + .../src/signer/instructions/stake.rs | 180 + .../src/signer/instructions/stake_account.rs | 224 + .../src/signer/instructions/token_transfer.rs | 191 + .../src/signer/instructions/transfer.rs | 95 + core/crates/gem_solana/src/signer/mod.rs | 8 + core/crates/gem_solana/src/signer/swap.rs | 99 + core/crates/gem_solana/src/signer/testkit.rs | 61 + .../gem_solana/src/signer/transaction.rs | 233 + core/crates/gem_solana/src/token_account.rs | 65 + core/crates/gem_solana/src/transaction.rs | 98 + .../gem_solana/testdata/balance_coin.json | 11 + .../testdata/balance_spl_token.json | 41 + .../gem_solana/testdata/balance_staking.json | 293 + .../testdata/chainflip_vault_swap.json | 145 + .../testdata/nft_mplcore_transfer.json | 102 + .../testdata/nft_token_program_transfer.json | 275 + .../gem_solana/testdata/pyusd_mint.json | 105 + .../testdata/swap_okx_token_to_token.json | 619 + .../testdata/swap_sol_to_token.json | 769 + .../testdata/swap_token_to_sol.json | 525 + .../testdata/swap_token_to_token.json | 872 + .../transaction_broadcast_swap_error.json | 81 + ...on_reverted_program_account_not_found.json | 35 + .../transaction_state_pending_not_found.json | 5 + ...te_reverted_program_account_not_found.json | 14 + .../transaction_state_transfer_sol.json | 91 + .../gem_solana/testdata/transfer_sol.json | 69 + .../testdata/transfer_sol_with_compute.json | 87 + .../crates/gem_solana/testdata/usdc_mint.json | 31 + .../gem_solana/testdata/usdc_transfer.json | 222 + .../testdata/usdc_transfer_fee_payer.json | 158 + core/crates/gem_stellar/Cargo.toml | 38 + core/crates/gem_stellar/src/address.rs | 88 + core/crates/gem_stellar/src/constants.rs | 4 + core/crates/gem_stellar/src/lib.rs | 17 + core/crates/gem_stellar/src/models/account.rs | 22 + core/crates/gem_stellar/src/models/block.rs | 10 + core/crates/gem_stellar/src/models/common.rs | 37 + core/crates/gem_stellar/src/models/fee.rs | 17 + core/crates/gem_stellar/src/models/mod.rs | 17 + core/crates/gem_stellar/src/models/node.rs | 15 + .../gem_stellar/src/models/signing/asset.rs | 45 + .../gem_stellar/src/models/signing/mod.rs | 7 + .../src/models/signing/operation.rs | 36 + .../src/models/signing/transaction.rs | 80 + .../gem_stellar/src/models/transaction.rs | 100 + .../gem_stellar/src/provider/balances.rs | 85 + .../src/provider/balances_mapper.rs | 112 + core/crates/gem_stellar/src/provider/mod.rs | 18 + .../gem_stellar/src/provider/preload.rs | 142 + .../src/provider/preload_mapper.rs | 55 + .../src/provider/request_classifier.rs | 14 + core/crates/gem_stellar/src/provider/state.rs | 18 + .../gem_stellar/src/provider/state_mapper.rs | 42 + .../gem_stellar/src/provider/testkit.rs | 20 + core/crates/gem_stellar/src/provider/token.rs | 52 + .../gem_stellar/src/provider/token_mapper.rs | 36 + .../src/provider/transaction_broadcast.rs | 25 + .../provider/transaction_broadcast_mapper.rs | 9 + .../src/provider/transaction_state.rs | 16 + .../src/provider/transaction_state_mapper.rs | 45 + .../gem_stellar/src/provider/transactions.rs | 88 + .../src/provider/transactions_mapper.rs | 128 + core/crates/gem_stellar/src/rpc/client.rs | 131 + core/crates/gem_stellar/src/rpc/mod.rs | 3 + .../gem_stellar/src/signer/chain_signer.rs | 149 + core/crates/gem_stellar/src/signer/mod.rs | 5 + .../gem_stellar/src/signer/serialization.rs | 111 + core/crates/gem_stellar/src/signer/signing.rs | 31 + core/crates/gem_stellar/testdata/balance.json | 85 + .../gem_stellar/testdata/balance_coin.json | 71 + core/crates/gem_stellar/testdata/fees.json | 37 + .../testdata/transaction_by_hash.json | 24 + .../testdata/transaction_state_error.json | 50 + .../testdata/transaction_state_success.json | 50 + .../transaction_transfer_broadcast_error.json | 13 + ..._transfer_broadcast_error_low_reserve.json | 16 + ...ransaction_transfer_broadcast_success.json | 50 + core/crates/gem_sui/Cargo.toml | 44 + core/crates/gem_sui/src/address.rs | 57 + core/crates/gem_sui/src/coin_type.rs | 68 + core/crates/gem_sui/src/error.rs | 33 + core/crates/gem_sui/src/gas_budget.rs | 19 + core/crates/gem_sui/src/lib.rs | 83 + core/crates/gem_sui/src/models/account.rs | 28 + core/crates/gem_sui/src/models/coin.rs | 101 + core/crates/gem_sui/src/models/core.rs | 93 + core/crates/gem_sui/src/models/inspect.rs | 47 + core/crates/gem_sui/src/models/mod.rs | 26 + core/crates/gem_sui/src/models/object_id.rs | 17 + core/crates/gem_sui/src/models/staking.rs | 73 + core/crates/gem_sui/src/models/testkit.rs | 28 + core/crates/gem_sui/src/models/transaction.rs | 123 + core/crates/gem_sui/src/provider/accounts.rs | 24 + core/crates/gem_sui/src/provider/balances.rs | 90 + .../gem_sui/src/provider/balances_mapper.rs | 137 + core/crates/gem_sui/src/provider/mod.rs | 21 + core/crates/gem_sui/src/provider/preload.rs | 196 + .../gem_sui/src/provider/preload_mapper.rs | 168 + .../src/provider/request_classifier.rs | 35 + core/crates/gem_sui/src/provider/staking.rs | 58 + .../gem_sui/src/provider/staking_mapper.rs | 54 + core/crates/gem_sui/src/provider/state.rs | 64 + .../gem_sui/src/provider/state_mapper.rs | 21 + core/crates/gem_sui/src/provider/testkit.rs | 23 + core/crates/gem_sui/src/provider/token.rs | 39 + .../gem_sui/src/provider/token_mapper.rs | 40 + .../src/provider/transaction_broadcast.rs | 39 + .../provider/transaction_broadcast_mapper.rs | 52 + .../gem_sui/src/provider/transaction_state.rs | 16 + .../src/provider/transaction_state_mapper.rs | 45 + .../gem_sui/src/provider/transactions.rs | 99 + .../src/provider/transactions_mapper.rs | 432 + core/crates/gem_sui/src/rpc/client.rs | 424 + core/crates/gem_sui/src/rpc/mapper.rs | 178 + core/crates/gem_sui/src/rpc/mod.rs | 7 + core/crates/gem_sui/src/rpc/proto/balances.rs | 92 + core/crates/gem_sui/src/rpc/proto/bcs.rs | 34 + .../gem_sui/src/rpc/proto/checkpoints.rs | 106 + core/crates/gem_sui/src/rpc/proto/field.rs | 39 + core/crates/gem_sui/src/rpc/proto/json.rs | 120 + core/crates/gem_sui/src/rpc/proto/mod.rs | 42 + .../gem_sui/src/rpc/proto/move_package.rs | 76 + core/crates/gem_sui/src/rpc/proto/objects.rs | 188 + core/crates/gem_sui/src/rpc/proto/service.rs | 115 + core/crates/gem_sui/src/rpc/proto/status.rs | 15 + .../crates/gem_sui/src/rpc/proto/timestamp.rs | 21 + .../rpc/proto/transaction_data/argument.rs | 55 + .../src/rpc/proto/transaction_data/command.rs | 228 + .../src/rpc/proto/transaction_data/input.rs | 132 + .../src/rpc/proto/transaction_data/mod.rs | 11 + .../rpc/proto/transaction_data/signature.rs | 27 + .../rpc/proto/transaction_data/transaction.rs | 107 + .../gem_sui/src/rpc/proto/transactions.rs | 308 + core/crates/gem_sui/src/rpc/staking.rs | 357 + core/crates/gem_sui/src/rpc/transport.rs | 15 + .../crates/gem_sui/src/signer/chain_signer.rs | 57 + core/crates/gem_sui/src/signer/mod.rs | 5 + core/crates/gem_sui/src/signer/signature.rs | 62 + core/crates/gem_sui/src/transfer_builder.rs | 112 + core/crates/gem_sui/src/tx_builder/balance.rs | 38 + core/crates/gem_sui/src/tx_builder/input.rs | 52 + core/crates/gem_sui/src/tx_builder/mod.rs | 23 + .../gem_sui/src/tx_builder/object_resolver.rs | 129 + .../crates/gem_sui/src/tx_builder/prefetch.rs | 54 + core/crates/gem_sui/src/tx_builder/stake.rs | 173 + .../gem_sui/src/tx_builder/transaction.rs | 132 + .../tx_builder/transaction_json/builder.rs | 154 + .../src/tx_builder/transaction_json/mod.rs | 15 + .../src/tx_builder/transaction_json/model.rs | 182 + .../src/tx_builder/transaction_json/replay.rs | 60 + .../tx_builder/transaction_json/resolver.rs | 131 + .../crates/gem_sui/src/tx_builder/transfer.rs | 280 + .../crates/gem_sui/testdata/balance_coin.json | 6 + .../gem_sui/testdata/balance_tokens.json | 50 + .../mayan_mctp_sui_usdc_to_arbitrum_usdc.json | 79 + .../testdata/sponsored_transfer_sui.json | 100 + core/crates/gem_sui/testdata/stake_grpc.json | 42 + core/crates/gem_sui/testdata/stakes.json | 60 + .../testdata/transaction_builder_json.json | 47 + .../crates/gem_sui/testdata/transfer_sui.json | 79 + .../testdata/transfer_token_contract.json | 56 + core/crates/gem_ton/Cargo.toml | 45 + core/crates/gem_ton/src/address.rs | 288 + core/crates/gem_ton/src/constants.rs | 24 + core/crates/gem_ton/src/lib.rs | 18 + core/crates/gem_ton/src/models/account.rs | 6 + core/crates/gem_ton/src/models/balance.rs | 64 + core/crates/gem_ton/src/models/block.rs | 21 + core/crates/gem_ton/src/models/fee.rs | 19 + core/crates/gem_ton/src/models/mod.rs | 15 + core/crates/gem_ton/src/models/nft.rs | 46 + core/crates/gem_ton/src/models/rpc.rs | 174 + core/crates/gem_ton/src/models/transaction.rs | 199 + core/crates/gem_ton/src/provider/balances.rs | 81 + .../gem_ton/src/provider/balances_mapper.rs | 62 + core/crates/gem_ton/src/provider/mod.rs | 16 + core/crates/gem_ton/src/provider/preload.rs | 323 + .../src/provider/request_classifier.rs | 14 + core/crates/gem_ton/src/provider/state.rs | 61 + .../gem_ton/src/provider/state_mapper.rs | 30 + core/crates/gem_ton/src/provider/testkit.rs | 75 + core/crates/gem_ton/src/provider/token.rs | 48 + .../src/provider/transaction_broadcast.rs | 25 + .../provider/transaction_broadcast_mapper.rs | 9 + .../gem_ton/src/provider/transaction_state.rs | 61 + .../src/provider/transaction_state_mapper.rs | 107 + .../gem_ton/src/provider/transactions.rs | 65 + .../src/provider/transactions_mapper.rs | 497 + core/crates/gem_ton/src/rpc/client.rs | 185 + core/crates/gem_ton/src/rpc/mod.rs | 3 + .../crates/gem_ton/src/signer/chain_signer.rs | 35 + core/crates/gem_ton/src/signer/mod.rs | 12 + .../gem_ton/src/signer/sign_data/message.rs | 153 + .../gem_ton/src/signer/sign_data/mod.rs | 8 + .../gem_ton/src/signer/sign_data/payload.rs | 50 + .../gem_ton/src/signer/sign_data/response.rs | 32 + .../gem_ton/src/signer/sign_data/sign.rs | 102 + core/crates/gem_ton/src/signer/signer.rs | 40 + core/crates/gem_ton/src/signer/testkit.rs | 61 + .../gem_ton/src/signer/transaction/message.rs | 103 + .../gem_ton/src/signer/transaction/mod.rs | 6 + .../gem_ton/src/signer/transaction/request.rs | 86 + .../gem_ton/src/signer/transaction/sign.rs | 260 + .../gem_ton/src/signer/transaction/wallet.rs | 96 + .../transaction/wallet_v4r2_code.boc.b64 | 1 + core/crates/gem_ton/src/tvm/bag.rs | 156 + core/crates/gem_ton/src/tvm/builder.rs | 182 + core/crates/gem_ton/src/tvm/cell.rs | 78 + core/crates/gem_ton/src/tvm/error.rs | 24 + core/crates/gem_ton/src/tvm/header.rs | 81 + core/crates/gem_ton/src/tvm/indexed_cell.rs | 73 + core/crates/gem_ton/src/tvm/mod.rs | 20 + core/crates/gem_ton/src/tvm/raw_cell.rs | 106 + core/crates/gem_ton/src/tvm/reader.rs | 99 + core/crates/gem_ton/src/tvm/writer.rs | 60 + .../gem_ton/testdata/balance_jettons.json | 336 + .../crates/gem_ton/testdata/block_traces.json | 86 + ...etton_swap_from_jetton_transfer_trace.json | 67 + .../gem_ton/testdata/jetton_swap_trace.json | 55 + .../testdata/jetton_transfer_trace.json | 54 + .../gem_ton/testdata/nft_transfer_trace.json | 902 + .../testdata/transaction_null_values.json | 151 + .../testdata/transaction_status_response.json | 141 + .../transaction_swap_jetton_ton_success.json | 141 + .../transaction_swap_ton_jetton_success.json | 141 + .../transaction_transfer_jetton_error.json | 147 + .../transaction_transfer_jetton_error_2.json | 181 + .../transaction_transfer_jetton_success.json | 141 + ...transaction_transfer_jetton_success_2.json | 141 + .../transaction_transfer_state_success.json | 141 + .../testdata/wc_sign_data_response.json | 10 + core/crates/gem_tron/Cargo.toml | 62 + core/crates/gem_tron/src/address/mod.rs | 185 + .../crates/gem_tron/src/address/serializer.rs | 34 + core/crates/gem_tron/src/lib.rs | 13 + core/crates/gem_tron/src/models/account.rs | 120 + core/crates/gem_tron/src/models/block.rs | 26 + core/crates/gem_tron/src/models/chain.rs | 25 + core/crates/gem_tron/src/models/contract.rs | 98 + .../gem_tron/src/models/contract_type.rs | 61 + core/crates/gem_tron/src/models/mod.rs | 238 + .../gem_tron/src/models/signing/contract.rs | 330 + .../crates/gem_tron/src/models/signing/mod.rs | 10 + .../gem_tron/src/models/signing/protobuf.rs | 238 + .../gem_tron/src/models/signing/raw_data.rs | 176 + .../src/models/signing/wallet_connect.rs | 88 + .../crates/gem_tron/src/models/transaction.rs | 37 + core/crates/gem_tron/src/provider/address.rs | 66 + .../gem_tron/src/provider/address_mapper.rs | 180 + core/crates/gem_tron/src/provider/balances.rs | 140 + .../gem_tron/src/provider/balances_mapper.rs | 442 + core/crates/gem_tron/src/provider/mod.rs | 21 + core/crates/gem_tron/src/provider/preload.rs | 229 + .../gem_tron/src/provider/preload_mapper.rs | 566 + .../src/provider/request_classifier.rs | 14 + core/crates/gem_tron/src/provider/staking.rs | 148 + .../gem_tron/src/provider/staking_mapper.rs | 76 + core/crates/gem_tron/src/provider/state.rs | 63 + .../gem_tron/src/provider/state_mapper.rs | 21 + core/crates/gem_tron/src/provider/testkit.rs | 68 + core/crates/gem_tron/src/provider/token.rs | 45 + .../src/provider/transaction_broadcast.rs | 25 + .../provider/transaction_broadcast_mapper.rs | 9 + .../src/provider/transaction_state.rs | 16 + .../src/provider/transaction_state_mapper.rs | 75 + .../gem_tron/src/provider/transactions.rs | 87 + .../src/provider/transactions_mapper.rs | 359 + core/crates/gem_tron/src/rpc/client.rs | 292 + core/crates/gem_tron/src/rpc/constants.rs | 23 + core/crates/gem_tron/src/rpc/mod.rs | 5 + .../gem_tron/src/rpc/trongrid/client.rs | 43 + .../gem_tron/src/rpc/trongrid/mapper.rs | 31 + core/crates/gem_tron/src/rpc/trongrid/mod.rs | 3 + .../crates/gem_tron/src/rpc/trongrid/model.rs | 19 + .../gem_tron/src/signer/chain_signer.rs | 731 + core/crates/gem_tron/src/signer/message.rs | 18 + core/crates/gem_tron/src/signer/mod.rs | 6 + .../crates/gem_tron/src/signer/transaction.rs | 148 + .../gem_tron/testdata/balance_coin.json | 95 + .../gem_tron/testdata/balance_token.json | 37 + .../testdata/block_mixed_contract_types.json | 126 + .../block_mixed_contract_types_receipts.json | 61 + .../testdata/transaction_broadcast_error.json | 5 + .../transaction_broadcast_success.json | 4 + .../testdata/transaction_coin_transfer.json | 31 + .../transaction_coin_transfer_receipt.json | 12 + .../gem_tron/testdata/transaction_freeze.json | 30 + .../testdata/transaction_freeze_energy.json | 31 + .../gem_tron/testdata/transaction_stake.json | 36 + .../testdata/transaction_thorchain_swap.json | 32 + .../testdata/transaction_token_transfer.json | 32 + .../testdata/transaction_unfreeze.json | 30 + core/crates/gem_wallet_connect/Cargo.toml | 18 + core/crates/gem_wallet_connect/src/actions.rs | 131 + core/crates/gem_wallet_connect/src/decode.rs | 72 + core/crates/gem_wallet_connect/src/lib.rs | 17 + .../src/request_handler/ethereum.rs | 169 + .../src/request_handler/mod.rs | 270 + .../src/request_handler/solana.rs | 99 + .../src/request_handler/sui.rs | 92 + .../src/request_handler/ton.rs | 101 + .../src/request_handler/tron.rs | 115 + .../src/response_handler.rs | 130 + core/crates/gem_wallet_connect/src/session.rs | 50 + .../gem_wallet_connect/src/sign_type.rs | 19 + .../gem_wallet_connect/src/validator.rs | 296 + .../crates/gem_wallet_connect/src/verifier.rs | 81 + .../solana_sign_all_transactions.json | 5 + .../testdata/tron_send_transaction.json | 28 + .../testdata/tron_sign_message.json | 4 + .../testdata/tron_sign_message_response.json | 3 + .../testdata/tron_sign_transaction.json | 28 + .../tron_sign_transaction_nested.json | 30 + .../tron_sign_transaction_response.json | 28 + core/crates/gem_xrp/Cargo.toml | 39 + core/crates/gem_xrp/src/address.rs | 76 + core/crates/gem_xrp/src/constants.rs | 4 + core/crates/gem_xrp/src/lib.rs | 16 + core/crates/gem_xrp/src/models/account.rs | 46 + core/crates/gem_xrp/src/models/asset.rs | 7 + core/crates/gem_xrp/src/models/block.rs | 8 + core/crates/gem_xrp/src/models/fee.rs | 15 + core/crates/gem_xrp/src/models/mod.rs | 18 + core/crates/gem_xrp/src/models/result.rs | 6 + core/crates/gem_xrp/src/models/rpc.rs | 239 + core/crates/gem_xrp/src/models/transaction.rs | 19 + core/crates/gem_xrp/src/provider/balances.rs | 109 + .../gem_xrp/src/provider/balances_mapper.rs | 150 + core/crates/gem_xrp/src/provider/mod.rs | 18 + core/crates/gem_xrp/src/provider/preload.rs | 36 + .../gem_xrp/src/provider/preload_mapper.rs | 56 + .../src/provider/request_classifier.rs | 14 + core/crates/gem_xrp/src/provider/state.rs | 53 + .../gem_xrp/src/provider/state_mapper.rs | 22 + core/crates/gem_xrp/src/provider/testkit.rs | 24 + core/crates/gem_xrp/src/provider/token.rs | 37 + .../gem_xrp/src/provider/token_mapper.rs | 39 + .../src/provider/transaction_broadcast.rs | 25 + .../provider/transaction_broadcast_mapper.rs | 11 + .../gem_xrp/src/provider/transaction_state.rs | 16 + .../src/provider/transaction_state_mapper.rs | 52 + .../gem_xrp/src/provider/transactions.rs | 44 + .../src/provider/transactions_mapper.rs | 260 + core/crates/gem_xrp/src/rpc/client.rs | 142 + core/crates/gem_xrp/src/rpc/mod.rs | 3 + core/crates/gem_xrp/src/signer/amount.rs | 195 + .../crates/gem_xrp/src/signer/chain_signer.rs | 293 + core/crates/gem_xrp/src/signer/mod.rs | 5 + core/crates/gem_xrp/src/signer/transaction.rs | 301 + .../account_transaction_thorchain_swap.json | 39 + .../src/testdata/account_transactions.json | 223 + .../src/testdata/accounts_objects_tokens.json | 59 + .../transaction_broadcast_failed.json | 31 + .../transaction_broadcast_success.json | 31 + .../src/testdata/transaction_by_address.json | 3846 + .../src/testdata/transaction_by_hash.json | 66 + .../src/testdata/transactions_by_block.json | 6103 ++ core/crates/in_app_notifications/Cargo.toml | 10 + core/crates/in_app_notifications/src/lib.rs | 127 + core/crates/job_runner/Cargo.toml | 10 + core/crates/job_runner/src/lib.rs | 200 + core/crates/job_runner/src/schedule.rs | 21 + core/crates/localizer/Cargo.toml | 9 + core/crates/localizer/i18n.toml | 12 + core/crates/localizer/i18n/ar/localizer.ftl | 63 + core/crates/localizer/i18n/de/localizer.ftl | 63 + core/crates/localizer/i18n/en/localizer.ftl | 64 + core/crates/localizer/i18n/es/localizer.ftl | 63 + core/crates/localizer/i18n/fa/localizer.ftl | 63 + core/crates/localizer/i18n/fr/localizer.ftl | 63 + core/crates/localizer/i18n/he/localizer.ftl | 63 + core/crates/localizer/i18n/hi/localizer.ftl | 63 + core/crates/localizer/i18n/id/localizer.ftl | 63 + core/crates/localizer/i18n/it/localizer.ftl | 63 + core/crates/localizer/i18n/ja/localizer.ftl | 63 + core/crates/localizer/i18n/ko/localizer.ftl | 63 + core/crates/localizer/i18n/pl/localizer.ftl | 63 + .../crates/localizer/i18n/pt-BR/localizer.ftl | 63 + core/crates/localizer/i18n/ru/localizer.ftl | 63 + core/crates/localizer/i18n/th/localizer.ftl | 63 + core/crates/localizer/i18n/tr/localizer.ftl | 63 + core/crates/localizer/i18n/uk/localizer.ftl | 63 + core/crates/localizer/i18n/vi/localizer.ftl | 63 + .../localizer/i18n/zh-Hans/localizer.ftl | 63 + .../localizer/i18n/zh-Hant/localizer.ftl | 63 + core/crates/localizer/src/lib.rs | 322 + core/crates/localizer/tests/localizer.rs | 52 + core/crates/metrics/Cargo.toml | 7 + core/crates/metrics/src/domain.rs | 6 + core/crates/metrics/src/histogram.rs | 7 + core/crates/metrics/src/lib.rs | 7 + core/crates/metrics/src/registry.rs | 35 + core/crates/name_resolver/Cargo.toml | 33 + core/crates/name_resolver/README.md | 29 + .../name_resolver/src/alldomains/client.rs | 165 + .../name_resolver/src/alldomains/mod.rs | 5 + .../name_resolver/src/alldomains/model.rs | 46 + core/crates/name_resolver/src/aptos.rs | 51 + .../crates/name_resolver/src/base/contract.rs | 17 + core/crates/name_resolver/src/base/mod.rs | 4 + .../crates/name_resolver/src/base/provider.rs | 81 + core/crates/name_resolver/src/client.rs | 180 + core/crates/name_resolver/src/codec/mod.rs | 5 + core/crates/name_resolver/src/did.rs | 67 + core/crates/name_resolver/src/ens/client.rs | 51 + core/crates/name_resolver/src/ens/contract.rs | 86 + core/crates/name_resolver/src/ens/mod.rs | 7 + .../name_resolver/src/ens/normalizer.rs | 8 + core/crates/name_resolver/src/ens/provider.rs | 26 + core/crates/name_resolver/src/error.rs | 19 + core/crates/name_resolver/src/eths.rs | 49 + .../src/hyperliquid/contracts.rs | 11 + .../name_resolver/src/hyperliquid/mod.rs | 5 + .../name_resolver/src/hyperliquid/provider.rs | 126 + .../name_resolver/src/hyperliquid/record.rs | 19 + core/crates/name_resolver/src/icns.rs | 79 + core/crates/name_resolver/src/injective.rs | 82 + core/crates/name_resolver/src/lens.rs | 70 + core/crates/name_resolver/src/lib.rs | 49 + core/crates/name_resolver/src/model.rs | 49 + core/crates/name_resolver/src/sns.rs | 84 + core/crates/name_resolver/src/spaceid.rs | 54 + core/crates/name_resolver/src/suins/client.rs | 71 + core/crates/name_resolver/src/suins/mod.rs | 4 + core/crates/name_resolver/src/suins/proto.rs | 93 + core/crates/name_resolver/src/testkit.rs | 58 + core/crates/name_resolver/src/ton.rs | 85 + core/crates/name_resolver/src/ton_codec.rs | 54 + core/crates/name_resolver/src/ud.rs | 127 + .../testdata/ton_dns_records_response.json | 9 + .../name_resolver/tests/integration_test.rs | 83 + core/crates/nft/Cargo.toml | 25 + core/crates/nft/src/client.rs | 316 + core/crates/nft/src/config.rs | 16 + core/crates/nft/src/factory.rs | 27 + core/crates/nft/src/lib.rs | 16 + core/crates/nft/src/provider.rs | 99 + core/crates/nft/src/provider_client.rs | 37 + core/crates/nft/src/providers/attribute.rs | 10 + .../nft/src/providers/magiceden/evm/client.rs | 61 + .../nft/src/providers/magiceden/evm/mapper.rs | 144 + .../nft/src/providers/magiceden/evm/mod.rs | 6 + .../nft/src/providers/magiceden/evm/model.rs | 86 + .../src/providers/magiceden/evm/provider.rs | 91 + .../crates/nft/src/providers/magiceden/mod.rs | 16 + .../src/providers/magiceden/solana/client.rs | 42 + .../src/providers/magiceden/solana/mapper.rs | 186 + .../nft/src/providers/magiceden/solana/mod.rs | 6 + .../src/providers/magiceden/solana/model.rs | 31 + .../providers/magiceden/solana/provider.rs | 90 + core/crates/nft/src/providers/mod.rs | 8 + .../nft/src/providers/opensea/client.rs | 49 + .../nft/src/providers/opensea/mapper.rs | 295 + core/crates/nft/src/providers/opensea/mod.rs | 14 + .../crates/nft/src/providers/opensea/model.rs | 79 + .../nft/src/providers/opensea/provider.rs | 88 + core/crates/nft/src/providers/ton/mapper.rs | 222 + core/crates/nft/src/providers/ton/mod.rs | 3 + core/crates/nft/src/providers/ton/provider.rs | 39 + core/crates/nft/src/providers/ton/verified.rs | 58 + .../nft/src/testdata/magiceden/evm_asset.json | 79 + .../src/testdata/magiceden/evm_assets.json | 84 + .../testdata/magiceden/evm_collection.json | 61 + .../src/testdata/magiceden/solana_asset.json | 62 + .../src/testdata/magiceden/solana_assets.json | 64 + .../testdata/magiceden/solana_collection.json | 62 + core/crates/nft/src/testkit.rs | 64 + core/crates/nft/testdata/magiceden/asset.json | 62 + .../crates/nft/testdata/magiceden/assets.json | 64 + .../nft/testdata/magiceden/collection.json | 62 + core/crates/nft/testdata/opensea/asset.json | 64 + .../nft/testdata/opensea/asset_ens_dates.json | 68 + .../testdata/opensea/asset_null_images.json | 56 + core/crates/nft/testdata/opensea/assets.json | 1605 + .../nft/testdata/opensea/collection.json | 54 + core/crates/nft/testdata/ton/collections.json | 22 + .../nft/testdata/ton/collections_getgems.json | 22 + .../nft/testdata/ton/collections_invalid.json | 19 + core/crates/nft/testdata/ton/items.json | 22 + .../nft/testdata/ton/items_unverified.json | 33 + core/crates/number_formatter/Cargo.toml | 9 + .../src/big_number_formatter.rs | 214 + core/crates/number_formatter/src/currency.rs | 554 + core/crates/number_formatter/src/lib.rs | 8 + .../number_formatter/src/number_formatter.rs | 72 + .../number_formatter/src/price_suggestion.rs | 64 + .../number_formatter/src/value_formatter.rs | 189 + core/crates/portfolio/Cargo.toml | 11 + core/crates/portfolio/src/lib.rs | 2 + core/crates/portfolio/src/portfolio_client.rs | 109 + core/crates/pricer/Cargo.toml | 19 + core/crates/pricer/src/chart_client.rs | 33 + core/crates/pricer/src/lib.rs | 20 + core/crates/pricer/src/markets_client.rs | 55 + core/crates/pricer/src/price_alert_client.rs | 292 + core/crates/pricer/src/price_client.rs | 205 + core/crates/prices/Cargo.toml | 26 + core/crates/prices/src/lib.rs | 57 + core/crates/prices/src/model.rs | 98 + .../prices/src/providers/coingecko/mapper.rs | 250 + .../prices/src/providers/coingecko/mod.rs | 2 + .../src/providers/coingecko/provider.rs | 131 + .../prices/src/providers/defillama/client.rs | 20 + .../prices/src/providers/defillama/mapper.rs | 112 + .../prices/src/providers/defillama/mod.rs | 4 + .../prices/src/providers/defillama/model.rs | 13 + .../src/providers/defillama/provider.rs | 67 + .../prices/src/providers/jupiter/client.rs | 19 + .../prices/src/providers/jupiter/mapper.rs | 101 + .../prices/src/providers/jupiter/mod.rs | 7 + .../prices/src/providers/jupiter/model.rs | 26 + .../prices/src/providers/jupiter/provider.rs | 81 + .../prices/src/providers/jupiter/testkit.rs | 10 + core/crates/prices/src/providers/mod.rs | 4 + .../prices/src/providers/pyth/client.rs | 42 + .../prices/src/providers/pyth/mapper.rs | 79 + core/crates/prices/src/providers/pyth/mod.rs | 7 + .../crates/prices/src/providers/pyth/model.rs | 29 + .../prices/src/providers/pyth/provider.rs | 111 + .../prices/src/providers/pyth/testkit.rs | 10 + .../prices/testdata/defillama/prices.json | 17 + core/crates/primitives/Cargo.toml | 19 + core/crates/primitives/src/account.rs | 13 + core/crates/primitives/src/address/error.rs | 26 + core/crates/primitives/src/address/mod.rs | 20 + .../primitives/src/address_formatter.rs | 93 + core/crates/primitives/src/address_name.rs | 16 + core/crates/primitives/src/address_status.rs | 9 + core/crates/primitives/src/app_constants.rs | 2 + core/crates/primitives/src/asset.rs | 175 + core/crates/primitives/src/asset_address.rs | 16 + core/crates/primitives/src/asset_balance.rs | 169 + core/crates/primitives/src/asset_constants.rs | 344 + core/crates/primitives/src/asset_details.rs | 172 + .../crates/primitives/src/asset_fiat_value.rs | 11 + core/crates/primitives/src/asset_id.rs | 249 + core/crates/primitives/src/asset_metadata.rs | 27 + core/crates/primitives/src/asset_order.rs | 7 + core/crates/primitives/src/asset_price.rs | 161 + .../crates/primitives/src/asset_price_info.rs | 111 + core/crates/primitives/src/asset_score.rs | 80 + core/crates/primitives/src/asset_type.rs | 37 + core/crates/primitives/src/auth.rs | 39 + core/crates/primitives/src/auth_status.rs | 8 + core/crates/primitives/src/balance_type.rs | 12 + core/crates/primitives/src/banner.rs | 30 + core/crates/primitives/src/block_explorer.rs | 149 + .../primitives/src/broadcast_options.rs | 15 + core/crates/primitives/src/chain.rs | 162 + core/crates/primitives/src/chain_address.rs | 33 + core/crates/primitives/src/chain_bitcoin.rs | 60 + core/crates/primitives/src/chain_config.rs | 1256 + core/crates/primitives/src/chain_cosmos.rs | 73 + core/crates/primitives/src/chain_evm.rs | 113 + core/crates/primitives/src/chain_nft.rs | 32 + core/crates/primitives/src/chain_request.rs | 41 + core/crates/primitives/src/chain_signer.rs | 51 + core/crates/primitives/src/chain_stake.rs | 76 + .../src/chain_transaction_timeout.rs | 58 + core/crates/primitives/src/chain_type.rs | 25 + core/crates/primitives/src/chart.rs | 51 + core/crates/primitives/src/config.rs | 44 + core/crates/primitives/src/config_key.rs | 402 + .../crates/primitives/src/config_param_key.rs | 63 + core/crates/primitives/src/contact.rs | 35 + .../primitives/src/contract_call_data.rs | 13 + .../primitives/src/contract_constants.rs | 66 + core/crates/primitives/src/currency.rs | 61 + core/crates/primitives/src/date_ext.rs | 91 + core/crates/primitives/src/deeplink.rs | 181 + core/crates/primitives/src/delegation.rs | 100 + core/crates/primitives/src/device.rs | 53 + core/crates/primitives/src/device_token.rs | 8 + core/crates/primitives/src/diff.rs | 38 + core/crates/primitives/src/duration.rs | 66 + core/crates/primitives/src/earn_type.rs | 25 + .../primitives/src/explorers/algorand.rs | 34 + core/crates/primitives/src/explorers/aptos.rs | 26 + .../primitives/src/explorers/blockchair.rs | 151 + .../primitives/src/explorers/blockscout.rs | 22 + .../primitives/src/explorers/blocksec.rs | 99 + .../primitives/src/explorers/blockvision.rs | 62 + .../primitives/src/explorers/cardano.rs | 10 + .../primitives/src/explorers/chainflip.rs | 34 + .../primitives/src/explorers/etherscan.rs | 39 + .../primitives/src/explorers/hypercore.rs | 91 + .../crates/primitives/src/explorers/mantle.rs | 10 + .../primitives/src/explorers/mayanscan.rs | 10 + .../primitives/src/explorers/mempool.rs | 6 + .../primitives/src/explorers/metadata.rs | 292 + .../primitives/src/explorers/mintscan.rs | 72 + core/crates/primitives/src/explorers/mod.rs | 56 + core/crates/primitives/src/explorers/near.rs | 20 + .../primitives/src/explorers/near_intents.rs | 54 + core/crates/primitives/src/explorers/okx.rs | 61 + core/crates/primitives/src/explorers/relay.rs | 46 + .../primitives/src/explorers/routescan.rs | 18 + core/crates/primitives/src/explorers/skip.rs | 51 + .../primitives/src/explorers/socketscan.rs | 10 + .../crates/primitives/src/explorers/solana.rs | 101 + .../src/explorers/stellar_expert.rs | 52 + .../primitives/src/explorers/subscan.rs | 18 + core/crates/primitives/src/explorers/sui.rs | 28 + .../primitives/src/explorers/thorchain.rs | 33 + .../primitives/src/explorers/threexpl.rs | 53 + core/crates/primitives/src/explorers/ton.rs | 30 + .../primitives/src/explorers/tronscan.rs | 18 + .../primitives/src/explorers/xrpscan.rs | 19 + .../crates/primitives/src/explorers/zksync.rs | 19 + core/crates/primitives/src/fee.rs | 41 + .../primitives/src/fee_priority_value.rs | 8 + core/crates/primitives/src/fiat_assets.rs | 41 + core/crates/primitives/src/fiat_provider.rs | 108 + .../crates/primitives/src/fiat_provider_id.rs | 22 + core/crates/primitives/src/fiat_quote.rs | 118 + .../primitives/src/fiat_quote_request.rs | 21 + core/crates/primitives/src/fiat_rate.rs | 15 + .../crates/primitives/src/fiat_transaction.rs | 103 + core/crates/primitives/src/gas_price_type.rs | 105 + core/crates/primitives/src/gorush.rs | 134 + core/crates/primitives/src/graphql.rs | 22 + core/crates/primitives/src/hex.rs | 87 + core/crates/primitives/src/image_formatter.rs | 72 + core/crates/primitives/src/ip_usage_type.rs | 50 + .../primitives/src/job_configuration.rs | 22 + core/crates/primitives/src/json_rpc.rs | 6 + core/crates/primitives/src/known_assets.rs | 109 + core/crates/primitives/src/latency_type.rs | 19 + core/crates/primitives/src/lib.rs | 328 + core/crates/primitives/src/link_type.rs | 36 + core/crates/primitives/src/list_item.rs | 52 + core/crates/primitives/src/localize.rs | 3 + core/crates/primitives/src/markets.rs | 34 + core/crates/primitives/src/metrics.rs | 23 + core/crates/primitives/src/name.rs | 36 + core/crates/primitives/src/nft.rs | 364 + core/crates/primitives/src/node.rs | 55 + core/crates/primitives/src/node_config.rs | 169 + core/crates/primitives/src/node_status.rs | 10 + .../crates/primitives/src/node_sync_status.rs | 66 + core/crates/primitives/src/notification.rs | 15 + .../primitives/src/notification_data.rs | 34 + .../primitives/src/notification_type.rs | 14 + .../primitives/src/number_incrementer.rs | 34 + .../primitives/src/payment_decoder/decoder.rs | 351 + .../primitives/src/payment_decoder/erc681.rs | 148 + .../primitives/src/payment_decoder/error.rs | 30 + .../primitives/src/payment_decoder/mod.rs | 8 + .../src/payment_decoder/solana_pay.rs | 129 + .../primitives/src/payment_decoder/ton_pay.rs | 62 + core/crates/primitives/src/payment_type.rs | 20 + core/crates/primitives/src/perpetual.rs | 210 + core/crates/primitives/src/perpetual_id.rs | 78 + .../primitives/src/perpetual_position.rs | 52 + .../primitives/src/perpetual_provider.rs | 20 + core/crates/primitives/src/platform.rs | 36 + core/crates/primitives/src/platform_store.rs | 25 + core/crates/primitives/src/portfolio.rs | 145 + core/crates/primitives/src/price.rs | 68 + core/crates/primitives/src/price_alert.rs | 194 + core/crates/primitives/src/price_config.rs | 6 + core/crates/primitives/src/price_data.rs | 20 + core/crates/primitives/src/price_id.rs | 50 + core/crates/primitives/src/price_provider.rs | 49 + core/crates/primitives/src/priority.rs | 143 + .../primitives/src/push_notification.rs | 106 + .../primitives/src/recent_activity_type.rs | 15 + core/crates/primitives/src/response.rs | 44 + core/crates/primitives/src/rewards.rs | 263 + core/crates/primitives/src/scan.rs | 77 + core/crates/primitives/src/search.rs | 20 + .../primitives/src/secure_preferences.rs | 110 + core/crates/primitives/src/signer_error.rs | 72 + core/crates/primitives/src/simulation.rs | 421 + core/crates/primitives/src/solana_nft.rs | 11 + .../primitives/src/solana_token_program.rs | 23 + core/crates/primitives/src/solana_types.rs | 21 + .../primitives/src/stake_provider_type.rs | 12 + core/crates/primitives/src/stake_type.rs | 67 + core/crates/primitives/src/stream.rs | 82 + core/crates/primitives/src/string_serde.rs | 19 + core/crates/primitives/src/subscription.rs | 90 + core/crates/primitives/src/swap/approval.rs | 117 + core/crates/primitives/src/swap/mod.rs | 38 + core/crates/primitives/src/swap/mode.rs | 14 + .../primitives/src/swap/price_impact.rs | 12 + .../crates/primitives/src/swap/quote_asset.rs | 36 + core/crates/primitives/src/swap/result.rs | 12 + core/crates/primitives/src/swap/slippage.rs | 20 + core/crates/primitives/src/swap_provider.rs | 162 + core/crates/primitives/src/tag.rs | 22 + .../src/testkit/address_name_mock.rs | 13 + .../primitives/src/testkit/asset_mock.rs | 72 + .../src/testkit/contract_call_data_mock.rs | 20 + .../primitives/src/testkit/delegation_mock.rs | 97 + .../primitives/src/testkit/device_mock.rs | 29 + .../primitives/src/testkit/fiat_mock.rs | 142 + .../primitives/src/testkit/gorush_mock.rs | 27 + core/crates/primitives/src/testkit/json.rs | 23 + .../crates/primitives/src/testkit/json_rpc.rs | 1 + core/crates/primitives/src/testkit/mod.rs | 24 + .../crates/primitives/src/testkit/nft_mock.rs | 63 + .../primitives/src/testkit/perpetual_mock.rs | 52 + .../src/testkit/quote_asset_mock.rs | 15 + .../primitives/src/testkit/signer_mock.rs | 6 + .../src/testkit/subscription_mock.rs | 13 + .../primitives/src/testkit/swap_mock.rs | 200 + .../src/testkit/transaction_fee_mock.rs | 12 + .../testkit/transaction_load_input_mock.rs | 204 + .../testkit/transaction_load_metadata_mock.rs | 93 + .../src/testkit/transaction_mock.rs | 57 + .../testkit/transaction_preload_input_mock.rs | 19 + .../testkit/transaction_state_request_mock.rs | 18 + .../src/testkit/transfer_data_extra_mock.rs | 19 + .../src/testkit/wallet_connect_mock.rs | 13 + .../testkit/wallet_connection_session_mock.rs | 12 + core/crates/primitives/src/time.rs | 5 + .../crates/primitives/src/total_value_type.rs | 11 + core/crates/primitives/src/tpsl_type.rs | 10 + core/crates/primitives/src/transaction.rs | 527 + .../primitives/src/transaction_data_output.rs | 18 + .../primitives/src/transaction_direction.rs | 12 + .../primitives/src/transaction_extended.rs | 21 + core/crates/primitives/src/transaction_fee.rs | 179 + core/crates/primitives/src/transaction_id.rs | 144 + .../primitives/src/transaction_input_type.rs | 298 + .../src/transaction_load_metadata.rs | 254 + .../src/transaction_metadata_types.rs | 85 + .../src/transaction_preload_input.rs | 26 + .../primitives/src/transaction_state.rs | 38 + .../src/transaction_state_request.rs | 24 + .../crates/primitives/src/transaction_type.rs | 42 + .../primitives/src/transaction_update.rs | 34 + .../crates/primitives/src/transaction_utxo.rs | 15 + .../primitives/src/transaction_wallet.rs | 11 + .../primitives/src/transfer_data_extra.rs | 34 + core/crates/primitives/src/url_action.rs | 50 + core/crates/primitives/src/username_status.rs | 19 + core/crates/primitives/src/utxo.rs | 11 + core/crates/primitives/src/validator.rs | 34 + core/crates/primitives/src/value_access.rs | 60 + .../primitives/src/verification_status.rs | 24 + core/crates/primitives/src/wallet.rs | 31 + .../primitives/src/wallet_configuration.rs | 19 + core/crates/primitives/src/wallet_connect.rs | 118 + .../src/wallet_connect_namespace.rs | 137 + .../crates/primitives/src/wallet_connector.rs | 148 + core/crates/primitives/src/wallet_id.rs | 135 + core/crates/primitives/src/wallet_type.rs | 25 + core/crates/primitives/src/webhook_kind.rs | 11 + core/crates/primitives/src/websocket.rs | 27 + core/crates/primitives/src/yield_provider.rs | 19 + core/crates/search_index/Cargo.toml | 11 + core/crates/search_index/src/lib.rs | 100 + core/crates/search_index/src/models/asset.rs | 56 + core/crates/search_index/src/models/mod.rs | 45 + core/crates/search_index/src/models/nft.rs | 32 + .../search_index/src/models/perpetual.rs | 43 + core/crates/security_provider/Cargo.toml | 25 + core/crates/security_provider/src/lib.rs | 15 + core/crates/security_provider/src/mapper.rs | 41 + core/crates/security_provider/src/model.rs | 57 + .../src/providers/goplus/mod.rs | 3 + .../src/providers/goplus/models.rs | 50 + .../src/providers/goplus/provider.rs | 67 + .../src/providers/hashdit/mod.rs | 3 + .../src/providers/hashdit/models.rs | 143 + .../src/providers/hashdit/provider.rs | 126 + .../security_provider/src/providers/mod.rs | 2 + .../tests/integration_test.rs | 94 + core/crates/serde_serializers/Cargo.toml | 14 + core/crates/serde_serializers/src/bigint.rs | 108 + core/crates/serde_serializers/src/biguint.rs | 106 + core/crates/serde_serializers/src/duration.rs | 87 + core/crates/serde_serializers/src/f64.rs | 55 + .../crates/serde_serializers/src/hex_bytes.rs | 88 + core/crates/serde_serializers/src/lib.rs | 23 + core/crates/serde_serializers/src/string.rs | 61 + core/crates/serde_serializers/src/u128.rs | 52 + core/crates/serde_serializers/src/u64.rs | 164 + .../serde_serializers/src/visitors/mod.rs | 14 + .../src/visitors/string_or_number.rs | 61 + .../src/visitors/string_value.rs | 78 + core/crates/settings/Cargo.toml | 12 + core/crates/settings/src/lib.rs | 405 + core/crates/settings/src/testkit.rs | 25 + core/crates/settings_chain/Cargo.toml | 31 + .../settings_chain/src/broadcast_providers.rs | 52 + .../settings_chain/src/chain_providers.rs | 118 + core/crates/settings_chain/src/lib.rs | 217 + .../settings_chain/src/provider_config.rs | 32 + core/crates/signer/Cargo.toml | 19 + core/crates/signer/src/address.rs | 15 + core/crates/signer/src/decode.rs | 240 + core/crates/signer/src/ed25519.rs | 27 + core/crates/signer/src/eip712/data.rs | 27 + core/crates/signer/src/eip712/hash_impl.rs | 286 + core/crates/signer/src/eip712/mod.rs | 73 + core/crates/signer/src/eip712/parse.rs | 144 + core/crates/signer/src/error.rs | 25 + core/crates/signer/src/lib.rs | 80 + core/crates/signer/src/secp256k1.rs | 114 + .../signer/testdata/eip712_arrays_nested.json | 40 + .../testdata/eip712_canonical_chain_id_1.json | 17 + .../eip712_canonical_chain_id_137.json | 17 + .../testdata/eip712_missing_message.json | 11 + .../testdata/eip712_reference_vector.json | 37 + .../testdata/eip712_signed_integers.json | 17 + core/crates/simulation/Cargo.toml | 26 + .../simulation/src/evm/approval_method.rs | 63 + .../simulation/src/evm/approval_request.rs | 271 + .../simulation/src/evm/approval_value.rs | 52 + core/crates/simulation/src/evm/client.rs | 182 + core/crates/simulation/src/evm/decode.rs | 467 + core/crates/simulation/src/evm/mod.rs | 12 + core/crates/simulation/src/lib.rs | 1 + .../permit_batch_excessive_expiration.json | 44 + .../permit_batch_multiple_tokens.json | 44 + .../testdata/permit_batch_shared_token.json | 44 + .../testdata/permit_batch_single_token.json | 38 + .../testdata/permit_excessive_expiration.json | 31 + core/crates/storage/Cargo.toml | 15 + core/crates/storage/src/config_cacher.rs | 136 + core/crates/storage/src/database/assets.rs | 164 + .../storage/src/database/assets_addresses.rs | 92 + .../storage/src/database/assets_links.rs | 26 + .../src/database/assets_usage_ranks.rs | 33 + core/crates/storage/src/database/chains.rs | 15 + core/crates/storage/src/database/charts.rs | 210 + core/crates/storage/src/database/config.rs | 39 + core/crates/storage/src/database/devices.rs | 104 + core/crates/storage/src/database/fiat.rs | 358 + .../crates/storage/src/database/migrations.rs | 13 + core/crates/storage/src/database/mod.rs | 166 + core/crates/storage/src/database/nft.rs | 187 + .../storage/src/database/notifications.rs | 55 + .../storage/src/database/parser_state.rs | 71 + .../crates/storage/src/database/perpetuals.rs | 64 + .../storage/src/database/price_alerts.rs | 67 + core/crates/storage/src/database/prices.rs | 161 + .../storage/src/database/prices_providers.rs | 29 + core/crates/storage/src/database/referrals.rs | 443 + core/crates/storage/src/database/releases.rs | 41 + core/crates/storage/src/database/rewards.rs | 150 + .../src/database/rewards_redemptions.rs | 117 + .../storage/src/database/scan_addresses.rs | 24 + core/crates/storage/src/database/tag.rs | 67 + .../storage/src/database/transactions.rs | 281 + core/crates/storage/src/database/usernames.rs | 49 + core/crates/storage/src/database/wallets.rs | 250 + core/crates/storage/src/database/webhooks.rs | 32 + core/crates/storage/src/error.rs | 341 + core/crates/storage/src/lib.rs | 172 + .../down.sql | 6 + .../up.sql | 36 + .../2023-07-18-212125_chains/down.sql | 1 + .../2023-07-18-212125_chains/up.sql | 8 + .../2023-07-19-000000_fiat_rates/down.sql | 1 + .../2023-07-19-000000_fiat_rates/up.sql | 9 + .../2023-07-20-000000_devices/down.sql | 3 + .../2023-07-20-000000_devices/up.sql | 25 + .../2023-07-22-205905_assets/down.sql | 8 + .../2023-07-22-205905_assets/up.sql | 84 + .../2023-07-23-000000_add_wallets/down.sql | 12 + .../2023-07-23-000000_add_wallets/up.sql | 31 + .../2023-07-23-215138_fiat/down.sql | 6 + .../migrations/2023-07-23-215138_fiat/up.sql | 75 + .../2023-07-28-193518_prices/down.sql | 3 + .../2023-07-28-193518_prices/up.sql | 44 + .../2023-07-29-000000_charts/down.sql | 8 + .../2023-07-29-000000_charts/up.sql | 62 + .../2023-09-03-220931_parser/down.sql | 1 + .../2023-09-03-220931_parser/up.sql | 17 + .../2023-09-04-220616_subscriptions/down.sql | 1 + .../2023-09-04-220616_subscriptions/up.sql | 11 + .../2023-09-05-011115_transactions/down.sql | 4 + .../2023-09-05-011115_transactions/up.sql | 41 + .../2023-10-18-184745_scan_address/down.sql | 2 + .../2023-10-18-184745_scan_address/up.sql | 21 + .../2024-09-12-202145_price_alerts/down.sql | 1 + .../2024-09-12-202145_price_alerts/up.sql | 22 + .../2024-09-24-204906_releases/down.sql | 1 + .../2024-09-24-204906_releases/up.sql | 12 + .../migrations/2025-01-14-162733_nft/down.sql | 6 + .../migrations/2025-01-14-162733_nft/up.sql | 105 + .../2025-10-02-120000_perpetuals/down.sql | 2 + .../2025-10-02-120000_perpetuals/up.sql | 29 + .../2025-12-10-120000_rewards/down.sql | 10 + .../2025-12-10-120000_rewards/up.sql | 101 + .../down.sql | 4 + .../up.sql | 32 + .../2025-12-18-120000_config/down.sql | 1 + .../2025-12-18-120000_config/up.sql | 9 + .../2026-01-13-120000_notifications/down.sql | 6 + .../2026-01-13-120000_notifications/up.sql | 17 + .../down.sql | 2 + .../up.sql | 16 + core/crates/storage/src/mod.rs | 3 + core/crates/storage/src/models/asset.rs | 160 + .../storage/src/models/asset_address.rs | 75 + .../storage/src/models/asset_usage_rank.rs | 11 + core/crates/storage/src/models/chain.rs | 10 + core/crates/storage/src/models/chart.rs | 86 + core/crates/storage/src/models/config.rs | 34 + core/crates/storage/src/models/device.rs | 83 + core/crates/storage/src/models/fiat.rs | 378 + core/crates/storage/src/models/min_max.rs | 19 + core/crates/storage/src/models/mod.rs | 63 + core/crates/storage/src/models/nft_asset.rs | 90 + .../src/models/nft_asset_association.rs | 17 + .../storage/src/models/nft_collection.rs | 82 + core/crates/storage/src/models/nft_link.rs | 33 + core/crates/storage/src/models/nft_report.rs | 10 + .../crates/storage/src/models/notification.rs | 43 + .../crates/storage/src/models/parser_state.rs | 22 + core/crates/storage/src/models/perpetual.rs | 109 + core/crates/storage/src/models/price.rs | 408 + core/crates/storage/src/models/price_alert.rs | 59 + .../storage/src/models/price_provider.rs | 24 + core/crates/storage/src/models/release.rs | 33 + core/crates/storage/src/models/reward.rs | 248 + .../storage/src/models/scan_addresses.rs | 93 + .../models/subscription_address_exclude.rs | 12 + core/crates/storage/src/models/tag.rs | 29 + core/crates/storage/src/models/transaction.rs | 146 + .../src/models/transaction_addresses.rs | 44 + core/crates/storage/src/models/username.rs | 32 + core/crates/storage/src/models/wallet.rs | 56 + core/crates/storage/src/models/webhook.rs | 21 + .../assets_addresses_repository.rs | 41 + .../repositories/assets_links_repository.rs | 26 + .../src/repositories/assets_repository.rs | 112 + .../assets_usage_ranks_repository.rs | 25 + .../src/repositories/chains_repository.rs | 13 + .../src/repositories/charts_repository.rs | 36 + .../src/repositories/config_repository.rs | 65 + .../src/repositories/devices_repository.rs | 75 + .../src/repositories/fiat_repository.rs | 80 + .../src/repositories/migrations_repository.rs | 14 + core/crates/storage/src/repositories/mod.rs | 26 + .../src/repositories/nft_repository.rs | 85 + .../repositories/notifications_repository.rs | 28 + .../repositories/parser_state_repository.rs | 33 + .../src/repositories/perpetuals_repository.rs | 35 + .../repositories/price_alerts_repository.rs | 73 + .../prices_providers_repository.rs | 19 + .../src/repositories/prices_repository.rs | 251 + .../src/repositories/releases_repository.rs | 32 + .../rewards_redemptions_repository.rs | 73 + .../src/repositories/rewards_repository.rs | 754 + .../repositories/risk_signals_repository.rs | 147 + .../repositories/scan_addresses_repository.rs | 110 + .../src/repositories/tag_repository.rs | 46 + .../repositories/transactions_repository.rs | 90 + .../src/repositories/wallets_repository.rs | 186 + .../src/repositories/webhooks_repository.rs | 19 + core/crates/storage/src/schema.rs | 1021 + core/crates/storage/src/sql_types.rs | 341 + core/crates/storage/src/testkit/asset_mock.rs | 33 + .../src/testkit/fiat_transaction_mock.rs | 39 + core/crates/storage/src/testkit/mod.rs | 4 + core/crates/storage/src/testkit/price_mock.rs | 22 + .../storage/src/testkit/scan_address_mock.rs | 21 + core/crates/streamer/Cargo.toml | 17 + core/crates/streamer/src/connection.rs | 35 + core/crates/streamer/src/consumer.rs | 118 + core/crates/streamer/src/exchange.rs | 42 + core/crates/streamer/src/lib.rs | 86 + core/crates/streamer/src/payload.rs | 391 + core/crates/streamer/src/queue.rs | 101 + .../streamer/src/steam_producer_queue.rs | 171 + core/crates/streamer/src/stream_producer.rs | 306 + core/crates/streamer/src/stream_reader.rs | 176 + core/crates/support/Cargo.toml | 15 + core/crates/support/src/client.rs | 95 + core/crates/support/src/lib.rs | 5 + core/crates/support/src/model.rs | 129 + core/crates/support/tests/model_tests.rs | 114 + .../chatwoot_conversation_updated.json | 124 + .../testdata/chatwoot_message_created.json | 105 + core/crates/swapper/Cargo.toml | 62 + core/crates/swapper/src/across/api.rs | 76 + .../crates/swapper/src/across/config_store.rs | 92 + core/crates/swapper/src/across/hubpool.rs | 114 + core/crates/swapper/src/across/mod.rs | 10 + core/crates/swapper/src/across/provider.rs | 892 + core/crates/swapper/src/alien/mock.rs | 52 + core/crates/swapper/src/alien/mod.rs | 6 + .../swapper/src/alien/reqwest_provider.rs | 114 + core/crates/swapper/src/approval/evm.rs | 170 + core/crates/swapper/src/approval/mod.rs | 28 + core/crates/swapper/src/cache.rs | 12 + core/crates/swapper/src/cetus_clmm/cache.rs | 4 + core/crates/swapper/src/cetus_clmm/client.rs | 489 + .../swapper/src/cetus_clmm/constants.rs | 63 + core/crates/swapper/src/cetus_clmm/mod.rs | 8 + core/crates/swapper/src/cetus_clmm/model.rs | 159 + .../crates/swapper/src/cetus_clmm/provider.rs | 241 + .../swapper/src/cetus_clmm/tx_builder.rs | 407 + .../swapper/src/chainflip/broker/client.rs | 106 + .../swapper/src/chainflip/broker/mod.rs | 5 + .../swapper/src/chainflip/broker/model.rs | 123 + .../swapper/src/chainflip/capitalize.rs | 7 + .../swapper/src/chainflip/client/mod.rs | 5 + .../swapper/src/chainflip/client/model.rs | 280 + .../swapper/src/chainflip/client/swap.rs | 47 + .../chainflip/client/test/btc_eth_quote.json | 162 + .../test/swap_btc_to_usdt_refunded.json | 16 + .../client/test/swap_eth_to_btc.json | 13 + .../client/test/swap_sol_to_btc.json | 13 + .../client/test/swap_usdc_to_btc_pending.json | 13 + .../client/test/swap_usdc_to_sol.json | 13 + core/crates/swapper/src/chainflip/default.rs | 21 + core/crates/swapper/src/chainflip/mod.rs | 12 + core/crates/swapper/src/chainflip/model.rs | 11 + core/crates/swapper/src/chainflip/price.rs | 36 + core/crates/swapper/src/chainflip/provider.rs | 425 + core/crates/swapper/src/chainflip/seed.rs | 15 + .../test/chainflip_boost_quotes.json | 330 + .../src/chainflip/test/chainflip_quotes.json | 158 + .../chainflip_sol_arb_usdc_quote_data.json | 41 + .../swapper/src/chainflip/tx_builder.rs | 85 + core/crates/swapper/src/chainlink.rs | 45 + core/crates/swapper/src/client_factory.rs | 47 + core/crates/swapper/src/config.rs | 47 + core/crates/swapper/src/cross_chain.rs | 151 + core/crates/swapper/src/error.rs | 188 + core/crates/swapper/src/eth_address.rs | 34 + core/crates/swapper/src/fee_token.rs | 84 + core/crates/swapper/src/fees/mod.rs | 16 + core/crates/swapper/src/fees/referral.rs | 104 + core/crates/swapper/src/fees/reserve.rs | 67 + core/crates/swapper/src/fees/slippage.rs | 64 + core/crates/swapper/src/hyperliquid/mod.rs | 2 + .../src/hyperliquid/provider/bridge.rs | 109 + .../src/hyperliquid/provider/hyperliquid.rs | 82 + .../swapper/src/hyperliquid/provider/mod.rs | 7 + .../src/hyperliquid/provider/spot/math.rs | 207 + .../src/hyperliquid/provider/spot/mod.rs | 6 + .../src/hyperliquid/provider/spot/provider.rs | 312 + .../hyperliquid/provider/spot/simulator.rs | 145 + core/crates/swapper/src/jupiter/client.rs | 31 + core/crates/swapper/src/jupiter/default.rs | 18 + core/crates/swapper/src/jupiter/mod.rs | 7 + core/crates/swapper/src/jupiter/model.rs | 53 + core/crates/swapper/src/jupiter/provider.rs | 237 + core/crates/swapper/src/lib.rs | 70 + core/crates/swapper/src/mayan/asset.rs | 137 + core/crates/swapper/src/mayan/cctp_domain.rs | 69 + .../swapper/src/mayan/client/explorer.rs | 20 + .../swapper/src/mayan/client/get_swap.rs | 27 + core/crates/swapper/src/mayan/client/mod.rs | 23 + core/crates/swapper/src/mayan/client/quote.rs | 161 + core/crates/swapper/src/mayan/constants.rs | 28 + core/crates/swapper/src/mayan/mapper.rs | 95 + core/crates/swapper/src/mayan/mod.rs | 13 + core/crates/swapper/src/mayan/model.rs | 693 + core/crates/swapper/src/mayan/provider.rs | 620 + .../src/mayan/test/btcbr_to_radr_swift.json | 166 + .../src/mayan/test/eth_to_sui_swift.json | 153 + .../src/mayan/test/fast_mctp_quote.json | 33 + .../swapper/src/mayan/test/mctp_pending.json | 146 + .../src/mayan/test/pol_to_bnb_swift.json | 146 + .../src/mayan/test/quote_response_swift.json | 26 + .../test/quote_response_swift_hypercore.json | 27 + .../src/mayan/test/quote_swift_solana.json | 38 + .../src/mayan/test/sui_client_swap.json | 14 + .../mayan/test/swift_quote_evm_to_solana.json | 52 + .../src/mayan/test/swift_refunded.json | 181 + .../src/mayan/test/usdt_to_owb_swift.json | 148 + core/crates/swapper/src/mayan/testkit.rs | 82 + .../swapper/src/mayan/tx_builder/address.rs | 35 + .../swapper/src/mayan/tx_builder/amount.rs | 84 + .../swapper/src/mayan/tx_builder/evm.rs | 177 + .../src/mayan/tx_builder/fast_mctp/evm.rs | 204 + .../src/mayan/tx_builder/fast_mctp/mod.rs | 74 + .../src/mayan/tx_builder/fast_mctp/solana.rs | 352 + .../swapper/src/mayan/tx_builder/hypercore.rs | 33 + .../swapper/src/mayan/tx_builder/mctp/evm.rs | 215 + .../swapper/src/mayan/tx_builder/mctp/mod.rs | 19 + .../src/mayan/tx_builder/mctp/solana.rs | 410 + .../swapper/src/mayan/tx_builder/mctp/sui.rs | 113 + .../src/mayan/tx_builder/mctp/sui/prefetch.rs | 162 + .../mayan/tx_builder/mctp/sui/transaction.rs | 157 + .../tx_builder/mctp/sui/transaction/bridge.rs | 164 + .../tx_builder/mctp/sui/transaction/fees.rs | 26 + .../tx_builder/mctp/sui/transaction/order.rs | 106 + .../swapper/src/mayan/tx_builder/mod.rs | 10 + .../src/mayan/tx_builder/mono_chain/evm.rs | 145 + .../src/mayan/tx_builder/mono_chain/mod.rs | 1 + .../swapper/src/mayan/tx_builder/route.rs | 37 + .../swapper/src/mayan/tx_builder/solana.rs | 206 + .../swapper/src/mayan/tx_builder/swift/evm.rs | 85 + .../mayan/tx_builder/swift/evm/contracts.rs | 24 + .../src/mayan/tx_builder/swift/evm/order.rs | 39 + .../mayan/tx_builder/swift/evm/transaction.rs | 110 + .../swapper/src/mayan/tx_builder/swift/mod.rs | 142 + .../src/mayan/tx_builder/swift/solana.rs | 16 + .../mayan/tx_builder/swift/solana/order.rs | 124 + .../mayan/tx_builder/swift/solana/payload.rs | 35 + .../tx_builder/swift/solana/transaction.rs | 221 + .../swapper/src/mayan/wormhole_chain.rs | 148 + core/crates/swapper/src/models.rs | 200 + .../crates/swapper/src/near_intents/assets.rs | 290 + .../crates/swapper/src/near_intents/client.rs | 63 + .../crates/swapper/src/near_intents/config.rs | 9 + core/crates/swapper/src/near_intents/mod.rs | 14 + core/crates/swapper/src/near_intents/model.rs | 103 + .../swapper/src/near_intents/provider.rs | 632 + .../tx_status_avax_to_smartchain.json | 13 + .../testdata/tx_status_solana_to_bitcoin.json | 13 + .../tx_status_ton_to_smartchain_refunded.json | 14 + core/crates/swapper/src/okx/auth.rs | 70 + core/crates/swapper/src/okx/client.rs | 46 + core/crates/swapper/src/okx/constants.rs | 52 + core/crates/swapper/src/okx/mod.rs | 9 + core/crates/swapper/src/okx/model.rs | 92 + core/crates/swapper/src/okx/provider.rs | 435 + core/crates/swapper/src/okx/referral.rs | 87 + core/crates/swapper/src/panora/client.rs | 26 + core/crates/swapper/src/panora/mod.rs | 5 + core/crates/swapper/src/panora/model.rs | 44 + core/crates/swapper/src/panora/provider.rs | 212 + .../src/panora/testdata/quote_response.json | 95 + core/crates/swapper/src/permit2_data.rs | 131 + core/crates/swapper/src/proxy/client.rs | 103 + core/crates/swapper/src/proxy/mod.rs | 6 + core/crates/swapper/src/proxy/provider.rs | 285 + .../swapper/src/proxy/provider_factory.rs | 8 + core/crates/swapper/src/relay/asset.rs | 94 + core/crates/swapper/src/relay/chain.rs | 42 + core/crates/swapper/src/relay/client.rs | 37 + core/crates/swapper/src/relay/mapper.rs | 137 + core/crates/swapper/src/relay/mod.rs | 12 + core/crates/swapper/src/relay/model.rs | 339 + core/crates/swapper/src/relay/provider.rs | 237 + .../testdata/request_bsc_usdt_to_sol.json | 38 + .../relay/testdata/request_eth_to_btc.json | 42 + core/crates/swapper/src/relay/testkit.rs | 46 + core/crates/swapper/src/route_cache.rs | 192 + core/crates/swapper/src/solana.rs | 10 + core/crates/swapper/src/squid/client.rs | 32 + core/crates/swapper/src/squid/mod.rs | 32 + core/crates/swapper/src/squid/model.rs | 98 + core/crates/swapper/src/squid/provider.rs | 261 + core/crates/swapper/src/stonfi/client.rs | 160 + core/crates/swapper/src/stonfi/constants.rs | 76 + core/crates/swapper/src/stonfi/mod.rs | 10 + core/crates/swapper/src/stonfi/model.rs | 31 + core/crates/swapper/src/stonfi/provider.rs | 837 + core/crates/swapper/src/stonfi/quote.rs | 222 + .../src/stonfi/testdata/v1_simulation.json | 17 + .../src/stonfi/testdata/v2_simulation.json | 17 + core/crates/swapper/src/stonfi/testkit.rs | 47 + .../swapper/src/stonfi/tx_builder/message.rs | 27 + .../swapper/src/stonfi/tx_builder/mod.rs | 34 + .../swapper/src/stonfi/tx_builder/model.rs | 49 + .../swapper/src/stonfi/tx_builder/v1.rs | 99 + .../swapper/src/stonfi/tx_builder/v2.rs | 295 + core/crates/swapper/src/swapper.rs | 368 + core/crates/swapper/src/swapper_trait.rs | 46 + core/crates/swapper/src/testkit.rs | 148 + core/crates/swapper/src/thorchain/asset.rs | 260 + core/crates/swapper/src/thorchain/chain.rs | 173 + core/crates/swapper/src/thorchain/client.rs | 68 + .../crates/swapper/src/thorchain/constants.rs | 2 + core/crates/swapper/src/thorchain/memo.rs | 128 + core/crates/swapper/src/thorchain/mod.rs | 105 + core/crates/swapper/src/thorchain/model.rs | 384 + core/crates/swapper/src/thorchain/provider.rs | 368 + .../src/thorchain/quote_data_mapper.rs | 173 + .../swapper/src/thorchain/swap_mapper.rs | 198 + .../src/thorchain/testdata/asgard_vaults.json | 34 + .../testdata/tx_status_bnb_to_eth_usdt.json | 105 + .../testdata/tx_status_bnb_to_tron.json | 106 + .../tx_status_bnb_to_tron_pending.json | 85 + .../tx_status_btc_to_tron_pending.json | 40 + .../testdata/tx_status_eth_usdt_to_rune.json | 46 + .../testdata/tx_status_ltc_to_eth.json | 103 + .../testdata/tx_status_ltc_to_tron_usdt.json | 105 + .../testdata/tx_status_tcy_to_eth_usdt.json | 127 + core/crates/swapper/src/uniswap/deadline.rs | 7 + core/crates/swapper/src/uniswap/default.rs | 51 + core/crates/swapper/src/uniswap/fee_token.rs | 73 + core/crates/swapper/src/uniswap/mod.rs | 12 + .../swapper/src/uniswap/native_asset.rs | 9 + .../swapper/src/uniswap/quote_result.rs | 39 + core/crates/swapper/src/uniswap/swap_route.rs | 48 + .../src/uniswap/universal_router/mod.rs | 99 + .../crates/swapper/src/uniswap/v3/commands.rs | 386 + core/crates/swapper/src/uniswap/v3/mod.rs | 19 + core/crates/swapper/src/uniswap/v3/path.rs | 57 + .../crates/swapper/src/uniswap/v3/provider.rs | 252 + .../swapper/src/uniswap/v3/quoter_v2.rs | 44 + .../crates/swapper/src/uniswap/v4/commands.rs | 188 + core/crates/swapper/src/uniswap/v4/mod.rs | 8 + core/crates/swapper/src/uniswap/v4/path.rs | 107 + .../crates/swapper/src/uniswap/v4/provider.rs | 326 + core/crates/swapper/src/uniswap/v4/quoter.rs | 104 + .../testdata/squid/status_response.json | 7 + core/crates/tracing/Cargo.toml | 8 + core/crates/tracing/src/lib.rs | 128 + core/crates/yielder/Cargo.toml | 30 + core/crates/yielder/src/client_factory.rs | 17 + core/crates/yielder/src/error.rs | 42 + core/crates/yielder/src/lib.rs | 9 + core/crates/yielder/src/provider.rs | 13 + core/crates/yielder/src/yielder.rs | 60 + core/crates/yielder/src/yo/assets.rs | 31 + core/crates/yielder/src/yo/client.rs | 150 + core/crates/yielder/src/yo/contract.rs | 9 + core/crates/yielder/src/yo/mapper.rs | 118 + core/crates/yielder/src/yo/mod.rs | 13 + core/crates/yielder/src/yo/provider.rs | 116 + core/diesel.toml | 9 + core/docker-compose.yml | 152 + core/docs/DEVICE_AUTHENTICATION.md | 85 + core/docs/DEVICE_WEBSOCKETS.md | 123 + core/docs/REWARDS_AND_REFERRALS.md | 84 + core/docs/WALLET_AUTHENTICATION.md | 103 + core/gemstone/.cargo/config.toml | 6 + core/gemstone/.gitignore | 13 + core/gemstone/Cargo.toml | 73 + core/gemstone/README.md | 22 + core/gemstone/android/.gitignore | 15 + core/gemstone/android/build.gradle.kts | 3 + core/gemstone/android/gemstone/.gitignore | 2 + .../android/gemstone/build.gradle.kts | 127 + .../android/gemstone/consumer-rules.pro | 0 .../android/gemstone/proguard-rules.pro | 27 + .../com/gemwallet/gemstone/GemstoneTest.kt | 146 + .../gemstone/src/main/AndroidManifest.xml | 4 + core/gemstone/android/gradle.properties | 24 + .../gradle/gradle-daemon-jvm.properties | 13 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + core/gemstone/android/gradlew | 185 + core/gemstone/android/gradlew.bat | 89 + core/gemstone/android/settings.gradle.kts | 26 + core/gemstone/justfile | 162 + core/gemstone/src/address.rs | 104 + core/gemstone/src/address_formatter.rs | 15 + core/gemstone/src/alien/client.rs | 10 + core/gemstone/src/alien/error.rs | 8 + core/gemstone/src/alien/mod.rs | 11 + core/gemstone/src/alien/provider.rs | 37 + core/gemstone/src/alien/reqwest_provider.rs | 17 + core/gemstone/src/alien/target.rs | 36 + core/gemstone/src/api_client/mod.rs | 22 + core/gemstone/src/auth.rs | 85 + core/gemstone/src/block_explorer/explorer.rs | 353 + core/gemstone/src/block_explorer/mod.rs | 5 + .../src/block_explorer/remote_types.rs | 10 + core/gemstone/src/config/chain.rs | 76 + core/gemstone/src/config/docs.rs | 91 + core/gemstone/src/config/mod.rs | 116 + core/gemstone/src/config/node.rs | 18 + core/gemstone/src/config/perpetual_config.rs | 53 + core/gemstone/src/config/public.rs | 32 + core/gemstone/src/config/rewards.rs | 78 + core/gemstone/src/config/social.rs | 73 + core/gemstone/src/config/stake.rs | 48 + core/gemstone/src/config/swap_config.rs | 17 + core/gemstone/src/config/validators.rs | 68 + core/gemstone/src/config/wallet_connect.rs | 18 + core/gemstone/src/deeplink.rs | 32 + core/gemstone/src/ethereum/decoder.rs | 39 + core/gemstone/src/ethereum/mod.rs | 3 + .../src/ethereum/test/fee_history.json | 64 + core/gemstone/src/gateway/chain_factory.rs | 107 + core/gemstone/src/gateway/error.rs | 128 + core/gemstone/src/gateway/mod.rs | 279 + core/gemstone/src/gateway/preferences.rs | 46 + core/gemstone/src/gem_swapper/error.rs | 13 + core/gemstone/src/gem_swapper/mod.rs | 62 + core/gemstone/src/gem_swapper/permit2.rs | 43 + core/gemstone/src/gem_swapper/remote_types.rs | 174 + core/gemstone/src/lib.rs | 110 + core/gemstone/src/message/eip712.rs | 155 + core/gemstone/src/message/mod.rs | 4 + core/gemstone/src/message/payload.rs | 475 + core/gemstone/src/message/sign_type.rs | 19 + core/gemstone/src/message/signer.rs | 692 + .../src/message/test/eip712_polymarket.json | 48 + .../src/message/test/eip712_seaport.json | 111 + core/gemstone/src/models/asset.rs | 57 + core/gemstone/src/models/balance.rs | 37 + core/gemstone/src/models/custom_types.rs | 62 + core/gemstone/src/models/gateway.rs | 89 + core/gemstone/src/models/mod.rs | 27 + core/gemstone/src/models/nft.rs | 57 + core/gemstone/src/models/node.rs | 10 + core/gemstone/src/models/perpetual.rs | 183 + core/gemstone/src/models/portfolio.rs | 32 + core/gemstone/src/models/scan.rs | 26 + core/gemstone/src/models/simulation.rs | 89 + core/gemstone/src/models/stake.rs | 98 + core/gemstone/src/models/swap.rs | 167 + core/gemstone/src/models/token.rs | 18 + core/gemstone/src/models/transaction.rs | 893 + core/gemstone/src/network/mod.rs | 4 + core/gemstone/src/payment/mod.rs | 43 + core/gemstone/src/perpetual.rs | 121 + core/gemstone/src/price_alert_formatter.rs | 20 + core/gemstone/src/signer/chain.rs | 160 + core/gemstone/src/signer/decode.rs | 20 + core/gemstone/src/signer/mod.rs | 4 + core/gemstone/src/siwe.rs | 24 + core/gemstone/src/testkit.rs | 39 + core/gemstone/src/transaction_state/config.rs | 18 + core/gemstone/src/transaction_state/error.rs | 29 + core/gemstone/src/transaction_state/mod.rs | 7 + .../src/transaction_state/status_provider.rs | 219 + core/gemstone/src/url_action.rs | 24 + core/gemstone/src/wallet_connect/mod.rs | 426 + .../gemstone/src/wallet_connect/simulation.rs | 61 + .../src/wallet_connect/simulation_client.rs | 75 + .../gemstone/tests/android/GemTest/.gitignore | 15 + .../tests/android/GemTest/app/.gitignore | 1 + .../tests/android/GemTest/app/build.gradle | 63 + .../android/GemTest/app/proguard-rules.pro | 21 + .../GemTest/app/src/main/AndroidManifest.xml | 32 + .../java/com/example/gemtest/MainActivity.kt | 78 + .../com/example/gemtest/NativeProvider.kt | 44 + .../com/example/gemtest/ui/theme/Color.kt | 11 + .../com/example/gemtest/ui/theme/Theme.kt | 70 + .../java/com/example/gemtest/ui/theme/Type.kt | 34 + .../res/drawable/ic_launcher_background.xml | 170 + .../res/drawable/ic_launcher_foreground.xml | 30 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../app/src/main/res/values/colors.xml | 10 + .../app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/values/themes.xml | 5 + .../app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../tests/android/GemTest/build.gradle | 5 + .../tests/android/GemTest/gradle.properties | 26 + .../GemTest/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + core/gemstone/tests/android/GemTest/gradlew | 185 + .../tests/android/GemTest/gradlew.bat | 89 + .../tests/android/GemTest/settings.gradle | 21 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../GemTest/Assets.xcassets/Contents.json | 6 + .../ios/GemTest/GemTest/ContentView.swift | 99 + .../GemTest/GemTest/Extension/Data+Hex.swift | 23 + .../Extension/Gemstone+Extension.swift | 23 + .../GemTest/Extension/Swapper+Ext.swift | 74 + .../Extension/URLRequest+Extension.swift | 44 + .../ios/GemTest/GemTest/GemTestApp.swift | 21 + .../GemTest/GemTest/Networking/Cache.swift | 56 + .../GemTest/GemTest/Networking/Provider.swift | 58 + .../Preview Assets.xcassets/Contents.json | 6 + .../ios/GemTest/GemTest/SwapRequests.swift | 177 + .../tests/ios/GemTest/GemTest/ViewModel.swift | 61 + .../GemTest/GemTestTests/GemTestTests.swift | 135 + core/gemstone/tests/ios/GemTest/Package.swift | 44 + .../tests/ios/Packages/Gemstone/Package.swift | 32 + .../Gemstone/Sources/GemstoneFFI/shim.c | 1 + core/gemstone/uniffi.toml | 8 + core/justfile | 108 + core/rustfmt.toml | 4 + core/scripts/free_disk_space.sh | 43 + core/scripts/localize.sh | 200 + core/skills/architecture.md | 102 + core/skills/code-style.md | 106 + core/skills/common-issues.md | 57 + core/skills/defensive-programming.md | 102 + core/skills/development-commands.md | 89 + core/skills/error-handling.md | 117 + core/skills/project-structure.md | 121 + core/skills/swapper-checklist.md | 58 + core/skills/tests.md | 117 + core/typeshare.toml | 36 + 2537 files changed, 333560 insertions(+), 1 deletion(-) delete mode 160000 core create mode 100644 core/.cargo/audit.toml create mode 100644 core/.cargo/config.toml create mode 100644 core/.claude/skills/review-changes/SKILL.md create mode 100644 core/.clippy.toml create mode 100644 core/.dockerignore create mode 100644 core/.gitattributes create mode 100644 core/.vscode/launch.json create mode 100644 core/.vscode/settings.json create mode 100644 core/AGENTS.md create mode 120000 core/CLAUDE.md create mode 100644 core/Cargo.lock create mode 100644 core/Cargo.toml create mode 100644 core/Dockerfile create mode 100644 core/LICENSE create mode 100644 core/README.md create mode 100644 core/Settings.yaml create mode 100644 core/apps/agent/.gitignore create mode 100644 core/apps/agent/Cargo.toml create mode 100644 core/apps/agent/Dockerfile create mode 100644 core/apps/agent/Dockerfile.dockerignore create mode 100644 core/apps/agent/README.md create mode 100644 core/apps/agent/Settings.yaml create mode 100644 core/apps/agent/agents/security/agent.yaml create mode 100644 core/apps/agent/agents/security/memory/.gitkeep create mode 100644 core/apps/agent/agents/security/role.md create mode 100644 core/apps/agent/context/repos.md create mode 100644 core/apps/agent/justfile create mode 100644 core/apps/agent/src/agent.rs create mode 100644 core/apps/agent/src/bin/repl.rs create mode 100644 core/apps/agent/src/chatwoot/client.rs create mode 100644 core/apps/agent/src/chatwoot/dispatch.rs create mode 100644 core/apps/agent/src/chatwoot/mod.rs create mode 100644 core/apps/agent/src/chatwoot/server.rs create mode 100644 core/apps/agent/src/chatwoot/testdata/chatwoot_bot_message_incoming.json create mode 100644 core/apps/agent/src/chatwoot/testdata/chatwoot_conversation_updated.json create mode 100644 core/apps/agent/src/chatwoot/testdata/chatwoot_message_created.json create mode 100644 core/apps/agent/src/config.rs create mode 100644 core/apps/agent/src/images.rs create mode 100644 core/apps/agent/src/lib.rs create mode 100644 core/apps/agent/src/main.rs create mode 100644 core/apps/agent/src/mcp/mod.rs create mode 100644 core/apps/agent/src/preamble.rs create mode 100644 core/apps/agent/src/replies.rs create mode 100644 core/apps/agent/src/scheduler/format.rs create mode 100644 core/apps/agent/src/scheduler/loader.rs create mode 100644 core/apps/agent/src/scheduler/mod.rs create mode 100644 core/apps/agent/src/scheduler/runner.rs create mode 100644 core/apps/agent/src/slack/client.rs create mode 100644 core/apps/agent/src/slack/dispatch.rs create mode 100644 core/apps/agent/src/slack/mod.rs create mode 100644 core/apps/agent/src/slack/mrkdwn.rs create mode 100644 core/apps/agent/src/slack/socket.rs create mode 100644 core/apps/agent/src/store.rs create mode 100644 core/apps/agent/src/tools/chatwoot_account.rs create mode 100644 core/apps/agent/src/tools/chatwoot_conversation.rs create mode 100644 core/apps/agent/src/tools/chatwoot_review_reply.rs create mode 100644 core/apps/agent/src/tools/fetch.rs create mode 100644 core/apps/agent/src/tools/gem_api.rs create mode 100644 core/apps/agent/src/tools/gem_docs.rs create mode 100644 core/apps/agent/src/tools/memory.rs create mode 100644 core/apps/agent/src/tools/mod.rs create mode 100644 core/apps/agent/src/tools/plausible.rs create mode 100644 core/apps/agent/src/tools/shell.rs create mode 100644 core/apps/agent/src/tools/slack_history.rs create mode 100644 core/apps/agent/src/tools/slack_post.rs create mode 100644 core/apps/agent/src/tools/telegram_post.rs create mode 100644 core/apps/api/Cargo.toml create mode 100644 core/apps/api/src/admin/assets.rs create mode 100644 core/apps/api/src/admin/mod.rs create mode 100644 core/apps/api/src/admin/nft.rs create mode 100644 core/apps/api/src/admin/prices.rs create mode 100644 core/apps/api/src/admin/transactions.rs create mode 100644 core/apps/api/src/assets/cilent.rs create mode 100644 core/apps/api/src/assets/filter.rs create mode 100644 core/apps/api/src/assets/mod.rs create mode 100644 core/apps/api/src/assets/model.rs create mode 100644 core/apps/api/src/auth/guard.rs create mode 100644 core/apps/api/src/auth/mod.rs create mode 100644 core/apps/api/src/catchers.rs create mode 100644 core/apps/api/src/chain/address.rs create mode 100644 core/apps/api/src/chain/block.rs create mode 100644 core/apps/api/src/chain/client.rs create mode 100644 core/apps/api/src/chain/mod.rs create mode 100644 core/apps/api/src/chain/nft.rs create mode 100644 core/apps/api/src/chain/staking.rs create mode 100644 core/apps/api/src/chain/swap.rs create mode 100644 core/apps/api/src/chain/token.rs create mode 100644 core/apps/api/src/chain/transaction.rs create mode 100644 core/apps/api/src/config/client.rs create mode 100644 core/apps/api/src/config/mod.rs create mode 100644 core/apps/api/src/devices/auth_config.rs create mode 100644 core/apps/api/src/devices/client.rs create mode 100644 core/apps/api/src/devices/clients/address_names.rs create mode 100644 core/apps/api/src/devices/clients/fiat.rs create mode 100644 core/apps/api/src/devices/clients/mod.rs create mode 100644 core/apps/api/src/devices/clients/notifications.rs create mode 100644 core/apps/api/src/devices/clients/portfolio.rs create mode 100644 core/apps/api/src/devices/clients/rewards.rs create mode 100644 core/apps/api/src/devices/clients/rewards_redemption.rs create mode 100644 core/apps/api/src/devices/clients/scan.rs create mode 100644 core/apps/api/src/devices/clients/transactions.rs create mode 100644 core/apps/api/src/devices/clients/wallet_configuration.rs create mode 100644 core/apps/api/src/devices/clients/wallets.rs create mode 100644 core/apps/api/src/devices/constants.rs create mode 100644 core/apps/api/src/devices/error.rs create mode 100644 core/apps/api/src/devices/guard/auth.rs create mode 100644 core/apps/api/src/devices/guard/authenticated_device.rs create mode 100644 core/apps/api/src/devices/guard/authenticated_device_wallet.rs create mode 100644 core/apps/api/src/devices/guard/mod.rs create mode 100644 core/apps/api/src/devices/guard/verified_device_id.rs create mode 100644 core/apps/api/src/devices/mod.rs create mode 100644 core/apps/api/src/devices/signature.rs create mode 100644 core/apps/api/src/fiat.rs create mode 100644 core/apps/api/src/lib.rs create mode 100644 core/apps/api/src/main.rs create mode 100644 core/apps/api/src/markets/mod.rs create mode 100644 core/apps/api/src/model.rs create mode 100644 core/apps/api/src/nft.rs create mode 100644 core/apps/api/src/params.rs create mode 100644 core/apps/api/src/prices/mod.rs create mode 100644 core/apps/api/src/referral.rs create mode 100644 core/apps/api/src/responders.rs create mode 100644 core/apps/api/src/status.rs create mode 100644 core/apps/api/src/swap/client.rs create mode 100644 core/apps/api/src/swap/mod.rs create mode 100644 core/apps/api/src/swap/near_intents.rs create mode 100644 core/apps/api/src/swap/okx.rs create mode 100644 core/apps/api/src/webhooks.rs create mode 100644 core/apps/api/src/websocket.rs create mode 100644 core/apps/api/src/websocket_prices/client.rs create mode 100644 core/apps/api/src/websocket_prices/mod.rs create mode 100644 core/apps/api/src/websocket_prices/stream.rs create mode 100644 core/apps/api/src/websocket_stream/client.rs create mode 100644 core/apps/api/src/websocket_stream/mod.rs create mode 100644 core/apps/api/src/websocket_stream/price_handler.rs create mode 100644 core/apps/api/src/websocket_stream/stream.rs create mode 100644 core/apps/daemon/Cargo.toml create mode 100644 core/apps/daemon/src/client/mod.rs create mode 100644 core/apps/daemon/src/client/vault_address.rs create mode 100644 core/apps/daemon/src/consumers/fiat/fiat_webhook_consumer.rs create mode 100644 core/apps/daemon/src/consumers/fiat/mod.rs create mode 100644 core/apps/daemon/src/consumers/indexer/fetch_address_transactions_consumer.rs create mode 100644 core/apps/daemon/src/consumers/indexer/fetch_assets_consumer.rs create mode 100644 core/apps/daemon/src/consumers/indexer/fetch_blocks_consumer.rs create mode 100644 core/apps/daemon/src/consumers/indexer/fetch_coin_addresses_consumer.rs create mode 100644 core/apps/daemon/src/consumers/indexer/fetch_nft_asset_consumer.rs create mode 100644 core/apps/daemon/src/consumers/indexer/fetch_nft_assets_addresses_consumer.rs create mode 100644 core/apps/daemon/src/consumers/indexer/fetch_prices_consumer.rs create mode 100644 core/apps/daemon/src/consumers/indexer/fetch_token_addresses_consumer.rs create mode 100644 core/apps/daemon/src/consumers/indexer/mod.rs create mode 100644 core/apps/daemon/src/consumers/mod.rs create mode 100644 core/apps/daemon/src/consumers/notifications/in_app_notifications_consumer.rs create mode 100644 core/apps/daemon/src/consumers/notifications/mod.rs create mode 100644 core/apps/daemon/src/consumers/notifications/notifications_consumer.rs create mode 100644 core/apps/daemon/src/consumers/notifications/notifications_failed_consumer.rs create mode 100644 core/apps/daemon/src/consumers/rewards/mod.rs create mode 100644 core/apps/daemon/src/consumers/rewards/rewards_consumer.rs create mode 100644 core/apps/daemon/src/consumers/rewards/rewards_redemption_consumer.rs create mode 100644 core/apps/daemon/src/consumers/runner.rs create mode 100644 core/apps/daemon/src/consumers/store/mod.rs create mode 100644 core/apps/daemon/src/consumers/store/store_pending_transactions_consumer.rs create mode 100644 core/apps/daemon/src/consumers/store/store_prices_consumer.rs create mode 100644 core/apps/daemon/src/consumers/store/store_transactions_consumer.rs create mode 100644 core/apps/daemon/src/consumers/store/store_transactions_consumer_config.rs create mode 100644 core/apps/daemon/src/consumers/store/wallet_stream_consumer.rs create mode 100644 core/apps/daemon/src/consumers/support/mod.rs create mode 100644 core/apps/daemon/src/consumers/support/support_webhook_consumer.rs create mode 100644 core/apps/daemon/src/health.rs create mode 100644 core/apps/daemon/src/main.rs create mode 100644 core/apps/daemon/src/metrics/consumer.rs create mode 100644 core/apps/daemon/src/metrics/job.rs create mode 100644 core/apps/daemon/src/metrics/mod.rs create mode 100644 core/apps/daemon/src/metrics/parser.rs create mode 100644 core/apps/daemon/src/model.rs create mode 100644 core/apps/daemon/src/parser/mod.rs create mode 100644 core/apps/daemon/src/parser/parser_options.rs create mode 100644 core/apps/daemon/src/parser/parser_state.rs create mode 100644 core/apps/daemon/src/parser/plan.rs create mode 100644 core/apps/daemon/src/pusher/mod.rs create mode 100644 core/apps/daemon/src/pusher/pusher.rs create mode 100644 core/apps/daemon/src/reporters/consumer.rs create mode 100644 core/apps/daemon/src/reporters/job.rs create mode 100644 core/apps/daemon/src/reporters/mod.rs create mode 100644 core/apps/daemon/src/reporters/parser.rs create mode 100644 core/apps/daemon/src/setup/mod.rs create mode 100644 core/apps/daemon/src/setup/scan_addresses.rs create mode 100644 core/apps/daemon/src/shutdown.rs create mode 100644 core/apps/daemon/src/worker/alerter/mod.rs create mode 100644 core/apps/daemon/src/worker/alerter/price_alerts_sender.rs create mode 100644 core/apps/daemon/src/worker/alerter/staking_rewards_notifier.rs create mode 100644 core/apps/daemon/src/worker/assets/asset_rank_updater.rs create mode 100644 core/apps/daemon/src/worker/assets/assets_has_price_updater.rs create mode 100644 core/apps/daemon/src/worker/assets/assets_images_updater.rs create mode 100644 core/apps/daemon/src/worker/assets/mod.rs create mode 100644 core/apps/daemon/src/worker/assets/perpetual_updater.rs create mode 100644 core/apps/daemon/src/worker/assets/staking_apy_updater.rs create mode 100644 core/apps/daemon/src/worker/assets/usage_rank_updater.rs create mode 100644 core/apps/daemon/src/worker/assets/validator_scanner.rs create mode 100644 core/apps/daemon/src/worker/context.rs create mode 100644 core/apps/daemon/src/worker/fiat/fiat_assets_updater.rs create mode 100644 core/apps/daemon/src/worker/fiat/fiat_rates_updater.rs create mode 100644 core/apps/daemon/src/worker/fiat/mod.rs create mode 100644 core/apps/daemon/src/worker/job_schedule.rs create mode 100644 core/apps/daemon/src/worker/jobs.rs create mode 100644 core/apps/daemon/src/worker/mod.rs create mode 100644 core/apps/daemon/src/worker/perpetuals/mod.rs create mode 100644 core/apps/daemon/src/worker/perpetuals/perpetual_address_refresher.rs create mode 100644 core/apps/daemon/src/worker/perpetuals/perpetual_classifier.rs create mode 100644 core/apps/daemon/src/worker/perpetuals/perpetual_observer.rs create mode 100644 core/apps/daemon/src/worker/plan.rs create mode 100644 core/apps/daemon/src/worker/prices/charts_updater.rs create mode 100644 core/apps/daemon/src/worker/prices/markets_updater.rs create mode 100644 core/apps/daemon/src/worker/prices/missing_prices_publisher.rs create mode 100644 core/apps/daemon/src/worker/prices/mod.rs create mode 100644 core/apps/daemon/src/worker/prices/observed_prices_updater.rs create mode 100644 core/apps/daemon/src/worker/prices/prices_cleanup_updater.rs create mode 100644 core/apps/daemon/src/worker/prices/prices_metrics_updater.rs create mode 100644 core/apps/daemon/src/worker/prices/prices_updater.rs create mode 100644 core/apps/daemon/src/worker/rewards/mod.rs create mode 100644 core/apps/daemon/src/worker/rewards/rewards_abuse_checker.rs create mode 100644 core/apps/daemon/src/worker/rewards/rewards_eligibility_checker.rs create mode 100644 core/apps/daemon/src/worker/runtime.rs create mode 100644 core/apps/daemon/src/worker/search/assets_index_updater.rs create mode 100644 core/apps/daemon/src/worker/search/mod.rs create mode 100644 core/apps/daemon/src/worker/search/nfts_index_updater.rs create mode 100644 core/apps/daemon/src/worker/search/perpetuals_index_updater.rs create mode 100644 core/apps/daemon/src/worker/search/sync.rs create mode 100644 core/apps/daemon/src/worker/system/device_updater.rs create mode 100644 core/apps/daemon/src/worker/system/mod.rs create mode 100644 core/apps/daemon/src/worker/system/model.rs create mode 100644 core/apps/daemon/src/worker/system/observers/inactive_devices_observer.rs create mode 100644 core/apps/daemon/src/worker/system/observers/mod.rs create mode 100644 core/apps/daemon/src/worker/system/transaction_cleanup.rs create mode 100644 core/apps/daemon/src/worker/system/version_updater.rs create mode 100644 core/apps/daemon/src/worker/transactions/in_transit_updater.rs create mode 100644 core/apps/daemon/src/worker/transactions/mod.rs create mode 100644 core/apps/daemon/src/worker/transactions/pending_transactions_updater.rs create mode 100644 core/apps/daemon/src/worker/transactions/vault_addresses_updater.rs create mode 100644 core/apps/dynode/CLAUDE.md create mode 100644 core/apps/dynode/Cargo.toml create mode 100644 core/apps/dynode/chains.yml create mode 100644 core/apps/dynode/config.yml create mode 100644 core/apps/dynode/justfile create mode 100644 core/apps/dynode/src/auth.rs create mode 100644 core/apps/dynode/src/cache/memory.rs create mode 100644 core/apps/dynode/src/cache/mod.rs create mode 100644 core/apps/dynode/src/cache/types.rs create mode 100644 core/apps/dynode/src/config/cache.rs create mode 100644 core/apps/dynode/src/config/domain.rs create mode 100644 core/apps/dynode/src/config/metrics.rs create mode 100644 core/apps/dynode/src/config/mod.rs create mode 100644 core/apps/dynode/src/config/url.rs create mode 100644 core/apps/dynode/src/jsonrpc_types.rs create mode 100644 core/apps/dynode/src/lib.rs create mode 100644 core/apps/dynode/src/main.rs create mode 100644 core/apps/dynode/src/metrics/mod.rs create mode 100644 core/apps/dynode/src/monitoring/chain_client.rs create mode 100644 core/apps/dynode/src/monitoring/mod.rs create mode 100644 core/apps/dynode/src/monitoring/service.rs create mode 100644 core/apps/dynode/src/monitoring/switch_reason.rs create mode 100644 core/apps/dynode/src/monitoring/sync.rs create mode 100644 core/apps/dynode/src/monitoring/telemetry.rs create mode 100644 core/apps/dynode/src/monitoring/worker.rs create mode 100644 core/apps/dynode/src/proxy/constants.rs create mode 100644 core/apps/dynode/src/proxy/jsonrpc.rs create mode 100644 core/apps/dynode/src/proxy/mod.rs create mode 100644 core/apps/dynode/src/proxy/proxy_builder.rs create mode 100644 core/apps/dynode/src/proxy/proxy_request.rs create mode 100644 core/apps/dynode/src/proxy/proxy_request_builder.rs create mode 100644 core/apps/dynode/src/proxy/request_builder.rs create mode 100644 core/apps/dynode/src/proxy/request_url.rs create mode 100644 core/apps/dynode/src/proxy/response_builder.rs create mode 100644 core/apps/dynode/src/proxy/service.rs create mode 100644 core/apps/dynode/src/proxy/types.rs create mode 100644 core/apps/dynode/src/response.rs create mode 100644 core/apps/dynode/src/testkit/config.rs create mode 100644 core/apps/dynode/src/testkit/mod.rs create mode 100644 core/apps/dynode/src/testkit/sync.rs create mode 100644 core/apps/dynode/src/webhook.rs create mode 100644 core/bin/cli/Cargo.toml create mode 100644 core/bin/cli/src/commands/asset.rs create mode 100644 core/bin/cli/src/commands/balance.rs create mode 100644 core/bin/cli/src/commands/mod.rs create mode 100644 core/bin/cli/src/main.rs create mode 100644 core/bin/gas-bench/Cargo.toml create mode 100644 core/bin/gas-bench/src/client.rs create mode 100644 core/bin/gas-bench/src/etherscan.rs create mode 100644 core/bin/gas-bench/src/gasflow.rs create mode 100644 core/bin/gas-bench/src/helius/client.rs create mode 100644 core/bin/gas-bench/src/helius/mod.rs create mode 100644 core/bin/gas-bench/src/helius/model.rs create mode 100644 core/bin/gas-bench/src/jito/client.rs create mode 100644 core/bin/gas-bench/src/jito/mod.rs create mode 100644 core/bin/gas-bench/src/jito/model.rs create mode 100644 core/bin/gas-bench/src/main.rs create mode 100644 core/bin/gas-bench/src/solana_client.rs create mode 100644 core/bin/generate/Cargo.toml create mode 100644 core/bin/generate/src/main.rs create mode 100644 core/bin/img-downloader/Cargo.toml create mode 100644 core/bin/img-downloader/src/cli_args.rs create mode 100644 core/bin/img-downloader/src/main.rs create mode 100644 core/bin/uniffi-bindgen/Cargo.toml create mode 100644 core/bin/uniffi-bindgen/src/main.rs create mode 100644 core/crates/api_connector/Cargo.toml create mode 100644 core/crates/api_connector/src/app_store_client/client.rs create mode 100644 core/crates/api_connector/src/app_store_client/mod.rs create mode 100644 core/crates/api_connector/src/app_store_client/models.rs create mode 100644 core/crates/api_connector/src/lib.rs create mode 100644 core/crates/api_connector/src/pusher/client.rs create mode 100644 core/crates/api_connector/src/pusher/mod.rs create mode 100644 core/crates/api_connector/src/pusher/model.rs create mode 100644 core/crates/api_connector/src/static_assets_client/client.rs create mode 100644 core/crates/api_connector/src/static_assets_client/mod.rs create mode 100644 core/crates/api_connector/src/static_assets_client/models.rs create mode 100644 core/crates/cacher/Cargo.toml create mode 100644 core/crates/cacher/src/error.rs create mode 100644 core/crates/cacher/src/keys.rs create mode 100644 core/crates/cacher/src/lib.rs create mode 100644 core/crates/chain_primitives/Cargo.toml create mode 100644 core/crates/chain_primitives/src/balance_diff.rs create mode 100644 core/crates/chain_primitives/src/lib.rs create mode 100644 core/crates/chain_primitives/src/token_id.rs create mode 100644 core/crates/chain_traits/Cargo.toml create mode 100644 core/crates/chain_traits/src/lib.rs create mode 100644 core/crates/coingecko/Cargo.toml create mode 100644 core/crates/coingecko/src/client.rs create mode 100644 core/crates/coingecko/src/lib.rs create mode 100644 core/crates/coingecko/src/mapper.rs create mode 100644 core/crates/coingecko/src/model.rs create mode 100644 core/crates/coingecko/src/testkit.rs create mode 100644 core/crates/fiat/Cargo.toml create mode 100644 core/crates/fiat/src/client.rs create mode 100644 core/crates/fiat/src/error.rs create mode 100644 core/crates/fiat/src/fiat_cacher_client.rs create mode 100644 core/crates/fiat/src/hmac_signature.rs create mode 100644 core/crates/fiat/src/ip_check_client.rs create mode 100644 core/crates/fiat/src/lib.rs create mode 100644 core/crates/fiat/src/model.rs create mode 100644 core/crates/fiat/src/provider.rs create mode 100644 core/crates/fiat/src/providers/banxa/client.rs create mode 100644 core/crates/fiat/src/providers/banxa/mapper.rs create mode 100644 core/crates/fiat/src/providers/banxa/mod.rs create mode 100644 core/crates/fiat/src/providers/banxa/models/asset.rs create mode 100644 core/crates/fiat/src/providers/banxa/models/country.rs create mode 100644 core/crates/fiat/src/providers/banxa/models/create_order.rs create mode 100644 core/crates/fiat/src/providers/banxa/models/fiat_currencies.rs create mode 100644 core/crates/fiat/src/providers/banxa/models/mod.rs create mode 100644 core/crates/fiat/src/providers/banxa/models/order.rs create mode 100644 core/crates/fiat/src/providers/banxa/models/quote.rs create mode 100644 core/crates/fiat/src/providers/banxa/models/webhook.rs create mode 100644 core/crates/fiat/src/providers/banxa/provider.rs create mode 100644 core/crates/fiat/src/providers/flashnet/client.rs create mode 100644 core/crates/fiat/src/providers/flashnet/mapper.rs create mode 100644 core/crates/fiat/src/providers/flashnet/mod.rs create mode 100644 core/crates/fiat/src/providers/flashnet/model.rs create mode 100644 core/crates/fiat/src/providers/flashnet/provider.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/client.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/mapper.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/mod.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/models/asset.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/models/limits.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/models/mod.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/models/quote.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/models/response.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/models/webhook.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/provider.rs create mode 100644 core/crates/fiat/src/providers/mercuryo/widget.rs create mode 100644 core/crates/fiat/src/providers/mod.rs create mode 100644 core/crates/fiat/src/providers/moonpay/client.rs create mode 100644 core/crates/fiat/src/providers/moonpay/mapper.rs create mode 100644 core/crates/fiat/src/providers/moonpay/mod.rs create mode 100644 core/crates/fiat/src/providers/moonpay/models/assets.rs create mode 100644 core/crates/fiat/src/providers/moonpay/models/common.rs create mode 100644 core/crates/fiat/src/providers/moonpay/models/countries.rs create mode 100644 core/crates/fiat/src/providers/moonpay/models/mod.rs create mode 100644 core/crates/fiat/src/providers/moonpay/models/quotes.rs create mode 100644 core/crates/fiat/src/providers/moonpay/models/transactions.rs create mode 100644 core/crates/fiat/src/providers/moonpay/provider.rs create mode 100644 core/crates/fiat/src/providers/moonpay/testkit.rs create mode 100644 core/crates/fiat/src/providers/paybis/client.rs create mode 100644 core/crates/fiat/src/providers/paybis/mapper.rs create mode 100644 core/crates/fiat/src/providers/paybis/mod.rs create mode 100644 core/crates/fiat/src/providers/paybis/models/asset.rs create mode 100644 core/crates/fiat/src/providers/paybis/models/country.rs create mode 100644 core/crates/fiat/src/providers/paybis/models/limits.rs create mode 100644 core/crates/fiat/src/providers/paybis/models/mod.rs create mode 100644 core/crates/fiat/src/providers/paybis/models/quote.rs create mode 100644 core/crates/fiat/src/providers/paybis/models/request.rs create mode 100644 core/crates/fiat/src/providers/paybis/models/webhook.rs create mode 100644 core/crates/fiat/src/providers/paybis/provider.rs create mode 100644 core/crates/fiat/src/providers/transak/client.rs create mode 100644 core/crates/fiat/src/providers/transak/mapper.rs create mode 100644 core/crates/fiat/src/providers/transak/mod.rs create mode 100644 core/crates/fiat/src/providers/transak/models/assets.rs create mode 100644 core/crates/fiat/src/providers/transak/models/auth.rs create mode 100644 core/crates/fiat/src/providers/transak/models/common.rs create mode 100644 core/crates/fiat/src/providers/transak/models/countries.rs create mode 100644 core/crates/fiat/src/providers/transak/models/fiat_currencies.rs create mode 100644 core/crates/fiat/src/providers/transak/models/mod.rs create mode 100644 core/crates/fiat/src/providers/transak/models/quotes.rs create mode 100644 core/crates/fiat/src/providers/transak/models/transactions.rs create mode 100644 core/crates/fiat/src/providers/transak/provider.rs create mode 100644 core/crates/fiat/src/rsa_signature.rs create mode 100644 core/crates/fiat/src/testkit.rs create mode 100644 core/crates/fiat/src/transaction_info_mapper.rs create mode 100644 core/crates/fiat/testdata/banxa/fiat_currencies.json create mode 100644 core/crates/fiat/testdata/banxa/transaction_sell_failed.json create mode 100644 core/crates/fiat/testdata/flashnet/estimate.json create mode 100644 core/crates/fiat/testdata/flashnet/onramp_response.json create mode 100644 core/crates/fiat/testdata/flashnet/order_completed.json create mode 100644 core/crates/fiat/testdata/flashnet/order_completed_eth.json create mode 100644 core/crates/fiat/testdata/flashnet/routes.json create mode 100644 core/crates/fiat/testdata/flashnet/webhook_completed.json create mode 100644 core/crates/fiat/testdata/flashnet/webhook_pending.json create mode 100644 core/crates/fiat/testdata/mercuryo/assets.json create mode 100644 core/crates/fiat/testdata/mercuryo/webhook_buy_complete.json create mode 100644 core/crates/fiat/testdata/mercuryo/webhook_mobile_pay_complete.json create mode 100644 core/crates/fiat/testdata/mercuryo/webhook_sell_complete.json create mode 100644 core/crates/fiat/testdata/mercuryo/webhook_withdraw_complete.json create mode 100644 core/crates/fiat/testdata/mercuryo/webhook_withdraw_same_order_complete.json create mode 100644 core/crates/fiat/testdata/moonpay/assets.json create mode 100644 core/crates/fiat/testdata/moonpay/sell_transaction_complete.json create mode 100644 core/crates/fiat/testdata/moonpay/transaction_sell_failed.json create mode 100644 core/crates/fiat/testdata/moonpay/webhook_buy_complete.json create mode 100644 core/crates/fiat/testdata/moonpay/webhook_sell_complete_.json create mode 100644 core/crates/fiat/testdata/paybis/assets.json create mode 100644 core/crates/fiat/testdata/paybis/assets_with_limits.json create mode 100644 core/crates/fiat/testdata/paybis/quote_bitcoin.json create mode 100644 core/crates/fiat/testdata/paybis/webhook_transaction_completed.json create mode 100644 core/crates/fiat/testdata/paybis/webhook_transaction_no_changes.json create mode 100644 core/crates/fiat/testdata/paybis/webhook_transaction_started.json create mode 100644 core/crates/fiat/testdata/paybis/webhook_transaction_started_no_payment.json create mode 100644 core/crates/fiat/testdata/transak/fiat_currencies.json create mode 100644 core/crates/fiat/testdata/transak/transaction_buy_error.json create mode 100644 core/crates/gem_algorand/Cargo.toml create mode 100644 core/crates/gem_algorand/src/address.rs create mode 100644 core/crates/gem_algorand/src/constants.rs create mode 100644 core/crates/gem_algorand/src/lib.rs create mode 100644 core/crates/gem_algorand/src/models/account.rs create mode 100644 core/crates/gem_algorand/src/models/asset.rs create mode 100644 core/crates/gem_algorand/src/models/block.rs create mode 100644 core/crates/gem_algorand/src/models/indexer.rs create mode 100644 core/crates/gem_algorand/src/models/mod.rs create mode 100644 core/crates/gem_algorand/src/models/signing/mod.rs create mode 100644 core/crates/gem_algorand/src/models/signing/operation.rs create mode 100644 core/crates/gem_algorand/src/models/signing/transaction.rs create mode 100644 core/crates/gem_algorand/src/models/transaction.rs create mode 100644 core/crates/gem_algorand/src/provider/balances.rs create mode 100644 core/crates/gem_algorand/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_algorand/src/provider/mod.rs create mode 100644 core/crates/gem_algorand/src/provider/preload.rs create mode 100644 core/crates/gem_algorand/src/provider/request_classifier.rs create mode 100644 core/crates/gem_algorand/src/provider/state.rs create mode 100644 core/crates/gem_algorand/src/provider/state_mapper.rs create mode 100644 core/crates/gem_algorand/src/provider/testkit.rs create mode 100644 core/crates/gem_algorand/src/provider/token.rs create mode 100644 core/crates/gem_algorand/src/provider/token_mapper.rs create mode 100644 core/crates/gem_algorand/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_algorand/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_algorand/src/provider/transaction_state.rs create mode 100644 core/crates/gem_algorand/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_algorand/src/provider/transactions.rs create mode 100644 core/crates/gem_algorand/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_algorand/src/rpc/client.rs create mode 100644 core/crates/gem_algorand/src/rpc/client_indexer.rs create mode 100644 core/crates/gem_algorand/src/rpc/mod.rs create mode 100644 core/crates/gem_algorand/src/signer/chain_signer.rs create mode 100644 core/crates/gem_algorand/src/signer/mod.rs create mode 100644 core/crates/gem_algorand/src/signer/serialization.rs create mode 100644 core/crates/gem_algorand/src/signer/signing.rs create mode 100644 core/crates/gem_algorand/testdata/account.json create mode 100644 core/crates/gem_algorand/testdata/transaction_broadcast_error.json create mode 100644 core/crates/gem_algorand/testdata/transaction_broadcast_success.json create mode 100644 core/crates/gem_algorand/testdata/transaction_by_hash.json create mode 100644 core/crates/gem_algorand/testdata/transaction_transfer_pending.json create mode 100644 core/crates/gem_algorand/testdata/transaction_transfer_success.json create mode 100644 core/crates/gem_aptos/Cargo.toml create mode 100644 core/crates/gem_aptos/src/address.rs create mode 100644 core/crates/gem_aptos/src/constants.rs create mode 100644 core/crates/gem_aptos/src/lib.rs create mode 100644 core/crates/gem_aptos/src/models/account.rs create mode 100644 core/crates/gem_aptos/src/models/coin.rs create mode 100644 core/crates/gem_aptos/src/models/fee.rs create mode 100644 core/crates/gem_aptos/src/models/ledger.rs create mode 100644 core/crates/gem_aptos/src/models/mod.rs create mode 100644 core/crates/gem_aptos/src/models/signer_transaction.rs create mode 100644 core/crates/gem_aptos/src/models/staking.rs create mode 100644 core/crates/gem_aptos/src/models/transaction.rs create mode 100644 core/crates/gem_aptos/src/move/mod.rs create mode 100644 core/crates/gem_aptos/src/move/parser.rs create mode 100644 core/crates/gem_aptos/src/move/types.rs create mode 100644 core/crates/gem_aptos/src/move/values.rs create mode 100644 core/crates/gem_aptos/src/provider/balances.rs create mode 100644 core/crates/gem_aptos/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_aptos/src/provider/mod.rs create mode 100644 core/crates/gem_aptos/src/provider/payload_builder.rs create mode 100644 core/crates/gem_aptos/src/provider/preload.rs create mode 100644 core/crates/gem_aptos/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_aptos/src/provider/request_classifier.rs create mode 100644 core/crates/gem_aptos/src/provider/staking.rs create mode 100644 core/crates/gem_aptos/src/provider/staking_mapper.rs create mode 100644 core/crates/gem_aptos/src/provider/state.rs create mode 100644 core/crates/gem_aptos/src/provider/state_mapper.rs create mode 100644 core/crates/gem_aptos/src/provider/testkit.rs create mode 100644 core/crates/gem_aptos/src/provider/token.rs create mode 100644 core/crates/gem_aptos/src/provider/token_mapper.rs create mode 100644 core/crates/gem_aptos/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_aptos/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_aptos/src/provider/transaction_state.rs create mode 100644 core/crates/gem_aptos/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_aptos/src/provider/transactions.rs create mode 100644 core/crates/gem_aptos/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_aptos/src/rpc/client.rs create mode 100644 core/crates/gem_aptos/src/rpc/mod.rs create mode 100644 core/crates/gem_aptos/src/signer/abi.rs create mode 100644 core/crates/gem_aptos/src/signer/chain_signer.rs create mode 100644 core/crates/gem_aptos/src/signer/mod.rs create mode 100644 core/crates/gem_aptos/src/signer/payload.rs create mode 100644 core/crates/gem_aptos/src/signer/transaction.rs create mode 100644 core/crates/gem_aptos/src/token_id.rs create mode 100644 core/crates/gem_aptos/testdata/invalid_transaction_response.json create mode 100644 core/crates/gem_aptos/testdata/transaction_near_intent_transfer.json create mode 100644 core/crates/gem_aptos/testdata/transaction_stake_delegate.json create mode 100644 core/crates/gem_aptos/testdata/transaction_stake_undelegate.json create mode 100644 core/crates/gem_aptos/testdata/transaction_swap_panora.json create mode 100644 core/crates/gem_auth/Cargo.toml create mode 100644 core/crates/gem_auth/src/client.rs create mode 100644 core/crates/gem_auth/src/device_signature.rs create mode 100644 core/crates/gem_auth/src/jwt.rs create mode 100644 core/crates/gem_auth/src/lib.rs create mode 100644 core/crates/gem_auth/src/signature.rs create mode 100644 core/crates/gem_bitcoin/Cargo.toml create mode 100644 core/crates/gem_bitcoin/src/lib.rs create mode 100644 core/crates/gem_bitcoin/src/models/account.rs create mode 100644 core/crates/gem_bitcoin/src/models/address.rs create mode 100644 core/crates/gem_bitcoin/src/models/block.rs create mode 100644 core/crates/gem_bitcoin/src/models/fee.rs create mode 100644 core/crates/gem_bitcoin/src/models/mod.rs create mode 100644 core/crates/gem_bitcoin/src/models/transaction.rs create mode 100644 core/crates/gem_bitcoin/src/provider/balances.rs create mode 100644 core/crates/gem_bitcoin/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_bitcoin/src/provider/mod.rs create mode 100644 core/crates/gem_bitcoin/src/provider/preload.rs create mode 100644 core/crates/gem_bitcoin/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_bitcoin/src/provider/request_classifier.rs create mode 100644 core/crates/gem_bitcoin/src/provider/state.rs create mode 100644 core/crates/gem_bitcoin/src/provider/state_mapper.rs create mode 100644 core/crates/gem_bitcoin/src/provider/testkit.rs create mode 100644 core/crates/gem_bitcoin/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_bitcoin/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_bitcoin/src/provider/transaction_state.rs create mode 100644 core/crates/gem_bitcoin/src/provider/transactions.rs create mode 100644 core/crates/gem_bitcoin/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_bitcoin/src/rpc/client.rs create mode 100644 core/crates/gem_bitcoin/src/rpc/mod.rs create mode 100644 core/crates/gem_bitcoin/src/signer/encoding.rs create mode 100644 core/crates/gem_bitcoin/src/signer/mod.rs create mode 100644 core/crates/gem_bitcoin/src/signer/signature.rs create mode 100644 core/crates/gem_bitcoin/src/signer/types.rs create mode 100644 core/crates/gem_bitcoin/src/testkit/mod.rs create mode 100644 core/crates/gem_bitcoin/src/testkit/transaction_mock.rs create mode 100644 core/crates/gem_bitcoin/testdata/transaction_by_hash.json create mode 100644 core/crates/gem_bsc/Cargo.toml create mode 100644 core/crates/gem_bsc/src/lib.rs create mode 100644 core/crates/gem_bsc/src/stake_hub.rs create mode 100644 core/crates/gem_cardano/Cargo.toml create mode 100644 core/crates/gem_cardano/src/address.rs create mode 100644 core/crates/gem_cardano/src/cbor.rs create mode 100644 core/crates/gem_cardano/src/lib.rs create mode 100644 core/crates/gem_cardano/src/models/account.rs create mode 100644 core/crates/gem_cardano/src/models/block.rs create mode 100644 core/crates/gem_cardano/src/models/mod.rs create mode 100644 core/crates/gem_cardano/src/models/rpc.rs create mode 100644 core/crates/gem_cardano/src/models/transaction.rs create mode 100644 core/crates/gem_cardano/src/models/utxo.rs create mode 100644 core/crates/gem_cardano/src/planner.rs create mode 100644 core/crates/gem_cardano/src/provider/balances.rs create mode 100644 core/crates/gem_cardano/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_cardano/src/provider/mod.rs create mode 100644 core/crates/gem_cardano/src/provider/preload.rs create mode 100644 core/crates/gem_cardano/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_cardano/src/provider/request_classifier.rs create mode 100644 core/crates/gem_cardano/src/provider/state.rs create mode 100644 core/crates/gem_cardano/src/provider/testkit.rs create mode 100644 core/crates/gem_cardano/src/provider/token.rs create mode 100644 core/crates/gem_cardano/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_cardano/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_cardano/src/provider/transaction_state.rs create mode 100644 core/crates/gem_cardano/src/provider/transactions.rs create mode 100644 core/crates/gem_cardano/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_cardano/src/rpc/client.rs create mode 100644 core/crates/gem_cardano/src/rpc/mod.rs create mode 100644 core/crates/gem_cardano/src/signer/chain_signer.rs create mode 100644 core/crates/gem_cardano/src/signer/extended_key.rs create mode 100644 core/crates/gem_cardano/src/signer/mod.rs create mode 100644 core/crates/gem_cardano/src/transaction.rs create mode 100644 core/crates/gem_client/Cargo.toml create mode 100644 core/crates/gem_client/src/client_config.rs create mode 100644 core/crates/gem_client/src/content_type.rs create mode 100644 core/crates/gem_client/src/lib.rs create mode 100644 core/crates/gem_client/src/query.rs create mode 100644 core/crates/gem_client/src/reqwest_client.rs create mode 100644 core/crates/gem_client/src/retry.rs create mode 100644 core/crates/gem_client/src/testkit.rs create mode 100644 core/crates/gem_client/src/types.rs create mode 100644 core/crates/gem_cosmos/Cargo.toml create mode 100644 core/crates/gem_cosmos/src/address.rs create mode 100644 core/crates/gem_cosmos/src/constants.rs create mode 100644 core/crates/gem_cosmos/src/lib.rs create mode 100644 core/crates/gem_cosmos/src/models/account.rs create mode 100644 core/crates/gem_cosmos/src/models/block.rs create mode 100644 core/crates/gem_cosmos/src/models/contract.rs create mode 100644 core/crates/gem_cosmos/src/models/ibc.rs create mode 100644 core/crates/gem_cosmos/src/models/long.rs create mode 100644 core/crates/gem_cosmos/src/models/message.rs create mode 100644 core/crates/gem_cosmos/src/models/mod.rs create mode 100644 core/crates/gem_cosmos/src/models/staking.rs create mode 100644 core/crates/gem_cosmos/src/models/staking_osmosis.rs create mode 100644 core/crates/gem_cosmos/src/models/transaction.rs create mode 100644 core/crates/gem_cosmos/src/provider/balances.rs create mode 100644 core/crates/gem_cosmos/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_cosmos/src/provider/mod.rs create mode 100644 core/crates/gem_cosmos/src/provider/preload.rs create mode 100644 core/crates/gem_cosmos/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_cosmos/src/provider/request_classifier.rs create mode 100644 core/crates/gem_cosmos/src/provider/staking.rs create mode 100644 core/crates/gem_cosmos/src/provider/staking_mapper.rs create mode 100644 core/crates/gem_cosmos/src/provider/state.rs create mode 100644 core/crates/gem_cosmos/src/provider/state_mapper.rs create mode 100644 core/crates/gem_cosmos/src/provider/testkit.rs create mode 100644 core/crates/gem_cosmos/src/provider/token.rs create mode 100644 core/crates/gem_cosmos/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_cosmos/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_cosmos/src/provider/transaction_state.rs create mode 100644 core/crates/gem_cosmos/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_cosmos/src/provider/transactions.rs create mode 100644 core/crates/gem_cosmos/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_cosmos/src/rpc/client.rs create mode 100644 core/crates/gem_cosmos/src/rpc/mod.rs create mode 100644 core/crates/gem_cosmos/src/signer/chain_signer.rs create mode 100644 core/crates/gem_cosmos/src/signer/mod.rs create mode 100644 core/crates/gem_cosmos/src/signer/transaction.rs create mode 100644 core/crates/gem_cosmos/testdata/delegate.json create mode 100644 core/crates/gem_cosmos/testdata/reverted_transfer_spam.json create mode 100644 core/crates/gem_cosmos/testdata/rewards.json create mode 100644 core/crates/gem_cosmos/testdata/staking_delegations.json create mode 100644 core/crates/gem_cosmos/testdata/staking_rewards.json create mode 100644 core/crates/gem_cosmos/testdata/staking_validators.json create mode 100644 core/crates/gem_cosmos/testdata/swap_execute_contract.json create mode 100644 core/crates/gem_cosmos/testdata/swap_ibc_transfer.json create mode 100644 core/crates/gem_cosmos/testdata/transaction_broadcast_failed.json create mode 100644 core/crates/gem_cosmos/testdata/transaction_broadcast_success.json create mode 100644 core/crates/gem_cosmos/testdata/transfer.json create mode 100644 core/crates/gem_cosmos/testdata/transfer_ibc.json create mode 100644 core/crates/gem_cosmos/testdata/transfer_thorchain.json create mode 100644 core/crates/gem_encoding/Cargo.toml create mode 100644 core/crates/gem_encoding/src/base32.rs create mode 100644 core/crates/gem_encoding/src/base58.rs create mode 100644 core/crates/gem_encoding/src/base64.rs create mode 100644 core/crates/gem_encoding/src/error.rs create mode 100644 core/crates/gem_encoding/src/lib.rs create mode 100644 core/crates/gem_encoding/src/protobuf/decode.rs create mode 100644 core/crates/gem_encoding/src/protobuf/encode.rs create mode 100644 core/crates/gem_encoding/src/protobuf/field_codec.rs create mode 100644 core/crates/gem_encoding/src/protobuf/grpc.rs create mode 100644 core/crates/gem_encoding/src/protobuf/message.rs create mode 100644 core/crates/gem_encoding/src/protobuf/mod.rs create mode 100644 core/crates/gem_encoding/src/protobuf/wire.rs create mode 100644 core/crates/gem_evm/Cargo.toml create mode 100644 core/crates/gem_evm/src/across/contracts/config_store.rs create mode 100644 core/crates/gem_evm/src/across/contracts/hub_pool.rs create mode 100644 core/crates/gem_evm/src/across/contracts/mod.rs create mode 100644 core/crates/gem_evm/src/across/contracts/multicall_handler.rs create mode 100644 core/crates/gem_evm/src/across/contracts/spoke_pool.rs create mode 100644 core/crates/gem_evm/src/across/deployment.rs create mode 100644 core/crates/gem_evm/src/across/fees/lp.rs create mode 100644 core/crates/gem_evm/src/across/fees/mod.rs create mode 100644 core/crates/gem_evm/src/across/fees/relayer.rs create mode 100644 core/crates/gem_evm/src/across/mod.rs create mode 100644 core/crates/gem_evm/src/address.rs create mode 100644 core/crates/gem_evm/src/address_deserializer.rs create mode 100644 core/crates/gem_evm/src/call_decoder.rs create mode 100644 core/crates/gem_evm/src/chainlink/contract.rs create mode 100644 core/crates/gem_evm/src/chainlink/mod.rs create mode 100644 core/crates/gem_evm/src/constants.rs create mode 100644 core/crates/gem_evm/src/contracts/erc1155.rs create mode 100644 core/crates/gem_evm/src/contracts/erc20.rs create mode 100644 core/crates/gem_evm/src/contracts/erc4626.rs create mode 100644 core/crates/gem_evm/src/contracts/erc721.rs create mode 100644 core/crates/gem_evm/src/contracts/mod.rs create mode 100644 core/crates/gem_evm/src/domain.rs create mode 100644 core/crates/gem_evm/src/eip712.rs create mode 100644 core/crates/gem_evm/src/encode.rs create mode 100644 core/crates/gem_evm/src/ether_conv.rs create mode 100644 core/crates/gem_evm/src/everstake/client.rs create mode 100644 core/crates/gem_evm/src/everstake/constants.rs create mode 100644 core/crates/gem_evm/src/everstake/contracts.rs create mode 100644 core/crates/gem_evm/src/everstake/mapper.rs create mode 100644 core/crates/gem_evm/src/everstake/mod.rs create mode 100644 core/crates/gem_evm/src/everstake/models.rs create mode 100644 core/crates/gem_evm/src/fee_calculator.rs create mode 100644 core/crates/gem_evm/src/jsonrpc.rs create mode 100644 core/crates/gem_evm/src/lib.rs create mode 100644 core/crates/gem_evm/src/message.rs create mode 100644 core/crates/gem_evm/src/models/block_parameter.rs create mode 100644 core/crates/gem_evm/src/models/fee.rs create mode 100644 core/crates/gem_evm/src/models/mod.rs create mode 100644 core/crates/gem_evm/src/models/transaction.rs create mode 100644 core/crates/gem_evm/src/monad/constants.rs create mode 100644 core/crates/gem_evm/src/monad/contracts.rs create mode 100644 core/crates/gem_evm/src/monad/mapper.rs create mode 100644 core/crates/gem_evm/src/monad/mod.rs create mode 100644 core/crates/gem_evm/src/multicall3.rs create mode 100644 core/crates/gem_evm/src/permit2.rs create mode 100644 core/crates/gem_evm/src/provider/accounts.rs create mode 100644 core/crates/gem_evm/src/provider/balances.rs create mode 100644 core/crates/gem_evm/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_evm/src/provider/balances_smartchain.rs create mode 100644 core/crates/gem_evm/src/provider/mod.rs create mode 100644 core/crates/gem_evm/src/provider/preload.rs create mode 100644 core/crates/gem_evm/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_evm/src/provider/preload_optimism.rs create mode 100644 core/crates/gem_evm/src/provider/request_classifier.rs create mode 100644 core/crates/gem_evm/src/provider/staking.rs create mode 100644 core/crates/gem_evm/src/provider/staking_ethereum.rs create mode 100644 core/crates/gem_evm/src/provider/staking_monad.rs create mode 100644 core/crates/gem_evm/src/provider/staking_smartchain.rs create mode 100644 core/crates/gem_evm/src/provider/state.rs create mode 100644 core/crates/gem_evm/src/provider/state_mapper.rs create mode 100644 core/crates/gem_evm/src/provider/testkit.rs create mode 100644 core/crates/gem_evm/src/provider/token.rs create mode 100644 core/crates/gem_evm/src/provider/token_mapper.rs create mode 100644 core/crates/gem_evm/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_evm/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_evm/src/provider/transaction_state.rs create mode 100644 core/crates/gem_evm/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_evm/src/provider/transactions.rs create mode 100644 core/crates/gem_evm/src/registry.rs create mode 100644 core/crates/gem_evm/src/rpc/ankr/client.rs create mode 100644 core/crates/gem_evm/src/rpc/ankr/mapper.rs create mode 100644 core/crates/gem_evm/src/rpc/ankr/mod.rs create mode 100644 core/crates/gem_evm/src/rpc/ankr/model.rs create mode 100644 core/crates/gem_evm/src/rpc/balance_differ.rs create mode 100644 core/crates/gem_evm/src/rpc/client.rs create mode 100644 core/crates/gem_evm/src/rpc/mapper.rs create mode 100644 core/crates/gem_evm/src/rpc/mod.rs create mode 100644 core/crates/gem_evm/src/rpc/model.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/dex.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/mod.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/okx.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/pancakeswap.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/staking/everstake.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/staking/mod.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/staking/monad.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/staking/smartchain.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/staking/transaction.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/universal_router.rs create mode 100644 core/crates/gem_evm/src/rpc/parsers/yo.rs create mode 100644 core/crates/gem_evm/src/signer/chain_signer.rs create mode 100644 core/crates/gem_evm/src/signer/eip1559.rs create mode 100644 core/crates/gem_evm/src/signer/mod.rs create mode 100644 core/crates/gem_evm/src/signer/model.rs create mode 100644 core/crates/gem_evm/src/signer/transaction.rs create mode 100644 core/crates/gem_evm/src/siwe.rs create mode 100644 core/crates/gem_evm/src/slippage.rs create mode 100644 core/crates/gem_evm/src/testkit/eip712_mock.rs create mode 100644 core/crates/gem_evm/src/testkit/mod.rs create mode 100644 core/crates/gem_evm/src/testkit/siwe_mock.rs create mode 100644 core/crates/gem_evm/src/thorchain/contracts.rs create mode 100644 core/crates/gem_evm/src/thorchain/mod.rs create mode 100644 core/crates/gem_evm/src/u256.rs create mode 100644 core/crates/gem_evm/src/uniswap/actions.rs create mode 100644 core/crates/gem_evm/src/uniswap/command.rs create mode 100644 core/crates/gem_evm/src/uniswap/contracts/mod.rs create mode 100644 core/crates/gem_evm/src/uniswap/contracts/v3.rs create mode 100644 core/crates/gem_evm/src/uniswap/contracts/v4.rs create mode 100644 core/crates/gem_evm/src/uniswap/deployment/mod.rs create mode 100644 core/crates/gem_evm/src/uniswap/deployment/v3.rs create mode 100644 core/crates/gem_evm/src/uniswap/deployment/v4.rs create mode 100644 core/crates/gem_evm/src/uniswap/mod.rs create mode 100644 core/crates/gem_evm/src/uniswap/path.rs create mode 100644 core/crates/gem_evm/src/weth.rs create mode 100644 core/crates/gem_evm/testdata/1inch_permit.json create mode 100644 core/crates/gem_evm/testdata/approve.json create mode 100644 core/crates/gem_evm/testdata/approve_receipt.json create mode 100644 core/crates/gem_evm/testdata/claim_rewards_receipt.json create mode 100644 core/crates/gem_evm/testdata/claim_rewards_tx.json create mode 100644 core/crates/gem_evm/testdata/contract_call_tx.json create mode 100644 core/crates/gem_evm/testdata/contract_call_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/contract_erc20_receipt.json create mode 100644 core/crates/gem_evm/testdata/contract_erc20_tx.json create mode 100644 core/crates/gem_evm/testdata/eip712_domain_chain_id_null_value.json create mode 100644 core/crates/gem_evm/testdata/eip712_domain_chain_id_schema_string.json create mode 100644 core/crates/gem_evm/testdata/eip712_domain_chain_id_without_schema_field.json create mode 100644 core/crates/gem_evm/testdata/eip712_int8_account_registration.json create mode 100644 core/crates/gem_evm/testdata/eip712_schema_chain_id_without_domain_value.json create mode 100644 core/crates/gem_evm/testdata/ens_upload_avatar.json create mode 100644 core/crates/gem_evm/testdata/everstake/transaction_stake.json create mode 100644 core/crates/gem_evm/testdata/everstake/transaction_stake_receipt.json create mode 100644 core/crates/gem_evm/testdata/everstake/transaction_unstake.json create mode 100644 core/crates/gem_evm/testdata/everstake/transaction_unstake_receipt.json create mode 100644 core/crates/gem_evm/testdata/everstake/transaction_withdraw.json create mode 100644 core/crates/gem_evm/testdata/everstake/transaction_withdraw_receipt.json create mode 100644 core/crates/gem_evm/testdata/everstake/transaction_withdraw_trace.json create mode 100644 core/crates/gem_evm/testdata/mayan_native_swap_tx.json create mode 100644 core/crates/gem_evm/testdata/mayan_native_swap_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/mayan_token_swap_tx.json create mode 100644 core/crates/gem_evm/testdata/mayan_token_swap_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/monad/transaction_staking_claim_rewards.json create mode 100644 core/crates/gem_evm/testdata/monad/transaction_staking_claim_rewards_receipt.json create mode 100644 core/crates/gem_evm/testdata/monad/transaction_staking_delegate.json create mode 100644 core/crates/gem_evm/testdata/monad/transaction_staking_delegate_receipt.json create mode 100644 core/crates/gem_evm/testdata/monad/transaction_staking_undelegate.json create mode 100644 core/crates/gem_evm/testdata/monad/transaction_staking_undelegate_receipt.json create mode 100644 core/crates/gem_evm/testdata/monad/transaction_staking_withdraw.json create mode 100644 core/crates/gem_evm/testdata/monad/transaction_staking_withdraw_receipt.json create mode 100644 core/crates/gem_evm/testdata/okx_base_swap_tx.json create mode 100644 core/crates/gem_evm/testdata/okx_base_swap_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/okx_bsc_swap_tx.json create mode 100644 core/crates/gem_evm/testdata/okx_bsc_swap_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/okx_bsc_swap_tx_trace.json create mode 100644 core/crates/gem_evm/testdata/pancakeswap_bsc_bnb_cake_tx.json create mode 100644 core/crates/gem_evm/testdata/pancakeswap_bsc_bnb_cake_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx.json create mode 100644 core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx_trace.json create mode 100644 core/crates/gem_evm/testdata/pancakeswap_bsc_swap_tx.json create mode 100644 core/crates/gem_evm/testdata/pancakeswap_bsc_swap_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/smartchain/transaction_staking_claim_rewards.json create mode 100644 core/crates/gem_evm/testdata/smartchain/transaction_staking_claim_rewards_receipt.json create mode 100644 core/crates/gem_evm/testdata/smartchain/transaction_staking_delegate.json create mode 100644 core/crates/gem_evm/testdata/smartchain/transaction_staking_delegate_receipt.json create mode 100644 core/crates/gem_evm/testdata/smartchain/transaction_staking_redelegate.json create mode 100644 core/crates/gem_evm/testdata/smartchain/transaction_staking_redelegate_receipt.json create mode 100644 core/crates/gem_evm/testdata/smartchain/transaction_staking_undelegate.json create mode 100644 core/crates/gem_evm/testdata/smartchain/transaction_staking_undelegate_receipt.json create mode 100644 core/crates/gem_evm/testdata/trace_replay_tx.json create mode 100644 core/crates/gem_evm/testdata/trace_replay_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/trace_replay_tx_trace.json create mode 100644 core/crates/gem_evm/testdata/transfer_erc20.json create mode 100644 core/crates/gem_evm/testdata/transfer_erc20_receipt.json create mode 100644 core/crates/gem_evm/testdata/transfer_high_gas_limit.json create mode 100644 core/crates/gem_evm/testdata/transfer_high_gas_limit_receipt.json create mode 100644 core/crates/gem_evm/testdata/transfer_nft_eip1155.json create mode 100644 core/crates/gem_evm/testdata/transfer_nft_eip1155_receipt.json create mode 100644 core/crates/gem_evm/testdata/transfer_nft_eip721.json create mode 100644 core/crates/gem_evm/testdata/transfer_nft_eip721_receipt.json create mode 100644 core/crates/gem_evm/testdata/uniswap_permit2.json create mode 100644 core/crates/gem_evm/testdata/v2_token_eth_tx.json create mode 100644 core/crates/gem_evm/testdata/v2_token_eth_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/v2_token_eth_tx_trace.json create mode 100644 core/crates/gem_evm/testdata/v3_eth_token_tx.json create mode 100644 core/crates/gem_evm/testdata/v3_eth_token_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/v3_pol_usdt_tx.json create mode 100644 core/crates/gem_evm/testdata/v3_pol_usdt_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/v3_token_eth_tx.json create mode 100644 core/crates/gem_evm/testdata/v3_token_eth_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/v3_usdc_paxg_receipt.json create mode 100644 core/crates/gem_evm/testdata/v3_usdc_paxg_tx.json create mode 100644 core/crates/gem_evm/testdata/v4_eth_dai_tx.json create mode 100644 core/crates/gem_evm/testdata/v4_eth_dai_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/v4_usdc_eth_tx.json create mode 100644 core/crates/gem_evm/testdata/v4_usdc_eth_tx_receipt.json create mode 100644 core/crates/gem_evm/testdata/yo_deposit_receipt.json create mode 100644 core/crates/gem_evm/testdata/yo_deposit_tx.json create mode 100644 core/crates/gem_evm/testdata/yo_withdraw_receipt.json create mode 100644 core/crates/gem_evm/testdata/yo_withdraw_tx.json create mode 100644 core/crates/gem_hash/Cargo.toml create mode 100644 core/crates/gem_hash/src/blake2.rs create mode 100644 core/crates/gem_hash/src/keccak.rs create mode 100644 core/crates/gem_hash/src/lib.rs create mode 100644 core/crates/gem_hash/src/message.rs create mode 100644 core/crates/gem_hash/src/sha2.rs create mode 100644 core/crates/gem_hash/src/sha3.rs create mode 100644 core/crates/gem_hypercore/Cargo.toml create mode 100644 core/crates/gem_hypercore/src/agent.rs create mode 100644 core/crates/gem_hypercore/src/config.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/agent/mod.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/agent/order.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/agent/set_referrer.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/agent/update_leverage.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/mod.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/approve_agent.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/approve_builder_fee.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/c_deposit.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/c_withdraw.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/cancel_order.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/mod.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/spot_send.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/token_delegate.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/usd_class_transfer.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/usd_send.rs create mode 100644 core/crates/gem_hypercore/src/core/actions/user/withdrawal.rs create mode 100644 core/crates/gem_hypercore/src/core/eip712.rs create mode 100644 core/crates/gem_hypercore/src/core/hahser.rs create mode 100644 core/crates/gem_hypercore/src/core/hypercore.rs create mode 100644 core/crates/gem_hypercore/src/core/mod.rs create mode 100644 core/crates/gem_hypercore/src/core/models.rs create mode 100644 core/crates/gem_hypercore/src/lib.rs create mode 100644 core/crates/gem_hypercore/src/models/action.rs create mode 100644 core/crates/gem_hypercore/src/models/balance.rs create mode 100644 core/crates/gem_hypercore/src/models/candlestick.rs create mode 100644 core/crates/gem_hypercore/src/models/metadata.rs create mode 100644 core/crates/gem_hypercore/src/models/mod.rs create mode 100644 core/crates/gem_hypercore/src/models/order.rs create mode 100644 core/crates/gem_hypercore/src/models/perp_dex.rs create mode 100644 core/crates/gem_hypercore/src/models/portfolio.rs create mode 100644 core/crates/gem_hypercore/src/models/position.rs create mode 100644 core/crates/gem_hypercore/src/models/referral.rs create mode 100644 core/crates/gem_hypercore/src/models/response.rs create mode 100644 core/crates/gem_hypercore/src/models/spot/mod.rs create mode 100644 core/crates/gem_hypercore/src/models/spot/orderbook.rs create mode 100644 core/crates/gem_hypercore/src/models/spot/spot_market.rs create mode 100644 core/crates/gem_hypercore/src/models/timestamp.rs create mode 100644 core/crates/gem_hypercore/src/models/token.rs create mode 100644 core/crates/gem_hypercore/src/models/transaction_id.rs create mode 100644 core/crates/gem_hypercore/src/models/user.rs create mode 100644 core/crates/gem_hypercore/src/models/websocket.rs create mode 100644 core/crates/gem_hypercore/src/perpetual_formatter.rs create mode 100644 core/crates/gem_hypercore/src/provider/balances.rs create mode 100644 core/crates/gem_hypercore/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_hypercore/src/provider/fee_calculator.rs create mode 100644 core/crates/gem_hypercore/src/provider/mod.rs create mode 100644 core/crates/gem_hypercore/src/provider/perpetual.rs create mode 100644 core/crates/gem_hypercore/src/provider/perpetual_mapper.rs create mode 100644 core/crates/gem_hypercore/src/provider/preload.rs create mode 100644 core/crates/gem_hypercore/src/provider/preload_cache.rs create mode 100644 core/crates/gem_hypercore/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_hypercore/src/provider/request_classifier.rs create mode 100644 core/crates/gem_hypercore/src/provider/staking.rs create mode 100644 core/crates/gem_hypercore/src/provider/staking_mapper.rs create mode 100644 core/crates/gem_hypercore/src/provider/state.rs create mode 100644 core/crates/gem_hypercore/src/provider/testkit.rs create mode 100644 core/crates/gem_hypercore/src/provider/token.rs create mode 100644 core/crates/gem_hypercore/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_hypercore/src/provider/transaction_state.rs create mode 100644 core/crates/gem_hypercore/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_hypercore/src/provider/transactions.rs create mode 100644 core/crates/gem_hypercore/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_hypercore/src/provider/websocket_mapper.rs create mode 100644 core/crates/gem_hypercore/src/rpc/client.rs create mode 100644 core/crates/gem_hypercore/src/rpc/mod.rs create mode 100644 core/crates/gem_hypercore/src/signer/core_signer.rs create mode 100644 core/crates/gem_hypercore/src/signer/mod.rs create mode 100644 core/crates/gem_hypercore/src/testkit.rs create mode 100644 core/crates/gem_hypercore/testdata/delegator_history_staking_actions.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_c_withdraw.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_cancel_orders.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_core_to_evm.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_market_short_tp_sl.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_open_long_order.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_open_short_order.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_perp_to_spot.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_set_referrer.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_spot_send_l1.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_spot_to_perps.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_spot_to_stake.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_stake_to_validator.json create mode 100644 core/crates/gem_hypercore/testdata/hl_action_update_position_tp_sl.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_approve_agent.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_c_withdraw.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_core_to_evm.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_perp_send_l1.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_perp_to_spot.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_spot_send_l1.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_spot_to_stake_balance.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_stake_to_validator.json create mode 100644 core/crates/gem_hypercore/testdata/hl_eip712_withdraw.json create mode 100644 core/crates/gem_hypercore/testdata/order_broadcast_error.json create mode 100644 core/crates/gem_hypercore/testdata/order_broadcast_filled.json create mode 100644 core/crates/gem_hypercore/testdata/order_broadcast_resting.json create mode 100644 core/crates/gem_hypercore/testdata/order_broadcast_simple_error.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state_dex1.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state_dex2.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders_dex1.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders_dex2.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_request_perp_dexs.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state_dex1.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state_dex2.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_response_open_orders.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_response_open_orders_dex1.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_response_perp_dexs.json create mode 100644 core/crates/gem_hypercore/testdata/perpetual_positions_response_user_abstraction_default.json create mode 100644 core/crates/gem_hypercore/testdata/referral_need_to_create_code.json create mode 100644 core/crates/gem_hypercore/testdata/referral_need_to_trade.json create mode 100644 core/crates/gem_hypercore/testdata/spot_meta_spot_swap.json create mode 100644 core/crates/gem_hypercore/testdata/staking_delegations.json create mode 100644 core/crates/gem_hypercore/testdata/transaction_broadcast_error_extra_agent.json create mode 100644 core/crates/gem_hypercore/testdata/user_fills_liquidation.json create mode 100644 core/crates/gem_hypercore/testdata/user_fills_multiple.json create mode 100644 core/crates/gem_hypercore/testdata/user_fills_shared_hash.json create mode 100644 core/crates/gem_hypercore/testdata/user_fills_spot_swap.json create mode 100644 core/crates/gem_hypercore/testdata/user_fills_spot_swap_buy.json create mode 100644 core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_action_hash.json create mode 100644 core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_c_staking_transfer.json create mode 100644 core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_spot_transfer.json create mode 100644 core/crates/gem_hypercore/testdata/ws_active_asset_ctx.json create mode 100644 core/crates/gem_hypercore/testdata/ws_all_mids.json create mode 100644 core/crates/gem_hypercore/testdata/ws_candle.json create mode 100644 core/crates/gem_hypercore/testdata/ws_clearinghouse_state.json create mode 100644 core/crates/gem_hypercore/testdata/ws_open_orders.json create mode 100644 core/crates/gem_jsonrpc/Cargo.toml create mode 100644 core/crates/gem_jsonrpc/src/alien.rs create mode 100644 core/crates/gem_jsonrpc/src/client.rs create mode 100644 core/crates/gem_jsonrpc/src/grpc.rs create mode 100644 core/crates/gem_jsonrpc/src/lib.rs create mode 100644 core/crates/gem_jsonrpc/src/rpc.rs create mode 100644 core/crates/gem_jsonrpc/src/testkit.rs create mode 100644 core/crates/gem_jsonrpc/src/types.rs create mode 100644 core/crates/gem_near/Cargo.toml create mode 100644 core/crates/gem_near/src/address.rs create mode 100644 core/crates/gem_near/src/constants.rs create mode 100644 core/crates/gem_near/src/lib.rs create mode 100644 core/crates/gem_near/src/models/account.rs create mode 100644 core/crates/gem_near/src/models/block.rs create mode 100644 core/crates/gem_near/src/models/fee.rs create mode 100644 core/crates/gem_near/src/models/mod.rs create mode 100644 core/crates/gem_near/src/models/rpc.rs create mode 100644 core/crates/gem_near/src/models/transaction.rs create mode 100644 core/crates/gem_near/src/provider/balances.rs create mode 100644 core/crates/gem_near/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_near/src/provider/mod.rs create mode 100644 core/crates/gem_near/src/provider/preload.rs create mode 100644 core/crates/gem_near/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_near/src/provider/request_classifier.rs create mode 100644 core/crates/gem_near/src/provider/state.rs create mode 100644 core/crates/gem_near/src/provider/state_mapper.rs create mode 100644 core/crates/gem_near/src/provider/testkit.rs create mode 100644 core/crates/gem_near/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_near/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_near/src/provider/transaction_state.rs create mode 100644 core/crates/gem_near/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_near/src/provider/transactions.rs create mode 100644 core/crates/gem_near/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_near/src/rpc/client.rs create mode 100644 core/crates/gem_near/src/rpc/mod.rs create mode 100644 core/crates/gem_near/src/signer/chain_signer.rs create mode 100644 core/crates/gem_near/src/signer/mod.rs create mode 100644 core/crates/gem_near/src/signer/models.rs create mode 100644 core/crates/gem_near/src/signer/serialization.rs create mode 100644 core/crates/gem_near/src/signer/signing.rs create mode 100644 core/crates/gem_near/testdata/balance_coin.json create mode 100644 core/crates/gem_near/testdata/successful_transaction.json create mode 100644 core/crates/gem_near/testdata/transaction_transfer_error.json create mode 100644 core/crates/gem_near/testdata/transaction_transfer_success.json create mode 100644 core/crates/gem_polkadot/Cargo.toml create mode 100644 core/crates/gem_polkadot/src/address.rs create mode 100644 core/crates/gem_polkadot/src/constants.rs create mode 100644 core/crates/gem_polkadot/src/lib.rs create mode 100644 core/crates/gem_polkadot/src/models/account.rs create mode 100644 core/crates/gem_polkadot/src/models/block.rs create mode 100644 core/crates/gem_polkadot/src/models/fee.rs create mode 100644 core/crates/gem_polkadot/src/models/mod.rs create mode 100644 core/crates/gem_polkadot/src/models/rpc.rs create mode 100644 core/crates/gem_polkadot/src/models/transaction.rs create mode 100644 core/crates/gem_polkadot/src/provider/balances.rs create mode 100644 core/crates/gem_polkadot/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_polkadot/src/provider/mod.rs create mode 100644 core/crates/gem_polkadot/src/provider/preload.rs create mode 100644 core/crates/gem_polkadot/src/provider/request_classifier.rs create mode 100644 core/crates/gem_polkadot/src/provider/staking.rs create mode 100644 core/crates/gem_polkadot/src/provider/state.rs create mode 100644 core/crates/gem_polkadot/src/provider/testkit.rs create mode 100644 core/crates/gem_polkadot/src/provider/token.rs create mode 100644 core/crates/gem_polkadot/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_polkadot/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_polkadot/src/provider/transaction_state.rs create mode 100644 core/crates/gem_polkadot/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_polkadot/src/provider/transactions.rs create mode 100644 core/crates/gem_polkadot/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_polkadot/src/rpc/client.rs create mode 100644 core/crates/gem_polkadot/src/rpc/mod.rs create mode 100644 core/crates/gem_polkadot/src/signer/chain_signer.rs create mode 100644 core/crates/gem_polkadot/src/signer/mod.rs create mode 100644 core/crates/gem_polkadot/src/testdata/balance_coin.json create mode 100644 core/crates/gem_polkadot/src/transfer.rs create mode 100644 core/crates/gem_rewards/Cargo.toml create mode 100644 core/crates/gem_rewards/src/abuseipdb/client.rs create mode 100644 core/crates/gem_rewards/src/abuseipdb/mod.rs create mode 100644 core/crates/gem_rewards/src/abuseipdb/model.rs create mode 100644 core/crates/gem_rewards/src/error.rs create mode 100644 core/crates/gem_rewards/src/ip_check_provider.rs create mode 100644 core/crates/gem_rewards/src/ip_security_client.rs create mode 100644 core/crates/gem_rewards/src/ipapi/client.rs create mode 100644 core/crates/gem_rewards/src/ipapi/mod.rs create mode 100644 core/crates/gem_rewards/src/ipapi/model.rs create mode 100644 core/crates/gem_rewards/src/lib.rs create mode 100644 core/crates/gem_rewards/src/model.rs create mode 100644 core/crates/gem_rewards/src/redemption.rs create mode 100644 core/crates/gem_rewards/src/redemption_service.rs create mode 100644 core/crates/gem_rewards/src/risk_scoring/client.rs create mode 100644 core/crates/gem_rewards/src/risk_scoring/mod.rs create mode 100644 core/crates/gem_rewards/src/risk_scoring/model.rs create mode 100644 core/crates/gem_rewards/src/risk_scoring/scoring.rs create mode 100644 core/crates/gem_rewards/src/transfer_provider/evm/mod.rs create mode 100644 core/crates/gem_rewards/src/transfer_provider/evm/provider.rs create mode 100644 core/crates/gem_rewards/src/transfer_provider/mod.rs create mode 100644 core/crates/gem_rewards/src/transfer_redemption_service.rs create mode 100644 core/crates/gem_solana/Cargo.toml create mode 100644 core/crates/gem_solana/src/address.rs create mode 100644 core/crates/gem_solana/src/constants.rs create mode 100644 core/crates/gem_solana/src/hash.rs create mode 100644 core/crates/gem_solana/src/jsonrpc.rs create mode 100644 core/crates/gem_solana/src/lib.rs create mode 100644 core/crates/gem_solana/src/metaplex/collection.rs create mode 100644 core/crates/gem_solana/src/metaplex/data.rs create mode 100644 core/crates/gem_solana/src/metaplex/metadata.rs create mode 100644 core/crates/gem_solana/src/metaplex/mod.rs create mode 100644 core/crates/gem_solana/src/metaplex/uses.rs create mode 100644 core/crates/gem_solana/src/metaplex_core.rs create mode 100644 core/crates/gem_solana/src/models/balances.rs create mode 100644 core/crates/gem_solana/src/models/block.rs create mode 100644 core/crates/gem_solana/src/models/blockhash.rs create mode 100644 core/crates/gem_solana/src/models/jito.rs create mode 100644 core/crates/gem_solana/src/models/mod.rs create mode 100644 core/crates/gem_solana/src/models/prioritization_fee.rs create mode 100644 core/crates/gem_solana/src/models/rpc.rs create mode 100644 core/crates/gem_solana/src/models/stake.rs create mode 100644 core/crates/gem_solana/src/models/token.rs create mode 100644 core/crates/gem_solana/src/models/token_account.rs create mode 100644 core/crates/gem_solana/src/models/transaction.rs create mode 100644 core/crates/gem_solana/src/models/value.rs create mode 100644 core/crates/gem_solana/src/provider/balances.rs create mode 100644 core/crates/gem_solana/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_solana/src/provider/mod.rs create mode 100644 core/crates/gem_solana/src/provider/preload.rs create mode 100644 core/crates/gem_solana/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_solana/src/provider/request_classifier.rs create mode 100644 core/crates/gem_solana/src/provider/staking.rs create mode 100644 core/crates/gem_solana/src/provider/staking_mapper.rs create mode 100644 core/crates/gem_solana/src/provider/state.rs create mode 100644 core/crates/gem_solana/src/provider/state_mapper.rs create mode 100644 core/crates/gem_solana/src/provider/testkit.rs create mode 100644 core/crates/gem_solana/src/provider/token.rs create mode 100644 core/crates/gem_solana/src/provider/token_mapper.rs create mode 100644 core/crates/gem_solana/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_solana/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_solana/src/provider/transaction_mapper.rs create mode 100644 core/crates/gem_solana/src/provider/transaction_state.rs create mode 100644 core/crates/gem_solana/src/provider/transactions.rs create mode 100644 core/crates/gem_solana/src/rpc/client.rs create mode 100644 core/crates/gem_solana/src/rpc/constants.rs create mode 100644 core/crates/gem_solana/src/rpc/mod.rs create mode 100644 core/crates/gem_solana/src/signer/chain_signer.rs create mode 100644 core/crates/gem_solana/src/signer/instructions/mod.rs create mode 100644 core/crates/gem_solana/src/signer/instructions/nft_transfer.rs create mode 100644 core/crates/gem_solana/src/signer/instructions/stake.rs create mode 100644 core/crates/gem_solana/src/signer/instructions/stake_account.rs create mode 100644 core/crates/gem_solana/src/signer/instructions/token_transfer.rs create mode 100644 core/crates/gem_solana/src/signer/instructions/transfer.rs create mode 100644 core/crates/gem_solana/src/signer/mod.rs create mode 100644 core/crates/gem_solana/src/signer/swap.rs create mode 100644 core/crates/gem_solana/src/signer/testkit.rs create mode 100644 core/crates/gem_solana/src/signer/transaction.rs create mode 100644 core/crates/gem_solana/src/token_account.rs create mode 100644 core/crates/gem_solana/src/transaction.rs create mode 100644 core/crates/gem_solana/testdata/balance_coin.json create mode 100644 core/crates/gem_solana/testdata/balance_spl_token.json create mode 100644 core/crates/gem_solana/testdata/balance_staking.json create mode 100644 core/crates/gem_solana/testdata/chainflip_vault_swap.json create mode 100644 core/crates/gem_solana/testdata/nft_mplcore_transfer.json create mode 100644 core/crates/gem_solana/testdata/nft_token_program_transfer.json create mode 100644 core/crates/gem_solana/testdata/pyusd_mint.json create mode 100644 core/crates/gem_solana/testdata/swap_okx_token_to_token.json create mode 100644 core/crates/gem_solana/testdata/swap_sol_to_token.json create mode 100644 core/crates/gem_solana/testdata/swap_token_to_sol.json create mode 100644 core/crates/gem_solana/testdata/swap_token_to_token.json create mode 100644 core/crates/gem_solana/testdata/transaction_broadcast_swap_error.json create mode 100644 core/crates/gem_solana/testdata/transaction_reverted_program_account_not_found.json create mode 100644 core/crates/gem_solana/testdata/transaction_state_pending_not_found.json create mode 100644 core/crates/gem_solana/testdata/transaction_state_reverted_program_account_not_found.json create mode 100644 core/crates/gem_solana/testdata/transaction_state_transfer_sol.json create mode 100644 core/crates/gem_solana/testdata/transfer_sol.json create mode 100644 core/crates/gem_solana/testdata/transfer_sol_with_compute.json create mode 100644 core/crates/gem_solana/testdata/usdc_mint.json create mode 100644 core/crates/gem_solana/testdata/usdc_transfer.json create mode 100644 core/crates/gem_solana/testdata/usdc_transfer_fee_payer.json create mode 100644 core/crates/gem_stellar/Cargo.toml create mode 100644 core/crates/gem_stellar/src/address.rs create mode 100644 core/crates/gem_stellar/src/constants.rs create mode 100644 core/crates/gem_stellar/src/lib.rs create mode 100644 core/crates/gem_stellar/src/models/account.rs create mode 100644 core/crates/gem_stellar/src/models/block.rs create mode 100644 core/crates/gem_stellar/src/models/common.rs create mode 100644 core/crates/gem_stellar/src/models/fee.rs create mode 100644 core/crates/gem_stellar/src/models/mod.rs create mode 100644 core/crates/gem_stellar/src/models/node.rs create mode 100644 core/crates/gem_stellar/src/models/signing/asset.rs create mode 100644 core/crates/gem_stellar/src/models/signing/mod.rs create mode 100644 core/crates/gem_stellar/src/models/signing/operation.rs create mode 100644 core/crates/gem_stellar/src/models/signing/transaction.rs create mode 100644 core/crates/gem_stellar/src/models/transaction.rs create mode 100644 core/crates/gem_stellar/src/provider/balances.rs create mode 100644 core/crates/gem_stellar/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_stellar/src/provider/mod.rs create mode 100644 core/crates/gem_stellar/src/provider/preload.rs create mode 100644 core/crates/gem_stellar/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_stellar/src/provider/request_classifier.rs create mode 100644 core/crates/gem_stellar/src/provider/state.rs create mode 100644 core/crates/gem_stellar/src/provider/state_mapper.rs create mode 100644 core/crates/gem_stellar/src/provider/testkit.rs create mode 100644 core/crates/gem_stellar/src/provider/token.rs create mode 100644 core/crates/gem_stellar/src/provider/token_mapper.rs create mode 100644 core/crates/gem_stellar/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_stellar/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_stellar/src/provider/transaction_state.rs create mode 100644 core/crates/gem_stellar/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_stellar/src/provider/transactions.rs create mode 100644 core/crates/gem_stellar/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_stellar/src/rpc/client.rs create mode 100644 core/crates/gem_stellar/src/rpc/mod.rs create mode 100644 core/crates/gem_stellar/src/signer/chain_signer.rs create mode 100644 core/crates/gem_stellar/src/signer/mod.rs create mode 100644 core/crates/gem_stellar/src/signer/serialization.rs create mode 100644 core/crates/gem_stellar/src/signer/signing.rs create mode 100644 core/crates/gem_stellar/testdata/balance.json create mode 100644 core/crates/gem_stellar/testdata/balance_coin.json create mode 100644 core/crates/gem_stellar/testdata/fees.json create mode 100644 core/crates/gem_stellar/testdata/transaction_by_hash.json create mode 100644 core/crates/gem_stellar/testdata/transaction_state_error.json create mode 100644 core/crates/gem_stellar/testdata/transaction_state_success.json create mode 100644 core/crates/gem_stellar/testdata/transaction_transfer_broadcast_error.json create mode 100644 core/crates/gem_stellar/testdata/transaction_transfer_broadcast_error_low_reserve.json create mode 100644 core/crates/gem_stellar/testdata/transaction_transfer_broadcast_success.json create mode 100644 core/crates/gem_sui/Cargo.toml create mode 100644 core/crates/gem_sui/src/address.rs create mode 100644 core/crates/gem_sui/src/coin_type.rs create mode 100644 core/crates/gem_sui/src/error.rs create mode 100644 core/crates/gem_sui/src/gas_budget.rs create mode 100644 core/crates/gem_sui/src/lib.rs create mode 100644 core/crates/gem_sui/src/models/account.rs create mode 100644 core/crates/gem_sui/src/models/coin.rs create mode 100644 core/crates/gem_sui/src/models/core.rs create mode 100644 core/crates/gem_sui/src/models/inspect.rs create mode 100644 core/crates/gem_sui/src/models/mod.rs create mode 100644 core/crates/gem_sui/src/models/object_id.rs create mode 100644 core/crates/gem_sui/src/models/staking.rs create mode 100644 core/crates/gem_sui/src/models/testkit.rs create mode 100644 core/crates/gem_sui/src/models/transaction.rs create mode 100644 core/crates/gem_sui/src/provider/accounts.rs create mode 100644 core/crates/gem_sui/src/provider/balances.rs create mode 100644 core/crates/gem_sui/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_sui/src/provider/mod.rs create mode 100644 core/crates/gem_sui/src/provider/preload.rs create mode 100644 core/crates/gem_sui/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_sui/src/provider/request_classifier.rs create mode 100644 core/crates/gem_sui/src/provider/staking.rs create mode 100644 core/crates/gem_sui/src/provider/staking_mapper.rs create mode 100644 core/crates/gem_sui/src/provider/state.rs create mode 100644 core/crates/gem_sui/src/provider/state_mapper.rs create mode 100644 core/crates/gem_sui/src/provider/testkit.rs create mode 100644 core/crates/gem_sui/src/provider/token.rs create mode 100644 core/crates/gem_sui/src/provider/token_mapper.rs create mode 100644 core/crates/gem_sui/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_sui/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_sui/src/provider/transaction_state.rs create mode 100644 core/crates/gem_sui/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_sui/src/provider/transactions.rs create mode 100644 core/crates/gem_sui/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_sui/src/rpc/client.rs create mode 100644 core/crates/gem_sui/src/rpc/mapper.rs create mode 100644 core/crates/gem_sui/src/rpc/mod.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/balances.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/bcs.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/checkpoints.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/field.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/json.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/mod.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/move_package.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/objects.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/service.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/status.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/timestamp.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/transaction_data/argument.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/transaction_data/command.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/transaction_data/input.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/transaction_data/mod.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/transaction_data/signature.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/transaction_data/transaction.rs create mode 100644 core/crates/gem_sui/src/rpc/proto/transactions.rs create mode 100644 core/crates/gem_sui/src/rpc/staking.rs create mode 100644 core/crates/gem_sui/src/rpc/transport.rs create mode 100644 core/crates/gem_sui/src/signer/chain_signer.rs create mode 100644 core/crates/gem_sui/src/signer/mod.rs create mode 100644 core/crates/gem_sui/src/signer/signature.rs create mode 100644 core/crates/gem_sui/src/transfer_builder.rs create mode 100644 core/crates/gem_sui/src/tx_builder/balance.rs create mode 100644 core/crates/gem_sui/src/tx_builder/input.rs create mode 100644 core/crates/gem_sui/src/tx_builder/mod.rs create mode 100644 core/crates/gem_sui/src/tx_builder/object_resolver.rs create mode 100644 core/crates/gem_sui/src/tx_builder/prefetch.rs create mode 100644 core/crates/gem_sui/src/tx_builder/stake.rs create mode 100644 core/crates/gem_sui/src/tx_builder/transaction.rs create mode 100644 core/crates/gem_sui/src/tx_builder/transaction_json/builder.rs create mode 100644 core/crates/gem_sui/src/tx_builder/transaction_json/mod.rs create mode 100644 core/crates/gem_sui/src/tx_builder/transaction_json/model.rs create mode 100644 core/crates/gem_sui/src/tx_builder/transaction_json/replay.rs create mode 100644 core/crates/gem_sui/src/tx_builder/transaction_json/resolver.rs create mode 100644 core/crates/gem_sui/src/tx_builder/transfer.rs create mode 100644 core/crates/gem_sui/testdata/balance_coin.json create mode 100644 core/crates/gem_sui/testdata/balance_tokens.json create mode 100644 core/crates/gem_sui/testdata/mayan_mctp_sui_usdc_to_arbitrum_usdc.json create mode 100644 core/crates/gem_sui/testdata/sponsored_transfer_sui.json create mode 100644 core/crates/gem_sui/testdata/stake_grpc.json create mode 100644 core/crates/gem_sui/testdata/stakes.json create mode 100644 core/crates/gem_sui/testdata/transaction_builder_json.json create mode 100644 core/crates/gem_sui/testdata/transfer_sui.json create mode 100644 core/crates/gem_sui/testdata/transfer_token_contract.json create mode 100644 core/crates/gem_ton/Cargo.toml create mode 100644 core/crates/gem_ton/src/address.rs create mode 100644 core/crates/gem_ton/src/constants.rs create mode 100644 core/crates/gem_ton/src/lib.rs create mode 100644 core/crates/gem_ton/src/models/account.rs create mode 100644 core/crates/gem_ton/src/models/balance.rs create mode 100644 core/crates/gem_ton/src/models/block.rs create mode 100644 core/crates/gem_ton/src/models/fee.rs create mode 100644 core/crates/gem_ton/src/models/mod.rs create mode 100644 core/crates/gem_ton/src/models/nft.rs create mode 100644 core/crates/gem_ton/src/models/rpc.rs create mode 100644 core/crates/gem_ton/src/models/transaction.rs create mode 100644 core/crates/gem_ton/src/provider/balances.rs create mode 100644 core/crates/gem_ton/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_ton/src/provider/mod.rs create mode 100644 core/crates/gem_ton/src/provider/preload.rs create mode 100644 core/crates/gem_ton/src/provider/request_classifier.rs create mode 100644 core/crates/gem_ton/src/provider/state.rs create mode 100644 core/crates/gem_ton/src/provider/state_mapper.rs create mode 100644 core/crates/gem_ton/src/provider/testkit.rs create mode 100644 core/crates/gem_ton/src/provider/token.rs create mode 100644 core/crates/gem_ton/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_ton/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_ton/src/provider/transaction_state.rs create mode 100644 core/crates/gem_ton/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_ton/src/provider/transactions.rs create mode 100644 core/crates/gem_ton/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_ton/src/rpc/client.rs create mode 100644 core/crates/gem_ton/src/rpc/mod.rs create mode 100644 core/crates/gem_ton/src/signer/chain_signer.rs create mode 100644 core/crates/gem_ton/src/signer/mod.rs create mode 100644 core/crates/gem_ton/src/signer/sign_data/message.rs create mode 100644 core/crates/gem_ton/src/signer/sign_data/mod.rs create mode 100644 core/crates/gem_ton/src/signer/sign_data/payload.rs create mode 100644 core/crates/gem_ton/src/signer/sign_data/response.rs create mode 100644 core/crates/gem_ton/src/signer/sign_data/sign.rs create mode 100644 core/crates/gem_ton/src/signer/signer.rs create mode 100644 core/crates/gem_ton/src/signer/testkit.rs create mode 100644 core/crates/gem_ton/src/signer/transaction/message.rs create mode 100644 core/crates/gem_ton/src/signer/transaction/mod.rs create mode 100644 core/crates/gem_ton/src/signer/transaction/request.rs create mode 100644 core/crates/gem_ton/src/signer/transaction/sign.rs create mode 100644 core/crates/gem_ton/src/signer/transaction/wallet.rs create mode 100644 core/crates/gem_ton/src/signer/transaction/wallet_v4r2_code.boc.b64 create mode 100644 core/crates/gem_ton/src/tvm/bag.rs create mode 100644 core/crates/gem_ton/src/tvm/builder.rs create mode 100644 core/crates/gem_ton/src/tvm/cell.rs create mode 100644 core/crates/gem_ton/src/tvm/error.rs create mode 100644 core/crates/gem_ton/src/tvm/header.rs create mode 100644 core/crates/gem_ton/src/tvm/indexed_cell.rs create mode 100644 core/crates/gem_ton/src/tvm/mod.rs create mode 100644 core/crates/gem_ton/src/tvm/raw_cell.rs create mode 100644 core/crates/gem_ton/src/tvm/reader.rs create mode 100644 core/crates/gem_ton/src/tvm/writer.rs create mode 100644 core/crates/gem_ton/testdata/balance_jettons.json create mode 100644 core/crates/gem_ton/testdata/block_traces.json create mode 100644 core/crates/gem_ton/testdata/jetton_swap_from_jetton_transfer_trace.json create mode 100644 core/crates/gem_ton/testdata/jetton_swap_trace.json create mode 100644 core/crates/gem_ton/testdata/jetton_transfer_trace.json create mode 100644 core/crates/gem_ton/testdata/nft_transfer_trace.json create mode 100644 core/crates/gem_ton/testdata/transaction_null_values.json create mode 100644 core/crates/gem_ton/testdata/transaction_status_response.json create mode 100644 core/crates/gem_ton/testdata/transaction_swap_jetton_ton_success.json create mode 100644 core/crates/gem_ton/testdata/transaction_swap_ton_jetton_success.json create mode 100644 core/crates/gem_ton/testdata/transaction_transfer_jetton_error.json create mode 100644 core/crates/gem_ton/testdata/transaction_transfer_jetton_error_2.json create mode 100644 core/crates/gem_ton/testdata/transaction_transfer_jetton_success.json create mode 100644 core/crates/gem_ton/testdata/transaction_transfer_jetton_success_2.json create mode 100644 core/crates/gem_ton/testdata/transaction_transfer_state_success.json create mode 100644 core/crates/gem_ton/testdata/wc_sign_data_response.json create mode 100644 core/crates/gem_tron/Cargo.toml create mode 100644 core/crates/gem_tron/src/address/mod.rs create mode 100644 core/crates/gem_tron/src/address/serializer.rs create mode 100644 core/crates/gem_tron/src/lib.rs create mode 100644 core/crates/gem_tron/src/models/account.rs create mode 100644 core/crates/gem_tron/src/models/block.rs create mode 100644 core/crates/gem_tron/src/models/chain.rs create mode 100644 core/crates/gem_tron/src/models/contract.rs create mode 100644 core/crates/gem_tron/src/models/contract_type.rs create mode 100644 core/crates/gem_tron/src/models/mod.rs create mode 100644 core/crates/gem_tron/src/models/signing/contract.rs create mode 100644 core/crates/gem_tron/src/models/signing/mod.rs create mode 100644 core/crates/gem_tron/src/models/signing/protobuf.rs create mode 100644 core/crates/gem_tron/src/models/signing/raw_data.rs create mode 100644 core/crates/gem_tron/src/models/signing/wallet_connect.rs create mode 100644 core/crates/gem_tron/src/models/transaction.rs create mode 100644 core/crates/gem_tron/src/provider/address.rs create mode 100644 core/crates/gem_tron/src/provider/address_mapper.rs create mode 100644 core/crates/gem_tron/src/provider/balances.rs create mode 100644 core/crates/gem_tron/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_tron/src/provider/mod.rs create mode 100644 core/crates/gem_tron/src/provider/preload.rs create mode 100644 core/crates/gem_tron/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_tron/src/provider/request_classifier.rs create mode 100644 core/crates/gem_tron/src/provider/staking.rs create mode 100644 core/crates/gem_tron/src/provider/staking_mapper.rs create mode 100644 core/crates/gem_tron/src/provider/state.rs create mode 100644 core/crates/gem_tron/src/provider/state_mapper.rs create mode 100644 core/crates/gem_tron/src/provider/testkit.rs create mode 100644 core/crates/gem_tron/src/provider/token.rs create mode 100644 core/crates/gem_tron/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_tron/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_tron/src/provider/transaction_state.rs create mode 100644 core/crates/gem_tron/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_tron/src/provider/transactions.rs create mode 100644 core/crates/gem_tron/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_tron/src/rpc/client.rs create mode 100644 core/crates/gem_tron/src/rpc/constants.rs create mode 100644 core/crates/gem_tron/src/rpc/mod.rs create mode 100644 core/crates/gem_tron/src/rpc/trongrid/client.rs create mode 100644 core/crates/gem_tron/src/rpc/trongrid/mapper.rs create mode 100644 core/crates/gem_tron/src/rpc/trongrid/mod.rs create mode 100644 core/crates/gem_tron/src/rpc/trongrid/model.rs create mode 100644 core/crates/gem_tron/src/signer/chain_signer.rs create mode 100644 core/crates/gem_tron/src/signer/message.rs create mode 100644 core/crates/gem_tron/src/signer/mod.rs create mode 100644 core/crates/gem_tron/src/signer/transaction.rs create mode 100644 core/crates/gem_tron/testdata/balance_coin.json create mode 100644 core/crates/gem_tron/testdata/balance_token.json create mode 100644 core/crates/gem_tron/testdata/block_mixed_contract_types.json create mode 100644 core/crates/gem_tron/testdata/block_mixed_contract_types_receipts.json create mode 100644 core/crates/gem_tron/testdata/transaction_broadcast_error.json create mode 100644 core/crates/gem_tron/testdata/transaction_broadcast_success.json create mode 100644 core/crates/gem_tron/testdata/transaction_coin_transfer.json create mode 100644 core/crates/gem_tron/testdata/transaction_coin_transfer_receipt.json create mode 100644 core/crates/gem_tron/testdata/transaction_freeze.json create mode 100644 core/crates/gem_tron/testdata/transaction_freeze_energy.json create mode 100644 core/crates/gem_tron/testdata/transaction_stake.json create mode 100644 core/crates/gem_tron/testdata/transaction_thorchain_swap.json create mode 100644 core/crates/gem_tron/testdata/transaction_token_transfer.json create mode 100644 core/crates/gem_tron/testdata/transaction_unfreeze.json create mode 100644 core/crates/gem_wallet_connect/Cargo.toml create mode 100644 core/crates/gem_wallet_connect/src/actions.rs create mode 100644 core/crates/gem_wallet_connect/src/decode.rs create mode 100644 core/crates/gem_wallet_connect/src/lib.rs create mode 100644 core/crates/gem_wallet_connect/src/request_handler/ethereum.rs create mode 100644 core/crates/gem_wallet_connect/src/request_handler/mod.rs create mode 100644 core/crates/gem_wallet_connect/src/request_handler/solana.rs create mode 100644 core/crates/gem_wallet_connect/src/request_handler/sui.rs create mode 100644 core/crates/gem_wallet_connect/src/request_handler/ton.rs create mode 100644 core/crates/gem_wallet_connect/src/request_handler/tron.rs create mode 100644 core/crates/gem_wallet_connect/src/response_handler.rs create mode 100644 core/crates/gem_wallet_connect/src/session.rs create mode 100644 core/crates/gem_wallet_connect/src/sign_type.rs create mode 100644 core/crates/gem_wallet_connect/src/validator.rs create mode 100644 core/crates/gem_wallet_connect/src/verifier.rs create mode 100644 core/crates/gem_wallet_connect/testdata/solana_sign_all_transactions.json create mode 100644 core/crates/gem_wallet_connect/testdata/tron_send_transaction.json create mode 100644 core/crates/gem_wallet_connect/testdata/tron_sign_message.json create mode 100644 core/crates/gem_wallet_connect/testdata/tron_sign_message_response.json create mode 100644 core/crates/gem_wallet_connect/testdata/tron_sign_transaction.json create mode 100644 core/crates/gem_wallet_connect/testdata/tron_sign_transaction_nested.json create mode 100644 core/crates/gem_wallet_connect/testdata/tron_sign_transaction_response.json create mode 100644 core/crates/gem_xrp/Cargo.toml create mode 100644 core/crates/gem_xrp/src/address.rs create mode 100644 core/crates/gem_xrp/src/constants.rs create mode 100644 core/crates/gem_xrp/src/lib.rs create mode 100644 core/crates/gem_xrp/src/models/account.rs create mode 100644 core/crates/gem_xrp/src/models/asset.rs create mode 100644 core/crates/gem_xrp/src/models/block.rs create mode 100644 core/crates/gem_xrp/src/models/fee.rs create mode 100644 core/crates/gem_xrp/src/models/mod.rs create mode 100644 core/crates/gem_xrp/src/models/result.rs create mode 100644 core/crates/gem_xrp/src/models/rpc.rs create mode 100644 core/crates/gem_xrp/src/models/transaction.rs create mode 100644 core/crates/gem_xrp/src/provider/balances.rs create mode 100644 core/crates/gem_xrp/src/provider/balances_mapper.rs create mode 100644 core/crates/gem_xrp/src/provider/mod.rs create mode 100644 core/crates/gem_xrp/src/provider/preload.rs create mode 100644 core/crates/gem_xrp/src/provider/preload_mapper.rs create mode 100644 core/crates/gem_xrp/src/provider/request_classifier.rs create mode 100644 core/crates/gem_xrp/src/provider/state.rs create mode 100644 core/crates/gem_xrp/src/provider/state_mapper.rs create mode 100644 core/crates/gem_xrp/src/provider/testkit.rs create mode 100644 core/crates/gem_xrp/src/provider/token.rs create mode 100644 core/crates/gem_xrp/src/provider/token_mapper.rs create mode 100644 core/crates/gem_xrp/src/provider/transaction_broadcast.rs create mode 100644 core/crates/gem_xrp/src/provider/transaction_broadcast_mapper.rs create mode 100644 core/crates/gem_xrp/src/provider/transaction_state.rs create mode 100644 core/crates/gem_xrp/src/provider/transaction_state_mapper.rs create mode 100644 core/crates/gem_xrp/src/provider/transactions.rs create mode 100644 core/crates/gem_xrp/src/provider/transactions_mapper.rs create mode 100644 core/crates/gem_xrp/src/rpc/client.rs create mode 100644 core/crates/gem_xrp/src/rpc/mod.rs create mode 100644 core/crates/gem_xrp/src/signer/amount.rs create mode 100644 core/crates/gem_xrp/src/signer/chain_signer.rs create mode 100644 core/crates/gem_xrp/src/signer/mod.rs create mode 100644 core/crates/gem_xrp/src/signer/transaction.rs create mode 100644 core/crates/gem_xrp/src/testdata/account_transaction_thorchain_swap.json create mode 100644 core/crates/gem_xrp/src/testdata/account_transactions.json create mode 100644 core/crates/gem_xrp/src/testdata/accounts_objects_tokens.json create mode 100644 core/crates/gem_xrp/src/testdata/transaction_broadcast_failed.json create mode 100644 core/crates/gem_xrp/src/testdata/transaction_broadcast_success.json create mode 100644 core/crates/gem_xrp/src/testdata/transaction_by_address.json create mode 100644 core/crates/gem_xrp/src/testdata/transaction_by_hash.json create mode 100644 core/crates/gem_xrp/src/testdata/transactions_by_block.json create mode 100644 core/crates/in_app_notifications/Cargo.toml create mode 100644 core/crates/in_app_notifications/src/lib.rs create mode 100644 core/crates/job_runner/Cargo.toml create mode 100644 core/crates/job_runner/src/lib.rs create mode 100644 core/crates/job_runner/src/schedule.rs create mode 100644 core/crates/localizer/Cargo.toml create mode 100644 core/crates/localizer/i18n.toml create mode 100644 core/crates/localizer/i18n/ar/localizer.ftl create mode 100644 core/crates/localizer/i18n/de/localizer.ftl create mode 100644 core/crates/localizer/i18n/en/localizer.ftl create mode 100644 core/crates/localizer/i18n/es/localizer.ftl create mode 100644 core/crates/localizer/i18n/fa/localizer.ftl create mode 100644 core/crates/localizer/i18n/fr/localizer.ftl create mode 100644 core/crates/localizer/i18n/he/localizer.ftl create mode 100644 core/crates/localizer/i18n/hi/localizer.ftl create mode 100644 core/crates/localizer/i18n/id/localizer.ftl create mode 100644 core/crates/localizer/i18n/it/localizer.ftl create mode 100644 core/crates/localizer/i18n/ja/localizer.ftl create mode 100644 core/crates/localizer/i18n/ko/localizer.ftl create mode 100644 core/crates/localizer/i18n/pl/localizer.ftl create mode 100644 core/crates/localizer/i18n/pt-BR/localizer.ftl create mode 100644 core/crates/localizer/i18n/ru/localizer.ftl create mode 100644 core/crates/localizer/i18n/th/localizer.ftl create mode 100644 core/crates/localizer/i18n/tr/localizer.ftl create mode 100644 core/crates/localizer/i18n/uk/localizer.ftl create mode 100644 core/crates/localizer/i18n/vi/localizer.ftl create mode 100644 core/crates/localizer/i18n/zh-Hans/localizer.ftl create mode 100644 core/crates/localizer/i18n/zh-Hant/localizer.ftl create mode 100644 core/crates/localizer/src/lib.rs create mode 100644 core/crates/localizer/tests/localizer.rs create mode 100644 core/crates/metrics/Cargo.toml create mode 100644 core/crates/metrics/src/domain.rs create mode 100644 core/crates/metrics/src/histogram.rs create mode 100644 core/crates/metrics/src/lib.rs create mode 100644 core/crates/metrics/src/registry.rs create mode 100644 core/crates/name_resolver/Cargo.toml create mode 100644 core/crates/name_resolver/README.md create mode 100644 core/crates/name_resolver/src/alldomains/client.rs create mode 100644 core/crates/name_resolver/src/alldomains/mod.rs create mode 100644 core/crates/name_resolver/src/alldomains/model.rs create mode 100644 core/crates/name_resolver/src/aptos.rs create mode 100644 core/crates/name_resolver/src/base/contract.rs create mode 100644 core/crates/name_resolver/src/base/mod.rs create mode 100644 core/crates/name_resolver/src/base/provider.rs create mode 100644 core/crates/name_resolver/src/client.rs create mode 100644 core/crates/name_resolver/src/codec/mod.rs create mode 100644 core/crates/name_resolver/src/did.rs create mode 100644 core/crates/name_resolver/src/ens/client.rs create mode 100644 core/crates/name_resolver/src/ens/contract.rs create mode 100644 core/crates/name_resolver/src/ens/mod.rs create mode 100644 core/crates/name_resolver/src/ens/normalizer.rs create mode 100644 core/crates/name_resolver/src/ens/provider.rs create mode 100644 core/crates/name_resolver/src/error.rs create mode 100644 core/crates/name_resolver/src/eths.rs create mode 100644 core/crates/name_resolver/src/hyperliquid/contracts.rs create mode 100644 core/crates/name_resolver/src/hyperliquid/mod.rs create mode 100644 core/crates/name_resolver/src/hyperliquid/provider.rs create mode 100644 core/crates/name_resolver/src/hyperliquid/record.rs create mode 100644 core/crates/name_resolver/src/icns.rs create mode 100644 core/crates/name_resolver/src/injective.rs create mode 100644 core/crates/name_resolver/src/lens.rs create mode 100644 core/crates/name_resolver/src/lib.rs create mode 100644 core/crates/name_resolver/src/model.rs create mode 100644 core/crates/name_resolver/src/sns.rs create mode 100644 core/crates/name_resolver/src/spaceid.rs create mode 100644 core/crates/name_resolver/src/suins/client.rs create mode 100644 core/crates/name_resolver/src/suins/mod.rs create mode 100644 core/crates/name_resolver/src/suins/proto.rs create mode 100644 core/crates/name_resolver/src/testkit.rs create mode 100644 core/crates/name_resolver/src/ton.rs create mode 100644 core/crates/name_resolver/src/ton_codec.rs create mode 100644 core/crates/name_resolver/src/ud.rs create mode 100644 core/crates/name_resolver/testdata/ton_dns_records_response.json create mode 100644 core/crates/name_resolver/tests/integration_test.rs create mode 100644 core/crates/nft/Cargo.toml create mode 100644 core/crates/nft/src/client.rs create mode 100644 core/crates/nft/src/config.rs create mode 100644 core/crates/nft/src/factory.rs create mode 100644 core/crates/nft/src/lib.rs create mode 100644 core/crates/nft/src/provider.rs create mode 100644 core/crates/nft/src/provider_client.rs create mode 100644 core/crates/nft/src/providers/attribute.rs create mode 100644 core/crates/nft/src/providers/magiceden/evm/client.rs create mode 100644 core/crates/nft/src/providers/magiceden/evm/mapper.rs create mode 100644 core/crates/nft/src/providers/magiceden/evm/mod.rs create mode 100644 core/crates/nft/src/providers/magiceden/evm/model.rs create mode 100644 core/crates/nft/src/providers/magiceden/evm/provider.rs create mode 100644 core/crates/nft/src/providers/magiceden/mod.rs create mode 100644 core/crates/nft/src/providers/magiceden/solana/client.rs create mode 100644 core/crates/nft/src/providers/magiceden/solana/mapper.rs create mode 100644 core/crates/nft/src/providers/magiceden/solana/mod.rs create mode 100644 core/crates/nft/src/providers/magiceden/solana/model.rs create mode 100644 core/crates/nft/src/providers/magiceden/solana/provider.rs create mode 100644 core/crates/nft/src/providers/mod.rs create mode 100644 core/crates/nft/src/providers/opensea/client.rs create mode 100644 core/crates/nft/src/providers/opensea/mapper.rs create mode 100644 core/crates/nft/src/providers/opensea/mod.rs create mode 100644 core/crates/nft/src/providers/opensea/model.rs create mode 100644 core/crates/nft/src/providers/opensea/provider.rs create mode 100644 core/crates/nft/src/providers/ton/mapper.rs create mode 100644 core/crates/nft/src/providers/ton/mod.rs create mode 100644 core/crates/nft/src/providers/ton/provider.rs create mode 100644 core/crates/nft/src/providers/ton/verified.rs create mode 100644 core/crates/nft/src/testdata/magiceden/evm_asset.json create mode 100644 core/crates/nft/src/testdata/magiceden/evm_assets.json create mode 100644 core/crates/nft/src/testdata/magiceden/evm_collection.json create mode 100644 core/crates/nft/src/testdata/magiceden/solana_asset.json create mode 100644 core/crates/nft/src/testdata/magiceden/solana_assets.json create mode 100644 core/crates/nft/src/testdata/magiceden/solana_collection.json create mode 100644 core/crates/nft/src/testkit.rs create mode 100644 core/crates/nft/testdata/magiceden/asset.json create mode 100644 core/crates/nft/testdata/magiceden/assets.json create mode 100644 core/crates/nft/testdata/magiceden/collection.json create mode 100644 core/crates/nft/testdata/opensea/asset.json create mode 100644 core/crates/nft/testdata/opensea/asset_ens_dates.json create mode 100644 core/crates/nft/testdata/opensea/asset_null_images.json create mode 100644 core/crates/nft/testdata/opensea/assets.json create mode 100644 core/crates/nft/testdata/opensea/collection.json create mode 100644 core/crates/nft/testdata/ton/collections.json create mode 100644 core/crates/nft/testdata/ton/collections_getgems.json create mode 100644 core/crates/nft/testdata/ton/collections_invalid.json create mode 100644 core/crates/nft/testdata/ton/items.json create mode 100644 core/crates/nft/testdata/ton/items_unverified.json create mode 100644 core/crates/number_formatter/Cargo.toml create mode 100644 core/crates/number_formatter/src/big_number_formatter.rs create mode 100644 core/crates/number_formatter/src/currency.rs create mode 100644 core/crates/number_formatter/src/lib.rs create mode 100644 core/crates/number_formatter/src/number_formatter.rs create mode 100644 core/crates/number_formatter/src/price_suggestion.rs create mode 100644 core/crates/number_formatter/src/value_formatter.rs create mode 100644 core/crates/portfolio/Cargo.toml create mode 100644 core/crates/portfolio/src/lib.rs create mode 100644 core/crates/portfolio/src/portfolio_client.rs create mode 100644 core/crates/pricer/Cargo.toml create mode 100644 core/crates/pricer/src/chart_client.rs create mode 100644 core/crates/pricer/src/lib.rs create mode 100644 core/crates/pricer/src/markets_client.rs create mode 100644 core/crates/pricer/src/price_alert_client.rs create mode 100644 core/crates/pricer/src/price_client.rs create mode 100644 core/crates/prices/Cargo.toml create mode 100644 core/crates/prices/src/lib.rs create mode 100644 core/crates/prices/src/model.rs create mode 100644 core/crates/prices/src/providers/coingecko/mapper.rs create mode 100644 core/crates/prices/src/providers/coingecko/mod.rs create mode 100644 core/crates/prices/src/providers/coingecko/provider.rs create mode 100644 core/crates/prices/src/providers/defillama/client.rs create mode 100644 core/crates/prices/src/providers/defillama/mapper.rs create mode 100644 core/crates/prices/src/providers/defillama/mod.rs create mode 100644 core/crates/prices/src/providers/defillama/model.rs create mode 100644 core/crates/prices/src/providers/defillama/provider.rs create mode 100644 core/crates/prices/src/providers/jupiter/client.rs create mode 100644 core/crates/prices/src/providers/jupiter/mapper.rs create mode 100644 core/crates/prices/src/providers/jupiter/mod.rs create mode 100644 core/crates/prices/src/providers/jupiter/model.rs create mode 100644 core/crates/prices/src/providers/jupiter/provider.rs create mode 100644 core/crates/prices/src/providers/jupiter/testkit.rs create mode 100644 core/crates/prices/src/providers/mod.rs create mode 100644 core/crates/prices/src/providers/pyth/client.rs create mode 100644 core/crates/prices/src/providers/pyth/mapper.rs create mode 100644 core/crates/prices/src/providers/pyth/mod.rs create mode 100644 core/crates/prices/src/providers/pyth/model.rs create mode 100644 core/crates/prices/src/providers/pyth/provider.rs create mode 100644 core/crates/prices/src/providers/pyth/testkit.rs create mode 100644 core/crates/prices/testdata/defillama/prices.json create mode 100644 core/crates/primitives/Cargo.toml create mode 100644 core/crates/primitives/src/account.rs create mode 100644 core/crates/primitives/src/address/error.rs create mode 100644 core/crates/primitives/src/address/mod.rs create mode 100644 core/crates/primitives/src/address_formatter.rs create mode 100644 core/crates/primitives/src/address_name.rs create mode 100644 core/crates/primitives/src/address_status.rs create mode 100644 core/crates/primitives/src/app_constants.rs create mode 100644 core/crates/primitives/src/asset.rs create mode 100644 core/crates/primitives/src/asset_address.rs create mode 100644 core/crates/primitives/src/asset_balance.rs create mode 100644 core/crates/primitives/src/asset_constants.rs create mode 100644 core/crates/primitives/src/asset_details.rs create mode 100644 core/crates/primitives/src/asset_fiat_value.rs create mode 100644 core/crates/primitives/src/asset_id.rs create mode 100644 core/crates/primitives/src/asset_metadata.rs create mode 100644 core/crates/primitives/src/asset_order.rs create mode 100644 core/crates/primitives/src/asset_price.rs create mode 100644 core/crates/primitives/src/asset_price_info.rs create mode 100644 core/crates/primitives/src/asset_score.rs create mode 100644 core/crates/primitives/src/asset_type.rs create mode 100644 core/crates/primitives/src/auth.rs create mode 100644 core/crates/primitives/src/auth_status.rs create mode 100644 core/crates/primitives/src/balance_type.rs create mode 100644 core/crates/primitives/src/banner.rs create mode 100644 core/crates/primitives/src/block_explorer.rs create mode 100644 core/crates/primitives/src/broadcast_options.rs create mode 100644 core/crates/primitives/src/chain.rs create mode 100644 core/crates/primitives/src/chain_address.rs create mode 100644 core/crates/primitives/src/chain_bitcoin.rs create mode 100644 core/crates/primitives/src/chain_config.rs create mode 100644 core/crates/primitives/src/chain_cosmos.rs create mode 100644 core/crates/primitives/src/chain_evm.rs create mode 100644 core/crates/primitives/src/chain_nft.rs create mode 100644 core/crates/primitives/src/chain_request.rs create mode 100644 core/crates/primitives/src/chain_signer.rs create mode 100644 core/crates/primitives/src/chain_stake.rs create mode 100644 core/crates/primitives/src/chain_transaction_timeout.rs create mode 100644 core/crates/primitives/src/chain_type.rs create mode 100644 core/crates/primitives/src/chart.rs create mode 100644 core/crates/primitives/src/config.rs create mode 100644 core/crates/primitives/src/config_key.rs create mode 100644 core/crates/primitives/src/config_param_key.rs create mode 100644 core/crates/primitives/src/contact.rs create mode 100644 core/crates/primitives/src/contract_call_data.rs create mode 100644 core/crates/primitives/src/contract_constants.rs create mode 100644 core/crates/primitives/src/currency.rs create mode 100644 core/crates/primitives/src/date_ext.rs create mode 100644 core/crates/primitives/src/deeplink.rs create mode 100644 core/crates/primitives/src/delegation.rs create mode 100644 core/crates/primitives/src/device.rs create mode 100644 core/crates/primitives/src/device_token.rs create mode 100644 core/crates/primitives/src/diff.rs create mode 100644 core/crates/primitives/src/duration.rs create mode 100644 core/crates/primitives/src/earn_type.rs create mode 100644 core/crates/primitives/src/explorers/algorand.rs create mode 100644 core/crates/primitives/src/explorers/aptos.rs create mode 100644 core/crates/primitives/src/explorers/blockchair.rs create mode 100644 core/crates/primitives/src/explorers/blockscout.rs create mode 100644 core/crates/primitives/src/explorers/blocksec.rs create mode 100644 core/crates/primitives/src/explorers/blockvision.rs create mode 100644 core/crates/primitives/src/explorers/cardano.rs create mode 100644 core/crates/primitives/src/explorers/chainflip.rs create mode 100644 core/crates/primitives/src/explorers/etherscan.rs create mode 100644 core/crates/primitives/src/explorers/hypercore.rs create mode 100644 core/crates/primitives/src/explorers/mantle.rs create mode 100644 core/crates/primitives/src/explorers/mayanscan.rs create mode 100644 core/crates/primitives/src/explorers/mempool.rs create mode 100644 core/crates/primitives/src/explorers/metadata.rs create mode 100644 core/crates/primitives/src/explorers/mintscan.rs create mode 100644 core/crates/primitives/src/explorers/mod.rs create mode 100644 core/crates/primitives/src/explorers/near.rs create mode 100644 core/crates/primitives/src/explorers/near_intents.rs create mode 100644 core/crates/primitives/src/explorers/okx.rs create mode 100644 core/crates/primitives/src/explorers/relay.rs create mode 100644 core/crates/primitives/src/explorers/routescan.rs create mode 100644 core/crates/primitives/src/explorers/skip.rs create mode 100644 core/crates/primitives/src/explorers/socketscan.rs create mode 100644 core/crates/primitives/src/explorers/solana.rs create mode 100644 core/crates/primitives/src/explorers/stellar_expert.rs create mode 100644 core/crates/primitives/src/explorers/subscan.rs create mode 100644 core/crates/primitives/src/explorers/sui.rs create mode 100644 core/crates/primitives/src/explorers/thorchain.rs create mode 100644 core/crates/primitives/src/explorers/threexpl.rs create mode 100644 core/crates/primitives/src/explorers/ton.rs create mode 100644 core/crates/primitives/src/explorers/tronscan.rs create mode 100644 core/crates/primitives/src/explorers/xrpscan.rs create mode 100644 core/crates/primitives/src/explorers/zksync.rs create mode 100644 core/crates/primitives/src/fee.rs create mode 100644 core/crates/primitives/src/fee_priority_value.rs create mode 100644 core/crates/primitives/src/fiat_assets.rs create mode 100644 core/crates/primitives/src/fiat_provider.rs create mode 100644 core/crates/primitives/src/fiat_provider_id.rs create mode 100644 core/crates/primitives/src/fiat_quote.rs create mode 100644 core/crates/primitives/src/fiat_quote_request.rs create mode 100644 core/crates/primitives/src/fiat_rate.rs create mode 100644 core/crates/primitives/src/fiat_transaction.rs create mode 100644 core/crates/primitives/src/gas_price_type.rs create mode 100644 core/crates/primitives/src/gorush.rs create mode 100644 core/crates/primitives/src/graphql.rs create mode 100644 core/crates/primitives/src/hex.rs create mode 100644 core/crates/primitives/src/image_formatter.rs create mode 100644 core/crates/primitives/src/ip_usage_type.rs create mode 100644 core/crates/primitives/src/job_configuration.rs create mode 100644 core/crates/primitives/src/json_rpc.rs create mode 100644 core/crates/primitives/src/known_assets.rs create mode 100644 core/crates/primitives/src/latency_type.rs create mode 100644 core/crates/primitives/src/lib.rs create mode 100644 core/crates/primitives/src/link_type.rs create mode 100644 core/crates/primitives/src/list_item.rs create mode 100644 core/crates/primitives/src/localize.rs create mode 100644 core/crates/primitives/src/markets.rs create mode 100644 core/crates/primitives/src/metrics.rs create mode 100644 core/crates/primitives/src/name.rs create mode 100644 core/crates/primitives/src/nft.rs create mode 100644 core/crates/primitives/src/node.rs create mode 100644 core/crates/primitives/src/node_config.rs create mode 100644 core/crates/primitives/src/node_status.rs create mode 100644 core/crates/primitives/src/node_sync_status.rs create mode 100644 core/crates/primitives/src/notification.rs create mode 100644 core/crates/primitives/src/notification_data.rs create mode 100644 core/crates/primitives/src/notification_type.rs create mode 100644 core/crates/primitives/src/number_incrementer.rs create mode 100644 core/crates/primitives/src/payment_decoder/decoder.rs create mode 100644 core/crates/primitives/src/payment_decoder/erc681.rs create mode 100644 core/crates/primitives/src/payment_decoder/error.rs create mode 100644 core/crates/primitives/src/payment_decoder/mod.rs create mode 100644 core/crates/primitives/src/payment_decoder/solana_pay.rs create mode 100644 core/crates/primitives/src/payment_decoder/ton_pay.rs create mode 100644 core/crates/primitives/src/payment_type.rs create mode 100644 core/crates/primitives/src/perpetual.rs create mode 100644 core/crates/primitives/src/perpetual_id.rs create mode 100644 core/crates/primitives/src/perpetual_position.rs create mode 100644 core/crates/primitives/src/perpetual_provider.rs create mode 100644 core/crates/primitives/src/platform.rs create mode 100644 core/crates/primitives/src/platform_store.rs create mode 100644 core/crates/primitives/src/portfolio.rs create mode 100644 core/crates/primitives/src/price.rs create mode 100644 core/crates/primitives/src/price_alert.rs create mode 100644 core/crates/primitives/src/price_config.rs create mode 100644 core/crates/primitives/src/price_data.rs create mode 100644 core/crates/primitives/src/price_id.rs create mode 100644 core/crates/primitives/src/price_provider.rs create mode 100644 core/crates/primitives/src/priority.rs create mode 100644 core/crates/primitives/src/push_notification.rs create mode 100644 core/crates/primitives/src/recent_activity_type.rs create mode 100644 core/crates/primitives/src/response.rs create mode 100644 core/crates/primitives/src/rewards.rs create mode 100644 core/crates/primitives/src/scan.rs create mode 100644 core/crates/primitives/src/search.rs create mode 100644 core/crates/primitives/src/secure_preferences.rs create mode 100644 core/crates/primitives/src/signer_error.rs create mode 100644 core/crates/primitives/src/simulation.rs create mode 100644 core/crates/primitives/src/solana_nft.rs create mode 100644 core/crates/primitives/src/solana_token_program.rs create mode 100644 core/crates/primitives/src/solana_types.rs create mode 100644 core/crates/primitives/src/stake_provider_type.rs create mode 100644 core/crates/primitives/src/stake_type.rs create mode 100644 core/crates/primitives/src/stream.rs create mode 100644 core/crates/primitives/src/string_serde.rs create mode 100644 core/crates/primitives/src/subscription.rs create mode 100644 core/crates/primitives/src/swap/approval.rs create mode 100644 core/crates/primitives/src/swap/mod.rs create mode 100644 core/crates/primitives/src/swap/mode.rs create mode 100644 core/crates/primitives/src/swap/price_impact.rs create mode 100644 core/crates/primitives/src/swap/quote_asset.rs create mode 100644 core/crates/primitives/src/swap/result.rs create mode 100644 core/crates/primitives/src/swap/slippage.rs create mode 100644 core/crates/primitives/src/swap_provider.rs create mode 100644 core/crates/primitives/src/tag.rs create mode 100644 core/crates/primitives/src/testkit/address_name_mock.rs create mode 100644 core/crates/primitives/src/testkit/asset_mock.rs create mode 100644 core/crates/primitives/src/testkit/contract_call_data_mock.rs create mode 100644 core/crates/primitives/src/testkit/delegation_mock.rs create mode 100644 core/crates/primitives/src/testkit/device_mock.rs create mode 100644 core/crates/primitives/src/testkit/fiat_mock.rs create mode 100644 core/crates/primitives/src/testkit/gorush_mock.rs create mode 100644 core/crates/primitives/src/testkit/json.rs create mode 100644 core/crates/primitives/src/testkit/json_rpc.rs create mode 100644 core/crates/primitives/src/testkit/mod.rs create mode 100644 core/crates/primitives/src/testkit/nft_mock.rs create mode 100644 core/crates/primitives/src/testkit/perpetual_mock.rs create mode 100644 core/crates/primitives/src/testkit/quote_asset_mock.rs create mode 100644 core/crates/primitives/src/testkit/signer_mock.rs create mode 100644 core/crates/primitives/src/testkit/subscription_mock.rs create mode 100644 core/crates/primitives/src/testkit/swap_mock.rs create mode 100644 core/crates/primitives/src/testkit/transaction_fee_mock.rs create mode 100644 core/crates/primitives/src/testkit/transaction_load_input_mock.rs create mode 100644 core/crates/primitives/src/testkit/transaction_load_metadata_mock.rs create mode 100644 core/crates/primitives/src/testkit/transaction_mock.rs create mode 100644 core/crates/primitives/src/testkit/transaction_preload_input_mock.rs create mode 100644 core/crates/primitives/src/testkit/transaction_state_request_mock.rs create mode 100644 core/crates/primitives/src/testkit/transfer_data_extra_mock.rs create mode 100644 core/crates/primitives/src/testkit/wallet_connect_mock.rs create mode 100644 core/crates/primitives/src/testkit/wallet_connection_session_mock.rs create mode 100644 core/crates/primitives/src/time.rs create mode 100644 core/crates/primitives/src/total_value_type.rs create mode 100644 core/crates/primitives/src/tpsl_type.rs create mode 100644 core/crates/primitives/src/transaction.rs create mode 100644 core/crates/primitives/src/transaction_data_output.rs create mode 100644 core/crates/primitives/src/transaction_direction.rs create mode 100644 core/crates/primitives/src/transaction_extended.rs create mode 100644 core/crates/primitives/src/transaction_fee.rs create mode 100644 core/crates/primitives/src/transaction_id.rs create mode 100644 core/crates/primitives/src/transaction_input_type.rs create mode 100644 core/crates/primitives/src/transaction_load_metadata.rs create mode 100644 core/crates/primitives/src/transaction_metadata_types.rs create mode 100644 core/crates/primitives/src/transaction_preload_input.rs create mode 100644 core/crates/primitives/src/transaction_state.rs create mode 100644 core/crates/primitives/src/transaction_state_request.rs create mode 100644 core/crates/primitives/src/transaction_type.rs create mode 100644 core/crates/primitives/src/transaction_update.rs create mode 100644 core/crates/primitives/src/transaction_utxo.rs create mode 100644 core/crates/primitives/src/transaction_wallet.rs create mode 100644 core/crates/primitives/src/transfer_data_extra.rs create mode 100644 core/crates/primitives/src/url_action.rs create mode 100644 core/crates/primitives/src/username_status.rs create mode 100644 core/crates/primitives/src/utxo.rs create mode 100644 core/crates/primitives/src/validator.rs create mode 100644 core/crates/primitives/src/value_access.rs create mode 100644 core/crates/primitives/src/verification_status.rs create mode 100644 core/crates/primitives/src/wallet.rs create mode 100644 core/crates/primitives/src/wallet_configuration.rs create mode 100644 core/crates/primitives/src/wallet_connect.rs create mode 100644 core/crates/primitives/src/wallet_connect_namespace.rs create mode 100644 core/crates/primitives/src/wallet_connector.rs create mode 100644 core/crates/primitives/src/wallet_id.rs create mode 100644 core/crates/primitives/src/wallet_type.rs create mode 100644 core/crates/primitives/src/webhook_kind.rs create mode 100644 core/crates/primitives/src/websocket.rs create mode 100644 core/crates/primitives/src/yield_provider.rs create mode 100644 core/crates/search_index/Cargo.toml create mode 100644 core/crates/search_index/src/lib.rs create mode 100644 core/crates/search_index/src/models/asset.rs create mode 100644 core/crates/search_index/src/models/mod.rs create mode 100644 core/crates/search_index/src/models/nft.rs create mode 100644 core/crates/search_index/src/models/perpetual.rs create mode 100644 core/crates/security_provider/Cargo.toml create mode 100644 core/crates/security_provider/src/lib.rs create mode 100644 core/crates/security_provider/src/mapper.rs create mode 100644 core/crates/security_provider/src/model.rs create mode 100644 core/crates/security_provider/src/providers/goplus/mod.rs create mode 100644 core/crates/security_provider/src/providers/goplus/models.rs create mode 100644 core/crates/security_provider/src/providers/goplus/provider.rs create mode 100644 core/crates/security_provider/src/providers/hashdit/mod.rs create mode 100644 core/crates/security_provider/src/providers/hashdit/models.rs create mode 100644 core/crates/security_provider/src/providers/hashdit/provider.rs create mode 100644 core/crates/security_provider/src/providers/mod.rs create mode 100644 core/crates/security_provider/tests/integration_test.rs create mode 100644 core/crates/serde_serializers/Cargo.toml create mode 100644 core/crates/serde_serializers/src/bigint.rs create mode 100644 core/crates/serde_serializers/src/biguint.rs create mode 100644 core/crates/serde_serializers/src/duration.rs create mode 100644 core/crates/serde_serializers/src/f64.rs create mode 100644 core/crates/serde_serializers/src/hex_bytes.rs create mode 100644 core/crates/serde_serializers/src/lib.rs create mode 100644 core/crates/serde_serializers/src/string.rs create mode 100644 core/crates/serde_serializers/src/u128.rs create mode 100644 core/crates/serde_serializers/src/u64.rs create mode 100644 core/crates/serde_serializers/src/visitors/mod.rs create mode 100644 core/crates/serde_serializers/src/visitors/string_or_number.rs create mode 100644 core/crates/serde_serializers/src/visitors/string_value.rs create mode 100644 core/crates/settings/Cargo.toml create mode 100644 core/crates/settings/src/lib.rs create mode 100644 core/crates/settings/src/testkit.rs create mode 100644 core/crates/settings_chain/Cargo.toml create mode 100644 core/crates/settings_chain/src/broadcast_providers.rs create mode 100644 core/crates/settings_chain/src/chain_providers.rs create mode 100644 core/crates/settings_chain/src/lib.rs create mode 100644 core/crates/settings_chain/src/provider_config.rs create mode 100644 core/crates/signer/Cargo.toml create mode 100644 core/crates/signer/src/address.rs create mode 100644 core/crates/signer/src/decode.rs create mode 100644 core/crates/signer/src/ed25519.rs create mode 100644 core/crates/signer/src/eip712/data.rs create mode 100644 core/crates/signer/src/eip712/hash_impl.rs create mode 100644 core/crates/signer/src/eip712/mod.rs create mode 100644 core/crates/signer/src/eip712/parse.rs create mode 100644 core/crates/signer/src/error.rs create mode 100644 core/crates/signer/src/lib.rs create mode 100644 core/crates/signer/src/secp256k1.rs create mode 100644 core/crates/signer/testdata/eip712_arrays_nested.json create mode 100644 core/crates/signer/testdata/eip712_canonical_chain_id_1.json create mode 100644 core/crates/signer/testdata/eip712_canonical_chain_id_137.json create mode 100644 core/crates/signer/testdata/eip712_missing_message.json create mode 100644 core/crates/signer/testdata/eip712_reference_vector.json create mode 100644 core/crates/signer/testdata/eip712_signed_integers.json create mode 100644 core/crates/simulation/Cargo.toml create mode 100644 core/crates/simulation/src/evm/approval_method.rs create mode 100644 core/crates/simulation/src/evm/approval_request.rs create mode 100644 core/crates/simulation/src/evm/approval_value.rs create mode 100644 core/crates/simulation/src/evm/client.rs create mode 100644 core/crates/simulation/src/evm/decode.rs create mode 100644 core/crates/simulation/src/evm/mod.rs create mode 100644 core/crates/simulation/src/lib.rs create mode 100644 core/crates/simulation/testdata/permit_batch_excessive_expiration.json create mode 100644 core/crates/simulation/testdata/permit_batch_multiple_tokens.json create mode 100644 core/crates/simulation/testdata/permit_batch_shared_token.json create mode 100644 core/crates/simulation/testdata/permit_batch_single_token.json create mode 100644 core/crates/simulation/testdata/permit_excessive_expiration.json create mode 100644 core/crates/storage/Cargo.toml create mode 100644 core/crates/storage/src/config_cacher.rs create mode 100644 core/crates/storage/src/database/assets.rs create mode 100644 core/crates/storage/src/database/assets_addresses.rs create mode 100644 core/crates/storage/src/database/assets_links.rs create mode 100644 core/crates/storage/src/database/assets_usage_ranks.rs create mode 100644 core/crates/storage/src/database/chains.rs create mode 100644 core/crates/storage/src/database/charts.rs create mode 100644 core/crates/storage/src/database/config.rs create mode 100644 core/crates/storage/src/database/devices.rs create mode 100644 core/crates/storage/src/database/fiat.rs create mode 100644 core/crates/storage/src/database/migrations.rs create mode 100644 core/crates/storage/src/database/mod.rs create mode 100644 core/crates/storage/src/database/nft.rs create mode 100644 core/crates/storage/src/database/notifications.rs create mode 100644 core/crates/storage/src/database/parser_state.rs create mode 100644 core/crates/storage/src/database/perpetuals.rs create mode 100644 core/crates/storage/src/database/price_alerts.rs create mode 100644 core/crates/storage/src/database/prices.rs create mode 100644 core/crates/storage/src/database/prices_providers.rs create mode 100644 core/crates/storage/src/database/referrals.rs create mode 100644 core/crates/storage/src/database/releases.rs create mode 100644 core/crates/storage/src/database/rewards.rs create mode 100644 core/crates/storage/src/database/rewards_redemptions.rs create mode 100644 core/crates/storage/src/database/scan_addresses.rs create mode 100644 core/crates/storage/src/database/tag.rs create mode 100644 core/crates/storage/src/database/transactions.rs create mode 100644 core/crates/storage/src/database/usernames.rs create mode 100644 core/crates/storage/src/database/wallets.rs create mode 100644 core/crates/storage/src/database/webhooks.rs create mode 100644 core/crates/storage/src/error.rs create mode 100644 core/crates/storage/src/lib.rs create mode 100644 core/crates/storage/src/migrations/00000000000000_diesel_initial_setup/down.sql create mode 100644 core/crates/storage/src/migrations/00000000000000_diesel_initial_setup/up.sql create mode 100644 core/crates/storage/src/migrations/2023-07-18-212125_chains/down.sql create mode 100644 core/crates/storage/src/migrations/2023-07-18-212125_chains/up.sql create mode 100644 core/crates/storage/src/migrations/2023-07-19-000000_fiat_rates/down.sql create mode 100644 core/crates/storage/src/migrations/2023-07-19-000000_fiat_rates/up.sql create mode 100644 core/crates/storage/src/migrations/2023-07-20-000000_devices/down.sql create mode 100644 core/crates/storage/src/migrations/2023-07-20-000000_devices/up.sql create mode 100644 core/crates/storage/src/migrations/2023-07-22-205905_assets/down.sql create mode 100644 core/crates/storage/src/migrations/2023-07-22-205905_assets/up.sql create mode 100644 core/crates/storage/src/migrations/2023-07-23-000000_add_wallets/down.sql create mode 100644 core/crates/storage/src/migrations/2023-07-23-000000_add_wallets/up.sql create mode 100644 core/crates/storage/src/migrations/2023-07-23-215138_fiat/down.sql create mode 100644 core/crates/storage/src/migrations/2023-07-23-215138_fiat/up.sql create mode 100644 core/crates/storage/src/migrations/2023-07-28-193518_prices/down.sql create mode 100644 core/crates/storage/src/migrations/2023-07-28-193518_prices/up.sql create mode 100644 core/crates/storage/src/migrations/2023-07-29-000000_charts/down.sql create mode 100644 core/crates/storage/src/migrations/2023-07-29-000000_charts/up.sql create mode 100644 core/crates/storage/src/migrations/2023-09-03-220931_parser/down.sql create mode 100644 core/crates/storage/src/migrations/2023-09-03-220931_parser/up.sql create mode 100644 core/crates/storage/src/migrations/2023-09-04-220616_subscriptions/down.sql create mode 100644 core/crates/storage/src/migrations/2023-09-04-220616_subscriptions/up.sql create mode 100644 core/crates/storage/src/migrations/2023-09-05-011115_transactions/down.sql create mode 100644 core/crates/storage/src/migrations/2023-09-05-011115_transactions/up.sql create mode 100644 core/crates/storage/src/migrations/2023-10-18-184745_scan_address/down.sql create mode 100644 core/crates/storage/src/migrations/2023-10-18-184745_scan_address/up.sql create mode 100644 core/crates/storage/src/migrations/2024-09-12-202145_price_alerts/down.sql create mode 100644 core/crates/storage/src/migrations/2024-09-12-202145_price_alerts/up.sql create mode 100644 core/crates/storage/src/migrations/2024-09-24-204906_releases/down.sql create mode 100644 core/crates/storage/src/migrations/2024-09-24-204906_releases/up.sql create mode 100644 core/crates/storage/src/migrations/2025-01-14-162733_nft/down.sql create mode 100644 core/crates/storage/src/migrations/2025-01-14-162733_nft/up.sql create mode 100644 core/crates/storage/src/migrations/2025-10-02-120000_perpetuals/down.sql create mode 100644 core/crates/storage/src/migrations/2025-10-02-120000_perpetuals/up.sql create mode 100644 core/crates/storage/src/migrations/2025-12-10-120000_rewards/down.sql create mode 100644 core/crates/storage/src/migrations/2025-12-10-120000_rewards/up.sql create mode 100644 core/crates/storage/src/migrations/2025-12-16-011051_rewards_redemptions/down.sql create mode 100644 core/crates/storage/src/migrations/2025-12-16-011051_rewards_redemptions/up.sql create mode 100644 core/crates/storage/src/migrations/2025-12-18-120000_config/down.sql create mode 100644 core/crates/storage/src/migrations/2025-12-18-120000_config/up.sql create mode 100644 core/crates/storage/src/migrations/2026-01-13-120000_notifications/down.sql create mode 100644 core/crates/storage/src/migrations/2026-01-13-120000_notifications/up.sql create mode 100644 core/crates/storage/src/migrations/2026-04-09-120000_webhook_endpoints/down.sql create mode 100644 core/crates/storage/src/migrations/2026-04-09-120000_webhook_endpoints/up.sql create mode 100644 core/crates/storage/src/mod.rs create mode 100644 core/crates/storage/src/models/asset.rs create mode 100644 core/crates/storage/src/models/asset_address.rs create mode 100644 core/crates/storage/src/models/asset_usage_rank.rs create mode 100644 core/crates/storage/src/models/chain.rs create mode 100644 core/crates/storage/src/models/chart.rs create mode 100644 core/crates/storage/src/models/config.rs create mode 100644 core/crates/storage/src/models/device.rs create mode 100644 core/crates/storage/src/models/fiat.rs create mode 100644 core/crates/storage/src/models/min_max.rs create mode 100644 core/crates/storage/src/models/mod.rs create mode 100644 core/crates/storage/src/models/nft_asset.rs create mode 100644 core/crates/storage/src/models/nft_asset_association.rs create mode 100644 core/crates/storage/src/models/nft_collection.rs create mode 100644 core/crates/storage/src/models/nft_link.rs create mode 100644 core/crates/storage/src/models/nft_report.rs create mode 100644 core/crates/storage/src/models/notification.rs create mode 100644 core/crates/storage/src/models/parser_state.rs create mode 100644 core/crates/storage/src/models/perpetual.rs create mode 100644 core/crates/storage/src/models/price.rs create mode 100644 core/crates/storage/src/models/price_alert.rs create mode 100644 core/crates/storage/src/models/price_provider.rs create mode 100644 core/crates/storage/src/models/release.rs create mode 100644 core/crates/storage/src/models/reward.rs create mode 100644 core/crates/storage/src/models/scan_addresses.rs create mode 100644 core/crates/storage/src/models/subscription_address_exclude.rs create mode 100644 core/crates/storage/src/models/tag.rs create mode 100644 core/crates/storage/src/models/transaction.rs create mode 100644 core/crates/storage/src/models/transaction_addresses.rs create mode 100644 core/crates/storage/src/models/username.rs create mode 100644 core/crates/storage/src/models/wallet.rs create mode 100644 core/crates/storage/src/models/webhook.rs create mode 100644 core/crates/storage/src/repositories/assets_addresses_repository.rs create mode 100644 core/crates/storage/src/repositories/assets_links_repository.rs create mode 100644 core/crates/storage/src/repositories/assets_repository.rs create mode 100644 core/crates/storage/src/repositories/assets_usage_ranks_repository.rs create mode 100644 core/crates/storage/src/repositories/chains_repository.rs create mode 100644 core/crates/storage/src/repositories/charts_repository.rs create mode 100644 core/crates/storage/src/repositories/config_repository.rs create mode 100644 core/crates/storage/src/repositories/devices_repository.rs create mode 100644 core/crates/storage/src/repositories/fiat_repository.rs create mode 100644 core/crates/storage/src/repositories/migrations_repository.rs create mode 100644 core/crates/storage/src/repositories/mod.rs create mode 100644 core/crates/storage/src/repositories/nft_repository.rs create mode 100644 core/crates/storage/src/repositories/notifications_repository.rs create mode 100644 core/crates/storage/src/repositories/parser_state_repository.rs create mode 100644 core/crates/storage/src/repositories/perpetuals_repository.rs create mode 100644 core/crates/storage/src/repositories/price_alerts_repository.rs create mode 100644 core/crates/storage/src/repositories/prices_providers_repository.rs create mode 100644 core/crates/storage/src/repositories/prices_repository.rs create mode 100644 core/crates/storage/src/repositories/releases_repository.rs create mode 100644 core/crates/storage/src/repositories/rewards_redemptions_repository.rs create mode 100644 core/crates/storage/src/repositories/rewards_repository.rs create mode 100644 core/crates/storage/src/repositories/risk_signals_repository.rs create mode 100644 core/crates/storage/src/repositories/scan_addresses_repository.rs create mode 100644 core/crates/storage/src/repositories/tag_repository.rs create mode 100644 core/crates/storage/src/repositories/transactions_repository.rs create mode 100644 core/crates/storage/src/repositories/wallets_repository.rs create mode 100644 core/crates/storage/src/repositories/webhooks_repository.rs create mode 100644 core/crates/storage/src/schema.rs create mode 100644 core/crates/storage/src/sql_types.rs create mode 100644 core/crates/storage/src/testkit/asset_mock.rs create mode 100644 core/crates/storage/src/testkit/fiat_transaction_mock.rs create mode 100644 core/crates/storage/src/testkit/mod.rs create mode 100644 core/crates/storage/src/testkit/price_mock.rs create mode 100644 core/crates/storage/src/testkit/scan_address_mock.rs create mode 100644 core/crates/streamer/Cargo.toml create mode 100644 core/crates/streamer/src/connection.rs create mode 100644 core/crates/streamer/src/consumer.rs create mode 100644 core/crates/streamer/src/exchange.rs create mode 100644 core/crates/streamer/src/lib.rs create mode 100644 core/crates/streamer/src/payload.rs create mode 100644 core/crates/streamer/src/queue.rs create mode 100644 core/crates/streamer/src/steam_producer_queue.rs create mode 100644 core/crates/streamer/src/stream_producer.rs create mode 100644 core/crates/streamer/src/stream_reader.rs create mode 100644 core/crates/support/Cargo.toml create mode 100644 core/crates/support/src/client.rs create mode 100644 core/crates/support/src/lib.rs create mode 100644 core/crates/support/src/model.rs create mode 100644 core/crates/support/tests/model_tests.rs create mode 100644 core/crates/support/tests/testdata/chatwoot_conversation_updated.json create mode 100644 core/crates/support/tests/testdata/chatwoot_message_created.json create mode 100644 core/crates/swapper/Cargo.toml create mode 100644 core/crates/swapper/src/across/api.rs create mode 100644 core/crates/swapper/src/across/config_store.rs create mode 100644 core/crates/swapper/src/across/hubpool.rs create mode 100644 core/crates/swapper/src/across/mod.rs create mode 100644 core/crates/swapper/src/across/provider.rs create mode 100644 core/crates/swapper/src/alien/mock.rs create mode 100644 core/crates/swapper/src/alien/mod.rs create mode 100644 core/crates/swapper/src/alien/reqwest_provider.rs create mode 100644 core/crates/swapper/src/approval/evm.rs create mode 100644 core/crates/swapper/src/approval/mod.rs create mode 100644 core/crates/swapper/src/cache.rs create mode 100644 core/crates/swapper/src/cetus_clmm/cache.rs create mode 100644 core/crates/swapper/src/cetus_clmm/client.rs create mode 100644 core/crates/swapper/src/cetus_clmm/constants.rs create mode 100644 core/crates/swapper/src/cetus_clmm/mod.rs create mode 100644 core/crates/swapper/src/cetus_clmm/model.rs create mode 100644 core/crates/swapper/src/cetus_clmm/provider.rs create mode 100644 core/crates/swapper/src/cetus_clmm/tx_builder.rs create mode 100644 core/crates/swapper/src/chainflip/broker/client.rs create mode 100644 core/crates/swapper/src/chainflip/broker/mod.rs create mode 100644 core/crates/swapper/src/chainflip/broker/model.rs create mode 100644 core/crates/swapper/src/chainflip/capitalize.rs create mode 100644 core/crates/swapper/src/chainflip/client/mod.rs create mode 100644 core/crates/swapper/src/chainflip/client/model.rs create mode 100644 core/crates/swapper/src/chainflip/client/swap.rs create mode 100644 core/crates/swapper/src/chainflip/client/test/btc_eth_quote.json create mode 100644 core/crates/swapper/src/chainflip/client/test/swap_btc_to_usdt_refunded.json create mode 100644 core/crates/swapper/src/chainflip/client/test/swap_eth_to_btc.json create mode 100644 core/crates/swapper/src/chainflip/client/test/swap_sol_to_btc.json create mode 100644 core/crates/swapper/src/chainflip/client/test/swap_usdc_to_btc_pending.json create mode 100644 core/crates/swapper/src/chainflip/client/test/swap_usdc_to_sol.json create mode 100644 core/crates/swapper/src/chainflip/default.rs create mode 100644 core/crates/swapper/src/chainflip/mod.rs create mode 100644 core/crates/swapper/src/chainflip/model.rs create mode 100644 core/crates/swapper/src/chainflip/price.rs create mode 100644 core/crates/swapper/src/chainflip/provider.rs create mode 100644 core/crates/swapper/src/chainflip/seed.rs create mode 100644 core/crates/swapper/src/chainflip/test/chainflip_boost_quotes.json create mode 100644 core/crates/swapper/src/chainflip/test/chainflip_quotes.json create mode 100644 core/crates/swapper/src/chainflip/test/chainflip_sol_arb_usdc_quote_data.json create mode 100644 core/crates/swapper/src/chainflip/tx_builder.rs create mode 100644 core/crates/swapper/src/chainlink.rs create mode 100644 core/crates/swapper/src/client_factory.rs create mode 100644 core/crates/swapper/src/config.rs create mode 100644 core/crates/swapper/src/cross_chain.rs create mode 100644 core/crates/swapper/src/error.rs create mode 100644 core/crates/swapper/src/eth_address.rs create mode 100644 core/crates/swapper/src/fee_token.rs create mode 100644 core/crates/swapper/src/fees/mod.rs create mode 100644 core/crates/swapper/src/fees/referral.rs create mode 100644 core/crates/swapper/src/fees/reserve.rs create mode 100644 core/crates/swapper/src/fees/slippage.rs create mode 100644 core/crates/swapper/src/hyperliquid/mod.rs create mode 100644 core/crates/swapper/src/hyperliquid/provider/bridge.rs create mode 100644 core/crates/swapper/src/hyperliquid/provider/hyperliquid.rs create mode 100644 core/crates/swapper/src/hyperliquid/provider/mod.rs create mode 100644 core/crates/swapper/src/hyperliquid/provider/spot/math.rs create mode 100644 core/crates/swapper/src/hyperliquid/provider/spot/mod.rs create mode 100644 core/crates/swapper/src/hyperliquid/provider/spot/provider.rs create mode 100644 core/crates/swapper/src/hyperliquid/provider/spot/simulator.rs create mode 100644 core/crates/swapper/src/jupiter/client.rs create mode 100644 core/crates/swapper/src/jupiter/default.rs create mode 100644 core/crates/swapper/src/jupiter/mod.rs create mode 100644 core/crates/swapper/src/jupiter/model.rs create mode 100644 core/crates/swapper/src/jupiter/provider.rs create mode 100644 core/crates/swapper/src/lib.rs create mode 100644 core/crates/swapper/src/mayan/asset.rs create mode 100644 core/crates/swapper/src/mayan/cctp_domain.rs create mode 100644 core/crates/swapper/src/mayan/client/explorer.rs create mode 100644 core/crates/swapper/src/mayan/client/get_swap.rs create mode 100644 core/crates/swapper/src/mayan/client/mod.rs create mode 100644 core/crates/swapper/src/mayan/client/quote.rs create mode 100644 core/crates/swapper/src/mayan/constants.rs create mode 100644 core/crates/swapper/src/mayan/mapper.rs create mode 100644 core/crates/swapper/src/mayan/mod.rs create mode 100644 core/crates/swapper/src/mayan/model.rs create mode 100644 core/crates/swapper/src/mayan/provider.rs create mode 100644 core/crates/swapper/src/mayan/test/btcbr_to_radr_swift.json create mode 100644 core/crates/swapper/src/mayan/test/eth_to_sui_swift.json create mode 100644 core/crates/swapper/src/mayan/test/fast_mctp_quote.json create mode 100644 core/crates/swapper/src/mayan/test/mctp_pending.json create mode 100644 core/crates/swapper/src/mayan/test/pol_to_bnb_swift.json create mode 100644 core/crates/swapper/src/mayan/test/quote_response_swift.json create mode 100644 core/crates/swapper/src/mayan/test/quote_response_swift_hypercore.json create mode 100644 core/crates/swapper/src/mayan/test/quote_swift_solana.json create mode 100644 core/crates/swapper/src/mayan/test/sui_client_swap.json create mode 100644 core/crates/swapper/src/mayan/test/swift_quote_evm_to_solana.json create mode 100644 core/crates/swapper/src/mayan/test/swift_refunded.json create mode 100644 core/crates/swapper/src/mayan/test/usdt_to_owb_swift.json create mode 100644 core/crates/swapper/src/mayan/testkit.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/address.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/amount.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/evm.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/fast_mctp/evm.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/fast_mctp/mod.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/fast_mctp/solana.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/hypercore.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/evm.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/mod.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/solana.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/sui.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/sui/prefetch.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/bridge.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/fees.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/order.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mod.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mono_chain/evm.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/mono_chain/mod.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/route.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/solana.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/evm.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/evm/contracts.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/evm/order.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/evm/transaction.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/mod.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/solana.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/solana/order.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/solana/payload.rs create mode 100644 core/crates/swapper/src/mayan/tx_builder/swift/solana/transaction.rs create mode 100644 core/crates/swapper/src/mayan/wormhole_chain.rs create mode 100644 core/crates/swapper/src/models.rs create mode 100644 core/crates/swapper/src/near_intents/assets.rs create mode 100644 core/crates/swapper/src/near_intents/client.rs create mode 100644 core/crates/swapper/src/near_intents/config.rs create mode 100644 core/crates/swapper/src/near_intents/mod.rs create mode 100644 core/crates/swapper/src/near_intents/model.rs create mode 100644 core/crates/swapper/src/near_intents/provider.rs create mode 100644 core/crates/swapper/src/near_intents/testdata/tx_status_avax_to_smartchain.json create mode 100644 core/crates/swapper/src/near_intents/testdata/tx_status_solana_to_bitcoin.json create mode 100644 core/crates/swapper/src/near_intents/testdata/tx_status_ton_to_smartchain_refunded.json create mode 100644 core/crates/swapper/src/okx/auth.rs create mode 100644 core/crates/swapper/src/okx/client.rs create mode 100644 core/crates/swapper/src/okx/constants.rs create mode 100644 core/crates/swapper/src/okx/mod.rs create mode 100644 core/crates/swapper/src/okx/model.rs create mode 100644 core/crates/swapper/src/okx/provider.rs create mode 100644 core/crates/swapper/src/okx/referral.rs create mode 100644 core/crates/swapper/src/panora/client.rs create mode 100644 core/crates/swapper/src/panora/mod.rs create mode 100644 core/crates/swapper/src/panora/model.rs create mode 100644 core/crates/swapper/src/panora/provider.rs create mode 100644 core/crates/swapper/src/panora/testdata/quote_response.json create mode 100644 core/crates/swapper/src/permit2_data.rs create mode 100644 core/crates/swapper/src/proxy/client.rs create mode 100644 core/crates/swapper/src/proxy/mod.rs create mode 100644 core/crates/swapper/src/proxy/provider.rs create mode 100644 core/crates/swapper/src/proxy/provider_factory.rs create mode 100644 core/crates/swapper/src/relay/asset.rs create mode 100644 core/crates/swapper/src/relay/chain.rs create mode 100644 core/crates/swapper/src/relay/client.rs create mode 100644 core/crates/swapper/src/relay/mapper.rs create mode 100644 core/crates/swapper/src/relay/mod.rs create mode 100644 core/crates/swapper/src/relay/model.rs create mode 100644 core/crates/swapper/src/relay/provider.rs create mode 100644 core/crates/swapper/src/relay/testdata/request_bsc_usdt_to_sol.json create mode 100644 core/crates/swapper/src/relay/testdata/request_eth_to_btc.json create mode 100644 core/crates/swapper/src/relay/testkit.rs create mode 100644 core/crates/swapper/src/route_cache.rs create mode 100644 core/crates/swapper/src/solana.rs create mode 100644 core/crates/swapper/src/squid/client.rs create mode 100644 core/crates/swapper/src/squid/mod.rs create mode 100644 core/crates/swapper/src/squid/model.rs create mode 100644 core/crates/swapper/src/squid/provider.rs create mode 100644 core/crates/swapper/src/stonfi/client.rs create mode 100644 core/crates/swapper/src/stonfi/constants.rs create mode 100644 core/crates/swapper/src/stonfi/mod.rs create mode 100644 core/crates/swapper/src/stonfi/model.rs create mode 100644 core/crates/swapper/src/stonfi/provider.rs create mode 100644 core/crates/swapper/src/stonfi/quote.rs create mode 100644 core/crates/swapper/src/stonfi/testdata/v1_simulation.json create mode 100644 core/crates/swapper/src/stonfi/testdata/v2_simulation.json create mode 100644 core/crates/swapper/src/stonfi/testkit.rs create mode 100644 core/crates/swapper/src/stonfi/tx_builder/message.rs create mode 100644 core/crates/swapper/src/stonfi/tx_builder/mod.rs create mode 100644 core/crates/swapper/src/stonfi/tx_builder/model.rs create mode 100644 core/crates/swapper/src/stonfi/tx_builder/v1.rs create mode 100644 core/crates/swapper/src/stonfi/tx_builder/v2.rs create mode 100644 core/crates/swapper/src/swapper.rs create mode 100644 core/crates/swapper/src/swapper_trait.rs create mode 100644 core/crates/swapper/src/testkit.rs create mode 100644 core/crates/swapper/src/thorchain/asset.rs create mode 100644 core/crates/swapper/src/thorchain/chain.rs create mode 100644 core/crates/swapper/src/thorchain/client.rs create mode 100644 core/crates/swapper/src/thorchain/constants.rs create mode 100644 core/crates/swapper/src/thorchain/memo.rs create mode 100644 core/crates/swapper/src/thorchain/mod.rs create mode 100644 core/crates/swapper/src/thorchain/model.rs create mode 100644 core/crates/swapper/src/thorchain/provider.rs create mode 100644 core/crates/swapper/src/thorchain/quote_data_mapper.rs create mode 100644 core/crates/swapper/src/thorchain/swap_mapper.rs create mode 100644 core/crates/swapper/src/thorchain/testdata/asgard_vaults.json create mode 100644 core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_eth_usdt.json create mode 100644 core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_tron.json create mode 100644 core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_tron_pending.json create mode 100644 core/crates/swapper/src/thorchain/testdata/tx_status_btc_to_tron_pending.json create mode 100644 core/crates/swapper/src/thorchain/testdata/tx_status_eth_usdt_to_rune.json create mode 100644 core/crates/swapper/src/thorchain/testdata/tx_status_ltc_to_eth.json create mode 100644 core/crates/swapper/src/thorchain/testdata/tx_status_ltc_to_tron_usdt.json create mode 100644 core/crates/swapper/src/thorchain/testdata/tx_status_tcy_to_eth_usdt.json create mode 100644 core/crates/swapper/src/uniswap/deadline.rs create mode 100644 core/crates/swapper/src/uniswap/default.rs create mode 100644 core/crates/swapper/src/uniswap/fee_token.rs create mode 100644 core/crates/swapper/src/uniswap/mod.rs create mode 100644 core/crates/swapper/src/uniswap/native_asset.rs create mode 100644 core/crates/swapper/src/uniswap/quote_result.rs create mode 100644 core/crates/swapper/src/uniswap/swap_route.rs create mode 100644 core/crates/swapper/src/uniswap/universal_router/mod.rs create mode 100644 core/crates/swapper/src/uniswap/v3/commands.rs create mode 100644 core/crates/swapper/src/uniswap/v3/mod.rs create mode 100644 core/crates/swapper/src/uniswap/v3/path.rs create mode 100644 core/crates/swapper/src/uniswap/v3/provider.rs create mode 100644 core/crates/swapper/src/uniswap/v3/quoter_v2.rs create mode 100644 core/crates/swapper/src/uniswap/v4/commands.rs create mode 100644 core/crates/swapper/src/uniswap/v4/mod.rs create mode 100644 core/crates/swapper/src/uniswap/v4/path.rs create mode 100644 core/crates/swapper/src/uniswap/v4/provider.rs create mode 100644 core/crates/swapper/src/uniswap/v4/quoter.rs create mode 100644 core/crates/swapper/testdata/squid/status_response.json create mode 100644 core/crates/tracing/Cargo.toml create mode 100644 core/crates/tracing/src/lib.rs create mode 100644 core/crates/yielder/Cargo.toml create mode 100644 core/crates/yielder/src/client_factory.rs create mode 100644 core/crates/yielder/src/error.rs create mode 100644 core/crates/yielder/src/lib.rs create mode 100644 core/crates/yielder/src/provider.rs create mode 100644 core/crates/yielder/src/yielder.rs create mode 100644 core/crates/yielder/src/yo/assets.rs create mode 100644 core/crates/yielder/src/yo/client.rs create mode 100644 core/crates/yielder/src/yo/contract.rs create mode 100644 core/crates/yielder/src/yo/mapper.rs create mode 100644 core/crates/yielder/src/yo/mod.rs create mode 100644 core/crates/yielder/src/yo/provider.rs create mode 100644 core/diesel.toml create mode 100644 core/docker-compose.yml create mode 100644 core/docs/DEVICE_AUTHENTICATION.md create mode 100644 core/docs/DEVICE_WEBSOCKETS.md create mode 100644 core/docs/REWARDS_AND_REFERRALS.md create mode 100644 core/docs/WALLET_AUTHENTICATION.md create mode 100644 core/gemstone/.cargo/config.toml create mode 100644 core/gemstone/.gitignore create mode 100644 core/gemstone/Cargo.toml create mode 100644 core/gemstone/README.md create mode 100644 core/gemstone/android/.gitignore create mode 100644 core/gemstone/android/build.gradle.kts create mode 100644 core/gemstone/android/gemstone/.gitignore create mode 100644 core/gemstone/android/gemstone/build.gradle.kts create mode 100644 core/gemstone/android/gemstone/consumer-rules.pro create mode 100644 core/gemstone/android/gemstone/proguard-rules.pro create mode 100644 core/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt create mode 100644 core/gemstone/android/gemstone/src/main/AndroidManifest.xml create mode 100644 core/gemstone/android/gradle.properties create mode 100644 core/gemstone/android/gradle/gradle-daemon-jvm.properties create mode 100644 core/gemstone/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 core/gemstone/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 core/gemstone/android/gradlew create mode 100644 core/gemstone/android/gradlew.bat create mode 100644 core/gemstone/android/settings.gradle.kts create mode 100644 core/gemstone/justfile create mode 100644 core/gemstone/src/address.rs create mode 100644 core/gemstone/src/address_formatter.rs create mode 100644 core/gemstone/src/alien/client.rs create mode 100644 core/gemstone/src/alien/error.rs create mode 100644 core/gemstone/src/alien/mod.rs create mode 100644 core/gemstone/src/alien/provider.rs create mode 100644 core/gemstone/src/alien/reqwest_provider.rs create mode 100644 core/gemstone/src/alien/target.rs create mode 100644 core/gemstone/src/api_client/mod.rs create mode 100644 core/gemstone/src/auth.rs create mode 100644 core/gemstone/src/block_explorer/explorer.rs create mode 100644 core/gemstone/src/block_explorer/mod.rs create mode 100644 core/gemstone/src/block_explorer/remote_types.rs create mode 100644 core/gemstone/src/config/chain.rs create mode 100644 core/gemstone/src/config/docs.rs create mode 100644 core/gemstone/src/config/mod.rs create mode 100644 core/gemstone/src/config/node.rs create mode 100644 core/gemstone/src/config/perpetual_config.rs create mode 100644 core/gemstone/src/config/public.rs create mode 100644 core/gemstone/src/config/rewards.rs create mode 100644 core/gemstone/src/config/social.rs create mode 100644 core/gemstone/src/config/stake.rs create mode 100644 core/gemstone/src/config/swap_config.rs create mode 100644 core/gemstone/src/config/validators.rs create mode 100644 core/gemstone/src/config/wallet_connect.rs create mode 100644 core/gemstone/src/deeplink.rs create mode 100644 core/gemstone/src/ethereum/decoder.rs create mode 100644 core/gemstone/src/ethereum/mod.rs create mode 100644 core/gemstone/src/ethereum/test/fee_history.json create mode 100644 core/gemstone/src/gateway/chain_factory.rs create mode 100644 core/gemstone/src/gateway/error.rs create mode 100644 core/gemstone/src/gateway/mod.rs create mode 100644 core/gemstone/src/gateway/preferences.rs create mode 100644 core/gemstone/src/gem_swapper/error.rs create mode 100644 core/gemstone/src/gem_swapper/mod.rs create mode 100644 core/gemstone/src/gem_swapper/permit2.rs create mode 100644 core/gemstone/src/gem_swapper/remote_types.rs create mode 100644 core/gemstone/src/lib.rs create mode 100644 core/gemstone/src/message/eip712.rs create mode 100644 core/gemstone/src/message/mod.rs create mode 100644 core/gemstone/src/message/payload.rs create mode 100644 core/gemstone/src/message/sign_type.rs create mode 100644 core/gemstone/src/message/signer.rs create mode 100644 core/gemstone/src/message/test/eip712_polymarket.json create mode 100644 core/gemstone/src/message/test/eip712_seaport.json create mode 100644 core/gemstone/src/models/asset.rs create mode 100644 core/gemstone/src/models/balance.rs create mode 100644 core/gemstone/src/models/custom_types.rs create mode 100644 core/gemstone/src/models/gateway.rs create mode 100644 core/gemstone/src/models/mod.rs create mode 100644 core/gemstone/src/models/nft.rs create mode 100644 core/gemstone/src/models/node.rs create mode 100644 core/gemstone/src/models/perpetual.rs create mode 100644 core/gemstone/src/models/portfolio.rs create mode 100644 core/gemstone/src/models/scan.rs create mode 100644 core/gemstone/src/models/simulation.rs create mode 100644 core/gemstone/src/models/stake.rs create mode 100644 core/gemstone/src/models/swap.rs create mode 100644 core/gemstone/src/models/token.rs create mode 100644 core/gemstone/src/models/transaction.rs create mode 100644 core/gemstone/src/network/mod.rs create mode 100644 core/gemstone/src/payment/mod.rs create mode 100644 core/gemstone/src/perpetual.rs create mode 100644 core/gemstone/src/price_alert_formatter.rs create mode 100644 core/gemstone/src/signer/chain.rs create mode 100644 core/gemstone/src/signer/decode.rs create mode 100644 core/gemstone/src/signer/mod.rs create mode 100644 core/gemstone/src/siwe.rs create mode 100644 core/gemstone/src/testkit.rs create mode 100644 core/gemstone/src/transaction_state/config.rs create mode 100644 core/gemstone/src/transaction_state/error.rs create mode 100644 core/gemstone/src/transaction_state/mod.rs create mode 100644 core/gemstone/src/transaction_state/status_provider.rs create mode 100644 core/gemstone/src/url_action.rs create mode 100644 core/gemstone/src/wallet_connect/mod.rs create mode 100644 core/gemstone/src/wallet_connect/simulation.rs create mode 100644 core/gemstone/src/wallet_connect/simulation_client.rs create mode 100644 core/gemstone/tests/android/GemTest/.gitignore create mode 100644 core/gemstone/tests/android/GemTest/app/.gitignore create mode 100644 core/gemstone/tests/android/GemTest/app/build.gradle create mode 100644 core/gemstone/tests/android/GemTest/app/proguard-rules.pro create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/AndroidManifest.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/MainActivity.kt create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/NativeProvider.kt create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Color.kt create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Theme.kt create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Type.kt create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/values/colors.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/values/strings.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/values/themes.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/xml/backup_rules.xml create mode 100644 core/gemstone/tests/android/GemTest/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 core/gemstone/tests/android/GemTest/build.gradle create mode 100644 core/gemstone/tests/android/GemTest/gradle.properties create mode 100644 core/gemstone/tests/android/GemTest/gradle/wrapper/gradle-wrapper.jar create mode 100644 core/gemstone/tests/android/GemTest/gradle/wrapper/gradle-wrapper.properties create mode 100755 core/gemstone/tests/android/GemTest/gradlew create mode 100644 core/gemstone/tests/android/GemTest/gradlew.bat create mode 100644 core/gemstone/tests/android/GemTest/settings.gradle create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Assets.xcassets/Contents.json create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/ContentView.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Extension/Data+Hex.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Extension/Gemstone+Extension.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Extension/Swapper+Ext.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Extension/URLRequest+Extension.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/GemTestApp.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Networking/Cache.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Networking/Provider.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/SwapRequests.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTest/ViewModel.swift create mode 100644 core/gemstone/tests/ios/GemTest/GemTestTests/GemTestTests.swift create mode 100644 core/gemstone/tests/ios/GemTest/Package.swift create mode 100644 core/gemstone/tests/ios/Packages/Gemstone/Package.swift create mode 100644 core/gemstone/tests/ios/Packages/Gemstone/Sources/GemstoneFFI/shim.c create mode 100644 core/gemstone/uniffi.toml create mode 100644 core/justfile create mode 100644 core/rustfmt.toml create mode 100755 core/scripts/free_disk_space.sh create mode 100644 core/scripts/localize.sh create mode 100644 core/skills/architecture.md create mode 100644 core/skills/code-style.md create mode 100644 core/skills/common-issues.md create mode 100644 core/skills/defensive-programming.md create mode 100644 core/skills/development-commands.md create mode 100644 core/skills/error-handling.md create mode 100644 core/skills/project-structure.md create mode 100644 core/skills/swapper-checklist.md create mode 100644 core/skills/tests.md create mode 100644 core/typeshare.toml diff --git a/core b/core deleted file mode 160000 index 5e198200b2..0000000000 --- a/core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5e198200b2669ebebe155687926accc4ba5a0d5c diff --git a/core/.cargo/audit.toml b/core/.cargo/audit.toml new file mode 100644 index 0000000000..8ec661dea3 --- /dev/null +++ b/core/.cargo/audit.toml @@ -0,0 +1,6 @@ +[advisories] +ignore = [ + # ruint::algorithms::div::reciprocal_mg10 OOB; only reachable if low-level algorithms are called directly. + # We only use ruint through alloy-primitives higher-level APIs. + "RUSTSEC-2025-0137", +] diff --git a/core/.cargo/config.toml b/core/.cargo/config.toml new file mode 100644 index 0000000000..d294d0535c --- /dev/null +++ b/core/.cargo/config.toml @@ -0,0 +1,3 @@ +# Apple Silicon Macs (Homebrew in /opt/homebrew) +[target.aarch64-apple-darwin] +rustflags = ["-L", "/opt/homebrew/opt/libpq/lib"] diff --git a/core/.claude/skills/review-changes/SKILL.md b/core/.claude/skills/review-changes/SKILL.md new file mode 100644 index 0000000000..793402f53b --- /dev/null +++ b/core/.claude/skills/review-changes/SKILL.md @@ -0,0 +1,161 @@ +--- +name: review-changes +description: Review and fix local git changes against Gem Wallet Core coding standards and patterns +argument-hint: "[--subagent]" +disable-model-invocation: true +allowed-tools: Read, Edit, Write, Glob, Grep, Bash, Task +--- + +# Review and Fix Local Changes + +Review uncommitted changes against the coding standards and patterns defined in this repository, then fix any issues found. + +## Arguments +- `--subagent`: Run in a subagent (isolates context, runs in background) + +## Mode Selection + +Check if `--subagent` is in the arguments: +- If `--subagent` is present: Use the Task tool with `subagent_type: "general-purpose"` to run this review in a subagent, passing all other arguments +- If `--subagent` is NOT present: Run directly in current context (default) + +## Context + +Current git diff to review: +!`git diff --no-color` + +Changed files: +!`git diff --name-only` + +## Review Checklist + +Analyze the diff above and check for the following issues: + +### 1. Import Patterns +- [ ] **No inline imports**: All imports must be at the top of the file, never inside functions +- [ ] **No full paths inline**: Never use `storage::DatabaseClient::new()` inline; import types first +- [ ] **Import order**: Standard library first, then external crates, then local crates, then `pub use` re-exports + +### 2. Naming Conventions +- [ ] **Files/modules**: `snake_case` (e.g., `asset_id.rs`) +- [ ] **Functions/variables**: `snake_case` +- [ ] **Structs/enums**: `PascalCase` +- [ ] **Constants**: `SCREAMING_SNAKE_CASE` +- [ ] **No generic names**: Avoid `util`, `utils`, `normalize`, or similar vague names +- [ ] **Concise helper names**: Within a module, use scope-reliant names (prefer `is_spot_swap` over `is_hypercore_spot_swap`) +- [ ] **No type suffixes**: Avoid `_str`, `_int`, `_vec` suffixes; Rust's type system makes them redundant + +### 3. Error Handling +- [ ] **Prefer plain `Error`**: Use plain Error types, not `thiserror` macros +- [ ] **Implement `From` traits**: For error conversion between types +- [ ] **Propagate with `?`**: Prefer `?` operator over manual `map_err` where possible +- [ ] **Consistent `Result`**: Use consistent return types +- [ ] **Constructor methods on errors**: Use `ErrorType::constructor(msg)` instead of verbose `ErrorType::Variant("redundant context".into())` + +### 3b. JSON Parameter Extraction +- [ ] **Use `primitives::ValueAccess`**: For `serde_json::Value` access, use composable trait methods (`get_value(key)`, `at(index)`, `string()`) instead of manual `.get().ok_or()` chains. Chain for compound access: `params.get_value("key")?.at(0)?.string()?` +- [ ] **Accessor methods on parent types**: Add accessor methods (e.g., `TransactionLoadInput::get_data_extra()`) to avoid pattern-matching boilerplate at call sites + +### 4. Code Style +- [ ] **Line length**: Maximum 180 characters +- [ ] **Avoid `matches!`**: Don't use `matches!` for pattern matching; it's easy to miss cases later +- [ ] **No over-engineering**: Only make changes directly requested or clearly necessary +- [ ] **No docstrings/comments/annotations**: Don't add docstrings, comments, or `///` docs unless explicitly asked; remove any that were added (including in mod.rs files) +- [ ] **No `#[allow(dead_code)]`**: Remove dead code instead of suppressing warnings; if code is needed, use it +- [ ] **No unused fields**: Remove unused fields from structs/models; don't keep fields "for future use" +- [ ] **Constants for magic numbers**: Extract magic numbers into named constants with clear meaning +- [ ] **Minimum interface**: Don't expose unnecessary functions; if client only needs one function, don't add multiple variants +- [ ] **Use uniffi::remote**: For UniFFI wrapper types around external models, use `#[uniffi::remote]` instead of creating duplicate structs with `From` implementations: + ```rust + // Record example + use primitives::AuthNonce; + pub type GemAuthNonce = AuthNonce; + #[uniffi::remote(Record)] + pub struct GemAuthNonce { pub nonce: String, pub timestamp: u32 } + + // Enum example + use primitives::SwapperMode; + pub type GemSwapperMode = SwapperMode; + #[uniffi::remote(Enum)] + pub enum GemSwapperMode { ExactIn, ExactOut } + ``` +- [ ] **Simple solutions**: Three similar lines is better than a premature abstraction +- [ ] **Avoid `mut`**: Prefer immutable bindings; use `mut` only when truly necessary +- [ ] **Prefer one-liners**: Inline single-use variables; avoid creating variables used only once +- [ ] **Avoid `#[serde(default)]`**: Only use when the field is genuinely optional in the API response; if the field is always present, omit it +- [ ] **Use accessor methods for enum variants**: Instead of destructuring enum variants with `match`, use typed accessor methods (e.g., `metadata.get_sequence()?` instead of `match &metadata { Cosmos { sequence, .. } => ... }`) + +### 5. Code Organization +- [ ] **Modular structure**: Break down files into smaller, focused modules; separate models from clients/logic (e.g., `models.rs` + `client.rs`, not everything in one file) +- [ ] **Folder modules for complexity**: When a module has multiple concerns (models, client, mappers), use a folder with `mod.rs` instead of a single file +- [ ] **Avoid duplication**: Search for existing implementations before writing new code; reuse existing code or crates +- [ ] **Shared crates**: Reusable logic belongs in shared crates (e.g., `gem_solana`, `gem_evm`), not in utility binaries; move shared code to appropriate crates +- [ ] **Bird's eye view**: Step back and identify opportunities to simplify and consolidate + +### 6. Async Patterns +- [ ] **Tokio runtime**: Use `tokio` for async operations +- [ ] **Shared state**: Use `Arc>` for shared async state +- [ ] **Async client structs**: Should return `Result` + +### 7. Database Patterns +- [ ] **Separate models**: Database models should be separate from domain primitives +- [ ] **Use `as_primitive()`**: For conversion from database models +- [ ] **Repository pattern**: Access via `DatabaseClient` methods + +### 8. Blockchain/RPC Patterns +- [ ] **Use `gem_jsonrpc::JsonRpcClient`**: For blockchain RPC interactions +- [ ] **Use `primitives::hex`**: For hex encoding/decoding (not `alloy_primitives::hex`) +- [ ] **U256 conversions**: Use `u256_to_biguint` and `biguint_to_u256` from `gem_evm/src/u256.rs` +- [ ] **Provider pattern**: Fetch raw data via RPC, then use mapper functions for conversion +- [ ] **Mapper files**: Place mapper functions in separate `*_mapper.rs` files + +### 9. Testing +- [ ] **`#[tokio::test]`**: Use for async tests +- [ ] **Test naming**: Prefix with `test_` descriptively +- [ ] **Error handling**: Use `Result<(), Box>` +- [ ] **Test data**: For long JSON (>20 lines), store in `testdata/` and use `include_str!()` +- [ ] **`.unwrap()` not `.expect()`**: Never use `.expect()` in tests; use `.unwrap()` for brevity +- [ ] **No `assert!` with `contains`**: Use `assert_eq!` with concrete values; `assert!(x.contains(...))` gives useless failure messages +- [ ] **No fallback, fail fast**: Don't silently return defaults on errors (e.g., `unwrap_or(0)`). Propagate errors with `?` or return `Result`. Fail rather than mask issues with fallbacks. +- [ ] **Methods over free functions**: Helper functions should be methods on the relevant struct, not top-level free functions +- [ ] **Mock methods in testkit**: Use `Type::mock()` constructors in `testkit/` modules instead of inline struct construction in tests +- [ ] **`PartialEq` + `assert_eq!`**: Derive `PartialEq` on test-relevant enums and use direct `assert_eq!` with constructed expected values instead of destructuring with `let ... else { panic! }` or `match ... { _ => panic! }` +- [ ] **Test helpers**: Create concise constructor functions (e.g., `fn object(json: &str) -> EnumType`, `fn sign_message(chain, sign_type, data) -> Action`) for frequently constructed enum variants in test modules + +### 10. Security +- [ ] **No hardcoded secrets**: Check for API keys, passwords, credentials +- [ ] **Input validation**: Validate at system boundaries (user input, external APIs) +- [ ] **OWASP top 10**: Watch for command injection, XSS, SQL injection vulnerabilities + +## Workflow + +Iterate at least 2-3 times to ensure all issues are caught and fixed: + +### Each Iteration: +1. **Analyze**: Review the diff against the checklist +2. **Read**: Read the full content of each changed file to understand context +3. **Fix**: Apply fixes directly using the Edit tool for each issue found +4. **Format**: Run `rustfmt --edition 2024 ` on modified files +5. **Verify**: Run `cargo clippy -p -- -D warnings` on affected crates +6. **Check**: Review the changes again - new issues may have been introduced or revealed + +### Stop when: +- No more issues are found after a full review pass +- Clippy passes with no warnings +- Code is properly formatted + +## Output Format + +After fixing issues, provide a summary: + +1. **Issues Fixed**: List each fix made with: + - File and line reference + - Category (from checklist above) + - What was changed +2. **Manual Review Needed**: Issues that require human decision (if any) +3. **Verification**: Clippy/format results + +Severity levels for reporting: +- **CRITICAL**: Security issues or bugs - fix immediately +- **WARNING**: Coding standard violations - fix automatically +- **SUGGESTION**: Minor improvements - fix if straightforward, otherwise note for user diff --git a/core/.clippy.toml b/core/.clippy.toml new file mode 100644 index 0000000000..b44f5be61c --- /dev/null +++ b/core/.clippy.toml @@ -0,0 +1,2 @@ +too-many-arguments-threshold = 18 # lower this until we fix current code, ideally 8~10 +type-complexity-threshold = 384 diff --git a/core/.dockerignore b/core/.dockerignore new file mode 100644 index 0000000000..16502340c3 --- /dev/null +++ b/core/.dockerignore @@ -0,0 +1,8 @@ +target/ +.git/ +.github/ +.gitignore +.dockerignore +*.md +Dockerfile +docker-compose*.yml \ No newline at end of file diff --git a/core/.gitattributes b/core/.gitattributes new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/.vscode/launch.json b/core/.vscode/launch.json new file mode 100644 index 0000000000..ca66452b8c --- /dev/null +++ b/core/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug API", + "type": "lldb-dap", + "request": "launch", + "program": "${workspaceFolder}/target/debug/api", + "args": [], + "cwd": "${workspaceFolder}", + "preLaunchTask": "cargo build --package api", + "env": {} + }, + ] +} \ No newline at end of file diff --git a/core/.vscode/settings.json b/core/.vscode/settings.json new file mode 100644 index 0000000000..ee776fc7cf --- /dev/null +++ b/core/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "rust-analyzer.check.command": "clippy", + "rust-analyzer.cargo.features": "all", + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer", + "editor.formatOnSave": true + }, + "[toml]": { + "editor.formatOnSave": true + }, + "[yaml]": { + "editor.formatOnSave": true + }, + "search.useIgnoreFiles": true, + "search.useGlobalIgnoreFiles": true, + "search.exclude": { + "**/target/**": true, + "**/.git/**": true + }, + "files.exclude": { + "**/target/": true, + "**/.git/": true + }, + "files.watcherExclude": { + "**/target/**": true + } +} \ No newline at end of file diff --git a/core/AGENTS.md b/core/AGENTS.md new file mode 100644 index 0000000000..900fdc2474 --- /dev/null +++ b/core/AGENTS.md @@ -0,0 +1,60 @@ +# AGENTS.md + +Guidance for AI assistants (Claude Code, Gemini, Codex, etc.) collaborating on this repository. + +## Skills + +Read this file first, then load the relevant skills for your current task. `project-structure.md`, `development-commands.md`, `code-style.md`, `tests.md`, and `defensive-programming.md` are the default set for most Core work. Load `error-handling.md` when touching error surfaces or JSON access, `architecture.md` when changing provider/repository/UniFFI patterns, `common-issues.md` when debugging tricky failures, and `swapper-checklist.md` only for swapper integrations. + +- [Project Structure](skills/project-structure.md) — Repo layout, crates, and tech stack +- [Development Commands](skills/development-commands.md) — Build, test, lint, format, mobile +- [Code Style](skills/code-style.md) — Formatting, naming, imports, code organization +- [Error Handling](skills/error-handling.md) — Error types, propagation, JSON access +- [Architecture](skills/architecture.md) — Provider/mapper, repository, RPC, UniFFI patterns +- [Tests](skills/tests.md) — Test conventions, mocks, integration tests +- [Defensive Programming](skills/defensive-programming.md) — Safety rules and exhaustive patterns +- [Common Issues](skills/common-issues.md) — Known anti-patterns and their fixes +- [Swapper Checklist](skills/swapper-checklist.md) — Integration checklist for swapper providers + +## Before Coding + +- State assumptions explicitly. UniFFI bounds, lifetimes, provider trait contracts, and JSON shape assumptions are invisible — call them out so a reviewer can spot the wrong one +- Read before you write. Open the file's existing exports, the immediate caller, the related provider/mapper/repository, and any obvious testkit fixture before adding code. "Looks orthogonal to me" is the most expensive sentence in this crate +- If two patterns in the codebase contradict (e.g., two providers handling decimals or error mapping differently), do not average them. Pick one — typically the more recent or better tested — explain why, and flag the other for cleanup + +## Task Completion + +During active implementation, rebase conflict resolution, or compile-fix loops, prefer targeted build/test commands and defer broad clippy/format runs until the change is ready to commit. Do not skip the required clippy/format checks silently before final handoff; run them then, or report the exact reason they are still pending. + +Before finishing a task: +1. **Review for simplification** — reduce duplication, extract helpers, consolidate modules, remove dead code +2. **Keep changes minimal** — code must be concise and focused; reviewers cannot realistically review thousands of lines per PR, so only include what is necessary for the task +3. **Run tests**: `just test` or `just test ` +4. **Run clippy**: `cargo clippy -p -- -D warnings` +5. **Format**: `just format` + +## Test Rules + +- Tests must verify intent, not just behavior. A test that still passes when the function returns a hardcoded constant is a tautology — fix the assertion or the function under test. +- Do not write tolerance-based assertions against live network values or values recomputed from separate RPC/API calls in integration tests. These tests are flaky and low-signal. +- For integration tests, assert stable invariants only. For exact numeric behavior, cover the pure calculation in unit tests with deterministic inputs. +- Write one test function with many assertions instead of many separate single-assertion test functions. Group related cases into a single `test_` test. + +## Testkit Mocks + +- Put reusable mocks in a crate `testkit` file and attach them to the type with `impl Type { pub fn mock() -> Self }`. +- Use `mock()` for the default case; use `mock_with_*` or a clearly named variant only when needed. +- Keep mocks small, valid, and fixed. If a fixture is only used once, an inline literal is fine. + +Mock example: +```rust +impl Asset { + pub fn mock() -> Self { + Asset::from_chain(Chain::Ethereum) + } +} +``` + +Examples: +- [crates/primitives/src/testkit/asset_mock.rs](crates/primitives/src/testkit/asset_mock.rs) +- [crates/storage/src/testkit/scan_address_mock.rs](crates/storage/src/testkit/scan_address_mock.rs) diff --git a/core/CLAUDE.md b/core/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/core/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/core/Cargo.lock b/core/Cargo.lock new file mode 100644 index 0000000000..9e53c5304e --- /dev/null +++ b/core/Cargo.lock @@ -0,0 +1,14335 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "agent" +version = "1.0.0" +dependencies = [ + "axum", + "base64 0.22.1", + "config", + "futures-util", + "gem_tracing", + "reqwest 0.13.4", + "rig", + "rig-fastembed", + "rig-sqlite", + "rmcp", + "rusqlite", + "rustls 0.23.37", + "serde", + "serde_json", + "serde_serializers", + "sqlite-vec", + "strum 0.28.0", + "tokio", + "tokio-rusqlite", + "tokio-tungstenite 0.29.0", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alloy-consensus" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83447eeb17816e172f1dfc0db1f9dc0b7c5d069bd1f7cecbecceb382bf931015" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "rand 0.8.5", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-consensus-any" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5406343e306856dc2be762700e98a16904de45dee14a07f233e742ce68daff2f" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a475bb02d9cef2dbb99065c1664ab3fe1f9352e21d6d5ed3f02cdbfc06ed1abc" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "derive_more", + "itoa", + "serde", + "serde_json", + "winnow 1.0.1", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip7928" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8222b1d88f9a6d03be84b0f5e76bb60cd83991b43ad8ab6477f0e4a7809b98d" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eips" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dca4c89ace90684b4b77366d00631ed498c9af962079af2a5dbc593a0618a77" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2 0.10.9", +] + +[[package]] +name = "alloy-ens" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325fac2b0d81edf7238446060a4a02f51241b3ae012bd5b477921f3d12818cd4" +dependencies = [ + "alloy-primitives", +] + +[[package]] +name = "alloy-json-abi" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0a82e56b1843bce483942d54fcadea92e676f1bde162e93c7d3b621fabc4e1" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http 1.4.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7db7b095b0b1db1d18ce7e91dcd2e82007f2d52bfb8125e6b64633a74a06bc3" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-network-primitives" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd28d9bfd11729037d194f2b1d43db8642eb3f342032691f4ca96bb745479c3c" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "indexmap 2.13.1", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.2", + "rapidhash", + "ruint", + "rustc-hash", + "secp256k1 0.31.1", + "serde", + "sha3", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc90b1e703d3c03f4ff7f48e82dd0bc1c8211ab7d079cd836a06fcfeb06651cb" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36834a5c0a2fa56e171bf256c34d70fca07d0c0031583edea1c4946b7889c9e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6561ed4759c974d9c144500a59e3fb8c1d87327a12900d5ce455c0cae6dcb6" +dependencies = [ + "alloy-consensus-any", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175a2a5b6017d7f61b5e4b800d21215fe8e94fe729d00828e13bb6d93dcf3492" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-serde" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc21a8772af7d78bba286726aa245bd2ff81cd9abe230afea2e91578996831c9" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffbce94c50dd9d4d1f83e044c5595bbd3ada981bd3057ce28b3a5470e77385d" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-signer-local" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48366d2c42b8d95ef951101bafa28486590f21b7a1e68b6b2d069746557bbe3" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256", + "rand 0.8.5", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" +dependencies = [ + "alloy-sol-macro-input", + "const-hex", + "heck 0.5.0", + "indexmap 2.13.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "sha3", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" +dependencies = [ + "const-hex", + "dunce", + "heck 0.5.0", + "macro-string", + "proc-macro2", + "quote", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" +dependencies = [ + "serde", + "winnow 1.0.1", +] + +[[package]] +name = "alloy-sol-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-trie" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more", + "nybbles", + "serde", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a0035943b75fe1e249f52e688492d7a1b1826bc2d19b8e1d5d3c24a2ad8f50" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "amq-protocol" +version = "10.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81833d7f9d250dda1ca55020d6dad20f5a242ad917dd6edd7c21f626424653a5" +dependencies = [ + "amq-protocol-tcp", + "amq-protocol-types", + "amq-protocol-uri", + "cookie-factory", + "nom 8.0.0", + "serde", +] + +[[package]] +name = "amq-protocol-tcp" +version = "10.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "889012b5e973bffd57bd668a9b184c2713754024ff1719164788e61ecb4450b5" +dependencies = [ + "amq-protocol-uri", + "async-rs", + "cfg-if", + "tcp-stream", + "tracing", +] + +[[package]] +name = "amq-protocol-types" +version = "10.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "356da5abbf8f1ad806d6eb77161ad2c878d892d74f756021c5002973303abb05" +dependencies = [ + "cookie-factory", + "nom 8.0.0", + "serde", + "serde_json", +] + +[[package]] +name = "amq-protocol-uri" +version = "10.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c4982c5c729761a6e9c1464c2da53094c07e9cf45aa3daceb2620e17b1fce9" +dependencies = [ + "amq-protocol-types", + "percent-encoding", + "url", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "api" +version = "1.0.0" +dependencies = [ + "api_connector", + "cacher", + "chrono", + "config", + "fiat", + "futures", + "gem_auth", + "gem_client", + "gem_hash", + "gem_rewards", + "gem_tracing", + "hex", + "in_app_notifications", + "localizer", + "metrics", + "name_resolver", + "nft", + "portfolio", + "pricer", + "prices", + "primitives", + "prometheus-client", + "redis", + "reqwest 0.13.4", + "rocket", + "rocket_ws", + "search_index", + "security_provider", + "serde", + "serde_json", + "settings", + "settings_chain", + "storage", + "streamer", + "strum 0.28.0", + "swapper", + "tokio", + "unic-langid", +] + +[[package]] +name = "api_connector" +version = "1.0.0" +dependencies = [ + "chrono", + "primitives", + "reqwest 0.13.4", + "serde", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arrow" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb98341a7e051bb79731ecb33ec00cbd6e0e315a542d6732b46d462c9215ea2" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ipc", + "arrow-json", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce4751cbc4bcccfeeea79df9571ff1dc066d61e44723c7604d11c7937f5b560" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num", +] + +[[package]] +name = "arrow-array" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b02ccba2e977a3aabb4384036109ca32f552399a2bc0588f925f91ed073ce70c" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "chrono-tz", + "half", + "hashbrown 0.16.1", + "num", +] + +[[package]] +name = "arrow-buffer" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90f8bece6a9ee316a699fbbfde368a206676a1206ce89b50f07937648e76c3c" +dependencies = [ + "bytes", + "half", + "num", +] + +[[package]] +name = "arrow-cast" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ffe645cfb4e80b1ca37a3a106ce7b4af66ccdd60c655a57e6b9aab096164a7" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num", + "ryu", +] + +[[package]] +name = "arrow-csv" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376e82c15a6298b49a53fbb0d89348db1d5dd3a5147977d62d5516d430cfed3" +dependencies = [ + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "csv", + "csv-core", + "regex", +] + +[[package]] +name = "arrow-data" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78468c813909465dd0f858950c8a0614eb63608134acf95c602ec21381258b28" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num", +] + +[[package]] +name = "arrow-ipc" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f88b0fbb33af28089ccd3e4dcd0ff09de46842168d00220b920f7231feddf5" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", + "lz4_flex", + "zstd", +] + +[[package]] +name = "arrow-json" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff14ad7669f0742f3c43c606465ad4aad97cfcee24e6317a30f68eba9d75070" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "indexmap 2.13.1", + "lexical-core", + "memchr", + "num", + "serde", + "serde_json", + "simdutf8", +] + +[[package]] +name = "arrow-ord" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aed58a38c3db0a2cf75ef70e3cb6bc4bd0da0a3d390de37c36139b31fae826e8" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "079ced0517daf4f09b070d09ff641cee7cc331aa216bebcb25d1a6474ad53086" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a0d5eb3fe25337ff83e8333a08379bdd1540b0961b1c888f6e505d971c198e1" +dependencies = [ + "bitflags 2.11.0", + "serde", + "serde_json", +] + +[[package]] +name = "arrow-select" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2368a78bd32902dba39d52519d70f63799c8b5dc8a9477129a30c2fd3dc70c19" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num", +] + +[[package]] +name = "arrow-string" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dece58a130b9187756ded8bc071bd8ee9dd7a146566af244b297c7e632fd1ef7" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num", + "regex", + "regex-syntax", +] + +[[package]] +name = "as-any" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.117", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-compression" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06575e6a9673580f52661c92107baabffbf41e2141373441cbcdc47cb733003c" +dependencies = [ + "brotli 7.0.0", + "bzip2 0.5.2", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "xz2", + "zstd", + "zstd-safe", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f937e26114b93193065fd44f507aa2e9169ad0cdabbb996920b1fe1ddea7ba" +dependencies = [ + "async-channel", + "async-executor", + "async-lock", + "blocking", + "futures-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-rs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7d98bcae2752f5f3edb17288ff34b799760be54f63261073eed9f6982367b5" +dependencies = [ + "async-compat", + "async-global-executor", + "async-trait", + "cfg-if", + "futures-core", + "futures-io", + "hickory-resolver", + "tokio", + "tokio-stream", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async_cell" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2" +dependencies = [ + "loom 0.7.2", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey 0.1.1", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-config" +version = "1.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517aa062d8bd9015ee23d6daa5e1c1372328412fdae4e6c4c1be9b69c6ad37a2" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.0", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ed8e8c52d2dc2390ad9f15647fe663f71e9780b4262c190fbb823a32721566" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-bedrockruntime" +version = "1.132.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a2940faeb61f4f579a434bc3a546e9ab49a89596e94527d329281ef55fd44d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body-util", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.105.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f4f8065fe615dbed9096458ba98dda6d641553ffd5aedd27e37e65211aca9f" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version 0.4.1", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bcs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b6598a2f5d564fb7855dc6b06fd1c38cff5a72bd8b863a4d021938497b440a" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "bcs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f2b5fa7b76b498158ec1079dc0ea842c5b622b6b3f675005fddd889f2c9a7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitpacking" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" +dependencies = [ + "crunchy", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "bnum" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119771309b95163ec7aaf79810da82f7cd0599c19722d48b9c03894dca833966" + +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 4.0.3", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 5.0.0", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "sha2 0.10.9", + "tinyvec", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "c-kzg" +version = "2.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] + +[[package]] +name = "cacher" +version = "1.0.0" +dependencies = [ + "redis", + "serde", + "serde_json", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver 1.0.28", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "census" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + +[[package]] +name = "chain_primitives" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "num-bigint", + "primitives", +] + +[[package]] +name = "chain_traits" +version = "1.0.0" +dependencies = [ + "async-trait", + "futures", + "primitives", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cli" +version = "1.0.0" +dependencies = [ + "clap", + "primitives", + "settings", + "settings_chain", + "tokio", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "coingecko" +version = "1.0.0" +dependencies = [ + "chrono", + "gem_client", + "primitives", + "reqwest 0.13.4", + "serde", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "comfy-table" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56" +dependencies = [ + "strum 0.26.3", + "strum_macros 0.26.4", + "unicode-width 0.2.2", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" +dependencies = [ + "async-trait", + "convert_case 0.6.0", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml 1.1.2+spec-1.1.0", + "winnow 1.0.1", + "yaml-rust2", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-hex" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version 0.4.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "daemon" +version = "1.0.0" +dependencies = [ + "api_connector", + "async-trait", + "cacher", + "chain_primitives", + "chain_traits", + "chrono", + "coingecko", + "fiat", + "futures", + "gem_client", + "gem_evm", + "gem_jsonrpc", + "gem_rewards", + "gem_tracing", + "job_runner", + "localizer", + "metrics", + "nft", + "num-bigint", + "number_formatter", + "pricer", + "prices", + "primitives", + "prometheus-client", + "reqwest 0.13.4", + "rocket", + "search_index", + "serde", + "serde_json", + "settings", + "settings_chain", + "storage", + "streamer", + "strum 0.28.0", + "support", + "swapper", + "tokio", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "serde", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "datafusion" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af15bb3c6ffa33011ef579f6b0bcbe7c26584688bd6c994f548e44df67f011a" +dependencies = [ + "arrow", + "arrow-ipc", + "arrow-schema", + "async-trait", + "bytes", + "bzip2 0.6.1", + "chrono", + "datafusion-catalog", + "datafusion-catalog-listing", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-datasource-csv", + "datafusion-datasource-json", + "datafusion-datasource-parquet", + "datafusion-execution", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-functions", + "datafusion-functions-aggregate", + "datafusion-functions-nested", + "datafusion-functions-table", + "datafusion-functions-window", + "datafusion-optimizer", + "datafusion-physical-expr", + "datafusion-physical-expr-adapter", + "datafusion-physical-expr-common", + "datafusion-physical-optimizer", + "datafusion-physical-plan", + "datafusion-session", + "datafusion-sql", + "flate2", + "futures", + "itertools 0.14.0", + "log", + "object_store", + "parking_lot", + "parquet", + "rand 0.9.2", + "regex", + "sqlparser", + "tempfile", + "tokio", + "url", + "uuid", + "xz2", + "zstd", +] + +[[package]] +name = "datafusion-catalog" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187622262ad8f7d16d3be9202b4c1e0116f1c9aa387e5074245538b755261621" +dependencies = [ + "arrow", + "async-trait", + "dashmap", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-plan", + "datafusion-session", + "datafusion-sql", + "futures", + "itertools 0.14.0", + "log", + "object_store", + "parking_lot", + "tokio", +] + +[[package]] +name = "datafusion-catalog-listing" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9657314f0a32efd0382b9a46fdeb2d233273ece64baa68a7c45f5a192daf0f83" +dependencies = [ + "arrow", + "async-trait", + "datafusion-catalog", + "datafusion-common", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "log", + "object_store", + "tokio", +] + +[[package]] +name = "datafusion-common" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a83760d9a13122d025fbdb1d5d5aaf93dd9ada5e90ea229add92aa30898b2d1" +dependencies = [ + "ahash 0.8.12", + "arrow", + "arrow-ipc", + "base64 0.22.1", + "chrono", + "half", + "hashbrown 0.14.5", + "indexmap 2.13.1", + "libc", + "log", + "object_store", + "parquet", + "paste", + "recursive", + "sqlparser", + "tokio", + "web-time", +] + +[[package]] +name = "datafusion-common-runtime" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b6234a6c7173fe5db1c6c35c01a12b2aa0f803a3007feee53483218817f8b1e" +dependencies = [ + "futures", + "log", + "tokio", +] + +[[package]] +name = "datafusion-datasource" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7256c9cb27a78709dd42d0c80f0178494637209cac6e29d5c93edd09b6721b86" +dependencies = [ + "arrow", + "async-compression", + "async-trait", + "bytes", + "bzip2 0.6.1", + "chrono", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-expr-adapter", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "flate2", + "futures", + "glob", + "itertools 0.14.0", + "log", + "object_store", + "parquet", + "rand 0.9.2", + "tempfile", + "tokio", + "tokio-util", + "url", + "xz2", + "zstd", +] + +[[package]] +name = "datafusion-datasource-csv" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64533a90f78e1684bfb113d200b540f18f268134622d7c96bbebc91354d04825" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "datafusion-catalog", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "object_store", + "regex", + "tokio", +] + +[[package]] +name = "datafusion-datasource-json" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7ebeb12c77df0aacad26f21b0d033aeede423a64b2b352f53048a75bf1d6e6" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "datafusion-catalog", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "object_store", + "serde_json", + "tokio", +] + +[[package]] +name = "datafusion-datasource-parquet" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e783c4c7d7faa1199af2df4761c68530634521b176a8d1331ddbc5a5c75133" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "datafusion-catalog", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions-aggregate", + "datafusion-physical-expr", + "datafusion-physical-expr-adapter", + "datafusion-physical-expr-common", + "datafusion-physical-optimizer", + "datafusion-physical-plan", + "datafusion-pruning", + "datafusion-session", + "futures", + "itertools 0.14.0", + "log", + "object_store", + "parking_lot", + "parquet", + "rand 0.9.2", + "tokio", +] + +[[package]] +name = "datafusion-doc" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ee6b1d9a80d13f9deb2291f45c07044b8e62fb540dbde2453a18be17a36429" + +[[package]] +name = "datafusion-execution" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4cec0a57653bec7b933fb248d3ffa3fa3ab3bd33bd140dc917f714ac036f531" +dependencies = [ + "arrow", + "async-trait", + "dashmap", + "datafusion-common", + "datafusion-expr", + "futures", + "log", + "object_store", + "parking_lot", + "rand 0.9.2", + "tempfile", + "url", +] + +[[package]] +name = "datafusion-expr" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76910bdca909722586389156d0aa4da4020e1631994d50fadd8ad4b1aa05fe" +dependencies = [ + "arrow", + "async-trait", + "chrono", + "datafusion-common", + "datafusion-doc", + "datafusion-expr-common", + "datafusion-functions-aggregate-common", + "datafusion-functions-window-common", + "datafusion-physical-expr-common", + "indexmap 2.13.1", + "paste", + "recursive", + "serde_json", + "sqlparser", +] + +[[package]] +name = "datafusion-expr-common" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d155ccbda29591ca71a1344dd6bed26c65a4438072b400df9db59447f590bb6" +dependencies = [ + "arrow", + "datafusion-common", + "indexmap 2.13.1", + "itertools 0.14.0", + "paste", +] + +[[package]] +name = "datafusion-functions" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de2782136bd6014670fd84fe3b0ca3b3e4106c96403c3ae05c0598577139977" +dependencies = [ + "arrow", + "arrow-buffer", + "base64 0.22.1", + "blake2", + "blake3", + "chrono", + "datafusion-common", + "datafusion-doc", + "datafusion-execution", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-macros", + "hex", + "itertools 0.14.0", + "log", + "md-5", + "rand 0.9.2", + "regex", + "sha2 0.10.9", + "unicode-segmentation", + "uuid", +] + +[[package]] +name = "datafusion-functions-aggregate" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07331fc13603a9da97b74fd8a273f4238222943dffdbbed1c4c6f862a30105bf" +dependencies = [ + "ahash 0.8.12", + "arrow", + "datafusion-common", + "datafusion-doc", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions-aggregate-common", + "datafusion-macros", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "half", + "log", + "paste", +] + +[[package]] +name = "datafusion-functions-aggregate-common" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5951e572a8610b89968a09b5420515a121fbc305c0258651f318dc07c97ab17" +dependencies = [ + "ahash 0.8.12", + "arrow", + "datafusion-common", + "datafusion-expr-common", + "datafusion-physical-expr-common", +] + +[[package]] +name = "datafusion-functions-nested" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdacca9302c3d8fc03f3e94f338767e786a88a33f5ebad6ffc0e7b50364b9ea3" +dependencies = [ + "arrow", + "arrow-ord", + "datafusion-common", + "datafusion-doc", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions", + "datafusion-functions-aggregate", + "datafusion-functions-aggregate-common", + "datafusion-macros", + "datafusion-physical-expr-common", + "itertools 0.14.0", + "log", + "paste", +] + +[[package]] +name = "datafusion-functions-table" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c37ff8a99434fbbad604a7e0669717c58c7c4f14c472d45067c4b016621d981" +dependencies = [ + "arrow", + "async-trait", + "datafusion-catalog", + "datafusion-common", + "datafusion-expr", + "datafusion-physical-plan", + "parking_lot", + "paste", +] + +[[package]] +name = "datafusion-functions-window" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e2aea7c79c926cffabb13dc27309d4eaeb130f4a21c8ba91cdd241c813652b" +dependencies = [ + "arrow", + "datafusion-common", + "datafusion-doc", + "datafusion-expr", + "datafusion-functions-window-common", + "datafusion-macros", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "log", + "paste", +] + +[[package]] +name = "datafusion-functions-window-common" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fead257ab5fd2ffc3b40fda64da307e20de0040fe43d49197241d9de82a487f" +dependencies = [ + "datafusion-common", + "datafusion-physical-expr-common", +] + +[[package]] +name = "datafusion-macros" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6f637bce95efac05cdfb9b6c19579ed4aa5f6b94d951cfa5bb054b7bb4f730" +dependencies = [ + "datafusion-expr", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "datafusion-optimizer" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6583ef666ae000a613a837e69e456681a9faa96347bf3877661e9e89e141d8a" +dependencies = [ + "arrow", + "chrono", + "datafusion-common", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-physical-expr", + "indexmap 2.13.1", + "itertools 0.14.0", + "log", + "recursive", + "regex", + "regex-syntax", +] + +[[package]] +name = "datafusion-physical-expr" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8668103361a272cbbe3a61f72eca60c9b7c706e87cc3565bcf21e2b277b84f6" +dependencies = [ + "ahash 0.8.12", + "arrow", + "datafusion-common", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-functions-aggregate-common", + "datafusion-physical-expr-common", + "half", + "hashbrown 0.14.5", + "indexmap 2.13.1", + "itertools 0.14.0", + "log", + "parking_lot", + "paste", + "petgraph 0.8.3", +] + +[[package]] +name = "datafusion-physical-expr-adapter" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815acced725d30601b397e39958e0e55630e0a10d66ef7769c14ae6597298bb0" +dependencies = [ + "arrow", + "datafusion-common", + "datafusion-expr", + "datafusion-functions", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "itertools 0.14.0", +] + +[[package]] +name = "datafusion-physical-expr-common" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6652fe7b5bf87e85ed175f571745305565da2c0b599d98e697bcbedc7baa47c3" +dependencies = [ + "ahash 0.8.12", + "arrow", + "datafusion-common", + "datafusion-expr-common", + "hashbrown 0.14.5", + "itertools 0.14.0", +] + +[[package]] +name = "datafusion-physical-optimizer" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b7d623eb6162a3332b564a0907ba00895c505d101b99af78345f1acf929b5c" +dependencies = [ + "arrow", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-pruning", + "itertools 0.14.0", + "log", + "recursive", +] + +[[package]] +name = "datafusion-physical-plan" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f7f778a1a838dec124efb96eae6144237d546945587557c9e6936b3414558c" +dependencies = [ + "ahash 0.8.12", + "arrow", + "arrow-ord", + "arrow-schema", + "async-trait", + "chrono", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions-aggregate-common", + "datafusion-functions-window-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "futures", + "half", + "hashbrown 0.14.5", + "indexmap 2.13.1", + "itertools 0.14.0", + "log", + "parking_lot", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "datafusion-pruning" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1e59e2ca14fe3c30f141600b10ad8815e2856caa59ebbd0e3e07cd3d127a65" +dependencies = [ + "arrow", + "arrow-schema", + "datafusion-common", + "datafusion-datasource", + "datafusion-expr-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "itertools 0.14.0", + "log", +] + +[[package]] +name = "datafusion-session" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ef8e2745583619bd7a49474e8f45fbe98ebb31a133f27802217125a7b3d58d" +dependencies = [ + "arrow", + "async-trait", + "dashmap", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-plan", + "datafusion-sql", + "futures", + "itertools 0.14.0", + "log", + "object_store", + "parking_lot", + "tokio", +] + +[[package]] +name = "datafusion-sql" +version = "50.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89abd9868770386fede29e5a4b14f49c0bf48d652c3b9d7a8a0332329b87d50b" +dependencies = [ + "arrow", + "bigdecimal", + "datafusion-common", + "datafusion-expr", + "indexmap 2.13.1", + "log", + "recursive", + "regex", + "sqlparser", +] + +[[package]] +name = "deepsize" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb987ec36f6bf7bfbea3f928b75590b736fc42af8e54d97592481351b2b96c" +dependencies = [ + "deepsize_derive", +] + +[[package]] +name = "deepsize_derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990101d41f3bc8c1a45641024377ee284ecc338e5ecf3ea0f0e236d897c72796" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "deluxe" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed332aaf752b459088acf3dd4eca323e3ef4b83c70a84ca48fb0ec5305f1488" +dependencies = [ + "deluxe-core", + "deluxe-macros", + "once_cell", + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "deluxe-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddada51c8576df9d6a8450c351ff63042b092c9458b8ac7d20f89cbd0ffd313" +dependencies = [ + "arrayvec", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.117", +] + +[[package]] +name = "deluxe-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87546d9c837f0b7557e47b8bd6eae52c3c223141b76aa233c345c9ab41d9117" +dependencies = [ + "deluxe-core", + "heck 0.4.1", + "if_chain", + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "devise" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" +dependencies = [ + "devise_codegen", + "devise_core", +] + +[[package]] +name = "devise_codegen" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" +dependencies = [ + "devise_core", + "quote", +] + +[[package]] +name = "devise_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" +dependencies = [ + "bitflags 2.11.0", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diesel" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9940fb8467a0a06312218ed384185cb8536aa10d8ec017d0ce7fad2c1bd882d5" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "chrono", + "diesel_derives", + "downcast-rs", + "itoa", + "pq-sys", + "r2d2", + "serde_json", +] + +[[package]] +name = "diesel_derives" +version = "2.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47618bf0fac06bb670c036e48404c26a865e6a71af4114dfd97dfe89936e404e" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "745fd255645f0f1135f9ec55c7b00e0882192af9683ab4731e4bba3da82b8f9c" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling 0.21.3", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "dynode" +version = "1.0.0" +dependencies = [ + "config", + "futures", + "gem_auth", + "gem_client", + "gem_tracing", + "metrics", + "primitives", + "prometheus-client", + "reqwest 0.13.4", + "rocket", + "serde", + "serde_json", + "serde_serializers", + "settings_chain", + "tokio", + "url", +] + +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools 0.11.0", + "num-traits", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom 7.1.3", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + +[[package]] +name = "fastdivide" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" + +[[package]] +name = "fastembed" +version = "4.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04c269a76bfc6cea69553b7d040acb16c793119cebd97c756d21e08d0f075ff8" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "ort-sys", + "rayon", + "serde_json", + "tokenizers", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat" +version = "1.0.0" +dependencies = [ + "async-trait", + "cacher", + "chain_primitives", + "futures", + "gem_client", + "gem_encoding", + "gem_tracing", + "hex", + "hmac 0.13.0", + "number_formatter", + "pem-rfc7468 1.0.0", + "primitives", + "reqwest 0.13.4", + "ring", + "serde", + "serde_json", + "serde_serializers", + "settings", + "sha2 0.11.0", + "storage", + "streamer", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "pear", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.5", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.11.0", + "rustc_version 0.4.1", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fs4" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "fsst" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffdff7a2d68d22afc0657eddde3e946371ce7cfe730a3f78a5ed44ea5b1cb2e" +dependencies = [ + "arrow-array", + "rand 0.9.2", +] + +[[package]] +name = "fst" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" +dependencies = [ + "utf8-ranges", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls 0.23.37", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + +[[package]] +name = "gas-bench" +version = "1.0.0" +dependencies = [ + "clap", + "gem_evm", + "gem_jsonrpc", + "gem_solana", + "gemstone", + "num-bigint", + "prettytable-rs", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "tokio", +] + +[[package]] +name = "gem_algorand" +version = "1.0.0" +dependencies = [ + "async-trait", + "chain_traits", + "chrono", + "gem_client", + "gem_encoding", + "gem_hash", + "hex", + "num-bigint", + "num-traits", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "settings", + "signer", + "tokio", +] + +[[package]] +name = "gem_aptos" +version = "1.0.0" +dependencies = [ + "async-trait", + "bcs 0.2.1", + "chain_primitives", + "chain_traits", + "chrono", + "ed25519-dalek", + "futures", + "gem_client", + "gem_hash", + "hex", + "num-bigint", + "num-traits", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", +] + +[[package]] +name = "gem_auth" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "alloy-signer", + "alloy-signer-local", + "cacher", + "chrono", + "ed25519-dalek", + "gem_encoding", + "jsonwebtoken", + "primitives", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "gem_bitcoin" +version = "1.0.0" +dependencies = [ + "async-trait", + "chain_traits", + "chrono", + "futures", + "gem_client", + "gem_hash", + "hex", + "num-bigint", + "number_formatter", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", +] + +[[package]] +name = "gem_bsc" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "hex", +] + +[[package]] +name = "gem_cardano" +version = "1.0.0" +dependencies = [ + "async-trait", + "bech32", + "chain_traits", + "chrono", + "curve25519-dalek", + "futures", + "gem_client", + "gem_hash", + "hex", + "num-bigint", + "num-traits", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "sha2 0.11.0", + "tokio", + "zeroize", +] + +[[package]] +name = "gem_client" +version = "0.1.0" +dependencies = [ + "async-trait", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", +] + +[[package]] +name = "gem_cosmos" +version = "1.0.0" +dependencies = [ + "async-trait", + "bech32", + "chain_traits", + "chrono", + "futures", + "gem_client", + "gem_encoding", + "gem_hash", + "hex", + "k256", + "num-bigint", + "number_formatter", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", +] + +[[package]] +name = "gem_encoding" +version = "1.0.0" +dependencies = [ + "base64 0.22.1", + "bs58", + "data-encoding", +] + +[[package]] +name = "gem_evm" +version = "1.0.0" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-primitives", + "alloy-signer-local", + "alloy-sol-types", + "async-trait", + "bigdecimal", + "chain_primitives", + "chain_traits", + "chrono", + "gem_bsc", + "gem_client", + "gem_hash", + "gem_jsonrpc", + "hex", + "num-bigint", + "num-traits", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", + "url", +] + +[[package]] +name = "gem_hash" +version = "1.0.0" +dependencies = [ + "blake2", + "hex", + "sha2 0.11.0", + "sha3", +] + +[[package]] +name = "gem_hypercore" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "async-trait", + "chain_traits", + "chrono", + "futures", + "gem_client", + "gem_evm", + "gem_hash", + "k256", + "num-bigint", + "number_formatter", + "primitives", + "reqwest 0.13.4", + "rmp-serde", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "strum 0.28.0", + "tokio", +] + +[[package]] +name = "gem_jsonrpc" +version = "1.0.0" +dependencies = [ + "async-trait", + "gem_client", + "hex", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "gem_near" +version = "1.0.0" +dependencies = [ + "async-trait", + "bs58", + "chain_traits", + "chrono", + "futures", + "gem_client", + "gem_encoding", + "gem_hash", + "gem_jsonrpc", + "hex", + "num-bigint", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", +] + +[[package]] +name = "gem_polkadot" +version = "1.0.0" +dependencies = [ + "async-trait", + "bs58", + "chain_traits", + "chrono", + "gem_client", + "gem_hash", + "hex", + "num-bigint", + "primitives", + "rand 0.10.1", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", +] + +[[package]] +name = "gem_rewards" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "async-trait", + "cacher", + "chain_traits", + "chrono", + "gem_client", + "gem_evm", + "gem_hash", + "hex", + "localizer", + "num-traits", + "primitives", + "regex", + "reqwest 0.13.4", + "serde", + "serde_json", + "storage", +] + +[[package]] +name = "gem_solana" +version = "1.0.0" +dependencies = [ + "async-trait", + "borsh", + "bs58", + "chain_traits", + "chrono", + "futures", + "gem_client", + "gem_encoding", + "gem_jsonrpc", + "num-bigint", + "num-traits", + "primitives", + "serde", + "serde_json", + "serde_serializers", + "settings", + "sha2 0.11.0", + "solana-primitives", + "tokio", +] + +[[package]] +name = "gem_stellar" +version = "1.0.0" +dependencies = [ + "async-trait", + "chain_traits", + "chrono", + "futures", + "gem_client", + "gem_encoding", + "gem_hash", + "hex", + "num-bigint", + "num-traits", + "number_formatter", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", + "url", +] + +[[package]] +name = "gem_sui" +version = "1.0.0" +dependencies = [ + "async-trait", + "bcs 0.2.1", + "chain_primitives", + "chain_traits", + "chrono", + "futures", + "gem_encoding", + "gem_jsonrpc", + "hex", + "num-bigint", + "num-traits", + "primitives", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "sui-sdk-types", + "sui-transaction-builder", + "tokio", +] + +[[package]] +name = "gem_ton" +version = "1.0.0" +dependencies = [ + "async-trait", + "chain_traits", + "chrono", + "crc", + "futures", + "gem_client", + "gem_encoding", + "gem_hash", + "hex", + "num-bigint", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", +] + +[[package]] +name = "gem_tracing" +version = "1.0.0" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "gem_tron" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "async-trait", + "bs58", + "chain_traits", + "chrono", + "futures", + "gem_client", + "gem_encoding", + "gem_evm", + "gem_hash", + "hex", + "num-bigint", + "num-traits", + "number_formatter", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "strum 0.28.0", + "tokio", +] + +[[package]] +name = "gem_wallet_connect" +version = "1.0.0" +dependencies = [ + "gem_evm", + "gem_ton", + "hex", + "primitives", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "gem_xrp" +version = "1.0.0" +dependencies = [ + "async-trait", + "bigdecimal", + "bs58", + "chain_traits", + "chrono", + "gem_client", + "gem_hash", + "gem_jsonrpc", + "hex", + "num-bigint", + "num-traits", + "number_formatter", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "signer", + "tokio", +] + +[[package]] +name = "gemstone" +version = "1.0.2" +dependencies = [ + "alloy-primitives", + "async-trait", + "base64 0.22.1", + "bs58", + "chain_traits", + "chrono", + "futures", + "gem_algorand", + "gem_aptos", + "gem_auth", + "gem_bitcoin", + "gem_cardano", + "gem_client", + "gem_cosmos", + "gem_evm", + "gem_hash", + "gem_hypercore", + "gem_jsonrpc", + "gem_near", + "gem_polkadot", + "gem_solana", + "gem_stellar", + "gem_sui", + "gem_ton", + "gem_tron", + "gem_wallet_connect", + "gem_xrp", + "hex", + "num-bigint", + "number_formatter", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "signer", + "simulation", + "sui-sdk-types", + "swapper", + "uniffi", + "url", + "yielder", + "zeroize", +] + +[[package]] +name = "generate" +version = "1.0.0" +dependencies = [ + "primitives", +] + +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "geo" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc1a1678e54befc9b4bcab6cd43b8e7f834ae8ea121118b0fd8c42747675b4a" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "robust", + "rstar", + "spade", +] + +[[package]] +name = "geo-traits" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7c353d12a704ccfab1ba8bfb1a7fe6cb18b665bf89d37f4f7890edcd260206" +dependencies = [ + "geo-types", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "rayon", + "rstar", + "serde", +] + +[[package]] +name = "geoarrow-array" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d1884b17253d8572e88833c282fcbb442365e4ae5f9052ced2831608253436c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "geo-traits", + "geoarrow-schema", + "num-traits", + "wkb", + "wkt", +] + +[[package]] +name = "geoarrow-expr-geo" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a67d3b543bc3ebeffdc204b67d69b8f9fcd33d76269ddd4a4618df99f053a934" +dependencies = [ + "arrow-array", + "arrow-buffer", + "geo", + "geo-traits", + "geoarrow-array", + "geoarrow-schema", +] + +[[package]] +name = "geoarrow-schema" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02f1b18b1c9a44ecd72be02e53d6e63bbccfdc8d1765206226af227327e2be6e" +dependencies = [ + "arrow-schema", + "geo-traits", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "geodatafusion" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d676b8d8b5f391ab4270ba31e9b599ee2c3d780405a38e272a0a7565ea189c" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-schema", + "datafusion", + "geo", + "geo-traits", + "geoarrow-array", + "geoarrow-expr-geo", + "geoarrow-schema", + "geohash", + "thiserror 1.0.69", + "wkt", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "geohash" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f58890382f70caccc5fa388981f7ac80c913795042afce9f3e065695d8f7464" +dependencies = [ + "geo-types", + "libm", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs", + "http 1.4.0", + "indicatif", + "libc", + "log", + "rand 0.9.2", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq", + "windows-sys 0.60.2", +] + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "hyperloglogplus" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" +dependencies = [ + "serde", +] + +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "notify", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e598ed73b67db92f61e04672e599eef2991a262a40e1666735b8a86d2e7e9f30" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "i_float" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010025c2c532c8d82e42d0b8bb5184afa449fa6f06c709ea9adcb16c49ae405b" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9190f86706ca38ac8add223b2aed8b1330002b5cdbbce28fb58b10914d38fc27" + +[[package]] +name = "i_overlay" +version = "4.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413183068e6e0289e18d7d0a1f661b81546e6918d5453a44570b9ab30cbed1b3" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", + "rayon", +] + +[[package]] +name = "i_shape" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea154b742f7d43dae2897fcd5ead86bc7b5eefcedd305a7ebf9f69d44d61082" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e6d558e6d4c7b82bc51d9c771e7a927862a161a7d87bf2b0541450e0e20915" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "img-downloader" +version = "1.0.0" +dependencies = [ + "chain_primitives", + "clap", + "coingecko", + "reqwest 0.13.4", + "settings", + "tokio", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "in_app_notifications" +version = "1.0.0" +dependencies = [ + "localizer", + "number_formatter", + "primitives", + "serde_json", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom 8.0.0", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "job_runner" +version = "1.0.0" +dependencies = [ + "async-trait", + "gem_tracing", + "tokio", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonb" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953" +dependencies = [ + "byteorder", + "ethnum", + "fast-float2", + "itoa", + "jiff", + "nom 8.0.0", + "num-traits", + "ordered-float 5.3.0", + "rand 0.9.2", + "serde", + "serde_json", + "zmij", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2 0.10.9", + "signature", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "keccak-asm" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa468878266ad91431012b3e5ef1bf9b170eab22883503a318d46857afa4579a" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lance" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c439decbc304e180748e34bb6d3df729069a222e83e74e2185c38f107136e9" +dependencies = [ + "arrow", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-ipc", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "async-recursion", + "async-trait", + "async_cell", + "byteorder", + "bytes", + "chrono", + "dashmap", + "datafusion", + "datafusion-expr", + "datafusion-functions", + "datafusion-physical-expr", + "datafusion-physical-plan", + "deepsize", + "either", + "futures", + "half", + "humantime", + "itertools 0.13.0", + "lance-arrow", + "lance-core", + "lance-datafusion", + "lance-encoding", + "lance-file", + "lance-geo", + "lance-index", + "lance-io", + "lance-linalg", + "lance-namespace", + "lance-table", + "log", + "moka", + "object_store", + "permutation", + "pin-project", + "prost", + "prost-types", + "rand 0.9.2", + "roaring 0.10.12", + "semver 1.0.28", + "serde", + "serde_json", + "snafu", + "tantivy", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "lance-arrow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ee5508b225456d3d56998eaeef0d8fbce5ea93856df47b12a94d2e74153210" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "arrow-select", + "bytes", + "getrandom 0.2.17", + "half", + "jsonb", + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "lance-bitpacking" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c065fb3bd4a8cc4f78428443e990d4921aa08f707b676753db740e0b402a21" +dependencies = [ + "arrayref", + "paste", + "seq-macro", +] + +[[package]] +name = "lance-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8856abad92e624b75cd57a04703f6441948a239463bdf973f2ac1924b0bcdbe" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "async-trait", + "byteorder", + "bytes", + "chrono", + "datafusion-common", + "datafusion-sql", + "deepsize", + "futures", + "lance-arrow", + "libc", + "log", + "mock_instant", + "moka", + "num_cpus", + "object_store", + "pin-project", + "prost", + "rand 0.9.2", + "roaring 0.10.12", + "serde_json", + "snafu", + "tempfile", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "lance-datafusion" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8835308044cef5467d7751be87fcbefc2db01c22370726a8704bd62991693f" +dependencies = [ + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-ord", + "arrow-schema", + "arrow-select", + "async-trait", + "chrono", + "datafusion", + "datafusion-common", + "datafusion-functions", + "datafusion-physical-expr", + "futures", + "jsonb", + "lance-arrow", + "lance-core", + "lance-datagen", + "lance-geo", + "log", + "pin-project", + "prost", + "snafu", + "tokio", + "tracing", +] + +[[package]] +name = "lance-datagen" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612de1e888bb36f6bf51196a6eb9574587fdf256b1759a4c50e643e00d5f96d0" +dependencies = [ + "arrow", + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "futures", + "half", + "hex", + "rand 0.9.2", + "rand_xoshiro", + "random_word", +] + +[[package]] +name = "lance-encoding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b456b29b135d3c7192602e516ccade38b5483986e121895fa43cf1fdb38bf60" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "arrow-select", + "bytemuck", + "byteorder", + "bytes", + "fsst", + "futures", + "hex", + "hyperloglogplus", + "itertools 0.13.0", + "lance-arrow", + "lance-bitpacking", + "lance-core", + "log", + "lz4", + "num-traits", + "prost", + "prost-build", + "prost-types", + "rand 0.9.2", + "snafu", + "strum 0.26.3", + "tokio", + "tracing", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "lance-file" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1538d14d5bb3735b4222b3f5aff83cfa59cc6ef7cdd3dd9139e4c77193c80b" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "async-recursion", + "async-trait", + "byteorder", + "bytes", + "datafusion-common", + "deepsize", + "futures", + "lance-arrow", + "lance-core", + "lance-encoding", + "lance-io", + "log", + "num-traits", + "object_store", + "prost", + "prost-build", + "prost-types", + "snafu", + "tokio", + "tracing", +] + +[[package]] +name = "lance-geo" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a69a2f3b55703d9c240ad7c5ffa2c755db69e9cf8aa05efe274a212910472d" +dependencies = [ + "datafusion", + "geo-types", + "geoarrow-array", + "geoarrow-schema", + "geodatafusion", +] + +[[package]] +name = "lance-index" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea84613df6fa6b9168a1f056ba4f9cb73b90a1b452814c6fd4b3529bcdbfc78" +dependencies = [ + "arrow", + "arrow-arith", + "arrow-array", + "arrow-ord", + "arrow-schema", + "arrow-select", + "async-channel", + "async-recursion", + "async-trait", + "bitpacking", + "bitvec", + "bytes", + "crossbeam-queue", + "datafusion", + "datafusion-common", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-sql", + "deepsize", + "dirs", + "fst", + "futures", + "half", + "itertools 0.13.0", + "jsonb", + "lance-arrow", + "lance-core", + "lance-datafusion", + "lance-datagen", + "lance-encoding", + "lance-file", + "lance-io", + "lance-linalg", + "lance-table", + "libm", + "log", + "ndarray", + "num-traits", + "object_store", + "prost", + "prost-build", + "prost-types", + "rand 0.9.2", + "rand_distr 0.5.1", + "rayon", + "roaring 0.10.12", + "serde", + "serde_json", + "snafu", + "tantivy", + "tempfile", + "tokio", + "tracing", + "twox-hash", + "uuid", +] + +[[package]] +name = "lance-io" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3fc4c1d941fceef40a0edbd664dbef108acfc5d559bb9e7f588d0c733cbc35" +dependencies = [ + "arrow", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "arrow-select", + "async-recursion", + "async-trait", + "byteorder", + "bytes", + "chrono", + "deepsize", + "futures", + "lance-arrow", + "lance-core", + "lance-namespace", + "log", + "object_store", + "path_abs", + "pin-project", + "prost", + "rand 0.9.2", + "serde", + "shellexpand", + "snafu", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "lance-linalg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ffbc5ce367fbf700a69de3fe0612ee1a11191a64a632888610b6bacfa0f63" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "cc", + "deepsize", + "half", + "lance-arrow", + "lance-core", + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "lance-namespace" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791bbcd868ee758123a34e07d320a1fb99379432b5ecc0e78d6b4686e999b629" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "lance-core", + "lance-namespace-reqwest-client", + "snafu", +] + +[[package]] +name = "lance-namespace-impls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee713505576f6b1988a491f77c7ca8b0cf7090a393598e63c85079fa70a53ebf" +dependencies = [ + "arrow", + "arrow-ipc", + "arrow-schema", + "async-trait", + "bytes", + "futures", + "lance", + "lance-core", + "lance-index", + "lance-io", + "lance-namespace", + "log", + "object_store", + "rand 0.9.2", + "serde_json", + "snafu", + "tokio", + "url", +] + +[[package]] +name = "lance-namespace-reqwest-client" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea349999bcda4eea53fc05d334b3775ec314761e6a706555c777d7a29b18d19" +dependencies = [ + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_repr", + "url", +] + +[[package]] +name = "lance-table" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fdb2d56bfa4d1511c765fa0cc00fdaa37e5d2d1cd2f57b3c6355d9072177052" +dependencies = [ + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-ipc", + "arrow-schema", + "async-trait", + "byteorder", + "bytes", + "chrono", + "deepsize", + "futures", + "lance-arrow", + "lance-core", + "lance-file", + "lance-io", + "log", + "object_store", + "prost", + "prost-build", + "prost-types", + "rand 0.9.2", + "rangemap", + "roaring 0.10.12", + "semver 1.0.28", + "serde", + "serde_json", + "snafu", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "lance-testing" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ccb1a4a9284435c6a8c02c8c06e7e041bece0d7f722152159353cf55dc51e3" +dependencies = [ + "arrow-array", + "arrow-schema", + "lance-arrow", + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "lancedb" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9217d7d3a1f4e088bdedaad9b4fa79045b077e07f961f1cd3ec6f90850c425f2" +dependencies = [ + "ahash 0.8.12", + "arrow", + "arrow-array", + "arrow-cast", + "arrow-data", + "arrow-ipc", + "arrow-ord", + "arrow-schema", + "arrow-select", + "async-trait", + "bytes", + "chrono", + "datafusion", + "datafusion-catalog", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-plan", + "futures", + "half", + "lance", + "lance-arrow", + "lance-core", + "lance-datafusion", + "lance-datagen", + "lance-encoding", + "lance-file", + "lance-index", + "lance-io", + "lance-linalg", + "lance-namespace", + "lance-namespace-impls", + "lance-table", + "lance-testing", + "lazy_static", + "log", + "moka", + "num-traits", + "object_store", + "pin-project", + "rand 0.9.2", + "regex", + "semver 1.0.28", + "serde", + "serde_json", + "serde_with", + "snafu", + "tempfile", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "lapin" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd20e01fd92597ca352ca7ceed3c589851ebad279dfcada48aa4d24fd3a7caa" +dependencies = [ + "amq-protocol", + "async-rs", + "async-trait", + "backon", + "cfg-if", + "event-listener", + "flume", + "futures-core", + "futures-io", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "localizer" +version = "1.0.0" +dependencies = [ + "i18n-embed", + "i18n-embed-fl", + "rust-embed", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator 0.7.5", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator 0.8.8", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "num_cpus", + "once_cell", + "rawpointer", + "thread-tree", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "measure_time" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" +dependencies = [ + "log", +] + +[[package]] +name = "meilisearch-index-setting-macro" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93b5b21df781c820a9cc387b808d4128cbc164dd28d67ac6ed666a00996f8f15" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "structmeta", + "syn 2.0.117", +] + +[[package]] +name = "meilisearch-sdk" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e6e3646ba2a9a306296c1edf4a050508a408c1b59ca456d9ad4965ec6e91e9" +dependencies = [ + "async-trait", + "bytes", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "iso8601", + "jsonwebtoken", + "log", + "meilisearch-index-setting-macro", + "pin-project-lite", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "uuid", + "wasm-bindgen-futures", + "web-sys", + "yaup", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metrics" +version = "1.0.0" +dependencies = [ + "prometheus-client", +] + +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mock_instant" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "tokio", + "tokio-util", + "version_check", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "murmurhash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" + +[[package]] +name = "name_resolver" +version = "1.0.0" +dependencies = [ + "alloy-ens", + "alloy-primitives", + "alloy-sol-types", + "async-trait", + "borsh", + "gem_client", + "gem_encoding", + "gem_evm", + "gem_hash", + "gem_jsonrpc", + "gem_solana", + "gem_ton", + "hex", + "idna", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "settings", + "tokio", +] + +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nft" +version = "1.0.0" +dependencies = [ + "async-trait", + "futures", + "gem_client", + "gem_evm", + "gem_ton", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "settings", + "storage", + "tokio", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "number_formatter" +version = "1.0.0" +dependencies = [ + "bigdecimal", + "num-bigint", + "rust_decimal", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object_store" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http 1.4.0", + "humantime", + "itertools 0.14.0", + "parking_lot", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.11.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ort" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52afb44b6b0cffa9bf45e4d37e5a4935b0334a51570658e279e9e3e6cf324aa5" +dependencies = [ + "ndarray", + "ort-sys", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41d7757331aef2d04b9cb09b45583a59217628beaf91895b7e76187b6e8c088" +dependencies = [ + "flate2", + "pkg-config", + "sha2 0.10.9", + "tar", + "ureq", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "ownedbytes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "p12-keystore" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2" +dependencies = [ + "base64 0.22.1", + "cbc", + "cms", + "der", + "des", + "hex", + "hmac 0.12.1", + "pkcs12", + "pkcs5", + "rand 0.10.1", + "rc2", + "sha1", + "sha2 0.10.9", + "thiserror 2.0.18", + "x509-parser", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parquet" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3abbfef8a25900f4925c86e4cb881ea24672ca3c31ee4fb50a8083c4c56d313" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64 0.22.1", + "brotli 8.0.2", + "bytes", + "chrono", + "flate2", + "futures", + "half", + "hashbrown 0.16.1", + "lz4_flex", + "num", + "num-bigint", + "object_store", + "paste", + "ring", + "seq-macro", + "simdutf8", + "snap", + "thrift", + "tokio", + "twox-hash", + "zstd", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "path_abs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ef02f6342ac01d8a93b65f96db53fe68a92a15f41144f97fb00a9e669633c3" +dependencies = [ + "serde", + "serde_derive", + "std_prelude", + "stfu8", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "permutation" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df202b0b0f5b8e389955afd5f27b007b00fb948162953f1db9c70d2c7e3157d7" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.13.1", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs12" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" +dependencies = [ + "cms", + "const-oid 0.9.6", + "der", + "digest 0.10.7", + "spki", + "x509-cert", + "zeroize", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2 0.10.9", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portfolio" +version = "1.0.0" +dependencies = [ + "chrono", + "number_formatter", + "primitives", + "storage", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pq-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width 0.1.14", +] + +[[package]] +name = "pricer" +version = "1.0.0" +dependencies = [ + "cacher", + "chrono", + "gem_tracing", + "localizer", + "number_formatter", + "prices", + "primitives", + "serde_json", + "storage", +] + +[[package]] +name = "prices" +version = "1.0.0" +dependencies = [ + "async-trait", + "chain_primitives", + "chrono", + "coingecko", + "gem_client", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "settings", + "tokio", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "primitives" +version = "1.0.0" +dependencies = [ + "chrono", + "hex", + "num-bigint", + "num-traits", + "serde", + "serde_json", + "strum 0.28.0", + "typeshare", + "url", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca3d75b4566b9a29fe1ed623587fb058e826eb329a0be4b7c4da1ebb2d7a6ca" +dependencies = [ + "dtoa", + "itoa", + "parking_lot", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adf1691c04c0a5ff46ff8f262b58beb07b0dbb61f96f9f54f6cbd82106ed87f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "random_word" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47a395bdb55442b883c89062d6bcff25dc90fa5f8369af81e0ac6d49d78cf81" +dependencies = [ + "ahash 0.8.12", + "brotli 8.0.2", + "paste", + "rand 0.9.2", + "unicase", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools 0.14.0", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rc2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +dependencies = [ + "cipher", +] + +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "redis" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "socket2 0.6.3", + "tokio", + "tokio-util", + "url", + "xxhash-rust", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rig" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d928cf3d6427216f3df8b1f4f6c38909d1d512d3ce5e1f503ac5dd869ab0540" +dependencies = [ + "rig-bedrock", + "rig-core", + "rig-fastembed", + "rig-helixdb", + "rig-lancedb", + "rig-milvus", +] + +[[package]] +name = "rig-bedrock" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4dae7c7d7f5fde99a79cff4c85f58da61543b77074179bdd6f01920636d6594" +dependencies = [ + "async-stream", + "aws-config", + "aws-sdk-bedrockruntime", + "aws-smithy-types", + "base64 0.22.1", + "nanoid", + "rig-core", + "rig-derive", + "schemars 1.2.1", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "rig-core" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6bd308c89a90f89ce7cd6641a078c7d2a2c55fb5a4147fd48b5a0989c3430bd" +dependencies = [ + "as-any", + "async-stream", + "base64 0.22.1", + "bytes", + "eventsource-stream", + "fastrand", + "futures", + "futures-timer", + "glob", + "http 1.4.0", + "mime", + "mime_guess", + "nanoid", + "ordered-float 5.3.0", + "pin-project-lite", + "reqwest 0.13.4", + "rig-derive", + "rmcp", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.23.1", + "tracing", + "tracing-futures", + "url", +] + +[[package]] +name = "rig-derive" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ba9149c63403a49ddfd5d373860487e6feef0021bfe5329812d1c4e72ee207c" +dependencies = [ + "convert_case 0.10.0", + "deluxe", + "indoc", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "rig-fastembed" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "995649143edc47fe9c5aa878f3ee69ef6e6d36d95d702c9d91860b0a4e20e24f" +dependencies = [ + "fastembed", + "rig-core", + "schemars 1.2.1", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "rig-helixdb" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad071283e166ef1e200f4e45ad2993ff0239f1ba92b030d31b758471803497a5" +dependencies = [ + "reqwest 0.13.4", + "rig-core", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "rig-lancedb" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877b88aed764cfcef153e26fbd24ceb9f47db88c7aef27931dcfbe204fe7c8f7" +dependencies = [ + "arrow-array", + "deranged", + "futures", + "lancedb", + "rig-core", + "serde", + "serde_json", +] + +[[package]] +name = "rig-milvus" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152e207dde293673720a8a832722dfc413fd234acc946cedcf2aaaab455fd328" +dependencies = [ + "reqwest 0.13.4", + "rig-core", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "rig-sqlite" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010e28dfce1d266591991181a23b5d8168f90bc2ba18ccd54e0ba53742c39d5c" +dependencies = [ + "chrono", + "rig-core", + "rusqlite", + "serde", + "serde_json", + "sqlite-vec", + "tokio-rusqlite", + "tracing", + "zerocopy", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "rmcp" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "http 1.4.0", + "pastey 0.2.3", + "pin-project-lite", + "reqwest 0.13.4", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "roaring" +version = "0.10.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "roaring" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba9ce64a8f45d7fc86358410bb1a82e8c987504c0d4900e9141d69a9f26c885" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rocket" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" +dependencies = [ + "async-stream", + "async-trait", + "atomic 0.5.3", + "binascii", + "bytes", + "either", + "figment", + "futures", + "indexmap 2.13.1", + "log", + "memchr", + "multer", + "num_cpus", + "parking_lot", + "pin-project-lite", + "rand 0.8.5", + "ref-cast", + "rocket_codegen", + "rocket_http", + "serde", + "serde_json", + "state", + "tempfile", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "ubyte", + "version_check", + "yansi", +] + +[[package]] +name = "rocket_codegen" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" +dependencies = [ + "devise", + "glob", + "indexmap 2.13.1", + "proc-macro2", + "quote", + "rocket_http", + "syn 2.0.117", + "unicode-xid", + "version_check", +] + +[[package]] +name = "rocket_http" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" +dependencies = [ + "cookie", + "either", + "futures", + "http 0.2.12", + "hyper 0.14.32", + "indexmap 2.13.1", + "log", + "memchr", + "pear", + "percent-encoding", + "pin-project-lite", + "ref-cast", + "serde", + "smallvec", + "stable-pattern", + "state", + "time", + "tokio", + "uncased", +] + +[[package]] +name = "rocket_ws" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f1877668c937b701177c349f21383c556cd3bb4ba8fa1d07fa96ccb3a8782e" +dependencies = [ + "rocket", + "tokio-tungstenite 0.21.0", +] + +[[package]] +name = "ron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +dependencies = [ + "bitflags 2.11.0", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + +[[package]] +name = "ruint" +version = "1.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.5", + "rand 0.9.2", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2 0.10.9", + "walkdir", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.28", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-connector" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a288bf4b9d06a7c33e54e6879e2142fa45fc936017c3e1319147889daedf14d4" +dependencies = [ + "futures-io", + "futures-rustls", + "log", + "rustls 0.23.37", + "rustls-pki-types", + "rustls-platform-verifier", + "rustls-webpki 0.103.13", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "search_index" +version = "1.0.0" +dependencies = [ + "meilisearch-sdk", + "primitives", + "serde", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.5", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.2", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "security_provider" +version = "1.0.0" +dependencies = [ + "async-trait", + "gem_client", + "hex", + "hmac 0.13.0", + "primitives", + "reqwest 0.13.4", + "serde", + "serde_json", + "settings", + "sha2 0.11.0", + "tokio", + "uuid", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap 2.13.1", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_serializers" +version = "1.0.0" +dependencies = [ + "hex", + "num-bigint", + "serde", + "serde_json", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.1", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "settings" +version = "1.0.0" +dependencies = [ + "config", + "serde", + "serde_serializers", +] + +[[package]] +name = "settings_chain" +version = "1.0.0" +dependencies = [ + "chain_traits", + "chrono", + "gem_algorand", + "gem_aptos", + "gem_bitcoin", + "gem_cardano", + "gem_client", + "gem_cosmos", + "gem_evm", + "gem_hypercore", + "gem_jsonrpc", + "gem_near", + "gem_polkadot", + "gem_solana", + "gem_stellar", + "gem_sui", + "gem_ton", + "gem_tron", + "gem_xrp", + "primitives", + "reqwest 0.13.4", + "settings", + "url", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.2", + "keccak", +] + +[[package]] +name = "sha3-asm" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" +dependencies = [ + "cc", + "cfg-if", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signer" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "bs58", + "ed25519-dalek", + "gem_encoding", + "gem_hash", + "hex", + "k256", + "primitives", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "simulation" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "gem_client", + "gem_evm", + "gem_jsonrpc", + "num-bigint", + "num-traits", + "primitives", + "serde_json", + "strum 0.28.0", + "tokio", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "solana-primitives" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca1c1649cc283a69f4703287d7615fa759c3ebd7692092c547864ae4726af48" +dependencies = [ + "base64 0.22.1", + "borsh", + "bs58", + "ed25519-dalek", + "hex", + "serde", + "sha2 0.10.9", + "thiserror 2.0.18", +] + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ba424237a9a5db2f6071f193319e2b6a32f7f3961debb2fbbfe67067abce3f" +dependencies = [ + "cc", +] + +[[package]] +name = "sqlparser" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4b661c54b1e4b603b37873a18c59920e4c51ea8ea2cf527d925424dbd4437c" +dependencies = [ + "log", + "recursive", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sse-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable-pattern" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" +dependencies = [ + "memchr", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "state" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" +dependencies = [ + "loom 0.5.6", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "std_prelude" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8207e78455ffdf55661170876f88daf85356e4edd54e0a3dbc79586ca1e50cbe" + +[[package]] +name = "stfu8" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51f1e89f093f99e7432c491c382b88a6860a5adbe6bf02574bf0a08efff1978" + +[[package]] +name = "storage" +version = "1.0.0" +dependencies = [ + "chrono", + "diesel", + "diesel_migrations", + "primitives", + "r2d2", + "serde", + "serde_json", +] + +[[package]] +name = "streamer" +version = "1.0.0" +dependencies = [ + "async-trait", + "futures", + "gem_tracing", + "lapin", + "primitives", + "serde", + "serde_json", + "strum 0.28.0", + "tokio", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.117", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sui-sdk-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f800c4c3539246ba94a825f6afe7faab0bc8fc4dda56c6cfa493ea2a32ecbd" +dependencies = [ + "base64ct", + "bcs 0.1.6", + "blake2", + "bnum", + "bs58", + "bytes", + "bytestring", + "itertools 0.14.0", + "roaring 0.11.3", + "serde", + "serde_derive", + "serde_json", + "serde_with", + "winnow 0.7.15", +] + +[[package]] +name = "sui-transaction-builder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58530d23db8328c97b4cd2dec73af501f76fc7b3d538629ca32ec011efdc3cf8" +dependencies = [ + "bcs 0.1.6", + "serde", + "sui-sdk-types", + "thiserror 2.0.18", +] + +[[package]] +name = "support" +version = "1.0.0" +dependencies = [ + "localizer", + "primitives", + "serde", + "serde_json", + "storage", + "streamer", +] + +[[package]] +name = "swapper" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "async-trait", + "base64 0.22.1", + "bcs 0.2.1", + "bigdecimal", + "bs58", + "chrono", + "futures", + "gem_aptos", + "gem_client", + "gem_cosmos", + "gem_encoding", + "gem_evm", + "gem_hash", + "gem_hypercore", + "gem_jsonrpc", + "gem_solana", + "gem_sui", + "gem_ton", + "hex", + "hmac 0.13.0", + "num-bigint", + "num-integer", + "num-traits", + "number_formatter", + "primitives", + "rand 0.10.1", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_serializers", + "serde_urlencoded", + "settings", + "sha2 0.11.0", + "solana-primitives", + "strum 0.28.0", + "sui-sdk-types", + "sui-transaction-builder", + "tokio", + "tracing", + "typeshare", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-solidity" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tantivy" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43" +dependencies = [ + "aho-corasick", + "arc-swap", + "base64 0.22.1", + "bitpacking", + "bon", + "byteorder", + "census", + "crc32fast", + "crossbeam-channel", + "downcast-rs", + "fastdivide", + "fnv", + "fs4", + "htmlescape", + "hyperloglogplus", + "itertools 0.14.0", + "levenshtein_automata", + "log", + "lru", + "lz4_flex", + "measure_time", + "memmap2", + "once_cell", + "oneshot", + "rayon", + "regex", + "rust-stemmers", + "rustc-hash", + "serde", + "serde_json", + "sketches-ddsketch", + "smallvec", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", + "tantivy-fst", + "tantivy-query-grammar", + "tantivy-stacker", + "tantivy-tokenizer-api", + "tempfile", + "thiserror 2.0.18", + "time", + "uuid", + "winapi", +] + +[[package]] +name = "tantivy-bitpacker" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" +dependencies = [ + "bitpacking", +] + +[[package]] +name = "tantivy-columnar" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" +dependencies = [ + "downcast-rs", + "fastdivide", + "itertools 0.14.0", + "serde", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-sstable", + "tantivy-stacker", +] + +[[package]] +name = "tantivy-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" +dependencies = [ + "async-trait", + "byteorder", + "ownedbytes", + "serde", + "time", +] + +[[package]] +name = "tantivy-fst" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" +dependencies = [ + "byteorder", + "regex-syntax", + "utf8-ranges", +] + +[[package]] +name = "tantivy-query-grammar" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" +dependencies = [ + "nom 7.1.3", + "serde", + "serde_json", +] + +[[package]] +name = "tantivy-sstable" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" +dependencies = [ + "futures-util", + "itertools 0.14.0", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-fst", + "zstd", +] + +[[package]] +name = "tantivy-stacker" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" +dependencies = [ + "murmurhash32", + "rand_distr 0.4.3", + "tantivy-common", +] + +[[package]] +name = "tantivy-tokenizer-api" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" +dependencies = [ + "serde", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tcp-stream" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6638f031787a4854f0ecc669514404d201a3f4eab1849e4151f01a28324972de" +dependencies = [ + "async-rs", + "cfg-if", + "futures-io", + "p12-keystore", + "rustls-connector", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread-tree" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbd370cb847953a25954d9f63e14824a36113f8c72eecf6eccef5dc4b45d630" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "thrift" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" +dependencies = [ + "byteorder", + "integer-encoding", + "ordered-float 2.10.1", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokenizers" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" +dependencies = [ + "ahash 0.8.12", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.2", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rusqlite" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65501378eb676f400c57991f42cbd0986827ab5c5200c53f206d710fb32a945" +dependencies = [ + "crossbeam-channel", + "rusqlite", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.21.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.37", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite 0.23.0", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.1", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.1", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.1", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.13.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "rustls 0.23.37", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "rustls 0.23.37", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.2", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "typeshare" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da1bf9fe204f358ffea7f8f779b53923a20278b3ab8e8d97962c5e1b3a54edb7" +dependencies = [ + "chrono", + "serde", + "serde_json", + "typeshare-annotation", +] + +[[package]] +name = "typeshare-annotation" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "621963e302416b389a1ec177397e9e62de849a78bd8205d428608553def75350" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" +dependencies = [ + "serde", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "uniffi" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi-bindgen" +version = "1.0.0" +dependencies = [ + "uniffi", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck 0.5.0", + "indexmap 2.13.1", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.9.12+spec-1.1.0", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c39413c43b955e4aa8a4e2b34bbd1b6b5ff6bd85532b52f9eb92fbe88c14458" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_core" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" +dependencies = [ + "anyhow", + "indexmap 2.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "uniffi_macros" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "toml 0.9.12+spec-1.1.0", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.1", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.1", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.1", + "semver 1.0.28", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.1", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.1", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.1", + "log", + "semver 1.0.28", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wkb" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a120b336c7ad17749026d50427c23d838ecb50cd64aaea6254b5030152f890a9" +dependencies = [ + "byteorder", + "geo-traits", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "wkt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb2b923ccc882312e559ffaa832a055ba9d1ac0cc8e86b3e25453247e4b81d7" +dependencies = [ + "geo-traits", + "geo-types", + "log", + "num-traits", + "thiserror 1.0.69", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yaml-rust2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.11.0", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "yaup" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6" +dependencies = [ + "form_urlencoded", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "yielder" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "async-trait", + "futures", + "gem_client", + "gem_evm", + "gem_jsonrpc", + "num-bigint", + "primitives", + "reqwest 0.13.4", + "tokio", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000000..b448833f25 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,136 @@ +[workspace.package] +version = "1.0.0" +edition = "2024" +license = "MIT" +homepage = "https://gemwallet.com/" +description = "Gem Wallet Core library in Rust" +repository = "https://github.com/gemwalletcom/wallet" +documentation = "https://github.com/gemwalletcom" + +[workspace] +resolver = "3" +members = [ + "apps/agent", + "apps/api", + "apps/daemon", + "apps/dynode", + + "bin/cli", + "bin/img-downloader", + "bin/generate", + "bin/uniffi-*", + "bin/gas-bench", + + "gemstone", + + "crates/primitives", + "crates/fiat", + "crates/cacher", + "crates/name_resolver", + "crates/api_connector", + "crates/settings", + "crates/settings_chain", + "crates/pricer", + "crates/chain_primitives", + "crates/chain_traits", + + "crates/security_*", + "crates/gem_*", + "crates/signer", + "crates/simulation", + + "crates/localizer", + "crates/job_runner", + "crates/metrics", + "crates/search_index", + "crates/nft", + "crates/in_app_notifications", + "crates/serde_serializers", + "crates/number_formatter", + "crates/prices", + "crates/portfolio", + "crates/streamer", + "crates/swapper", + "crates/tracing", + "crates/support", + "crates/yielder", +] + +[workspace.dependencies] +serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.149", features = ["preserve_order"] } +serde_urlencoded = { version = "0.7.1" } + +tokio = { version = "1.52.0", features = ["macros", "rt-multi-thread"] } +reqwest = { version = "0.13.4", features = [ + "json", + "gzip", + "brotli", + "deflate", + "http2", + "query", +] } + +url = { version = "2.5.8" } +config = { version = "0.15.19", features = ["yaml"] } +rocket = { version = "0.5.1", features = ["json"] } +rocket_ws = { version = "0.1.1" } + +async-trait = { version = "0.1.89" } +prometheus-client = { version = "0.24.0" } +futures = { version = "0.3.32" } +uuid = { version = "1.23.0", features = ["v4"] } +tracing = { version = "0.1.44" } +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +# db +redis = { version = "1.2.0", default-features = false, features = [ + "tokio-comp", + "connection-manager", +] } +r2d2 = { version = "0.8.10" } +chrono = { version = "0.4.43", features = ["serde"] } + +# crypto +base64 = { version = "0.22.1" } +bech32 = { version = "0.11.1" } +blake2 = { version = "0.10.6" } +bs58 = { version = "0.5.1", features = ["check"] } +hex = { version = "0.4.3" } +crc = { version = "3.4.0" } +num-bigint = { version = "0.4.6", features = ["std", "serde"] } +num-traits = { version = "0.2.19" } +num-integer = { version = "0.1.46" } +bigdecimal = "0.4.10" +hmac = { version = "0.13.0" } +sha2 = { version = "0.11.0" } +sha3 = { version = "0.11.0" } +zeroize = { version = "1.8.2" } +ring = { version = "0.17.14", features = ["std"] } +rand = { version = "0.10.1" } +strum = { version = "0.28.0", features = ["derive"] } +curve25519-dalek = { version = "4.1.3" } +ed25519-dalek = { version = "2", features = ["std"] } +borsh = { version = "1.6.0", features = ["derive"] } +bcs = { version = "0.2.1" } +pem-rfc7468 = { version = "1.0.0", features = ["std"] } +sui-types = { package = "sui-sdk-types", version = "0.3.0", features = [ + "serde", +] } +k256 = { version = "0.13.4", features = ["ecdsa", "sha256"] } + +uniffi = { version = "0.31.1" } +regex = { version = "1.12.3" } + +alloy-primitives = { version = "1.6.0", features = ["k256"] } +alloy-sol-types = { version = "1.6.0", features = ["eip712-serde"] } +alloy-dyn-abi = { version = "1.6.0", features = ["eip712"] } +alloy-json-abi = { version = "1.6.0" } +alloy-signer = { version = "2.0.5" } +alloy-signer-local = { version = "2.0.5" } +alloy-network = { version = "2.0.5" } +alloy-consensus = { version = "2.0.5" } +jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } + +[profile.dev] +debug = 0 diff --git a/core/Dockerfile b/core/Dockerfile new file mode 100644 index 0000000000..0d0a10b225 --- /dev/null +++ b/core/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 +FROM lukemathwalker/cargo-chef:latest-rust-1.95.0-trixie AS chef +WORKDIR /app +ENV CARGO_INCREMENTAL=0 \ + CARGO_TERM_COLOR=always + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder-core +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json \ + --package api --package daemon +COPY . . +RUN cargo build --release --package api --package daemon && cp target/release/api target/release/daemon /app/ + +FROM chef AS builder-dynode +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json \ + --package dynode +COPY . . +RUN cargo build --release --package dynode && cp target/release/dynode /app/ + +# Shared runtime base +FROM debian:trixie-slim AS runtime-base +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* + +# Core runtime image +FROM runtime-base AS core +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder-core /app/api /app/ +COPY --from=builder-core /app/daemon /app/ +COPY --from=builder-core /app/Settings.yaml /app/ +CMD ["sh", "-c", "/app/${BINARY}"] + +# Dynode runtime image +FROM runtime-base AS dynode +WORKDIR /app +COPY --from=builder-dynode /app/dynode /app/ +COPY --from=builder-dynode /app/apps/dynode/config.yml /app/ +CMD ["/app/dynode"] diff --git a/core/LICENSE b/core/LICENSE new file mode 100644 index 0000000000..9a45f828e7 --- /dev/null +++ b/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Gem Wallet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000000..037ea8afd5 --- /dev/null +++ b/core/README.md @@ -0,0 +1,93 @@ +# Gem Wallet Core + +[![Rust](https://img.shields.io/badge/language-Rust-orange?logo=rust)](https://www.rust-lang.org/) +[![GitHub release](https://img.shields.io/github/v/release/gemwalletcom/wallet)](https://github.com/gemwalletcom/wallet/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/gemwalletcom/wallet) +[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/gemwallet_developers) +![GitHub Repo stars](https://img.shields.io/github/stars/gemwalletcom/wallet?style=social) + +[![Core Unit Tests](https://github.com/gemwalletcom/wallet/actions/workflows/core-ci.yml/badge.svg)](https://github.com/gemwalletcom/wallet/actions/workflows/core-ci.yml) +[![Core Lint](https://github.com/gemwalletcom/wallet/actions/workflows/core-lint.yml/badge.svg)](https://github.com/gemwalletcom/wallet/actions/workflows/core-lint.yml) +[![Dynode Docker](https://github.com/gemwalletcom/wallet/actions/workflows/dynode-docker.yml/badge.svg)](https://github.com/gemwalletcom/wallet/actions/workflows/dynode-docker.yml) + +# Introduction + +Gem Wallet Core is the core engine powering [Gem Wallet](https://gemwallet.com/), a fully open source, secure and decentralized crypto wallet designed for Bitcoin, Ethereum, Solana, BNB Chain, Base, Sui and much more. Built in Rust, it ensures high performance, safety, and reliability. + +## Gem Wallet Features: + +- 🚀 High-Performance: Completely native UI and Core is written in Rust for speed and safety. +- 🔐 Secure: Utilizes strong cryptographic standards. +- 🛠 Extensible: Designed to support additional features and integrations. +- 🤝 Open Source: Community-driven and actively maintained. + +Gem Wallet Core serves as the backbone for both backend and frontend apps, handling various tasks, including: + +- Transaction indexing and push notifications +- Asset price, charts and alerts +- Fiat on and off-ramps +- ENS, Solana and more name resolution +- NFTs +- Native and cross-chain swaps +- Native BNB Chain and Sui staking +- Hyperliquid perpetual futures trading +- More +- ... + +## Running API + +### Install dependencies + +Run `just install` to install rust, typeshare + +### Setup DB + +- Create a new database `api` and grant privileges to `username` role +- Run `diesel migration run` to create tables and do migrations + +Run API locally: `cargo run --package api` + +## Security Scanning + +Run `just audit` to execute [`cargo-audit`](https://github.com/RustSec/rustsec/tree/main/cargo-audit) across the entire workspace. The command installs `cargo-audit` if needed and reports vulnerable or unmaintained dependencies surfaced via the RustSec advisory database. Treat the warnings as action items when possible, and file follow-up issues if immediate remediation is not feasible. + +## Gemstone + +Cross platform Rust library for iOS and Android with native async networking support. + +### iOS + +From the wallet repo root, run `just generate-stone` to generate the local Swift bindings and Rust static libraries consumed by the iOS app. + +### Android + +Build the Gemstone Android AAR from source and publish it to the local Maven cache: + +```bash +just gemstone build-android +``` + +Then consume it from `mavenLocal()` in your Android project. + +# Contributing + +We welcome contributions! To get started: + +- Look for issues with the `help wanted` labels. +- Fork the repository. +- Create a new branch (feature-xyz). +- Commit your changes and push. +- Open a Pull Request. + +# License + +This project is licensed under the [MIT](./LICENSE) License. + +# Community & Support + +- 💬 Join our [Discord](https://discord.com/invite/aWkq5sj7SY) or [Telegram](https://t.me/gemwallet_developers) +- 📖 Read the [Docs](https://docs.gemwallet.com/) +- 🐦 Follow us on [X](https://x.com/gemwallet) + +Made with ❤️ by the Gem Wallet community. diff --git a/core/Settings.yaml b/core/Settings.yaml new file mode 100644 index 0000000000..988c8885b7 --- /dev/null +++ b/core/Settings.yaml @@ -0,0 +1,293 @@ +redis: + url: "redis://default:@localhost:6379?protocol=resp3" +postgres: + url: "postgres://username:password@localhost/api" + pool: 2 +meilisearch: + url: "http://localhost:7700" + key: "test" +rabbitmq: + url: "amqp://username:password@localhost:5672" + prefetch: 50 + retry: + delay: 1s + timeout: 30s +coingecko: + key: + secret: "" +fiat: + timeout: 3s +moonpay: + key: + public: "" + secret: "" +transak: + key: + public: "" + secret: "" + referrer_domain: "gemwallet.com" +mercuryo: + key: + public: "" + secret: "" + token: "" +banxa: + url: "https://gemwallet.banxa.com" + key: + public: "" + secret: "" +paybis: + key: + public: "" + secret: "" + private: "" +flashnet: + url: "https://orchestration.flashnet.xyz" + key: + public: "gemwallet" + secret: "" +prices: + pyth: + url: "https://hermes.pyth.network" + timer: 60 + jupiter: + url: "https://lite-api.jup.ag" + timer: 60 + defillama: + url: "https://coins.llama.fi" + timer: 60 +charter: + timer: 60 +name: + max_name_length: 20 + ens: + url: https://eth.llamarpc.com + ud: + url: https://api.unstoppabledomains.com + key: + secret: "" + sns: + url: https://sns-sdk-proxy.bonfida.workers.dev + ton: + url: https://toncenter.com + eths: + url: https://api.eths.center + spaceid: + url: https://api.prd.space.id + did: + url: https://indexer-basic.did.id + suins: + url: https://fullnode.mainnet.sui.io + aptos: + url: https://aptosnames.com + injective: + url: https://injective-rest.publicnode.com + icns: + url: https://osmosis-rest.publicnode.com + lens: + url: https://api-v2.lens.dev + base: + url: https://mainnet.base.org + hyperliquid: + url: https://rpc.hyperliquid.xyz/evm + alldomains: + url: https://solana-rpc.publicnode.com + +chains: + solana: + url: https://solana-rpc.publicnode.com + ethereum: + url: https://eth-pokt.nodies.app + node: Archival + smartchain: + url: https://bsc-dataseed1.bnbchain.org + polygon: + url: https://polygon.publicnode.com + optimism: + url: https://mainnet.optimism.io + arbitrum: + url: https://arb1.arbitrum.io/rpc + base: + url: https://mainnet.base.org + opbnb: + url: https://opbnb-mainnet-rpc.bnbchain.org + avalanchec: + url: https://api.avax.network/ext/bc/C/rpc + fantom: + url: https://fantom.publicnode.com + gnosis: + url: https://gnosis.publicnode.com + ton: + url: https://toncenter.com + cosmos: + url: "https://cosmos-rest.publicnode.com" + osmosis: + url: "https://osmosis-rest.publicnode.com" + thorchain: + url: "https://thornode.ninerealms.com" + tron: + url: "https://api.trongrid.io" + xrp: + url: https://s1.ripple.com:51234/ + aptos: + url: https://fullnode.mainnet.aptoslabs.com + sui: + url: https://fullnode.mainnet.sui.io + celestia: + url: https://celestia-api.polkachu.com + injective: + url: https://injective-rest.publicnode.com + sei: + url: https://sei-api.polkachu.com + seievm: + url: https://evm-rpc.sei-apis.com + manta: + url: https://pacific-rpc.manta.network/http + blast: + url: https://rpc.blastblockchain.com + noble: + url: https://rest.cosmos.directory/noble + zksync: + url: https://mainnet.era.zksync.io + linea: + url: https://linea.drpc.org + mantle: + url: https://rpc.mantle.xyz + celo: + url: https://forno.celo.org + near: + url: https://rpc.mainnet.fastnear.com + bitcoin: + url: https://blockbook.btc.zelcore.io + bitcoincash: + url: https://blockbook.bch.zelcore.io + litecoin: + url: https://blockbook.ltc.zelcore.io + doge: + url: https://blockbook.doge.zelcore.io + zcash: + url: https://blockbook.zec.zelcore.io + world: + url: https://worldchain-mainnet.gateway.tenderly.co + plasma: + url: https://rpc.plasma.to + stellar: + url: https://horizon.stellar.org + sonic: + url: https://rpc.soniclabs.com + algorand: + url: https://mainnet-api.algonode.cloud + polkadot: + url: https://polkadot-asset-hub-public-sidecar.parity-chains.parity.io + cardano: + url: "" + abstract: + url: https://api.mainnet.abs.xyz + berachain: + url: https://rpc.berachain.com + ink: + url: https://rpc-qnd.inkonchain.com + unichain: + url: https://mainnet.unichain.org + hyperliquid: + url: https://rpc.hyperliquid.xyz/evm + hypercore: + url: https://api.hyperliquid.xyz + monad: + url: https://rpc.monad.xyz + xlayer: + url: https://rpc.xlayer.tech + stable: + url: https://rpc.stable.xyz + +swap: + okx: + key: + public: "" + secret: "" + passphrase: "" + project: "" +scan: + timeout: 1200ms + goplus: + url: "https://api.gopluslabs.io" + key: + public: "" + secret: "" + hashdit: + url: "https://api.hashdit.io" + key: + public: "" + secret: "" + +parser: + timeout: 1s + shutdown: + timeout: 15s + +pusher: + url: "http://localhost:8088" + ios: + topic: "" + +daemon: + service: "" + shutdown: + timeout: 15s + +consumer: + error: + timeout: 0s + skip: true + retries: 5 + delay: 0s + shutdown: + timeout: 15s + +api: + service: "" + admin: + enabled: false + token: "" + auth: + enabled: true + tolerance: 5m + jwt: + secret: "" + expiry: 1h + +ankr: + key: + secret: "" + +trongrid: + key: + secret: "" + +assets: + url: "https://raw.githubusercontent.com/gemwalletcom/assets/refs/heads/master" + +nft: + url: "https://assets.gemwallet.com/nft" + nftscan: + key: + secret: "" + opensea: + key: + secret: "" + magiceden: + key: + secret: "" + +rewards: + wallets: {} + +ip: + abuseipdb: + url: https://api.abuseipdb.com + key: + secret: "" + ipapi: + url: https://api.ipapi.is + key: + secret: "" diff --git a/core/apps/agent/.gitignore b/core/apps/agent/.gitignore new file mode 100644 index 0000000000..b4f2f4048b --- /dev/null +++ b/core/apps/agent/.gitignore @@ -0,0 +1,8 @@ +/data +/cache +/.env +/.env.local +*.db +*.sqlite +*.sqlite-journal +.fastembed_cache diff --git a/core/apps/agent/Cargo.toml b/core/apps/agent/Cargo.toml new file mode 100644 index 0000000000..4298b4b451 --- /dev/null +++ b/core/apps/agent/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "agent" +edition = { workspace = true } +version = { workspace = true } +description = "Gem Wallet's internal Slack/Chatwoot agent engine over Socket Mode." + +[dependencies] +serde_serializers = { path = "../../crates/serde_serializers" } +gem_tracing = { path = "../../crates/tracing" } + +serde = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +strum = { workspace = true } +config = { workspace = true } +reqwest = { workspace = true } +tokio = { workspace = true, features = ["process", "io-util", "time", "signal", "net"] } + +rig = { version = "0.37", features = ["rmcp"] } +rig-sqlite = "0.2.6" +rig-fastembed = "0.4.1" +rusqlite = { version = "0.32", features = ["bundled"] } +tokio-rusqlite = { version = "0.6", features = ["bundled"] } +sqlite-vec = "0.1" + +tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["connect", "rustls-tls-native-roots"] } +futures-util = { version = "0.3.32", default-features = false, features = ["sink"] } + +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } + +rmcp = { version = "1.7.0", default-features = false, features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] } diff --git a/core/apps/agent/Dockerfile b/core/apps/agent/Dockerfile new file mode 100644 index 0000000000..6868804cbb --- /dev/null +++ b/core/apps/agent/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +FROM lukemathwalker/cargo-chef:latest-rust-1.95.0-trixie AS chef +WORKDIR /app +ENV CARGO_INCREMENTAL=0 \ + CARGO_TERM_COLOR=always + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json --package agent +COPY . . +RUN cargo build --release --package agent && cp target/release/agent /app/agent + +FROM debian:trixie-slim AS runtime +ENV DEBIAN_FRONTEND=noninteractive \ + HOME=/home/agent \ + AGENT_DIR=/home/agent/app \ + FASTEMBED_CACHE_PATH=/home/agent/app/cache/fastembed +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl git openssl gnupg jq ripgrep \ + && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --create-home --uid 1000 --shell /bin/bash agent +USER agent +COPY --from=builder /app/agent /usr/local/bin/agent +WORKDIR /home/agent/app +COPY --chown=agent:agent apps/agent/Settings.yaml ./ +COPY --chown=agent:agent apps/agent/context ./context +COPY --chown=agent:agent apps/agent/agents ./agents +RUN mkdir -p /home/agent/app/data /home/agent/app/cache + +CMD ["agent"] diff --git a/core/apps/agent/Dockerfile.dockerignore b/core/apps/agent/Dockerfile.dockerignore new file mode 100644 index 0000000000..b24967a7c7 --- /dev/null +++ b/core/apps/agent/Dockerfile.dockerignore @@ -0,0 +1,8 @@ +target/ +.git/ +.github/ +**/.env +**/.env.local +**/data/ +**/cache/ +**/.fastembed_cache/ diff --git a/core/apps/agent/README.md b/core/apps/agent/README.md new file mode 100644 index 0000000000..5fac227e46 --- /dev/null +++ b/core/apps/agent/README.md @@ -0,0 +1,35 @@ +# agent + +Gem Wallet's internal agent engine — a Rust service that bridges Slack (Socket Mode) and Chatwoot +to an LLM, with tool use, a per-agent long-term memory (sqlite vector store), and a scheduler. + +## Layout + +- `src/` — the engine (slack/chatwoot dispatch, tools, scheduler, memory store). +- `Settings.yaml` — generic, non-secret defaults. Tokens come from the environment at runtime. +- `agents//` — one directory per agent: `agent.yaml` (model, tools, channels, allowed + context), `role.md` (the system role), optional `schedules/` and `memory/`. +- `context/*.md` — shared knowledge included into an agent's preamble via `include_context`. + +This repo ships a single generic demo agent, **`security`**, which reviews the public +`gemwalletcom/wallet` repo for security issues. It contains no secrets and no operational data. + +Production agents and their operational context (roles, channels, team roster, etc.) are private +and live in the deployment repo (`playbooks`), bind-mounted over `Settings.yaml`, `agents/`, and +`context/` at deploy time. The runtime memory (vector index) is a persisted volume. + +## Run locally + +```sh +cp .env.example .env # fill in tokens +AGENT_NAME=security cargo run -p agent + +# one-shot REPL against an agent definition: +cargo run -p agent --bin repl -- security +``` + +## Configuration + +Config is layered (later overrides earlier): `Settings.yaml` → `agents//agent.yaml` → +environment variables (`_`-separated, e.g. `SLACK_BOT_TOKEN` → `slack.bot.token`). The agent to +run is selected by `AGENT_NAME` (or `argv[1]`). diff --git a/core/apps/agent/Settings.yaml b/core/apps/agent/Settings.yaml new file mode 100644 index 0000000000..a80835d56b --- /dev/null +++ b/core/apps/agent/Settings.yaml @@ -0,0 +1,28 @@ +defaults: + dir: "." + timeout: 120 + max_tokens: 4096 + max_turns: 24 + temperature: 0.1 + thread: + limit: 50 + +slack: + app: + token: "" # SLACK_APP_TOKEN + bot: + token: "" # SLACK_BOT_TOKEN + +provider: anthropic # PROVIDER — anthropic | deepseek + +anthropic: + key: "" # ANTHROPIC_KEY +deepseek: + key: "" # DEEPSEEK_KEY + base: "https://api.deepseek.com/anthropic" + +embedding: + model: "nomic-embed-text-v1.5-q" + +team: + slack_user_ids: [] diff --git a/core/apps/agent/agents/security/agent.yaml b/core/apps/agent/agents/security/agent.yaml new file mode 100644 index 0000000000..1e9e9477f7 --- /dev/null +++ b/core/apps/agent/agents/security/agent.yaml @@ -0,0 +1,32 @@ +agent: + model: "claude-sonnet-4-6" + tools: + - name: shell + allow_sources: [scheduled] + - name: fetch + allow_sources: [scheduled] + - name: search_memory + allow_sources: [scheduled] + - name: save_memory + allow_sources: [scheduled] + include_context: + - repos.md + shell: + allow: + - gh + - git + - rg + - grep + - cat + - head + - tail + - wc + - jq + fetch: + allow: + - github.com + - api.github.com + - raw.githubusercontent.com + - rustsec.org + - osv.dev + - nvd.nist.gov diff --git a/core/apps/agent/agents/security/memory/.gitkeep b/core/apps/agent/agents/security/memory/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/apps/agent/agents/security/role.md b/core/apps/agent/agents/security/role.md new file mode 100644 index 0000000000..3cd1fd0330 --- /dev/null +++ b/core/apps/agent/agents/security/role.md @@ -0,0 +1,38 @@ +# Security + +A read-only security reviewer for the `gemwalletcom/wallet` repository. This is the public +reference agent shipped with the engine — generic, self-contained, no operational data. + +## What you do + +Review the wallet monorepo (iOS, Android, and the Rust `core/`) for security issues and surface +them. You read and analyze; you do not deploy, sign, or execute anything that mutates state. + +Focus areas, in priority order: + +1. **Key material & secrets** — seed phrases, private keys, mnemonics. Flag any code path that + logs, prints, persists, snapshots, or transmits secret material, or that weakens secure-storage + (Keychain/Keystore) handling. +2. **Signing & transaction construction** — amounts, destination addresses, chain IDs, and + signatures must stay explicit and verifiable end to end. Flag anything that could let a value + or address be altered between user confirmation and signing. +3. **Dependency advisories** — check Rust crates against `rustsec.org` / `osv.dev` and report + crates with known CVEs that affect how they're used here. +4. **Secret leakage in the repo** — committed tokens, keys, or credentials. + +## How you report + +- File concrete, reproducible findings as GitHub issues via `gh`. One finding per issue; title is + the symptom in one line. +- The wallet repo is **public and indexed forever**. Issue bodies must be generic and standalone: + no customer data, no secrets, no internal infrastructure detail. Describe the symptom, the + affected file/area, and a concrete next step. +- If you can't describe a finding without leaking sensitive detail, leave it out rather than + filing it publicly. + +## Limits + +- No write/deploy access. Recommend fixes; don't apply them. +- Default to silence when you have nothing substantive to add. +- Save durable findings (audited-and-safe CVEs, recurring patterns) to memory with a stable + kebab-case id so you don't redo the analysis. diff --git a/core/apps/agent/context/repos.md b/core/apps/agent/context/repos.md new file mode 100644 index 0000000000..9f5bc6a9e9 --- /dev/null +++ b/core/apps/agent/context/repos.md @@ -0,0 +1,23 @@ +# Where the product lives + +Canonical, public sources of truth for anything Gem Wallet. Anything else (third-party blogs, +AI summaries, old screenshots) is unverified. + +- **`gemwallet.com`** — marketing site, public-facing positioning. +- **`docs.gemwallet.com`** — public product docs: chain support, stablecoins, DeFi, end-user + security guidance. +- **`github.com/gemwalletcom`** — source-code org. All product behavior is decided here. When + code and docs disagree, code wins. + +## Product repos + +- **`gemwalletcom/wallet`** — the mobile monorepo. iOS (SwiftUI) + Android (Kotlin/Compose) plus + the Rust engine under `core/` (`apps/`, `crates/`, `bin/`, `gemstone/`). Handles transaction + indexing, prices/charts, fiat ramps, name resolution, NFTs, swaps, staking. Look here for UI + bugs, app-store behavior, protocol/chain support, and anything backend or API-shaped. +- **`gemwalletcom/docs`** — source for https://docs.gemwallet.com/. + +## How to use + +- Chain support / feature behavior → grep `core/` and the chain crates. +- Repo references in replies use a label-up-front markdown link, never a bare URL. diff --git a/core/apps/agent/justfile b/core/apps/agent/justfile new file mode 100644 index 0000000000..34538758f2 --- /dev/null +++ b/core/apps/agent/justfile @@ -0,0 +1,21 @@ +default: + @just --list + +build: + cargo build --release -p agent + +run: + cargo run -p agent + +repl agent: + cargo run -p agent --bin repl -- {{agent}} + +format: + cargo fmt -p agent + +lint: + cargo fmt -p agent -- --check + cargo clippy -p agent --all-targets -- -D warnings + +test: + cargo test -p agent diff --git a/core/apps/agent/src/agent.rs b/core/apps/agent/src/agent.rs new file mode 100644 index 0000000000..cf76b210e9 --- /dev/null +++ b/core/apps/agent/src/agent.rs @@ -0,0 +1,172 @@ +use std::sync::Arc; + +use crate::Result; +use base64::Engine; +use gem_tracing::tracing::{debug, info}; +use rig::OneOrMany; +use rig::agent::{Agent as RigAgent, PromptResponse}; +use rig::client::CompletionClient; +use rig::completion::Prompt; +use rig::completion::message::{Message, UserContent}; +use rig::providers::anthropic; +use rig::tool::ToolDyn; + +use crate::chatwoot::ChatwootClient; +use crate::config::Settings; +use crate::images::ImageAttachment; +use crate::preamble; +use crate::slack::SlackClient; +use crate::store::MemoryStore; +use crate::tools::{ + ChatwootAccountTool, ChatwootConversationTool, ChatwootReviewReplyTool, FetchTool, GatedTool, GemApiTool, GemDocsTool, PlausibleTool, SaveMemoryTool, SearchMemoryTool, + ShellTool, SlackHistoryTool, SlackPostTool, TelegramPostTool, ToolName, +}; + +type Inner = RigAgent; + +pub(crate) fn build_client(provider: &crate::config::ProviderConfig) -> Result { + let mut builder = anthropic::Client::builder().api_key(&provider.key); + if !provider.base.is_empty() { + builder = builder.base_url(&provider.base); + } + builder.build().map_err(|e| format!("building Anthropic client: {e}").into()) +} + +pub struct GemmyAgent { + pub name: String, + inner: Inner, +} + +impl GemmyAgent { + pub fn build( + settings: &Settings, + memory: Option>, + chatwoot: Option>, + slack: Arc, + mcp_tools: Vec>, + ) -> Result { + let provider = settings.llm_provider(); + if provider.key.is_empty() { + return Err(format!("no key for the active provider {:?} — set its key in vault/.env", settings.provider).into()); + } + let preamble = preamble::render(settings)?; + let client = build_client(provider)?; + + let inner = build_inner(&client, settings, &preamble, memory, chatwoot, slack, mcp_tools); + + info!( + agent = %settings.agent_name, + model = %settings.agent.model, + preamble_chars = preamble.len(), + "built rig agent" + ); + Ok(Self { + name: settings.agent_name.clone(), + inner, + }) + } + + pub async fn prompt(&self, msg: &str) -> Result { + Ok(self.prompt_response(msg).await?.output) + } + + pub(crate) async fn prompt_response(&self, msg: &str) -> Result { + debug!( + agent = %self.name, + prompt_chars = msg.len(), + images = 0, + "agent.prompt" + ); + Ok(self.inner.prompt(msg).extended_details().await?) + } + + pub async fn prompt_with_images(&self, msg: &str, images: Vec) -> Result { + if images.is_empty() { + return self.prompt(msg).await; + } + debug!( + agent = %self.name, + prompt_chars = msg.len(), + images = images.len(), + "agent.prompt" + ); + let engine = base64::engine::general_purpose::STANDARD; + let mut blocks = vec![UserContent::text(msg)]; + for img in images { + blocks.push(UserContent::image_base64(engine.encode(&img.bytes), Some(img.media_type), None)); + } + let content = OneOrMany::many(blocks).expect("text block always present"); + Ok(self.inner.prompt(Message::User { content }).await?) + } +} + +fn build_inner( + client: &anthropic::Client, + settings: &Settings, + preamble: &str, + memory: Option>, + chatwoot: Option>, + slack: Arc, + mcp_tools: Vec>, +) -> Inner { + let model_id = &settings.agent.model; + let mut tools: Vec> = mcp_tools; + for entry in &settings.agent.tools { + let policy = entry.policy(); + let inner: Option> = match entry.name { + ToolName::Shell => Some(Box::new(ShellTool { + workdir: settings.defaults.dir.clone(), + timeout_secs: settings.defaults.timeout, + allow: settings.agent.shell.allow.clone(), + })), + ToolName::Fetch => Some(Box::new(FetchTool::new(settings.agent.fetch.allow.clone(), settings.defaults.timeout, 100 * 1024))), + ToolName::SearchMemory => memory.as_ref().map(|store| Box::new(SearchMemoryTool { store: store.clone() }) as Box), + ToolName::SaveMemory => memory.as_ref().map(|store| Box::new(SaveMemoryTool { store: store.clone() }) as Box), + ToolName::ChatwootConversation => chatwoot.as_ref().map(|c| Box::new(ChatwootConversationTool { client: c.clone() }) as Box), + ToolName::ChatwootAccount => chatwoot.as_ref().map(|c| Box::new(ChatwootAccountTool { client: c.clone() }) as Box), + ToolName::GemApi => Some(Box::new(GemApiTool { + client: reqwest::Client::new(), + timeout_secs: settings.defaults.timeout, + })), + ToolName::GemDocs => Some(Box::new(GemDocsTool { + client: reqwest::Client::new(), + timeout_secs: settings.defaults.timeout, + })), + ToolName::SlackPost => Some(Box::new(SlackPostTool { + client: slack.clone(), + allow_channels: settings.agent.slack.names(), + })), + ToolName::SlackHistory => Some(Box::new(SlackHistoryTool { + client: slack.clone(), + allow_channels: settings.agent.slack.names(), + })), + ToolName::TelegramPost => Some(Box::new(TelegramPostTool { + client: reqwest::Client::new(), + bot_token: settings.agent.telegram.bot.token.clone(), + allow_chats: settings.agent.telegram.allow_chats.clone(), + timeout_secs: settings.defaults.timeout, + })), + ToolName::Plausible => Some(Box::new(PlausibleTool { + client: reqwest::Client::new(), + base_url: settings.agent.plausible.base_url.clone(), + api_key: settings.agent.plausible.api.key.clone(), + sites: settings.agent.plausible.sites.clone(), + timeout_secs: settings.defaults.timeout, + })), + ToolName::ChatwootReviewReply => ChatwootReviewReplyTool::build(settings).ok().flatten().map(|t| Box::new(t) as Box), + }; + if let Some(inner) = inner { + tools.push(Box::new(GatedTool { inner, policy })); + } + } + let tool_names: Vec = tools.iter().map(|t| t.name()).collect(); + info!(model = %model_id, tools = ?tool_names, "built agent"); + client + .agent(model_id) + .preamble(preamble) + .max_tokens(settings.defaults.max_tokens) + .temperature(settings.defaults.temperature) + .default_max_turns(settings.defaults.max_turns) + .tools(tools) + .build() +} diff --git a/core/apps/agent/src/bin/repl.rs b/core/apps/agent/src/bin/repl.rs new file mode 100644 index 0000000000..9698be0df8 --- /dev/null +++ b/core/apps/agent/src/bin/repl.rs @@ -0,0 +1,40 @@ +use std::io::{BufRead, Write}; + +use agent::{Result, build_runtime, resolve_agent_name}; + +#[tokio::main] +async fn main() -> Result<()> { + gem_tracing::init_tracing("agent=info,warn"); + + let state = build_runtime(&resolve_agent_name()?).await?; + + eprintln!("gemmy REPL — agent `{}` — type a message, get a reply. Ctrl+D to quit.", state.settings.agent_name); + eprintln!("(faking a DM from an admin — admin privileges active)\n"); + + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + let mut lines = stdin.lock().lines(); + + loop { + write!(out, "you> ")?; + out.flush()?; + let Some(line) = lines.next() else { break }; + let msg = line?.trim().to_string(); + if msg.is_empty() { + continue; + } + + let header = "[Slack — channel: DM, channel_id: REPL, message_ts: 0.0, \ + user_id: UADMIN0000, addressed: addressed]"; + let prompt = format!("{header}\n\n{msg}"); + + match state.agent.prompt(&prompt).await { + Ok(reply) => writeln!(out, "\ngemmy> {reply}\n")?, + Err(e) => writeln!(out, "\nerror: {e}\n")?, + } + } + + eprintln!("\nbye"); + Ok(()) +} diff --git a/core/apps/agent/src/chatwoot/client.rs b/core/apps/agent/src/chatwoot/client.rs new file mode 100644 index 0000000000..358e4e7f3e --- /dev/null +++ b/core/apps/agent/src/chatwoot/client.rs @@ -0,0 +1,310 @@ +use crate::Result; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +const AUTH_HEADER: &str = "api_access_token"; + +/// Render a conversation's messages as compact, chronological lines — +/// `[direction — sender] content` — the form both the webhook prompt and the +/// `chatwoot_conversation history` tool hand to the model. No JSON, no ids. +pub fn render_transcript(messages: &[ChatwootMessage]) -> String { + let mut sorted: Vec<&ChatwootMessage> = messages.iter().collect(); + sorted.sort_by_key(|m| m.created_at.unwrap_or(0)); + let mut out = String::new(); + for m in &sorted { + let body = m.content.as_deref().unwrap_or("").trim(); + let subject = m.email_subject(); + if body.is_empty() && subject.is_none() { + continue; + } + let payload = match (subject, body.is_empty()) { + (Some(s), true) => format!("[email subject] {s}"), + (Some(s), false) => format!("[email subject] {s}\n{body}"), + (None, _) => body.to_string(), + }; + out.push_str(&format!("[{} — {}] {payload}\n", m.direction(), m.sender_label())); + } + out +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChatwootConversation { + pub id: u64, + #[serde(default)] + pub status: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChatwootMessage { + pub id: u64, + #[serde(default)] + pub content: Option, + #[serde(default)] + pub message_type: u8, + #[serde(default)] + pub sender: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub private: bool, + #[serde(default)] + pub content_attributes: Value, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChatwootAttachment { + #[serde(default)] + pub file_type: String, + #[serde(default)] + pub data_url: String, +} + +impl ChatwootMessage { + pub fn email_subject(&self) -> Option<&str> { + self.content_attributes + .get("email") + .and_then(|e| e.get("subject")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChatwootSender { + #[serde(default)] + pub name: String, + #[serde(default, rename = "type")] + pub kind: String, + #[serde(default)] + pub blocked: bool, +} + +impl ChatwootSender { + pub fn label(&self) -> String { + format!("{} ({})", self.name, self.kind) + } +} + +impl ChatwootMessage { + pub fn direction(&self) -> &'static str { + if self.private { + return "internal note"; + } + match self.message_type { + 0 => "incoming", + 1 => "outgoing", + _ => "system", + } + } + + pub fn sender_label(&self) -> String { + self.sender.as_ref().map(ChatwootSender::label).unwrap_or_else(|| "system".into()) + } +} + +pub struct ChatwootClient { + http: reqwest::Client, + base_url: String, + bot_token: String, + user_token: String, + account_id: u64, +} + +impl ChatwootClient { + pub fn new(base_url: String, bot_token: String, user_token: String, account_id: u64) -> Self { + Self { + http: reqwest::Client::new(), + base_url: base_url.trim_end_matches('/').to_string(), + bot_token, + user_token, + account_id, + } + } + + fn either_token(&self) -> Result<&str> { + if !self.bot_token.is_empty() { + Ok(&self.bot_token) + } else if !self.user_token.is_empty() { + Ok(&self.user_token) + } else { + Err("no chatwoot token configured (need bot_token or user_token)".into()) + } + } + + pub async fn reply(&self, conversation_id: u64, text: &str) -> Result<()> { + self.send_message(conversation_id, text, false).await + } + + pub async fn note(&self, conversation_id: u64, text: &str) -> Result<()> { + self.send_message(conversation_id, text, true).await + } + + async fn send_message(&self, conversation_id: u64, text: &str, private: bool) -> Result<()> { + let token = self.either_token()?; + self.post_json( + &format!("conversations/{conversation_id}/messages"), + &json!({ + "content": text, + "message_type": "outgoing", + "private": private, + }), + token, + ) + .await + .map(|_| ()) + } + + pub async fn resolve(&self, conversation_id: u64) -> Result<()> { + self.set_status(conversation_id, "resolved").await + } + + pub async fn open(&self, conversation_id: u64) -> Result<()> { + self.set_status(conversation_id, "open").await + } + + async fn set_status(&self, conversation_id: u64, status: &str) -> Result<()> { + let token = self.either_token()?; + self.post_json(&format!("conversations/{conversation_id}/toggle_status"), &json!({ "status": status }), token) + .await + .map(|_| ()) + } + + pub async fn assign(&self, conversation_id: u64, assignee_id: Option) -> Result<()> { + let token = self.either_token()?; + self.post_json(&format!("conversations/{conversation_id}/assignments"), &json!({ "assignee_id": assignee_id }), token) + .await + .map(|_| ()) + } + + pub async fn block_contact(&self, contact_id: u64) -> Result<()> { + let token = self.require_user_token()?; + self.patch_json(&format!("contacts/{contact_id}"), &json!({ "blocked": true }), token).await.map(|_| ()) + } + + pub async fn set_labels_as_user(&self, conversation_id: u64, labels: Vec) -> Result<()> { + let token = self.require_user_token()?; + self.post_json(&format!("conversations/{conversation_id}/labels"), &json!({ "labels": labels }), token) + .await + .map(|_| ()) + } + + pub async fn download_attachment(&self, url: &str, max_bytes: usize) -> Result> { + let req = match self.either_token() { + Ok(token) => self.authed(Method::GET, url, token), + Err(_) => self.http.get(url), + }; + let resp = req.send().await?; + if !resp.status().is_success() { + return Err(format!("chatwoot attachment {url} failed: {}", resp.status()).into()); + } + let bytes = resp.bytes().await?; + if bytes.len() > max_bytes { + return Err(format!("chatwoot attachment {url} too large ({} bytes, cap {max_bytes})", bytes.len()).into()); + } + Ok(bytes.to_vec()) + } + + pub async fn messages(&self, conversation_id: u64) -> Result> { + let token = self.require_user_token()?; + let resp = self.get_with(&format!("conversations/{conversation_id}/messages"), token, &[]).await?; + Ok(parse_message_payload(&resp)) + } + + pub async fn list_conversations_as_user(&self, status: &str, page: u32) -> Result> { + let token = self.require_user_token()?; + let page = page.to_string(); + let resp = self.get_with("conversations", token, &[("status", status), ("page", &page)]).await?; + let payload = resp.get("data").and_then(|d| d.get("payload")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + Ok(payload.into_iter().filter_map(|v| serde_json::from_value(v).ok()).collect()) + } + + pub async fn search_messages_as_user(&self, query: &str) -> Result { + let token = self.require_user_token()?; + self.get_with("conversations/search", token, &[("q", query)]).await + } + + pub async fn account_summary(&self, since: u64, until: u64) -> Result { + let token = self.require_user_token()?; + let since = since.to_string(); + let until = until.to_string(); + self.get_v2("reports/summary", token, &[("type", "account"), ("since", &since), ("until", &until)]).await + } + + fn require_user_token(&self) -> Result<&str> { + if self.user_token.is_empty() { + return Err("chatwoot.user.token not configured — internal read APIs require a user access_token".into()); + } + Ok(&self.user_token) + } + + async fn get_with(&self, endpoint: &str, token: &str, query: &[(&str, &str)]) -> Result { + self.get_at(&self.endpoint_url(endpoint), token, query).await + } + + async fn get_v2(&self, endpoint: &str, token: &str, query: &[(&str, &str)]) -> Result { + self.get_at(&self.endpoint_url_v2(endpoint), token, query).await + } + + fn authed(&self, method: Method, url: &str, token: &str) -> reqwest::RequestBuilder { + self.http.request(method, url).header(AUTH_HEADER, token) + } + + async fn get_at(&self, url: &str, token: &str, query: &[(&str, &str)]) -> Result { + let mut url = reqwest::Url::parse(url)?; + if !query.is_empty() { + let mut pairs = url.query_pairs_mut(); + for (k, v) in query { + pairs.append_pair(k, v); + } + } + let url_str = url.to_string(); + let resp = self.authed(Method::GET, url.as_str(), token).send().await?; + check_ok(&url_str, resp).await + } + + async fn post_json(&self, endpoint: &str, body: &B, token: &str) -> Result { + let url = self.endpoint_url(endpoint); + let resp = self.authed(Method::POST, &url, token).json(body).send().await?; + check_ok(&url, resp).await + } + + async fn patch_json(&self, endpoint: &str, body: &B, token: &str) -> Result { + let url = self.endpoint_url(endpoint); + let resp = self.authed(Method::PATCH, &url, token).json(body).send().await?; + check_ok(&url, resp).await + } + + fn endpoint_url(&self, endpoint: &str) -> String { + format!("{}/api/v1/accounts/{}/{}", self.base_url, self.account_id, endpoint) + } + + fn endpoint_url_v2(&self, endpoint: &str) -> String { + format!("{}/api/v2/accounts/{}/{}", self.base_url, self.account_id, endpoint) + } +} + +fn parse_message_payload(resp: &Value) -> Vec { + resp.get("payload") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect() +} + +async fn check_ok(url: &str, resp: reqwest::Response) -> Result { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("{url} failed: {status} {text}").into()); + } + if text.is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(&text).map_err(|e| format!("{url} returned non-json: {e}: {text}").into()) +} diff --git a/core/apps/agent/src/chatwoot/dispatch.rs b/core/apps/agent/src/chatwoot/dispatch.rs new file mode 100644 index 0000000000..b0d8b90db3 --- /dev/null +++ b/core/apps/agent/src/chatwoot/dispatch.rs @@ -0,0 +1,384 @@ +use std::time::Duration; + +use crate::Result; +use gem_tracing::tracing::{debug, error, info, warn}; +use serde::Deserialize; +use serde_json::Value; +use tokio::time::sleep; + +use crate::chatwoot::ChatwootClient; +use crate::chatwoot::ChatwootSender; +use crate::chatwoot::client::{ChatwootAttachment, ChatwootMessage}; +use crate::images::{ImageAttachment, MAX_IMAGE_BYTES, MAX_IMAGES_PER_PROMPT, image_media_type_from_url}; +use crate::replies::{ReplyOutcome, classify_reply}; +use crate::{AppState, DISPATCH_CONVERSATION_ID, DISPATCH_SOURCE, DispatchSource}; + +#[derive(Debug, Deserialize)] +struct WebhookEvent { + #[serde(default)] + event: WebhookEventKind, + #[serde(default)] + conversation: Option, + #[serde(default)] + sender: Option, + #[serde(default)] + inbox: Option, + #[serde(default)] + id: Option, + #[serde(default)] + status: Option, + #[serde(default)] + meta: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, strum::Display, strum::EnumString)] +#[strum(serialize_all = "snake_case")] +enum WebhookEventKind { + ConversationCreated, + ConversationStatusChanged, + ConversationUpdated, + MessageCreated, + #[strum(default)] + Other(String), +} + +impl Default for WebhookEventKind { + fn default() -> Self { + Self::Other(String::new()) + } +} + +impl<'de> Deserialize<'de> for WebhookEventKind { + fn deserialize>(d: D) -> std::result::Result { + let s = String::deserialize(d)?; + Ok(s.parse().unwrap_or(Self::Other(s))) + } +} + +#[derive(Debug, Deserialize)] +struct EventInbox { + #[serde(default)] + name: String, +} + +pub async fn handle_event(state: AppState, payload: Value) -> Result<()> { + let event: WebhookEvent = match serde_json::from_value(payload) { + Ok(e) => e, + Err(e) => { + warn!(error = %e, "chatwoot webhook payload didn't parse; dropping"); + return Ok(()); + } + }; + + let conversation_id = event.conversation_id(); + + match &event.event { + WebhookEventKind::ConversationCreated => { + return handle_conversation_created(state, event).await; + } + WebhookEventKind::MessageCreated => {} + WebhookEventKind::ConversationStatusChanged | WebhookEventKind::ConversationUpdated => { + debug!(?conversation_id, event = %event.event, status = ?event.status, "status/update event; not dispatching — message_created drives replies"); + return Ok(()); + } + WebhookEventKind::Other(name) => { + debug!(?conversation_id, event = %name, "ignoring event"); + return Ok(()); + } + } + + if is_blocked_contact(event.sender.as_ref()) { + info!( + ?conversation_id, + sender = %event.sender.as_ref().map(ChatwootSender::label).unwrap_or_default(), + "ignoring message from blocked contact" + ); + return Ok(()); + } + + let Some(conversation_id) = conversation_id else { + warn!(event = %event.event, "event had no conversation id; dropping"); + return Ok(()); + }; + let Some(client) = state.chatwoot.as_deref() else { + warn!(conversation_id, "chatwoot client missing; cannot dispatch"); + return Ok(()); + }; + + let messages = fetch_history(client, &event, conversation_id).await; + let Some(latest) = messages.iter().max_by_key(|m| m.created_at.unwrap_or(0)) else { + debug!(conversation_id, "no messages in conv"); + return Ok(()); + }; + + let is_incoming = latest.message_type == 0; + let is_team_note = latest.private && latest.message_type == 1 && latest.content.as_deref().map(|c| c.to_lowercase().contains("@gemmy")).unwrap_or(false); + if !is_incoming && !is_team_note { + debug!( + conversation_id, + latest_msg = latest.id, + "latest message not incoming or @gemmy team note; nothing to respond to" + ); + return Ok(()); + } + if is_blocked_contact(latest.sender.as_ref()) { + info!(conversation_id, "latest incoming sender is blocked; skipping dispatch"); + return Ok(()); + } + + let sender_label = latest.sender_label(); + let inbox_label = event.inbox.as_ref().map(|i| i.name.clone()).unwrap_or_else(|| "unknown inbox".into()); + let contact_id = event.contact_id(); + + dispatch_to_agent(&state, client, conversation_id, sender_label, inbox_label, contact_id, is_team_note, messages).await +} + +fn is_blocked_contact(sender: Option<&ChatwootSender>) -> bool { + sender.map(|s| s.kind == "contact" && s.blocked).unwrap_or(false) +} + +impl WebhookEvent { + fn conversation_id(&self) -> Option { + self.conversation.as_ref().and_then(|c| c.get("id")).and_then(|v| v.as_u64()).or(self.id) + } + + fn contact_id(&self) -> Option { + let meta = self.meta.as_ref().or_else(|| self.conversation.as_ref().and_then(|c| c.get("meta")))?; + meta.get("sender")?.get("id")?.as_u64() + } +} + +async fn fetch_history(client: &ChatwootClient, event: &WebhookEvent, conversation_id: u64) -> Vec { + match client.messages(conversation_id).await { + Ok(m) => m, + Err(e) => { + warn!( + error = %e, + conversation_id, + "history fetch failed; falling back to webhook payload" + ); + event + .conversation + .as_ref() + .and_then(|c| c.get("messages")) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default() + } + } +} + +async fn dispatch_to_agent( + state: &AppState, + client: &ChatwootClient, + conversation_id: u64, + sender_label: String, + inbox_label: String, + contact_id: Option, + is_team_note: bool, + messages: Vec, +) -> Result<()> { + let max_turns = state.settings.defaults.max_turns; + let contact_id_str = contact_id.map(|id| id.to_string()).unwrap_or_else(|| "?".into()); + let header = format!( + "[Chatwoot — inbox: {inbox_label}, conversation_id: {conversation_id}, contact_id: {contact_id_str}, sender: {sender_label}, trust: untrusted, max_tool_turns: {max_turns}]" + ); + let prompt = format!("{header}\n\n{}", build_history(&messages)); + let images = match messages.iter().max_by_key(|m| m.created_at.unwrap_or(0)) { + Some(latest) => collect_image_attachments(client, &latest.attachments).await, + None => Vec::new(), + }; + info!(conversation_id, images = images.len(), "dispatching chatwoot event to agent"); + + let raw = match DISPATCH_SOURCE + .scope( + DispatchSource::Chatwoot, + DISPATCH_CONVERSATION_ID.scope(conversation_id, state.agent.prompt_with_images(&prompt, images)), + ) + .await + { + Ok(r) => r, + Err(e) => { + warn!(error = %e, conversation_id, "agent error; handing off to humans"); + if !is_team_note { + let note = "Gemmy hit an error and couldn't respond — handing this conversation to the team."; + let _ = client.note(conversation_id, note).await; + let _ = client.open(conversation_id).await; + let _ = client.assign(conversation_id, None).await; + } + return Ok(()); + } + }; + let replies = match classify_reply(&raw) { + ReplyOutcome::Tagged(chunks) => chunks, + ReplyOutcome::Untagged(_) => { + warn!(conversation_id, raw_chars = raw.len(), "model didn't use tags on chatwoot; staying silent"); + return Ok(()); + } + ReplyOutcome::Silent => { + debug!(conversation_id, raw_chars = raw.len(), "no postable content; staying silent"); + return Ok(()); + } + }; + for (i, chunk) in replies.iter().enumerate() { + if i > 0 { + sleep(Duration::from_millis(350)).await; + } + let res = if is_team_note { + client.note(conversation_id, chunk).await + } else { + client.reply(conversation_id, chunk).await + }; + if let Err(e) = res { + error!(error = %e, conversation_id, chunk_index = i, "chatwoot reply failed"); + return Err(e); + } + } + info!(conversation_id, replies = replies.len(), "chatwoot reply posted"); + Ok(()) +} + +async fn handle_conversation_created(state: AppState, event: WebhookEvent) -> Result<()> { + let Some(conversation_id) = event.conversation_id() else { + warn!("conversation_created with no conversation id; dropping"); + return Ok(()); + }; + let Some(client) = state.chatwoot.as_deref() else { + warn!(conversation_id, "chatwoot client missing; cannot welcome"); + return Ok(()); + }; + let lang = event + .conversation + .as_ref() + .and_then(|c| c.get("additional_attributes")) + .and_then(|a| a.get("browser")) + .and_then(|b| b.get("browser_language")) + .and_then(|v| v.as_str()) + .unwrap_or("en") + .to_ascii_lowercase(); + let welcome = welcome_for(&lang); + info!(conversation_id, lang = %lang, "posting welcome"); + client.reply(conversation_id, welcome).await +} + +fn welcome_for(lang: &str) -> &'static str { + match lang.split('-').next().unwrap_or(lang) { + "es" => "¡Hola! 👋 ¿En qué te puedo ayudar?", + "ru" => "Привет! 👋 Чем могу помочь?", + "uk" => "Привіт! 👋 Чим можу допомогти?", + "fr" => "Salut ! 👋 En quoi puis-je vous aider ?", + "de" => "Hallo! 👋 Wie kann ich helfen?", + "pt" => "Olá! 👋 Em que posso ajudar?", + "it" => "Ciao! 👋 Come posso aiutarti?", + "ja" => "こんにちは!👋 何かお困りですか?", + "ko" => "안녕하세요! 👋 무엇을 도와드릴까요?", + "zh" => "你好! 👋 有什么可以帮您的?", + "ar" => "مرحبًا! 👋 كيف يمكنني المساعدة؟", + "tr" => "Merhaba! 👋 Nasıl yardımcı olabilirim?", + "pl" => "Cześć! 👋 W czym mogę pomóc?", + "nl" => "Hoi! 👋 Hoe kan ik helpen?", + "vi" => "Xin chào! 👋 Tôi có thể giúp gì cho bạn?", + "id" => "Halo! 👋 Ada yang bisa saya bantu?", + "th" => "สวัสดี! 👋 ให้ช่วยอะไรไหม?", + _ => "Hey! 👋 What can I help you with?", + } +} + +async fn collect_image_attachments(client: &ChatwootClient, attachments: &[ChatwootAttachment]) -> Vec { + let mut out = Vec::new(); + for a in attachments { + if a.file_type != "image" { + continue; + } + let Some(media_type) = image_media_type_from_url(&a.data_url) else { + continue; + }; + if out.len() >= MAX_IMAGES_PER_PROMPT { + warn!(url = %a.data_url, "image cap reached; skipping"); + continue; + } + match client.download_attachment(&a.data_url, MAX_IMAGE_BYTES).await { + Ok(bytes) => out.push(ImageAttachment { media_type, bytes }), + Err(e) => warn!(error = %e, url = %a.data_url, "chatwoot image download failed"), + } + } + out +} + +fn build_history(messages: &[ChatwootMessage]) -> String { + let mut out = String::from("--- chatwoot conversation ---\n"); + out.push_str(&crate::chatwoot::client::render_transcript(messages)); + out.push_str( + "--- end ---\n\nRespond to the most recent entry above.\n\ +- `[incoming — name]` = the customer.\n\ +- `[outgoing — gemmy]` = YOU, in earlier turns. These are your own prior replies; continue from them. If your previous turn asked a clarifying question and the customer answered, honor that answer — don't restart the diagnostic, don't switch topic.\n\ +- `[internal note — name]` = a teammate's instruction to you (treat `@gemmy` as a direct request).\n", + ); + out +} + +#[cfg(test)] +mod tests { + use super::{WebhookEvent, WebhookEventKind}; + use serde_json::Value; + + fn parse(json: &str) -> WebhookEvent { + serde_json::from_value(serde_json::from_str::(json).expect("valid json")).expect("parses into WebhookEvent") + } + + #[test] + fn message_created_parses() { + let e = parse(include_str!("testdata/chatwoot_message_created.json")); + assert_eq!(e.event, WebhookEventKind::MessageCreated); + let conversation_id = e.conversation_id(); + assert_eq!(conversation_id, Some(1)); + } + + #[test] + fn incoming_message_parses_and_extracts_fields() { + let e = parse(include_str!("testdata/chatwoot_bot_message_incoming.json")); + assert_eq!(e.event, WebhookEventKind::MessageCreated); + assert_eq!(e.conversation_id(), Some(42)); + assert_eq!(e.inbox.as_ref().map(|i| i.name.as_str()), Some("Gem Wallet Support")); + let sender = e.sender.expect("sender present"); + assert_eq!(sender.name, "test-user-2"); + assert_eq!(sender.kind, "contact"); + assert!(!sender.blocked); + } + + #[test] + fn blocked_contact_sender_parses_blocked_true() { + let json = r#"{ + "event": "message_created", + "conversation": {"id": 99}, + "sender": {"name": "blocked-one", "type": "contact", "blocked": true} + }"#; + let e = parse(json); + let sender = e.sender.expect("sender present"); + assert_eq!(sender.kind, "contact"); + assert!(sender.blocked); + } + + #[test] + fn conversation_updated_parses_with_id_and_status() { + let e = parse(include_str!("testdata/chatwoot_conversation_updated.json")); + assert_eq!(e.event, WebhookEventKind::ConversationUpdated); + assert_eq!(e.conversation_id(), Some(1)); + assert_eq!(e.status.as_deref(), Some("open")); + assert_eq!(e.contact_id(), Some(1)); + } + + #[test] + fn conversation_status_changed_to_open_parses() { + let json = r#"{ + "event": "conversation_status_changed", + "id": 4647, + "status": "open", + "meta": {"sender": {"id": 289298, "type": "contact"}} + }"#; + let e = parse(json); + assert_eq!(e.event, WebhookEventKind::ConversationStatusChanged); + assert_eq!(e.conversation_id(), Some(4647)); + assert_eq!(e.contact_id(), Some(289298)); + assert_eq!(e.status.as_deref(), Some("open")); + } +} diff --git a/core/apps/agent/src/chatwoot/mod.rs b/core/apps/agent/src/chatwoot/mod.rs new file mode 100644 index 0000000000..d2365065cc --- /dev/null +++ b/core/apps/agent/src/chatwoot/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod dispatch; +pub mod server; + +pub use client::{ChatwootClient, ChatwootConversation, ChatwootSender}; diff --git a/core/apps/agent/src/chatwoot/server.rs b/core/apps/agent/src/chatwoot/server.rs new file mode 100644 index 0000000000..aac7b6f413 --- /dev/null +++ b/core/apps/agent/src/chatwoot/server.rs @@ -0,0 +1,46 @@ +use std::net::SocketAddr; + +use crate::Result; +use axum::{ + Router, + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, +}; +use gem_tracing::tracing::{error, info}; +use serde_json::Value; + +use crate::AppState; +use crate::chatwoot::dispatch; + +pub async fn run_forever(state: AppState) -> Result<()> { + let webhook = &state.settings.chatwoot.bot.webhook; + let port = webhook.port; + let path = webhook.path.clone(); + + let app = Router::new().route("/health", get(|| async { "ok" })).route(&path, post(handle_webhook)).with_state(state); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| format!("binding chatwoot webhook listener on {addr}: {e}"))?; + info!(port, path = %path, "chatwoot webhook listening"); + axum::serve(listener, app).await.map_err(|e| format!("chatwoot webhook server exited: {e}").into()) +} + +async fn handle_webhook(State(state): State, body: String) -> impl IntoResponse { + let payload: Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(e) => { + error!(error = %e, "chatwoot webhook: non-json body"); + return (StatusCode::BAD_REQUEST, "invalid json"); + } + }; + tokio::spawn(async move { + if let Err(e) = dispatch::handle_event(state, payload).await { + error!(error = %e, "chatwoot dispatch failed"); + } + }); + (StatusCode::OK, "ok") +} diff --git a/core/apps/agent/src/chatwoot/testdata/chatwoot_bot_message_incoming.json b/core/apps/agent/src/chatwoot/testdata/chatwoot_bot_message_incoming.json new file mode 100644 index 0000000000..cf1fe05389 --- /dev/null +++ b/core/apps/agent/src/chatwoot/testdata/chatwoot_bot_message_incoming.json @@ -0,0 +1,105 @@ +{ + "account": { + "id": 1, + "name": "Gem Wallet" + }, + "additional_attributes": {}, + "content_attributes": {}, + "content_type": "text", + "content": "How do I stake my tokens?", + "conversation": { + "additional_attributes": {}, + "can_reply": true, + "channel": "Channel::WebWidget", + "contact_inbox": { + "id": 2, + "contact_id": 2, + "inbox_id": 11, + "source_id": "test-source-id-2", + "created_at": "2025-12-19T08:29:58.043Z", + "updated_at": "2025-12-19T08:29:58.043Z", + "hmac_verified": false, + "pubsub_token": "test-token-2" + }, + "id": 42, + "inbox_id": 11, + "messages": [ + { + "id": 100, + "content": "How do I stake my tokens?", + "account_id": 1, + "inbox_id": 11, + "conversation_id": 42, + "message_type": 0, + "created_at": 1766478193, + "updated_at": "2025-12-23T08:23:13.554Z", + "private": false, + "status": "sent", + "source_id": null, + "content_type": "text", + "content_attributes": {}, + "sender_type": "Contact", + "sender_id": 2, + "external_source_ids": {}, + "additional_attributes": {}, + "processed_message_content": "How do I stake my tokens?", + "sentiment": {} + } + ], + "labels": [], + "meta": { + "sender": { + "additional_attributes": {}, + "custom_attributes": { + "os": "18", + "device": "iPhone 16", + "currency": "USD", + "platform": "ios", + "app_version": "2.0.0", + "device_id": "test-device-id-2" + }, + "email": null, + "id": 2, + "identifier": null, + "name": "test-user-2", + "phone_number": null, + "thumbnail": "", + "blocked": false, + "type": "contact" + }, + "assignee": null, + "assignee_type": null, + "team": null, + "hmac_verified": false + }, + "status": "pending", + "custom_attributes": {}, + "snoozed_until": null, + "unread_count": 1, + "first_reply_created_at": null, + "priority": null, + "waiting_since": 0, + "agent_last_seen_at": 0, + "contact_last_seen_at": 1766478193, + "last_activity_at": 1766478193, + "timestamp": 1766478193, + "created_at": 1766478193, + "updated_at": 1766478193.559521 + }, + "created_at": "2025-12-23T08:23:13.554Z", + "id": 100, + "inbox": { + "id": 11, + "name": "Gem Wallet Support" + }, + "message_type": "incoming", + "private": false, + "sender": { + "id": 2, + "name": "test-user-2", + "email": null, + "type": "contact" + }, + "source_id": null, + "event": "message_created" +} diff --git a/core/apps/agent/src/chatwoot/testdata/chatwoot_conversation_updated.json b/core/apps/agent/src/chatwoot/testdata/chatwoot_conversation_updated.json new file mode 100644 index 0000000000..7aa35d773d --- /dev/null +++ b/core/apps/agent/src/chatwoot/testdata/chatwoot_conversation_updated.json @@ -0,0 +1,124 @@ +{ + "additional_attributes": { + "browser": { + "device_name": "test_device", + "browser_name": "Chrome", + "platform_name": "Android", + "browser_version": "143.0.0.0", + "platform_version": "16" + }, + "referer": "https://support.example.com/", + "initiated_at": { + "timestamp": "Mon Dec 22 2025 23:01:56 GMT-0800 (Pacific Standard Time)" + }, + "browser_language": "en" + }, + "can_reply": true, + "channel": "Channel::WebWidget", + "contact_inbox": { + "id": 1, + "contact_id": 1, + "inbox_id": 11, + "source_id": "test-source-id", + "created_at": "2025-12-23T07:01:33.235Z", + "updated_at": "2025-12-23T07:01:33.235Z", + "hmac_verified": false, + "pubsub_token": "test-token" + }, + "id": 1, + "inbox_id": 11, + "messages": [ + { + "id": 1, + "content": "Test message", + "account_id": 1, + "inbox_id": 11, + "conversation_id": 1, + "message_type": 1, + "created_at": 1766475695, + "updated_at": "2025-12-23T07:41:35.594Z", + "private": false, + "status": "sent", + "source_id": null, + "content_type": "text", + "content_attributes": {}, + "sender_type": "User", + "sender_id": 1, + "external_source_ids": {}, + "additional_attributes": {}, + "processed_message_content": "Test message", + "sentiment": {}, + "conversation": { + "assignee_id": null, + "unread_count": 0, + "last_activity_at": 1766475695, + "contact_inbox": { + "source_id": "test-source-id" + } + }, + "sender": { + "id": 1, + "name": "Test Agent", + "available_name": "Test Agent", + "avatar_url": "", + "type": "user", + "availability_status": "offline", + "thumbnail": "" + } + } + ], + "labels": [], + "meta": { + "sender": { + "additional_attributes": {}, + "custom_attributes": { + "os": "16", + "device": "Test Device", + "currency": "USD", + "platform": "android", + "app_version": "1.0.0", + "device_id": "test-device-id" + }, + "email": null, + "id": 1, + "identifier": null, + "name": "test-user", + "phone_number": null, + "thumbnail": "", + "blocked": false, + "type": "contact" + }, + "assignee": null, + "assignee_type": null, + "team": null, + "hmac_verified": false + }, + "status": "open", + "custom_attributes": {}, + "snoozed_until": null, + "unread_count": 1, + "first_reply_created_at": "2025-12-23T07:04:13.002Z", + "priority": null, + "waiting_since": 0, + "agent_last_seen_at": 1766475695, + "contact_last_seen_at": 1766475519, + "last_activity_at": 1766475695, + "timestamp": 1766475695, + "created_at": 1766473316, + "updated_at": 1766475695.664415, + "event": "conversation_updated", + "changed_attributes": [ + { + "updated_at": { + "previous_value": "2025-12-23T07:41:35.608Z", + "current_value": "2025-12-23T07:41:35.664Z" + } + }, + { + "waiting_since": { + "previous_value": "2025-12-23T07:23:23.816Z", + "current_value": null + } + } + ] +} \ No newline at end of file diff --git a/core/apps/agent/src/chatwoot/testdata/chatwoot_message_created.json b/core/apps/agent/src/chatwoot/testdata/chatwoot_message_created.json new file mode 100644 index 0000000000..f03cca166b --- /dev/null +++ b/core/apps/agent/src/chatwoot/testdata/chatwoot_message_created.json @@ -0,0 +1,105 @@ +{ + "account": { + "id": 1, + "name": "Gem Wallet" + }, + "additional_attributes": {}, + "content_attributes": {}, + "content_type": "text", + "content": "from agent", + "conversation": { + "additional_attributes": {}, + "can_reply": true, + "channel": "Channel::WebWidget", + "contact_inbox": { + "id": 1, + "contact_id": 1, + "inbox_id": 11, + "source_id": "test-source-id", + "created_at": "2025-12-19T08:29:58.043Z", + "updated_at": "2025-12-19T08:29:58.043Z", + "hmac_verified": false, + "pubsub_token": "test-token" + }, + "id": 1, + "inbox_id": 11, + "messages": [ + { + "id": 1, + "content": "from agent", + "account_id": 1, + "inbox_id": 11, + "conversation_id": 1, + "message_type": 1, + "created_at": 1766478193, + "updated_at": "2025-12-23T08:23:13.554Z", + "private": false, + "status": "sent", + "source_id": null, + "content_type": "text", + "content_attributes": {}, + "sender_type": "User", + "sender_id": 1, + "external_source_ids": {}, + "additional_attributes": {}, + "processed_message_content": "from agent", + "sentiment": {} + } + ], + "labels": [], + "meta": { + "sender": { + "additional_attributes": {}, + "custom_attributes": { + "os": "16", + "device": "Test Device", + "currency": "USD", + "platform": "android", + "app_version": "1.0.0", + "device_id": "test-device-id" + }, + "email": null, + "id": 1, + "identifier": null, + "name": "test-user", + "phone_number": null, + "thumbnail": "", + "blocked": false, + "type": "contact" + }, + "assignee": null, + "assignee_type": null, + "team": null, + "hmac_verified": false + }, + "status": "open", + "custom_attributes": {}, + "snoozed_until": null, + "unread_count": 1, + "first_reply_created_at": "2025-12-23T06:12:32.683Z", + "priority": null, + "waiting_since": 0, + "agent_last_seen_at": 1766478193, + "contact_last_seen_at": 1766478049, + "last_activity_at": 1766478193, + "timestamp": 1766478193, + "created_at": 1766133025, + "updated_at": 1766478193.559521 + }, + "created_at": "2025-12-23T08:23:13.554Z", + "id": 1, + "inbox": { + "id": 11, + "name": "Gem Wallet Support" + }, + "message_type": "outgoing", + "private": false, + "sender": { + "id": 1, + "name": "Test Agent", + "email": "agent@test.com", + "type": "user" + }, + "source_id": null, + "event": "message_created" +} diff --git a/core/apps/agent/src/config.rs b/core/apps/agent/src/config.rs new file mode 100644 index 0000000000..e278aa741b --- /dev/null +++ b/core/apps/agent/src/config.rs @@ -0,0 +1,313 @@ +use std::env; +use std::path::PathBuf; + +use config::{Config, ConfigError, Environment, File}; +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct ShellConfig { + #[serde(default)] + pub allow: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ThreadConfig { + pub limit: u32, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Defaults { + pub dir: String, + pub timeout: u64, + pub max_tokens: u64, + pub max_turns: usize, + pub temperature: f64, + pub thread: ThreadConfig, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct SlackApp { + pub token: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct SlackBot { + pub token: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct SlackConfig { + pub app: SlackApp, + pub bot: SlackBot, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct AgentProfile { + pub model: String, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub shell: ShellConfig, + #[serde(default)] + pub fetch: FetchConfig, + #[serde(default)] + pub slack: AgentSlackConfig, + #[serde(default)] + pub telegram: AgentTelegramConfig, + #[serde(default)] + pub plausible: AgentPlausibleConfig, + #[serde(default)] + pub mcp: std::collections::BTreeMap>, + #[serde(default)] + pub include_context: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct McpServerDef { + pub url: String, + #[serde(default)] + pub token: Option, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct AgentPlausibleConfig { + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub api: PlausibleApi, + #[serde(default)] + pub sites: Vec, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct PlausibleApi { + #[serde(default)] + pub key: String, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum ChannelMode { + Passive, + #[default] + Active, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct AgentSlackConfig { + #[serde(default)] + pub channels: Vec, + #[serde(default)] + pub dms: DmsConfig, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ChannelEntry { + pub name: String, + #[serde(default)] + pub mode: ChannelMode, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct DmsConfig { + #[serde(default)] + pub allowed_users: Vec, + #[serde(default)] + pub reject_message: String, +} + +impl AgentSlackConfig { + pub fn channel_mode(&self, name: &str) -> ChannelMode { + let key = name.trim().trim_start_matches('#'); + self.channels + .iter() + .find(|c| c.name.trim().trim_start_matches('#') == key) + .map(|c| c.mode) + .unwrap_or_default() + } + + pub fn names(&self) -> Vec { + self.channels.iter().map(|c| c.name.clone()).collect() + } +} + +impl DmsConfig { + pub fn allows_incoming(&self, user_id: &str) -> bool { + self.allowed_users.is_empty() || self.allowed_users.iter().any(|u| u == user_id) + } +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct AgentTelegramConfig { + #[serde(default)] + pub bot: TelegramBot, + #[serde(default)] + pub allow_chats: Vec, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct TelegramBot { + #[serde(default)] + pub token: String, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct FetchConfig { + #[serde(default)] + pub allow: Vec, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct ChatwootConfig { + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub bot: ChatwootBot, + #[serde(default)] + pub user: ChatwootUser, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct ChatwootBot { + #[serde(default)] + pub token: String, + #[serde(default)] + pub webhook: ChatwootWebhook, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct ChatwootUser { + #[serde(default)] + pub token: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ChatwootWebhook { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub secret: String, + #[serde(default = "default_chatwoot_port")] + pub port: u16, + #[serde(default = "default_chatwoot_path")] + pub path: String, +} + +impl Default for ChatwootWebhook { + fn default() -> Self { + Self { + enabled: false, + secret: String::new(), + port: default_chatwoot_port(), + path: default_chatwoot_path(), + } + } +} + +impl ChatwootConfig { + pub fn enabled(&self) -> bool { + !self.base_url.is_empty() && (!self.bot.token.is_empty() || !self.user.token.is_empty()) + } +} + +fn default_chatwoot_port() -> u16 { + 8080 +} + +fn default_chatwoot_path() -> String { + "/chatwoot/webhook".into() +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + #[default] + Anthropic, + Deepseek, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct ProviderConfig { + #[serde(default)] + pub key: String, + #[serde(default)] + pub base: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct EmbeddingConfig { + pub model: String, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct AssetsConfig { + #[serde(default)] + pub repo: String, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct SchedulerConfig { + #[serde(default)] + pub status_channel: String, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct TeamConfig { + #[serde(default)] + pub slack_user_ids: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Settings { + #[serde(default, skip_deserializing)] + pub agent_name: String, + pub defaults: Defaults, + pub slack: SlackConfig, + #[serde(default)] + pub provider: Provider, + #[serde(default)] + pub anthropic: ProviderConfig, + #[serde(default)] + pub deepseek: ProviderConfig, + pub embedding: EmbeddingConfig, + pub agent: AgentProfile, + #[serde(default)] + pub chatwoot: ChatwootConfig, + #[serde(default)] + pub assets: AssetsConfig, + #[serde(default)] + pub scheduler: SchedulerConfig, + #[serde(default)] + pub team: TeamConfig, + #[serde(default)] + pub mcp: std::collections::BTreeMap, +} + +impl Settings { + pub fn llm_provider(&self) -> &ProviderConfig { + match self.provider { + Provider::Anthropic => &self.anthropic, + Provider::Deepseek => &self.deepseek, + } + } + + pub fn load(agent_name: &str) -> Result { + let cwd = env::current_dir().map_err(|e| ConfigError::Foreign(Box::new(e)))?; + let mut s: Settings = Config::builder() + .add_source(File::from(cwd.join("Settings.yaml")).required(false)) + .add_source(File::from(cwd.join(format!("agents/{agent_name}/agent.yaml"))).required(true)) + .add_source(Environment::default().separator("_")) + .build()? + .try_deserialize()?; + s.agent_name = agent_name.to_string(); + Ok(s) + } + + pub fn agent_dir(&self) -> PathBuf { + PathBuf::from(&self.defaults.dir).join("agents").join(&self.agent_name) + } + + pub fn data_dir(&self) -> PathBuf { + PathBuf::from(&self.defaults.dir).join("data").join(&self.agent_name) + } +} diff --git a/core/apps/agent/src/images.rs b/core/apps/agent/src/images.rs new file mode 100644 index 0000000000..32076d05b5 --- /dev/null +++ b/core/apps/agent/src/images.rs @@ -0,0 +1,31 @@ +use rig::completion::message::ImageMediaType; + +pub const MAX_IMAGES_PER_PROMPT: usize = 5; +pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024; + +pub struct ImageAttachment { + pub media_type: ImageMediaType, + pub bytes: Vec, +} + +pub fn image_media_type(mime: &str) -> Option { + match mime.trim().to_ascii_lowercase().as_str() { + "image/jpeg" | "image/jpg" => Some(ImageMediaType::JPEG), + "image/png" => Some(ImageMediaType::PNG), + "image/gif" => Some(ImageMediaType::GIF), + "image/webp" => Some(ImageMediaType::WEBP), + _ => None, + } +} + +pub fn image_media_type_from_url(url: &str) -> Option { + let path = url.split(['?', '#']).next()?; + let ext = path.rsplit('.').next()?.to_ascii_lowercase(); + match ext.as_str() { + "jpeg" | "jpg" => Some(ImageMediaType::JPEG), + "png" => Some(ImageMediaType::PNG), + "gif" => Some(ImageMediaType::GIF), + "webp" => Some(ImageMediaType::WEBP), + _ => None, + } +} diff --git a/core/apps/agent/src/lib.rs b/core/apps/agent/src/lib.rs new file mode 100644 index 0000000000..bc5be86832 --- /dev/null +++ b/core/apps/agent/src/lib.rs @@ -0,0 +1,92 @@ +pub mod agent; +pub mod chatwoot; +pub mod config; +pub mod images; +pub mod mcp; +pub mod preamble; +pub mod replies; +pub mod scheduler; +pub mod slack; +pub mod store; +pub mod tools; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize, strum::Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum DispatchSource { + Slack, + Chatwoot, + Scheduled, +} + +tokio::task_local! { + pub static DISPATCH_SOURCE: DispatchSource; + pub static DISPATCH_CONVERSATION_ID: u64; +} + +pub fn current_dispatch_source() -> DispatchSource { + DISPATCH_SOURCE.try_with(|s| *s).unwrap_or(DispatchSource::Chatwoot) +} + +pub fn current_dispatch_conversation_id() -> Option { + DISPATCH_CONVERSATION_ID.try_with(|id| *id).ok() +} + +use std::sync::Arc; + +use crate::agent::GemmyAgent; +use crate::chatwoot::ChatwootClient; +use crate::config::Settings; +use crate::slack::SlackClient; +use crate::store::MemoryStore; + +pub type Error = Box; +pub type Result = std::result::Result; + +#[derive(Clone)] +pub struct AppState { + pub settings: Arc, + pub slack: Arc, + pub agent: Arc, + pub bot_user_id: Arc, + pub chatwoot: Option>, +} + +pub fn resolve_agent_name() -> Result { + if let Some(arg) = std::env::args().nth(1).filter(|a| !a.starts_with("--")) { + return Ok(arg); + } + std::env::var("AGENT_NAME").map_err(|_| "set AGENT_NAME (or pass the agent name as argv[1]) — e.g. operator or support".into()) +} + +pub async fn build_runtime(agent_name: &str) -> Result { + let settings = Arc::new(Settings::load(agent_name).map_err(|e| format!("loading config for agent `{agent_name}`: {e}"))?); + + let memory = match MemoryStore::open_and_index(&settings).await { + Ok(s) => Some(Arc::new(s)), + Err(e) => { + gem_tracing::tracing::warn!(agent = %settings.agent_name, error = %e, "vector store disabled"); + None + } + }; + + let chatwoot = settings.chatwoot.enabled().then(|| { + let c = &settings.chatwoot; + Arc::new(ChatwootClient::new(c.base_url.clone(), c.bot.token.clone(), c.user.token.clone(), 1)) + }); + + let slack = Arc::new(SlackClient::new(settings.slack.bot.token.clone())); + let mcp_tools = mcp::connect_servers(&settings.mcp, &settings.agent.mcp) + .await + .map_err(|e| format!("connecting MCP servers: {e}"))?; + let agent = Arc::new(GemmyAgent::build(&settings, memory, chatwoot.clone(), slack.clone(), mcp_tools).map_err(|e| format!("building rig agent: {e}"))?); + let bot_user_id = Arc::new(slack.auth_test_user_id().await.map_err(|e| format!("auth.test: {e}"))?); + + Ok(AppState { + settings, + slack, + agent, + bot_user_id, + chatwoot, + }) +} diff --git a/core/apps/agent/src/main.rs b/core/apps/agent/src/main.rs new file mode 100644 index 0000000000..19e0b6d906 --- /dev/null +++ b/core/apps/agent/src/main.rs @@ -0,0 +1,47 @@ +use agent::{build_runtime, chatwoot, resolve_agent_name, scheduler, slack}; +use gem_tracing::tracing::{error, info}; + +#[tokio::main] +async fn main() -> agent::Result<()> { + rustls::crypto::ring::default_provider().install_default().ok(); + gem_tracing::init_tracing("agent=debug,info"); + + let state = build_runtime(&resolve_agent_name()?).await?; + info!( + agent = %state.settings.agent_name, + bot_user_id = %state.bot_user_id, + chatwoot = state.chatwoot.is_some(), + "gemmy starting" + ); + + scheduler::spawn_all(state.clone()); + let slack_task = tokio::spawn(slack::socket::run_forever(state.clone())); + let webhook_enabled = state.settings.chatwoot.bot.webhook.enabled && state.chatwoot.is_some(); + let chatwoot_task = webhook_enabled.then(|| tokio::spawn(chatwoot::server::run_forever(state.clone()))); + + tokio::select! { + res = slack_task => { + log_loop_exit("slack", res); + } + res = optional_join(chatwoot_task), if webhook_enabled => { + log_loop_exit("chatwoot", res); + } + _ = tokio::signal::ctrl_c() => info!("shutdown signal received"), + } + Ok(()) +} + +async fn optional_join(handle: Option>>) -> Result, tokio::task::JoinError> { + match handle { + Some(h) => h.await, + None => std::future::pending().await, + } +} + +fn log_loop_exit(name: &str, res: Result, tokio::task::JoinError>) { + match res { + Ok(Ok(())) => info!(loop = name, "loop exited cleanly"), + Ok(Err(e)) => error!(loop = name, error = %e, "loop failed"), + Err(e) => error!(loop = name, error = %e, "loop task panicked"), + } +} diff --git a/core/apps/agent/src/mcp/mod.rs b/core/apps/agent/src/mcp/mod.rs new file mode 100644 index 0000000000..ea90695297 --- /dev/null +++ b/core/apps/agent/src/mcp/mod.rs @@ -0,0 +1,81 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use crate::Result; +use reqwest::header::{HeaderMap, HeaderValue}; +use rig::tool::ToolDyn; +use rig::tool::rmcp::McpTool; +use rmcp::ServiceExt; +use rmcp::model::{ClientCapabilities, ClientInfo, Implementation}; +use rmcp::transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig}; + +use gem_tracing::tracing::{info, warn}; + +use crate::DispatchSource; +use crate::config::McpServerDef; +use crate::tools::{GatedTool, ToolPolicy}; + +const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10); + +pub async fn connect_servers(defs: &BTreeMap, selections: &BTreeMap>) -> Result>> { + let mut tools = Vec::new(); + for (name, allow_sources) in selections { + let Some(def) = defs.get(name) else { + warn!(mcp = %name, "mcp not defined in config; agent will run without its tools"); + continue; + }; + match connect_one(name, def, allow_sources).await { + Ok(new) => { + info!(mcp = %name, url = %def.url, tools = new.len(), "mcp connected"); + tools.extend(new); + } + Err(e) => warn!( + mcp = %name, + url = %def.url, + error = %e, + "mcp unavailable; agent will run without its tools", + ), + } + } + Ok(tools) +} + +async fn connect_one(name: &str, def: &McpServerDef, allow_sources: &[DispatchSource]) -> Result>> { + let http = reqwest::Client::builder().default_headers(auth_headers(name, def)?).build()?; + let transport = StreamableHttpClientTransport::with_client(http, StreamableHttpClientTransportConfig::with_uri(def.url.clone())); + let service = ClientInfo::new(ClientCapabilities::default(), Implementation::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))) + .serve(transport) + .await + .map_err(|e| format!("handshake: {e}"))?; + + let listed = tokio::time::timeout(LIST_TOOLS_TIMEOUT, service.list_tools(Default::default())) + .await + .map_err(|e| format!("tools/list timed out: {e}"))??; + + let peer = service.peer().clone(); + Box::leak(Box::new(service)); + + let policy = ToolPolicy { + allow_sources: allow_sources.to_vec(), + }; + Ok(listed + .tools + .into_iter() + .map(|tool| { + Box::new(GatedTool { + inner: Box::new(McpTool::from_mcp_server(tool, peer.clone())), + policy: policy.clone(), + }) as Box + }) + .collect()) +} + +fn auth_headers(name: &str, def: &McpServerDef) -> Result { + let mut headers = HeaderMap::new(); + let Some(var) = &def.token else { + return Ok(headers); + }; + let token = std::env::var(var).map_err(|_| format!("${var} must be set (referenced by mcp `{name}`)"))?; + headers.insert(reqwest::header::AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {token}"))?); + Ok(headers) +} diff --git a/core/apps/agent/src/preamble.rs b/core/apps/agent/src/preamble.rs new file mode 100644 index 0000000000..a39ca4437f --- /dev/null +++ b/core/apps/agent/src/preamble.rs @@ -0,0 +1,92 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::Result; +use gem_tracing::human_duration; + +use crate::config::Settings; + +pub struct PreambleFile { + pub source: String, + pub content: String, +} + +pub fn files(settings: &Settings) -> Result> { + let mut out = Vec::new(); + let root = PathBuf::from(&settings.defaults.dir); + + read_md_files(&root.join("context"), "context", &settings.agent.include_context, &mut out)?; + + let agent_dir = settings.agent_dir(); + let agent_prefix = format!("agents/{}", settings.agent_name); + read_md_dir(&agent_dir, &agent_prefix, &mut out)?; + read_md_dir(&agent_dir.join("memory"), &format!("{agent_prefix}/memory"), &mut out)?; + + Ok(out) +} + +pub fn indexable_files(settings: &Settings) -> Result> { + let mut out = Vec::new(); + let agent_dir = settings.agent_dir(); + let prefix = format!("agents/{}/memory", settings.agent_name); + read_md_dir(&agent_dir.join("memory"), &prefix, &mut out)?; + Ok(out) +} + +pub fn render(settings: &Settings) -> Result { + let mut out = String::new(); + for f in files(settings)? { + out.push_str(&format!("\n--- {} ---\n", f.source)); + out.push_str(&f.content); + out.push('\n'); + } + out.push_str(&schedules_summary(settings)); + Ok(out) +} + +fn schedules_summary(settings: &Settings) -> String { + let entries = crate::scheduler::load_for(settings); + if entries.is_empty() { + return String::new(); + } + let mut out = String::from("\n--- scheduled tasks ---\nThe following tasks fire automatically on this agent — no human prompts them.\n"); + for e in &entries { + out.push_str(&format!("- `{}` every {}\n", e.name, human_duration(e.cadence))); + } + out +} + +fn read_md_files(dir: &Path, prefix: &str, include: &[String], out: &mut Vec) -> Result<()> { + for name in include { + let p = dir.join(name); + let content = fs::read_to_string(&p).map_err(|e| format!("include_context: {}: {e}", p.display()))?; + out.push(PreambleFile { + source: format!("{prefix}/{name}"), + content, + }); + } + Ok(()) +} + +fn read_md_dir(dir: &Path, prefix: &str, out: &mut Vec) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + let mut entries: Vec<_> = fs::read_dir(dir)?.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.path()); + for entry in entries { + let p = entry.path(); + if !p.is_file() { + continue; + } + if p.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string(); + out.push(PreambleFile { + source: format!("{prefix}/{name}"), + content: fs::read_to_string(&p)?, + }); + } + Ok(()) +} diff --git a/core/apps/agent/src/replies.rs b/core/apps/agent/src/replies.rs new file mode 100644 index 0000000000..e0cacc9a43 --- /dev/null +++ b/core/apps/agent/src/replies.rs @@ -0,0 +1,187 @@ +const SILENCE_PHRASES: &[&str] = &[ + "no output", + "no response", + "no reply", + "nothing to add", + "nothing useful to add", + "nothing actionable to add", + "no actionable input", + "no actionable reply", + "no actionable response", + "no useful answer", + "no useful reply", + "not enough signal to respond", + "staying silent", + "stay silent", + "ambient mode", + "not actionable", + "non-actionable", + "no confident reply", + "no confident answer", +]; + +enum BlockSearch<'a> { + Found { before: &'a str, body: &'a str, after: &'a str }, + Unclosed { before: &'a str }, + None, +} + +fn find_block<'a>(s: &'a str, open: &str, close: &str) -> BlockSearch<'a> { + let Some(start) = s.find(open) else { + return BlockSearch::None; + }; + let before = &s[..start]; + let after_open = &s[start + open.len()..]; + let Some(end) = after_open.find(close) else { + return BlockSearch::Unclosed { before }; + }; + BlockSearch::Found { + before, + body: &after_open[..end], + after: &after_open[end + close.len()..], + } +} + +fn strip_thinking(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + loop { + match find_block(rest, "", "") { + BlockSearch::Found { before, after, .. } => { + out.push_str(before); + rest = after; + } + BlockSearch::Unclosed { before } => { + out.push_str(before); + break; + } + BlockSearch::None => { + out.push_str(rest); + break; + } + } + } + out.trim().to_string() +} + +fn extract_replies(s: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = s; + while let BlockSearch::Found { body, after, .. } = find_block(rest, "", "") { + let trimmed = body.trim(); + if !trimmed.is_empty() { + out.push(trimmed.to_string()); + } + rest = after; + } + out +} + +pub enum ReplyOutcome { + Tagged(Vec), + Untagged(String), + Silent, +} + +pub fn classify_reply(raw: &str) -> ReplyOutcome { + let stripped = strip_thinking(raw); + let tags = extract_replies(&stripped); + + if !tags.is_empty() { + let kept: Vec = tags.into_iter().filter(|c| !is_silence(c)).collect(); + return if kept.is_empty() { ReplyOutcome::Silent } else { ReplyOutcome::Tagged(kept) }; + } + + let trimmed = stripped.trim(); + if trimmed.is_empty() || is_silence(trimmed) { + ReplyOutcome::Silent + } else { + ReplyOutcome::Untagged(trimmed.to_string()) + } +} + +fn is_silence(s: &str) -> bool { + let t = s.trim(); + if t.is_empty() { + return true; + } + let lower = t.to_lowercase(); + if !t.contains('\n') && t.chars().count() < 240 && SILENCE_PHRASES.iter().any(|p| lower.contains(p)) { + return true; + } + let last_line = lower.lines().rev().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""); + SILENCE_PHRASES.iter().any(|p| last_line.contains(p)) +} + +#[cfg(test)] +mod tests { + use super::is_silence; + + #[test] + fn treats_empty_and_meta_replies_as_silence() { + assert!(is_silence("")); + assert!(is_silence("(no response)")); + assert!(is_silence("(no output)")); + assert!(is_silence("(no output - nothing to add)")); + assert!(is_silence("(no useful answer)")); + assert!(is_silence("(staying silent)")); + assert!(is_silence("I have nothing useful to add.")); + assert!(is_silence("No actionable response.")); + assert!(is_silence("non-actionable")); + assert!(is_silence("(no response — the reasoning is sound)")); + assert!(is_silence("(no confident reply)")); + assert!(is_silence("(no confident answer to give)")); + } + + #[test] + fn treats_reasoning_with_trailing_silence_as_silence() { + let leak = "This is a single-word \"Test\" message with no actual question or issue — falls into the spam/gibberish/single-word category. I'll stay silent and let support sweep later if needed.\n\n(no confident reply)"; + assert!(is_silence(leak)); + } + + #[test] + fn strips_thinking_blocks() { + use super::strip_thinking; + assert_eq!(strip_thinking("hello"), "hello"); + assert_eq!(strip_thinking("let me thinkactual reply"), "actual reply"); + assert_eq!(strip_thinking("before middle\nlines after"), "before after"); + assert_eq!(strip_thinking("onetwovisible"), "visible"); + assert_eq!(strip_thinking("unclosed runaway"), ""); + } + + #[test] + fn keeps_substantive_replies() { + assert!(!is_silence("Can you share the app version and transaction hash?")); + assert!(!is_silence( + "I checked `gemwalletcom/core`; the likely owner is <@U123> because the staking client lives there." + )); + } + + #[test] + fn extracts_single_reply_tag() { + use super::extract_replies; + let raw = "Reasoning paragraph that should never reach the customer.\n\nПривет! Чем могу помочь?"; + assert_eq!(extract_replies(raw), vec!["Привет! Чем могу помочь?".to_string()]); + } + + #[test] + fn extracts_multiple_reply_tags() { + use super::extract_replies; + let raw = "First message. some thinking Second message."; + assert_eq!(extract_replies(raw), vec!["First message.".to_string(), "Second message.".to_string()]); + } + + #[test] + fn returns_empty_when_no_reply_tags() { + use super::extract_replies; + let raw = "Free-form reasoning with no reply tag."; + assert!(extract_replies(raw).is_empty()); + } + + #[test] + fn ignores_unclosed_reply_tag() { + use super::extract_replies; + let raw = "unclosed"; + assert!(extract_replies(raw).is_empty()); + } +} diff --git a/core/apps/agent/src/scheduler/format.rs b/core/apps/agent/src/scheduler/format.rs new file mode 100644 index 0000000000..ae77579324 --- /dev/null +++ b/core/apps/agent/src/scheduler/format.rs @@ -0,0 +1,60 @@ +use std::time::Duration; + +use gem_tracing::human_duration; +use rig::agent::PromptResponse; +use rig::completion::Message; + +use crate::replies::{ReplyOutcome, classify_reply}; + +pub(super) enum Status<'a> { + Started, + Succeeded { elapsed: Duration, response: &'a PromptResponse }, + Failed { elapsed: Duration, error: &'a str }, +} + +pub(super) fn status(agent: &str, name: &str, status: Status<'_>) -> String { + match status { + Status::Started => format!(":rocket: {agent} {name} started."), + Status::Succeeded { elapsed, response } => { + let metrics = match (response.turns(), response.tokens()) { + (0, 0) => String::new(), + (turns, 0) => format!(" turns:{turns}."), + (0, tokens) => format!(" tokens:{tokens}."), + (turns, tokens) => format!(" turns:{turns} tokens:{tokens}."), + }; + let summary = response.summary().map(|text| format!(" {text}")).unwrap_or_default(); + format!(":white_check_mark: {agent} {name} succeeded in {}.{metrics}{summary}", human_duration(elapsed)) + } + Status::Failed { elapsed, error } => format!(":x: {agent} {name} failed in {}: {}", human_duration(elapsed), error.trim()), + } +} + +trait PromptResponseExt { + fn turns(&self) -> usize; + fn tokens(&self) -> u64; + fn summary(&self) -> Option; +} + +impl PromptResponseExt for PromptResponse { + fn turns(&self) -> usize { + self.messages + .as_deref() + .unwrap_or_default() + .iter() + .filter(|message| matches!(message, Message::Assistant { .. })) + .count() + } + + fn tokens(&self) -> u64 { + self.usage.total_tokens.max(self.usage.input_tokens + self.usage.output_tokens) + } + + fn summary(&self) -> Option { + let text = match classify_reply(&self.output) { + ReplyOutcome::Tagged(chunks) => chunks.join(" / "), + ReplyOutcome::Untagged(_) | ReplyOutcome::Silent => return None, + }; + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } +} diff --git a/core/apps/agent/src/scheduler/loader.rs b/core/apps/agent/src/scheduler/loader.rs new file mode 100644 index 0000000000..166e8ba844 --- /dev/null +++ b/core/apps/agent/src/scheduler/loader.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::Path; +use std::time::Duration; + +use config::{Config, File, FileFormat}; +use gem_tracing::tracing::error; +use serde::Deserialize; +use serde_serializers::duration; + +use super::ScheduleEntry; + +pub(super) fn load_from_dir(dir: &Path) -> Vec { + let mut entries = Vec::new(); + if dir.exists() { + collect_md(dir, &mut entries); + } + entries +} + +fn collect_md(dir: &Path, entries: &mut Vec) { + let Ok(read) = fs::read_dir(dir) else { + error!(path = %dir.display(), "cannot read schedules dir"); + return; + }; + for f in read.filter_map(|e| e.ok()) { + let path = f.path(); + if path.is_dir() { + collect_md(&path, entries); + } else if path.extension().and_then(|e| e.to_str()) == Some("md") { + match parse_schedule_md(&path) { + Ok(entry) => entries.push(entry), + Err(e) => error!(path = %path.display(), error = %e, "invalid schedule; skipping"), + } + } + } +} + +#[derive(Deserialize)] +struct ScheduleMeta { + name: String, + #[serde(deserialize_with = "duration::deserialize")] + every: Duration, +} + +fn parse_schedule_md(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; + let body = raw.trim_start().strip_prefix("---\n").ok_or("missing `---` frontmatter")?; + let (meta_yaml, prompt) = body.split_once("\n---\n").ok_or("missing closing `---`")?; + let meta: ScheduleMeta = Config::builder() + .add_source(File::from_str(meta_yaml, FileFormat::Yaml)) + .build() + .and_then(|c| c.try_deserialize()) + .map_err(|e| format!("frontmatter: {e}"))?; + Ok(ScheduleEntry { + name: meta.name, + cadence: meta.every, + prompt: prompt.trim().to_string(), + }) +} diff --git a/core/apps/agent/src/scheduler/mod.rs b/core/apps/agent/src/scheduler/mod.rs new file mode 100644 index 0000000000..be41a73e96 --- /dev/null +++ b/core/apps/agent/src/scheduler/mod.rs @@ -0,0 +1,26 @@ +mod format; +mod loader; +mod runner; + +use std::time::Duration; + +use crate::AppState; +use crate::config::Settings; + +pub(crate) struct ScheduleEntry { + pub(crate) name: String, + pub(crate) cadence: Duration, + pub(crate) prompt: String, +} + +pub fn spawn_all(state: AppState) { + let dir = state.settings.agent_dir().join("schedules"); + for entry in loader::load_from_dir(&dir) { + runner::spawn_one(state.clone(), entry); + } +} + +pub(crate) fn load_for(settings: &Settings) -> Vec { + let dir = settings.agent_dir().join("schedules"); + loader::load_from_dir(&dir) +} diff --git a/core/apps/agent/src/scheduler/runner.rs b/core/apps/agent/src/scheduler/runner.rs new file mode 100644 index 0000000000..4f940277a9 --- /dev/null +++ b/core/apps/agent/src/scheduler/runner.rs @@ -0,0 +1,50 @@ +use std::time::Duration; + +use gem_tracing::tracing::{info, warn}; +use tokio::time::{Instant, interval_at}; + +use crate::slack::mrkdwn::to_slack_mrkdwn; +use crate::{AppState, DISPATCH_SOURCE, DispatchSource}; + +use super::ScheduleEntry; +use super::format::{self, Status}; + +pub(super) fn spawn_one(state: AppState, entry: ScheduleEntry) { + tokio::spawn(async move { run_loop(state, entry.name, entry.prompt, entry.cadence).await }); +} + +async fn run_loop(state: AppState, name: String, prompt: String, cadence: Duration) { + info!(schedule = %name, cadence_secs = cadence.as_secs(), "scheduler started"); + let mut tick = interval_at(Instant::now() + cadence, cadence); + loop { + tick.tick().await; + let started = Instant::now(); + info!(schedule = %name, "scheduler firing"); + post_status(&state, &name, Status::Started).await; + let result = DISPATCH_SOURCE.scope(DispatchSource::Scheduled, state.agent.prompt_response(&prompt)).await; + match result { + Ok(response) => { + let elapsed = started.elapsed(); + let reply_chars = response.output.len(); + post_status(&state, &name, Status::Succeeded { elapsed, response: &response }).await; + info!(schedule = %name, reply_chars, "scheduler completed"); + } + Err(e) => { + let elapsed = started.elapsed(); + let error = e.to_string(); + post_status(&state, &name, Status::Failed { elapsed, error: &error }).await; + warn!(schedule = %name, error = %e, "scheduler failed"); + } + } + } +} + +async fn post_status(state: &AppState, schedule: &str, status: Status<'_>) { + let channel = &state.settings.scheduler.status_channel; + let text = format::status(&state.settings.agent_name, schedule, status); + let text = to_slack_mrkdwn(&text); + match state.slack.post_message(channel, None, &text).await { + Ok(ts) => info!(channel = %channel, ts = %ts, "schedule status posted"), + Err(e) => warn!(channel = %channel, error = %e, "schedule status post failed"), + } +} diff --git a/core/apps/agent/src/slack/client.rs b/core/apps/agent/src/slack/client.rs new file mode 100644 index 0000000000..3fa5180988 --- /dev/null +++ b/core/apps/agent/src/slack/client.rs @@ -0,0 +1,120 @@ +use crate::Result; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const API: &str = "https://slack.com/api"; + +pub struct SlackClient { + http: reqwest::Client, + token: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadMessage { + #[serde(default)] + pub user: Option, + #[serde(default)] + pub bot_id: Option, + #[serde(default)] + pub text: String, + #[serde(default)] + pub ts: String, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SlackFile { + #[serde(default)] + pub name: String, + #[serde(default)] + pub mimetype: String, + #[serde(default)] + pub url_private: String, + #[serde(default)] + pub permalink: String, +} + +impl SlackClient { + pub fn new(bot_token: String) -> Self { + Self { + http: reqwest::Client::new(), + token: bot_token, + } + } + pub async fn auth_test_user_id(&self) -> Result { + let resp = self.post("auth.test", &serde_json::json!({})).await?; + Ok(resp.get("user_id").and_then(|v| v.as_str()).unwrap_or_default().to_string()) + } + + pub async fn post_message(&self, channel: &str, thread_ts: Option<&str>, text: &str) -> Result { + #[derive(Serialize)] + struct Req<'a> { + channel: &'a str, + text: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + thread_ts: Option<&'a str>, + } + let resp = self.post("chat.postMessage", &Req { channel, text, thread_ts }).await?; + Ok(resp.get("ts").and_then(|v| v.as_str()).unwrap_or_default().to_string()) + } + + pub async fn conversation_name(&self, channel: &str) -> Result> { + let resp = self.get(&format!("conversations.info?channel={channel}")).await?; + let ch = resp.get("channel").cloned().unwrap_or(Value::Null); + if ch.get("is_im").and_then(|v| v.as_bool()) == Some(true) { + return Ok(None); + } + Ok(ch.get("name").and_then(|v| v.as_str()).map(String::from)) + } + + pub async fn conversations_history(&self, channel: &str, limit: u32) -> Result> { + let resp = self.get(&format!("conversations.history?channel={channel}&limit={limit}")).await?; + let mut messages = parse_messages(&resp); + messages.reverse(); + Ok(messages) + } + + pub async fn conversations_replies(&self, channel: &str, thread_ts: &str, limit: u32) -> Result> { + let resp = self.get(&format!("conversations.replies?channel={channel}&ts={thread_ts}&limit={limit}")).await?; + Ok(parse_messages(&resp)) + } + + pub async fn download_file(&self, url_private: &str, max_bytes: usize) -> Result<(Vec, String)> { + let resp = self.http.get(url_private).bearer_auth(&self.token).send().await?; + let status = resp.status(); + if !status.is_success() { + return Err(format!("slack file download {url_private} failed: {status}").into()); + } + let content_type = resp.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + let bytes = resp.bytes().await?; + if bytes.len() > max_bytes { + return Err(format!("slack file {} too large ({} bytes, cap {})", url_private, bytes.len(), max_bytes).into()); + } + Ok((bytes.to_vec(), content_type)) + } + + async fn get(&self, endpoint: &str) -> Result { + let resp: Value = self.http.get(format!("{API}/{endpoint}")).bearer_auth(&self.token).send().await?.json().await?; + check_ok(endpoint, resp) + } + + async fn post(&self, endpoint: &str, body: &B) -> Result { + let resp: Value = self.http.post(format!("{API}/{endpoint}")).bearer_auth(&self.token).json(body).send().await?.json().await?; + check_ok(endpoint, resp) + } +} + +fn check_ok(endpoint: &str, resp: Value) -> Result { + if resp.get("ok").and_then(|v| v.as_bool()) != Some(true) { + return Err(format!("{endpoint} failed: {resp}").into()); + } + Ok(resp) +} + +fn parse_messages(resp: &Value) -> Vec { + resp.get("messages") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()) + .unwrap_or_default() +} diff --git a/core/apps/agent/src/slack/dispatch.rs b/core/apps/agent/src/slack/dispatch.rs new file mode 100644 index 0000000000..8d18f7bd90 --- /dev/null +++ b/core/apps/agent/src/slack/dispatch.rs @@ -0,0 +1,276 @@ +use crate::Result; +use gem_tracing::tracing::{debug, error, info, warn}; +use serde::Deserialize; +use serde_json::Value; + +use crate::config::ChannelMode; +use crate::images::{ImageAttachment, MAX_IMAGE_BYTES, MAX_IMAGES_PER_PROMPT, image_media_type}; +use crate::replies::{ReplyOutcome, classify_reply}; +use crate::slack::client::SlackFile; +use crate::slack::mrkdwn::to_slack_mrkdwn; +use crate::{AppState, DISPATCH_SOURCE, DispatchSource}; + +#[derive(Debug, Deserialize)] +struct EventEnvelope { + event: SlackEvent, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +enum SlackEvent { + #[serde(rename = "app_mention")] + AppMention(MessageEvent), + #[serde(rename = "message")] + Message(MessageEvent), + #[serde(other)] + Other, +} + +#[derive(Debug, Deserialize)] +struct MessageEvent { + #[serde(default)] + user: Option, + #[serde(default)] + bot_id: Option, + #[serde(default)] + text: String, + channel: String, + #[serde(default, rename = "channel_type")] + channel_type: Option, + ts: String, + #[serde(default)] + thread_ts: Option, + #[serde(default)] + parent_user_id: Option, + #[serde(default)] + subtype: Option, + #[serde(default)] + files: Vec, +} + +impl MessageEvent { + fn is_dm(&self) -> bool { + self.channel_type.as_deref() == Some("im") + } +} + +fn trust_for(settings: &crate::config::Settings, user_id: Option<&str>) -> &'static str { + match user_id { + Some(id) if settings.team.slack_user_ids.iter().any(|u| u == id) => "team", + _ => "external", + } +} + +pub async fn handle_event(state: AppState, payload: Value) -> Result<()> { + let envelope = match serde_json::from_value::(payload) { + Ok(e) => e, + Err(e) => { + debug!(error = %e, "slack envelope didn't parse; dropping"); + return Ok(()); + } + }; + + let (msg, is_mention_event) = match envelope.event { + SlackEvent::AppMention(m) => (m, true), + SlackEvent::Message(m) => (m, false), + SlackEvent::Other => { + debug!("dropping non-message slack event"); + return Ok(()); + } + }; + if msg.bot_id.is_some() || msg.subtype.is_some() { + debug!( + ts = %msg.ts, + bot_id = ?msg.bot_id, + subtype = ?msg.subtype, + "dropping bot-authored or subtype event" + ); + return Ok(()); + } + if msg.user.as_deref() == Some(state.bot_user_id.as_str()) { + debug!(ts = %msg.ts, "dropping self-authored event"); + return Ok(()); + } + if !is_mention_event && msg.text.contains(&format!("<@{}>", state.bot_user_id)) { + debug!(ts = %msg.ts, "skipping message event (duplicate of app_mention)"); + return Ok(()); + } + + let addressed = is_mention_event || msg.is_dm(); + + let location = match state.slack.conversation_name(&msg.channel).await { + Ok(Some(name)) => format!("#{name}"), + Ok(None) if msg.is_dm() => "DM".into(), + Ok(None) => msg.channel.clone(), + Err(e) => { + error!(error = %e, "conversations.info failed"); + msg.channel.clone() + } + }; + let slack_cfg = &state.settings.agent.slack; + if msg.is_dm() && !slack_cfg.dms.allows_incoming(msg.user.as_deref().unwrap_or("")) { + if !slack_cfg.dms.reject_message.is_empty() { + state.slack.post_message(&msg.channel, None, &slack_cfg.dms.reject_message).await?; + } + debug!(user = ?msg.user, "DM from non-allowed user; rejected"); + return Ok(()); + } + if !addressed && slack_cfg.channel_mode(&location) == ChannelMode::Passive && msg.parent_user_id.as_deref() != Some(state.bot_user_id.as_str()) { + debug!( + channel = %location, + ts = %msg.ts, + "passive channel: not addressed and not in own thread; dropping" + ); + return Ok(()); + } + + let stripped = strip_mention(&msg.text); + let latest = if stripped.trim().is_empty() { + if !addressed { + return Ok(()); + } + "(mention-only summons — no body text; answer the actual question from the thread above)".to_string() + } else { + stripped.trim().to_string() + }; + let image_attachments = collect_image_attachments(&state, &msg.files).await; + let user_id = msg.user.as_deref().unwrap_or(""); + let max_turns = state.settings.defaults.max_turns; + let addressed_label = if addressed { "addressed" } else { "listening" }; + let trust = trust_for(&state.settings, msg.user.as_deref()); + let header = format!( + "[Slack — channel: {location}, channel_id: {}, message_ts: {}, user_id: {user_id}, addressed: {addressed_label}, trust: {trust}, max_tool_turns: {max_turns}]", + msg.channel, msg.ts + ); + + let body = build_history(&state, &msg, &latest).await.unwrap_or_else(|e| { + error!(error = %e, "history fetch failed; using latest only"); + latest.clone() + }); + let prompt = format!("{header}\n\n{body}"); + let reply_thread: Option<&str> = msg.thread_ts.as_deref().or_else(|| (!msg.is_dm()).then_some(msg.ts.as_str())); + info!( + channel = %msg.channel, + user = %user_id, + addressed, + thread = ?reply_thread, + images = image_attachments.len(), + "dispatching to rig agent" + ); + + let agent_result = DISPATCH_SOURCE + .scope(DispatchSource::Slack, state.agent.prompt_with_images(&prompt, image_attachments)) + .await; + let raw = match agent_result { + Ok(r) => r, + Err(e) if !addressed => { + warn!(error = %e, "channel-listening sub-agent error (silent)"); + return Ok(()); + } + Err(e) => { + let warning = format!(":warning: sub-agent error: `{e}`"); + state.slack.post_message(&msg.channel, reply_thread, &warning).await?; + return Ok(()); + } + }; + let chunks = match classify_reply(&raw) { + ReplyOutcome::Tagged(chunks) => chunks, + ReplyOutcome::Untagged(text) if msg.is_dm() => { + debug!(raw_chars = raw.len(), "DM without tags; posting raw text"); + vec![text] + } + ReplyOutcome::Untagged(_) => { + warn!(addressed, raw_chars = raw.len(), "model didn't use tags; staying silent"); + return Ok(()); + } + ReplyOutcome::Silent => { + debug!(addressed, raw_chars = raw.len(), "no postable content; staying silent"); + return Ok(()); + } + }; + for chunk in &chunks { + let text = to_slack_mrkdwn(chunk); + state.slack.post_message(&msg.channel, reply_thread, &text).await?; + } + info!(addressed, replies = chunks.len(), "reply posted"); + Ok(()) +} + +async fn build_history(state: &AppState, msg: &MessageEvent, latest: &str) -> Result { + let limit = state.settings.defaults.thread.limit; + let bot_id = state.bot_user_id.as_str(); + + let (label, messages) = match msg.thread_ts.as_deref() { + Some(ts) => ("thread history", state.slack.conversations_replies(&msg.channel, ts, limit).await?), + None if msg.is_dm() => ("DM history", state.slack.conversations_history(&msg.channel, limit).await?), + None => ("channel history (recent)", state.slack.conversations_history(&msg.channel, limit).await?), + }; + + let mut out = format!("Earlier messages in this {label} are below; respond to the LATEST message at the end.\n\n--- {label} ---\n"); + for m in &messages { + if m.ts == msg.ts { + continue; + } + let speaker = if m.bot_id.is_some() || m.user.as_deref() == Some(bot_id) { + "gemmy" + } else { + m.user.as_deref().unwrap_or("user") + }; + let body = strip_mention(&m.text); + let body = body.trim(); + if !body.is_empty() { + out.push_str(&format!("[{speaker}] {body}\n")); + } + for f in &m.files { + out.push_str(&format!( + "[{speaker}] (attached file: {} [{}] — slack permalink: {} — private url: {})\n", + f.name, f.mimetype, f.permalink, f.url_private + )); + } + } + out.push_str(&format!("--- end {label} ---\n\nLatest message to respond to:\n{latest}\n")); + Ok(out) +} +async fn collect_image_attachments(state: &AppState, files: &[SlackFile]) -> Vec { + let mut out = Vec::new(); + for f in files { + let Some(media_type) = image_media_type(&f.mimetype) else { + continue; + }; + if f.url_private.is_empty() { + continue; + } + if out.len() >= MAX_IMAGES_PER_PROMPT { + warn!(file = %f.name, "image cap reached; skipping"); + continue; + } + let (bytes, content_type) = match state.slack.download_file(&f.url_private, MAX_IMAGE_BYTES).await { + Ok(p) => p, + Err(e) => { + warn!(error = %e, file = %f.name, "slack image download failed"); + continue; + } + }; + if !content_type.starts_with("image/") { + warn!( + file = %f.name, + content_type = %content_type, + "slack file download returned non-image content (check files:read scope); skipping" + ); + continue; + } + out.push(ImageAttachment { media_type, bytes }); + } + out +} + +fn strip_mention(text: &str) -> String { + let mut s = text.trim_start(); + while let Some(rest) = s.strip_prefix("<@") { + let Some(end) = rest.find('>') else { + break; + }; + s = rest[end + 1..].trim_start(); + } + s.to_string() +} diff --git a/core/apps/agent/src/slack/mod.rs b/core/apps/agent/src/slack/mod.rs new file mode 100644 index 0000000000..1aaeb9bcbd --- /dev/null +++ b/core/apps/agent/src/slack/mod.rs @@ -0,0 +1,11 @@ +pub mod client; +pub mod dispatch; +pub mod mrkdwn; +pub mod socket; + +pub use client::SlackClient; + +pub fn channel_allowed(channel: &str, allow: &[String]) -> bool { + let needle = channel.trim().trim_start_matches('#'); + allow.iter().any(|a| a.trim().trim_start_matches('#') == needle) +} diff --git a/core/apps/agent/src/slack/mrkdwn.rs b/core/apps/agent/src/slack/mrkdwn.rs new file mode 100644 index 0000000000..cf6f4b502f --- /dev/null +++ b/core/apps/agent/src/slack/mrkdwn.rs @@ -0,0 +1,88 @@ +pub fn to_slack_mrkdwn(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_code = false; + for line in s.split_inclusive('\n') { + if line.trim_start().starts_with("```") { + in_code = !in_code; + out.push_str(line); + continue; + } + if in_code { + out.push_str(line); + continue; + } + let with_links = convert_markdown_links(line); + let mut chars = with_links.chars().peekable(); + while let Some(c) = chars.next() { + if c == '*' && chars.peek() == Some(&'*') { + chars.next(); + out.push('*'); + } else { + out.push(c); + } + } + } + out +} + +fn convert_markdown_links(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some((before, after_open)) = rest.split_once('[') { + out.push_str(before); + if let Some((label, url, tail)) = parse_link(after_open) { + out.push('<'); + out.push_str(url); + out.push('|'); + out.push_str(label); + out.push('>'); + rest = tail; + } else { + out.push('['); + rest = after_open; + } + } + out.push_str(rest); + out +} + +fn parse_link(s: &str) -> Option<(&str, &str, &str)> { + let (label, after) = s.split_once("](")?; + let (url, tail) = after.split_once(')')?; + Some((label, url, tail)) +} + +#[cfg(test)] +mod tests { + use super::to_slack_mrkdwn; + + #[test] + fn bold() { + assert_eq!(to_slack_mrkdwn("**bold**"), "*bold*"); + assert_eq!(to_slack_mrkdwn("a **b c** d"), "a *b c* d"); + } + + #[test] + fn leaves_correct_mrkdwn_alone() { + assert_eq!(to_slack_mrkdwn("*already*"), "*already*"); + assert_eq!(to_slack_mrkdwn("plain text"), "plain text"); + } + + #[test] + fn preserves_code_blocks() { + assert_eq!(to_slack_mrkdwn("```\n**kept inside code**\n```"), "```\n**kept inside code**\n```"); + } + + #[test] + fn converts_markdown_links() { + assert_eq!( + to_slack_mrkdwn("see [#4670](https://example.com/4670) for context"), + "see for context" + ); + assert_eq!(to_slack_mrkdwn("[a](u1) and [b](u2)"), " and "); + assert_eq!(to_slack_mrkdwn("no link here"), "no link here"); + assert_eq!(to_slack_mrkdwn("[bracket only]"), "[bracket only]"); + assert_eq!(to_slack_mrkdwn("**bold** and [link](url)"), "*bold* and "); + assert_eq!(to_slack_mrkdwn("[foo](bar"), "[foo](bar"); + } +} diff --git a/core/apps/agent/src/slack/socket.rs b/core/apps/agent/src/slack/socket.rs new file mode 100644 index 0000000000..2846fb87aa --- /dev/null +++ b/core/apps/agent/src/slack/socket.rs @@ -0,0 +1,98 @@ +use std::time::Duration; + +use crate::Result; +use futures_util::{SinkExt, StreamExt}; +use gem_tracing::tracing::{debug, error, info, warn}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use crate::AppState; +use crate::slack::dispatch; + +const APPS_CONNECTIONS_OPEN: &str = "https://slack.com/api/apps.connections.open"; + +#[derive(Debug, Deserialize)] +struct OpenResponse { + ok: bool, + url: Option, + error: Option, +} + +pub async fn run_forever(state: AppState) -> Result<()> { + let mut backoff = Duration::from_secs(1); + loop { + match connect_once(&state).await { + Ok(()) => { + info!("socket closed cleanly, reconnecting"); + backoff = Duration::from_secs(1); + } + Err(e) => { + error!(error = %e, backoff_secs = backoff.as_secs(), "socket failed; backing off"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + } + } + } +} + +async fn connect_once(state: &AppState) -> Result<()> { + let url = open_socket(&state.settings.slack.app.token).await.map_err(|e| format!("apps.connections.open: {e}"))?; + let (ws, _) = connect_async(&url).await.map_err(|e| format!("websocket connect: {e}"))?; + let (mut tx, mut rx) = ws.split(); + info!("socket mode connected"); + + while let Some(msg) = rx.next().await { + match msg.map_err(|e| format!("ws recv: {e}"))? { + Message::Text(text) => { + let v: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "non-json frame"); + continue; + } + }; + let env_type = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let envelope_id = v.get("envelope_id").and_then(|e| e.as_str()).map(String::from); + debug!(env_type, ?envelope_id, "envelope"); + + match env_type { + "hello" => continue, + "disconnect" => { + let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or(""); + info!(reason, "slack asked us to reconnect"); + return Ok(()); + } + "events_api" => { + if let Some(eid) = envelope_id { + tx.send(Message::Text(json!({"envelope_id": eid}).to_string().into())).await?; + } + let payload = v.get("payload").cloned().unwrap_or(Value::Null); + let state = state.clone(); + tokio::spawn(async move { + if let Err(e) = dispatch::handle_event(state, payload).await { + error!(error = %e, "event handler failed"); + } + }); + } + other => warn!(other, "unhandled envelope type"), + } + } + Message::Ping(p) => tx.send(Message::Pong(p)).await?, + Message::Close(_) => { + info!("slack closed the socket"); + return Ok(()); + } + _ => {} + } + } + Ok(()) +} + +async fn open_socket(app_token: &str) -> Result { + let resp: OpenResponse = reqwest::Client::new().post(APPS_CONNECTIONS_OPEN).bearer_auth(app_token).send().await?.json().await?; + if !resp.ok { + return Err(format!("apps.connections.open: {}", resp.error.unwrap_or_default()).into()); + } + resp.url.ok_or_else(|| "no url in apps.connections.open response".into()) +} diff --git a/core/apps/agent/src/store.rs b/core/apps/agent/src/store.rs new file mode 100644 index 0000000000..4bb810225b --- /dev/null +++ b/core/apps/agent/src/store.rs @@ -0,0 +1,122 @@ +use std::fs; + +use gem_tracing::tracing::info; +use rig::vector_store::request::VectorSearchRequest; +use rig::vector_store::{InsertDocuments, VectorStoreIndex}; +use rig::{Embed, embeddings::EmbeddingsBuilder}; +use rig_fastembed::{Client as FastembedClient, EmbeddingModel as FastembedEmbeddingModel, FastembedModel}; +use rig_sqlite::{Column, ColumnValue, SqliteVectorStore, SqliteVectorStoreTable}; +use serde::{Deserialize, Serialize}; +use tokio_rusqlite::Connection; + +use crate::config::Settings; +use crate::preamble; + +#[derive(Embed, Clone, Debug, Deserialize, Serialize)] +pub struct Memo { + pub id: String, + pub source: String, + #[embed] + pub content: String, +} + +impl SqliteVectorStoreTable for Memo { + fn name() -> &'static str { + "memos" + } + fn schema() -> Vec { + vec![Column::new("id", "TEXT PRIMARY KEY"), Column::new("source", "TEXT"), Column::new("content", "TEXT")] + } + fn id(&self) -> String { + self.id.clone() + } + fn column_values(&self) -> Vec<(&'static str, Box)> { + vec![ + ("id", Box::new(self.id.clone())), + ("source", Box::new(self.source.clone())), + ("content", Box::new(self.content.clone())), + ] + } +} + +pub struct MemoryStore { + store: SqliteVectorStore, + model: FastembedEmbeddingModel, +} + +impl MemoryStore { + pub async fn open_and_index(settings: &Settings) -> crate::Result { + register_sqlite_vec_extension(); + let fastembed_model = pick_fastembed_model(&settings.embedding.model)?; + + let data_dir = settings.data_dir(); + fs::create_dir_all(&data_dir).map_err(|e| format!("create agent data dir: {e}"))?; + let db_path = data_dir.join("index.sqlite"); + info!( + agent = %settings.agent_name, + path = %db_path.display(), + model = %settings.embedding.model, + "opening vector store" + ); + let conn = Connection::open(&db_path).await.map_err(|e| format!("open sqlite: {e}"))?; + + let fastembed_client = FastembedClient::new(); + let model = fastembed_client.embedding_model(&fastembed_model).map_err(|e| format!("loading fastembed model: {e}"))?; + + let store: SqliteVectorStore<_, Memo> = SqliteVectorStore::new(conn, &model).await.map_err(|e| format!("init sqlite store: {e}"))?; + + let docs: Vec = preamble::indexable_files(settings)? + .into_iter() + .map(|f| Memo { + id: f.source.clone(), + source: f.source, + content: f.content, + }) + .collect(); + if !docs.is_empty() { + info!( + agent = %settings.agent_name, + count = docs.len(), + "indexing markdown files" + ); + let embeddings = EmbeddingsBuilder::new(model.clone()) + .documents(docs)? + .build() + .await + .map_err(|e| format!("building embeddings: {e}"))?; + store.insert_documents(embeddings).await.map_err(|e| format!("inserting documents: {e}"))?; + } + Ok(Self { store, model }) + } + + pub async fn search(&self, query: &str, top_n: u32) -> crate::Result> { + let req = VectorSearchRequest::builder().samples(top_n as u64).query(query).build(); + let index = self.store.clone().index(self.model.clone()); + let hits: Vec<(f64, String, Memo)> = index.top_n::(req).await?; + Ok(hits.into_iter().map(|(_, _, m)| m).collect()) + } + + pub async fn save(&self, id: String, source: String, content: String) -> crate::Result<()> { + let memo = Memo { id, source, content }; + let embeddings = EmbeddingsBuilder::new(self.model.clone()).documents(vec![memo])?.build().await?; + self.store.insert_documents(embeddings).await?; + Ok(()) + } +} + +fn pick_fastembed_model(name: &str) -> crate::Result { + match name { + "nomic-embed-text-v1.5-q" => Ok(FastembedModel::NomicEmbedTextV15Q), + other => Err(format!("unsupported embedding.model: `{other}`").into()), + } +} + +fn register_sqlite_vec_extension() { + use rusqlite::ffi::{sqlite3, sqlite3_api_routines, sqlite3_auto_extension}; + use sqlite_vec::sqlite3_vec_init; + type ExtFn = unsafe extern "C" fn(*mut sqlite3, *mut *mut std::os::raw::c_char, *const sqlite3_api_routines) -> i32; + unsafe { + #[allow(clippy::missing_transmute_annotations)] + sqlite3_auto_extension(Some(std::mem::transmute::<*const (), ExtFn>(sqlite3_vec_init as *const ()))); + } +} diff --git a/core/apps/agent/src/tools/chatwoot_account.rs b/core/apps/agent/src/tools/chatwoot_account.rs new file mode 100644 index 0000000000..cd72bd66e9 --- /dev/null +++ b/core/apps/agent/src/tools/chatwoot_account.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; + +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use strum::{Display, EnumIter}; + +use crate::chatwoot::ChatwootClient; +use crate::chatwoot::client::ChatwootConversation; +use crate::tools::{ToolFailure, enum_slugs}; + +#[derive(Clone)] +pub struct ChatwootAccountTool { + pub client: Arc, +} + +#[derive(Debug, Clone, Copy, Deserialize, Display, EnumIter)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ChatwootAccountAction { + List, + Search, + Label, + Summary, +} + +#[derive(Debug, Deserialize)] +pub struct ChatwootAccountArgs { + pub action: ChatwootAccountAction, + #[serde(default)] + pub conversation_id: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub page: Option, + #[serde(default)] + pub query: Option, + #[serde(default)] + pub labels: Option>, + #[serde(default)] + pub since: Option, + #[serde(default)] + pub until: Option, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum ChatwootAccountOutput { + Ok { status: String }, + Conversations(Vec), + Raw(Value), +} + +impl Tool for ChatwootAccountTool { + const NAME: &'static str = "chatwoot_account"; + type Error = ToolFailure; + type Args = ChatwootAccountArgs; + type Output = ChatwootAccountOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Account-wide chatwoot ops that cross conversation boundaries (uses the \ + user access_token). Per-conversation reads/writes, including blocking a contact, \ + belong in chatwoot_conversation. action: \ + list (conversations; optional status open|resolved|pending|snoozed, default open, and page 1-based); \ + search (full-text across conversations + messages, needs query); \ + label (replace a conversation_id's labels with the full slug list); \ + summary (account metrics over a range plus a previous-window block, needs since + until unix timestamps)." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "action": { "type": "string", "enum": enum_slugs::() }, + "conversation_id": { "type": "integer", "description": "Required for action=label." }, + "status": { "type": "string", "description": "Filter for action=list. open|resolved|pending|snoozed." }, + "page": { "type": "integer", "description": "Page number for action=list (1-based)." }, + "query": { "type": "string", "description": "Required for action=search." }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Full label slug list (action=label) — replaces existing labels." + }, + "since": { "type": "integer", "description": "Unix timestamp lower bound. Required for action=summary." }, + "until": { "type": "integer", "description": "Unix timestamp upper bound. Required for action=summary." } + }, + "required": ["action"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let missing = |field: &str| ToolFailure::missing(field, args.action); + match args.action { + ChatwootAccountAction::List => { + let status = args.status.as_deref().unwrap_or("open"); + let page = args.page.unwrap_or(1); + let raw = self.client.list_conversations_as_user(status, page).await?; + Ok(ChatwootAccountOutput::Conversations(raw)) + } + ChatwootAccountAction::Search => { + let query = args.query.ok_or_else(|| missing("query"))?; + Ok(ChatwootAccountOutput::Raw(self.client.search_messages_as_user(&query).await?)) + } + ChatwootAccountAction::Label => { + let id = args.conversation_id.ok_or_else(|| missing("conversation_id"))?; + let labels = args.labels.ok_or_else(|| missing("labels"))?; + self.client.set_labels_as_user(id, labels).await?; + Ok(ChatwootAccountOutput::Ok { + status: format!("labels set on conversation {id}"), + }) + } + ChatwootAccountAction::Summary => { + let since = args.since.ok_or_else(|| missing("since"))?; + let until = args.until.ok_or_else(|| missing("until"))?; + Ok(ChatwootAccountOutput::Raw(self.client.account_summary(since, until).await?)) + } + } + } +} diff --git a/core/apps/agent/src/tools/chatwoot_conversation.rs b/core/apps/agent/src/tools/chatwoot_conversation.rs new file mode 100644 index 0000000000..c8fd7fa7a6 --- /dev/null +++ b/core/apps/agent/src/tools/chatwoot_conversation.rs @@ -0,0 +1,139 @@ +use std::sync::Arc; + +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use strum::{Display, EnumIter}; + +use crate::chatwoot::ChatwootClient; +use crate::chatwoot::client::render_transcript; +use crate::tools::{ToolFailure, enum_slugs}; + +#[derive(Clone)] +pub struct ChatwootConversationTool { + pub client: Arc, +} + +#[derive(Debug, Clone, Copy, Deserialize, Display, EnumIter)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ChatwootConversationAction { + History, + Note, + Reply, + Resolve, + Assign, + Handoff, + Block, +} + +#[derive(Debug, Deserialize)] +pub struct ChatwootConversationArgs { + pub action: ChatwootConversationAction, + pub conversation_id: u64, + #[serde(default)] + pub content: Option, + #[serde(default)] + pub assignee_id: Option, + #[serde(default)] + pub contact_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum ChatwootConversationOutput { + Ok { status: String }, + Transcript(String), +} + +impl Tool for ChatwootConversationTool { + const NAME: &'static str = "chatwoot_conversation"; + type Error = ToolFailure; + type Args = ChatwootConversationArgs; + type Output = ChatwootConversationOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Operations on one chatwoot conversation. action: \ + history (fetch its messages); \ + note (teammate-only internal note, needs content); \ + reply (public customer message, needs content — slack/scheduled dispatch only, rejected on a chatwoot webhook where your text is the reply); \ + resolve (only after the customer confirms or for clear noise, never mid human investigation); \ + assign (to a chatwoot agent id, or unassign by omitting assignee_id); \ + handoff (open + unassign the bot so humans see it when escalating); \ + block (resolve the conversation, then block a scammer/spammer by contact_id from the dispatch header — last resort, admin unblocks in UI, then handoff). \ + On a chatwoot webhook this tool is locked to the dispatched conversation_id; from slack any conversation_id works." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": enum_slugs::() + }, + "conversation_id": { "type": "integer" }, + "content": { "type": "string", "description": "Message body. Required for action=note (private) and action=reply (public)." }, + "assignee_id": { "type": ["integer", "null"], "description": "Chatwoot agent id (action=assign)." }, + "contact_id": { "type": "integer", "description": "Contact id to block (action=block)." } + }, + "required": ["action", "conversation_id"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + use ChatwootConversationAction::*; + let id = args.conversation_id; + if let Some(scope_id) = crate::current_dispatch_conversation_id() + && scope_id != id + { + return Err(ToolFailure::not_allowed(format!( + "conversation_id {id} does not match the dispatched conversation ({scope_id}); you can only act on the conversation you were dispatched for" + ))); + } + let missing = |field: &str| ToolFailure::missing(field, args.action); + let status = match args.action { + History => { + let messages = self.client.messages(id).await?; + return Ok(ChatwootConversationOutput::Transcript(render_transcript(&messages))); + } + Note => { + let content = args.content.ok_or_else(|| missing("content"))?; + self.client.note(id, &content).await?; + format!("private note posted to conversation {id}") + } + Reply => { + if crate::current_dispatch_source() == crate::DispatchSource::Chatwoot { + return Err(ToolFailure::not_allowed( + "on a chatwoot customer dispatch your reply IS your natural-text response (wrapped in tags) — action=reply is only for slack/scheduled dispatch where a teammate asks you to message a specific customer", + )); + } + let content = args.content.ok_or_else(|| missing("content"))?; + self.client.reply(id, &content).await?; + format!("public reply posted to conversation {id}") + } + Resolve => { + self.client.resolve(id).await?; + format!("resolved conversation {id}") + } + Assign => { + self.client.assign(id, args.assignee_id).await?; + format!("assigned conversation {id}") + } + Handoff => { + self.client.open(id).await?; + self.client.assign(id, None).await?; + format!("handed off conversation {id} to humans (status=open, unassigned)") + } + Block => { + let contact_id = args.contact_id.ok_or_else(|| missing("contact_id"))?; + self.client.resolve(id).await?; + self.client.block_contact(contact_id).await?; + format!("resolved conversation {id} and blocked contact {contact_id}") + } + }; + Ok(ChatwootConversationOutput::Ok { status }) + } +} diff --git a/core/apps/agent/src/tools/chatwoot_review_reply.rs b/core/apps/agent/src/tools/chatwoot_review_reply.rs new file mode 100644 index 0000000000..b6c25ba213 --- /dev/null +++ b/core/apps/agent/src/tools/chatwoot_review_reply.rs @@ -0,0 +1,153 @@ +use std::fs; +use std::sync::Arc; + +use gem_tracing::tracing::{debug, warn}; +use rig::agent::Agent as RigAgent; +use rig::client::CompletionClient; +use rig::completion::{Prompt, ToolDefinition}; +use rig::providers::anthropic; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::config::Settings; +use crate::tools::ToolFailure; + +#[derive(Clone)] +pub struct ChatwootReviewReplyTool { + inner: Arc>, +} + +#[derive(Debug, Deserialize)] +pub struct ChatwootReviewReplyArgs { + pub reply: String, + #[serde(default)] + pub conversation_context: Option, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "verdict", rename_all = "lowercase")] +pub enum ChatwootReviewReplyOutput { + Pass, + Rewrite { suggested: String }, +} + +impl ChatwootReviewReplyTool { + pub fn build(settings: &Settings) -> crate::Result> { + let preamble_path = settings.agent_dir().join("supervisor.md"); + if !preamble_path.exists() { + return Ok(None); + } + let preamble = fs::read_to_string(&preamble_path)?; + let provider = settings.llm_provider(); + if provider.key.is_empty() { + return Ok(None); + } + let client = crate::agent::build_client(provider)?; + let model = &settings.agent.model; + let inner = client.agent(model).preamble(&preamble).max_tokens(2048).temperature(0.2).build(); + Ok(Some(Self { inner: Arc::new(inner) })) + } +} + +impl Tool for ChatwootReviewReplyTool { + const NAME: &'static str = "chatwoot_review_reply"; + type Error = ToolFailure; + type Args = ChatwootReviewReplyArgs; + type Output = ChatwootReviewReplyOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Second pair of eyes on a customer-facing reply before you ship it. \ + Pass the proposed reply (the text you would put inside `...`) \ + plus optional conversation context (last few messages). Returns either \ + `verdict: pass` (the reply is fine, ship it as-is) or `verdict: rewrite, \ + suggested: ` (ship the suggested rewrite instead of your \ + original). This is a quality net, not a blocker — it never refuses. Use it \ + on every customer-facing reply before emitting `` tags." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "reply": { + "type": "string", + "description": "The proposed customer-facing reply text (what would go inside ...)." + }, + "conversation_context": { + "type": ["string", "null"], + "description": "Optional. Last few conversation messages so the reviewer can spot repetition, missed context, or repeated greetings." + } + }, + "required": ["reply"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let context = args.conversation_context.as_deref().unwrap_or("(none)"); + let prompt = format!("Conversation context:\n{context}\n\n---\nProposed reply to review:\n{}\n---", args.reply); + let raw = match self.inner.prompt(&prompt).await { + Ok(r) => r, + Err(e) => { + warn!(error = %e, "review_reply call failed; passing original"); + return Ok(ChatwootReviewReplyOutput::Pass); + } + }; + Ok(parse_verdict(&raw)) + } +} + +fn parse_verdict(raw: &str) -> ChatwootReviewReplyOutput { + let trimmed = raw.trim(); + if let Some(rest) = trimmed.strip_prefix("REWRITE") { + let body = rest.trim_start_matches(':').trim(); + if !body.is_empty() { + return ChatwootReviewReplyOutput::Rewrite { suggested: body.to_string() }; + } + } + if !trimmed.eq_ignore_ascii_case("PASS") { + debug!(raw = %trimmed, "review_reply output not PASS or REWRITE:; defaulting to PASS"); + } + ChatwootReviewReplyOutput::Pass +} + +#[cfg(test)] +mod tests { + use super::{ChatwootReviewReplyOutput, parse_verdict}; + + #[test] + fn parses_pass() { + assert!(matches!(parse_verdict("PASS"), ChatwootReviewReplyOutput::Pass)); + assert!(matches!(parse_verdict(" pass "), ChatwootReviewReplyOutput::Pass)); + } + + #[test] + fn parses_rewrite_with_body() { + match parse_verdict("REWRITE: clean text") { + ChatwootReviewReplyOutput::Rewrite { suggested } => assert_eq!(suggested, "clean text"), + _ => panic!("expected rewrite"), + } + } + + #[test] + fn parses_rewrite_multiline() { + match parse_verdict("REWRITE:\nfirst line\nsecond line") { + ChatwootReviewReplyOutput::Rewrite { suggested } => { + assert_eq!(suggested, "first line\nsecond line") + } + _ => panic!("expected rewrite"), + } + } + + #[test] + fn empty_rewrite_falls_back_to_pass() { + assert!(matches!(parse_verdict("REWRITE:"), ChatwootReviewReplyOutput::Pass)); + } + + #[test] + fn unknown_output_defaults_to_pass() { + assert!(matches!(parse_verdict(""), ChatwootReviewReplyOutput::Pass)); + assert!(matches!(parse_verdict("hmm"), ChatwootReviewReplyOutput::Pass)); + } +} diff --git a/core/apps/agent/src/tools/fetch.rs b/core/apps/agent/src/tools/fetch.rs new file mode 100644 index 0000000000..af4b06148b --- /dev/null +++ b/core/apps/agent/src/tools/fetch.rs @@ -0,0 +1,145 @@ +use std::time::Duration; + +use super::ToolFailure; +use reqwest::redirect::Policy; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Clone)] +pub struct FetchTool { + pub client: reqwest::Client, + pub allow: Vec, + pub timeout_secs: u64, + pub max_bytes: usize, +} + +impl FetchTool { + pub fn new(allow: Vec, timeout_secs: u64, max_bytes: usize) -> Self { + // Custom redirect policy: every hop's host must also pass the allowlist. + // Closes the SSRF-via-open-redirect bypass — an attacker who finds an + // open-redirect endpoint on an allowed host can't pivot to internal / + // off-list hosts. + let allow_for_policy = allow.clone(); + let client = reqwest::Client::builder() + .redirect(Policy::custom(move |attempt| { + let host = attempt.url().host_str().unwrap_or(""); + if host_allowed(host, &allow_for_policy) { attempt.follow() } else { attempt.stop() } + })) + .build() + .expect("reqwest::Client::builder failed (rustls + redirect policy)"); + Self { + client, + allow, + timeout_secs, + max_bytes, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FetchArgs { + pub url: String, +} + +#[derive(Debug, Serialize)] +pub struct FetchOutput { + pub status: u16, + pub body: String, + pub truncated: bool, +} + +impl Tool for FetchTool { + const NAME: &'static str = "fetch"; + type Error = ToolFailure; + type Args = FetchArgs; + type Output = FetchOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + let allow = self.allow.join(", "); + ToolDefinition { + name: Self::NAME.to_string(), + description: format!( + "HTTP GET against a strict allowlist of hosts. Use this for any external \ + read — Gem Wallet docs/site, GitHub API/raw, etc. The URL's host must \ + exactly match (or be a subdomain of) one of: {allow}. Anything else \ + fails. Returns the response body as text, truncated. No POST, no headers, \ + no redirects to non-allowed hosts." + ), + parameters: json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Absolute URL to GET. Must use http or https." + } + }, + "required": ["url"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let url = reqwest::Url::parse(&args.url).map_err(|e| ToolFailure::other(format!("invalid url: {e}")))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(ToolFailure::not_allowed(format!("scheme `{}` not allowed", url.scheme()))); + } + let host = url.host_str().ok_or_else(|| ToolFailure::other("url has no host"))?; + if !host_allowed(host, &self.allow) { + return Err(ToolFailure::other(format!("host `{host}` not in fetch.allow allowlist"))); + } + let resp = self + .client + .get(url) + .header(reqwest::header::USER_AGENT, "gemmy-support-bot") + .timeout(Duration::from_secs(self.timeout_secs)) + .send() + .await + .map_err(|e| ToolFailure::other(format!("request failed: {e}")))?; + let status = resp.status().as_u16(); + let bytes = resp.bytes().await.map_err(|e| ToolFailure::other(format!("reading body: {e}")))?; + let truncated = bytes.len() > self.max_bytes; + let slice = if truncated { &bytes[..self.max_bytes] } else { &bytes[..] }; + let body = String::from_utf8_lossy(slice).into_owned(); + Ok(FetchOutput { status, body, truncated }) + } +} + +fn host_allowed(host: &str, allow: &[String]) -> bool { + allow.iter().any(|a| { + let a = a.trim().trim_start_matches("*.").to_lowercase(); + let host = host.to_lowercase(); + host == a || host.ends_with(&format!(".{a}")) + }) +} + +#[cfg(test)] +mod tests { + use super::host_allowed; + + #[test] + fn allows_exact_and_subdomains() { + let allow = vec!["docs.gemwallet.com".to_string(), "gemwallet.com".to_string(), "github.com".to_string()]; + assert!(host_allowed("docs.gemwallet.com", &allow)); + assert!(host_allowed("gemwallet.com", &allow)); + assert!(host_allowed("api.github.com", &allow)); + assert!(host_allowed("raw.githubusercontent.com", &["githubusercontent.com".to_string()])); + } + + #[test] + fn rejects_anything_off_list() { + let allow = vec!["docs.gemwallet.com".to_string()]; + assert!(!host_allowed("evil.com", &allow)); + assert!(!host_allowed("docs.gemwallet.com.evil.com", &allow)); + assert!(!host_allowed("gemwallet.com", &allow)); + } + + #[test] + fn star_prefix_is_normalised() { + let allow = vec!["*.gemwallet.com".to_string()]; + assert!(host_allowed("docs.gemwallet.com", &allow)); + assert!(host_allowed("gemwallet.com", &allow)); + assert!(!host_allowed("evil.com", &allow)); + } +} diff --git a/core/apps/agent/src/tools/gem_api.rs b/core/apps/agent/src/tools/gem_api.rs new file mode 100644 index 0000000000..b8848c141c --- /dev/null +++ b/core/apps/agent/src/tools/gem_api.rs @@ -0,0 +1,156 @@ +use std::time::Duration; + +use super::ToolFailure; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use strum::{Display, EnumIter}; + +use crate::tools::enum_slugs; + +const BASE_URL: &str = "https://api.gemwallet.com"; + +#[derive(Clone)] +pub struct GemApiTool { + pub client: reqwest::Client, + pub timeout_secs: u64, +} + +#[derive(Debug, Clone, Copy, Deserialize, Display, EnumIter)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum GemApiAction { + Transaction, + AddressBalances, + AddressAssets, + AddressTransactions, + AddressNfts, + StakingValidators, + StakingApy, + Asset, + Nft, + NftCollection, +} + +#[derive(Debug, Deserialize)] +pub struct GemApiArgs { + pub action: GemApiAction, + #[serde(default)] + pub chain: Option, + #[serde(default)] + pub hash: Option, + #[serde(default)] + pub address: Option, + #[serde(default)] + pub asset_id: Option, + #[serde(default)] + pub collection_id: Option, + #[serde(default)] + pub from_timestamp: Option, +} + +#[derive(Debug, Serialize)] +pub struct GemApiOutput(Value); + +impl Tool for GemApiTool { + const NAME: &'static str = "gem_api"; + type Error = ToolFailure; + type Args = GemApiArgs; + type Output = GemApiOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Query the public Gem Wallet API at api.gemwallet.com (same backend the \ + apps use, normalized across chains) for diagnostic checks — faster than per-chain \ + explorers. It's Gem's indexer: if it disagrees with the chain, the chain wins \ + (flag the gap), and for load-bearing claims the on-chain receipt is canonical. \ + uses Gem's canonical chain ids (see chains.md); is the canonical \ + asset id (ethereum, ethereum_0x… for ERC-20s, solana_… for SPL). action: \ + transaction (full tx record, needs chain + hash); \ + address_balances (native + staking-with-delegation + assets in one payload, needs chain + address); \ + address_assets (token balances only, needs chain + address); \ + address_transactions (recent txs, needs chain + address, optional from_timestamp unix seconds); \ + address_nfts (owned NFTs, needs chain + address); \ + staking_validators (needs chain); staking_apy (needs chain); \ + asset (metadata/price/supply, needs asset_id); \ + nft (needs asset_id); nft_collection (needs collection_id)." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "action": { "type": "string", "enum": enum_slugs::() }, + "chain": { "type": "string" }, + "hash": { "type": "string" }, + "address": { "type": "string" }, + "asset_id": { "type": "string" }, + "collection_id": { "type": "string" }, + "from_timestamp": { "type": "integer", "description": "Unix seconds lower bound for action=address_transactions." } + }, + "required": ["action"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + use GemApiAction::*; + let missing = |field: &str| ToolFailure::missing(field, args.action); + let chain = || args.chain.as_deref().ok_or_else(|| missing("chain")); + let address = || args.address.as_deref().ok_or_else(|| missing("address")); + let url = match args.action { + Transaction => { + let hash = args.hash.as_deref().ok_or_else(|| missing("hash"))?; + format!("{BASE_URL}/v1/chain/transactions/{}/{hash}", chain()?) + } + AddressBalances => { + format!("{BASE_URL}/v1/chain/address/{}/{}/balances", chain()?, address()?) + } + AddressAssets => { + format!("{BASE_URL}/v1/chain/address/{}/{}/assets", chain()?, address()?) + } + AddressTransactions => { + let mut url = format!("{BASE_URL}/v1/chain/address/{}/{}/transactions", chain()?, address()?); + if let Some(ts) = args.from_timestamp { + url.push_str(&format!("?from_timestamp={ts}")); + } + url + } + AddressNfts => { + format!("{BASE_URL}/v1/chain/address/{}/{}/nfts", chain()?, address()?) + } + StakingValidators => { + format!("{BASE_URL}/v1/chain/staking/{}/validators", chain()?) + } + StakingApy => { + format!("{BASE_URL}/v1/chain/staking/{}/apy", chain()?) + } + Asset => { + let id = args.asset_id.as_deref().ok_or_else(|| missing("asset_id"))?; + format!("{BASE_URL}/v1/assets/{id}") + } + Nft => { + let id = args.asset_id.as_deref().ok_or_else(|| missing("asset_id"))?; + format!("{BASE_URL}/v1/chain/nft/assets/{id}") + } + NftCollection => { + let id = args.collection_id.as_deref().ok_or_else(|| missing("collection_id"))?; + format!("{BASE_URL}/v1/chain/nft/collections/{id}") + } + }; + let resp = self + .client + .get(&url) + .timeout(Duration::from_secs(self.timeout_secs)) + .send() + .await + .map_err(|e| ToolFailure::other(format!("{url}: {e}")))?; + let status = resp.status(); + let body = resp.text().await.map_err(|e| ToolFailure::other(format!("{url}: read body: {e}")))?; + if !status.is_success() { + return Err(ToolFailure::other(format!("{url} failed: {status}: {body}"))); + } + let value: Value = serde_json::from_str(&body).map_err(|e| ToolFailure::other(format!("{url} returned non-json: {e}: {body}")))?; + Ok(GemApiOutput(value)) + } +} diff --git a/core/apps/agent/src/tools/gem_docs.rs b/core/apps/agent/src/tools/gem_docs.rs new file mode 100644 index 0000000000..784effb931 --- /dev/null +++ b/core/apps/agent/src/tools/gem_docs.rs @@ -0,0 +1,98 @@ +use std::time::Duration; + +use super::ToolFailure; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use strum::{Display, EnumIter}; + +use crate::tools::enum_slugs; + +const BASE_URL: &str = "https://docs.gemwallet.com"; + +#[derive(Clone)] +pub struct GemDocsTool { + pub client: reqwest::Client, + pub timeout_secs: u64, +} + +#[derive(Debug, Clone, Copy, Deserialize, Display, EnumIter)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum GemDocsAction { + Index, + Page, +} + +#[derive(Debug, Deserialize)] +pub struct GemDocsArgs { + pub action: GemDocsAction, + #[serde(default)] + pub path: Option, +} + +#[derive(Debug, Serialize)] +pub struct GemDocsOutput { + pub content: String, +} + +impl Tool for GemDocsTool { + const NAME: &'static str = "gem_docs"; + type Error = ToolFailure; + type Args = GemDocsArgs; + type Output = GemDocsOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Read the public Gem Wallet docs at docs.gemwallet.com (code in \ + gemwalletcom/core / wallet is authoritative when docs lag — flag the gap). action: \ + index (the full page map of titles + paths, to discover what exists); \ + page (fetch one page as markdown — `path` is the page path without extension, e.g. \ + blockchains/solana or guides/gem-wallet-basics; blockchain pages are \ + blockchains/ keyed off chains.md, so construct them directly without the index). \ + When citing to a customer, link the human-facing page (no extension), never the raw markdown URL." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "action": { "type": "string", "enum": enum_slugs::() }, + "path": { "type": "string", "description": "Page path without extension. Required for action=page." } + }, + "required": ["action"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let url = match args.action { + GemDocsAction::Index => format!("{BASE_URL}/llms.txt"), + GemDocsAction::Page => { + let path = args + .path + .as_deref() + .ok_or_else(|| ToolFailure::missing("path", "page"))? + .trim() + .trim_matches('/') + .trim_end_matches(".md"); + format!("{BASE_URL}/{path}.md") + } + }; + let resp = self + .client + .get(&url) + .timeout(Duration::from_secs(self.timeout_secs)) + .send() + .await + .map_err(|e| ToolFailure::other(format!("{url}: {e}")))?; + let status = resp.status(); + let content = resp.text().await.map_err(|e| ToolFailure::other(format!("{url}: read body: {e}")))?; + if !status.is_success() { + return Err(ToolFailure::other(format!( + "{url} failed: {status} — page may not exist; check action=index for the right path" + ))); + } + Ok(GemDocsOutput { content }) + } +} diff --git a/core/apps/agent/src/tools/memory.rs b/core/apps/agent/src/tools/memory.rs new file mode 100644 index 0000000000..693c4682b3 --- /dev/null +++ b/core/apps/agent/src/tools/memory.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; + +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::store::MemoryStore; +use crate::tools::ToolFailure; + +#[derive(Clone)] +pub struct SearchMemoryTool { + pub store: Arc, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SearchMemoryArgs { + pub query: String, + #[serde(default)] + pub top_n: Option, +} + +#[derive(Debug, Serialize)] +pub struct SearchMemoryHit { + pub source: String, + pub content: String, +} + +impl Tool for SearchMemoryTool { + const NAME: &'static str = "search_memory"; + type Error = ToolFailure; + type Args = SearchMemoryArgs; + type Output = Vec; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Semantic search over the long-term memory store \ + (indexed `context/*.md` + `agents//*.md` + `agents//memory/*.md` \ + plus runtime-saved entries). Returns the top-N most relevant docs \ + (source path + full content). Use when a question hints at internal Gem \ + knowledge but you don't know which exact file holds it. For known files, \ + `cat` is faster." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "What you're trying to find out." }, + "top_n": { "type": "integer", "default": 3, "description": "How many hits to return." } + }, + "required": ["query"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let n = args.top_n.unwrap_or(3); + let hits = self.store.search(&args.query, n).await?; + Ok(hits + .into_iter() + .map(|m| SearchMemoryHit { + source: m.source, + content: m.content, + }) + .collect()) + } +} + +#[derive(Clone)] +pub struct SaveMemoryTool { + pub store: Arc, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SaveMemoryArgs { + pub id: String, + pub content: String, +} + +impl Tool for SaveMemoryTool { + const NAME: &'static str = "save_memory"; + type Error = ToolFailure; + type Args = SaveMemoryArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Persist a durable note into long-term memory (sqlite vector store). \ + Pick a stable kebab-case `id`; reusing an `id` overwrites the previous entry. \ + `content` is the text to remember, formatted as Markdown." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "kebab-case slug" }, + "content": { "type": "string", "description": "the fact, 1-3 sentences" } + }, + "required": ["id", "content"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let id = args.id.clone(); + self.store.save(args.id, "runtime".to_string(), args.content).await?; + Ok(format!("saved memo `{}` to long-term memory", id)) + } +} diff --git a/core/apps/agent/src/tools/mod.rs b/core/apps/agent/src/tools/mod.rs new file mode 100644 index 0000000000..71325f85ee --- /dev/null +++ b/core/apps/agent/src/tools/mod.rs @@ -0,0 +1,164 @@ +pub mod chatwoot_account; +pub mod chatwoot_conversation; +pub mod chatwoot_review_reply; +pub mod fetch; +pub mod gem_api; +pub mod gem_docs; +pub mod memory; +pub mod plausible; +pub mod shell; +pub mod slack_history; +pub mod slack_post; +pub mod telegram_post; + +pub use chatwoot_account::ChatwootAccountTool; +pub use chatwoot_conversation::ChatwootConversationTool; +pub use chatwoot_review_reply::ChatwootReviewReplyTool; +pub use fetch::FetchTool; +pub use gem_api::GemApiTool; +pub use gem_docs::GemDocsTool; +pub use memory::{SaveMemoryTool, SearchMemoryTool}; +pub use plausible::PlausibleTool; +pub use shell::ShellTool; +pub use slack_history::SlackHistoryTool; +pub use slack_post::SlackPostTool; +pub use telegram_post::TelegramPostTool; + +use core::fmt::Display; +use std::pin::Pin; + +use gem_tracing::tracing::info; +use rig::completion::ToolDefinition; +use rig::tool::{ToolDyn, ToolError}; +use serde::Deserialize; +use strum::{Display as StrumDisplay, IntoEnumIterator}; + +use crate::{DispatchSource, current_dispatch_source}; + +#[derive(Debug, Deserialize, StrumDisplay, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum ToolName { + Shell, + Fetch, + SearchMemory, + SaveMemory, + ChatwootConversation, + ChatwootAccount, + GemApi, + GemDocs, + SlackPost, + SlackHistory, + TelegramPost, + Plausible, + ChatwootReviewReply, +} + +/// Lowercase-string slugs for every variant of an enum, used to populate the +/// `enum` field of a JSON-schema tool definition. +pub fn enum_slugs() -> Vec { + T::iter().map(|v| v.to_string()).collect() +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ToolEntry { + pub name: ToolName, + pub allow_sources: Vec, +} + +impl ToolEntry { + pub fn policy(&self) -> ToolPolicy { + ToolPolicy { + allow_sources: self.allow_sources.clone(), + } + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ToolPolicy { + pub allow_sources: Vec, +} + +impl ToolPolicy { + pub fn gate(&self, source: DispatchSource) -> Result<(), String> { + if self.allow_sources.contains(&source) { + Ok(()) + } else { + Err(format!("tool refused: dispatch source `{source}` is not in this tool's allow_sources",)) + } + } +} + +/// Wraps any `Box` with a `ToolPolicy` gate. The wrapped tool +/// inspects the current dispatch source at call time and refuses if the source +/// isn't in the policy's allow list — tools themselves stay unaware of policy. +pub struct GatedTool { + pub inner: Box, + pub policy: ToolPolicy, +} + +#[derive(Debug)] +pub enum ToolFailure { + MissingField(String), + NotAllowed(String), + Other(String), +} + +impl ToolFailure { + pub fn missing(field: &str, action: impl std::fmt::Display) -> Self { + Self::MissingField(format!("`{field}` is required for action=`{action}`")) + } + + pub fn not_allowed(message: impl Into) -> Self { + Self::NotAllowed(message.into()) + } + + pub fn other(message: impl Into) -> Self { + Self::Other(message.into()) + } +} + +impl std::fmt::Display for ToolFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingField(message) | Self::NotAllowed(message) | Self::Other(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ToolFailure {} + +impl From for ToolFailure { + fn from(error: std::io::Error) -> Self { + Self::Other(error.to_string()) + } +} + +impl From for ToolFailure { + fn from(error: crate::Error) -> Self { + Self::Other(error.to_string()) + } +} + +impl ToolDyn for GatedTool { + fn name(&self) -> String { + self.inner.name() + } + + fn definition<'a>(&'a self, prompt: String) -> Pin + Send + 'a>> { + self.inner.definition(prompt) + } + + fn call<'a>(&'a self, args: String) -> Pin> + Send + 'a>> { + let source = current_dispatch_source(); + if let Err(msg) = self.policy.gate(source) { + return Box::pin(async move { + Err(ToolError::ToolCallError(Box::new(ToolFailure::not_allowed(format!( + "tool refused by dispatch policy: {msg}" + ))))) + }); + } + info!(tool = %self.inner.name(), %args, "tool call"); + self.inner.call(args) + } +} diff --git a/core/apps/agent/src/tools/plausible.rs b/core/apps/agent/src/tools/plausible.rs new file mode 100644 index 0000000000..b707b4c3de --- /dev/null +++ b/core/apps/agent/src/tools/plausible.rs @@ -0,0 +1,107 @@ +use super::ToolFailure; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Clone)] +pub struct PlausibleTool { + pub client: reqwest::Client, + pub base_url: String, + pub api_key: String, + pub sites: Vec, + pub timeout_secs: u64, +} + +#[derive(Debug, Deserialize)] +pub struct PlausibleArgs { + pub site_id: String, + pub metrics: Vec, + #[serde(default)] + pub dimensions: Vec, + #[serde(default)] + pub date_range: Option, + #[serde(default)] + pub filters: Option, +} + +#[derive(Debug, Serialize)] +pub struct PlausibleOutput(Value); + +impl Tool for PlausibleTool { + const NAME: &'static str = "plausible"; + type Error = ToolFailure; + type Args = PlausibleArgs; + type Output = PlausibleOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + let sites = self.sites.join(", "); + ToolDefinition { + name: Self::NAME.to_string(), + description: format!( + "Query the Plausible analytics Stats API. `site_id` must be one of the tracked \ + domains ({sites}); anything else is rejected at the tool boundary. `metrics` is \ + required (e.g. `visitors`, `pageviews`, `visits`, `bounce_rate`, \ + `visit_duration`, `events`). `dimensions` optionally groups results (e.g. \ + `visit:source`, `event:page`, `visit:country`). `date_range` is a string like \ + `7d`, `30d`, `month`, `12mo`, or a `[start, end]` ISO pair (default `7d`). \ + `filters` is the Plausible v2 filter array if you need to scope results." + ), + parameters: json!({ + "type": "object", + "properties": { + "site_id": { "type": "string", "description": "Plausible site id (domain)." }, + "metrics": { + "type": "array", + "items": { "type": "string" }, + "description": "Plausible metrics to aggregate." + }, + "dimensions": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional dimensions to group by." + }, + "date_range": { "type": "string", "description": "Date range, default `7d`." }, + "filters": { "description": "Optional Plausible v2 filter array." } + }, + "required": ["site_id", "metrics"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + if self.api_key.is_empty() { + return Err(ToolFailure::not_allowed("plausible api key not configured")); + } + if !self.sites.contains(&args.site_id) { + return Err(ToolFailure::not_allowed(format!( + "site_id `{}` is not in the configured plausible.sites allow-list", + args.site_id + ))); + } + let body = json!({ + "site_id": args.site_id, + "metrics": args.metrics, + "dimensions": args.dimensions, + "date_range": args.date_range.as_deref().unwrap_or("7d"), + "filters": args.filters.unwrap_or(Value::Array(vec![])), + }); + let url = format!("{}/api/v2/query", self.base_url.trim_end_matches('/')); + let resp = self + .client + .post(&url) + .timeout(std::time::Duration::from_secs(self.timeout_secs)) + .bearer_auth(&self.api_key) + .json(&body) + .send() + .await + .map_err(|e| ToolFailure::other(format!("plausible request: {e}")))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(ToolFailure::other(format!("plausible {status}: {text}"))); + } + let value: Value = serde_json::from_str(&text).map_err(|e| ToolFailure::other(format!("plausible non-json: {e}")))?; + Ok(PlausibleOutput(value)) + } +} diff --git a/core/apps/agent/src/tools/shell.rs b/core/apps/agent/src/tools/shell.rs new file mode 100644 index 0000000000..50e3162462 --- /dev/null +++ b/core/apps/agent/src/tools/shell.rs @@ -0,0 +1,107 @@ +use std::process::Stdio; +use std::time::Duration; + +use gem_tracing::tracing::debug; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::io::AsyncWriteExt; + +use super::ToolFailure; + +#[derive(Clone)] +pub struct ShellTool { + pub workdir: String, + pub timeout_secs: u64, + pub allow: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ShellArgs { + pub argv: Vec, + #[serde(default)] + pub stdin: Option, +} + +#[derive(Debug, Serialize)] +pub struct ShellOutput { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +impl Tool for ShellTool { + const NAME: &'static str = "shell"; + type Error = ToolFailure; + type Args = ShellArgs; + type Output = ShellOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Run an allowlisted binary directly (no shell). Pass `argv` as an array \ + where `argv[0]` is the program (must be in `agent.shell.allow`) and the rest are \ + its arguments. There is no shell interpretation — no pipes, no redirects, no \ + `&&`/`||`/`;`, no `$VAR` expansion, no globs. To chain or pipe, call the tool \ + multiple times and process output in-context. Returns stdout, stderr, and exit \ + code. Be specific — broad commands waste tokens." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "argv": { + "type": "array", + "items": { "type": "string" }, + "description": "Program + args, no shell. e.g. [\"git\", \"log\", \"--grep=foo\", \"--since=3 months ago\"]." + }, + "stdin": { + "type": "string", + "description": "Optional stdin payload." + } + }, + "required": ["argv"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let program = args.argv.first().ok_or_else(|| ToolFailure::other("argv must be non-empty"))?.clone(); + debug!(program = %program, argc = args.argv.len(), "shell tool"); + if !self.allow.iter().any(|a| a == &program) { + return Err(ToolFailure::not_allowed(format!("command not allowed: `{program}` (not in agent.shell.allow)"))); + } + + let mut cmd = tokio::process::Command::new(&program); + cmd.args(&args.argv[1..]) + .current_dir(&self.workdir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + if let (Some(stdin_payload), Some(mut child_stdin)) = (args.stdin, child.stdin.take()) { + child_stdin.write_all(stdin_payload.as_bytes()).await?; + child_stdin.shutdown().await?; + } + + let output = tokio::time::timeout(Duration::from_secs(self.timeout_secs), child.wait_with_output()) + .await + .map_err(|_| ToolFailure::other(format!("timed out after {}s", self.timeout_secs)))??; + Ok(ShellOutput { + stdout: truncate(&String::from_utf8_lossy(&output.stdout), 60_000), + stderr: truncate(&String::from_utf8_lossy(&output.stderr), 8_000), + exit_code: output.status.code().unwrap_or(-1), + }) + } +} + +fn truncate(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let head: String = s.chars().take(max_chars).collect(); + let total = s.chars().count(); + format!("{head}\n…[truncated, {total} chars total]") + } +} diff --git a/core/apps/agent/src/tools/slack_history.rs b/core/apps/agent/src/tools/slack_history.rs new file mode 100644 index 0000000000..d104d663ce --- /dev/null +++ b/core/apps/agent/src/tools/slack_history.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use super::ToolFailure; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::slack::SlackClient; +use crate::slack::channel_allowed; + +#[derive(Clone)] +pub struct SlackHistoryTool { + pub client: Arc, + pub allow_channels: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct SlackHistoryArgs { + pub channel: String, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct SlackHistoryOutput { + pub messages: Vec, +} + +#[derive(Debug, Serialize)] +pub struct SlackHistoryEntry { + pub ts: String, + pub user: Option, + pub bot_id: Option, + pub text: String, +} + +impl Tool for SlackHistoryTool { + const NAME: &'static str = "slack_history"; + type Error = ToolFailure; + type Args = SlackHistoryArgs; + type Output = SlackHistoryOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + let allow = self.allow_channels.join(", "); + ToolDefinition { + name: Self::NAME.to_string(), + description: format!( + "Read recent messages from a Slack channel via conversations.history. \ + The target channel must be in the configured allow-list ({allow}). \ + Returns the most recent N messages (default 50, max 200) with `ts`, `user`, \ + `bot_id`, and `text` for each. Each `ts` doubles as the thread parent if you \ + want to reply in that thread via `slack_post thread_ts=`. Top-level \ + messages have no `thread_ts` of their own; replies inside threads carry the \ + parent's `ts` in their text context but you'll get them mixed with top-level \ + in the same recency-ordered stream." + ), + parameters: json!({ + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Channel id or `#name`. Must be in the allow-list." + }, + "limit": { + "type": ["integer", "null"], + "description": "How many recent messages to return. Default 50, max 200." + } + }, + "required": ["channel"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + if !channel_allowed(&args.channel, &self.allow_channels) { + return Err(ToolFailure::not_allowed(format!("channel `{}` not in slack.allow_channels allow-list", args.channel))); + } + let limit = args.limit.unwrap_or(50).clamp(1, 200); + let messages = self + .client + .conversations_history(&args.channel, limit) + .await + .map_err(|e| ToolFailure::other(e.to_string()))? + .into_iter() + .map(|m| SlackHistoryEntry { + ts: m.ts, + user: m.user, + bot_id: m.bot_id, + text: m.text, + }) + .collect(); + Ok(SlackHistoryOutput { messages }) + } +} diff --git a/core/apps/agent/src/tools/slack_post.rs b/core/apps/agent/src/tools/slack_post.rs new file mode 100644 index 0000000000..bc6e336a4a --- /dev/null +++ b/core/apps/agent/src/tools/slack_post.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; + +use super::ToolFailure; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::slack::SlackClient; +use crate::slack::channel_allowed; +use crate::slack::mrkdwn::to_slack_mrkdwn; + +#[derive(Clone)] +pub struct SlackPostTool { + pub client: Arc, + pub allow_channels: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct SlackPostArgs { + pub channel: String, + pub text: String, + #[serde(default)] + pub thread_ts: Option, +} + +#[derive(Debug, Serialize)] +pub struct SlackPostOutput { + pub status: String, +} + +impl Tool for SlackPostTool { + const NAME: &'static str = "slack_post"; + type Error = ToolFailure; + type Args = SlackPostArgs; + type Output = SlackPostOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + let allow = self.allow_channels.join(", "); + ToolDefinition { + name: Self::NAME.to_string(), + description: format!( + "Post a message to a Slack channel via the bot's chat.postMessage. The \ + target channel must be in the configured allow-list ({allow}); anything \ + else is refused at the tool boundary. Use for bridging an escalated \ + chatwoot conversation into team awareness. Pass `thread_ts` to reply inside \ + an existing Slack thread (avoids creating duplicate top-level threads for \ + the same chatwoot conversation); omit it for a brand-new thread. The Slack \ + ts of the posted message is returned in `status` so you can save it via \ + `save_memory` and reuse it on follow-up escalations." + ), + parameters: json!({ + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Channel id or `#name`. Must be in the allow-list." + }, + "text": { + "type": "string", + "description": "Message body. Plain text or Slack mrkdwn." + }, + "thread_ts": { + "type": ["string", "null"], + "description": "Optional. Slack message ts of the thread parent. When set, this post is a reply in that thread; when omitted, a new top-level thread is created." + } + }, + "required": ["channel", "text"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + if !args.channel.starts_with('U') && !channel_allowed(&args.channel, &self.allow_channels) { + return Err(ToolFailure::not_allowed(format!("channel `{}` not in slack.channels allow-list", args.channel))); + } + let text = to_slack_mrkdwn(&args.text); + let posted_ts = self + .client + .post_message(&args.channel, args.thread_ts.as_deref(), &text) + .await + .map_err(|e| ToolFailure::other(e.to_string()))?; + let status = match args.thread_ts { + Some(parent) => format!("posted to {} in thread {parent} (this reply ts: {posted_ts})", args.channel), + None => format!("posted to {} (thread ts: {posted_ts})", args.channel), + }; + Ok(SlackPostOutput { status }) + } +} + +#[cfg(test)] +mod tests { + use crate::slack::channel_allowed; + + #[test] + fn matches_with_and_without_hash() { + let allow = vec!["#support".to_string()]; + assert!(channel_allowed("#support", &allow)); + assert!(channel_allowed("support", &allow)); + assert!(!channel_allowed("#general", &allow)); + assert!(!channel_allowed("supports", &allow)); + } +} diff --git a/core/apps/agent/src/tools/telegram_post.rs b/core/apps/agent/src/tools/telegram_post.rs new file mode 100644 index 0000000000..4c26394088 --- /dev/null +++ b/core/apps/agent/src/tools/telegram_post.rs @@ -0,0 +1,125 @@ +use std::time::Duration; + +use super::ToolFailure; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Clone)] +pub struct TelegramPostTool { + pub client: reqwest::Client, + pub bot_token: String, + pub allow_chats: Vec, + pub timeout_secs: u64, +} + +#[derive(Debug, Deserialize)] +pub struct TelegramPostArgs { + pub chat: String, + pub text: String, + #[serde(default)] + pub parse_mode: Option, + #[serde(default)] + pub disable_web_page_preview: Option, +} + +#[derive(Debug, Serialize)] +pub struct TelegramPostOutput { + pub status: String, +} + +impl Tool for TelegramPostTool { + const NAME: &'static str = "telegram_post"; + type Error = ToolFailure; + type Args = TelegramPostArgs; + type Output = TelegramPostOutput; + + async fn definition(&self, _: String) -> ToolDefinition { + let allow = self.allow_chats.join(", "); + ToolDefinition { + name: Self::NAME.to_string(), + description: format!( + "Send a message to a Telegram chat via the Bot API. `chat` must be in the \ + configured allow-list ({allow}); anything else is refused at the tool boundary. \ + Use for outgoing growth/community posts. Supports `parse_mode` (`HTML` or \ + `MarkdownV2`) and `disable_web_page_preview` (default true)." + ), + parameters: json!({ + "type": "object", + "properties": { + "chat": { + "type": "string", + "description": "Telegram chat id (numeric like -100…) or @channelusername. Must be in the allow-list." + }, + "text": { + "type": "string", + "description": "Message body. Plain text by default; set parse_mode for HTML/MarkdownV2." + }, + "parse_mode": { + "type": "string", + "enum": ["HTML", "MarkdownV2"], + "description": "Optional. If set, Telegram interprets the text accordingly." + }, + "disable_web_page_preview": { + "type": "boolean", + "description": "Default true. Set false to allow link previews." + } + }, + "required": ["chat", "text"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + if !chat_allowed(&args.chat, &self.allow_chats) { + return Err(ToolFailure::not_allowed(format!("chat `{}` not in telegram.allow_chats allow-list", args.chat))); + } + if self.bot_token.is_empty() { + return Err(ToolFailure::not_allowed("telegram.bot.token not configured in this deployment")); + } + let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token); + let body = json!({ + "chat_id": args.chat, + "text": args.text, + "parse_mode": args.parse_mode, + "disable_web_page_preview": args.disable_web_page_preview.unwrap_or(true), + }); + let resp = self + .client + .post(&url) + .json(&body) + .timeout(Duration::from_secs(self.timeout_secs)) + .send() + .await + .map_err(|e| ToolFailure::other(format!("request failed: {e}")))?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(ToolFailure::other(format!("telegram api returned {status}: {text}"))); + } + Ok(TelegramPostOutput { + status: format!("posted to {}", args.chat), + }) + } +} + +fn chat_allowed(chat: &str, allow: &[String]) -> bool { + let needle = chat.trim(); + allow.iter().any(|a| a.trim() == needle) +} + +#[cfg(test)] +mod tests { + use super::chat_allowed; + + #[test] + fn exact_match_only() { + let allow = vec!["@gemwallet".to_string(), "-1001234567890".to_string()]; + assert!(chat_allowed("@gemwallet", &allow)); + assert!(chat_allowed("-1001234567890", &allow)); + assert!(!chat_allowed("@gemwallet_dev", &allow)); + assert!(!chat_allowed("-1009999999999", &allow)); + assert!(!chat_allowed("gemwallet", &allow)); + } +} diff --git a/core/apps/api/Cargo.toml b/core/apps/api/Cargo.toml new file mode 100644 index 0000000000..7ed46b049c --- /dev/null +++ b/core/apps/api/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "api" +edition = { workspace = true } +version = { workspace = true } + +[lib] +path = "src/lib.rs" + +[dependencies] +rocket = { workspace = true } +rocket_ws = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +config = { workspace = true } +prometheus-client = { workspace = true } +strum = { workspace = true } +redis = { workspace = true } +chrono = { workspace = true } +futures = { workspace = true } +gem_hash = { path = "../../crates/gem_hash" } +hex = { workspace = true } +reqwest = { workspace = true } + +gem_tracing = { path = "../../crates/tracing" } +storage = { path = "../../crates/storage" } +pricer = { path = "../../crates/pricer" } +prices = { path = "../../crates/prices" } +fiat = { path = "../../crates/fiat" } +settings = { path = "../../crates/settings" } +settings_chain = { path = "../../crates/settings_chain" } +name_resolver = { path = "../../crates/name_resolver" } +in_app_notifications = { path = "../../crates/in_app_notifications" } +localizer = { path = "../../crates/localizer" } +primitives = { path = "../../crates/primitives" } +portfolio = { path = "../../crates/portfolio" } +api_connector = { path = "../../crates/api_connector" } +search_index = { path = "../../crates/search_index" } +nft = { path = "../../crates/nft" } +cacher = { path = "../../crates/cacher" } +metrics = { path = "../../crates/metrics" } +streamer = { path = "../../crates/streamer" } +gem_client = { path = "../../crates/gem_client", features = ["reqwest"] } +swapper = { path = "../../crates/swapper", features = ["reqwest_provider"] } +gem_rewards = { path = "../../crates/gem_rewards" } + +security_provider = { path = "../../crates/security_provider" } +gem_auth = { path = "../../crates/gem_auth", features = ["client"] } +unic-langid = "0.9.6" + +[dev-dependencies] +primitives = { path = "../../crates/primitives", features = ["testkit"] } diff --git a/core/apps/api/src/admin/assets.rs b/core/apps/api/src/admin/assets.rs new file mode 100644 index 0000000000..43a25cd3da --- /dev/null +++ b/core/apps/api/src/admin/assets.rs @@ -0,0 +1,13 @@ +use primitives::AssetId; +use rocket::{State, post, serde::json::Json}; +use streamer::{StreamProducer, StreamProducerQueue}; + +use crate::admin::AdminAuthorized; +use crate::responders::{ApiError, ApiResponse}; + +#[post("/assets/add", format = "json", data = "")] +pub async fn add_asset(_admin: AdminAuthorized, asset_id: Json, stream_producer: &State) -> Result, ApiError> { + let asset_id = asset_id.into_inner(); + stream_producer.publish_fetch_assets(vec![asset_id.clone()]).await?; + Ok(asset_id.into()) +} diff --git a/core/apps/api/src/admin/mod.rs b/core/apps/api/src/admin/mod.rs new file mode 100644 index 0000000000..7deb061178 --- /dev/null +++ b/core/apps/api/src/admin/mod.rs @@ -0,0 +1,104 @@ +pub mod assets; +pub mod nft; +pub mod prices; +pub mod transactions; + +use rocket::Request; +use rocket::http::Status; +use rocket::outcome::Outcome::{Error, Success}; +use rocket::request::{FromRequest, Outcome}; + +use crate::responders::cache_error; + +const AUTHORIZATION_HEADER: &str = "Authorization"; +const BEARER_PREFIX: &str = "Bearer "; + +fn error_outcome(req: &Request<'_>, status: Status, message: &str) -> Outcome { + cache_error(req, message); + Error((status, message.to_string())) +} + +#[derive(Debug, Clone)] +pub struct AdminConfig { + pub token: String, +} + +pub struct AdminAuthorized; + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AdminAuthorized { + type Error = String; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let Success(config) = req.guard::<&rocket::State>().await else { + return error_outcome(req, Status::InternalServerError, "Admin config not available"); + }; + + if config.token.is_empty() { + return error_outcome(req, Status::InternalServerError, "Admin token is not configured"); + } + + let Some(auth_value) = req.headers().get_one(AUTHORIZATION_HEADER) else { + return error_outcome(req, Status::Unauthorized, "Missing Authorization header"); + }; + + if !auth_value.starts_with(BEARER_PREFIX) { + return error_outcome(req, Status::Unauthorized, "Invalid authorization format"); + } + + if auth_value[BEARER_PREFIX.len()..] != config.token { + return error_outcome(req, Status::Unauthorized, "Invalid admin token"); + } + + Success(AdminAuthorized) + } +} + +#[cfg(test)] +mod tests { + use rocket::http::{Header, Status}; + use rocket::local::asynchronous::Client; + use rocket::{Build, Rocket, get, routes}; + + use super::{AdminAuthorized, AdminConfig}; + + #[get("/protected")] + async fn protected(_admin: AdminAuthorized) -> &'static str { + "ok" + } + + fn rocket(config: AdminConfig) -> Rocket { + rocket::build().manage(config).mount("/", routes![protected]) + } + + fn bearer_header(token: &str) -> Header<'static> { + Header::new("Authorization", format!("Bearer {token}")) + } + + #[rocket::async_test] + async fn test_invalid_token_returns_unauthorized() { + let client = Client::tracked(rocket(AdminConfig { token: "secret".to_string() })).await.unwrap(); + + let response = client.get("/protected").header(bearer_header("wrong")).dispatch().await; + + assert_eq!(response.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn test_correct_token_returns_ok() { + let client = Client::tracked(rocket(AdminConfig { token: "secret".to_string() })).await.unwrap(); + + let response = client.get("/protected").header(bearer_header("secret")).dispatch().await; + + assert_eq!(response.status(), Status::Ok); + } + + #[rocket::async_test] + async fn test_enabled_with_empty_token_returns_internal_server_error() { + let client = Client::tracked(rocket(AdminConfig { token: String::new() })).await.unwrap(); + + let response = client.get("/protected").dispatch().await; + + assert_eq!(response.status(), Status::InternalServerError); + } +} diff --git a/core/apps/api/src/admin/nft.rs b/core/apps/api/src/admin/nft.rs new file mode 100644 index 0000000000..66a10d1723 --- /dev/null +++ b/core/apps/api/src/admin/nft.rs @@ -0,0 +1,17 @@ +use ::nft::NFTClient; +use rocket::{State, put}; +use streamer::{StreamProducer, StreamProducerQueue}; + +use crate::admin::AdminAuthorized; +use crate::params::{NftAssetIdParam, NftCollectionIdParam}; +use crate::responders::{ApiError, ApiResponse}; + +#[put("/nft/collections/update/")] +pub async fn update_nft_collection(_admin: AdminAuthorized, collection_id: NftCollectionIdParam, client: &State) -> Result, ApiError> { + Ok(client.update_collection(&collection_id.0.to_string()).await?.into()) +} + +#[put("/nft/assets/update/")] +pub async fn update_nft_asset(_admin: AdminAuthorized, asset_id: NftAssetIdParam, stream_producer: &State) -> Result, ApiError> { + Ok(stream_producer.publish_fetch_nft_asset(asset_id.0).await?.into()) +} diff --git a/core/apps/api/src/admin/prices.rs b/core/apps/api/src/admin/prices.rs new file mode 100644 index 0000000000..99c30b2a5f --- /dev/null +++ b/core/apps/api/src/admin/prices.rs @@ -0,0 +1,12 @@ +use rocket::{State, post, serde::json::Json}; +use streamer::{FetchPricesPayload, StreamProducer, StreamProducerQueue}; + +use crate::admin::AdminAuthorized; +use crate::responders::{ApiError, ApiResponse}; + +#[post("/prices/add", format = "json", data = "")] +pub async fn add_price(_admin: AdminAuthorized, payload: Json, stream_producer: &State) -> Result, ApiError> { + let payload = payload.into_inner(); + stream_producer.publish_fetch_prices(payload.clone()).await?; + Ok(payload.into()) +} diff --git a/core/apps/api/src/admin/transactions.rs b/core/apps/api/src/admin/transactions.rs new file mode 100644 index 0000000000..46103b13ec --- /dev/null +++ b/core/apps/api/src/admin/transactions.rs @@ -0,0 +1,28 @@ +use primitives::{Transaction, TransactionId}; +use rocket::serde::json::Json; +use rocket::{State, post, tokio::sync::Mutex}; +use streamer::{StreamProducer, StreamProducerQueue, TransactionsPayload}; + +use crate::admin::AdminAuthorized; +use crate::chain::ChainClient; +use crate::responders::{ApiError, ApiResponse}; + +#[post("/transactions/add", format = "json", data = "")] +pub async fn add_transaction( + _admin: AdminAuthorized, + transaction_id: Json, + chain_client: &State>, + stream_producer: &State, +) -> Result>, ApiError> { + let client = chain_client.lock().await; + + let transaction_id = transaction_id.0; + let transaction = client.get_transaction_by_hash(transaction_id.chain, transaction_id.hash).await?; + + if let Some(transaction) = transaction.as_ref() { + let payload = TransactionsPayload::new(transaction.asset_id.chain, vec![transaction.clone()]); + stream_producer.publish_transactions(payload).await?; + } + + Ok(transaction.into()) +} diff --git a/core/apps/api/src/assets/cilent.rs b/core/apps/api/src/assets/cilent.rs new file mode 100644 index 0000000000..61f8e87bc6 --- /dev/null +++ b/core/apps/api/src/assets/cilent.rs @@ -0,0 +1,116 @@ +use std::collections::HashMap; +use std::error::Error; + +use super::filter::{build_assets_filters, build_filter}; +use super::model::SearchRequest; +use chrono::{DateTime, Utc}; +use pricer::PriceClient; +use primitives::{Asset, AssetBasic, AssetFull, AssetId, ChainAddress, NFTCollection, PerpetualSearchData, PriceConfig}; +use search_index::{ASSETS_INDEX_NAME, AssetDocument, NFTDocument, NFTS_INDEX_NAME, PERPETUALS_INDEX_NAME, PerpetualDocument, SearchIndexClient}; +use storage::{AssetsAddressesRepository, AssetsRepository, Database, WalletsRepository}; + +#[derive(Clone)] +pub struct AssetsClient { + database: Database, + config: PriceConfig, +} + +impl AssetsClient { + pub fn new(database: Database, config: PriceConfig) -> Self { + Self { database, config } + } + + #[allow(unused)] + pub fn get_asset(&self, asset_id: &AssetId) -> Result> { + Ok(self.database.assets()?.get_asset(asset_id)?) + } + + pub fn get_assets(&self, asset_ids: Vec, rate: f64) -> Result, Box> { + Ok(self + .database + .assets()? + .get_assets_with_prices(asset_ids, self.config.primary_price_max_age)? + .into_iter() + .map(|asset| asset.asset_basic_with_rate(rate)) + .collect()) + } + + pub fn get_asset_full(&self, asset_id: &AssetId) -> Result> { + Ok(self.database.assets()?.get_asset_full(asset_id, self.config.primary_price_max_age)?) + } + + pub fn get_assets_by_wallet_id(&self, device_id: i32, wallet_id: i32, from_timestamp: Option) -> Result, Box> { + let subscriptions = self.database.wallets()?.get_subscriptions_by_wallet_id(device_id, wallet_id)?; + let chain_addresses: Vec = subscriptions.into_iter().map(|(sub, addr)| ChainAddress::new(sub.chain.0, addr.address)).collect(); + let from_datetime = from_timestamp.and_then(|ts| DateTime::::from_timestamp(ts as i64, 0).map(|dt| dt.naive_utc())); + + Ok(self.database.assets_addresses()?.get_assets_by_addresses(chain_addresses, from_datetime)?) + } +} + +pub struct SearchClient { + client: SearchIndexClient, + price_client: PriceClient, +} + +impl SearchClient { + pub fn new(client: &SearchIndexClient, price_client: PriceClient) -> Self { + Self { + client: client.clone(), + price_client, + } + } + + pub async fn get_assets_search(&self, request: &SearchRequest) -> Result, Box> { + let filters = build_assets_filters(request); + + let assets: Vec = self + .client + .search(ASSETS_INDEX_NAME, &request.query, &build_filter(filters), [].as_ref(), request.limit, request.offset) + .await?; + + if assets.is_empty() { + return Ok(vec![]); + } + + let asset_ids: Vec = assets.iter().map(|x| x.asset.id.clone()).collect(); + let prices: HashMap = self + .price_client + .get_cache_prices(asset_ids) + .await? + .into_iter() + .map(|p| (p.asset_id.clone(), p.as_price_primitive())) + .collect(); + + Ok(assets + .into_iter() + .map(|x| { + let price = prices.get(&x.asset.id).cloned(); + AssetBasic { + asset: x.asset, + properties: x.properties, + score: x.score, + price, + } + }) + .collect()) + } + + pub async fn get_perpetuals_search(&self, request: &SearchRequest) -> Result, Box> { + let perpetuals: Vec = self + .client + .search(PERPETUALS_INDEX_NAME, &request.query, &build_filter(vec![]), [].as_ref(), request.limit, request.offset) + .await?; + + Ok(perpetuals.into_iter().map(Into::into).collect()) + } + + pub async fn get_nfts_search(&self, request: &SearchRequest) -> Result, Box> { + let nfts: Vec = self + .client + .search(NFTS_INDEX_NAME, &request.query, &build_filter(vec![]), [].as_ref(), request.limit, request.offset) + .await?; + + Ok(nfts.into_iter().map(|x| x.collection).collect()) + } +} diff --git a/core/apps/api/src/assets/filter.rs b/core/apps/api/src/assets/filter.rs new file mode 100644 index 0000000000..280d1ba403 --- /dev/null +++ b/core/apps/api/src/assets/filter.rs @@ -0,0 +1,73 @@ +use super::SearchRequest; + +pub fn build_assets_filters(request: &SearchRequest) -> Vec { + let mut filters = vec![]; + filters.push(format!("score.rank > {}", request.rank_threshold())); + + if !request.tags.is_empty() { + filters.push(filter_array("tags", request.tags.clone())); + } + + if !request.chains.is_empty() { + filters.push(filter_array("asset.chain", request.chains.clone())); + } + + filters +} + +pub fn build_filter(filters: Vec) -> String { + filters.join(" AND ") +} + +fn filter_array(field: &str, values: Vec) -> String { + format!("{} IN [\"{}\"]", field, values.join("\",\"")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_assets_filters_short_query() { + let request = SearchRequest::new("USDT", None, None, None, None); + let filters = build_assets_filters(&request); + + assert_eq!(filters[0], "score.rank > 15"); + } + + #[test] + fn build_assets_filters_long_query() { + let request = SearchRequest::new("ethereum", None, None, None, None); + let filters = build_assets_filters(&request); + + assert_eq!(filters[0], "score.rank > 5"); + } + + #[test] + fn build_assets_filters_with_tags() { + let request = SearchRequest::new("longquery", None, Some("defi"), None, None); + let filters = build_assets_filters(&request); + + assert_eq!(filters[0], "score.rank > 5"); + assert_eq!(filters[1], "tags IN [\"defi\"]"); + } + + #[test] + fn build_assets_filters_with_chains() { + let request = SearchRequest::new("longquery", Some("ethereum"), None, None, None); + let filters = build_assets_filters(&request); + + assert_eq!(filters[0], "score.rank > 5"); + assert_eq!(filters[1], "asset.chain IN [\"ethereum\"]"); + } + + #[test] + fn build_filter_joins_with_and() { + assert_eq!(build_filter(vec!["a".to_string(), "b".to_string()]), "a AND b"); + } + + #[test] + fn filter_array_formats_correctly() { + assert_eq!(filter_array("tags", vec!["defi".to_string(), "nft".to_string()]), "tags IN [\"defi\",\"nft\"]"); + } +} diff --git a/core/apps/api/src/assets/mod.rs b/core/apps/api/src/assets/mod.rs new file mode 100644 index 0000000000..afc438ea8a --- /dev/null +++ b/core/apps/api/src/assets/mod.rs @@ -0,0 +1,69 @@ +pub mod cilent; +mod filter; +mod model; + +use crate::params::{AssetIdParam, SearchQueryParam}; +use crate::responders::{ApiError, ApiResponse}; +pub use cilent::{AssetsClient, SearchClient}; +pub use model::SearchRequest; +use pricer::PriceClient; +use primitives::{AssetBasic, AssetFull, AssetId, DEFAULT_FIAT_CURRENCY, SearchResponse}; +use rocket::{State, get, post, serde::json::Json, tokio::sync::Mutex}; + +#[get("/assets/?")] +pub async fn get_asset( + asset_id: AssetIdParam, + currency: Option<&str>, + client: &State>, + price_client: &State>, +) -> Result, ApiError> { + let asset = client.lock().await.get_asset_full(&asset_id.0)?; + let currency = currency.unwrap_or(DEFAULT_FIAT_CURRENCY); + let rate = price_client.lock().await.get_fiat_rate(currency)?.rate; + Ok(asset.with_rate(rate).into()) +} + +#[post("/assets?", format = "json", data = "")] +pub async fn get_assets( + asset_ids: Json>, + currency: Option<&str>, + client: &State>, + price_client: &State>, +) -> Result>, ApiError> { + let currency = currency.unwrap_or(DEFAULT_FIAT_CURRENCY); + let rate = price_client.lock().await.get_fiat_rate(currency)?.rate; + + Ok(client.lock().await.get_assets(asset_ids.0, rate)?.into()) +} + +#[get("/assets/search?&&&&")] +pub async fn get_assets_search( + query: SearchQueryParam, + chains: Option<&str>, + tags: Option<&str>, + limit: Option, + offset: Option, + client: &State>, +) -> Result>, ApiError> { + let request = SearchRequest::new(&query.0, chains, tags, limit, offset); + Ok(client.lock().await.get_assets_search(&request).await?.into()) +} + +#[get("/search?&&&&")] +pub async fn get_search( + query: SearchQueryParam, + chains: Option<&str>, + tags: Option<&str>, + limit: Option, + offset: Option, + client: &State>, +) -> Result, ApiError> { + let request = SearchRequest::new(&query.0, chains, tags, limit, offset); + + let search_client = client.lock().await; + let assets = search_client.get_assets_search(&request).await?; + let perpetuals = search_client.get_perpetuals_search(&request).await?; + let nfts = search_client.get_nfts_search(&request).await?; + + Ok(SearchResponse { assets, perpetuals, nfts }.into()) +} diff --git a/core/apps/api/src/assets/model.rs b/core/apps/api/src/assets/model.rs new file mode 100644 index 0000000000..8e291c6e64 --- /dev/null +++ b/core/apps/api/src/assets/model.rs @@ -0,0 +1,72 @@ +use primitives::Chain; +use std::str::FromStr; + +const MAX_LIMIT: usize = 500; +const MAX_OFFSET: usize = 10_000; +const DEFAULT_LIMIT: usize = 50; + +pub struct SearchRequest { + pub query: String, + pub chains: Vec, + pub tags: Vec, + pub limit: usize, + pub offset: usize, +} + +impl SearchRequest { + pub fn new(query: &str, chains: Option<&str>, tags: Option<&str>, limit: Option, offset: Option) -> Self { + let chains = chains + .unwrap_or_default() + .split(',') + .flat_map(Chain::from_str) + .map(|x| x.to_string()) + .collect::>(); + + let tags = tags + .unwrap_or_default() + .split(',') + .filter(|x| !x.is_empty()) + .map(|x| x.to_string()) + .collect::>(); + + Self { + query: query.to_string(), + chains, + tags, + limit: limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT), + offset: offset.unwrap_or(0).min(MAX_OFFSET), + } + } + + pub fn rank_threshold(&self) -> u32 { + if self.query.len() < 8 { 15 } else { 5 } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rank_threshold() { + assert_eq!(SearchRequest::new("BTC", None, None, None, None).rank_threshold(), 15); + assert_eq!(SearchRequest::new("USDT", None, None, None, None).rank_threshold(), 15); + assert_eq!(SearchRequest::new("ethereum", None, None, None, None).rank_threshold(), 5); + } + + #[test] + fn new() { + let request = SearchRequest::new("test", Some("ethereum,bitcoin"), Some("defi,nft"), Some(100), Some(10)); + assert_eq!(request.query, "test"); + assert_eq!(request.chains, vec!["ethereum", "bitcoin"]); + assert_eq!(request.tags, vec!["defi", "nft"]); + assert_eq!(request.limit, 100); + assert_eq!(request.offset, 10); + + let default_request = SearchRequest::new("query", None, None, None, None); + assert!(default_request.chains.is_empty()); + assert!(default_request.tags.is_empty()); + assert_eq!(default_request.limit, 50); + assert_eq!(default_request.offset, 0); + } +} diff --git a/core/apps/api/src/auth/guard.rs b/core/apps/api/src/auth/guard.rs new file mode 100644 index 0000000000..5a1cd69b57 --- /dev/null +++ b/core/apps/api/src/auth/guard.rs @@ -0,0 +1,125 @@ +use crate::responders::cache_error; +use gem_auth::{AuthClient, verify_auth_signature}; +use gem_hash::sha2::sha256; +use primitives::{AuthMessage, AuthenticatedRequest, WalletId}; +use rocket::data::{FromData, Outcome, ToByteUnit}; +use rocket::http::Status; +use rocket::outcome::Outcome::{Error, Success}; +use rocket::{Data, Request, State}; +use serde::de::DeserializeOwned; +use std::sync::Arc; + +fn error_outcome<'r, T>(req: &'r Request<'_>, status: Status, message: &str) -> Outcome<'r, T, String> { + cache_error(req, message); + Error((status, message.to_string())) +} + +struct VerifiedBody { + address: String, + data: T, +} + +async fn verify_wallet_signature<'r, T: DeserializeOwned + Send, O>(req: &'r Request<'_>, data: Data<'r>) -> Result, Outcome<'r, O, String>> { + let Success(auth_client) = req.guard::<&State>>().await else { + return Err(error_outcome(req, Status::InternalServerError, "Auth client not available")); + }; + + let Ok(bytes) = data.open(32.mebibytes()).into_bytes().await else { + return Err(error_outcome(req, Status::BadRequest, "Failed to read body")); + }; + if !bytes.is_complete() { + return Err(error_outcome(req, Status::BadRequest, "Request body too large")); + } + + let raw_body = bytes.into_inner(); + + if let Some(expected_hash) = req.headers().get_one("x-device-body-hash") { + let actual_hash = hex::encode(sha256(&raw_body)); + if actual_hash != expected_hash { + return Err(error_outcome(req, Status::BadRequest, "Body hash mismatch")); + } + } + + let Ok(body) = serde_json::from_slice::>(&raw_body) else { + return Err(error_outcome(req, Status::BadRequest, "Invalid JSON")); + }; + + let Ok(auth_nonce) = auth_client.get_auth_nonce(&body.auth.device_id, &body.auth.nonce).await else { + return Err(error_outcome(req, Status::Unauthorized, "Invalid nonce")); + }; + + let auth_message = AuthMessage { + chain: body.auth.chain, + address: body.auth.address.clone(), + auth_nonce, + }; + if !verify_auth_signature(&auth_message, &body.auth.signature) { + return Err(error_outcome(req, Status::Unauthorized, "Invalid signature")); + } + + if auth_client.invalidate_nonce(&body.auth.device_id, &body.auth.nonce).await.is_err() { + return Err(error_outcome(req, Status::InternalServerError, "Failed to invalidate nonce")); + } + + Ok(VerifiedBody { + address: body.auth.address, + data: body.data, + }) +} + +// Auth layering principles: +// Device guards verify the request/device signature and database scope. +// WalletSigned verifies wallet ownership of the signed JSON body using an auth nonce. +// Routes that mutate wallet-owned reward state should require both and bind the signed wallet to the resolved wallet. +pub struct WalletSigned { + pub address: String, + pub data: T, +} + +impl WalletSigned { + pub fn matches_multicoin_wallet(&self, wallet_id: &WalletId) -> bool { + WalletId::Multicoin(self.address.clone()) == *wallet_id + } +} + +#[rocket::async_trait] +impl<'r, T: DeserializeOwned + Send> FromData<'r> for WalletSigned { + type Error = String; + + async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> { + let verified = match verify_wallet_signature(req, data).await { + Ok(v) => v, + Err(outcome) => return outcome, + }; + + Success(WalletSigned { + address: verified.address, + data: verified.data, + }) + } +} + +#[cfg(test)] +mod tests { + use super::WalletSigned; + use primitives::{Chain, WalletId}; + + const ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + const OTHER_ADDRESS: &str = "0x2222222222222222222222222222222222222222"; + + fn signed_wallet(address: &str) -> WalletSigned<()> { + WalletSigned { + address: address.to_string(), + data: (), + } + } + + #[test] + fn test_matches_multicoin_wallet() { + let request = signed_wallet(ADDRESS); + + assert!(request.matches_multicoin_wallet(&WalletId::Multicoin(ADDRESS.to_string()))); + assert!(!request.matches_multicoin_wallet(&WalletId::Multicoin(OTHER_ADDRESS.to_string()))); + assert!(!request.matches_multicoin_wallet(&WalletId::Single(Chain::Ethereum, ADDRESS.to_string()))); + } +} diff --git a/core/apps/api/src/auth/mod.rs b/core/apps/api/src/auth/mod.rs new file mode 100644 index 0000000000..44b9247a1f --- /dev/null +++ b/core/apps/api/src/auth/mod.rs @@ -0,0 +1,3 @@ +mod guard; + +pub use guard::WalletSigned; diff --git a/core/apps/api/src/catchers.rs b/core/apps/api/src/catchers.rs new file mode 100644 index 0000000000..1aa6de4313 --- /dev/null +++ b/core/apps/api/src/catchers.rs @@ -0,0 +1,20 @@ +use crate::responders::ErrorContext; +use gem_tracing::info_with_fields; +use primitives::ResponseResult; +use rocket::http::Status; +use rocket::serde::json::Json; +use rocket::{Request, catch}; + +#[catch(default)] +pub fn default_catcher(status: Status, req: &Request) -> (Status, Json>) { + let context = req.local_cache(|| ErrorContext(String::new())); + let message = if context.0.is_empty() { + format!("{} {}", status.code, status.reason_lossy()) + } else { + context.0.clone() + }; + let user_agent = req.headers().get_one("User-Agent").unwrap_or("unknown"); + let uri = req.uri().to_string(); + info_with_fields!("Request failed", uri = uri, status = status.code, error = message, user_agent = user_agent); + (status, Json(ResponseResult::error(message))) +} diff --git a/core/apps/api/src/chain/address.rs b/core/apps/api/src/chain/address.rs new file mode 100644 index 0000000000..a142330569 --- /dev/null +++ b/core/apps/api/src/chain/address.rs @@ -0,0 +1,34 @@ +use rocket::{State, get, tokio::sync::Mutex}; + +use crate::params::{AddressParam, ChainParam}; +use crate::responders::{ApiError, ApiResponse}; +use primitives::{AddressBalances, AssetBalance, ChainAddress, Transaction}; + +use super::ChainClient; + +#[get("/chain/address//
/balances")] +pub async fn get_balances(chain: ChainParam, address: AddressParam, client: &State>) -> Result, ApiError> { + let request = ChainAddress::new(chain.0, address.0); + let client = client.lock().await; + let coin = client.get_balances_coin(request.clone()).await?; + let staking = client.get_balances_staking(request.clone()).await?; + let assets = client.get_balances_assets(request).await?; + Ok(AddressBalances { coin, staking, assets }.into()) +} + +#[get("/chain/address//
/assets")] +pub async fn get_assets(chain: ChainParam, address: AddressParam, client: &State>) -> Result>, ApiError> { + let request = ChainAddress::new(chain.0, address.0); + Ok(client.lock().await.get_balances_assets(request).await?.into()) +} + +#[get("/chain/address//
/transactions?")] +pub async fn get_transactions( + chain: ChainParam, + address: AddressParam, + from_timestamp: Option, + client: &State>, +) -> Result>, ApiError> { + let request = ChainAddress::new(chain.0, address.0); + Ok(client.lock().await.get_transactions(request, from_timestamp).await?.into()) +} diff --git a/core/apps/api/src/chain/block.rs b/core/apps/api/src/chain/block.rs new file mode 100644 index 0000000000..90dbb01e75 --- /dev/null +++ b/core/apps/api/src/chain/block.rs @@ -0,0 +1,38 @@ +use rocket::{State, get, tokio::sync::Mutex}; + +use crate::params::{AddressParam, ChainParam}; +use crate::responders::{ApiError, ApiResponse}; +use primitives::Transaction; + +use super::ChainClient; + +#[get("/chain/blocks//latest")] +pub async fn get_latest_block_number(chain: ChainParam, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_latest_block(chain.0).await?.into()) +} + +#[get("/chain/blocks//?")] +pub async fn get_block_transactions( + chain: ChainParam, + block_number: i64, + transaction_type: Option<&str>, + client: &State>, +) -> Result>, ApiError> { + Ok(client.lock().await.get_block_transactions(chain.0, block_number, transaction_type).await?.into()) +} + +#[get("/chain/blocks///finalize?
&")] +pub async fn get_block_transactions_finalize( + chain: ChainParam, + block_number: i64, + address: AddressParam, + transaction_type: Option<&str>, + client: &State>, +) -> Result>, ApiError> { + Ok(client + .lock() + .await + .get_block_transactions_finalize(chain.0, block_number, vec![address.0], transaction_type) + .await? + .into()) +} diff --git a/core/apps/api/src/chain/client.rs b/core/apps/api/src/chain/client.rs new file mode 100644 index 0000000000..0ff0d8e78b --- /dev/null +++ b/core/apps/api/src/chain/client.rs @@ -0,0 +1,92 @@ +use std::error::Error; + +use primitives::{Asset, AssetBalance, Chain, ChainAddress, Transaction, TransactionUpdate}; +use settings_chain::{ChainProviders, TransactionsRequest}; + +pub struct ChainClient { + providers: ChainProviders, +} + +impl ChainClient { + pub fn new(providers: ChainProviders) -> Self { + Self { providers } + } + + pub async fn get_token_data(&self, chain: Chain, token_id: String) -> Result> { + self.providers.get_token_data(chain, token_id).await + } + + #[allow(unused)] + pub fn get_is_token_address(&self, chain: Chain, token_id: &str) -> Result> { + self.providers.get_is_token_address(chain, token_id) + } + + pub async fn get_balances_coin(&self, request: ChainAddress) -> Result> { + self.providers.get_balance_coin(request.chain, request.address).await + } + + pub async fn get_balances_staking(&self, request: ChainAddress) -> Result, Box> { + self.providers.get_balance_staking(request.chain, request.address).await + } + + pub async fn get_balances_assets(&self, request: ChainAddress) -> Result, Box> { + self.providers.get_balance_assets(request.chain, request.address).await + } + + pub async fn get_transactions(&self, request: ChainAddress, from_timestamp: Option) -> Result, Box> { + self.providers + .get_transactions_by_address(request.chain, TransactionsRequest::new(request.address).with_from_timestamp(from_timestamp)) + .await + } + + pub async fn get_validators(&self, chain: Chain) -> Result, Box> { + self.providers.get_validators(chain).await + } + + pub async fn get_staking_apy(&self, chain: Chain) -> Result> { + self.providers.get_staking_apy(chain).await + } + + pub async fn get_transaction_by_hash(&self, chain: Chain, hash: String) -> Result, Box> { + self.providers.get_transaction_by_hash(chain, hash).await + } + + pub async fn get_transaction_status(&self, chain: Chain, hash: String) -> Result> { + self.providers.get_transaction_status(chain, hash).await + } + + pub async fn get_block_transactions(&self, chain: Chain, block_number: i64, transaction_type: Option<&str>) -> Result, Box> { + let transactions = self.providers.get_block_transactions(chain, block_number as u64).await?; + Ok(self.filter_transactions(transactions, transaction_type)) + } + + pub async fn get_block_transactions_finalize( + &self, + chain: Chain, + block_number: i64, + addresses: Vec, + transaction_type: Option<&str>, + ) -> Result, Box> { + let transactions = self + .get_block_transactions(chain, block_number, None) + .await? + .into_iter() + .map(|x| x.finalize(addresses.clone())) + .collect::>(); + Ok(self.filter_transactions(transactions, transaction_type)) + } + + fn filter_transactions(&self, transactions: Vec, transaction_type: Option<&str>) -> Vec { + if let Some(transaction_type) = transaction_type { + return transactions + .into_iter() + .filter(|x| x.transaction_type.as_ref() == transaction_type) + .collect::>(); + } + transactions + } + + pub async fn get_latest_block(&self, chain: Chain) -> Result> { + Ok(self.providers.get_latest_block(chain).await? as i64) + } +} diff --git a/core/apps/api/src/chain/mod.rs b/core/apps/api/src/chain/mod.rs new file mode 100644 index 0000000000..19290111f9 --- /dev/null +++ b/core/apps/api/src/chain/mod.rs @@ -0,0 +1,10 @@ +pub mod address; +pub mod block; +pub mod client; +pub mod nft; +pub mod staking; +pub mod swap; +pub mod token; +pub mod transaction; + +pub use client::ChainClient; diff --git a/core/apps/api/src/chain/nft.rs b/core/apps/api/src/chain/nft.rs new file mode 100644 index 0000000000..7bbbdcaf52 --- /dev/null +++ b/core/apps/api/src/chain/nft.rs @@ -0,0 +1,21 @@ +use rocket::{State, get}; + +use crate::params::{AddressParam, ChainParam, NftAssetIdParam, NftCollectionIdParam}; +use crate::responders::{ApiError, ApiResponse}; +use ::nft::NFTProviderClient; +use primitives::{NFTAsset, NFTCollection, NFTData}; + +#[get("/chain/nft/assets/")] +pub async fn get_nft_asset(asset_id: NftAssetIdParam, client: &State) -> Result, ApiError> { + Ok(client.get_nft_asset(asset_id.0).await?.into()) +} + +#[get("/chain/nft/collections/")] +pub async fn get_nft_collection(collection_id: NftCollectionIdParam, client: &State) -> Result, ApiError> { + Ok(client.get_nft_collection(collection_id.0).await?.into()) +} + +#[get("/chain/address//
/nfts")] +pub async fn get_nfts(chain: ChainParam, address: AddressParam, client: &State) -> Result>, ApiError> { + Ok(client.get_nft_data(chain.0, &address.0).await?.into()) +} diff --git a/core/apps/api/src/chain/staking.rs b/core/apps/api/src/chain/staking.rs new file mode 100644 index 0000000000..acf1ea97ef --- /dev/null +++ b/core/apps/api/src/chain/staking.rs @@ -0,0 +1,17 @@ +use rocket::{State, get, tokio::sync::Mutex}; + +use crate::params::ChainParam; +use crate::responders::{ApiError, ApiResponse}; +use primitives::StakeValidator; + +use super::ChainClient; + +#[get("/chain/staking//validators")] +pub async fn get_validators(chain: ChainParam, client: &State>) -> Result>, ApiError> { + Ok(client.lock().await.get_validators(chain.0).await?.into()) +} + +#[get("/chain/staking//apy")] +pub async fn get_staking_apy(chain: ChainParam, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_staking_apy(chain.0).await?.into()) +} diff --git a/core/apps/api/src/chain/swap.rs b/core/apps/api/src/chain/swap.rs new file mode 100644 index 0000000000..b2d6569df3 --- /dev/null +++ b/core/apps/api/src/chain/swap.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use primitives::{AssetId, swap::SwapResult}; +use rocket::{State, get}; +use swapper::{Options, QuoteRequest, SwapQuotes, SwapperQuoteAsset, config::get_default_slippage, cross_chain::VaultAddresses, swapper::GemSwapper}; + +use crate::params::{AddressParam, AssetIdParam, ChainParam, SwapProviderParam}; +use crate::responders::{ApiError, ApiResponse}; + +#[get("/chain/swaps//transaction/?")] +pub async fn get_swap_result(provider: SwapProviderParam, hash: &str, chain: ChainParam, swapper: &State>) -> Result, ApiError> { + Ok(swapper.get_swap_result(chain.0, provider.0, hash).await?.into()) +} + +#[get("/chain/swaps//vault_addresses")] +pub async fn get_vault_addresses(provider: SwapProviderParam, swapper: &State>) -> Result, ApiError> { + Ok(swapper.get_vault_addresses(&provider.0, None).await?.into()) +} + +#[get("/chain/swaps/quote?&&&&")] +pub async fn get_swap_quote( + from_asset: AssetIdParam, + to_asset: AssetIdParam, + value: &str, + wallet_address: AddressParam, + destination_address: AddressParam, + swapper: &State>, +) -> Result, ApiError> { + let request = build_quote_request(from_asset.0, to_asset.0, value, wallet_address.0, destination_address.0); + Ok(swapper.get_quotes(&request).await?.into()) +} + +fn build_quote_request(from_asset_id: AssetId, to_asset_id: AssetId, value: &str, wallet_address: String, destination_address: String) -> QuoteRequest { + let from_asset = SwapperQuoteAsset::from(from_asset_id.clone()); + let to_asset = SwapperQuoteAsset::from(to_asset_id); + + QuoteRequest { + from_asset, + to_asset, + wallet_address, + destination_address, + value: value.to_string(), + options: Options { + slippage: get_default_slippage(&from_asset_id.chain), + use_max_amount: false, + }, + } +} diff --git a/core/apps/api/src/chain/token.rs b/core/apps/api/src/chain/token.rs new file mode 100644 index 0000000000..1705127e37 --- /dev/null +++ b/core/apps/api/src/chain/token.rs @@ -0,0 +1,12 @@ +use rocket::{State, get, tokio::sync::Mutex}; + +use crate::params::ChainParam; +use crate::responders::{ApiError, ApiResponse}; +use primitives::Asset; + +use super::ChainClient; + +#[get("/chain/token///info")] +pub async fn get_token(chain: ChainParam, token_id: &str, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_token_data(chain.0, token_id.to_string()).await?.into()) +} diff --git a/core/apps/api/src/chain/transaction.rs b/core/apps/api/src/chain/transaction.rs new file mode 100644 index 0000000000..4dc77dbcf6 --- /dev/null +++ b/core/apps/api/src/chain/transaction.rs @@ -0,0 +1,17 @@ +use rocket::{State, get, tokio::sync::Mutex}; + +use crate::params::ChainParam; +use crate::responders::{ApiError, ApiResponse}; +use primitives::{Transaction, TransactionUpdate}; + +use super::ChainClient; + +#[get("/chain/transactions//")] +pub async fn get_transaction(chain: ChainParam, hash: &str, client: &State>) -> Result>, ApiError> { + Ok(client.lock().await.get_transaction_by_hash(chain.0, hash.to_string()).await?.into()) +} + +#[get("/chain/transactions///status")] +pub async fn get_transaction_status(chain: ChainParam, hash: &str, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_transaction_status(chain.0, hash.to_string()).await?.into()) +} diff --git a/core/apps/api/src/config/client.rs b/core/apps/api/src/config/client.rs new file mode 100644 index 0000000000..36a1a6301a --- /dev/null +++ b/core/apps/api/src/config/client.rs @@ -0,0 +1,36 @@ +use primitives::{ConfigResponse, ConfigVersions, SwapConfig, SwapProvider}; +use std::error::Error; +use storage::{AssetFilter, AssetsRepository, Database, ReleasesRepository}; + +#[derive(Clone)] +pub struct ConfigClient { + database: Database, +} + +impl ConfigClient { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub fn get_config(&self) -> Result> { + let fiat_on_ramp_assets = self.database.assets()?.get_assets_by_filter(vec![AssetFilter::IsBuyable(true)])?.len() as i32; + let fiat_off_ramp_assets = self.database.assets()?.get_assets_by_filter(vec![AssetFilter::IsSellable(true)])?.len() as i32; + let swap_assets_version = self.database.assets()?.get_swap_assets_version()?; + let releases = self.database.releases()?.get_releases()?; + + let releases = releases.into_iter().map(|x| x.as_primitive()).collect(); + + let response = ConfigResponse { + releases, + versions: ConfigVersions { + fiat_on_ramp_assets, + fiat_off_ramp_assets, + swap_assets: swap_assets_version, + }, + swap: SwapConfig { + enabled_providers: SwapProvider::all().iter().map(|x| x.as_ref().to_string()).collect(), + }, + }; + Ok(response) + } +} diff --git a/core/apps/api/src/config/mod.rs b/core/apps/api/src/config/mod.rs new file mode 100644 index 0000000000..a5d3892b23 --- /dev/null +++ b/core/apps/api/src/config/mod.rs @@ -0,0 +1,10 @@ +pub mod client; +use crate::responders::{ApiError, ApiResponse}; +pub use client::ConfigClient; +use primitives::config::ConfigResponse; +use rocket::{State, get, tokio::sync::Mutex}; + +#[get("/config")] +pub async fn get_config(config_client: &State>) -> Result, ApiError> { + Ok(config_client.lock().await.get_config()?.into()) +} diff --git a/core/apps/api/src/devices/auth_config.rs b/core/apps/api/src/devices/auth_config.rs new file mode 100644 index 0000000000..03082ce7d5 --- /dev/null +++ b/core/apps/api/src/devices/auth_config.rs @@ -0,0 +1,18 @@ +use std::time::Duration; + +pub struct JwtConfig { + pub secret: String, + pub expiry: Duration, +} + +pub struct AuthConfig { + pub enabled: bool, + pub tolerance: Duration, + pub jwt: JwtConfig, +} + +impl AuthConfig { + pub fn new(enabled: bool, tolerance: Duration, jwt: JwtConfig) -> Self { + Self { enabled, tolerance, jwt } + } +} diff --git a/core/apps/api/src/devices/client.rs b/core/apps/api/src/devices/client.rs new file mode 100644 index 0000000000..405b8a43eb --- /dev/null +++ b/core/apps/api/src/devices/client.rs @@ -0,0 +1,50 @@ +use api_connector::PusherClient; +use primitives::{Device, GorushNotification, PushNotification, PushNotificationTypes}; +use std::error::Error; +use storage::{Database, DevicesRepository, models::UpdateDeviceRow}; + +#[derive(Clone)] +pub struct DevicesClient { + database: Database, + pusher: PusherClient, +} + +impl DevicesClient { + pub fn new(database: Database, pusher: PusherClient) -> Self { + Self { database, pusher } + } + + pub fn add_device(&self, device: Device) -> Result> { + let add_device = UpdateDeviceRow::from_primitive(device.clone()); + Ok(self.database.devices()?.add_device(add_device)?) + } + + pub fn get_device(&self, device_id: &str) -> Result> { + Ok(self.database.devices()?.get_device(device_id)?) + } + + pub fn update_device(&self, device: Device) -> Result> { + let update_device = UpdateDeviceRow::from_primitive(device); + Ok(self.database.devices()?.update_device(update_device)?) + } + + pub async fn send_push_notification_device(&self, device_id: &str) -> Result> { + let device = self.get_device(device_id)?; + let notifications: Vec<_> = GorushNotification::from_device( + device, + "Test Notification".to_string(), + "Test Message".to_string(), + PushNotification { + notification_type: PushNotificationTypes::Test, + data: None, + }, + ) + .into_iter() + .collect(); + Ok(self.pusher.push_notifications(notifications).await?.response.counts > 0) + } + + pub fn is_device_registered(&self, device_id: &str) -> Result> { + Ok(self.database.devices()?.get_device_exist(device_id)?) + } +} diff --git a/core/apps/api/src/devices/clients/address_names.rs b/core/apps/api/src/devices/clients/address_names.rs new file mode 100644 index 0000000000..946732babf --- /dev/null +++ b/core/apps/api/src/devices/clients/address_names.rs @@ -0,0 +1,92 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; + +use primitives::{AddressName, AddressType, Asset, AssetId, ChainAddress, VerificationStatus}; +use storage::{AssetsRepository, Database, ScanAddressesRepository}; + +#[derive(Clone)] +pub struct AddressNamesClient { + database: Database, +} + +impl AddressNamesClient { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub fn get_address_names(&self, requests: Vec) -> Result, Box> { + let requests: Vec = requests.into_iter().filter(|request| !request.address.is_empty()).collect(); + if requests.is_empty() { + return Ok(vec![]); + } + + let queries = requests.iter().map(|request| (request.chain, request.address.as_str())).collect::>(); + let scan_names = self + .database + .scan_addresses()? + .get_scan_addresses(&queries)? + .into_iter() + .filter_map(|x| x.as_primitive()) + .map(|name| (ChainAddress::new(name.chain, name.address.clone()), name)) + .collect::>(); + let asset_ids = requests + .iter() + .map(|request| AssetId::from(request.chain, Some(request.address.clone()))) + .collect::>(); + let asset_names = self + .database + .assets()? + .get_assets(asset_ids)? + .into_iter() + .filter_map(asset_entry) + .collect::>(); + + Ok(map_requests(requests, &scan_names, &asset_names)) + } +} + +fn map_requests(requests: Vec, scan_names: &HashMap, asset_names: &HashMap) -> Vec { + requests + .into_iter() + .filter_map(|request| asset_names.get(&request).or_else(|| scan_names.get(&request)).cloned()) + .scan(HashSet::new(), |seen, name| { + seen.insert(ChainAddress::new(name.chain, name.address.clone())).then_some(name) + }) + .collect() +} + +fn asset_entry(asset: Asset) -> Option<(ChainAddress, AddressName)> { + let address = asset.token_id?; + + Some(( + ChainAddress::new(asset.chain, address.clone()), + AddressName { + chain: asset.chain, + address, + name: asset.name, + address_type: AddressType::Contract, + status: VerificationStatus::Verified, + }, + )) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::map_requests; + use primitives::{AddressName, AddressType, Chain, ChainAddress, VerificationStatus}; + + #[test] + fn test_map_requests_prefers_asset_then_scan() { + let asset_request = ChainAddress::new(Chain::Ethereum, "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()); + let scan_request = ChainAddress::new(Chain::Ethereum, "0x123".to_string()); + let asset_name = AddressName::mock("0xdAC17F958D2ee523a2206206994597C13D831ec7", "USDT", AddressType::Contract, VerificationStatus::Verified); + let scan_name = AddressName::mock("0x123", "Legacy Name", AddressType::Address, VerificationStatus::Unverified); + + let scan_names = HashMap::from([(asset_request.clone(), scan_name.clone()), (scan_request.clone(), scan_name.clone())]); + let asset_names = HashMap::from([(asset_request.clone(), asset_name.clone())]); + + assert_eq!(map_requests(vec![asset_request, scan_request], &scan_names, &asset_names), vec![asset_name, scan_name]); + } +} diff --git a/core/apps/api/src/devices/clients/fiat.rs b/core/apps/api/src/devices/clients/fiat.rs new file mode 100644 index 0000000000..3a0ca728cb --- /dev/null +++ b/core/apps/api/src/devices/clients/fiat.rs @@ -0,0 +1,54 @@ +use std::collections::BTreeSet; +use std::error::Error; + +use fiat::FiatClient; +use primitives::{FiatQuote, FiatQuoteRequest, FiatQuoteUrl, FiatQuotes, FiatTransaction, FiatTransactionData}; +use storage::{Database, FiatRepository, WalletsRepository}; + +pub struct FiatQuotesClient { + database: Database, + fiat_client: FiatClient, +} + +impl FiatQuotesClient { + pub fn new(database: Database, fiat_client: FiatClient) -> Self { + Self { database, fiat_client } + } + + pub async fn get_quotes(&self, request: FiatQuoteRequest) -> Result> { + self.fiat_client.get_quotes(request).await + } + + pub async fn get_quote(&self, quote_id: &str) -> Result> { + self.fiat_client.get_quote(quote_id).await + } + + pub async fn get_quote_url(&self, quote_id: &str, wallet_id: i32, device_id: i32, ip_address: &str, locale: &str) -> Result> { + self.fiat_client.get_quote_url(quote_id, wallet_id, device_id, ip_address, locale).await + } + + pub async fn get_on_ramp_assets(&self) -> Result> { + self.fiat_client.get_on_ramp_assets().await + } + + pub async fn get_off_ramp_assets(&self) -> Result> { + self.fiat_client.get_off_ramp_assets().await + } + + pub async fn process_and_publish_webhook(&self, provider: &str, webhook_data: serde_json::Value) -> Result> { + self.fiat_client.process_and_publish_webhook(provider, webhook_data).await + } + + pub async fn get_order_status(&self, provider: &str, order_id: &str) -> Result> { + self.fiat_client.get_order_status(provider, order_id).await + } + + pub fn get_transactions_by_wallet_id(&self, device_row_id: i32, wallet_id: i32) -> Result, Box> { + let subscriptions = self.database.wallets()?.get_subscriptions_by_wallet_id(device_row_id, wallet_id)?; + let addresses = subscriptions.into_iter().map(|(_, address)| address.address).collect::>().into_iter().collect(); + + let transactions = FiatRepository::get_fiat_transactions_by_addresses(&mut self.database.fiat()?, addresses)?; + + Ok(transactions.into_iter().map(fiat::fiat_transaction_info).collect()) + } +} diff --git a/core/apps/api/src/devices/clients/mod.rs b/core/apps/api/src/devices/clients/mod.rs new file mode 100644 index 0000000000..09979a99d7 --- /dev/null +++ b/core/apps/api/src/devices/clients/mod.rs @@ -0,0 +1,22 @@ +mod address_names; +mod fiat; +mod notifications; +mod portfolio; +mod rewards; +mod rewards_redemption; +mod scan; +mod transactions; +mod wallet_configuration; +mod wallets; + +pub use address_names::AddressNamesClient; +pub use fiat::FiatQuotesClient; +pub use notifications::NotificationsClient; +pub use portfolio::PortfolioClient; +pub use rewards::RewardsClient; +pub use rewards_redemption::RewardsRedemptionClient; +pub use scan::{ScanClient, ScanProviderFactory}; +pub use transactions::TransactionsClient; +pub use wallet_configuration::WalletConfigurationClient; +pub(crate) use wallets::WalletSubscriptionInput; +pub use wallets::WalletsClient; diff --git a/core/apps/api/src/devices/clients/notifications.rs b/core/apps/api/src/devices/clients/notifications.rs new file mode 100644 index 0000000000..19466f2b2e --- /dev/null +++ b/core/apps/api/src/devices/clients/notifications.rs @@ -0,0 +1,29 @@ +use chrono::{DateTime, Utc}; +use in_app_notifications::map_notification; +use localizer::LanguageLocalizer; +use primitives::InAppNotification; +use std::error::Error; +use storage::{Database, DevicesRepository, NotificationsRepository}; + +#[derive(Clone)] +pub struct NotificationsClient { + database: Database, +} + +impl NotificationsClient { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub fn get_notifications(&self, device_id: &str, from_timestamp: Option) -> Result, Box> { + let device = self.database.devices()?.get_device(device_id)?; + let localizer = LanguageLocalizer::new_with_language(device.locale.as_str()); + let from_datetime = from_timestamp.and_then(|ts| DateTime::::from_timestamp(ts as i64, 0).map(|dt| dt.naive_utc())); + let notifications = self.database.notifications()?.get_notifications_by_device_id(device_id, from_datetime)?; + Ok(notifications.into_iter().filter_map(|n| map_notification(n, &localizer)).collect()) + } + + pub fn mark_all_as_read(&self, device_id: &str) -> Result> { + Ok(self.database.notifications()?.mark_all_as_read(device_id)?) + } +} diff --git a/core/apps/api/src/devices/clients/portfolio.rs b/core/apps/api/src/devices/clients/portfolio.rs new file mode 100644 index 0000000000..4fd97f0002 --- /dev/null +++ b/core/apps/api/src/devices/clients/portfolio.rs @@ -0,0 +1 @@ +pub use portfolio::PortfolioClient; diff --git a/core/apps/api/src/devices/clients/rewards.rs b/core/apps/api/src/devices/clients/rewards.rs new file mode 100644 index 0000000000..e61732d285 --- /dev/null +++ b/core/apps/api/src/devices/clients/rewards.rs @@ -0,0 +1,446 @@ +use std::error::Error; + +use api_connector::PusherClient; +use chrono::NaiveDateTime; +use gem_rewards::{IpSecurityClient, ReferralError, RewardsError, RiskScoreConfig, RiskScoringInput, UsernameError, evaluate_risk}; +use primitives::rewards::{RewardRedemptionOption, RewardStatus}; +use primitives::{ConfigKey, Localize, NaiveDateTimeExt, Platform, ReferralLeaderboard, RewardEvent, Rewards, WalletId, now}; +use storage::models::DeviceRow; +use storage::{ + ConfigCacher, Database, NewWalletRow, ReferralValidationError, RewardsRedemptionsRepository, RewardsRepository, RiskSignalsRepository, WalletSource, WalletType, + WalletsRepository, +}; +use streamer::{RewardsNotificationPayload, StreamProducer, StreamProducerQueue}; + +enum ReferralProcessResult { + Success { risk_signal_id: i32, referrer_status: RewardStatus }, + Failed(ReferralError), + RiskScoreExceeded(i32, ReferralError), +} + +struct ReferralLimitsConfig { + tor_allowed: bool, + ineligible_countries: Vec, + daily_limit: i64, + device_daily_limit: i64, + ip_daily_limit: i64, + ip_weekly_limit: i64, + country_daily_limit: i64, +} + +fn referrer_multiplier(config: &ConfigCacher, status: &RewardStatus) -> Result { + if *status == RewardStatus::Trusted { + config.get_i64(ConfigKey::ReferralTrustedMultiplier) + } else { + config.get_i64(ConfigKey::ReferralVerifiedMultiplier) + } +} + +pub struct RewardsClient { + db: Database, + config: ConfigCacher, + stream_producer: StreamProducer, + ip_security_client: IpSecurityClient, + pusher: PusherClient, +} + +impl RewardsClient { + pub fn new(database: Database, stream_producer: StreamProducer, ip_security_client: IpSecurityClient, pusher: PusherClient) -> Self { + let config = ConfigCacher::new(database.clone()); + Self { + db: database, + config, + stream_producer, + ip_security_client, + pusher, + } + } + + fn map_username_error(&self, error: Box, locale: &str) -> RewardsError { + if let Some(username_error) = error.downcast_ref::() { + RewardsError::Username(username_error.localize(locale)) + } else { + RewardsError::Username(error.to_string()) + } + } + + pub fn get_rewards_by_wallet_id(&self, wallet_id: i32) -> Result> { + match self.db.rewards()?.get_reward_by_wallet_id(wallet_id) { + Ok(r) => Ok(r), + Err(error) if error.is_not_found() => Ok(Rewards::default()), + Err(e) => Err(e.into()), + } + } + + pub fn get_rewards_events_by_wallet_id(&self, wallet_id: i32) -> Result, Box> { + Ok(self.db.rewards()?.get_reward_events_by_wallet_id(wallet_id)?) + } + + pub fn get_rewards_leaderboard(&self) -> Result> { + Ok(self.db.rewards()?.get_rewards_leaderboard()?) + } + + pub fn get_rewards_redemption_option(&self, code: &str) -> Result> { + Ok(self.db.rewards_redemptions()?.get_redemption_option(code)?) + } + + pub async fn create_username(&self, wallet_identifier: &str, code: &str, device_id: i32, ip_address: &str, locale: &str) -> Result> { + let wallet = self.db.wallets()?.get_wallet(wallet_identifier)?; + + let global_daily_limit = self.config.get_i64(ConfigKey::UsernameCreationGlobalDailyLimit)?; + let ip_limit = self.config.get_i64(ConfigKey::UsernameCreationPerIp)?; + let device_limit = self.config.get_i64(ConfigKey::UsernameCreationPerDevice)?; + let country_daily_limit = self.config.get_i64(ConfigKey::UsernameCreationPerCountryDailyLimit)?; + + self.ip_security_client + .check_username_creation_limits(ip_address, device_id, global_daily_limit, ip_limit, device_limit) + .await + .map_err(|e| self.map_username_error(e, locale))?; + + let ip_result = self.ip_security_client.check_ip(ip_address).await?; + + self.ip_security_client + .check_username_creation_country_limit(&ip_result.country_code, country_daily_limit) + .await + .map_err(|e| self.map_username_error(e, locale))?; + + let (rewards, event_id) = self + .db + .rewards()? + .create_reward(wallet.id, code) + .map_err(|e| RewardsError::Username(UsernameError::Validation(e).localize(locale)))?; + self.ip_security_client.record_username_creation(&ip_result.country_code, ip_address, device_id).await?; + self.publish_events(vec![event_id]).await?; + Ok(rewards) + } + + pub async fn use_referral_code( + &self, + device: &DeviceRow, + address: &str, + code: &str, + ip_address: &str, + user_agent: &str, + ) -> Result, Box> { + let locale = device.locale.as_str(); + let wallet_identifier = WalletId::Multicoin(address.to_string()).id(); + let wallet = self.db.wallets()?.get_or_create_wallet(NewWalletRow { + identifier: wallet_identifier, + wallet_type: WalletType::Multicoin, + source: WalletSource::Import, + })?; + + let mut client = self.db.client()?; + + let referrer_username = client.rewards().get_referral_code(code)?.ok_or_else(|| { + let error = ReferralError::from(ReferralValidationError::CodeDoesNotExist); + RewardsError::Referral(error.localize(locale)) + })?; + + if client.rewards().is_pending_referral(&referrer_username, wallet.id, device.id)? { + let referrer_info = client.rewards().get_referrer_info(&referrer_username)?; + if !referrer_info.status.is_verified() { + return Err(RewardsError::Referral(ReferralError::from(ReferralValidationError::RewardsNotEnabled(referrer_username.clone())).localize(locale)).into()); + } + let events = client + .rewards() + .use_or_verify_referral(&referrer_username, &referrer_info.status, wallet.id, device.id, 0)?; + return Ok(events); + } + drop(client); + + match self.validate_and_score_referral(device, wallet.id, &referrer_username, ip_address, user_agent).await { + ReferralProcessResult::Success { risk_signal_id, referrer_status } => { + let events = self + .db + .rewards()? + .use_or_verify_referral(&referrer_username, &referrer_status, wallet.id, device.id, risk_signal_id)?; + Ok(events) + } + ReferralProcessResult::Failed(error) => { + let _ = self.db.rewards()?.add_referral_attempt(&referrer_username, wallet.id, device.id, None, &error.to_string()); + Err(RewardsError::Referral(error.localize(locale)).into()) + } + ReferralProcessResult::RiskScoreExceeded(risk_signal_id, error) => { + let _ = self + .db + .rewards()? + .add_referral_attempt(&referrer_username, wallet.id, device.id, Some(risk_signal_id), &error.to_string()); + Err(RewardsError::Referral(error.localize(locale)).into()) + } + } + } + + async fn validate_and_score_referral(&self, device: &DeviceRow, wallet_id: i32, referrer_username: &str, ip_address: &str, user_agent: &str) -> ReferralProcessResult { + match self.validate_and_score_referral_inner(device, wallet_id, referrer_username, ip_address, user_agent).await { + Ok(result) => result, + Err(e) => ReferralProcessResult::Failed(e), + } + } + + async fn validate_and_score_referral_inner( + &self, + device: &DeviceRow, + wallet_id: i32, + referrer_username: &str, + ip_address: &str, + user_agent: &str, + ) -> Result { + let referrer_info; + { + let mut client = self.db.client()?; + + referrer_info = client.rewards().get_referrer_info(referrer_username)?; + if !referrer_info.status.is_verified() { + return Err(ReferralValidationError::RewardsNotEnabled(referrer_username.to_string()).into()); + } + + let multiplier = referrer_multiplier(&self.config, &referrer_info.status)?; + let current = now(); + let cooldown = self.config.get_duration(ConfigKey::ReferralCooldown)?; + + self.check_referrer_rate_limit(&mut client, referrer_username, current.days_ago(7), ConfigKey::ReferralPerUserWeekly, multiplier)?; + self.check_referrer_rate_limit(&mut client, referrer_username, current.days_ago(1), ConfigKey::ReferralPerUserDaily, multiplier)?; + self.check_referrer_rate_limit(&mut client, referrer_username, current.hours_ago(1), ConfigKey::ReferralPerUserHourly, multiplier)?; + + if client.rewards().count_referrals_since(referrer_username, current.ago(cooldown))? >= 1 { + return Err(ReferralError::ReferrerLimitReached(ConfigKey::ReferralCooldown)); + } + + let eligibility = self.config.get_duration(ConfigKey::ReferralEligibility)?; + let eligibility_days = (eligibility.as_secs() / 86400) as i64; + client + .rewards() + .validate_referral_use(referrer_username, referrer_info.wallet_id, wallet_id, device.id, device.created_at, eligibility_days)?; + } + + if *device.platform == Platform::Android { + match self.pusher.is_device_token_valid(&device.token, device.platform.as_i32()).await { + Ok(true) => {} + Ok(false) => return Err(ReferralError::InvalidDeviceToken("token_not_registered".to_string())), + Err(e) => return Err(ReferralError::InvalidDeviceToken(e.to_string())), + } + } + + let ip_result = self.ip_security_client.check_ip(ip_address).await?; + let limits_config = self.load_referral_limits_config()?; + let risk_score_config = self.load_risk_score_config()?; + let since = now().ago(risk_score_config.lookback); + + let mut client = self.db.client()?; + + if client.count_disabled_users_by_device(device.id, since)? > 0 { + return Err(ReferralError::LimitReached(ConfigKey::ReferralPerDeviceDaily)); + } + + let scoring_input = RiskScoringInput { + username: referrer_username.to_string(), + device_id: device.id, + device_platform: *device.platform, + device_platform_store: *device.platform_store, + device_os: device.os.clone(), + device_model: device.model.clone(), + device_locale: device.locale.as_str().to_string(), + device_currency: device.currency.clone(), + ip_result, + referrer_status: referrer_info.status, + referrer_referral_count: referrer_info.referral_count as i64, + user_agent: user_agent.to_string(), + }; + + let signal_input = scoring_input.to_signal_input(); + let fingerprint = signal_input.generate_fingerprint(); + + if client.has_fingerprint_for_referrer(&fingerprint, referrer_username, since).unwrap_or(false) { + return Err(ReferralError::DuplicateAttempt); + } + + if !limits_config.tor_allowed && scoring_input.ip_result.is_tor { + return Err(ReferralError::IpTorNotAllowed); + } + + if limits_config.ineligible_countries.contains(&scoring_input.ip_result.country_code) { + return Err(ReferralError::IpCountryIneligible(scoring_input.ip_result.country_code.clone())); + } + + let one_day_ago = now().days_ago(1); + self.check_global_rate_limit(&mut client, None, one_day_ago, limits_config.daily_limit, ConfigKey::ReferralUseDailyLimit)?; + self.check_device_rate_limit(&mut client, scoring_input.device_id, one_day_ago, limits_config.device_daily_limit)?; + self.check_global_rate_limit( + &mut client, + Some(&scoring_input.ip_result.ip_address), + one_day_ago, + limits_config.ip_daily_limit, + ConfigKey::ReferralPerIpDaily, + )?; + self.check_global_rate_limit( + &mut client, + Some(&scoring_input.ip_result.ip_address), + now().days_ago(7), + limits_config.ip_weekly_limit, + ConfigKey::ReferralPerIpWeekly, + )?; + self.check_country_rate_limit(&mut client, &scoring_input.ip_result.country_code, one_day_ago, limits_config.country_daily_limit)?; + + let existing_signals = client.get_matching_risk_signals( + &fingerprint, + &signal_input.ip_address, + &signal_input.ip_isp, + &signal_input.device_model, + signal_input.device_id, + since, + )?; + + let device_model_ring_count = + client.count_unique_referrers_for_device_model_pattern(&signal_input.device_model, signal_input.device_platform, &signal_input.device_locale, since)?; + let ip_abuser_count = client.count_disabled_users_by_ip(&signal_input.ip_address, since)?; + let cross_referrer_fingerprint_count = client.count_unique_referrers_for_fingerprint(&fingerprint, since)?; + let referrer_country_count = client.count_unique_countries_for_referrer(referrer_username, since)?; + let referrer_device_count = client.count_unique_devices_for_referrer(referrer_username, since)?; + + let risk_result = evaluate_risk( + &scoring_input, + &existing_signals, + device_model_ring_count, + ip_abuser_count, + cross_referrer_fingerprint_count, + referrer_country_count, + referrer_device_count, + &risk_score_config, + ); + let risk_signal_id = client.add_risk_signal(risk_result.signal)?; + + if !risk_result.score.is_allowed { + return Ok(ReferralProcessResult::RiskScoreExceeded( + risk_signal_id, + ReferralError::RiskScoreExceeded { + score: risk_result.score.score, + max_allowed: risk_score_config.max_allowed_score, + }, + )); + } + + Ok(ReferralProcessResult::Success { + risk_signal_id, + referrer_status: referrer_info.status, + }) + } + + fn check_referrer_rate_limit( + &self, + client: &mut storage::DatabaseClient, + referrer_username: &str, + since: NaiveDateTime, + key: ConfigKey, + multiplier: i64, + ) -> Result<(), ReferralError> { + let count = client.rewards().count_referrals_since(referrer_username, since)?; + let limit = self.config.get_i64(key.clone())? * multiplier; + if count >= limit { + return Err(ReferralError::ReferrerLimitReached(key)); + } + Ok(()) + } + + fn check_global_rate_limit( + &self, + client: &mut storage::DatabaseClient, + ip_address: Option<&str>, + since: NaiveDateTime, + limit: i64, + key: ConfigKey, + ) -> Result<(), ReferralError> { + if client.count_signals_since(ip_address, since)? >= limit { + return Err(ReferralError::LimitReached(key)); + } + Ok(()) + } + + fn check_device_rate_limit(&self, client: &mut storage::DatabaseClient, device_id: i32, since: NaiveDateTime, limit: i64) -> Result<(), ReferralError> { + if client.count_signals_for_device_id(device_id, since)? >= limit { + return Err(ReferralError::LimitReached(ConfigKey::ReferralPerDeviceDaily)); + } + Ok(()) + } + + fn check_country_rate_limit(&self, client: &mut storage::DatabaseClient, country_code: &str, since: NaiveDateTime, limit: i64) -> Result<(), ReferralError> { + if client.count_signals_for_country(country_code, since)? >= limit { + return Err(ReferralError::LimitReached(ConfigKey::ReferralPerCountryDaily)); + } + Ok(()) + } + + fn load_referral_limits_config(&self) -> Result { + Ok(ReferralLimitsConfig { + tor_allowed: self.config.get_bool(ConfigKey::ReferralIpTorAllowed)?, + ineligible_countries: self.config.get_vec_string(ConfigKey::ReferralIneligibleCountries)?, + daily_limit: self.config.get_i64(ConfigKey::ReferralUseDailyLimit)?, + device_daily_limit: self.config.get_i64(ConfigKey::ReferralPerDeviceDaily)?, + ip_daily_limit: self.config.get_i64(ConfigKey::ReferralPerIpDaily)?, + ip_weekly_limit: self.config.get_i64(ConfigKey::ReferralPerIpWeekly)?, + country_daily_limit: self.config.get_i64(ConfigKey::ReferralPerCountryDaily)?, + }) + } + + fn load_risk_score_config(&self) -> Result { + Ok(RiskScoreConfig { + fingerprint_match_penalty_per_referrer: self.config.get_i64(ConfigKey::ReferralRiskScoreFingerprintMatchPerReferrer)?, + fingerprint_match_max_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreFingerprintMatchMaxPenalty)?, + ip_reuse_score: self.config.get_i64(ConfigKey::ReferralRiskScoreIpReuse)?, + isp_model_match_score: self.config.get_i64(ConfigKey::ReferralRiskScoreIspModelMatch)?, + device_id_reuse_penalty_per_referrer: self.config.get_i64(ConfigKey::ReferralRiskScoreDeviceIdReusePerReferrer)?, + device_id_reuse_max_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreDeviceIdReuseMaxPenalty)?, + ineligible_ip_type_score: self.config.get_i64(ConfigKey::ReferralRiskScoreIneligibleIpType)?, + blocked_ip_types: self.config.get_vec(ConfigKey::ReferralBlockedIpTypes)?, + blocked_ip_type_penalty: self.config.get_i64(ConfigKey::ReferralBlockedIpTypePenalty)?, + max_abuse_score: self.config.get_i64(ConfigKey::ReferralMaxAbuseScore)?, + penalty_isps: self.config.get_vec_string(ConfigKey::ReferralPenaltyIsps)?, + isp_penalty_score: self.config.get_i64(ConfigKey::ReferralPenaltyIspsScore)?, + verified_user_reduction: self.config.get_i64(ConfigKey::ReferralRiskScoreVerifiedUserReduction)?, + early_referral_reduction_initial: self.config.get_i64(ConfigKey::ReferralRiskScoreEarlyReferralReductionInitial)?, + early_referral_reduction_step: self.config.get_i64(ConfigKey::ReferralRiskScoreEarlyReferralReductionStep)?, + max_allowed_score: self.config.get_i64(ConfigKey::ReferralRiskScoreMaxAllowed)?, + same_referrer_pattern_threshold: self.config.get_i64(ConfigKey::ReferralRiskScoreSameReferrerPatternThreshold)?, + same_referrer_pattern_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreSameReferrerPatternPenalty)?, + same_referrer_fingerprint_threshold: self.config.get_i64(ConfigKey::ReferralRiskScoreSameReferrerFingerprintThreshold)?, + same_referrer_fingerprint_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreSameReferrerFingerprintPenalty)?, + same_referrer_device_model_threshold: self.config.get_i64(ConfigKey::ReferralRiskScoreSameReferrerDeviceModelThreshold)?, + same_referrer_device_model_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreSameReferrerDeviceModelPenalty)?, + device_model_ring_threshold: self.config.get_i64(ConfigKey::ReferralRiskScoreDeviceModelRingThreshold)?, + device_model_ring_penalty_per_member: self.config.get_i64(ConfigKey::ReferralRiskScoreDeviceModelRingPenaltyPerMember)?, + lookback: self.config.get_duration(ConfigKey::ReferralRiskScoreLookback)?, + high_risk_platform_stores: self.config.get_vec_string(ConfigKey::ReferralRiskScoreHighRiskPlatformStores)?, + high_risk_platform_store_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreHighRiskPlatformStorePenalty)?, + high_risk_countries: self.config.get_vec_string(ConfigKey::ReferralRiskScoreHighRiskCountries)?, + high_risk_country_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreHighRiskCountryPenalty)?, + high_risk_locales: self.config.get_vec_string(ConfigKey::ReferralRiskScoreHighRiskLocales)?, + high_risk_locale_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreHighRiskLocalePenalty)?, + high_risk_device_models: self.config.get_vec_string(ConfigKey::ReferralRiskScoreHighRiskDeviceModels)?, + high_risk_device_model_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreHighRiskDeviceModelPenalty)?, + high_risk_user_agents: self.config.get_vec_string(ConfigKey::ReferralRiskScoreHighRiskUserAgents)?, + high_risk_user_agent_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreHighRiskUserAgentPenalty)?, + ip_history_penalty_per_abuser: self.config.get_i64(ConfigKey::ReferralRiskScoreIpHistoryPenaltyPerAbuser)?, + ip_history_max_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreIpHistoryMaxPenalty)?, + velocity_window: self.config.get_duration(ConfigKey::ReferralAbuseVelocityWindow)?, + velocity_divisor: self.config.get_i64(ConfigKey::ReferralAbuseVelocityDivisor)?, + velocity_penalty: self.config.get_i64(ConfigKey::ReferralAbuseVelocityPenaltyPerSignal)?, + referral_per_user_daily: self.config.get_i64(ConfigKey::ReferralPerUserDaily)?, + verified_multiplier: self.config.get_i64(ConfigKey::ReferralVerifiedMultiplier)?, + trusted_multiplier: self.config.get_i64(ConfigKey::ReferralTrustedMultiplier)?, + cross_referrer_device_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreCrossReferrerDevicePenalty)?, + cross_referrer_fingerprint_threshold: self.config.get_i64(ConfigKey::ReferralRiskScoreCrossReferrerFingerprintThreshold)?, + cross_referrer_fingerprint_penalty: self.config.get_i64(ConfigKey::ReferralRiskScoreCrossReferrerFingerprintPenalty)?, + country_diversity_threshold: self.config.get_i64(ConfigKey::ReferralRiskScoreCountryDiversityThreshold)?, + country_diversity_penalty_per_country: self.config.get_i64(ConfigKey::ReferralRiskScoreCountryDiversityPenaltyPerCountry)?, + device_farming_threshold: self.config.get_i64(ConfigKey::ReferralRiskScoreDeviceFarmingThreshold)?, + device_farming_penalty_per_device: self.config.get_i64(ConfigKey::ReferralRiskScoreDeviceFarmingPenaltyPerDevice)?, + }) + } + + async fn publish_events(&self, event_ids: Vec) -> Result<(), Box> { + self.stream_producer + .publish_rewards_events(event_ids.into_iter().map(RewardsNotificationPayload::new).collect()) + .await?; + Ok(()) + } +} diff --git a/core/apps/api/src/devices/clients/rewards_redemption.rs b/core/apps/api/src/devices/clients/rewards_redemption.rs new file mode 100644 index 0000000000..fd00a4296d --- /dev/null +++ b/core/apps/api/src/devices/clients/rewards_redemption.rs @@ -0,0 +1,68 @@ +use gem_rewards::{RewardsRedemptionError, redeem_points}; +use primitives::rewards::{RedemptionResult, Rewards}; +use primitives::{ConfigKey, NaiveDateTimeExt, now}; +use storage::{ConfigCacher, Database, RewardsRedemptionsRepository, RewardsRepository}; +use streamer::{StreamProducer, StreamProducerQueue}; + +pub struct RewardsRedemptionClient { + database: Database, + config: ConfigCacher, + stream_producer: StreamProducer, +} + +impl RewardsRedemptionClient { + pub fn new(database: Database, stream_producer: StreamProducer) -> Self { + let config = ConfigCacher::new(database.clone()); + Self { + database, + config, + stream_producer, + } + } + + pub async fn redeem_by_wallet_id(&self, wallet_id: i32, id: &str, device_id: i32) -> Result> { + let rewards = self.database.rewards()?.get_reward_by_wallet_id(wallet_id)?; + + if !rewards.status.is_verified() { + return Err(RewardsRedemptionError::NotEligible("Not eligible for rewards".to_string()).into()); + } + + let username = rewards.code.clone().ok_or(RewardsRedemptionError::NoUsername)?; + + self.check_redemption_limits(&username, &rewards)?; + + let response = redeem_points(&mut self.database.client()?, &username, id, device_id, wallet_id)?; + self.stream_producer + .publish_rewards_redemption(streamer::RewardsRedemptionPayload::new(response.redemption_id)) + .await?; + + Ok(response.result) + } + + fn check_redemption_limits(&self, username: &str, rewards: &Rewards) -> Result<(), Box> { + let current = now(); + + if rewards.created_at > current.ago(self.config.get_duration(ConfigKey::RedemptionMinAccountAge)?) { + return Err(RewardsRedemptionError::AccountTooNew.into()); + } + + let cooldown_since = current.ago(self.config.get_duration(ConfigKey::RedemptionCooldownAfterReferral)?); + if self.database.rewards()?.count_referrals_since(username, cooldown_since)? > 0 { + return Err(RewardsRedemptionError::CooldownNotElapsed.into()); + } + + let daily_limit = self.config.get_i64(ConfigKey::RedemptionPerUserDaily)?; + let daily_count = self.database.rewards_redemptions()?.count_redemptions_since_days(username, 1)?; + if daily_count >= daily_limit { + return Err(RewardsRedemptionError::DailyLimitReached.into()); + } + + let weekly_limit = self.config.get_i64(ConfigKey::RedemptionPerUserWeekly)?; + let weekly_count = self.database.rewards_redemptions()?.count_redemptions_since_days(username, 7)?; + if weekly_count >= weekly_limit { + return Err(RewardsRedemptionError::WeeklyLimitReached.into()); + } + + Ok(()) + } +} diff --git a/core/apps/api/src/devices/clients/scan.rs b/core/apps/api/src/devices/clients/scan.rs new file mode 100644 index 0000000000..df4bc5ac70 --- /dev/null +++ b/core/apps/api/src/devices/clients/scan.rs @@ -0,0 +1,110 @@ +use gem_client::ReqwestClient; +use gem_tracing::error_with_fields; +use primitives::{ScanTransaction, ScanTransactionPayload}; +use rocket::futures::future; +use security_provider::providers::goplus::GoPlusProvider; +use security_provider::providers::hashdit::HashDitProvider; +use security_provider::{AddressTarget, ScanProvider, ScanResult, TokenTarget}; +use settings::Settings; +use std::error::Error; +use std::sync::Arc; +use storage::{Database, ScanAddressesRepository}; + +pub struct ScanProviderFactory {} + +impl ScanProviderFactory { + pub fn create_providers(settings: &Settings) -> Vec> { + let client = gem_client::builder().timeout(settings.scan.timeout).build().unwrap(); + + vec![ + Box::new(GoPlusProvider::new( + ReqwestClient::new(settings.scan.goplus.url.clone(), client.clone()), + &settings.scan.goplus.key.public, + )), + Box::new(HashDitProvider::new( + ReqwestClient::new(settings.scan.hashdit.url.clone(), client.clone()), + &settings.scan.hashdit.key.public, + &settings.scan.hashdit.key.secret, + )), + ] + } +} + +#[derive(Clone)] +pub struct ScanClient { + database: Database, + pub security_providers: Vec>, +} + +impl ScanClient { + pub fn new(database: Database, security_providers: Vec>) -> Self { + let security_providers = security_providers.into_iter().map(Arc::from).collect(); + Self { database, security_providers } + } + + pub async fn get_scan_transaction(&self, payload: ScanTransactionPayload) -> Result> { + let local_scan = self.get_scan_transaction_local(payload.clone())?; + if local_scan.is_malicious { + return Ok(local_scan); + } + + let target = AddressTarget { + chain: payload.origin.asset_id.chain, + address: payload.origin.address.clone(), + }; + let providers_scan = self.scan_address_providers(target).await?; + + Ok(ScanTransaction { + is_malicious: providers_scan.iter().any(|scan| scan.is_malicious), + is_memo_required: local_scan.is_memo_required, + }) + } + + fn get_scan_transaction_local(&self, payload: ScanTransactionPayload) -> Result> { + let queries = [ + (payload.origin.asset_id.chain, payload.origin.address.as_str()), + (payload.target.asset_id.chain, payload.target.address.as_str()), + ]; + let addresses = self.database.scan_addresses()?.get_scan_addresses(&queries)?; + let is_malicious = addresses.iter().any(|address| address.is_fraudulent); + let is_memo_required = addresses.iter().any(|address| address.is_memo_required); + + Ok(ScanTransaction { is_malicious, is_memo_required }) + } + + pub async fn scan_address_providers(&self, target: AddressTarget) -> Result>, Box> { + let results = future::join_all(self.security_providers.iter().map(|provider| provider.scan_address(&target))) + .await + .into_iter() + .filter_map(|result| match result { + Err(e) => { + error_with_fields!("error scanning", e.as_ref(),); + None + } + Ok(result) => Some(result), + }) + .collect(); + Ok(results) + } + + #[allow(dead_code)] + pub async fn scan_token(&self, chain: primitives::Chain, token_id: &str) -> Result>, Box> { + let target = TokenTarget { + token_id: token_id.to_string(), + chain, + }; + + let results = future::join_all(self.security_providers.iter().map(|provider| provider.scan_token(&target))) + .await + .into_iter() + .filter_map(|result| match result { + Err(e) => { + error_with_fields!("error scanning token", e.as_ref(),); + None + } + Ok(result) => Some(result), + }) + .collect(); + Ok(results) + } +} diff --git a/core/apps/api/src/devices/clients/transactions.rs b/core/apps/api/src/devices/clients/transactions.rs new file mode 100644 index 0000000000..2ebb730d70 --- /dev/null +++ b/core/apps/api/src/devices/clients/transactions.rs @@ -0,0 +1,50 @@ +use std::error::Error; + +use primitives::{AssetId, Transaction, TransactionId, TransactionsResponse}; +use storage::{Database, ScanAddressesRepository, TransactionsRepository, WalletsRepository}; + +use chrono::{DateTime, Utc}; + +pub struct TransactionsClient { + database: Database, +} + +impl TransactionsClient { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub async fn get_transactions_by_wallet_id( + &self, + device_id: &str, + device_row_id: i32, + wallet_id: i32, + asset_id: Option, + from_timestamp: Option, + ) -> Result> { + let subscriptions = self.database.wallets()?.get_subscriptions_by_wallet_id(device_row_id, wallet_id)?; + let addresses = subscriptions.iter().map(|(_, addr)| addr.address.clone()).collect::>(); + let chains = subscriptions.iter().map(|(sub, _)| sub.chain.0.as_ref().to_string()).collect::>(); + let from_datetime = from_timestamp.and_then(|ts| DateTime::::from_timestamp(ts as i64, 0).map(|dt| dt.naive_utc())); + let transactions = self + .database + .transactions()? + .get_transactions_by_device_id(device_id, addresses.clone(), chains, asset_id, from_datetime)? + .into_iter() + .map(|x| x.as_primitive(addresses.clone()).finalize(addresses.clone())) + .collect::>(); + let address_names = self + .database + .scan_addresses()? + .get_scan_addresses_by_addresses(transactions.iter().flat_map(|x| x.addresses()).collect())? + .into_iter() + .filter_map(|x| x.as_primitive()) + .collect(); + + Ok(TransactionsResponse::new(transactions, address_names)) + } + + pub fn get_transaction_by_id(&self, id: &TransactionId) -> Result> { + Ok(self.database.transactions()?.get_transaction_by_id(id)?.as_primitive(vec![])) + } +} diff --git a/core/apps/api/src/devices/clients/wallet_configuration.rs b/core/apps/api/src/devices/clients/wallet_configuration.rs new file mode 100644 index 0000000000..2897aea6ba --- /dev/null +++ b/core/apps/api/src/devices/clients/wallet_configuration.rs @@ -0,0 +1,87 @@ +use std::collections::HashSet; +use std::error::Error; + +use cacher::{CacheKey, CacherClient}; +use futures::future::join_all; +use primitives::{AddressStatus, Chain, ChainAddress, WalletConfiguration, WalletConfigurationResult, WalletId}; +use settings_chain::ChainProviders; +use storage::{Database, WalletsRepository}; + +const ADDRESS_STATUS_CHAINS: [Chain; 1] = [Chain::Tron]; + +pub struct WalletConfigurationClient { + database: Database, + providers: ChainProviders, + cacher: CacherClient, +} + +impl WalletConfigurationClient { + pub fn new(database: Database, providers: ChainProviders, cacher: CacherClient) -> Self { + Self { database, providers, cacher } + } + + pub async fn get_configuration(&self, device_id: i32, wallet_id: i32, wallet_identifier: WalletId) -> Result> { + Ok(WalletConfigurationResult { + wallet_id: wallet_identifier, + configuration: WalletConfiguration { + multi_signature_accounts: self.multi_signature_accounts(device_id, wallet_id).await?, + }, + }) + } + + async fn multi_signature_accounts(&self, device_id: i32, wallet_id: i32) -> Result, Box> { + Ok(join_all( + self.subscribed_addresses(device_id, wallet_id)? + .into_iter() + .map(|address| async move { self.has_multi_signature_status(&address).await.then_some(address) }), + ) + .await + .into_iter() + .flatten() + .collect()) + } + + async fn has_multi_signature_status(&self, address: &ChainAddress) -> bool { + self.get_statuses(address).await.is_some_and(|statuses| statuses.contains(&AddressStatus::MultiSignature)) + } + + fn subscribed_addresses(&self, device_id: i32, wallet_id: i32) -> Result, Box> { + Ok(self + .database + .wallets()? + .get_subscriptions_by_wallet_id(device_id, wallet_id)? + .into_iter() + .filter_map(|(subscription, address)| { + ADDRESS_STATUS_CHAINS + .contains(&subscription.chain.0) + .then_some(ChainAddress::new(subscription.chain.0, address.address)) + }) + .collect()) + } + + async fn get_statuses(&self, address: &ChainAddress) -> Option> { + if let Some(statuses) = self + .cacher + .get_cached_optional::>(cache_key(address)) + .await + .ok() + .flatten() + .filter(|statuses| !statuses.is_empty()) + { + return Some(statuses); + } + + let statuses = self.providers.get_address_status(address.chain, address.address.clone()).await.ok()?; + if statuses.is_empty() { + return None; + } + + let _ = self.cacher.set_cached(cache_key(address), &statuses).await; + + Some(statuses) + } +} + +fn cache_key(address: &ChainAddress) -> CacheKey<'_> { + CacheKey::AddressStatus(address.chain.as_ref(), &address.address) +} diff --git a/core/apps/api/src/devices/clients/wallets.rs b/core/apps/api/src/devices/clients/wallets.rs new file mode 100644 index 0000000000..632a1ea8e6 --- /dev/null +++ b/core/apps/api/src/devices/clients/wallets.rs @@ -0,0 +1,130 @@ +use std::collections::{BTreeMap, HashMap}; +use std::error::Error; + +use primitives::{Chain, WalletId, WalletSource, WalletSubscription, WalletSubscriptionChains, WalletSubscriptionLegacy}; +use serde::Deserialize; +use storage::models::NewWalletRow; +use storage::sql_types::WalletType; +use storage::{Database, WalletsRepository}; +use streamer::{ChainAddressPayload, StreamProducer, StreamProducerQueue}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum WalletSubscriptionInput { + New(WalletSubscription), + Legacy(WalletSubscriptionLegacy), +} + +impl WalletSubscriptionInput { + pub fn into_wallet_subscription(self) -> WalletSubscription { + match self { + Self::New(ws) => ws, + Self::Legacy(legacy) => WalletSubscription::from(legacy), + } + } +} + +#[derive(Clone)] +pub struct WalletsClient { + database: Database, + stream_producer: StreamProducer, +} + +impl WalletsClient { + pub fn new(database: Database, stream_producer: StreamProducer) -> Self { + Self { database, stream_producer } + } + + pub fn get_subscriptions(&self, device_row_id: i32) -> Result, Box> { + let rows = self.database.wallets()?.get_subscriptions(device_row_id)?; + + Ok(rows + .into_iter() + .fold( + BTreeMap::)>::new(), + |mut acc, (wallet_row, subscription_row, _address_row)| { + let wallet_id = wallet_row.wallet_id.0.clone(); + acc.entry(wallet_id.id()).or_insert((wallet_id, Vec::new())).1.push(subscription_row.chain.0); + acc + }, + ) + .into_values() + .map(|(wallet_id, mut chains)| { + chains.sort_by(|a, b| a.as_ref().cmp(b.as_ref())); + WalletSubscriptionChains { wallet_id, chains } + }) + .collect()) + } + + pub async fn add_subscriptions(&self, device_row_id: i32, wallet_subscriptions: Vec) -> Result> { + if wallet_subscriptions.is_empty() { + return Ok(0); + } + + let mut store = self.database.wallets()?; + + let identifiers: Vec = wallet_subscriptions.iter().map(|x| x.wallet_id.id()).collect(); + let mut wallet_ids: HashMap = store.get_wallets(identifiers)?.into_iter().map(|x| (x.wallet_id.id(), x.id)).collect(); + + let new_wallets: Vec = wallet_subscriptions + .iter() + .filter(|x| !wallet_ids.contains_key(&x.wallet_id.id())) + .map(|x| NewWalletRow { + identifier: x.wallet_id.id(), + wallet_type: WalletType::from(x.wallet_id.wallet_type()), + source: storage::sql_types::WalletSource::from(x.source.clone().unwrap_or(WalletSource::Import)), + }) + .collect(); + + if !new_wallets.is_empty() { + let new_identifiers: Vec = new_wallets.iter().map(|x| x.identifier.clone()).collect(); + store.create_wallets(new_wallets)?; + wallet_ids.extend(store.get_wallets(new_identifiers)?.into_iter().map(|x| (x.wallet_id.id(), x.id))); + } + + let subscriptions: Vec<(i32, Chain, String)> = wallet_subscriptions + .iter() + .filter_map(|ws| { + wallet_ids + .get(&ws.wallet_id.id()) + .map(|&wallet_id| ws.chain_addresses().into_iter().map(move |ca| (wallet_id, ca.chain, ca.address))) + }) + .flatten() + .collect(); + + let count = store.add_subscriptions(device_row_id, subscriptions)?; + + let payload: Vec = wallet_subscriptions + .into_iter() + .filter(|x| x.source == Some(WalletSource::Import)) + .flat_map(|x| x.chain_addresses()) + .map(ChainAddressPayload::from) + .collect(); + + if !payload.is_empty() { + self.stream_producer.publish_new_addresses(payload).await?; + } + + Ok(count) + } + + pub async fn delete_subscriptions(&self, device_row_id: i32, subscriptions: Vec) -> Result> { + if subscriptions.is_empty() { + return Ok(0); + } + + let mut store = self.database.wallets()?; + + let identifiers: Vec = subscriptions.iter().map(|x| x.wallet_id.id()).collect(); + let wallet_ids: HashMap = store.get_wallets(identifiers)?.into_iter().map(|x| (x.wallet_id.id(), x.id)).collect(); + + let mut count = 0; + for ws in subscriptions { + if let Some(&wallet_id) = wallet_ids.get(&ws.wallet_id.id()) { + count += store.delete_wallet_chains(device_row_id, wallet_id, ws.chains)?; + } + } + + Ok(count) + } +} diff --git a/core/apps/api/src/devices/constants.rs b/core/apps/api/src/devices/constants.rs new file mode 100644 index 0000000000..1057466738 --- /dev/null +++ b/core/apps/api/src/devices/constants.rs @@ -0,0 +1,11 @@ +pub const HEADER_DEVICE_ID: &str = "x-device-id"; +pub const HEADER_WALLET_ID: &str = "x-wallet-id"; +pub const HEADER_DEVICE_SIGNATURE: &str = "x-device-signature"; +pub const HEADER_DEVICE_TIMESTAMP: &str = "x-device-timestamp"; +pub const HEADER_DEVICE_BODY_HASH: &str = "x-device-body-hash"; + +pub const DEVICE_ID_LENGTH: usize = 64; + +pub const AUTHORIZATION_HEADER: &str = "Authorization"; + +pub const DEBUG_FIAT_IP: &str = "210.138.184.59"; diff --git a/core/apps/api/src/devices/error.rs b/core/apps/api/src/devices/error.rs new file mode 100644 index 0000000000..8b70511202 --- /dev/null +++ b/core/apps/api/src/devices/error.rs @@ -0,0 +1,31 @@ +use std::fmt; + +pub enum DeviceError { + MissingHeader(&'static str), + InvalidDeviceId, + InvalidTimestamp, + TimestampExpired, + InvalidSignature, + DeviceNotFound, + WalletNotFound, + DatabaseUnavailable, + InvalidAuthorizationFormat, + DatabaseError, +} + +impl fmt::Display for DeviceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingHeader(name) => write!(f, "Missing header: {}", name), + Self::InvalidDeviceId => write!(f, "Invalid device ID"), + Self::InvalidTimestamp => write!(f, "Invalid timestamp"), + Self::TimestampExpired => write!(f, "Timestamp expired"), + Self::InvalidSignature => write!(f, "Invalid signature"), + Self::DeviceNotFound => write!(f, "Device not found"), + Self::WalletNotFound => write!(f, "Wallet not found"), + Self::DatabaseUnavailable => write!(f, "Database not available"), + Self::InvalidAuthorizationFormat => write!(f, "Invalid authorization format"), + Self::DatabaseError => write!(f, "Database error"), + } + } +} diff --git a/core/apps/api/src/devices/guard/auth.rs b/core/apps/api/src/devices/guard/auth.rs new file mode 100644 index 0000000000..9b3fae4741 --- /dev/null +++ b/core/apps/api/src/devices/guard/auth.rs @@ -0,0 +1,126 @@ +use rocket::Request; +use rocket::http::Status; +use rocket::outcome::Outcome::{Error, Success}; +use rocket::request::Outcome; +use storage::database::devices::DevicesStore; +use storage::models::{DeviceRow, WalletRow}; +use storage::{Database, DatabaseClient, WalletsRepository}; + +use crate::devices::auth_config::AuthConfig; +use crate::devices::constants::{DEVICE_ID_LENGTH, HEADER_DEVICE_ID, HEADER_WALLET_ID}; +use crate::devices::error::DeviceError; +use crate::devices::signature::{parse_auth_components, verify_request_signature}; +use crate::responders::cache_error; + +pub(super) struct AuthResult { + pub(super) device_id: String, + pub(super) wallet_id: Option, +} + +pub(super) fn auth_error_outcome(req: &Request<'_>, error: DeviceError, device_id: Option<&str>, wallet_id: Option<&str>) -> Outcome { + let status = match error { + DeviceError::MissingHeader(_) + | DeviceError::InvalidDeviceId + | DeviceError::InvalidTimestamp + | DeviceError::TimestampExpired + | DeviceError::InvalidSignature + | DeviceError::InvalidAuthorizationFormat => Status::Unauthorized, + DeviceError::DeviceNotFound | DeviceError::WalletNotFound => Status::NotFound, + DeviceError::DatabaseUnavailable | DeviceError::DatabaseError => Status::InternalServerError, + }; + let message = format_auth_error_message(&error, device_id, wallet_id); + cache_error(req, &message); + Error((status, message)) +} + +fn format_auth_error_message(error: &DeviceError, device_id: Option<&str>, wallet_id: Option<&str>) -> String { + let mut message = error.to_string(); + if let Some(id) = device_id { + message.push_str(&format!(" device_id={id}")); + } + if let Some(id) = wallet_id { + message.push_str(&format!(" wallet_id={id}")); + } + message +} + +pub(super) async fn authenticate(req: &Request<'_>) -> Result> { + let Success(config) = req.guard::<&rocket::State>().await else { + panic!("AuthConfig not configured"); + }; + + if !config.enabled { + let device_id = req + .headers() + .get_one(HEADER_DEVICE_ID) + .ok_or_else(|| auth_error_outcome(req, DeviceError::MissingHeader(HEADER_DEVICE_ID), None, None))?; + + if device_id.len() != DEVICE_ID_LENGTH { + return Err(auth_error_outcome(req, DeviceError::InvalidDeviceId, Some(device_id), None)); + } + + return Ok(AuthResult { + device_id: device_id.to_string(), + wallet_id: req.headers().get_one(HEADER_WALLET_ID).map(|s| s.to_string()), + }); + } + + let components = parse_auth_components(req).map_err(|e| auth_error_outcome(req, e, None, None))?; + + if components.device_id.len() != DEVICE_ID_LENGTH { + return Err(auth_error_outcome(req, DeviceError::InvalidDeviceId, Some(&components.device_id), None)); + } + + verify_request_signature(req, &components, config.tolerance.as_millis() as u64).map_err(|(status, msg)| { + cache_error(req, &msg); + Error((status, msg)) + })?; + + let wallet_id = components.wallet_id.clone().or_else(|| req.headers().get_one(HEADER_WALLET_ID).map(|s| s.to_string())); + + Ok(AuthResult { + device_id: components.device_id, + wallet_id, + }) +} + +pub(super) async fn lookup_device(req: &Request<'_>, device_id: &str) -> Result<(DeviceRow, DatabaseClient), Outcome> { + let Success(database) = req.guard::<&rocket::State>().await else { + return Err(auth_error_outcome(req, DeviceError::DatabaseUnavailable, Some(device_id), None)); + }; + + let Ok(mut db_client) = database.client() else { + return Err(auth_error_outcome(req, DeviceError::DatabaseError, Some(device_id), None)); + }; + + let Ok(device_row) = DevicesStore::get_device(&mut db_client, device_id) else { + return Err(auth_error_outcome(req, DeviceError::DeviceNotFound, Some(device_id), None)); + }; + + Ok((device_row, db_client)) +} + +pub(super) async fn lookup_device_wallet(req: &Request<'_>, device_id: &str, wallet_id: &str) -> Result<(DeviceRow, WalletRow), Outcome> { + let (device_row, mut db_client) = lookup_device(req, device_id).await?; + + let wallet_row = match db_client.get_wallet_by_device_and_identifier(device_row.id, wallet_id) { + Ok(wallet_row) => wallet_row, + Err(error) if error.is_not_found() => return Err(auth_error_outcome(req, DeviceError::WalletNotFound, Some(device_id), Some(wallet_id))), + Err(_) => return Err(auth_error_outcome(req, DeviceError::DatabaseError, Some(device_id), Some(wallet_id))), + }; + + Ok((device_row, wallet_row)) +} + +#[cfg(test)] +mod tests { + use super::format_auth_error_message; + use crate::devices::error::DeviceError; + + #[test] + fn test_format_auth_error_message_includes_wallet_id() { + let message = format_auth_error_message(&DeviceError::WalletNotFound, Some("device_123"), Some("wallet_456")); + + assert_eq!(message, "Wallet not found device_id=device_123 wallet_id=wallet_456"); + } +} diff --git a/core/apps/api/src/devices/guard/authenticated_device.rs b/core/apps/api/src/devices/guard/authenticated_device.rs new file mode 100644 index 0000000000..c76a41e010 --- /dev/null +++ b/core/apps/api/src/devices/guard/authenticated_device.rs @@ -0,0 +1,30 @@ +use rocket::Request; +use rocket::outcome::Outcome::Success; +use rocket::request::{FromRequest, Outcome}; +use storage::models::DeviceRow; + +use super::auth::{authenticate, lookup_device}; + +// Verifies the device request signature, then checks that the device exists. +pub struct AuthenticatedDevice { + pub device_row: DeviceRow, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AuthenticatedDevice { + type Error = String; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let auth = match authenticate(req).await { + Ok(auth) => auth, + Err(error) => return error, + }; + + let (device_row, _) = match lookup_device(req, &auth.device_id).await { + Ok(result) => result, + Err(error) => return error, + }; + + Success(AuthenticatedDevice { device_row }) + } +} diff --git a/core/apps/api/src/devices/guard/authenticated_device_wallet.rs b/core/apps/api/src/devices/guard/authenticated_device_wallet.rs new file mode 100644 index 0000000000..8d6a299c89 --- /dev/null +++ b/core/apps/api/src/devices/guard/authenticated_device_wallet.rs @@ -0,0 +1,44 @@ +use primitives::WalletId; +use rocket::Request; +use rocket::outcome::Outcome::Success; +use rocket::request::{FromRequest, Outcome}; +use storage::models::DeviceRow; + +use super::auth::{auth_error_outcome, authenticate, lookup_device_wallet}; +use crate::devices::constants::HEADER_WALLET_ID; +use crate::devices::error::DeviceError; + +// Verifies control of the device key, then resolves a wallet attached to that device. +// This proves device-wallet scope, not wallet-owner intent; routes that need owner approval must also use WalletSigned. +pub struct AuthenticatedDeviceWallet { + pub device_row: DeviceRow, + pub wallet_id: i32, + pub wallet_identifier: WalletId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AuthenticatedDeviceWallet { + type Error = String; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let auth = match authenticate(req).await { + Ok(auth) => auth, + Err(error) => return error, + }; + + let Some(wallet_id_str) = auth.wallet_id else { + return auth_error_outcome(req, DeviceError::MissingHeader(HEADER_WALLET_ID), Some(&auth.device_id), None); + }; + + let (device_row, wallet_row) = match lookup_device_wallet(req, &auth.device_id, &wallet_id_str).await { + Ok(result) => result, + Err(error) => return error, + }; + + Success(AuthenticatedDeviceWallet { + device_row, + wallet_id: wallet_row.id, + wallet_identifier: wallet_row.wallet_id.0, + }) + } +} diff --git a/core/apps/api/src/devices/guard/mod.rs b/core/apps/api/src/devices/guard/mod.rs new file mode 100644 index 0000000000..a886ff4048 --- /dev/null +++ b/core/apps/api/src/devices/guard/mod.rs @@ -0,0 +1,8 @@ +mod auth; +mod authenticated_device; +mod authenticated_device_wallet; +mod verified_device_id; + +pub use authenticated_device::AuthenticatedDevice; +pub use authenticated_device_wallet::AuthenticatedDeviceWallet; +pub use verified_device_id::VerifiedDeviceId; diff --git a/core/apps/api/src/devices/guard/verified_device_id.rs b/core/apps/api/src/devices/guard/verified_device_id.rs new file mode 100644 index 0000000000..7059dc8f1f --- /dev/null +++ b/core/apps/api/src/devices/guard/verified_device_id.rs @@ -0,0 +1,20 @@ +use rocket::Request; +use rocket::outcome::Outcome::Success; +use rocket::request::{FromRequest, Outcome}; + +use super::auth::authenticate; + +// Verifies the device request signature without checking the database. +pub struct VerifiedDeviceId(pub String); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for VerifiedDeviceId { + type Error = String; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + match authenticate(req).await { + Ok(auth) => Success(VerifiedDeviceId(auth.device_id)), + Err(error) => error, + } + } +} diff --git a/core/apps/api/src/devices/mod.rs b/core/apps/api/src/devices/mod.rs new file mode 100644 index 0000000000..4d7d7b5ba6 --- /dev/null +++ b/core/apps/api/src/devices/mod.rs @@ -0,0 +1,453 @@ +pub mod auth_config; +pub mod client; +pub mod clients; +pub mod constants; +pub mod error; +pub mod guard; +pub mod signature; +use crate::assets::AssetsClient; +use crate::params::{ + AssetIdParam, ChainParam, ChartPeriodParam, CurrencyParam, DeviceParam, FiatProviderIdParam, FiatQuoteTypeParam, NftAssetIdParam, TransactionIdParam, UserAgent, +}; +use crate::responders::{ApiError, ApiResponse}; +use auth_config::AuthConfig; +pub use client::DevicesClient; +pub(crate) use clients::WalletSubscriptionInput; +pub use clients::{ + AddressNamesClient, FiatQuotesClient, NotificationsClient, PortfolioClient, RewardsClient, RewardsRedemptionClient, ScanClient, ScanProviderFactory, TransactionsClient, + WalletConfigurationClient, WalletsClient, +}; +use gem_auth::AuthClient; +use guard::{AuthenticatedDevice, AuthenticatedDeviceWallet, VerifiedDeviceId}; +use name_resolver::client::Client as NameClient; +use nft::NFTClient; +use primitives::DeviceToken; +use primitives::device::Device; +use primitives::name::NameRecord; +use primitives::nft::NFTAssetData; +use primitives::rewards::{RedemptionRequest, RedemptionResult, RewardRedemptionOption}; +use primitives::{ + AddressName, AssetId, AuthNonce, ChainAddress, FiatAssets, FiatQuote, FiatQuoteRequest, FiatQuoteType, FiatQuoteUrl, FiatQuotes, InAppNotification, NFTData, PortfolioAssets, + PortfolioAssetsRequest, PriceAlerts, ReportNft, RewardEvent, Rewards, ScanTransaction, ScanTransactionPayload, Transaction, TransactionsResponse, WalletConfigurationResult, + WalletSubscriptionChains, +}; +use rocket::{State, delete, get, post, put, serde::json::Json, tokio::sync::Mutex}; +use std::sync::Arc; +use streamer::{StreamProducer, StreamProducerQueue}; + +use crate::auth::WalletSigned; + +#[post("/devices", format = "json", data = "")] +pub async fn add_device_v2(device_id: VerifiedDeviceId, device: DeviceParam, client: &State>) -> Result, ApiError> { + let device = device.0; + if device.id != device_id.0 { + return Err(ApiError::BadRequest("Device id mismatch".to_string())); + } + Ok(client.lock().await.add_device(device)?.into()) +} + +#[get("/devices")] +pub async fn get_device_v2(device: AuthenticatedDevice, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_device(&device.device_row.device_id)?.into()) +} + +#[get("/devices/is_registered")] +pub async fn is_device_registered_v2(device_id: VerifiedDeviceId, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.is_device_registered(&device_id.0)?.into()) +} + +#[get("/devices/assets?")] +pub async fn get_device_assets_v2( + device: AuthenticatedDeviceWallet, + from_timestamp: Option, + client: &State>, +) -> Result>, ApiError> { + Ok(client.lock().await.get_assets_by_wallet_id(device.device_row.id, device.wallet_id, from_timestamp)?.into()) +} + +#[get("/devices/transactions?&")] +pub async fn get_device_transactions_v2( + device: AuthenticatedDeviceWallet, + asset_id: Option, + from_timestamp: Option, + client: &State>, +) -> Result, ApiError> { + Ok(client + .lock() + .await + .get_transactions_by_wallet_id(&device.device_row.device_id, device.device_row.id, device.wallet_id, asset_id.map(|x| x.0), from_timestamp) + .await? + .into()) +} + +#[get("/devices/transactions/")] +pub async fn get_device_transaction_by_id_v2( + _device: AuthenticatedDevice, + id: TransactionIdParam, + client: &State>, +) -> Result, ApiError> { + get_device_transaction(id, client).await +} + +#[get("/devices/transaction/")] +pub async fn get_device_transaction_v2( + _device: AuthenticatedDevice, + id: TransactionIdParam, + client: &State>, +) -> Result, ApiError> { + get_device_transaction(id, client).await +} + +async fn get_device_transaction(id: TransactionIdParam, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_transaction_by_id(&id.0)?.into()) +} + +#[post("/devices/address_names", format = "json", data = "")] +pub async fn get_device_address_names_v2( + _device: AuthenticatedDevice, + requests: Json>, + client: &State>, +) -> Result>, ApiError> { + Ok(client.lock().await.get_address_names(requests.into_inner())?.into()) +} + +#[get("/devices/nft_assets")] +pub async fn get_device_nft_assets_v2(device: AuthenticatedDeviceWallet, client: &State) -> Result>, ApiError> { + Ok(client.get_nft_assets_by_wallet_id(device.device_row.id, device.wallet_id).await?.into()) +} + +#[get("/devices/nft_assets/")] +pub async fn get_device_nft_asset_v2(_device: AuthenticatedDevice, asset_id: NftAssetIdParam, client: &State) -> Result, ApiError> { + Ok(client.get_nft_asset_data(asset_id.0)?.into()) +} + +#[post("/devices/nft_assets//refresh")] +pub async fn refresh_device_nft_asset_v2( + _device: AuthenticatedDeviceWallet, + asset_id: NftAssetIdParam, + stream_producer: &State, +) -> Result, ApiError> { + Ok(stream_producer.publish_fetch_nft_asset(asset_id.0).await?.into()) +} + +#[get("/devices/rewards")] +pub async fn get_device_rewards_v2(device: AuthenticatedDeviceWallet, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_rewards_by_wallet_id(device.wallet_id)?.into()) +} + +#[get("/devices/rewards/events")] +pub async fn get_device_rewards_events_v2(device: AuthenticatedDeviceWallet, client: &State>) -> Result>, ApiError> { + Ok(client.lock().await.get_rewards_events_by_wallet_id(device.wallet_id)?.into()) +} + +#[get("/devices/rewards/redemptions/")] +pub async fn get_device_rewards_redemption_v2( + _device: AuthenticatedDevice, + code: &str, + client: &State>, +) -> Result, ApiError> { + Ok(client.lock().await.get_rewards_redemption_option(code)?.into()) +} + +#[post("/devices/rewards/referrals/create", format = "json", data = "")] +pub async fn create_device_referral_v2( + device: AuthenticatedDevice, + request: WalletSigned, + ip: std::net::IpAddr, + client: &State>, +) -> Result, ApiError> { + let wallet_identifier = primitives::WalletId::Multicoin(request.address.clone()).id(); + Ok(client + .lock() + .await + .create_username( + &wallet_identifier, + &request.data.code, + device.device_row.id, + &ip.to_string(), + device.device_row.locale.as_str(), + ) + .await? + .into()) +} + +#[post("/devices/rewards/referrals/use", format = "json", data = "")] +pub async fn use_device_referral_code_v2( + device: AuthenticatedDevice, + request: WalletSigned, + ip: std::net::IpAddr, + user_agent: UserAgent, + client: &State>, +) -> Result, ApiError> { + client + .lock() + .await + .use_referral_code(&device.device_row, &request.address, &request.data.code, &ip.to_string(), &user_agent.0) + .await?; + Ok(true.into()) +} + +#[post("/devices/rewards/redeem", format = "json", data = "")] +pub async fn redeem_device_rewards_v2( + device: AuthenticatedDeviceWallet, + request: WalletSigned, + client: &State>, +) -> Result, ApiError> { + if !request.matches_multicoin_wallet(&device.wallet_identifier) { + return Err(ApiError::BadRequest("Wallet signature mismatch".to_string())); + } + + Ok(client + .lock() + .await + .redeem_by_wallet_id(device.wallet_id, &request.data.id, device.device_row.id) + .await? + .into()) +} + +#[put("/devices", format = "json", data = "")] +pub async fn update_device_v2(device: AuthenticatedDevice, device_param: DeviceParam, client: &State>) -> Result, ApiError> { + let device_param = device_param.0; + if device_param.id != device.device_row.device_id { + return Err(ApiError::BadRequest("Device id mismatch".to_string())); + } + Ok(client.lock().await.update_device(device_param)?.into()) +} + +#[post("/devices/push-notification")] +pub async fn send_push_notification_device_v2(device: AuthenticatedDevice, client: &State>) -> Result, ApiError> { + Ok(ApiResponse::from( + client + .lock() + .await + .send_push_notification_device(&device.device_row.device_id) + .await + .map_err(ApiError::from)?, + )) +} + +#[post("/devices/nft/report", format = "json", data = "")] +pub async fn report_device_nft_v2(device: AuthenticatedDevice, request: Json, client: &State) -> Result, ApiError> { + let asset_id = request + .asset_id + .as_deref() + .map(|asset_id| AssetId::new(asset_id).ok_or_else(|| ApiError::BadRequest(format!("Invalid asset_id: {asset_id}")))) + .transpose()?; + + Ok(client + .report_nft(&device.device_row.device_id, request.collection_id.clone(), asset_id, request.reason.clone())? + .into()) +} + +#[get("/devices/name/resolve/?")] +pub async fn get_device_name_resolve_v2( + _device: AuthenticatedDevice, + name: &str, + chain: ChainParam, + client: &State>, +) -> Result>, ApiError> { + let result = client.lock().await.resolve(name, chain.0).await; + match result { + Ok(record) => Ok(Some(record).into()), + Err(_) => Ok(None.into()), + } +} + +#[post("/devices/scan/transaction", data = "")] +pub async fn scan_device_transaction_v2( + _device: AuthenticatedDevice, + request: Json, + client: &State>, +) -> Result, ApiError> { + Ok(client.lock().await.get_scan_transaction(request.0).await?.into()) +} + +#[get("/devices/wallet_configuration")] +pub async fn get_device_wallet_configuration_v2( + device: AuthenticatedDeviceWallet, + client: &State, +) -> Result, ApiError> { + Ok(client.get_configuration(device.device_row.id, device.wallet_id, device.wallet_identifier).await?.into()) +} + +#[get("/devices/notifications?")] +pub async fn get_device_notifications_v2( + device: AuthenticatedDevice, + from_timestamp: Option, + client: &State>, +) -> Result>, ApiError> { + Ok(client.lock().await.get_notifications(&device.device_row.device_id, from_timestamp)?.into()) +} + +#[post("/devices/notifications/read")] +pub async fn mark_device_notifications_read_v2(device: AuthenticatedDevice, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.mark_all_as_read(&device.device_row.device_id)?.into()) +} + +#[get("/devices/subscriptions")] +pub async fn get_device_subscriptions_v2(device: AuthenticatedDevice, client: &State>) -> Result>, ApiError> { + Ok(client.lock().await.get_subscriptions(device.device_row.id)?.into()) +} + +#[post("/devices/subscriptions", format = "json", data = "")] +pub async fn add_device_subscriptions_v2( + device: AuthenticatedDevice, + subscriptions: Json>, + client: &State>, +) -> Result, ApiError> { + let wallet_subscriptions = subscriptions.0.into_iter().map(|x| x.into_wallet_subscription()).collect(); + Ok(client.lock().await.add_subscriptions(device.device_row.id, wallet_subscriptions).await?.into()) +} + +#[delete("/devices/subscriptions", format = "json", data = "")] +pub async fn delete_device_subscriptions_v2( + device: AuthenticatedDevice, + subscriptions: Json>, + client: &State>, +) -> Result, ApiError> { + Ok(client.lock().await.delete_subscriptions(device.device_row.id, subscriptions.0).await?.into()) +} + +#[get("/devices/auth/nonce")] +pub async fn get_auth_nonce_v2(device: AuthenticatedDevice, client: &State>) -> Result, ApiError> { + Ok(client.get_nonce(&device.device_row.device_id).await?.into()) +} + +#[get("/devices/token")] +pub async fn get_device_token_v2(device: AuthenticatedDevice, config: &State, client: &State>) -> Result, ApiError> { + Ok(client.create_device_token(&device.device_row.device_id, &config.jwt.secret, config.jwt.expiry)?.into()) +} + +#[get("/devices/price_alerts?")] +pub async fn get_device_price_alerts_v2( + device: AuthenticatedDevice, + asset_id: Option, + client: &State>, +) -> Result, ApiError> { + Ok(client + .lock() + .await + .get_price_alerts(&device.device_row.device_id, asset_id.as_ref().map(|x| &x.0)) + .await? + .into()) +} + +#[post("/devices/price_alerts", format = "json", data = "")] +pub async fn add_device_price_alerts_v2( + device: AuthenticatedDevice, + price_alerts: Json, + client: &State>, +) -> Result, ApiError> { + Ok(client.lock().await.add_price_alerts(&device.device_row.device_id, price_alerts.0).await?.into()) +} + +#[delete("/devices/price_alerts", format = "json", data = "")] +pub async fn delete_device_price_alerts_v2( + device: AuthenticatedDevice, + price_alerts: Json, + client: &State>, +) -> Result, ApiError> { + Ok(client.lock().await.delete_price_alerts(&device.device_row.device_id, price_alerts.0).await?.into()) +} + +#[get("/devices/fiat/transactions")] +pub async fn get_device_fiat_transactions_v2( + device: AuthenticatedDeviceWallet, + client: &State>, +) -> Result>, ApiError> { + Ok(client.lock().await.get_transactions_by_wallet_id(device.device_row.id, device.wallet_id)?.into()) +} + +#[get("/devices/fiat/assets/")] +pub async fn get_device_fiat_assets_v2( + _device: AuthenticatedDevice, + quote_type: FiatQuoteTypeParam, + client: &State>, +) -> Result, ApiError> { + let assets = match quote_type.0 { + FiatQuoteType::Buy => client.lock().await.get_on_ramp_assets().await?, + FiatQuoteType::Sell => client.lock().await.get_off_ramp_assets().await?, + }; + Ok(assets.into()) +} + +#[get("/devices/fiat/quotes//?&&&", rank = 2)] +pub async fn get_fiat_quotes_v2( + _device: AuthenticatedDeviceWallet, + quote_type: FiatQuoteTypeParam, + asset_id: AssetIdParam, + amount: f64, + currency: CurrencyParam, + provider: Option, + ip_address: Option<&str>, + ip: std::net::IpAddr, + client: &State>, +) -> Result, ApiError> { + let fallback_ip_address = if cfg!(debug_assertions) { constants::DEBUG_FIAT_IP.to_string() } else { ip.to_string() }; + let quote_request = FiatQuoteRequest { + asset_id: asset_id.0, + quote_type: quote_type.0, + amount, + currency: currency.as_string(), + provider_id: provider.map(|p| p.0.id().to_string()), + ip_address: ip_address.map(str::to_string).unwrap_or(fallback_ip_address), + }; + let quotes = client.lock().await.get_quotes(quote_request).await?; + Ok(quotes.into()) +} + +#[get("/devices/fiat/quotes/", rank = 2)] +pub async fn get_fiat_quote_v2(_device: AuthenticatedDevice, quote_id: &str, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_quote(quote_id).await?.into()) +} + +#[get("/fiat/quotes/?&&&&")] +pub async fn get_fiat_quotes_v1( + quote_type: FiatQuoteTypeParam, + asset_id: AssetIdParam, + amount: f64, + currency: CurrencyParam, + provider_id: Option, + ip_address: Option<&str>, + ip: std::net::IpAddr, + client: &State>, +) -> Result, ApiError> { + let fallback_ip_address = if cfg!(debug_assertions) { constants::DEBUG_FIAT_IP.to_string() } else { ip.to_string() }; + let quote_request = FiatQuoteRequest { + asset_id: asset_id.0, + quote_type: quote_type.0, + amount, + currency: currency.as_string(), + provider_id: provider_id.map(|p| p.0.id().to_string()), + ip_address: ip_address.map(str::to_string).unwrap_or(fallback_ip_address), + }; + let quotes = client.lock().await.get_quotes(quote_request).await?; + Ok(quotes.into()) +} + +#[get("/devices/fiat/quotes//url", rank = 1)] +pub async fn get_fiat_quote_url_v2( + device: AuthenticatedDeviceWallet, + quote_id: &str, + ip: std::net::IpAddr, + client: &State>, +) -> Result, ApiError> { + let locale = device.device_row.locale.as_str(); + let ip_address = if cfg!(debug_assertions) { constants::DEBUG_FIAT_IP.to_string() } else { ip.to_string() }; + let url = client + .lock() + .await + .get_quote_url(quote_id, device.wallet_id, device.device_row.id, &ip_address, locale) + .await?; + Ok(url.into()) +} + +#[post("/devices/portfolio/assets?", format = "json", data = "")] +pub async fn get_device_portfolio_assets_v2( + _device: AuthenticatedDevice, + period: ChartPeriodParam, + request: Json, + portfolio_client: &State>, +) -> Result, ApiError> { + Ok(portfolio_client.lock().await.get_portfolio_charts(request.0.assets, period.0)?.into()) +} diff --git a/core/apps/api/src/devices/signature.rs b/core/apps/api/src/devices/signature.rs new file mode 100644 index 0000000000..6e9e8da5f8 --- /dev/null +++ b/core/apps/api/src/devices/signature.rs @@ -0,0 +1,64 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use gem_auth::{DeviceAuthPayload, decode_signature, parse_device_auth, verify_device_signature}; +use rocket::Request; +use rocket::http::Status; + +use crate::devices::constants::{AUTHORIZATION_HEADER, HEADER_DEVICE_BODY_HASH, HEADER_DEVICE_ID, HEADER_DEVICE_SIGNATURE, HEADER_DEVICE_TIMESTAMP}; +use crate::devices::error::DeviceError; + +pub fn parse_auth_components(req: &Request<'_>) -> Result { + if let Some(auth_value) = req.headers().get_one(AUTHORIZATION_HEADER) + && auth_value.starts_with(gem_auth::GEM_AUTH_SCHEME) + { + return parse_device_auth(auth_value).ok_or(DeviceError::InvalidAuthorizationFormat); + } + + let device_id = req.headers().get_one(HEADER_DEVICE_ID).ok_or(DeviceError::MissingHeader(HEADER_DEVICE_ID))?; + let timestamp = req.headers().get_one(HEADER_DEVICE_TIMESTAMP).ok_or(DeviceError::MissingHeader(HEADER_DEVICE_TIMESTAMP))?; + let body_hash = req.headers().get_one(HEADER_DEVICE_BODY_HASH).ok_or(DeviceError::MissingHeader(HEADER_DEVICE_BODY_HASH))?; + let signature = req.headers().get_one(HEADER_DEVICE_SIGNATURE).ok_or(DeviceError::MissingHeader(HEADER_DEVICE_SIGNATURE))?; + let signature = decode_signature(signature).ok_or(DeviceError::InvalidSignature)?; + + Ok(DeviceAuthPayload { + scheme: gem_auth::AuthScheme::Legacy, + device_id: device_id.to_string(), + timestamp: timestamp.to_string(), + wallet_id: None, + body_hash: body_hash.to_string(), + signature, + }) +} + +pub fn verify_request_signature(req: &Request<'_>, components: &DeviceAuthPayload, tolerance_ms: u64) -> Result<(), (Status, String)> { + let timestamp_ms: u64 = components + .timestamp + .parse() + .map_err(|_| (Status::Unauthorized, DeviceError::InvalidTimestamp.to_string()))?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| (Status::Unauthorized, DeviceError::InvalidTimestamp.to_string()))? + .as_millis() as u64; + + if now_ms.abs_diff(timestamp_ms) > tolerance_ms { + return Err((Status::Unauthorized, DeviceError::TimestampExpired.to_string())); + } + + let method = req.method().as_str(); + let path = req.uri().path().as_str(); + let message = match components.scheme { + gem_auth::AuthScheme::Gem => { + let wallet_id = components.wallet_id.as_deref().unwrap_or(""); + format!("{}.{}.{}.{}.{}", components.timestamp, method, path, wallet_id, components.body_hash) + } + gem_auth::AuthScheme::Legacy => { + format!("v1.{}.{}.{}.{}", components.timestamp, method, path, components.body_hash) + } + }; + + if !verify_device_signature(&components.device_id, &message, &components.signature) { + return Err((Status::Unauthorized, DeviceError::InvalidSignature.to_string())); + } + + Ok(()) +} diff --git a/core/apps/api/src/fiat.rs b/core/apps/api/src/fiat.rs new file mode 100644 index 0000000000..9ea8e0561e --- /dev/null +++ b/core/apps/api/src/fiat.rs @@ -0,0 +1,8 @@ +use crate::devices::FiatQuotesClient; +use crate::responders::{ApiError, ApiResponse}; +use rocket::{State, get, tokio::sync::Mutex}; + +#[get("/fiat/orders//")] +pub async fn get_fiat_order_v1(provider: &str, order_id: &str, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_order_status(provider, order_id).await?.into()) +} diff --git a/core/apps/api/src/lib.rs b/core/apps/api/src/lib.rs new file mode 100644 index 0000000000..b433e1486e --- /dev/null +++ b/core/apps/api/src/lib.rs @@ -0,0 +1,4 @@ +pub mod assets; +pub mod chain; +pub mod params; +pub mod responders; diff --git a/core/apps/api/src/main.rs b/core/apps/api/src/main.rs new file mode 100644 index 0000000000..7fcb925575 --- /dev/null +++ b/core/apps/api/src/main.rs @@ -0,0 +1,371 @@ +mod admin; +mod assets; +mod auth; +mod catchers; +mod chain; +mod config; +mod devices; +mod fiat; +mod markets; +mod model; +mod nft; +mod params; +mod prices; +mod referral; +mod responders; +mod status; +mod swap; +mod webhooks; +mod websocket; +mod websocket_prices; +mod websocket_stream; + +use gem_tracing::info_with_fields; +use std::{str::FromStr, sync::Arc}; +use strum::IntoEnumIterator; + +use ::fiat::FiatClient; +use ::fiat::FiatProviderFactory; +use ::nft::{NFTClient, NFTProviderClient, NFTProviderConfig}; +use admin::AdminConfig; +use api_connector::PusherClient; +use assets::{AssetsClient, SearchClient}; +use cacher::CacherClient; +use config::ConfigClient; +use devices::DevicesClient; +use devices::{ + AddressNamesClient, FiatQuotesClient, NotificationsClient, PortfolioClient, RewardsClient, RewardsRedemptionClient, ScanClient, ScanProviderFactory, TransactionsClient, + WalletConfigurationClient, WalletsClient, +}; +use gem_auth::AuthClient; +use gem_rewards::{AbuseIPDBClient, IpApiClient, IpCheckProvider, IpSecurityClient}; +use model::APIService; +use name_resolver::NameProviderFactory; +use name_resolver::client::{Client as NameClient, NameConfig}; +use pricer::{ChartClient, MarketsClient, PriceAlertClient, PriceClient}; +use primitives::PriceConfig; +use rocket::tokio::sync::Mutex; +use rocket::{Build, Rocket, catchers, routes}; +use search_index::SearchIndexClient; +use settings::Settings; +use settings_chain::{ChainProviders, ProviderFactory}; +use storage::Database; +use streamer::{StreamProducer, StreamProducerConfig}; +use swap::SwapClient; +use swapper::okx::{OkxClientConfig, OkxProvider}; +use swapper::swapper::GemSwapper; +use webhooks::WebhooksClient; +use websocket_prices::PriceObserverConfig; + +fn mount_routes(rocket: Rocket, admin_enabled: bool) -> Rocket { + let rocket = rocket + .mount("/", routes![status::get_status, status::get_health]) + .mount( + "/v1", + routes![ + prices::get_price, + prices::get_assets_prices, + prices::get_charts, + prices::get_fiat_rates, + devices::get_fiat_quotes_v1, + fiat::get_fiat_order_v1, + webhooks::create_webhook, + config::get_config, + assets::get_asset, + assets::get_assets, + assets::get_assets_search, + assets::get_search, + chain::block::get_latest_block_number, + chain::block::get_block_transactions, + chain::block::get_block_transactions_finalize, + chain::swap::get_swap_result, + chain::swap::get_swap_quote, + chain::swap::get_vault_addresses, + swap::get_swap_assets, + nft::get_nft_asset_preview, + nft::get_nft_asset_resource, + nft::get_nft_collection_preview, + markets::get_markets, + chain::staking::get_validators, + chain::staking::get_staking_apy, + chain::token::get_token, + chain::address::get_balances, + chain::address::get_assets, + chain::address::get_transactions, + chain::nft::get_nfts, + chain::nft::get_nft_asset, + chain::nft::get_nft_collection, + chain::transaction::get_transaction, + chain::transaction::get_transaction_status, + referral::get_rewards_leaderboard, + swap::post_near_intents_quote, + swap::okx::post_okx_quote, + swap::okx::post_okx_quote_data, + ], + ) + .mount( + "/v2", + routes![ + devices::get_device_fiat_transactions_v2, + devices::get_device_fiat_assets_v2, + devices::get_fiat_quotes_v2, + devices::get_fiat_quote_v2, + devices::get_fiat_quote_url_v2, + devices::add_device_v2, + devices::get_device_v2, + devices::is_device_registered_v2, + devices::update_device_v2, + devices::send_push_notification_device_v2, + devices::report_device_nft_v2, + devices::scan_device_transaction_v2, + devices::get_device_assets_v2, + devices::get_device_wallet_configuration_v2, + devices::get_device_name_resolve_v2, + devices::get_device_transaction_v2, + devices::get_device_transaction_by_id_v2, + devices::get_device_transactions_v2, + devices::get_device_address_names_v2, + devices::get_device_nft_assets_v2, + devices::get_device_nft_asset_v2, + devices::refresh_device_nft_asset_v2, + devices::get_device_rewards_v2, + devices::get_device_rewards_events_v2, + devices::get_device_rewards_redemption_v2, + devices::create_device_referral_v2, + devices::use_device_referral_code_v2, + devices::redeem_device_rewards_v2, + devices::get_device_notifications_v2, + devices::mark_device_notifications_read_v2, + devices::get_device_subscriptions_v2, + devices::add_device_subscriptions_v2, + devices::delete_device_subscriptions_v2, + devices::get_device_price_alerts_v2, + devices::add_device_price_alerts_v2, + devices::delete_device_price_alerts_v2, + devices::get_auth_nonce_v2, + devices::get_device_token_v2, + devices::get_device_portfolio_assets_v2, + ], + ) + .register("/", catchers![catchers::default_catcher]); + + if admin_enabled { + rocket.mount( + "/v1/admin", + routes![ + admin::assets::add_asset, + admin::transactions::add_transaction, + admin::prices::add_price, + admin::nft::update_nft_asset, + admin::nft::update_nft_collection, + ], + ) + } else { + rocket + } +} + +async fn rocket_api(settings: Settings) -> Result, Box> { + let redis_url = settings.redis.url.as_str(); + let postgres_url = settings.postgres.url.as_str(); + let settings_clone = settings.clone(); + + let database = Database::new(postgres_url, settings.postgres.pool); + let cacher_client = CacherClient::new(redis_url).await; + let config_cacher = storage::ConfigCacher::new(database.clone()); + let price_config = PriceConfig { + primary_price_max_age: config_cacher.get_duration(primitives::ConfigKey::PricePrimaryMaxAge)?, + }; + + let price_client = PriceClient::new(database.clone(), cacher_client.clone()); + let charts_client = ChartClient::new(database.clone(), price_config); + let config_client = ConfigClient::new(database.clone()); + let price_alert_client = PriceAlertClient::new(database.clone()); + let name_config = NameConfig { + max_name_length: settings_clone.name.max_name_length, + }; + let providers = NameProviderFactory::create_providers(settings_clone.clone()); + let name_client = NameClient::new(providers, name_config); + + let chain_client = chain::ChainClient::new(ChainProviders::new(ProviderFactory::new_providers(&settings))); + let portfolio_client = PortfolioClient::new(database.clone(), price_config); + let endpoints = ProviderFactory::get_chain_endpoints(&settings); + let native_provider = Arc::new(swapper::NativeProvider::new_with_endpoints(endpoints)); + let swapper = Arc::new(GemSwapper::new(native_provider.clone())); + + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let pusher_client = PusherClient::new(settings.pusher.url.clone(), settings.pusher.ios.topic.clone()); + let devices_client = DevicesClient::new(database.clone(), pusher_client.clone()); + let transactions_client = TransactionsClient::new(database.clone()); + let address_names_client = AddressNamesClient::new(database.clone()); + let stream_producer = StreamProducer::new(&rabbitmq_config, "api", streamer::no_shutdown()).await.unwrap(); + let wallets_client = WalletsClient::new(database.clone(), stream_producer.clone()); + + let security_providers = ScanProviderFactory::create_providers(&settings_clone); + let scan_client = ScanClient::new(database.clone(), security_providers); + let wallet_configuration_client = WalletConfigurationClient::new(database.clone(), ChainProviders::new(ProviderFactory::new_providers(&settings)), cacher_client.clone()); + let assets_client = AssetsClient::new(database.clone(), price_config); + let search_index_client = SearchIndexClient::new(&settings_clone.meilisearch.url.clone(), &settings_clone.meilisearch.key.clone()); + let search_client = SearchClient::new(&search_index_client, price_client.clone()); + let swap_client = SwapClient::new(database.clone()); + let fiat_providers = FiatProviderFactory::new_providers(settings_clone.clone()); + let fiat_ip_check_client = FiatProviderFactory::new_ip_check_client(settings_clone.clone()); + let fiat_client = FiatClient::new( + database.clone(), + cacher_client.clone(), + fiat_providers, + fiat_ip_check_client.clone(), + stream_producer.clone(), + ); + let fiat_quotes_client = FiatQuotesClient::new(database.clone(), fiat_client); + let nft_config = NFTProviderConfig::new( + settings.nft.opensea.key.secret.clone(), + settings.nft.magiceden.key.secret.clone(), + settings.chains.ton.url.clone(), + ); + let nft_client = NFTClient::from_config(database.clone(), nft_config.clone(), settings.nft.url.clone()); + let nft_provider_client = NFTProviderClient::new(nft_config); + let auth_client = Arc::new(AuthClient::new(cacher_client.clone())); + let markets_client = MarketsClient::new(database.clone(), cacher_client.clone()); + let webhooks_client = WebhooksClient::new(stream_producer.clone()); + let ip_check_providers: Vec> = vec![ + Arc::new(AbuseIPDBClient::new(settings.ip.abuseipdb.url.clone(), settings.ip.abuseipdb.key.secret.clone())), + Arc::new(IpApiClient::new(settings.ip.ipapi.url.clone(), settings.ip.ipapi.key.secret.clone())), + ]; + let ip_security_client = IpSecurityClient::new(ip_check_providers, cacher_client.clone()); + let rewards_client = RewardsClient::new(database.clone(), stream_producer.clone(), ip_security_client, pusher_client.clone()); + let redemption_client = RewardsRedemptionClient::new(database.clone(), stream_producer.clone()); + let notifications_client = NotificationsClient::new(database.clone()); + let near_intents_client = swap::NearIntentsProxyClient::new(cacher_client.clone()); + let okx_provider = OkxProvider::new( + OkxClientConfig { + api_key: settings.swap.okx.key.public.clone(), + secret_key: settings.swap.okx.key.secret.clone(), + passphrase: settings.swap.okx.passphrase.clone(), + project: settings.swap.okx.project.clone(), + }, + native_provider.clone(), + ); + let jwt_config = devices::auth_config::JwtConfig { + secret: settings.api.auth.jwt.secret.clone(), + expiry: settings.api.auth.jwt.expiry, + }; + let auth_config = devices::auth_config::AuthConfig::new(settings.api.auth.enabled, settings.api.auth.tolerance, jwt_config); + let mut rocket = rocket::build() + .manage(auth_config) + .manage(database) + .manage(Mutex::new(fiat_quotes_client)) + .manage(Mutex::new(price_client)) + .manage(Mutex::new(charts_client)) + .manage(Mutex::new(config_client)) + .manage(Mutex::new(name_client)) + .manage(Mutex::new(devices_client)) + .manage(Mutex::new(assets_client)) + .manage(Mutex::new(search_client)) + .manage(Mutex::new(transactions_client)) + .manage(Mutex::new(address_names_client)) + .manage(wallet_configuration_client) + .manage(Mutex::new(scan_client)) + .manage(Mutex::new(swap_client)) + .manage(nft_client) + .manage(nft_provider_client) + .manage(Mutex::new(price_alert_client)) + .manage(Mutex::new(chain_client)) + .manage(swapper) + .manage(Mutex::new(markets_client)) + .manage(Mutex::new(webhooks_client)) + .manage(Mutex::new(rewards_client)) + .manage(Mutex::new(redemption_client)) + .manage(Mutex::new(wallets_client)) + .manage(Mutex::new(notifications_client)) + .manage(Mutex::new(near_intents_client)) + .manage(okx_provider) + .manage(Mutex::new(portfolio_client)) + .manage(auth_client) + .manage(stream_producer); + + if settings.api.admin.enabled { + rocket = rocket.manage(AdminConfig { + token: settings.api.admin.token.clone(), + }); + } + + Ok(mount_routes(rocket, settings.api.admin.enabled)) +} + +async fn rocket_ws_prices(settings: Settings) -> Rocket { + let cacher_client = CacherClient::new(&settings.redis.url).await; + let database = storage::Database::new(&settings.postgres.url, settings.postgres.pool); + let price_client = PriceClient::new(database, cacher_client); + let price_observer_config = PriceObserverConfig { + redis_url: settings.redis.url.clone(), + }; + rocket::build() + .manage(Arc::new(Mutex::new(price_client))) + .manage(Arc::new(price_observer_config)) + .mount("/", routes![websocket_prices::ws_health]) + .mount("/v1/ws", routes![websocket_prices::ws_prices]) + .register("/", catchers![catchers::default_catcher]) +} + +async fn rocket_ws_stream(settings: Settings) -> Rocket { + let cacher_client = CacherClient::new(&settings.redis.url).await; + let database = storage::Database::new(&settings.postgres.url, settings.postgres.pool); + let price_client = PriceClient::new(database.clone(), cacher_client); + let stream_observer_config = websocket_stream::StreamObserverConfig { + redis_url: settings.redis.url.clone(), + }; + + let jwt_config = devices::auth_config::JwtConfig { + secret: settings.api.auth.jwt.secret.clone(), + expiry: settings.api.auth.jwt.expiry, + }; + let auth_config = devices::auth_config::AuthConfig::new(settings.api.auth.enabled, settings.api.auth.tolerance, jwt_config); + + rocket::build() + .manage(auth_config) + .manage(database) + .manage(Arc::new(Mutex::new(price_client))) + .manage(Arc::new(stream_observer_config)) + .mount("/v2/devices", routes![websocket_stream::ws_stream]) + .mount("/", routes![websocket_stream::ws_health]) + .register("/", catchers![catchers::default_catcher]) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let settings = Settings::new()?; + + let service = match std::env::args().nth(1) { + Some(arg) => APIService::from_str(&arg).unwrap_or_else(|_| { + let services: Vec<_> = APIService::iter().map(|s| format!("api {}", s.as_ref())).collect(); + panic!("unknown service: {arg}\nAvailable:\n {}", services.join("\n ")) + }), + None => APIService::Api, + }; + + info_with_fields!("api start service", service = service.as_ref()); + + let rocket = match service { + APIService::Api => rocket_api(settings).await?, + APIService::WebsocketPrices => rocket_ws_prices(settings).await, + APIService::WebsocketStream => rocket_ws_stream(settings).await, + }; + rocket.launch().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rocket::async_test] + async fn test_no_route_collisions() { + let rocket = mount_routes(rocket::build(), true); + if let Err(e) = rocket.ignite().await { + let error = format!("{:?}", e); + assert!(!error.contains("Collisions"), "Route collisions detected: {error}"); + } + } +} diff --git a/core/apps/api/src/markets/mod.rs b/core/apps/api/src/markets/mod.rs new file mode 100644 index 0000000000..bab26d8731 --- /dev/null +++ b/core/apps/api/src/markets/mod.rs @@ -0,0 +1,9 @@ +use crate::responders::{ApiError, ApiResponse}; +use pricer::MarketsClient; +use primitives::Markets; +use rocket::{State, get, tokio::sync::Mutex}; + +#[get("/markets")] +pub async fn get_markets(client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_markets().await?.into()) +} diff --git a/core/apps/api/src/model.rs b/core/apps/api/src/model.rs new file mode 100644 index 0000000000..191acc9676 --- /dev/null +++ b/core/apps/api/src/model.rs @@ -0,0 +1,9 @@ +use strum::{AsRefStr, EnumIter, EnumString}; + +#[derive(Debug, Clone, AsRefStr, EnumString, EnumIter, PartialEq)] +#[strum(serialize_all = "snake_case")] +pub enum APIService { + Api, + WebsocketPrices, + WebsocketStream, +} diff --git a/core/apps/api/src/nft.rs b/core/apps/api/src/nft.rs new file mode 100644 index 0000000000..faa5cf6ead --- /dev/null +++ b/core/apps/api/src/nft.rs @@ -0,0 +1,23 @@ +use crate::params::{NftAssetIdParam, NftCollectionIdParam}; +use crate::responders::ApiError; +use ::nft::NFTClient; +use primitives::NFTResource; +use rocket::serde::json::Json; +use rocket::{State, get}; + +#[get("/nft/assets//preview")] +pub async fn get_nft_asset_preview(asset_id: NftAssetIdParam, client: &State) -> Result, ApiError> { + let identifier = asset_id.0.to_string(); + Ok(Json(client.load_nft_asset(&identifier)?.images.preview)) +} + +#[get("/nft/assets//resource")] +pub async fn get_nft_asset_resource(asset_id: NftAssetIdParam, client: &State) -> Result, ApiError> { + let identifier = asset_id.0.to_string(); + Ok(Json(client.load_nft_asset(&identifier)?.resource)) +} + +#[get("/nft/collections//preview")] +pub async fn get_nft_collection_preview(collection_id: NftCollectionIdParam, client: &State) -> Result, ApiError> { + Ok(Json(client.load_nft_collection(&collection_id.0.to_string())?.images.preview)) +} diff --git a/core/apps/api/src/params.rs b/core/apps/api/src/params.rs new file mode 100644 index 0000000000..a853d47077 --- /dev/null +++ b/core/apps/api/src/params.rs @@ -0,0 +1,228 @@ +use primitives::currency::Currency; +use primitives::{AssetId, Chain, ChartPeriod, Device, FiatProviderName, FiatQuoteType, NFTAssetId, NFTCollectionId, SwapProvider, TransactionId}; +use rocket::data::{FromData, Outcome, ToByteUnit}; +use rocket::form::{self, FromFormField, ValueField}; +use rocket::http::Status; +use rocket::outcome::Outcome::{Error, Success}; +use rocket::request::FromParam; +use rocket::{Data, Request}; +use std::str::FromStr; +use unic_langid::LanguageIdentifier; + +const MAX_ADDRESS_LENGTH: usize = 256; +const MAX_ASSET_ID_LENGTH: usize = 256; +const MAX_NFT_ID_LENGTH: usize = 256; +const MAX_SEARCH_QUERY_LENGTH: usize = 128; + +pub struct ChainParam(pub Chain); + +impl<'r> FromParam<'r> for ChainParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + Chain::from_str(param).map(ChainParam).map_err(|_| param) + } +} + +impl<'r> FromFormField<'r> for ChainParam { + fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> { + Chain::from_str(field.value) + .map(ChainParam) + .map_err(|_| form::Error::validation(format!("Invalid chain: {}", field.value)).into()) + } +} + +pub struct TransactionIdParam(pub TransactionId); + +impl<'r> FromParam<'r> for TransactionIdParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + TransactionId::from_str(param).map(TransactionIdParam).map_err(|_| param) + } +} + +pub struct FiatQuoteTypeParam(pub FiatQuoteType); + +impl<'r> FromParam<'r> for FiatQuoteTypeParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + FiatQuoteType::from_str(param).map(FiatQuoteTypeParam).map_err(|_| param) + } +} + +pub struct CurrencyParam(pub Currency); + +impl<'r> FromFormField<'r> for CurrencyParam { + fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> { + Currency::from_str(field.value).map(CurrencyParam).or_else(|_| Ok(CurrencyParam(Currency::USD))) + } +} + +impl CurrencyParam { + pub fn as_string(&self) -> String { + self.0.as_ref().to_string() + } +} + +pub struct AddressParam(pub String); + +impl<'r> FromParam<'r> for AddressParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + if param.is_empty() || param.len() > MAX_ADDRESS_LENGTH { + return Err(param); + } + Ok(AddressParam(param.to_string())) + } +} + +impl<'r> FromFormField<'r> for AddressParam { + fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> { + if field.value.is_empty() || field.value.len() > MAX_ADDRESS_LENGTH { + return Err(form::Error::validation(format!("Invalid address: {}", field.value)).into()); + } + Ok(AddressParam(field.value.to_string())) + } +} + +pub struct NftCollectionIdParam(pub NFTCollectionId); + +impl<'r> FromParam<'r> for NftCollectionIdParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + if param.len() > MAX_NFT_ID_LENGTH { + return Err(param); + } + param.parse().map(NftCollectionIdParam).map_err(|_| param) + } +} + +pub struct NftAssetIdParam(pub NFTAssetId); + +impl<'r> FromParam<'r> for NftAssetIdParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + if param.len() > MAX_NFT_ID_LENGTH { + return Err(param); + } + param.parse().map(NftAssetIdParam).map_err(|_| param) + } +} + +pub struct AssetIdParam(pub AssetId); + +impl<'r> FromParam<'r> for AssetIdParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + if param.is_empty() || param.len() > MAX_ASSET_ID_LENGTH { + return Err(param); + } + AssetId::new(param).map(AssetIdParam).ok_or(param) + } +} + +impl<'r> FromFormField<'r> for AssetIdParam { + fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> { + if field.value.is_empty() || field.value.len() > MAX_ASSET_ID_LENGTH { + return Err(form::Error::validation(format!("Invalid asset_id: {}", field.value)).into()); + } + AssetId::new(field.value) + .map(AssetIdParam) + .ok_or_else(|| form::Error::validation(format!("Invalid asset_id: {}", field.value)).into()) + } +} + +pub struct ChartPeriodParam(pub ChartPeriod); + +impl<'r> FromParam<'r> for ChartPeriodParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + ChartPeriod::new(param.to_string()).map(ChartPeriodParam).ok_or(param) + } +} + +impl<'r> FromFormField<'r> for ChartPeriodParam { + fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> { + ChartPeriod::new(field.value.to_string()) + .map(ChartPeriodParam) + .ok_or_else(|| form::Error::validation(format!("Invalid period: {}", field.value)).into()) + } +} + +pub struct SwapProviderParam(pub SwapProvider); + +impl<'r> FromParam<'r> for SwapProviderParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + SwapProvider::from_str(param).map(SwapProviderParam).map_err(|_| param) + } +} + +pub struct FiatProviderIdParam(pub FiatProviderName); + +impl<'r> FromFormField<'r> for FiatProviderIdParam { + fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> { + FiatProviderName::from_str(field.value) + .map(FiatProviderIdParam) + .map_err(|_| form::Error::validation(format!("Invalid provider: {}", field.value)).into()) + } +} + +pub struct SearchQueryParam(pub String); + +impl<'r> FromFormField<'r> for SearchQueryParam { + fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> { + if field.value.len() > MAX_SEARCH_QUERY_LENGTH { + return Err(form::Error::validation(format!("Invalid query length: {}", field.value.len())).into()); + } + Ok(SearchQueryParam(field.value.to_string())) + } +} + +pub struct UserAgent(pub String); + +#[rocket::async_trait] +impl<'r> rocket::request::FromRequest<'r> for UserAgent { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome { + match request.headers().get_one(rocket::http::hyper::header::USER_AGENT.as_str()) { + Some(ua) => rocket::request::Outcome::Success(UserAgent(ua.to_string())), + None => rocket::request::Outcome::Error((Status::BadRequest, ())), + } + } +} + +pub struct DeviceParam(pub Device); + +#[rocket::async_trait] +impl<'r> FromData<'r> for DeviceParam { + type Error = String; + + async fn from_data(_req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> { + let Ok(bytes) = data.open(64.kibibytes()).into_bytes().await else { + return Error((Status::BadRequest, "Failed to read body".to_string())); + }; + if !bytes.is_complete() { + return Error((Status::BadRequest, "Request body too large".to_string())); + } + + let Ok(device) = serde_json::from_slice::(&bytes.into_inner()) else { + return Error((Status::BadRequest, "Invalid JSON".to_string())); + }; + + if device.locale.parse::().is_err() { + return Error((Status::BadRequest, format!("Invalid locale: {}", device.locale))); + } + + Success(DeviceParam(device)) + } +} diff --git a/core/apps/api/src/prices/mod.rs b/core/apps/api/src/prices/mod.rs new file mode 100644 index 0000000000..b7527719d9 --- /dev/null +++ b/core/apps/api/src/prices/mod.rs @@ -0,0 +1,49 @@ +use pricer::{ChartClient, PriceClient}; +use primitives::{AssetMarketPrice, AssetPrices, AssetPricesRequest, ChartPeriod, Charts, DEFAULT_FIAT_CURRENCY, FiatRate}; +use rocket::{State, get, post, serde::json::Json, tokio::sync::Mutex}; + +use crate::params::{AssetIdParam, ChartPeriodParam}; +use crate::responders::{ApiError, ApiResponse}; + +#[get("/prices/?")] +pub async fn get_price(asset_id: AssetIdParam, currency: Option<&str>, price_client: &State>) -> Result, ApiError> { + let currency = currency.unwrap_or(DEFAULT_FIAT_CURRENCY); + Ok(price_client.lock().await.get_asset_price(&asset_id.0, currency).await?.into()) +} + +#[post("/prices", format = "json", data = "")] +pub async fn get_assets_prices(request: Json, price_client: &State>) -> Result, ApiError> { + let currency: String = request.currency.clone().unwrap_or(DEFAULT_FIAT_CURRENCY.to_string()); + Ok(price_client.lock().await.get_asset_prices(currency.as_str(), request.0.asset_ids).await?.into()) +} + +#[get("/fiat_rates")] +pub async fn get_fiat_rates(price_client: &State>) -> Result>, ApiError> { + Ok(price_client.lock().await.get_fiat_rates()?.into()) +} + +#[get("/charts/?&")] +pub async fn get_charts( + asset_id: AssetIdParam, + period: Option, + currency: Option<&str>, + charts_client: &State>, + price_client: &State>, +) -> Result, ApiError> { + let period = period.map(|p| p.0).unwrap_or(ChartPeriod::Day); + let currency_value = currency.unwrap_or(DEFAULT_FIAT_CURRENCY); + + let asset_id = asset_id.0; + let prices = charts_client.lock().await.get_charts_prices(&asset_id, period, currency_value).await?; + let asset_price = price_client.lock().await.get_asset_price(&asset_id, currency_value).await?; + + let response = Charts { + price: asset_price.price, + market: asset_price.market, + prices, + market_caps: vec![], + total_volumes: vec![], + }; + + Ok(response.into()) +} diff --git a/core/apps/api/src/referral.rs b/core/apps/api/src/referral.rs new file mode 100644 index 0000000000..64ad349f6a --- /dev/null +++ b/core/apps/api/src/referral.rs @@ -0,0 +1,10 @@ +use crate::devices::RewardsClient; +use crate::responders::{ApiError, ApiResponse}; +use primitives::ReferralLeaderboard; +use rocket::{State, get}; +use tokio::sync::Mutex; + +#[get("/rewards/leaderboard")] +pub async fn get_rewards_leaderboard(client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_rewards_leaderboard()?.into()) +} diff --git a/core/apps/api/src/responders.rs b/core/apps/api/src/responders.rs new file mode 100644 index 0000000000..3e00ae0451 --- /dev/null +++ b/core/apps/api/src/responders.rs @@ -0,0 +1,206 @@ +use cacher::CacheError; +use fiat::error::FiatQuoteError; +use gem_rewards::{RewardsError, RewardsRedemptionError, UsernameError}; +use primitives::ResponseResult; +use rocket::response::{Responder, Response}; +use rocket::serde::json::Json; +use rocket::{Request, http::Status}; +use serde::Serialize; +use storage::DatabaseError; +use strum::ParseError; + +pub struct ErrorContext(pub String); + +pub fn cache_error(req: &Request<'_>, message: &str) { + req.local_cache(|| ErrorContext(message.to_string())); +} + +fn ok_error_message(error: &(dyn std::error::Error + 'static)) -> Option { + downcast_error_message::(error) + .or_else(|| downcast_error_message::(error)) + .or_else(|| downcast_error_message::(error)) +} + +fn downcast_error_message(error: &(dyn std::error::Error + 'static)) -> Option +where + T: std::error::Error + 'static, +{ + error.downcast_ref::().map(ToString::to_string) +} + +#[derive(Debug)] +pub enum ApiError { + OkError(String), + BadRequest(String), + NotFound(String), + InternalServerError(String), +} + +impl<'r> Responder<'r, 'static> for ApiError { + fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'static> { + let (status, message) = match self { + ApiError::OkError(msg) => (Status::Ok, msg), + ApiError::BadRequest(msg) => (Status::BadRequest, msg), + ApiError::NotFound(msg) => (Status::NotFound, msg), + ApiError::InternalServerError(msg) => (Status::InternalServerError, msg), + }; + + let error_response = ResponseResult::<()>::error(message); + let json_response = Json(error_response); + + Response::build_from(json_response.respond_to(request)?).status(status).ok() + } +} + +impl From for ApiError { + fn from(error: CacheError) -> Self { + match error { + CacheError::NotFound { .. } | CacheError::ResourceNotFound(_) => ApiError::NotFound(error.to_string()), + CacheError::KeyNotFound(_) => ApiError::InternalServerError("Unexpected cache miss".to_string()), + } + } +} + +impl From for ApiError { + fn from(error: ParseError) -> Self { + ApiError::NotFound(format!("Invalid parameter: {}", error)) + } +} + +impl From for ApiError { + fn from(error: DatabaseError) -> Self { + match error { + DatabaseError::NotFound { .. } => ApiError::NotFound(error.to_string()), + DatabaseError::Error(msg) => ApiError::InternalServerError(msg), + } + } +} + +impl From for ApiError { + fn from(error: serde_json::Error) -> Self { + ApiError::InternalServerError(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: swapper::SwapperError) -> Self { + ApiError::InternalServerError(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: FiatQuoteError) -> Self { + ApiError::BadRequest(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: RewardsError) -> Self { + ApiError::OkError(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: RewardsRedemptionError) -> Self { + ApiError::OkError(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: UsernameError) -> Self { + ApiError::OkError(error.to_string()) + } +} + +impl From> for ApiError { + fn from(error: Box) -> Self { + let mut current_error: &(dyn std::error::Error + 'static) = error.as_ref(); + loop { + if let Some(cache_error) = current_error.downcast_ref::() { + return cache_error.clone().into(); + } + if let Some(db_error) = current_error.downcast_ref::() { + return db_error.clone().into(); + } + if let Some(fiat_error) = current_error.downcast_ref::() { + return fiat_error.clone().into(); + } + if let Some(message) = ok_error_message(current_error) { + return ApiError::OkError(message); + } + match current_error.source() { + Some(source) => current_error = source, + None => break, + } + } + + ApiError::InternalServerError(format!("{}", error)) + } +} + +pub struct ApiResponse(pub ResponseResult); + +impl From for ApiResponse { + fn from(data: T) -> Self { + ApiResponse(ResponseResult::new(data)) + } +} + +impl<'r, T: Serialize> Responder<'r, 'static> for ApiResponse { + fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'static> { + Json(self.0).respond_to(request) + } +} + +#[cfg(test)] +mod tests { + use super::ApiError; + use cacher::CacheError; + use gem_rewards::{RewardsError, RewardsRedemptionError}; + use storage::DatabaseError; + + #[test] + fn test_cache_not_found_maps_to_public_not_found() { + let error = ApiError::from(CacheError::not_found("FiatQuote", "abc")); + match error { + ApiError::NotFound(message) => assert_eq!(message, "FiatQuote abc not found"), + _ => panic!("expected not found"), + } + } + + #[test] + fn test_cache_key_not_found_maps_to_internal_server_error() { + let error = ApiError::from(CacheError::KeyNotFound("fiat:quote:abc".to_string())); + match error { + ApiError::InternalServerError(message) => assert_eq!(message, "Unexpected cache miss"), + _ => panic!("expected internal server error"), + } + } + + #[test] + fn test_boxed_database_not_found_hides_internal_lookup() { + let error: Box = Box::new(DatabaseError::not_found_internal("Device", "1")); + match ApiError::from(error) { + ApiError::NotFound(message) => assert_eq!(message, "Device not found"), + _ => panic!("expected not found"), + } + } + + #[test] + fn test_boxed_rewards_error_maps_to_ok_error() { + let error: Box = Box::new(RewardsError::Username("Daily username creation limit has been reached".to_string())); + match ApiError::from(error) { + ApiError::OkError(message) => assert_eq!(message, "Daily username creation limit has been reached"), + _ => panic!("expected ok error"), + } + } + + #[test] + fn test_boxed_rewards_redemption_error_maps_to_ok_error() { + let error: Box = Box::new(RewardsRedemptionError::DailyLimitReached); + match ApiError::from(error) { + ApiError::OkError(message) => assert_eq!(message, "Daily redemption limit reached"), + _ => panic!("expected ok error"), + } + } +} diff --git a/core/apps/api/src/status.rs b/core/apps/api/src/status.rs new file mode 100644 index 0000000000..25ad352b66 --- /dev/null +++ b/core/apps/api/src/status.rs @@ -0,0 +1,25 @@ +use rocket::{get, http::Status as HttpStatus, serde::Serialize, serde::json::Json}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[get("/")] +pub fn get_status(ip: std::net::IpAddr) -> Json { + Json(Status { + time: get_epoch_ms(), + ipv4: ip.to_string(), + }) +} + +#[get("/health")] +pub fn get_health() -> HttpStatus { + HttpStatus::Ok +} + +fn get_epoch_ms() -> u128 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() +} + +#[derive(Serialize)] +pub struct Status { + time: u128, + ipv4: String, +} diff --git a/core/apps/api/src/swap/client.rs b/core/apps/api/src/swap/client.rs new file mode 100644 index 0000000000..c359ecd083 --- /dev/null +++ b/core/apps/api/src/swap/client.rs @@ -0,0 +1,25 @@ +use std::error::Error; + +use primitives::FiatAssets; +use storage::{AssetsRepository, Database}; + +#[derive(Clone)] +pub struct SwapClient { + database: Database, +} + +impl SwapClient { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub async fn get_swap_assets(&self) -> Result> { + let assets = self.database.assets()?.get_swap_assets()?; + let version = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs() / 3600; + + Ok(FiatAssets { + version: version as u32, + asset_ids: assets, + }) + } +} diff --git a/core/apps/api/src/swap/mod.rs b/core/apps/api/src/swap/mod.rs new file mode 100644 index 0000000000..0ec1f84552 --- /dev/null +++ b/core/apps/api/src/swap/mod.rs @@ -0,0 +1,21 @@ +pub mod client; +pub mod near_intents; +pub mod okx; + +pub use client::SwapClient; +pub use near_intents::NearIntentsProxyClient; + +use crate::responders::{ApiError, ApiResponse}; +use primitives::FiatAssets; +use rocket::{State, get, post, serde::json::Json, tokio::sync::Mutex}; + +#[get("/swap/assets")] +pub async fn get_swap_assets(client: &State>) -> Result, ApiError> { + Ok(client.lock().await.get_swap_assets().await?.into()) +} + +#[post("/swaps/near_intents/quote", data = "")] +pub async fn post_near_intents_quote(body: Json, client: &State>) -> Result, ApiError> { + let response = client.lock().await.quote(body.0).await?; + Ok(Json(response)) +} diff --git a/core/apps/api/src/swap/near_intents.rs b/core/apps/api/src/swap/near_intents.rs new file mode 100644 index 0000000000..f1e3cde144 --- /dev/null +++ b/core/apps/api/src/swap/near_intents.rs @@ -0,0 +1,33 @@ +use cacher::{CacheKey, CacherClient}; +use primitives::SwapProvider; +use swapper::near_intents::base_url; + +pub struct NearIntentsProxyClient { + client: reqwest::Client, + cacher: CacherClient, +} + +impl NearIntentsProxyClient { + pub fn new(cacher: CacherClient) -> Self { + Self { + client: reqwest::Client::new(), + cacher, + } + } + + pub async fn quote(&self, body: serde_json::Value) -> Result> { + let url = format!("{}/v0/quote/forward", base_url()); + let response = self.client.post(&url).json(&body).send().await?.json::().await?; + + if let Some(address) = response.pointer("/quote/depositAddress").and_then(|v| v.as_str()) + && !address.is_empty() + { + let _ = self + .cacher + .add_to_set_cached(CacheKey::SwapDepositAddresses(SwapProvider::NearIntents.as_ref()), &[address.to_string()]) + .await; + } + + Ok(response) + } +} diff --git a/core/apps/api/src/swap/okx.rs b/core/apps/api/src/swap/okx.rs new file mode 100644 index 0000000000..1df155642e --- /dev/null +++ b/core/apps/api/src/swap/okx.rs @@ -0,0 +1,13 @@ +use primitives::swap::{ProxyQuote, ProxyQuoteRequest, SwapQuoteData}; +use rocket::serde::json::Json; +use swapper::{RpcClient, okx::OkxProvider, proxy::ProxyResponse}; + +#[rocket::post("/swaps/providers/okx/quote", data = "")] +pub async fn post_okx_quote(body: Json, provider: &rocket::State>) -> Json> { + Json(provider.get_quote(body.into_inner()).await.into()) +} + +#[rocket::post("/swaps/providers/okx/quote_data", data = "")] +pub async fn post_okx_quote_data(body: Json, provider: &rocket::State>) -> Json> { + Json(provider.get_quote_data(body.into_inner()).await.into()) +} diff --git a/core/apps/api/src/webhooks.rs b/core/apps/api/src/webhooks.rs new file mode 100644 index 0000000000..8b213027cf --- /dev/null +++ b/core/apps/api/src/webhooks.rs @@ -0,0 +1,88 @@ +use gem_tracing::info_with_fields; +use primitives::{TransactionId, WebhookKind}; +use rocket::http::Status; +use rocket::request::FromParam; +use rocket::{State, post, serde::json::Json, tokio::sync::Mutex}; +use std::str::FromStr; +use storage::Database; +use storage::database::webhooks::WebhooksStore; +use streamer::{QueueName, StreamProducer, SupportWebhookPayload}; + +use crate::devices::FiatQuotesClient; +use crate::responders::ApiError; + +pub struct WebhooksClient { + stream_producer: StreamProducer, +} + +impl WebhooksClient { + pub fn new(stream_producer: StreamProducer) -> Self { + Self { stream_producer } + } + + pub async fn process_support_webhook(&self, webhook_data: serde_json::Value) -> Result<(), Box> { + let payload = SupportWebhookPayload::new(webhook_data); + self.stream_producer.publish(QueueName::SupportWebhooks, &payload).await?; + Ok(()) + } + + pub async fn process_broadcast_webhook(&self, payload: TransactionId) -> Result<(), Box> { + let transaction_id = payload.to_string(); + info_with_fields!("received broadcast webhook", transaction_id = transaction_id.as_str()); + self.stream_producer.publish(QueueName::StorePendingTransactions, &payload).await?; + info_with_fields!("published broadcast webhook", transaction_id = transaction_id.as_str()); + Ok(()) + } +} + +pub struct WebhookKindParam(WebhookKind); + +impl<'r> FromParam<'r> for WebhookKindParam { + type Error = &'r str; + + fn from_param(param: &'r str) -> Result { + WebhookKind::from_str(param).map(Self).map_err(|_| param) + } +} + +fn authorize_webhook(database: &State, kind: WebhookKind, sender: &str, secret: &str) -> Result<(), ApiError> { + let enabled = database + .client() + .and_then(|mut c| WebhooksStore::get_webhook_endpoint(&mut c, kind, sender, secret).map_err(Into::into)) + .map_err(|_| ApiError::InternalServerError("Failed to load webhook endpoint".to_string()))? + .ok_or_else(|| ApiError::NotFound("Webhook endpoint not found".to_string()))?; + + if !enabled { + return Err(ApiError::NotFound("Webhook endpoint not found".to_string())); + } + + Ok(()) +} + +#[post("/webhooks///", data = "")] +pub async fn create_webhook( + kind: WebhookKindParam, + sender: &str, + secret: &str, + database: &State, + webhook_data: Json, + fiat_quotes_client: &State>, + webhooks_client: &State>, +) -> Result { + authorize_webhook(database, kind.0, sender, secret)?; + + let webhook_data = webhook_data.0; + match kind.0 { + WebhookKind::Transactions => { + let payload: TransactionId = serde_json::from_value(webhook_data)?; + webhooks_client.lock().await.process_broadcast_webhook(payload).await?; + } + WebhookKind::Support => { + webhooks_client.lock().await.process_support_webhook(webhook_data).await?; + } + WebhookKind::Fiat => { + fiat_quotes_client.lock().await.process_and_publish_webhook(sender, webhook_data).await?; + } + } + Ok(Status::Ok) +} diff --git a/core/apps/api/src/websocket.rs b/core/apps/api/src/websocket.rs new file mode 100644 index 0000000000..630e8ea742 --- /dev/null +++ b/core/apps/api/src/websocket.rs @@ -0,0 +1,30 @@ +use std::error::Error; + +use redis::aio::MultiplexedConnection; +use redis::{PushInfo, PushKind}; +use rocket_ws::result::Error as WsError; +use rocket_ws::stream::DuplexStream; +use tokio::sync::mpsc::UnboundedReceiver; + +pub fn decode_push_message(message: &PushInfo) -> Option<(&str, &[u8])> { + match (&message.kind, message.data.as_slice()) { + (PushKind::Message, [redis::Value::BulkString(channel), redis::Value::BulkString(value)]) => Some((std::str::from_utf8(channel).ok()?, value)), + _ => None, + } +} + +#[allow(clippy::match_like_matches_macro)] +pub fn is_disconnect_error(error: &WsError) -> bool { + match error { + WsError::Protocol(_) | WsError::ConnectionClosed | WsError::AlreadyClosed => true, + _ => false, + } +} + +pub async fn setup_ws_resources(redis_url: &str, stream: DuplexStream) -> Result<(DuplexStream, MultiplexedConnection, UnboundedReceiver), Box> { + let client = redis::Client::open(redis_url)?; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let config = redis::AsyncConnectionConfig::new().set_push_sender(tx); + let redis_connection = client.get_multiplexed_async_connection_with_config(&config).await?; + Ok((stream, redis_connection, rx)) +} diff --git a/core/apps/api/src/websocket_prices/client.rs b/core/apps/api/src/websocket_prices/client.rs new file mode 100644 index 0000000000..8d8d8eb65d --- /dev/null +++ b/core/apps/api/src/websocket_prices/client.rs @@ -0,0 +1,129 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::sync::Arc; + +use cacher::CacheKey; +use pricer::PriceClient; +use primitives::{AssetId, AssetPrice, AssetPriceInfo, WebSocketPriceAction, WebSocketPriceActionType, WebSocketPricePayload}; +use redis::PushInfo; +use redis::aio::MultiplexedConnection; +use rocket::futures::SinkExt; +use rocket::serde::json::serde_json; +use rocket::tokio::sync::Mutex; +use rocket_ws::Message; +use rocket_ws::stream::DuplexStream; + +use crate::websocket::decode_push_message; + +pub struct PriceObserverConfig { + pub redis_url: String, +} + +pub struct PriceObserverClient { + price_client: Arc>, + assets: HashSet, + prices_to_publish: HashMap, + interval: rocket::tokio::time::Interval, +} + +impl PriceObserverClient { + pub fn new(price_client: Arc>) -> Self { + PriceObserverClient { + price_client, + assets: HashSet::new(), + prices_to_publish: HashMap::new(), + interval: rocket::tokio::time::interval(std::time::Duration::from_secs(5)), + } + } + + pub async fn next_interval(&mut self) { + self.interval.tick().await; + } + + pub fn take_prices(&mut self) -> Vec { + self.prices_to_publish.drain().map(|(_, v)| v).collect() + } + + fn get_channel_ids(&self) -> Vec { + self.assets.iter().map(|id| CacheKey::Price(&id.to_string()).key()).collect() + } + + async fn price_payload(&self, include_rates: bool) -> Result> { + let client = self.price_client.lock().await; + let prices = client + .get_cache_prices(self.assets.iter().cloned().collect()) + .await? + .into_iter() + .map(|x| x.as_asset_price_primitive()) + .collect(); + let rates = if include_rates { client.get_cache_fiat_rates().await? } else { vec![] }; + Ok(WebSocketPricePayload { prices, rates }) + } + + pub async fn handle_ws_message( + &mut self, + message: Message, + redis_connection: &mut MultiplexedConnection, + stream: &mut DuplexStream, + ) -> Result<(), Box> { + match message { + Message::Binary(data) => self.handle_message_payload(data, redis_connection, stream).await, + Message::Text(text) => self.handle_message_payload(text.into_bytes(), redis_connection, stream).await.or(Ok(())), + Message::Ping(data) => Ok(stream.send(Message::Pong(data)).await?), + Message::Close(_) => Ok(()), + Message::Pong(_) | Message::Frame(_) => Ok(()), + } + } + + async fn handle_message_payload(&mut self, data: Vec, redis_connection: &mut MultiplexedConnection, stream: &mut DuplexStream) -> Result<(), Box> { + let action = serde_json::from_slice::(&data)?; + let new_assets: HashSet = action.assets.iter().cloned().collect(); + + let (needs_rates, subscribe_channels) = match action.action { + WebSocketPriceActionType::Subscribe => { + let old_channels = self.get_channel_ids(); + self.assets.clear(); + self.prices_to_publish.clear(); + if !old_channels.is_empty() { + redis_connection.unsubscribe(old_channels).await?; + } + self.assets.extend(new_assets); + (true, self.get_channel_ids()) + } + WebSocketPriceActionType::Add => { + let added_assets: Vec = new_assets.into_iter().filter(|asset| !self.assets.contains(asset)).collect(); + let subscribe_channels: Vec = added_assets.iter().map(|id| CacheKey::Price(&id.to_string()).key()).collect(); + self.assets.extend(added_assets); + (false, subscribe_channels) + } + }; + + let observed: Vec = self.assets.iter().cloned().collect(); + let _ = self.price_client.lock().await.track_observed_assets(&observed).await; + + let payload = self.price_payload(needs_rates).await?; + self.send_payload(stream, payload).await?; + + if !subscribe_channels.is_empty() { + redis_connection.subscribe(subscribe_channels).await?; + } + Ok(()) + } + + pub async fn send_payload(&self, stream: &mut DuplexStream, payload: WebSocketPricePayload) -> Result<(), Box> { + let text = serde_json::to_string(&payload)?; + Ok(stream.send(Message::Text(text)).await?) + } + + pub fn handle_redis_message(&mut self, message: &PushInfo) -> Result<(), Box> { + let Some((_, value)) = decode_push_message(message) else { + return Ok(()); + }; + let info = serde_json::from_slice::(value)?; + if !self.assets.contains(&info.asset_id) { + return Ok(()); + } + self.prices_to_publish.insert(info.asset_id.to_string(), info.as_asset_price_primitive()); + Ok(()) + } +} diff --git a/core/apps/api/src/websocket_prices/mod.rs b/core/apps/api/src/websocket_prices/mod.rs new file mode 100644 index 0000000000..cf66d351d6 --- /dev/null +++ b/core/apps/api/src/websocket_prices/mod.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use pricer::PriceClient; +use rocket::State; +use rocket::http::Status; +use rocket::tokio::sync::Mutex; +use rocket_ws::{Channel, WebSocket}; + +mod client; +mod stream; + +pub use client::PriceObserverConfig; + +#[rocket::get("/prices")] +pub async fn ws_prices(ws: WebSocket, price_client: &State>>, config: &State>) -> Channel<'static> { + let price_client = price_client.inner().clone(); + let redis_url = config.redis_url.clone(); + + ws.channel(move |ws_stream| { + Box::pin(async move { + let mut observer = client::PriceObserverClient::new(price_client); + stream::new_stream(&redis_url, &mut observer, ws_stream).await; + Ok::<(), rocket_ws::result::Error>(()) + }) + }) +} + +#[rocket::get("/health")] +pub fn ws_health() -> Status { + Status::Ok +} diff --git a/core/apps/api/src/websocket_prices/stream.rs b/core/apps/api/src/websocket_prices/stream.rs new file mode 100644 index 0000000000..96c101a1dc --- /dev/null +++ b/core/apps/api/src/websocket_prices/stream.rs @@ -0,0 +1,53 @@ +use gem_tracing::error_fields; +use primitives::WebSocketPricePayload; +use rocket::futures::StreamExt; +use rocket_ws::stream::DuplexStream; + +use super::client::PriceObserverClient; + +pub async fn new_stream(redis_url: &str, observer: &mut PriceObserverClient, stream: DuplexStream) { + let Ok((mut stream, mut redis_connection, mut rx)) = crate::websocket::setup_ws_resources(redis_url, stream).await else { + error_fields!("websocket failed to setup redis connection"); + return; + }; + + loop { + tokio::select! { + biased; + _ = observer.next_interval() => { + let prices = observer.take_prices(); + if prices.is_empty() { + continue; + } + + let payload = WebSocketPricePayload { prices, rates: vec![] }; + if observer.send_payload(&mut stream, payload).await.is_err() { + break; + } + } + Some(message) = rx.recv() => { + if let Err(e) = observer.handle_redis_message(&message) { + error_fields!("websocket redis message handler error", message = format!("{e:?}")); + } + } + message = stream.next() => { + match message { + Some(Ok(message)) => { + if let Err(e) = observer.handle_ws_message(message, &mut redis_connection, &mut stream).await { + error_fields!("websocket message handler error", message = format!("{e:?}")); + } + } + Some(Err(e)) => { + if !crate::websocket::is_disconnect_error(&e) { + error_fields!("websocket stream error", message = format!("{e:?}")); + } + break; + } + None => { + break; + } + } + } + } + } +} diff --git a/core/apps/api/src/websocket_stream/client.rs b/core/apps/api/src/websocket_stream/client.rs new file mode 100644 index 0000000000..cd9004dcfc --- /dev/null +++ b/core/apps/api/src/websocket_stream/client.rs @@ -0,0 +1,92 @@ +use std::error::Error; +use std::sync::Arc; + +use gem_tracing::info_with_fields; +use pricer::PriceClient; +use primitives::{AssetPrice, StreamEvent, StreamMessage, device_stream_channel}; +use redis::PushInfo; +use redis::aio::MultiplexedConnection; +use rocket::futures::SinkExt; +use rocket::serde::json::serde_json; +use rocket::tokio::sync::Mutex; +use rocket_ws::Message; +use rocket_ws::stream::DuplexStream; + +use super::price_handler::PriceHandler; +use crate::websocket::decode_push_message; + +pub struct StreamObserverConfig { + pub redis_url: String, +} + +pub struct StreamObserverClient { + device_channel: String, + price_handler: PriceHandler, +} + +impl StreamObserverClient { + pub fn new(device_id: String, price_client: Arc>) -> Self { + let device_channel = device_stream_channel(&device_id); + Self { + device_channel, + price_handler: PriceHandler::new(price_client), + } + } + + pub async fn next_price_interval(&mut self) { + self.price_handler.next_interval().await; + } + + pub fn take_prices(&mut self) -> Vec { + self.price_handler.take_prices() + } + + pub async fn subscribe_device_channel(&self, redis_connection: &mut MultiplexedConnection) -> Result<(), Box> { + redis_connection.subscribe(&self.device_channel).await?; + Ok(()) + } + + pub async fn handle_ws_message( + &mut self, + message: Message, + redis_connection: &mut MultiplexedConnection, + stream: &mut DuplexStream, + ) -> Result<(), Box> { + match message { + Message::Binary(data) => self.handle_message_payload(data, redis_connection, stream).await, + Message::Text(text) => self.handle_message_payload(text.into_bytes(), redis_connection, stream).await.or(Ok(())), + Message::Ping(data) => Ok(stream.send(Message::Pong(data)).await?), + Message::Close(_) => { + info_with_fields!("websocket client closed connection gracefully", status = "ok"); + Ok(()) + } + Message::Pong(_) | Message::Frame(_) => Ok(()), + } + } + + async fn handle_message_payload(&mut self, data: Vec, redis_connection: &mut MultiplexedConnection, stream: &mut DuplexStream) -> Result<(), Box> { + let message = serde_json::from_slice::(&data)?; + if let Some(event) = self.price_handler.handle_stream_message(&message, redis_connection).await? { + self.send_event(stream, event).await?; + } + Ok(()) + } + + pub fn handle_redis_message(&mut self, message: &PushInfo) -> Result, Box> { + let Some((channel, value)) = decode_push_message(message) else { + return Ok(None); + }; + + if channel == self.device_channel { + Ok(Some(serde_json::from_slice::(value)?)) + } else { + self.price_handler.handle_price_message(value)?; + Ok(None) + } + } + + pub async fn send_event(&self, stream: &mut DuplexStream, event: StreamEvent) -> Result<(), Box> { + let text = serde_json::to_string(&event)?; + Ok(stream.send(Message::Text(text)).await?) + } +} diff --git a/core/apps/api/src/websocket_stream/mod.rs b/core/apps/api/src/websocket_stream/mod.rs new file mode 100644 index 0000000000..84d7b0fae5 --- /dev/null +++ b/core/apps/api/src/websocket_stream/mod.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use pricer::PriceClient; +use rocket::State; +use rocket::tokio::sync::Mutex; +use rocket_ws::{Channel, WebSocket}; + +use crate::devices::auth_config::AuthConfig; +use crate::devices::guard::AuthenticatedDevice; + +mod client; +mod price_handler; +mod stream; + +#[rocket::get("/health")] +pub fn ws_health(_config: &State) -> rocket::http::Status { + rocket::http::Status::Ok +} + +pub use client::StreamObserverConfig; + +#[rocket::get("/stream")] +pub async fn ws_stream(ws: WebSocket, auth: AuthenticatedDevice, price_client: &State>>, config: &State>) -> Channel<'static> { + let price_client = price_client.inner().clone(); + let redis_url = config.redis_url.clone(); + let device_id = auth.device_row.device_id.clone(); + + ws.channel(move |ws_stream| { + Box::pin(async move { + let mut observer = client::StreamObserverClient::new(device_id, price_client); + stream::new_stream(&redis_url, &mut observer, ws_stream).await; + Ok::<(), rocket_ws::result::Error>(()) + }) + }) +} diff --git a/core/apps/api/src/websocket_stream/price_handler.rs b/core/apps/api/src/websocket_stream/price_handler.rs new file mode 100644 index 0000000000..f8b22dc626 --- /dev/null +++ b/core/apps/api/src/websocket_stream/price_handler.rs @@ -0,0 +1,116 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::sync::Arc; + +use cacher::CacheKey; +use pricer::PriceClient; +use primitives::{AssetId, AssetPrice, AssetPriceInfo, StreamEvent, StreamMessage, StreamMessagePrices, WebSocketPricePayload}; +use redis::aio::MultiplexedConnection; +use rocket::tokio::sync::Mutex; + +pub struct PriceHandler { + price_client: Arc>, + assets: HashSet, + prices_to_publish: HashMap, + interval: rocket::tokio::time::Interval, +} + +impl PriceHandler { + pub fn new(price_client: Arc>) -> Self { + Self { + price_client, + assets: HashSet::new(), + prices_to_publish: HashMap::new(), + interval: rocket::tokio::time::interval(std::time::Duration::from_secs(5)), + } + } + + pub async fn next_interval(&mut self) { + self.interval.tick().await; + } + + pub fn take_prices(&mut self) -> Vec { + self.prices_to_publish.drain().map(|(_, v)| v).collect() + } + + fn get_channel_ids(&self) -> Vec { + self.assets.iter().map(|id| CacheKey::Price(&id.to_string()).key()).collect() + } + + pub fn handle_price_message(&mut self, value: &[u8]) -> Result<(), Box> { + let info = serde_json::from_slice::(value)?; + if !self.assets.contains(&info.asset_id) { + return Ok(()); + } + let price = info.as_asset_price_primitive(); + self.prices_to_publish.insert(price.asset_id.to_string(), price); + Ok(()) + } + + pub async fn handle_stream_message( + &mut self, + message: &StreamMessage, + redis_connection: &mut MultiplexedConnection, + ) -> Result, Box> { + match message { + StreamMessage::GetPrices(msg) => Ok(Some(self.get_prices(msg).await?)), + StreamMessage::SubscribePrices(msg) => Ok(Some(self.subscribe_prices(msg, redis_connection).await?)), + StreamMessage::AddPrices(msg) => Ok(Some(self.add_prices(msg, redis_connection).await?)), + StreamMessage::UnsubscribePrices(msg) => Ok(Some(self.unsubscribe_prices(msg, redis_connection).await?)), + StreamMessage::SubscribeRealtimePrices(_) | StreamMessage::UnsubscribeRealtimePrices(_) => Ok(None), + } + } + + async fn get_prices(&self, message: &StreamMessagePrices) -> Result> { + self.price_event(message.assets.clone(), false).await + } + + async fn subscribe_prices(&mut self, message: &StreamMessagePrices, redis_connection: &mut MultiplexedConnection) -> Result> { + let old_channels = self.get_channel_ids(); + self.assets = message.assets.iter().cloned().collect(); + self.prices_to_publish.clear(); + if !old_channels.is_empty() { + redis_connection.unsubscribe(old_channels).await?; + } + self.observe_assets().await; + let event = self.price_event(self.assets.iter().cloned().collect(), true).await?; + redis_connection.subscribe(self.get_channel_ids()).await?; + Ok(event) + } + + async fn add_prices(&mut self, message: &StreamMessagePrices, redis_connection: &mut MultiplexedConnection) -> Result> { + let new_assets: Vec = message.assets.iter().filter(|asset| !self.assets.contains(*asset)).cloned().collect(); + let new_channels: Vec = new_assets.iter().map(|id| CacheKey::Price(&id.to_string()).key()).collect(); + self.assets.extend(new_assets); + self.observe_assets().await; + let event = self.price_event(self.assets.iter().cloned().collect(), false).await?; + if !new_channels.is_empty() { + redis_connection.subscribe(new_channels).await?; + } + Ok(event) + } + + async fn unsubscribe_prices(&mut self, message: &StreamMessagePrices, redis_connection: &mut MultiplexedConnection) -> Result> { + let removed_assets: Vec = message.assets.iter().filter(|asset| self.assets.contains(*asset)).cloned().collect(); + let removed_channels: Vec = removed_assets.iter().map(|id| CacheKey::Price(&id.to_string()).key()).collect(); + for asset in &removed_assets { + self.assets.remove(asset); + self.prices_to_publish.remove(&asset.to_string()); + } + let event = self.price_event(self.assets.iter().cloned().collect(), false).await?; + redis_connection.unsubscribe(removed_channels).await?; + Ok(event) + } + + async fn observe_assets(&self) { + let observed: Vec = self.assets.iter().cloned().collect(); + let _ = self.price_client.lock().await.track_observed_assets(&observed).await; + } + + async fn price_event(&self, asset_ids: Vec, include_rates: bool) -> Result> { + let client = self.price_client.lock().await; + let prices = client.get_cache_prices(asset_ids).await?.into_iter().map(|x| x.as_asset_price_primitive()).collect(); + let rates = if include_rates { client.get_cache_fiat_rates().await? } else { vec![] }; + Ok(StreamEvent::Prices(WebSocketPricePayload { prices, rates })) + } +} diff --git a/core/apps/api/src/websocket_stream/stream.rs b/core/apps/api/src/websocket_stream/stream.rs new file mode 100644 index 0000000000..5782e9dad3 --- /dev/null +++ b/core/apps/api/src/websocket_stream/stream.rs @@ -0,0 +1,76 @@ +use gem_tracing::{error_fields, info_with_fields}; +use primitives::{StreamEvent, WebSocketPricePayload}; +use rocket::futures::StreamExt; +use rocket_ws::stream::DuplexStream; + +use super::client::StreamObserverClient; + +pub async fn new_stream(redis_url: &str, observer: &mut StreamObserverClient, stream: DuplexStream) { + let Ok((mut stream, mut redis_connection, mut rx)) = crate::websocket::setup_ws_resources(redis_url, stream).await else { + error_fields!("websocket failed to setup redis connection"); + return; + }; + + info_with_fields!("websocket device stream connected", status = "ok"); + + if let Err(e) = observer.subscribe_device_channel(&mut redis_connection).await { + error_fields!("websocket failed to subscribe device channel", message = format!("{e:?}")); + return; + } + + loop { + tokio::select! { + biased; + _ = observer.next_price_interval() => { + let prices = observer.take_prices(); + if prices.is_empty() { + continue; + } + + let payload = WebSocketPricePayload { prices, rates: vec![] }; + match observer.send_event(&mut stream, StreamEvent::Prices(payload)).await { + Ok(_) => { + info_with_fields!("websocket tick notified prices", status = "ok"); + } + Err(e) => { + error_fields!("websocket send error on tick", message = format!("{e:?}")); + break; + } + } + } + Some(message) = rx.recv() => { + match observer.handle_redis_message(&message) { + Ok(Some(event)) => { + if let Err(e) = observer.send_event(&mut stream, event).await { + error_fields!("websocket send event error", message = format!("{e:?}")); + break; + } + } + Ok(None) => { } + Err(e) => { + error_fields!("websocket redis message handler error", message = format!("{e:?}")); + } + } + } + message = stream.next() => { + match message { + Some(Ok(message)) => { + if let Err(e) = observer.handle_ws_message(message, &mut redis_connection, &mut stream).await { + error_fields!("websocket message handler error", message = format!("{e:?}")); + } + } + Some(Err(e)) => { + if !crate::websocket::is_disconnect_error(&e) { + error_fields!("websocket stream error", message = format!("{e:?}")); + } + break; + } + None => { + break; + } + } + } + } + } + info_with_fields!("websocket device stream disconnected", status = "ok"); +} diff --git a/core/apps/daemon/Cargo.toml b/core/apps/daemon/Cargo.toml new file mode 100644 index 0000000000..49ee9fc7cd --- /dev/null +++ b/core/apps/daemon/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "daemon" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["signal"] } +rocket = { workspace = true } +reqwest = { workspace = true } +futures = { workspace = true } +chrono = { workspace = true } +strum = { workspace = true } +async-trait = { workspace = true } +num-bigint = { workspace = true } + +prometheus-client = { workspace = true } +metrics = { path = "../../crates/metrics" } +gem_tracing = { path = "../../crates/tracing" } +settings = { path = "../../crates/settings" } +storage = { path = "../../crates/storage" } +api_connector = { path = "../../crates/api_connector" } +coingecko = { path = "../../crates/coingecko" } +pricer = { path = "../../crates/pricer" } +prices = { path = "../../crates/prices" } +primitives = { path = "../../crates/primitives" } +fiat = { path = "../../crates/fiat" } +nft = { path = "../../crates/nft" } +job_runner = { path = "../../crates/job_runner" } +chain_primitives = { path = "../../crates/chain_primitives" } +search_index = { path = "../../crates/search_index" } +cacher = { path = "../../crates/cacher" } +streamer = { path = "../../crates/streamer" } +localizer = { path = "../../crates/localizer" } +support = { path = "../../crates/support" } +settings_chain = { path = "../../crates/settings_chain" } +chain_traits = { path = "../../crates/chain_traits" } +number_formatter = { path = "../../crates/number_formatter" } +gem_rewards = { path = "../../crates/gem_rewards" } +gem_client = { path = "../../crates/gem_client", features = ["reqwest"] } +gem_evm = { path = "../../crates/gem_evm", features = ["rpc", "reqwest"] } +gem_jsonrpc = { path = "../../crates/gem_jsonrpc", features = ["client", "reqwest"] } +swapper = { path = "../../crates/swapper", features = ["reqwest_provider"] } + +[dev-dependencies] +primitives = { path = "../../crates/primitives", features = ["testkit"] } diff --git a/core/apps/daemon/src/client/mod.rs b/core/apps/daemon/src/client/mod.rs new file mode 100644 index 0000000000..7530bd3dc4 --- /dev/null +++ b/core/apps/daemon/src/client/mod.rs @@ -0,0 +1,3 @@ +mod vault_address; + +pub use vault_address::SwapVaultAddressClient; diff --git a/core/apps/daemon/src/client/vault_address.rs b/core/apps/daemon/src/client/vault_address.rs new file mode 100644 index 0000000000..2324aced78 --- /dev/null +++ b/core/apps/daemon/src/client/vault_address.rs @@ -0,0 +1,42 @@ +use std::error::Error; + +use cacher::{CacheKey, CacherClient}; +use primitives::SwapProvider; +use std::collections::HashMap; + +use swapper::SwapperProvider; + +type AddressMap = HashMap; + +#[derive(Clone)] +pub struct SwapVaultAddressClient { + cacher: CacherClient, +} + +impl SwapVaultAddressClient { + pub fn new(cacher: CacherClient) -> Self { + Self { cacher } + } + + pub async fn get_deposit_address_map(&self) -> Result> { + self.get_address_map(|p| CacheKey::SwapDepositAddresses(p)).await + } + + pub async fn get_send_address_map(&self) -> Result> { + self.get_address_map(|p| CacheKey::SwapSendAddresses(p)).await + } + + async fn get_address_map(&self, key_fn: F) -> Result> + where + F: Fn(&str) -> CacheKey<'_>, + { + let providers = SwapProvider::cross_chain_providers(); + let keys: Vec = providers.iter().map(|p| key_fn(p.as_ref()).key()).collect(); + let results = self.cacher.get_set_members_grouped(keys).await?; + Ok(providers + .into_iter() + .zip(results) + .flat_map(|(provider, members)| members.into_iter().map(move |addr| (addr, provider))) + .collect()) + } +} diff --git a/core/apps/daemon/src/consumers/fiat/fiat_webhook_consumer.rs b/core/apps/daemon/src/consumers/fiat/fiat_webhook_consumer.rs new file mode 100644 index 0000000000..eae67fd28a --- /dev/null +++ b/core/apps/daemon/src/consumers/fiat/fiat_webhook_consumer.rs @@ -0,0 +1,136 @@ +use std::error::Error; + +use async_trait::async_trait; +use fiat::FiatProvider; +use fiat::FiatProviderFactory; +use gem_tracing::{error_with_fields, info_with_fields}; +use localizer::LanguageLocalizer; +use primitives::{Device, FiatTransactionStatus, GorushNotification, PushNotification, TransactionId}; +use settings::Settings; +use storage::models::FiatTransactionRow; +use storage::{AssetsRepository, Database, WalletsRepository}; +use streamer::consumer::MessageConsumer; +use streamer::{FiatWebhook, FiatWebhookPayload, NotificationsPayload, QueueName, StreamProducer, StreamProducerQueue, WalletStreamEvent, WalletStreamPayload}; + +use crate::pusher::Pusher; + +pub struct FiatWebhookConsumer { + pub database: Database, + pub providers: Vec>, + pub stream_producer: StreamProducer, +} + +impl FiatWebhookConsumer { + pub fn new(database: Database, settings: Settings, stream_producer: StreamProducer) -> Self { + let providers = FiatProviderFactory::new_providers(settings); + + Self { + database, + providers, + stream_producer, + } + } + + async fn send_fiat_notification(&self, updated: &FiatTransactionRow) -> Result<(), Box> { + let asset = self.database.assets()?.get_asset(&updated.asset_id.0)?; + let wallet_id = self.database.wallets()?.get_wallet_by_id(updated.wallet_id)?.wallet_id.0; + let devices: Vec = self + .database + .wallets()? + .get_devices_by_wallet_id(updated.wallet_id)? + .into_iter() + .map(|d| d.as_primitive()) + .collect(); + + let Some(crypto_value) = updated.value.as_deref() else { + return Ok(()); + }; + let provider = updated.provider_id.0; + let quote_type = updated.transaction_type.0.clone(); + let notifications: Vec = devices + .iter() + .filter_map(|device| { + let localizer = LanguageLocalizer::new_with_language(device.locale.as_str()); + let message = Pusher::fiat_transaction_message(&localizer, "e_type, provider.name(), &asset, crypto_value).ok()?; + let data = PushNotification::new_fiat_transaction(wallet_id.clone(), asset.id.clone()); + GorushNotification::from_device(device.clone(), message.title, message.message.unwrap_or_default(), data) + }) + .collect(); + + self.stream_producer.publish_notifications_fiat_purchase(NotificationsPayload::new(notifications)).await?; + Ok(()) + } +} + +#[async_trait] +impl MessageConsumer for FiatWebhookConsumer { + async fn should_process(&self, _payload: FiatWebhookPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: FiatWebhookPayload) -> Result> { + info_with_fields!("received webhook", provider = payload.provider.id(), payload = payload.data.to_string()); + + let provider = match self.providers.iter().find(|provider| provider.name() == payload.provider) { + Some(provider) => provider, + None => { + info_with_fields!("ignoring webhook for unsupported provider", provider = payload.provider.id()); + return Ok(false); + } + }; + let provider_name = provider.name(); + let provider_id = provider_name.id(); + + let transaction_update = match &payload.payload { + FiatWebhook::OrderId(order_id) => { + info_with_fields!("fetching order status", provider = provider_id, provider_transaction_id = order_id); + match provider.get_order_status(order_id).await { + Ok(transaction) => transaction, + Err(e) => { + error_with_fields!("get_order_status", &*e, provider = provider_id, provider_transaction_id = order_id); + return Err(e); + } + } + } + FiatWebhook::Transaction(transaction) => transaction.clone(), + FiatWebhook::None => { + info_with_fields!("ignoring webhook", provider = provider_id); + return Ok(true); + } + }; + + let existing = self.database.fiat()?.get_fiat_transaction(provider_name, &transaction_update.transaction_id)?; + let updated = self.database.fiat()?.update_fiat_transaction(provider_name, transaction_update)?; + + info_with_fields!( + "processed webhook", + provider = provider_id, + provider_transaction_id = updated.provider_transaction_id.as_deref().unwrap_or(""), + status = format!("{:?}", updated.status.0), + quote_id = updated.quote_id.as_str(), + transaction_hash = updated.transaction_hash.as_deref().unwrap_or("") + ); + + if updated.status.0 == FiatTransactionStatus::Complete && !existing.is_some_and(|row| row.status.0 == FiatTransactionStatus::Complete) { + if let Some(hash) = &updated.transaction_hash { + let transaction_id = TransactionId::new(updated.asset_id.0.chain, hash.clone()); + let _ = self.stream_producer.publish(QueueName::StorePendingTransactions, &transaction_id).await; + info_with_fields!("published fiat transaction to pending", provider = provider_id, transaction_id = transaction_id.to_string()); + } + + if let Err(e) = self.send_fiat_notification(&updated).await { + error_with_fields!("send_fiat_notification", &*e, provider = provider_id); + } + } + + let _ = self + .stream_producer + .publish_wallet_stream_events(vec![WalletStreamPayload { + wallet_id: updated.wallet_id, + event: WalletStreamEvent::FiatTransaction, + }]) + .await; + + Ok(true) + } +} diff --git a/core/apps/daemon/src/consumers/fiat/mod.rs b/core/apps/daemon/src/consumers/fiat/mod.rs new file mode 100644 index 0000000000..f5dadb217c --- /dev/null +++ b/core/apps/daemon/src/consumers/fiat/mod.rs @@ -0,0 +1,22 @@ +pub mod fiat_webhook_consumer; + +use std::error::Error; +use std::sync::Arc; + +use settings::Settings; +use storage::Database; +use streamer::{ConsumerStatusReporter, FiatWebhookPayload, QueueName, ShutdownReceiver, run_consumer}; + +use crate::consumers::{consumer_config, producer_for_queue, reader_for_queue}; + +use fiat_webhook_consumer::FiatWebhookConsumer; + +pub async fn run_consumer_fiat(settings: Settings, shutdown_rx: ShutdownReceiver, reporter: Arc) -> Result<(), Box> { + let database = Database::new(&settings.postgres.url, settings.postgres.pool); + let queue = QueueName::FiatOrderWebhooks; + let (name, stream_reader) = reader_for_queue(&settings, &queue, &shutdown_rx).await?; + let stream_producer = producer_for_queue(&settings, &format!("{name}_producer"), shutdown_rx.clone()).await?; + let consumer = FiatWebhookConsumer::new(database, settings.clone(), stream_producer); + let consumer_config = consumer_config(&settings.consumer); + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config, shutdown_rx, reporter).await +} diff --git a/core/apps/daemon/src/consumers/indexer/fetch_address_transactions_consumer.rs b/core/apps/daemon/src/consumers/indexer/fetch_address_transactions_consumer.rs new file mode 100644 index 0000000000..f49cb35c10 --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/fetch_address_transactions_consumer.rs @@ -0,0 +1,48 @@ +use std::error::Error; + +use async_trait::async_trait; +use cacher::{CacheKey, CacherClient}; +use primitives::{Chain, Transaction}; +use settings_chain::{ChainProviders, TransactionsRequest}; +use storage::Database; +use streamer::{ChainAddressPayload, StreamProducer, StreamProducerQueue, TransactionsPayload, consumer::MessageConsumer}; + +pub struct FetchAddressTransactionsConsumer { + #[allow(dead_code)] + pub database: Database, + pub providers: ChainProviders, + pub producer: StreamProducer, + pub cacher: CacherClient, +} + +impl FetchAddressTransactionsConsumer { + pub fn new(database: Database, providers: ChainProviders, producer: StreamProducer, cacher: CacherClient) -> Self { + Self { + database, + providers, + producer, + cacher, + } + } + + pub async fn process_result(&self, chain: Chain, transactions: Vec) -> Result> { + self.producer.publish_transactions(TransactionsPayload::new(chain, transactions.clone())).await + } +} + +#[async_trait] +impl MessageConsumer for FetchAddressTransactionsConsumer { + async fn should_process(&self, payload: ChainAddressPayload) -> Result> { + self.cacher + .can_process_cached(CacheKey::FetchAddressTransactions(payload.value.chain.as_ref(), &payload.value.address)) + .await + } + async fn process(&self, payload: ChainAddressPayload) -> Result> { + let transactions = self + .providers + .get_transactions_by_address(payload.value.chain, TransactionsRequest::new(payload.value.address.clone())) + .await?; + let _ = self.process_result(payload.value.chain, transactions.clone()).await; + Ok(transactions.len()) + } +} diff --git a/core/apps/daemon/src/consumers/indexer/fetch_assets_consumer.rs b/core/apps/daemon/src/consumers/indexer/fetch_assets_consumer.rs new file mode 100644 index 0000000000..8dd454e089 --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/fetch_assets_consumer.rs @@ -0,0 +1,38 @@ +use std::error::Error; + +use async_trait::async_trait; +use cacher::{CacheKey, CacherClient}; +use gem_tracing::info_with_fields; +use settings_chain::ChainProviders; +use storage::{AssetsRepository, Database}; +use streamer::{FetchAssetsPayload, consumer::MessageConsumer}; + +pub struct FetchAssetsConsumer { + pub database: Database, + pub providers: ChainProviders, + pub cacher: CacherClient, +} + +#[async_trait] +impl MessageConsumer for FetchAssetsConsumer { + async fn should_process(&self, payload: FetchAssetsPayload) -> Result> { + self.cacher.can_process_cached(CacheKey::FetchAssets(&payload.asset_id.to_string())).await + } + + async fn process(&self, payload: FetchAssetsPayload) -> Result> { + let Some(token_id) = payload.asset_id.token_id.clone() else { + return Ok(0); + }; + let asset = self.providers.get_token_data(payload.asset_id.chain, token_id.to_string()).await?; + let added = self.database.assets()?.add_assets(vec![asset.as_basic_primitive()])?; + let name = format!("{:?}", asset.name); + info_with_fields!( + "fetch asset", + chain = payload.asset_id.chain.as_ref(), + symbol = asset.symbol.as_str(), + name = name.as_str(), + added = added + ); + Ok(added) + } +} diff --git a/core/apps/daemon/src/consumers/indexer/fetch_blocks_consumer.rs b/core/apps/daemon/src/consumers/indexer/fetch_blocks_consumer.rs new file mode 100644 index 0000000000..1ffe0c6a3c --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/fetch_blocks_consumer.rs @@ -0,0 +1,30 @@ +use std::error::Error; + +use async_trait::async_trait; +use settings_chain::ChainProviders; +use streamer::{FetchBlocksPayload, StreamProducer, StreamProducerQueue, TransactionsPayload, consumer::MessageConsumer}; + +pub struct FetchBlocksConsumer { + pub providers: ChainProviders, + pub stream_producer: StreamProducer, +} + +impl FetchBlocksConsumer { + pub fn new(providers: ChainProviders, stream_producer: StreamProducer) -> Self { + Self { providers, stream_producer } + } +} + +#[async_trait] +impl MessageConsumer for FetchBlocksConsumer { + async fn should_process(&self, _payload: FetchBlocksPayload) -> Result> { + Ok(true) + } + async fn process(&self, payload: FetchBlocksPayload) -> Result> { + let blocks = vec![payload.block]; + let transactions = self.providers.get_transactions_in_blocks(payload.chain, blocks.clone()).await?; + let payload = TransactionsPayload::new_with_notify(payload.chain, blocks, transactions.clone()); + self.stream_producer.publish_transactions(payload).await?; + Ok(transactions.len()) + } +} diff --git a/core/apps/daemon/src/consumers/indexer/fetch_coin_addresses_consumer.rs b/core/apps/daemon/src/consumers/indexer/fetch_coin_addresses_consumer.rs new file mode 100644 index 0000000000..c0f1637c76 --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/fetch_coin_addresses_consumer.rs @@ -0,0 +1,48 @@ +use num_bigint::BigUint; +use primitives::AssetAddress; +use std::error::Error; + +use async_trait::async_trait; +use cacher::{CacheKey, CacherClient}; +use settings_chain::ChainProviders; +use storage::AssetsAddressesRepository; +use storage::Database; +use streamer::{ChainAddressPayload, consumer::MessageConsumer}; + +pub struct FetchCoinAddressesConsumer { + pub provider: ChainProviders, + pub database: Database, + pub cacher: CacherClient, +} + +impl FetchCoinAddressesConsumer { + pub fn new(provider: ChainProviders, database: Database, cacher: CacherClient) -> Self { + Self { provider, database, cacher } + } +} + +#[async_trait] +impl MessageConsumer for FetchCoinAddressesConsumer { + async fn should_process(&self, payload: ChainAddressPayload) -> Result> { + self.cacher + .can_process_cached(CacheKey::FetchCoinAddresses(payload.value.chain.as_ref(), &payload.value.address)) + .await + } + + async fn process(&self, payload: ChainAddressPayload) -> Result> { + let chain_address = payload.value; + let balance = self.provider.get_balance_coin(chain_address.chain, chain_address.address.clone()).await?; + let balance_value = balance.balance.available.to_string(); + let asset_id = balance.asset_id; + let asset_address = AssetAddress::new(asset_id.clone(), chain_address.address.clone(), Some(balance_value.clone())); + let mut assets_addresses = self.database.assets_addresses()?; + + if balance.balance.available == BigUint::ZERO && assets_addresses.get_asset_address(chain_address, asset_id)?.is_some() { + assets_addresses.delete_assets_addresses(vec![asset_address])?; + } else { + assets_addresses.add_assets_addresses(vec![asset_address])?; + } + + Ok(balance_value) + } +} diff --git a/core/apps/daemon/src/consumers/indexer/fetch_nft_asset_consumer.rs b/core/apps/daemon/src/consumers/indexer/fetch_nft_asset_consumer.rs new file mode 100644 index 0000000000..edbaac1b78 --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/fetch_nft_asset_consumer.rs @@ -0,0 +1,25 @@ +use std::{error::Error, sync::Arc}; + +use ::nft::NFTClient; +use async_trait::async_trait; +use cacher::{CacheKey, CacherClient}; +use streamer::{FetchNFTAssetPayload, consumer::MessageConsumer}; +use tokio::sync::Mutex; + +pub struct FetchNftAssetConsumer { + pub nft_client: Arc>, + pub cacher: CacherClient, +} + +#[async_trait] +impl MessageConsumer for FetchNftAssetConsumer { + async fn should_process(&self, payload: FetchNFTAssetPayload) -> Result> { + let asset_id = payload.asset_id.to_string(); + self.cacher.can_process_cached(CacheKey::FetchNftAsset(&asset_id)).await + } + + async fn process(&self, payload: FetchNFTAssetPayload) -> Result> { + self.nft_client.lock().await.refresh_asset(payload.asset_id).await?; + Ok(1) + } +} diff --git a/core/apps/daemon/src/consumers/indexer/fetch_nft_assets_addresses_consumer.rs b/core/apps/daemon/src/consumers/indexer/fetch_nft_assets_addresses_consumer.rs new file mode 100644 index 0000000000..788ac990b9 --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/fetch_nft_assets_addresses_consumer.rs @@ -0,0 +1,72 @@ +use std::{collections::HashMap, error::Error, sync::Arc}; +use tokio::sync::Mutex; + +use ::nft::{NFTClient, NFTProviderConfig}; +use async_trait::async_trait; +use cacher::{CacheKey, CacherClient}; +use primitives::Chain; +use settings::Settings; +use storage::Database; +use streamer::{ + ChainAddressPayload, ConsumerConfig, ConsumerStatusReporter, QueueName, ShutdownReceiver, StreamConnection, StreamProducer, StreamReader, consumer::MessageConsumer, + run_consumer, +}; + +use crate::consumers::reader_config; + +pub struct FetchNftAssetsAddressesConsumer { + #[allow(dead_code)] + pub database: Database, + #[allow(dead_code)] + pub stream_producer: StreamProducer, + pub cacher: CacherClient, + pub nft_client: Arc>, +} + +impl FetchNftAssetsAddressesConsumer { + pub async fn run( + settings: Settings, + database: Database, + chain: Chain, + connection: &StreamConnection, + cacher: CacherClient, + consumer_config: ConsumerConfig, + shutdown_rx: ShutdownReceiver, + reporter: Arc, + ) -> Result<(), Box> { + let queue = QueueName::FetchNftAssociations; + let name = format!("{}.{}", queue, chain.as_ref()); + let config = reader_config(&settings.rabbitmq, name.clone()); + let stream_reader = StreamReader::from_connection(connection, config).await?; + let stream_producer = StreamProducer::from_connection(connection, shutdown_rx.clone()).await?; + let nft_config = NFTProviderConfig::new( + settings.nft.opensea.key.secret.clone(), + settings.nft.magiceden.key.secret.clone(), + settings.chains.ton.url.clone(), + ); + let nft_client = NFTClient::from_config(database.clone(), nft_config, settings.nft.url.clone()); + let nft_client = Arc::new(Mutex::new(nft_client)); + let consumer = Self { + database, + stream_producer, + cacher, + nft_client, + }; + run_consumer::(&name, stream_reader, queue, Some(chain.as_ref()), consumer, consumer_config, shutdown_rx, reporter).await + } +} + +#[async_trait] +impl MessageConsumer for FetchNftAssetsAddressesConsumer { + async fn should_process(&self, payload: ChainAddressPayload) -> Result> { + self.cacher + .can_process_cached(CacheKey::FetchNftAssetsAddresses(payload.value.chain.as_ref(), &payload.value.address)) + .await + } + + async fn process(&self, payload: ChainAddressPayload) -> Result> { + let map = HashMap::from([(payload.value.chain, payload.value.address.clone())]); + let assets = self.nft_client.lock().await.update_assets_for_addresses(map).await?; + Ok(assets.len()) + } +} diff --git a/core/apps/daemon/src/consumers/indexer/fetch_prices_consumer.rs b/core/apps/daemon/src/consumers/indexer/fetch_prices_consumer.rs new file mode 100644 index 0000000000..9b8d7e8fe1 --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/fetch_prices_consumer.rs @@ -0,0 +1,28 @@ +use std::error::Error; + +use async_trait::async_trait; +use gem_tracing::info_with_fields; +use pricer::{PriceClient, PriceProviders}; +use streamer::{FetchPricesPayload, consumer::MessageConsumer}; + +pub struct FetchPricesConsumer { + pub price_client: PriceClient, + pub providers: PriceProviders, +} + +#[async_trait] +impl MessageConsumer for FetchPricesConsumer { + async fn should_process(&self, _payload: FetchPricesPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: FetchPricesPayload) -> Result> { + let count = match &payload { + FetchPricesPayload::AssetId(asset_id) => self.price_client.add_prices_for_asset_id(&self.providers, asset_id).await?, + FetchPricesPayload::PriceId(price_id) => self.price_client.add_prices_for_price_id(&self.providers, price_id).await?, + }; + let payload_str = payload.to_string(); + info_with_fields!("fetch prices", payload = payload_str.as_str(), count = count); + Ok(count) + } +} diff --git a/core/apps/daemon/src/consumers/indexer/fetch_token_addresses_consumer.rs b/core/apps/daemon/src/consumers/indexer/fetch_token_addresses_consumer.rs new file mode 100644 index 0000000000..e30760dd45 --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/fetch_token_addresses_consumer.rs @@ -0,0 +1,148 @@ +use num_bigint::BigUint; +use primitives::{AssetAddress, AssetBalance, AssetVecExt, ChainAddress}; +use std::collections::HashSet; +use std::error::Error; + +use async_trait::async_trait; +use cacher::{CacheKey, CacherClient}; +use settings_chain::ChainProviders; +use storage::{AssetsAddressesRepository, AssetsRepository, Database}; +use streamer::{ChainAddressPayload, StreamProducer, StreamProducerQueue, consumer::MessageConsumer}; + +pub struct FetchTokenAddressesConsumer { + pub provider: ChainProviders, + pub database: Database, + pub stream_producer: StreamProducer, + pub cacher: CacherClient, +} + +impl FetchTokenAddressesConsumer { + pub fn new(provider: ChainProviders, database: Database, stream_producer: StreamProducer, cacher: CacherClient) -> Self { + Self { + provider, + database, + stream_producer, + cacher, + } + } +} + +#[async_trait] +impl MessageConsumer for FetchTokenAddressesConsumer { + async fn should_process(&self, payload: ChainAddressPayload) -> Result> { + self.cacher + .can_process_cached(CacheKey::FetchTokenAddresses(payload.value.chain.as_ref(), &payload.value.address)) + .await + } + + async fn process(&self, payload: ChainAddressPayload) -> Result> { + let chain_address = payload.value; + let all_assets = self.provider.get_balance_assets(chain_address.chain, chain_address.address.clone()).await?; + let mut assets_addresses = self.database.assets_addresses()?; + let existing_addresses = assets_addresses.get_asset_addresses(chain_address.clone())?; + let changes = TokenAddressChanges::from_balances(&chain_address, existing_addresses, all_assets); + + let asset_ids: Vec<_> = changes.latest_addresses.iter().map(|address| address.asset_id.clone()).collect(); + let existing_ids: HashSet<_> = self.database.assets()?.get_assets(asset_ids)?.ids().into_iter().collect(); + let mut latest_addresses = Vec::new(); + let mut missing_ids = Vec::new(); + + for address in changes.latest_addresses { + if existing_ids.contains(&address.asset_id) { + latest_addresses.push(address); + } else { + missing_ids.push(address.asset_id); + } + } + + let latest_count = latest_addresses.len(); + assets_addresses.delete_assets_addresses(changes.stale_addresses)?; + assets_addresses.add_assets_addresses(latest_addresses)?; + + self.stream_producer.publish_fetch_assets(missing_ids).await?; + + Ok(latest_count) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct TokenAddressChanges { + latest_addresses: Vec, + stale_addresses: Vec, +} + +impl TokenAddressChanges { + fn from_balances(chain_address: &ChainAddress, existing_addresses: Vec, latest_balances: Vec) -> Self { + let mut seen = HashSet::new(); + let latest_addresses: Vec<_> = latest_balances + .into_iter() + .filter(|asset| asset.asset_id.token_id.is_some()) + .filter(|asset| seen.insert(asset.asset_id.clone())) + .filter(|asset| asset.balance.available > BigUint::ZERO) + .map(|asset| AssetAddress::new(asset.asset_id, chain_address.address.clone(), Some(asset.balance.available.to_string()))) + .collect(); + + let latest_ids: HashSet<_> = latest_addresses.iter().map(|address| address.asset_id.clone()).collect(); + let stale_addresses = existing_addresses + .into_iter() + .filter(|address| address.asset_id.token_id.is_some()) + .filter(|address| !latest_ids.contains(&address.asset_id)) + .collect(); + + Self { + latest_addresses, + stale_addresses, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Asset, Chain}; + + #[test] + fn test_from_balances() { + let chain_address = ChainAddress::new(Chain::Ethereum, "0xwallet".to_string()); + let existing_addresses = vec![ + AssetAddress::new(Asset::mock_eth().id, chain_address.address.clone(), Some("10".to_string())), + AssetAddress::new(Asset::mock_ethereum_usdc().id.clone(), chain_address.address.clone(), Some("5".to_string())), + AssetAddress::new(Asset::mock_erc20().id.clone(), chain_address.address.clone(), Some("7".to_string())), + ]; + + let omitted_zero_changes = TokenAddressChanges::from_balances( + &chain_address, + existing_addresses.clone(), + vec![AssetBalance::new(Asset::mock_erc20().id.clone(), BigUint::from(9u32))], + ); + assert_eq!( + omitted_zero_changes.stale_addresses, + vec![AssetAddress::new(Asset::mock_ethereum_usdc().id, chain_address.address.clone(), Some("5".to_string()))] + ); + assert_eq!( + omitted_zero_changes.latest_addresses, + vec![AssetAddress::new(Asset::mock_erc20().id, chain_address.address.clone(), Some("9".to_string()))] + ); + + let explicit_zero_changes = TokenAddressChanges::from_balances( + &chain_address, + existing_addresses, + vec![ + AssetBalance::new(Asset::mock_ethereum_usdc().id.clone(), BigUint::ZERO), + AssetBalance::new(Asset::mock_erc20().id.clone(), BigUint::from(9u32)), + ], + ); + assert_eq!( + explicit_zero_changes.stale_addresses, + vec![AssetAddress::new(Asset::mock_ethereum_usdc().id, chain_address.address.clone(), Some("5".to_string()))] + ); + assert_eq!( + explicit_zero_changes.latest_addresses, + vec![AssetAddress::new(Asset::mock_erc20().id, chain_address.address.clone(), Some("9".to_string()))] + ); + + let new_zero_changes = TokenAddressChanges::from_balances(&chain_address, vec![], vec![AssetBalance::new(Asset::mock_ethereum_usdc().id.clone(), BigUint::ZERO)]); + assert_eq!(new_zero_changes.stale_addresses, vec![]); + assert_eq!(new_zero_changes.latest_addresses, vec![]); + } +} diff --git a/core/apps/daemon/src/consumers/indexer/mod.rs b/core/apps/daemon/src/consumers/indexer/mod.rs new file mode 100644 index 0000000000..9829b4494a --- /dev/null +++ b/core/apps/daemon/src/consumers/indexer/mod.rs @@ -0,0 +1,291 @@ +pub mod fetch_address_transactions_consumer; +pub mod fetch_assets_consumer; +pub mod fetch_blocks_consumer; +pub mod fetch_coin_addresses_consumer; +pub mod fetch_nft_asset_consumer; +pub mod fetch_nft_assets_addresses_consumer; +pub mod fetch_prices_consumer; +pub mod fetch_token_addresses_consumer; + +use std::error::Error; +use std::sync::Arc; + +use ::nft::NFTClient; +use cacher::CacherClient; +use pricer::PriceClient; +use primitives::{Chain, NFTChain}; +use settings::Settings; +use storage::Database; +use streamer::{ + ChainAddressPayload, ConsumerStatusReporter, FetchAssetsPayload, FetchBlocksPayload, FetchNFTAssetPayload, FetchPricesPayload, QueueName, ShutdownReceiver, StreamConnection, + StreamReader, run_consumer, +}; + +use crate::consumers::runner::ChainConsumerRunner; +use crate::consumers::{chain_providers, chain_providers_for, consumer_config, reader_config}; + +use fetch_address_transactions_consumer::FetchAddressTransactionsConsumer; +use fetch_assets_consumer::FetchAssetsConsumer; +use fetch_blocks_consumer::FetchBlocksConsumer; +use fetch_coin_addresses_consumer::FetchCoinAddressesConsumer; +use fetch_nft_asset_consumer::FetchNftAssetConsumer; +use fetch_nft_assets_addresses_consumer::FetchNftAssetsAddressesConsumer; +use fetch_prices_consumer::FetchPricesConsumer; +use fetch_token_addresses_consumer::FetchTokenAddressesConsumer; + +pub async fn run_consumer_indexer( + settings: Settings, + shutdown_rx: ShutdownReceiver, + reporter: Arc, + only: Option, +) -> Result<(), Box> { + use crate::model::IndexerConsumer::*; + + let database = Database::new(&settings.postgres.url, settings.postgres.pool); + let settings = Arc::new(settings); + + let selected: Vec = match only { + Some(one) => vec![one], + None => vec![ + FetchBlocks, + FetchAssets, + FetchPrices, + FetchTokenAssociations, + FetchCoinAssociations, + FetchNftAssociations, + FetchNftAssets, + FetchAddressTransactions, + ], + }; + + let handles: Vec<_> = selected + .into_iter() + .map(|kind| { + let settings = settings.clone(); + let database = database.clone(); + let shutdown_rx = shutdown_rx.clone(); + let reporter = reporter.clone(); + tokio::spawn(async move { + match kind { + FetchBlocks => run_fetch_blocks(settings, database, shutdown_rx, reporter).await, + FetchAssets => run_fetch_assets(settings, database, shutdown_rx, reporter).await, + FetchPrices => run_fetch_prices(settings, database, shutdown_rx, reporter).await, + FetchTokenAssociations => run_fetch_token_associations(settings, database, shutdown_rx, reporter).await, + FetchCoinAssociations => run_fetch_coin_associations(settings, database, shutdown_rx, reporter).await, + FetchNftAssociations => run_fetch_nft_associations(settings, database, shutdown_rx, reporter).await, + FetchNftAssets => run_fetch_nft_assets(settings, database, shutdown_rx, reporter).await, + FetchAddressTransactions => run_fetch_transaction_associations(settings, database, shutdown_rx, reporter).await, + } + }) + }) + .collect(); + + for handle in futures::future::join_all(handles).await { + handle??; + } + Ok(()) +} + +async fn run_fetch_blocks( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + ChainConsumerRunner::new((*settings).clone(), database, QueueName::FetchBlocks, shutdown_rx, reporter) + .await? + .run(|runner, chain| async move { + let queue = QueueName::FetchBlocks; + let name = format!("{}.{}", queue, chain.as_ref()); + let stream_reader = runner.stream_reader().await?; + let stream_producer = runner.stream_producer().await?; + let consumer = FetchBlocksConsumer::new(chain_providers_for(chain, &runner.settings, &name), stream_producer); + run_consumer::( + &name, + stream_reader, + queue, + Some(chain.as_ref()), + consumer, + runner.config, + runner.shutdown_rx, + runner.reporter, + ) + .await + }) + .await +} + +async fn run_fetch_assets( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::FetchAssets; + let name = queue.to_string(); + let connection = StreamConnection::new(&settings.rabbitmq.url, name.clone()).await?; + let config = reader_config(&settings.rabbitmq, name.clone()); + let stream_reader = StreamReader::from_connection(&connection, config).await?; + let cacher = CacherClient::new(&settings.redis.url).await; + let consumer = FetchAssetsConsumer { + providers: chain_providers(&settings, &name), + database, + cacher, + }; + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config(&settings.consumer), shutdown_rx, reporter).await +} + +async fn run_fetch_prices( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::FetchPrices; + let name = queue.to_string(); + let connection = StreamConnection::new(&settings.rabbitmq.url, name.clone()).await?; + let config = reader_config(&settings.rabbitmq, name.clone()); + let stream_reader = StreamReader::from_connection(&connection, config).await?; + let cacher = CacherClient::new(&settings.redis.url).await; + let price_client = PriceClient::new(database, cacher); + let providers = crate::worker::prices::price_providers(&settings); + let consumer = FetchPricesConsumer { price_client, providers }; + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config(&settings.consumer), shutdown_rx, reporter).await +} + +async fn run_fetch_token_associations( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + ChainConsumerRunner::new((*settings).clone(), database, QueueName::FetchTokenAssociations, shutdown_rx, reporter) + .await? + .run(|runner, chain| async move { + let queue = QueueName::FetchTokenAssociations; + let name = format!("{}.{}", queue, chain.as_ref()); + let stream_reader = runner.stream_reader().await?; + let stream_producer = runner.stream_producer().await?; + let consumer = FetchTokenAddressesConsumer::new(chain_providers_for(chain, &runner.settings, &name), runner.database, stream_producer, runner.cacher); + run_consumer::( + &name, + stream_reader, + queue, + Some(chain.as_ref()), + consumer, + runner.config, + runner.shutdown_rx, + runner.reporter, + ) + .await + }) + .await +} + +async fn run_fetch_coin_associations( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + ChainConsumerRunner::new((*settings).clone(), database, QueueName::FetchCoinAssociations, shutdown_rx, reporter) + .await? + .run(|runner, chain| async move { + let queue = QueueName::FetchCoinAssociations; + let name = format!("{}.{}", queue, chain.as_ref()); + let stream_reader = runner.stream_reader().await?; + let consumer = FetchCoinAddressesConsumer::new(chain_providers_for(chain, &runner.settings, &name), runner.database, runner.cacher); + run_consumer::( + &name, + stream_reader, + queue, + Some(chain.as_ref()), + consumer, + runner.config, + runner.shutdown_rx, + runner.reporter, + ) + .await + }) + .await +} + +async fn run_fetch_nft_associations( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let chains: Vec = NFTChain::all().into_iter().map(Into::into).collect(); + ChainConsumerRunner::new((*settings).clone(), database, QueueName::FetchNftAssociations, shutdown_rx, reporter) + .await? + .run_for_chains(chains, |runner, chain| async move { + FetchNftAssetsAddressesConsumer::run( + runner.settings, + runner.database, + chain, + &runner.connection, + runner.cacher, + runner.config, + runner.shutdown_rx, + runner.reporter, + ) + .await + }) + .await +} + +async fn run_fetch_nft_assets( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::FetchNFTCollectionAssets; + let name = queue.to_string(); + let connection = StreamConnection::new(&settings.rabbitmq.url, name.clone()).await?; + let config = reader_config(&settings.rabbitmq, name.clone()); + let stream_reader = StreamReader::from_connection(&connection, config).await?; + let cacher = CacherClient::new(&settings.redis.url).await; + let nft_config = ::nft::NFTProviderConfig::new( + settings.nft.opensea.key.secret.clone(), + settings.nft.magiceden.key.secret.clone(), + settings.chains.ton.url.clone(), + ); + let nft_client = NFTClient::from_config(database, nft_config, settings.nft.url.clone()); + let consumer = FetchNftAssetConsumer { + nft_client: Arc::new(tokio::sync::Mutex::new(nft_client)), + cacher, + }; + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config(&settings.consumer), shutdown_rx, reporter) + .await +} + +async fn run_fetch_transaction_associations( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + ChainConsumerRunner::new((*settings).clone(), database, QueueName::FetchAddressTransactions, shutdown_rx, reporter) + .await? + .run(|runner, chain| async move { + let queue = QueueName::FetchAddressTransactions; + let name = format!("{}.{}", queue, chain.as_ref()); + let stream_reader = runner.stream_reader().await?; + let stream_producer = runner.stream_producer().await?; + let consumer = FetchAddressTransactionsConsumer::new(runner.database, chain_providers_for(chain, &runner.settings, &name), stream_producer, runner.cacher); + run_consumer::( + &name, + stream_reader, + queue, + Some(chain.as_ref()), + consumer, + runner.config, + runner.shutdown_rx, + runner.reporter, + ) + .await + }) + .await +} diff --git a/core/apps/daemon/src/consumers/mod.rs b/core/apps/daemon/src/consumers/mod.rs new file mode 100644 index 0000000000..a8a80c210f --- /dev/null +++ b/core/apps/daemon/src/consumers/mod.rs @@ -0,0 +1,58 @@ +pub mod fiat; +pub mod indexer; +pub mod notifications; +pub mod rewards; +pub mod runner; +pub mod store; +pub mod support; + +use std::error::Error; + +use settings::Settings; +use settings_chain::ChainProviders; +use streamer::{ConsumerConfig, QueueName, ShutdownReceiver, StreamProducer, StreamProducerConfig, StreamReader, StreamReaderConfig}; + +pub use fiat::run_consumer_fiat; +pub use indexer::run_consumer_indexer; +pub use rewards::run_consumer_rewards; +pub use store::run_consumer_store; +pub use support::run_consumer_support; + +pub fn chain_providers(settings: &Settings, name: &str) -> ChainProviders { + ChainProviders::from_settings(settings, &settings::service_user_agent("consumer", Some(name))) +} + +pub fn chain_providers_for(chain: primitives::Chain, settings: &Settings, name: &str) -> ChainProviders { + ChainProviders::for_chain(chain, settings, &settings::service_user_agent("consumer", Some(name))) +} + +pub(crate) fn consumer_config(consumer: &settings::Consumer) -> ConsumerConfig { + ConsumerConfig { + timeout_on_error: consumer.error.timeout, + skip_on_error: consumer.error.skip, + delay: consumer.delay, + retries: consumer.error.retries, + } +} + +pub(crate) fn reader_config(rabbitmq: &settings::RabbitMQ, name: String) -> StreamReaderConfig { + let retry = streamer::Retry::new(rabbitmq.retry.delay, rabbitmq.retry.timeout); + StreamReaderConfig::new(rabbitmq.url.clone(), name, rabbitmq.prefetch, retry) +} + +pub(crate) async fn reader_for_queue(settings: &Settings, queue: &QueueName, shutdown_rx: &ShutdownReceiver) -> Result<(String, StreamReader), Box> { + let name = queue.to_string(); + let config = reader_config(&settings.rabbitmq, name.clone()); + let reader = StreamReader::new(config, shutdown_rx).await?.ok_or("shutdown during connect")?; + Ok((name, reader)) +} + +fn producer_config(settings: &Settings) -> StreamProducerConfig { + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry) +} + +pub(crate) async fn producer_for_queue(settings: &Settings, name: &str, shutdown_rx: ShutdownReceiver) -> Result> { + let config = producer_config(settings); + StreamProducer::new(&config, name, shutdown_rx).await +} diff --git a/core/apps/daemon/src/consumers/notifications/in_app_notifications_consumer.rs b/core/apps/daemon/src/consumers/notifications/in_app_notifications_consumer.rs new file mode 100644 index 0000000000..62e70f5d51 --- /dev/null +++ b/core/apps/daemon/src/consumers/notifications/in_app_notifications_consumer.rs @@ -0,0 +1,104 @@ +use std::error::Error; + +use async_trait::async_trait; +use localizer::LanguageLocalizer; +use number_formatter::{ValueFormatter, ValueStyle}; +use primitives::{ + Device, GorushNotification, JsonDecode, NotificationRewardsRedeemMetadata, NotificationType, PushNotification, PushNotificationReward, PushNotificationTypes, RewardEventType, +}; +use storage::{AssetsRepository, Database, NewNotificationRow, NotificationType as StorageNotificationType, NotificationsRepository, WalletsRepository}; +use streamer::{InAppNotificationPayload, NotificationsPayload, StreamProducer, StreamProducerQueue, consumer::MessageConsumer}; + +pub struct InAppNotificationsConsumer { + database: Database, + stream_producer: StreamProducer, +} + +impl InAppNotificationsConsumer { + pub fn new(database: Database, stream_producer: StreamProducer) -> Self { + Self { database, stream_producer } + } + + fn create_push_notification( + &self, + device: &Device, + notification_type: NotificationType, + wallet_id: i32, + points: i32, + reward_value: Option<&str>, + ) -> Option { + let localizer = LanguageLocalizer::new_with_language(device.locale.as_str()); + let (title, message) = notification_content(&localizer, notification_type, points, reward_value); + let data = PushNotification { + notification_type: PushNotificationTypes::Rewards, + data: serde_json::to_value(PushNotificationReward { wallet_id: wallet_id.to_string() }).ok(), + }; + GorushNotification::from_device(device.clone(), title, message, data) + } +} + +#[async_trait] +impl MessageConsumer for InAppNotificationsConsumer { + async fn should_process(&self, _payload: InAppNotificationPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: InAppNotificationPayload) -> Result> { + let redeem: Option = payload.metadata.decode(); + let reward_value = redeem.as_ref().and_then(|m| { + let asset_id = payload.asset_id.as_ref()?; + let asset = self.database.assets().ok()?.get_asset(asset_id).ok()?; + ValueFormatter::format_with_symbol(ValueStyle::Auto, &m.value, asset.decimals, &asset.symbol).ok() + }); + let points = redeem.as_ref().map(|m| m.points).unwrap_or(0); + + let notification = NewNotificationRow { + wallet_id: payload.wallet_id, + asset_id: payload.asset_id.map(Into::into), + notification_type: StorageNotificationType::from(payload.notification_type), + metadata: payload.metadata.clone(), + }; + self.database.notifications()?.create_notifications(vec![notification])?; + + let devices: Vec = self + .database + .wallets()? + .get_devices_by_wallet_id(payload.wallet_id)? + .into_iter() + .map(|d| d.as_primitive()) + .collect(); + + let notifications: Vec = devices + .iter() + .filter_map(|device| self.create_push_notification(device, payload.notification_type, payload.wallet_id, points, reward_value.as_deref())) + .collect(); + + let count = notifications.len(); + self.stream_producer.publish_notifications_rewards(NotificationsPayload::new(notifications)).await?; + + Ok(count) + } +} + +fn notification_content(localizer: &LanguageLocalizer, notification_type: NotificationType, points: i32, reward_value: Option<&str>) -> (String, String) { + match notification_type { + NotificationType::RewardsCreateUsername => ( + localizer.notification_reward_title(RewardEventType::CreateUsername.points()), + localizer.notification_reward_create_username_description(), + ), + NotificationType::RewardsInvite => ( + localizer.notification_reward_title(RewardEventType::InviteNew.points()), + localizer.notification_reward_invite_description(), + ), + NotificationType::ReferralJoined => ( + localizer.notification_reward_title(RewardEventType::Joined.points()), + localizer.notification_reward_joined_description(), + ), + NotificationType::RewardsEnabled => (localizer.notification_rewards_enabled_title(), localizer.notification_rewards_enabled_description()), + NotificationType::RewardsCodeDisabled => (localizer.notification_rewards_disabled_title(), localizer.notification_rewards_disabled_description()), + NotificationType::RewardsRedeemed => ( + localizer.notification_reward_redeemed_title(), + localizer.notification_reward_redeemed_description(points, reward_value), + ), + } +} diff --git a/core/apps/daemon/src/consumers/notifications/mod.rs b/core/apps/daemon/src/consumers/notifications/mod.rs new file mode 100644 index 0000000000..b7cc99363f --- /dev/null +++ b/core/apps/daemon/src/consumers/notifications/mod.rs @@ -0,0 +1,141 @@ +mod in_app_notifications_consumer; +mod notifications_consumer; +mod notifications_failed_consumer; + +pub use in_app_notifications_consumer::InAppNotificationsConsumer; +pub use notifications_consumer::NotificationsConsumer; +pub use notifications_failed_consumer::NotificationsFailedConsumer; + +use api_connector::PusherClient; +use settings::Settings; +use std::error::Error; +use std::sync::Arc; +use storage::Database; +use streamer::{ + ConsumerConfig, ConsumerStatusReporter, InAppNotificationPayload, NotificationsFailedPayload, NotificationsPayload, QueueName, ShutdownReceiver, StreamProducer, + StreamProducerConfig, StreamReader, run_consumer, +}; + +use crate::consumers::reader_config; + +fn consumer_config(consumer: &settings::Consumer) -> ConsumerConfig { + ConsumerConfig { + timeout_on_error: consumer.error.timeout, + skip_on_error: consumer.error.skip, + delay: consumer.delay, + retries: consumer.error.retries, + } +} + +pub async fn run(settings: Settings, shutdown_rx: ShutdownReceiver, reporter: Arc) -> Result<(), Box> { + let database = Database::new(&settings.postgres.url, settings.postgres.pool); + let settings = Arc::new(settings); + let database = Arc::new(database); + + futures::future::try_join_all(vec![ + tokio::spawn(run_notification_consumer( + settings.clone(), + QueueName::NotificationsPriceAlerts, + shutdown_rx.clone(), + reporter.clone(), + )), + tokio::spawn(run_notification_consumer( + settings.clone(), + QueueName::NotificationsTransactions, + shutdown_rx.clone(), + reporter.clone(), + )), + tokio::spawn(run_notification_consumer( + settings.clone(), + QueueName::NotificationsObservers, + shutdown_rx.clone(), + reporter.clone(), + )), + tokio::spawn(run_notification_consumer( + settings.clone(), + QueueName::NotificationsSupport, + shutdown_rx.clone(), + reporter.clone(), + )), + tokio::spawn(run_notification_consumer( + settings.clone(), + QueueName::NotificationsRewards, + shutdown_rx.clone(), + reporter.clone(), + )), + tokio::spawn(run_notification_consumer( + settings.clone(), + QueueName::NotificationsFiatPurchase, + shutdown_rx.clone(), + reporter.clone(), + )), + tokio::spawn(run_notifications_failed_consumer( + settings.clone(), + database.clone(), + QueueName::NotificationsFailed, + shutdown_rx.clone(), + reporter.clone(), + )), + tokio::spawn(run_in_app_notifications_consumer(settings.clone(), database.clone(), shutdown_rx.clone(), reporter.clone())), + ]) + .await?; + + Ok(()) +} + +async fn run_notification_consumer( + settings: Arc, + queue: QueueName, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let name = queue.to_string(); + let stream_reader = StreamReader::new(reader_config(&settings.rabbitmq, name.clone()), &shutdown_rx) + .await? + .ok_or("shutdown during connect")?; + let pusher_client = PusherClient::new(settings.pusher.url.clone(), settings.pusher.ios.topic.clone()); + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, &name, shutdown_rx.clone()).await?; + let consumer = NotificationsConsumer::new(pusher_client, stream_producer); + + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config(&settings.consumer), shutdown_rx, reporter) + .await +} + +async fn run_notifications_failed_consumer( + settings: Arc, + database: Arc, + queue: QueueName, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let name = queue.to_string(); + let stream_reader = StreamReader::new(reader_config(&settings.rabbitmq, name.clone()), &shutdown_rx) + .await? + .ok_or("shutdown during connect")?; + let consumer = NotificationsFailedConsumer::new((*database).clone()); + + let consumer_config = consumer_config(&settings.consumer); + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config, shutdown_rx, reporter).await +} + +async fn run_in_app_notifications_consumer( + settings: Arc, + database: Arc, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::NotificationsInApp; + let name = queue.to_string(); + let stream_reader = StreamReader::new(reader_config(&settings.rabbitmq, name.clone()), &shutdown_rx) + .await? + .ok_or("shutdown during connect")?; + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, &name, shutdown_rx.clone()).await?; + let consumer = InAppNotificationsConsumer::new((*database).clone(), stream_producer); + + let consumer_config = consumer_config(&settings.consumer); + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config, shutdown_rx, reporter).await +} diff --git a/core/apps/daemon/src/consumers/notifications/notifications_consumer.rs b/core/apps/daemon/src/consumers/notifications/notifications_consumer.rs new file mode 100644 index 0000000000..f999a18218 --- /dev/null +++ b/core/apps/daemon/src/consumers/notifications/notifications_consumer.rs @@ -0,0 +1,56 @@ +use std::error::Error; + +use api_connector::PusherClient; +use async_trait::async_trait; +use gem_tracing::info_with_fields; +use streamer::{NotificationsFailedPayload, NotificationsPayload, StreamProducer, StreamProducerQueue, consumer::MessageConsumer}; + +pub struct NotificationsConsumer { + pub pusher: PusherClient, + pub stream_producer: StreamProducer, +} + +impl NotificationsConsumer { + pub fn new(pusher: PusherClient, stream_producer: StreamProducer) -> Self { + Self { pusher, stream_producer } + } +} + +#[async_trait] +impl MessageConsumer for NotificationsConsumer { + async fn should_process(&self, _payload: NotificationsPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: NotificationsPayload) -> Result> { + for notification in &payload.notifications { + info_with_fields!( + "send push notification", + device_id = notification.device_id.as_str(), + notification_type = notification.data.notification_type.as_ref(), + title = notification.title.as_str(), + message = notification.message.as_str() + ); + } + + let result = self.pusher.push_notifications(payload.notifications).await?; + let counts = result.response.counts as usize; + let success = &result.response.success; + let logs = &result.response.logs; + + info_with_fields!("gorush response", counts = counts, success = success.as_str(), logs = format!("{:?}", logs)); + + let failures = result.failures(); + + if !failures.is_empty() { + info_with_fields!( + "push failures", + count = failures.len(), + failures = format!("{:?}", failures.iter().map(|f| (&f.notification.device_id, &f.error.error)).collect::>()) + ); + self.stream_producer.publish_notifications_failed(NotificationsFailedPayload::new(failures)).await?; + } + + Ok(counts) + } +} diff --git a/core/apps/daemon/src/consumers/notifications/notifications_failed_consumer.rs b/core/apps/daemon/src/consumers/notifications/notifications_failed_consumer.rs new file mode 100644 index 0000000000..6b1afd436a --- /dev/null +++ b/core/apps/daemon/src/consumers/notifications/notifications_failed_consumer.rs @@ -0,0 +1,41 @@ +use std::error::Error; + +use async_trait::async_trait; +use storage::{Database, database::devices::DeviceFieldUpdate}; +use streamer::{NotificationsFailedPayload, consumer::MessageConsumer}; + +pub struct NotificationsFailedConsumer { + pub database: Database, +} + +impl NotificationsFailedConsumer { + pub fn new(database: Database) -> Self { + Self { database } + } +} + +#[async_trait] +impl MessageConsumer for NotificationsFailedConsumer { + async fn should_process(&self, _payload: NotificationsFailedPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: NotificationsFailedPayload) -> Result> { + let device_ids: Vec = payload + .failures + .iter() + .filter(|f| f.error.is_device_invalid()) + .map(|f| f.notification.device_id.clone()) + .collect(); + + if device_ids.is_empty() { + return Ok(0); + } + + Ok(self + .database + .client()? + .devices() + .update_device_fields(device_ids, vec![DeviceFieldUpdate::IsPushEnabled(false)])?) + } +} diff --git a/core/apps/daemon/src/consumers/rewards/mod.rs b/core/apps/daemon/src/consumers/rewards/mod.rs new file mode 100644 index 0000000000..48be680823 --- /dev/null +++ b/core/apps/daemon/src/consumers/rewards/mod.rs @@ -0,0 +1,107 @@ +pub mod rewards_consumer; +pub mod rewards_redemption_consumer; + +use std::collections::HashMap; +use std::error::Error; +use std::str::FromStr; +use std::sync::Arc; + +use gem_client::ReqwestClient; +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::JsonRpcClient; +use gem_rewards::{EvmClientProvider, TransferRedemptionService, WalletConfig}; +use primitives::rewards::RedemptionStatus; +use primitives::{ChainType, ConfigKey, EVMChain}; +use settings::Settings; +use settings_chain::ProviderFactory; +use storage::{ConfigCacher, Database}; +use streamer::{ConsumerStatusReporter, QueueName, RewardsNotificationPayload, RewardsRedemptionPayload, ShutdownReceiver, run_consumer}; + +use crate::consumers::{consumer_config, producer_for_queue, reader_for_queue}; + +pub async fn run_consumer_rewards(settings: Settings, shutdown_rx: ShutdownReceiver, reporter: Arc) -> Result<(), Box> { + let database = Database::new(&settings.postgres.url, settings.postgres.pool); + let settings = Arc::new(settings); + + futures::future::try_join_all(vec![ + tokio::spawn(run_rewards_events(settings.clone(), database.clone(), shutdown_rx.clone(), reporter.clone())), + tokio::spawn(run_rewards_redemptions(settings.clone(), database.clone(), shutdown_rx.clone(), reporter.clone())), + ]) + .await?; + + Ok(()) +} + +async fn run_rewards_events( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::RewardsEvents; + let (name, stream_reader) = reader_for_queue(&settings, &queue, &shutdown_rx).await?; + let stream_producer = producer_for_queue(&settings, &name, shutdown_rx.clone()).await?; + let consumer = rewards_consumer::RewardsConsumer::new(database, stream_producer); + let consumer_config = consumer_config(&settings.consumer); + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config, shutdown_rx, reporter).await +} + +async fn run_rewards_redemptions( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let config = ConfigCacher::new(database.clone()); + let retry_config = rewards_redemption_consumer::RedemptionRetryConfig { + max_retries: config.get_i64(ConfigKey::RedemptionRetryMaxRetries)? as u32, + delay: config.get_duration(ConfigKey::RedemptionRetryDelay)?, + errors: config.get_vec_string(ConfigKey::RedemptionRetryErrors)?, + }; + let queue = QueueName::RewardsRedemptions; + let (name, stream_reader) = reader_for_queue(&settings, &queue, &shutdown_rx).await?; + let stream_producer = producer_for_queue(&settings, &name, shutdown_rx.clone()).await?; + let wallets = parse_rewards_wallets(&settings)?; + let client_provider = create_evm_client_provider((*settings).clone()); + let redemption_service = Arc::new(TransferRedemptionService::new(wallets, client_provider)); + let consumer = rewards_redemption_consumer::RewardsRedemptionConsumer::new(database, redemption_service, retry_config, stream_producer); + let consumer_config = consumer_config(&settings.consumer); + run_consumer::, RedemptionStatus>( + &name, + stream_reader, + queue, + None, + consumer, + consumer_config, + shutdown_rx, + reporter, + ) + .await +} + +fn parse_rewards_wallets(settings: &Settings) -> Result, Box> { + let mut wallets = HashMap::new(); + + for (chain_type_name, wallet_config) in &settings.rewards.wallets { + let chain_type = ChainType::from_str(chain_type_name).map_err(|_| format!("Invalid chain type: {}", chain_type_name))?; + wallets.insert( + chain_type, + WalletConfig { + key: wallet_config.key.clone(), + address: wallet_config.address.clone(), + }, + ); + } + + Ok(wallets) +} + +fn create_evm_client_provider(settings: Settings) -> EvmClientProvider { + Arc::new(move |chain: EVMChain| { + let chain_config = ProviderFactory::get_chain_config(chain.to_chain(), &settings); + let reqwest_client = gem_client::builder().build().ok()?; + let client = ReqwestClient::new(chain_config.url.clone(), reqwest_client); + let rpc_client = JsonRpcClient::new(client); + Some(EthereumClient::new(rpc_client, chain)) + }) +} diff --git a/core/apps/daemon/src/consumers/rewards/rewards_consumer.rs b/core/apps/daemon/src/consumers/rewards/rewards_consumer.rs new file mode 100644 index 0000000000..1f2c2f8bfb --- /dev/null +++ b/core/apps/daemon/src/consumers/rewards/rewards_consumer.rs @@ -0,0 +1,70 @@ +use std::error::Error; + +use async_trait::async_trait; +use primitives::{NotificationRewardsMetadata, NotificationType, RewardEventType}; +use storage::{Database, RewardsRepository}; +use streamer::{InAppNotificationPayload, RewardsNotificationPayload, StreamProducer, StreamProducerQueue, consumer::MessageConsumer}; + +pub struct RewardsConsumer { + database: Database, + stream_producer: StreamProducer, +} + +#[async_trait] +impl MessageConsumer for RewardsConsumer { + async fn should_process(&self, _payload: RewardsNotificationPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: RewardsNotificationPayload) -> Result> { + let event = self.database.rewards()?.get_reward_event(payload.event_id)?; + let notifications = self.create_in_app_notification_payloads(&event)?; + let count = notifications.len(); + self.stream_producer.publish_in_app_notifications(notifications).await?; + Ok(count) + } +} + +impl RewardsConsumer { + pub fn new(database: Database, stream_producer: StreamProducer) -> Self { + Self { database, stream_producer } + } + + fn create_in_app_notification_payloads(&self, event: &primitives::RewardEvent) -> Result, Box> { + let metadata = NotificationRewardsMetadata { + username: Some(event.username.clone()), + points: (event.points > 0).then_some(event.points), + }; + let metadata_value = serde_json::to_value(metadata).ok(); + + match event.event { + RewardEventType::CreateUsername => { + let wallet_id = self.database.rewards()?.get_wallet_id_by_username(&event.username)?; + Ok(vec![InAppNotificationPayload::new(wallet_id, NotificationType::RewardsCreateUsername, metadata_value)]) + } + RewardEventType::InvitePending | RewardEventType::InviteNew => { + let wallet_id = self.database.rewards()?.get_wallet_id_by_username(&event.username)?; + Ok(vec![InAppNotificationPayload::new(wallet_id, NotificationType::RewardsInvite, metadata_value)]) + } + RewardEventType::Joined => { + let Some(referrer) = self.database.rewards()?.get_referrer_username(&event.username)? else { + return Ok(vec![]); + }; + let wallet_id = self.database.rewards()?.get_wallet_id_by_username(&referrer)?; + Ok(vec![InAppNotificationPayload::new(wallet_id, NotificationType::ReferralJoined, metadata_value)]) + } + RewardEventType::Enabled => { + let wallet_id = self.database.rewards()?.get_wallet_id_by_username(&event.username)?; + Ok(vec![InAppNotificationPayload::new(wallet_id, NotificationType::RewardsEnabled, metadata_value)]) + } + RewardEventType::Disabled => { + let wallet_id = self.database.rewards()?.get_wallet_id_by_username(&event.username)?; + Ok(vec![InAppNotificationPayload::new(wallet_id, NotificationType::RewardsCodeDisabled, metadata_value)]) + } + RewardEventType::Redeemed => { + let wallet_id = self.database.rewards()?.get_wallet_id_by_username(&event.username)?; + Ok(vec![InAppNotificationPayload::new(wallet_id, NotificationType::RewardsRedeemed, metadata_value)]) + } + } + } +} diff --git a/core/apps/daemon/src/consumers/rewards/rewards_redemption_consumer.rs b/core/apps/daemon/src/consumers/rewards/rewards_redemption_consumer.rs new file mode 100644 index 0000000000..f9437144b1 --- /dev/null +++ b/core/apps/daemon/src/consumers/rewards/rewards_redemption_consumer.rs @@ -0,0 +1,139 @@ +use async_trait::async_trait; +use gem_rewards::{RedemptionAsset, RedemptionRequest, RedemptionService}; +use gem_tracing::info_with_fields; +use primitives::rewards::RedemptionStatus as PrimitiveRedemptionStatus; +use primitives::{NotificationRewardsRedeemMetadata, NotificationType, TransactionId}; +use std::error::Error; +use std::sync::Arc; +use std::time::Duration; +use storage::sql_types::RedemptionStatus; +use storage::{Database, RedemptionUpdate, RewardsRedemptionsRepository, RewardsRepository}; +use streamer::consumer::MessageConsumer; +use streamer::{InAppNotificationPayload, QueueName, RewardsRedemptionPayload, StreamProducer, StreamProducerQueue}; + +pub struct RedemptionRetryConfig { + pub max_retries: u32, + pub delay: Duration, + pub errors: Vec, +} + +pub struct RewardsRedemptionConsumer { + database: Database, + redemption_service: Arc, + retry_config: RedemptionRetryConfig, + stream_producer: StreamProducer, +} + +impl RewardsRedemptionConsumer { + pub fn new(database: Database, redemption_service: Arc, retry_config: RedemptionRetryConfig, stream_producer: StreamProducer) -> Self { + Self { + database, + redemption_service, + retry_config, + stream_producer, + } + } + + async fn process_with_retry(&self, request: RedemptionRequest) -> Result> { + let mut attempt = 0; + loop { + match self.redemption_service.process_redemption(request.clone()).await { + Ok(result) => return Ok(result.transaction_id), + Err(e) => { + let is_retryable = self.retry_config.errors.iter().any(|p| e.to_string().contains(p)); + if attempt < self.retry_config.max_retries && is_retryable { + attempt += 1; + tokio::time::sleep(self.retry_config.delay).await; + continue; + } + return Err(e); + } + } + } + } +} + +#[async_trait] +impl MessageConsumer for RewardsRedemptionConsumer { + async fn should_process(&self, payload: RewardsRedemptionPayload) -> Result> { + let redemption = self.database.rewards_redemptions()?.get_redemption(payload.redemption_id)?; + Ok(*redemption.status == PrimitiveRedemptionStatus::Pending) + } + + async fn process(&self, payload: RewardsRedemptionPayload) -> Result> { + let redemption = self.database.rewards_redemptions()?.get_redemption(payload.redemption_id)?; + + self.database + .rewards_redemptions()? + .update_redemption(payload.redemption_id, vec![RedemptionUpdate::Status(RedemptionStatus::Processing)])?; + + let recipient_address = self.database.rewards()?.get_address_by_username(&redemption.username)?; + let option = self.database.rewards_redemptions()?.get_redemption_option(&redemption.option_id)?; + + let asset_id = option.asset.as_ref().map(|a| a.id.clone()); + let asset_id_str = asset_id.as_ref().map(|a| a.to_string()); + let value = option.value.clone(); + let points = option.points; + + let asset = option.asset.map(|asset| RedemptionAsset { + asset, + value: option.value.clone(), + }); + + let request = RedemptionRequest { recipient_address, asset }; + + match self.process_with_retry(request).await { + Ok(transaction_id) => { + let updates = vec![ + RedemptionUpdate::TransactionId(transaction_id.clone()), + RedemptionUpdate::Status(RedemptionStatus::Completed), + ]; + self.database.rewards_redemptions()?.update_redemption(payload.redemption_id, updates)?; + + if let Some(id) = &asset_id { + let pending_tx_id = TransactionId::new(id.chain, transaction_id.clone()); + if let Err(e) = self.stream_producer.publish(QueueName::StorePendingTransactions, &pending_tx_id).await { + info_with_fields!( + "failed to publish redemption transaction to pending", + transaction_id = pending_tx_id.to_string(), + error = e.to_string() + ); + } else { + info_with_fields!("published redemption transaction to pending", transaction_id = pending_tx_id.to_string()); + } + + let metadata = NotificationRewardsRedeemMetadata { + transaction_id: transaction_id.clone(), + points, + value: value.clone(), + }; + let notification = + InAppNotificationPayload::new_with_asset(redemption.wallet_id, id.clone(), NotificationType::RewardsRedeemed, serde_json::to_value(metadata).ok()); + self.stream_producer.publish_in_app_notifications(vec![notification]).await?; + } + + info_with_fields!( + "redemption completed", + id = payload.redemption_id, + asset = asset_id_str.as_deref().unwrap_or("none"), + value = value, + tx_id = transaction_id + ); + Ok(PrimitiveRedemptionStatus::Completed) + } + Err(e) => { + let error_msg = e.to_string(); + let updates = vec![RedemptionUpdate::Status(RedemptionStatus::Failed), RedemptionUpdate::Error(error_msg.clone())]; + self.database.rewards_redemptions()?.update_redemption(payload.redemption_id, updates)?; + info_with_fields!( + "redemption failed", + id = payload.redemption_id, + asset = asset_id_str.as_deref().unwrap_or("none"), + value = value, + error = error_msg + ); + Ok(PrimitiveRedemptionStatus::Failed) + } + } + } +} diff --git a/core/apps/daemon/src/consumers/runner.rs b/core/apps/daemon/src/consumers/runner.rs new file mode 100644 index 0000000000..2a0bb565f1 --- /dev/null +++ b/core/apps/daemon/src/consumers/runner.rs @@ -0,0 +1,109 @@ +use std::error::Error; +use std::sync::Arc; + +use cacher::CacherClient; +use gem_tracing::{error_with_fields, info_with_fields}; +use primitives::Chain; +use settings::Settings; +use storage::Database; +use streamer::{ConsumerConfig, ConsumerStatusReporter, ShutdownReceiver, StreamConnection, StreamProducer, StreamReader}; + +use crate::consumers::{consumer_config, reader_config}; + +#[derive(Clone)] +pub struct ChainConsumerRunner { + pub settings: Settings, + pub database: Database, + pub connection: StreamConnection, + pub cacher: CacherClient, + pub config: ConsumerConfig, + pub shutdown_rx: ShutdownReceiver, + pub reporter: Arc, + queue: streamer::QueueName, +} + +impl ChainConsumerRunner { + pub async fn new( + settings: Settings, + database: Database, + queue: streamer::QueueName, + shutdown_rx: ShutdownReceiver, + reporter: Arc, + ) -> Result> { + let connection = StreamConnection::new(&settings.rabbitmq.url, queue.to_string()).await?; + let cacher = CacherClient::new(&settings.redis.url).await; + let config = consumer_config(&settings.consumer); + Ok(Self { + settings, + database, + connection, + cacher, + config, + shutdown_rx, + reporter, + queue, + }) + } + + pub async fn stream_reader(&self) -> Result> { + let config = reader_config(&self.settings.rabbitmq, self.connection.name().to_string()); + StreamReader::from_connection(&self.connection, config).await + } + + pub async fn stream_producer(&self) -> Result> { + StreamProducer::from_connection(&self.connection, self.shutdown_rx.clone()).await + } + + pub async fn run(self, f: F) -> Result<(), Box> + where + F: Fn(Self, Chain) -> Fut + Clone + Send + 'static, + Fut: std::future::Future>> + Send + 'static, + { + self.run_for_chains(Chain::all(), f).await + } + + pub async fn run_for_chains(self, chains: Vec, f: F) -> Result<(), Box> + where + F: Fn(Self, Chain) -> Fut + Clone + Send + 'static, + Fut: std::future::Future>> + Send + 'static, + { + info_with_fields!("running consumer", consumer = self.queue.to_string(), chains = chains.len()); + let restart_delay = self.config.timeout_on_error; + let retries = self.config.retries; + let queue = self.queue.to_string(); + + let mut set = tokio::task::JoinSet::new(); + for chain in chains { + let runner = self.clone(); + let f = f.clone(); + let queue = queue.clone(); + set.spawn(async move { + let mut failures: u32 = 0; + loop { + match f(runner.clone(), chain).await { + Ok(()) => return Ok(()), + Err(err) => { + failures += 1; + error_with_fields!("consumer chain error", &*err, consumer = queue.as_str(), chain = chain.as_ref(), attempt = failures); + if failures >= retries { + return Err(err); + } + if crate::shutdown::sleep_or_shutdown(restart_delay, &runner.shutdown_rx).await { + return Ok(()); + } + } + } + } + }); + } + + while let Some(joined) = set.join_next().await { + match joined { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(err), + Err(join_err) => return Err(Box::new(join_err)), + } + } + Ok(()) + } +} diff --git a/core/apps/daemon/src/consumers/store/mod.rs b/core/apps/daemon/src/consumers/store/mod.rs new file mode 100644 index 0000000000..5f41bbe70c --- /dev/null +++ b/core/apps/daemon/src/consumers/store/mod.rs @@ -0,0 +1,118 @@ +pub mod store_pending_transactions_consumer; +pub mod store_prices_consumer; +pub mod store_transactions_consumer; +pub mod store_transactions_consumer_config; +pub mod wallet_stream_consumer; + +pub use store_transactions_consumer::StoreTransactionsConsumer; +pub use store_transactions_consumer_config::StoreTransactionsConsumerConfig; + +use std::error::Error; +use std::sync::Arc; + +use crate::client::SwapVaultAddressClient; +use cacher::CacherClient; +use pricer::PriceClient; +use primitives::{ConfigKey, TransactionId}; +use settings::Settings; +use storage::{ConfigCacher, Database}; +use streamer::{ConsumerStatusReporter, PricesPayload, QueueName, ShutdownReceiver, TransactionsPayload, WalletStreamPayload, run_consumer}; + +use crate::consumers::{consumer_config, producer_for_queue, reader_for_queue}; +use crate::pusher::Pusher; + +use store_pending_transactions_consumer::StorePendingTransactionsConsumer; +use store_prices_consumer::StorePricesConsumer; +use wallet_stream_consumer::WalletStreamConsumer; + +pub async fn run_consumer_store(settings: Settings, shutdown_rx: ShutdownReceiver, reporter: Arc) -> Result<(), Box> { + let database = Database::new(&settings.postgres.url, settings.postgres.pool); + let settings = Arc::new(settings); + + tokio::try_join!( + run_store_transactions(settings.clone(), database.clone(), shutdown_rx.clone(), reporter.clone()), + run_store_prices(settings.clone(), database.clone(), shutdown_rx.clone(), reporter.clone()), + run_store_pending_transactions(settings.clone(), shutdown_rx.clone(), reporter.clone()), + run_wallet_stream(settings.clone(), database.clone(), shutdown_rx.clone(), reporter.clone()), + )?; + + Ok(()) +} + +async fn run_store_transactions( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::StoreTransactions; + let (name, stream_reader) = reader_for_queue(&settings, &queue, &shutdown_rx).await?; + let stream_producer = producer_for_queue(&settings, &name, shutdown_rx.clone()).await?; + let cacher = CacherClient::new(&settings.redis.url).await; + let config_cacher = ConfigCacher::new(database.clone()); + let config = StoreTransactionsConsumerConfig { + swap_outdated_timeout: config_cacher.get_duration(ConfigKey::TransactionSwapOutdatedTimeout)?, + outdated_block_count: config_cacher.get_i64(ConfigKey::TransactionsOutdatedBlockCount)? as u64, + outdated_min_timeout: config_cacher.get_duration(ConfigKey::TransactionsOutdatedMinTimeout)?, + min_amount_usd: config_cacher.get_f64(ConfigKey::TransactionsMinAmountUsd)?, + primary_price_max_age: config_cacher.get_duration(ConfigKey::PricePrimaryMaxAge)?, + }; + let consumer = StoreTransactionsConsumer { + database: database.clone(), + stream_producer, + pusher: Pusher::new(database), + config, + vault_client: SwapVaultAddressClient::new(cacher), + }; + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config(&settings.consumer), shutdown_rx, reporter) + .await +} + +async fn run_store_prices( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::StorePrices; + let (name, stream_reader) = reader_for_queue(&settings, &queue, &shutdown_rx).await?; + let cacher_client = CacherClient::new(&settings.redis.url).await; + let price_client = PriceClient::new(database.clone(), cacher_client); + let config = ConfigCacher::new(database.clone()); + let ttl_seconds = config.get_duration(ConfigKey::PriceOutdated)?.as_secs() as i64; + let consumer = StorePricesConsumer::new( + database, + price_client, + store_prices_consumer::StorePricesConsumerConfig { + ttl_seconds, + primary_price_max_age: config.get_duration(ConfigKey::PricePrimaryMaxAge)?, + }, + ); + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config(&settings.consumer), shutdown_rx, reporter).await +} + +async fn run_wallet_stream( + settings: Arc, + database: Database, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::WalletStreamEvents; + let (name, stream_reader) = reader_for_queue(&settings, &queue, &shutdown_rx).await?; + let cacher_client = CacherClient::new(&settings.redis.url).await; + let consumer = WalletStreamConsumer { database, cacher_client }; + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config(&settings.consumer), shutdown_rx, reporter).await +} + +async fn run_store_pending_transactions( + settings: Arc, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> { + let queue = QueueName::StorePendingTransactions; + let (name, stream_reader) = reader_for_queue(&settings, &queue, &shutdown_rx).await?; + let cacher = CacherClient::new(&settings.redis.url).await; + let consumer = StorePendingTransactionsConsumer::new(cacher); + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config(&settings.consumer), shutdown_rx, reporter) + .await +} diff --git a/core/apps/daemon/src/consumers/store/store_pending_transactions_consumer.rs b/core/apps/daemon/src/consumers/store/store_pending_transactions_consumer.rs new file mode 100644 index 0000000000..9c7b23c697 --- /dev/null +++ b/core/apps/daemon/src/consumers/store/store_pending_transactions_consumer.rs @@ -0,0 +1,37 @@ +use std::error::Error; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use cacher::{CacheKey, CacherClient}; +use gem_tracing::info_with_fields; +use primitives::{TransactionId, chain_transaction_timeout}; +use streamer::consumer::MessageConsumer; + +pub struct StorePendingTransactionsConsumer { + cacher: CacherClient, +} + +impl StorePendingTransactionsConsumer { + pub fn new(cacher: CacherClient) -> Self { + Self { cacher } + } +} + +#[async_trait] +impl MessageConsumer for StorePendingTransactionsConsumer { + async fn should_process(&self, _payload: TransactionId) -> Result> { + Ok(true) + } + + async fn process(&self, payload: TransactionId) -> Result> { + let transaction_id = payload.to_string(); + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs() + .saturating_add(u64::from(chain_transaction_timeout(payload.chain)) / 1000) as f64; + let key = CacheKey::PendingTransactions(payload.chain.as_ref()); + self.cacher.add_to_sorted_set_cached(key, &[(payload.hash, expires_at)]).await?; + info_with_fields!("stored pending transaction", transaction_id = transaction_id.as_str()); + Ok(1) + } +} diff --git a/core/apps/daemon/src/consumers/store/store_prices_consumer.rs b/core/apps/daemon/src/consumers/store/store_prices_consumer.rs new file mode 100644 index 0000000000..9e2c177516 --- /dev/null +++ b/core/apps/daemon/src/consumers/store/store_prices_consumer.rs @@ -0,0 +1,57 @@ +use async_trait::async_trait; +use pricer::PriceClient; +use std::collections::HashMap; +use std::error::Error; +use std::time::Duration; +use storage::models::PriceRow; +use storage::{AssetsRepository, Database, PricesRepository}; +use streamer::{PricesPayload, consumer::MessageConsumer}; + +#[derive(Clone, Copy)] +pub struct StorePricesConsumerConfig { + pub ttl_seconds: i64, + pub primary_price_max_age: Duration, +} + +pub struct StorePricesConsumer { + pub database: Database, + pub price_client: PriceClient, + pub config: StorePricesConsumerConfig, +} + +impl StorePricesConsumer { + pub fn new(database: Database, price_client: PriceClient, config: StorePricesConsumerConfig) -> Self { + Self { database, price_client, config } + } +} + +#[async_trait] +impl MessageConsumer for StorePricesConsumer { + async fn should_process(&self, _payload: PricesPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: PricesPayload) -> Result> { + let prices: Vec = payload.prices.into_iter().map(PriceRow::from_price_data).collect(); + let asset_ids = self.database.prices()?.set_prices(prices)?; + if asset_ids.is_empty() { + return Ok(0); + } + + let primary_prices = self.database.prices()?.get_primary_prices(&asset_ids, self.config.primary_price_max_age)?; + if primary_prices.is_empty() { + return Ok(0); + } + + let count = primary_prices.len(); + let primary_asset_ids: Vec<_> = primary_prices.iter().map(|(id, _)| id.clone()).collect(); + let assets_by_id: HashMap = self.database.assets()?.get_assets_rows(primary_asset_ids)?.into_iter().map(|a| (a.id.clone(), a)).collect(); + let cache_entries = primary_prices + .into_iter() + .filter_map(|(asset_id, price)| assets_by_id.get(&asset_id.to_string()).map(|asset| price.as_price_asset_info(asset))) + .collect(); + self.price_client.set_cache_prices(cache_entries, self.config.ttl_seconds).await?; + + Ok(count) + } +} diff --git a/core/apps/daemon/src/consumers/store/store_transactions_consumer.rs b/core/apps/daemon/src/consumers/store/store_transactions_consumer.rs new file mode 100644 index 0000000000..db424e8855 --- /dev/null +++ b/core/apps/daemon/src/consumers/store/store_transactions_consumer.rs @@ -0,0 +1,444 @@ +use std::collections::HashSet; +use std::{collections::HashMap, error::Error}; + +use async_trait::async_trait; +use primitives::{AssetAddress, DeviceSubscription, NFTAssetId, Transaction, TransactionId, TransactionState, TransactionType}; +use storage::{AssetsAddressesRepository, AssetsRepository, Database, NftAssetFilter, NftRepository, TransactionsRepository, WalletsRepository}; +use streamer::{AssetId, NotificationsPayload, StreamProducer, StreamProducerQueue, TransactionsPayload, WalletStreamEvent, WalletStreamPayload, consumer::MessageConsumer}; +use swapper::cross_chain::{self, DepositAddressMap, SendAddressMap}; + +use crate::client::SwapVaultAddressClient; +use crate::consumers::store::StoreTransactionsConsumerConfig; +use crate::pusher::Pusher; + +const TRANSACTION_BATCH_SIZE: usize = 100; + +const CROSS_CHAIN_SOURCE_TYPES: [TransactionType; 3] = [TransactionType::Transfer, TransactionType::SmartContractCall, TransactionType::Swap]; + +pub struct StoreTransactionsConsumer { + pub database: Database, + pub stream_producer: StreamProducer, + pub pusher: Pusher, + pub config: StoreTransactionsConsumerConfig, + pub vault_client: SwapVaultAddressClient, +} + +struct ProcessingResult { + transactions: Vec, + assets_addresses: Vec, + notifications: Vec, + wallet_events: Vec, +} + +#[async_trait] +impl MessageConsumer for StoreTransactionsConsumer { + async fn should_process(&self, _payload: TransactionsPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: TransactionsPayload) -> Result> { + let chain = payload.chain; + let is_notify_devices = payload.should_notify_devices(); + let deposit_addresses = self.vault_client.get_deposit_address_map().await?; + let send_addresses = self.vault_client.get_send_address_map().await?; + let transactions = Self::transactions_for_storage(payload.transactions, &deposit_addresses, &send_addresses); + + let min_amount = self.config.min_amount_usd; + + let addresses: Vec<_> = transactions + .iter() + .flat_map(|transaction| transaction.addresses()) + .collect::>() + .into_iter() + .collect(); + let subscriptions = self.database.wallets()?.get_subscriptions_by_chain_addresses(chain, addresses)?; + let notification_subscriptions = Self::unique_subscriptions_per_device(subscriptions.clone()); + + let subscription_addresses: HashSet<_> = subscriptions.iter().map(|s| &s.address).collect(); + + let asset_ids: Vec = transactions + .iter() + .filter(|x| x.addresses().iter().any(|addr| subscription_addresses.contains(addr))) + .flat_map(|x| x.asset_ids()) + .collect::>() + .into_iter() + .collect(); + + let (existing_assets, missing_assets) = self.get_existing_and_missing_assets(asset_ids).await?; + let existing_assets_map: HashMap = existing_assets.into_iter().map(|asset| (asset.asset.asset.id.clone(), asset)).collect(); + + let _ = self.stream_producer.publish_fetch_assets(missing_assets).await; + + let nft_asset_ids: Vec = transactions + .iter() + .filter(|x| x.addresses().iter().any(|addr| subscription_addresses.contains(addr))) + .filter_map(|x| x.nft_asset_id()) + .collect::>() + .into_iter() + .collect(); + + let missing_nft_assets = self.get_missing_nft_assets(nft_asset_ids).await?; + let _ = self.stream_producer.publish_fetch_nft_assets(missing_nft_assets).await; + + let mut transactions_map: HashMap = HashMap::new(); + let mut assets_addresses = HashSet::new(); + let mut notifications: Vec = Vec::new(); + let mut wallet_events_map: HashMap, HashSet)> = HashMap::new(); + + for subscription in &subscriptions { + for transaction in &transactions { + if !transaction.addresses().contains(&subscription.address) { + continue; + } + + let transaction_asset_ids = transaction.asset_ids(); + + if transaction_asset_ids.iter().any(|id| !existing_assets_map.contains_key(id)) { + continue; + } + + let Some(asset_price) = existing_assets_map.get(&transaction.asset_id) else { + continue; + }; + + if self + .config + .is_transaction_insufficient_amount(transaction, &asset_price.asset.asset, asset_price.price, min_amount) + { + continue; + } + + if Self::should_store_asset_addresses(transaction) { + assets_addresses.extend( + transaction + .assets_addresses_with_fee() + .into_iter() + .filter(|address| address.address == subscription.address) + .filter(|address| existing_assets_map.contains_key(&address.asset_id)), + ); + } + + transactions_map.entry(transaction.id.clone()).or_insert_with(|| transaction.clone()); + + let (txn_ids, asset_ids) = wallet_events_map.entry(subscription.wallet_row_id).or_default(); + txn_ids.insert(transaction.id.clone()); + asset_ids.extend(transaction_asset_ids.iter().cloned()); + } + } + + for subscription in ¬ification_subscriptions { + for transaction in transactions_map.values() { + if !transaction.addresses().contains(&subscription.address) { + continue; + } + + if !self.config.should_notify_transaction(transaction, is_notify_devices, &send_addresses) { + continue; + } + + let transaction_asset_ids = transaction.asset_ids(); + let assets: Vec = transaction_asset_ids + .iter() + .filter_map(|id| existing_assets_map.get(id)) + .map(|asset_price| asset_price.asset.asset.clone()) + .collect(); + + if self.database.transactions()?.get_transaction_exists(&transaction.id)? { + continue; + } + + if let Ok(push_notifications) = self.pusher.get_messages(subscription, transaction.clone(), assets).await { + notifications.push(NotificationsPayload::new(push_notifications)); + } + } + } + + let wallet_events = wallet_events_map + .into_iter() + .map(|(wallet_id, (transaction_ids, asset_ids))| WalletStreamPayload { + wallet_id, + event: WalletStreamEvent::Transactions { + transaction_ids: transaction_ids.into_iter().collect(), + asset_ids: asset_ids.into_iter().collect(), + }, + }) + .collect(); + + let transactions: Vec<_> = transactions_map.into_values().collect(); + let result = ProcessingResult { + transactions, + assets_addresses: assets_addresses.into_iter().collect(), + notifications, + wallet_events, + }; + self.publish_results(result).await + } +} + +impl StoreTransactionsConsumer { + fn should_store_asset_addresses(transaction: &Transaction) -> bool { + match transaction.state { + TransactionState::Confirmed | TransactionState::InTransit => true, + TransactionState::Pending | TransactionState::Failed | TransactionState::Reverted => false, + } + } + + fn unique_subscriptions_per_device(subscriptions: Vec) -> Vec { + subscriptions + .into_iter() + .fold(HashMap::<(String, String), DeviceSubscription>::new(), |mut best, sub| { + let key = (sub.device.id.clone(), sub.address.clone()); + best.entry(key) + .and_modify(|existing| { + if sub.wallet_id.wallet_type().notification_priority() < existing.wallet_id.wallet_type().notification_priority() { + *existing = sub.clone(); + } + }) + .or_insert(sub); + best + }) + .into_values() + .collect() + } + + fn transactions_for_storage(transactions: Vec, deposit_addresses: &DepositAddressMap, send_addresses: &SendAddressMap) -> Vec { + transactions + .into_iter() + .filter_map(|mut transaction| { + if cross_chain::is_from_vault_address(&transaction, send_addresses) { + return None; + } + + if Self::should_mark_in_transit(&transaction, deposit_addresses) { + transaction.state = TransactionState::InTransit; + } + + Some(transaction) + }) + .collect() + } + + fn should_mark_in_transit(transaction: &Transaction, deposit_addresses: &DepositAddressMap) -> bool { + transaction.state == TransactionState::Confirmed + && CROSS_CHAIN_SOURCE_TYPES.contains(&transaction.transaction_type) + && !(transaction.transaction_type == TransactionType::Swap && transaction.metadata.is_some()) + && cross_chain::is_cross_chain_swap(transaction, deposit_addresses) + } + + async fn publish_results(&self, result: ProcessingResult) -> Result> { + let transactions_count = self.store_transactions(result.transactions).await?; + self.database.assets_addresses()?.add_assets_addresses(result.assets_addresses)?; + let _ = self.stream_producer.publish_notifications_transactions(result.notifications).await; + let _ = self.stream_producer.publish_wallet_stream_events(result.wallet_events).await; + + Ok(transactions_count) + } + + async fn get_existing_and_missing_assets(&self, assets_ids: Vec) -> Result<(Vec, Vec), Box> { + let assets_with_prices = self.database.assets()?.get_assets_with_prices(assets_ids.clone(), self.config.primary_price_max_age)?; + + let missing_assets_ids = assets_ids + .into_iter() + .filter(|asset_id| !assets_with_prices.iter().any(|a| &a.asset.asset.id == asset_id)) + .collect::>(); + + Ok((assets_with_prices, missing_assets_ids)) + } + + async fn get_missing_nft_assets(&self, nft_asset_ids: Vec) -> Result, Box> { + if nft_asset_ids.is_empty() { + return Ok(Vec::new()); + } + let identifiers: Vec = nft_asset_ids.iter().map(|id| id.to_string()).collect(); + let existing = self.database.nft()?.get_nft_assets_by_filter(vec![NftAssetFilter::Identifiers(identifiers)])?; + let existing_ids: HashSet = existing.into_iter().map(|row| row.identifier.0).collect(); + Ok(nft_asset_ids.into_iter().filter(|id| !existing_ids.contains(id)).collect()) + } + + async fn store_transactions(&self, transactions: Vec) -> Result> { + if transactions.is_empty() { + return Ok(0); + } + + for chunk in transactions.chunks(TRANSACTION_BATCH_SIZE) { + self.database.transactions()?.add_transactions(chunk.to_vec())?; + } + + Ok(transactions.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{AssetId, Chain, Device, SwapProvider, TransactionSwapMetadata, WalletId}; + + #[test] + fn test_transactions_for_storage() { + let thorchain_vault = "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146".to_string(); + let near_vault = "TMoD2uJiUAvB2RhLGm1BmzCVVzi5VLFDVt".to_string(); + let deposit_addresses = DepositAddressMap::from([(thorchain_vault.clone(), SwapProvider::Thorchain), (near_vault.clone(), SwapProvider::NearIntents)]); + let send_addresses = SendAddressMap::from([(thorchain_vault.clone(), SwapProvider::Thorchain), (near_vault.clone(), SwapProvider::NearIntents)]); + + let cross_chain = Transaction { + to: thorchain_vault.clone(), + memo: Some("=:BTC:bc1qaddress:0/1/0".to_string()), + ..Transaction::mock() + }; + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![cross_chain], &deposit_addresses, &SendAddressMap::new())[0].state, + TransactionState::InTransit + ); + + let vault_no_memo = Transaction { + to: "bc1qvault".to_string(), + ..Transaction::mock() + }; + let deposit_addresses_bc = DepositAddressMap::from([("bc1qvault".to_string(), SwapProvider::Thorchain)]); + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![vault_no_memo], &deposit_addresses_bc, &SendAddressMap::new())[0].state, + TransactionState::Confirmed + ); + + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![Transaction::mock()], &DepositAddressMap::new(), &SendAddressMap::new())[0].state, + TransactionState::Confirmed + ); + + let swap_type = Transaction { + transaction_type: TransactionType::Swap, + memo: Some("=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0".to_string()), + ..Transaction::mock() + }; + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![swap_type], &DepositAddressMap::new(), &SendAddressMap::new())[0].state, + TransactionState::Confirmed + ); + + let cross_chain_swap_type = Transaction { + transaction_type: TransactionType::Swap, + to: near_vault.clone(), + ..Transaction::mock() + }; + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![cross_chain_swap_type], &deposit_addresses, &SendAddressMap::new())[0].state, + TransactionState::InTransit + ); + + let confirmed_cross_chain_swap_update = Transaction { + transaction_type: TransactionType::Swap, + to: near_vault.clone(), + metadata: Some( + serde_json::to_value(TransactionSwapMetadata { + from_asset: AssetId::from_chain(Chain::Solana), + from_value: "5000000".to_string(), + to_asset: AssetId::from_chain(Chain::Ton), + to_value: "2508437099".to_string(), + provider: Some(SwapProvider::NearIntents.as_ref().to_string()), + }) + .unwrap(), + ), + ..Transaction::mock() + }; + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![confirmed_cross_chain_swap_update], &deposit_addresses, &SendAddressMap::new())[0].state, + TransactionState::Confirmed + ); + + let token_approval = Transaction { + transaction_type: TransactionType::TokenApproval, + to: "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2".to_string(), + ..Transaction::mock() + }; + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![token_approval], &DepositAddressMap::new(), &SendAddressMap::new())[0].state, + TransactionState::Confirmed + ); + + let pending = Transaction { + state: TransactionState::Pending, + memo: Some("=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0".to_string()), + ..Transaction::mock() + }; + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![pending], &DepositAddressMap::new(), &SendAddressMap::new())[0].state, + TransactionState::Pending + ); + + let near_intents = Transaction { + to: near_vault.clone(), + ..Transaction::mock() + }; + assert_eq!( + StoreTransactionsConsumer::transactions_for_storage(vec![near_intents], &deposit_addresses, &SendAddressMap::new())[0].state, + TransactionState::InTransit + ); + + let outbound = Transaction { + from: thorchain_vault.clone(), + ..Transaction::mock() + }; + let regular = Transaction::mock(); + + let transactions = StoreTransactionsConsumer::transactions_for_storage(vec![outbound, regular.clone()], &DepositAddressMap::new(), &send_addresses); + + assert_eq!(transactions, vec![regular]); + } + + #[test] + fn test_should_store_asset_addresses() { + assert!(StoreTransactionsConsumer::should_store_asset_addresses(&Transaction::mock())); + assert!(StoreTransactionsConsumer::should_store_asset_addresses(&Transaction { + state: TransactionState::InTransit, + ..Transaction::mock() + })); + assert!(!StoreTransactionsConsumer::should_store_asset_addresses(&Transaction { + state: TransactionState::Pending, + ..Transaction::mock() + })); + assert!(!StoreTransactionsConsumer::should_store_asset_addresses(&Transaction { + state: TransactionState::Failed, + ..Transaction::mock() + })); + assert!(!StoreTransactionsConsumer::should_store_asset_addresses(&Transaction { + state: TransactionState::Reverted, + ..Transaction::mock() + })); + } + + #[test] + fn test_unique_subscriptions_per_device() { + let multicoin = DeviceSubscription::mock(); + let single = DeviceSubscription { + wallet_id: WalletId::Single(Chain::Ethereum, "0xABC".to_string()), + ..DeviceSubscription::mock() + }; + let view = DeviceSubscription { + wallet_id: WalletId::View(Chain::Ethereum, "0xABC".to_string()), + ..DeviceSubscription::mock() + }; + + let result = StoreTransactionsConsumer::unique_subscriptions_per_device(vec![view.clone(), single.clone(), multicoin.clone()]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].wallet_id, multicoin.wallet_id); + + let result = StoreTransactionsConsumer::unique_subscriptions_per_device(vec![view.clone(), single.clone()]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].wallet_id, single.wallet_id); + + let result = StoreTransactionsConsumer::unique_subscriptions_per_device(vec![view.clone()]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].wallet_id, view.wallet_id); + + let other_device = DeviceSubscription { + device: Device { + id: "device-2".to_string(), + ..Device::mock() + }, + wallet_id: WalletId::View(Chain::Ethereum, "0xABC".to_string()), + ..DeviceSubscription::mock() + }; + let result = StoreTransactionsConsumer::unique_subscriptions_per_device(vec![multicoin.clone(), other_device.clone()]); + assert_eq!(result.len(), 2); + } +} diff --git a/core/apps/daemon/src/consumers/store/store_transactions_consumer_config.rs b/core/apps/daemon/src/consumers/store/store_transactions_consumer_config.rs new file mode 100644 index 0000000000..e38cd42bf7 --- /dev/null +++ b/core/apps/daemon/src/consumers/store/store_transactions_consumer_config.rs @@ -0,0 +1,169 @@ +use std::time::Duration; + +use chrono::NaiveDateTime; +use number_formatter::BigNumberFormatter; +use primitives::{Asset, Chain, Price, Transaction, TransactionState, TransactionType}; +use swapper::cross_chain::{self, SendAddressMap}; + +pub struct StoreTransactionsConsumerConfig { + pub swap_outdated_timeout: Duration, + pub outdated_block_count: u64, + pub outdated_min_timeout: Duration, + pub min_amount_usd: f64, + pub primary_price_max_age: Duration, +} + +impl StoreTransactionsConsumerConfig { + pub fn is_transaction_outdated(&self, transaction_created_at: NaiveDateTime, chain: Chain, transaction_type: TransactionType) -> bool { + let elapsed = (chrono::Utc::now().naive_utc() - transaction_created_at).to_std().unwrap_or_default(); + elapsed > self.outdated_timeout(chain, transaction_type) + } + + fn outdated_timeout(&self, chain: Chain, transaction_type: TransactionType) -> Duration { + if transaction_type == TransactionType::Swap { + return self.swap_outdated_timeout; + } + let block_time_secs = chain.block_time() as u64 / 1000; + Duration::from_secs(block_time_secs * self.outdated_block_count).max(self.outdated_min_timeout) + } + + pub fn should_notify_transaction(&self, transaction: &Transaction, is_notify_devices: bool, send_addresses: &SendAddressMap) -> bool { + is_notify_devices + && transaction.state != TransactionState::InTransit + && !cross_chain::is_from_vault_address(transaction, send_addresses) + && !self.is_transaction_outdated(transaction.created_at.naive_utc(), transaction.asset_id.chain, transaction.transaction_type.clone()) + } + + pub fn is_transaction_insufficient_amount(&self, transaction: &Transaction, asset: &Asset, price: Option, min_amount: f64) -> bool { + if transaction.transaction_type == TransactionType::Transfer + && let Ok(amount) = BigNumberFormatter::value_as_f64(&transaction.value, asset.decimals as u32) + && let Some(price) = price + { + return amount * price.price <= min_amount; + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Chain, TransactionType}; + + impl StoreTransactionsConsumerConfig { + fn mock() -> Self { + Self { + swap_outdated_timeout: Duration::from_secs(7_200), + outdated_block_count: 12, + outdated_min_timeout: Duration::from_secs(900), + min_amount_usd: 0.01, + primary_price_max_age: Duration::from_secs(24 * 60 * 60), + } + } + } + + #[test] + fn test_is_transaction_outdated_positive() { + let config = StoreTransactionsConsumerConfig::mock(); + let timeout = config.outdated_timeout(Chain::Bitcoin, TransactionType::Transfer); + let created_at = chrono::Utc::now() - chrono::Duration::from_std(timeout).unwrap() - chrono::Duration::seconds(1); + assert!(config.is_transaction_outdated(created_at.naive_utc(), Chain::Bitcoin, TransactionType::Transfer)); + } + + #[test] + fn test_is_transaction_outdated_negative() { + let config = StoreTransactionsConsumerConfig::mock(); + let timeout = config.outdated_timeout(Chain::Bitcoin, TransactionType::Transfer); + let created_at = chrono::Utc::now() - chrono::Duration::from_std(timeout).unwrap() + chrono::Duration::seconds(1); + assert!(!config.is_transaction_outdated(created_at.naive_utc(), Chain::Bitcoin, TransactionType::Transfer)); + } + + #[test] + fn test_is_swap_outdated_positive() { + let config = StoreTransactionsConsumerConfig::mock(); + let timeout = config.outdated_timeout(Chain::Ethereum, TransactionType::Swap); + let created_at = chrono::Utc::now() - chrono::Duration::from_std(timeout).unwrap() - chrono::Duration::seconds(1); + assert!(config.is_transaction_outdated(created_at.naive_utc(), Chain::Ethereum, TransactionType::Swap)); + } + + #[test] + fn test_is_swap_outdated_negative() { + let config = StoreTransactionsConsumerConfig::mock(); + let timeout = config.outdated_timeout(Chain::Ethereum, TransactionType::Swap); + let created_at = chrono::Utc::now() - chrono::Duration::from_std(timeout).unwrap() + chrono::Duration::seconds(1); + assert!(!config.is_transaction_outdated(created_at.naive_utc(), Chain::Ethereum, TransactionType::Swap)); + } + + #[test] + fn test_is_transaction_insufficient_amount() { + use chrono::Utc; + use primitives::AssetId; + + let config = StoreTransactionsConsumerConfig::mock(); + + let token_asset = Asset::mock_erc20(); + let native_asset = Asset::mock_btc(); + + let price_high = Some(Price::new(1.0, 0.0, Utc::now(), primitives::PriceProvider::Coingecko)); + let price_low = Some(Price::new(0.005, 0.0, Utc::now(), primitives::PriceProvider::Coingecko)); + + let transaction_transfer = Transaction::mock_with_params( + AssetId::from(Chain::Ethereum, Some("0xA0b86a33E6441066d64bb38954e41F6b4b925c59".to_string())), + TransactionType::Transfer, + "100000".to_string(), + ); + + let transaction_swap = Transaction::mock_with_params( + AssetId::from(Chain::Ethereum, Some("0xA0b86a33E6441066d64bb38954e41F6b4b925c59".to_string())), + TransactionType::Swap, + "100000".to_string(), + ); + + let test_cases = vec![ + (transaction_transfer.clone(), &token_asset, price_high, 0.01, false), + (transaction_transfer.clone(), &token_asset, price_low, 0.01, true), + (transaction_transfer.clone(), &token_asset, price_high, 0.5, true), + (transaction_transfer.clone(), &native_asset, price_low, 0.01, true), + (transaction_transfer.clone(), &token_asset, None, 0.01, false), + (transaction_swap.clone(), &token_asset, price_low, 0.01, false), + ]; + + for (transaction, asset, price, min_amount, expected) in test_cases { + assert_eq!(config.is_transaction_insufficient_amount(&transaction, asset, price, min_amount), expected); + } + } + + #[test] + fn test_should_notify_transaction() { + let config = StoreTransactionsConsumerConfig::mock(); + let empty = SendAddressMap::new(); + + assert!(config.should_notify_transaction(&Transaction::mock(), true, &empty)); + } + + #[test] + fn test_should_notify_transaction_in_transit() { + let config = StoreTransactionsConsumerConfig::mock(); + let empty = SendAddressMap::new(); + let tx = Transaction { + state: TransactionState::InTransit, + ..Transaction::mock() + }; + assert!(!config.should_notify_transaction(&tx, true, &empty)); + } + + #[test] + fn test_should_notify_transaction_no_devices() { + let config = StoreTransactionsConsumerConfig::mock(); + let empty = SendAddressMap::new(); + assert!(!config.should_notify_transaction(&Transaction::mock(), false, &empty)); + } + + #[test] + fn test_should_notify_transaction_from_vault() { + let config = StoreTransactionsConsumerConfig::mock(); + let tx = Transaction::mock(); + let vault_addresses = SendAddressMap::from([(tx.from.clone(), primitives::SwapProvider::Thorchain)]); + assert!(!config.should_notify_transaction(&tx, true, &vault_addresses)); + } +} diff --git a/core/apps/daemon/src/consumers/store/wallet_stream_consumer.rs b/core/apps/daemon/src/consumers/store/wallet_stream_consumer.rs new file mode 100644 index 0000000000..a412ea690f --- /dev/null +++ b/core/apps/daemon/src/consumers/store/wallet_stream_consumer.rs @@ -0,0 +1,57 @@ +use std::error::Error; + +use async_trait::async_trait; +use cacher::CacherClient; +use primitives::{StreamBalanceUpdate, StreamEvent, StreamTransactionsUpdate, StreamWalletUpdate, WalletId, device_stream_channel}; +use storage::{Database, WalletsRepository}; +use streamer::{WalletStreamEvent, WalletStreamPayload, consumer::MessageConsumer}; + +pub struct WalletStreamConsumer { + pub database: Database, + pub cacher_client: CacherClient, +} + +fn stream_events(wallet_id: WalletId, event: WalletStreamEvent) -> Vec { + match event { + WalletStreamEvent::Transactions { transaction_ids, asset_ids } => asset_ids + .into_iter() + .map(|asset_id| { + StreamEvent::Balances(StreamBalanceUpdate { + wallet_id: wallet_id.clone(), + asset_id, + }) + }) + .chain(std::iter::once(StreamEvent::Transactions(StreamTransactionsUpdate { + wallet_id: wallet_id.clone(), + transactions: transaction_ids, + }))) + .collect(), + WalletStreamEvent::FiatTransaction => vec![StreamEvent::FiatTransaction(StreamWalletUpdate { wallet_id })], + WalletStreamEvent::Nft => vec![StreamEvent::Nft(StreamWalletUpdate { wallet_id })], + WalletStreamEvent::Perpetual => vec![StreamEvent::Perpetual(StreamWalletUpdate { wallet_id })], + } +} + +#[async_trait] +impl MessageConsumer for WalletStreamConsumer { + async fn should_process(&self, _payload: WalletStreamPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: WalletStreamPayload) -> Result> { + let wallet = self.database.wallets()?.get_wallet_by_id(payload.wallet_id)?; + let devices = self.database.wallets()?.get_devices_by_wallet_id(payload.wallet_id)?; + let wallet_id = wallet.wallet_id.0; + let events = stream_events(wallet_id, payload.event); + + let mut count = 0; + for device in &devices { + let channel = device_stream_channel(&device.device_id); + for event in &events { + self.cacher_client.publish(&channel, event).await?; + count += 1; + } + } + Ok(count) + } +} diff --git a/core/apps/daemon/src/consumers/support/mod.rs b/core/apps/daemon/src/consumers/support/mod.rs new file mode 100644 index 0000000000..998ccf916c --- /dev/null +++ b/core/apps/daemon/src/consumers/support/mod.rs @@ -0,0 +1,29 @@ +pub mod support_webhook_consumer; + +use std::error::Error; +use std::sync::Arc; + +use settings::Settings; +use storage::Database; +use streamer::{ConsumerStatusReporter, QueueName, ShutdownReceiver, StreamProducer, StreamProducerConfig, SupportWebhookPayload, run_consumer}; +use support::SupportClient; + +use crate::consumers::{consumer_config, reader_for_queue}; + +use support_webhook_consumer::SupportWebhookConsumer; + +pub async fn run_consumer_support(settings: Settings, shutdown_rx: ShutdownReceiver, reporter: Arc) -> Result<(), Box> { + let database = Database::new(&settings.postgres.url, settings.postgres.pool); + + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, "daemon_support_producer", shutdown_rx.clone()).await?; + + let support_client = SupportClient::new(database, stream_producer); + let consumer = SupportWebhookConsumer::new(support_client); + + let queue = QueueName::SupportWebhooks; + let (name, stream_reader) = reader_for_queue(&settings, &queue, &shutdown_rx).await?; + let consumer_config = consumer_config(&settings.consumer); + run_consumer::(&name, stream_reader, queue, None, consumer, consumer_config, shutdown_rx, reporter).await +} diff --git a/core/apps/daemon/src/consumers/support/support_webhook_consumer.rs b/core/apps/daemon/src/consumers/support/support_webhook_consumer.rs new file mode 100644 index 0000000000..40a7f1c839 --- /dev/null +++ b/core/apps/daemon/src/consumers/support/support_webhook_consumer.rs @@ -0,0 +1,65 @@ +use std::error::Error; + +use async_trait::async_trait; +use gem_tracing::{error_with_fields, info_with_fields}; +use streamer::SupportWebhookPayload; +use streamer::consumer::MessageConsumer; + +use primitives::Device; +use support::{ChatwootWebhookPayload, EVENT_CONVERSATION_STATUS_CHANGED, EVENT_CONVERSATION_UPDATED, EVENT_MESSAGE_CREATED, SupportClient}; + +pub struct SupportWebhookConsumer { + support_client: SupportClient, +} + +impl SupportWebhookConsumer { + pub fn new(support_client: SupportClient) -> Self { + Self { support_client } + } + + async fn process_notification(&self, device: &Device, webhook: &ChatwootWebhookPayload) -> Result> { + match webhook.event.as_str() { + EVENT_MESSAGE_CREATED => self.support_client.handle_message_created(device, webhook).await, + EVENT_CONVERSATION_UPDATED | EVENT_CONVERSATION_STATUS_CHANGED => self.support_client.handle_conversation_updated(webhook).map(|_| 0), + _ => Ok(0), + } + } +} + +#[async_trait] +impl MessageConsumer for SupportWebhookConsumer { + async fn should_process(&self, _payload: SupportWebhookPayload) -> Result> { + Ok(true) + } + + async fn process(&self, payload: SupportWebhookPayload) -> Result> { + let webhook = match serde_json::from_value::(payload.data.clone()) { + Ok(w) => w, + Err(e) => { + error_with_fields!("support webhook parsing failed", &e, payload = payload.data.to_string()); + return Ok(true); + } + }; + + let Some(device_id) = webhook.get_device_id() else { + info_with_fields!("support webhook missing device_id", event = webhook.event); + return Ok(true); + }; + + let Some(device) = self.support_client.get_device(&device_id)? else { + info_with_fields!("support webhook device not found", device_id = device_id); + return Ok(true); + }; + + match self.process_notification(&device, &webhook).await { + Ok(notifications) => { + info_with_fields!("support webhook processed", device_id = device_id, event = webhook.event, notifications = notifications); + Ok(true) + } + Err(error) => { + error_with_fields!("support webhook failed", &*error, device_id = device_id, payload = payload.data.to_string()); + Err(error) + } + } + } +} diff --git a/core/apps/daemon/src/health.rs b/core/apps/daemon/src/health.rs new file mode 100644 index 0000000000..00049fbaef --- /dev/null +++ b/core/apps/daemon/src/health.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use rocket::{State, get, http::Status, routes}; + +use crate::metrics::{self, MetricsProvider}; + +pub struct HealthState { + ready: AtomicBool, +} + +impl Default for HealthState { + fn default() -> Self { + Self::new() + } +} + +impl HealthState { + pub fn new() -> Self { + Self { ready: AtomicBool::new(false) } + } + + pub fn set_ready(&self) { + self.ready.store(true, Ordering::Relaxed); + } + + pub fn is_ready(&self) -> bool { + self.ready.load(Ordering::Relaxed) + } +} + +#[get("/health")] +fn health(state: &State>) -> Status { + if state.is_ready() { Status::Ok } else { Status::ServiceUnavailable } +} + +pub async fn run_server(state: Arc, metrics_provider: Arc) { + let _ = rocket::build() + .manage(state) + .manage(metrics_provider) + .mount("/", routes![health]) + .mount("/metrics", routes![metrics::get_metrics]) + .launch() + .await; +} + +pub fn spawn_server(metrics_provider: Arc) -> Arc { + let state = Arc::new(HealthState::new()); + tokio::spawn({ + let state = state.clone(); + async move { run_server(state, metrics_provider).await } + }); + state +} diff --git a/core/apps/daemon/src/main.rs b/core/apps/daemon/src/main.rs new file mode 100644 index 0000000000..aa95f8c1ea --- /dev/null +++ b/core/apps/daemon/src/main.rs @@ -0,0 +1,251 @@ +mod client; +mod consumers; +mod health; +mod metrics; +mod model; +mod parser; +mod pusher; +mod reporters; +mod setup; +mod shutdown; +mod worker; + +use std::str::FromStr; +use std::sync::{Arc, Mutex}; + +use crate::model::{ConsumerOptions, ConsumerService, DaemonService, WorkerOptions, WorkerService}; +use crate::reporters::consumer::ConsumerReporter; +use crate::reporters::job::JobReporter; +use crate::shutdown::ShutdownReceiver; +use crate::worker::context::WorkerContext; +use crate::worker::job_schedule::CacherJobTracker; +use crate::worker::runtime::WorkerRuntime; +use cacher::CacherClient; +use gem_tracing::{error_with_fields, info_with_fields}; +use job_runner::{JobHandle, JobSchedule}; +use std::sync::atomic::{AtomicBool, Ordering}; +use streamer::ConsumerStatusReporter; + +#[tokio::main] +pub async fn main() { + let args: Vec = std::env::args().collect(); + let service_arg = args.iter().skip(1).map(|s| s.as_str()).collect::>().join(" "); + + let service = DaemonService::from_str(&service_arg).unwrap_or_else(|e| { + panic!("{e}\nUsage examples:\n daemon parser\n daemon parser ethereum\n daemon worker alerter\n daemon worker prices jupiter\n daemon consumer fetch_transactions"); + }); + + let settings = settings::Settings::new().unwrap(); + + info_with_fields!("daemon start", service = service.name()); + + match service { + DaemonService::Setup => { + let _ = setup::run_setup(settings).await; + } + DaemonService::SetupDev => { + let _ = setup::run_setup_dev(settings).await; + } + DaemonService::Worker(opts) => { + let services = match opts.service { + Some(worker) => vec![worker], + None => WorkerService::all(), + }; + run_worker_services(settings, &services, opts).await; + } + DaemonService::Parser(chain) => { + let parser_metrics = Arc::new(metrics::parser::ParserMetrics::new()); + let health_state = health::spawn_server(parser_metrics.clone()); + parser::run(settings, chain, health_state, parser_metrics).await.expect("Parser failed"); + } + DaemonService::Consumer(opts) => { + let services = match opts.service.clone() { + Some(consumer) => vec![consumer], + None => ConsumerService::all(), + }; + run_consumer_services(settings, &services, opts).await.expect("Consumer failed"); + } + } +} + +async fn run_worker_services(settings: settings::Settings, services: &[WorkerService], options: WorkerOptions) { + if services.is_empty() { + info_with_fields!("no worker services requested", status = "ok"); + return; + } + + let settings = Arc::new(settings); + let (shutdown_tx, shutdown_rx) = shutdown::channel(); + let shutdown_timeout = settings.daemon.shutdown.timeout; + + let scheduler_cacher = CacherClient::new(&settings.redis.url).await; + let database = storage::Database::new(&settings.postgres.url, settings.postgres.pool); + + let service_name = services.first().map(|s| s.as_ref()).unwrap_or("worker"); + let job_metrics = Arc::new(metrics::job::JobMetrics::new(service_name)); + let composite = Arc::new(metrics::Metrics::new(vec![job_metrics.clone()])); + let health_state = health::spawn_server(composite); + + let signal_handle = shutdown::spawn_signal_handler(shutdown_tx); + + let worker_jobs: Vec<_> = futures::future::join_all(services.iter().map(|service| { + let svc = *service; + let tracker = Arc::new(CacherJobTracker::new(scheduler_cacher.clone(), service.as_ref())); + let reporter = Arc::new(JobReporter::new(job_metrics.clone())); + let schedule: Arc = tracker; + let runtime = WorkerRuntime::new(reporter, schedule); + let context = WorkerContext::new(settings.clone(), database.clone(), runtime, options.job.clone()); + let shutdown_rx = shutdown_rx.clone(); + async move { + match svc.run_jobs(context, shutdown_rx).await { + Ok(handles) => Some((svc, handles)), + Err(err) => { + error_with_fields!("worker init failed", &*err, worker = svc.as_ref()); + None + } + } + } + })) + .await + .into_iter() + .flatten() + .collect(); + + let job_count: usize = worker_jobs.iter().map(|(_, jobs)| jobs.len()).sum(); + health_state.set_ready(); + info_with_fields!("workers ready", workers = worker_jobs.len(), jobs = job_count); + + signal_handle.await.ok(); + + if worker_jobs.is_empty() { + info_with_fields!("no workers started", status = "ok"); + return; + } + + let status_tracks = collect_status_tracks(&worker_jobs); + log_pending_workers(&status_tracks, "waiting for worker shutdown"); + + let handles_only: Vec<_> = worker_jobs.into_iter().flat_map(|(_, jobs)| jobs.into_iter().map(JobHandle::into_handle)).collect(); + let completed = shutdown::wait_with_timeout(handles_only, shutdown_timeout).await; + + if !completed { + log_pending_workers(&status_tracks, "force-stopping unfinished jobs"); + } + + info_with_fields!("all workers stopped", status = "ok"); +} + +struct WorkerStatusTrack { + worker: WorkerService, + jobs: Vec<(String, Arc)>, +} + +fn collect_status_tracks(handles: &[(WorkerService, Vec)]) -> Vec { + handles + .iter() + .map(|(worker, jobs)| WorkerStatusTrack { + worker: *worker, + jobs: jobs.iter().map(|job| (job.name().to_string(), job.status_flag())).collect(), + }) + .collect() +} + +fn log_pending_workers(tracks: &[WorkerStatusTrack], message: &str) { + for track in tracks { + let pending: Vec<_> = track + .jobs + .iter() + .filter_map(|(name, flag)| if flag.load(Ordering::Relaxed) { None } else { Some(name.clone()) }) + .collect(); + if pending.is_empty() { + continue; + } + info_with_fields!(message, worker = track.worker.as_ref(), jobs = pending.join(", ")); + } +} + +async fn run_consumer_services(settings: settings::Settings, services: &[ConsumerService], options: ConsumerOptions) -> Result<(), Box> { + if services.is_empty() { + info_with_fields!("no consumer services requested", status = "ok"); + return Ok(()); + } + + let settings = Arc::new(settings); + let (shutdown_tx, shutdown_rx) = shutdown::channel(); + let signal_handle = shutdown::spawn_signal_handler(shutdown_tx); + + let consumer_metrics = Arc::new(metrics::consumer::ConsumerMetrics::new()); + let composite = Arc::new(metrics::Metrics::new(vec![consumer_metrics.clone()])); + let health_state = health::spawn_server(composite); + let reporter: Arc = Arc::new(ConsumerReporter::new(consumer_metrics)); + let failures = Arc::new(Mutex::new(Vec::new())); + + let handles: Vec<_> = services + .iter() + .map(|service| { + let svc = service.clone(); + let svc_name = svc.as_ref().to_string(); + let settings = settings.clone(); + let reporter = reporter.clone(); + let shutdown_rx = shutdown_rx.clone(); + let failures = failures.clone(); + let options = options.clone(); + tokio::spawn(async move { + let restart_delay = settings.consumer.error.timeout; + loop { + if *shutdown_rx.borrow() { + break; + } + match run_consumer((*settings.as_ref()).clone(), svc.clone(), shutdown_rx.clone(), reporter.clone(), options.clone()).await { + Ok(_) => { + info_with_fields!("consumer stopped", consumer = svc_name.as_str(), status = "ok"); + break; + } + Err(err) => { + let message = err.to_string(); + error_with_fields!("consumer failed", &*err, consumer = svc_name.as_str()); + if let Ok(mut list) = failures.lock() { + list.push(format!("{}: {}", svc_name, message)); + } + if shutdown::sleep_or_shutdown(restart_delay, &shutdown_rx).await { + break; + } + info_with_fields!("consumer restarting", consumer = svc_name.as_str()); + } + } + } + }) + }) + .collect(); + + health_state.set_ready(); + + signal_handle.await.ok(); + futures::future::join_all(handles).await; + + match failures.lock() { + Ok(errors) if errors.is_empty() => { + info_with_fields!("all consumers stopped", status = "ok"); + Ok(()) + } + Ok(errors) => Err(errors.join(", ").into()), + Err(_) => Err("failed to inspect consumer results".into()), + } +} + +async fn run_consumer( + settings: settings::Settings, + service: ConsumerService, + shutdown_rx: ShutdownReceiver, + reporter: Arc, + options: ConsumerOptions, +) -> Result<(), Box> { + match service { + ConsumerService::Store => consumers::run_consumer_store(settings, shutdown_rx, reporter).await, + ConsumerService::Indexer => consumers::run_consumer_indexer(settings, shutdown_rx, reporter, options.indexer).await, + ConsumerService::Notifications => consumers::notifications::run(settings, shutdown_rx, reporter).await, + ConsumerService::Rewards => consumers::run_consumer_rewards(settings, shutdown_rx, reporter).await, + ConsumerService::Support => consumers::run_consumer_support(settings, shutdown_rx, reporter).await, + ConsumerService::Fiat => consumers::run_consumer_fiat(settings, shutdown_rx, reporter).await, + } +} diff --git a/core/apps/daemon/src/metrics/consumer.rs b/core/apps/daemon/src/metrics/consumer.rs new file mode 100644 index 0000000000..d92ef0b91a --- /dev/null +++ b/core/apps/daemon/src/metrics/consumer.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use prometheus_client::encoding::EncodeLabelSet; +use prometheus_client::metrics::family::Family; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client::registry::Registry; + +use super::MetricsProvider; + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct ConsumerLabels { + consumer: String, +} + +#[derive(Default)] +struct ConsumerState { + total_processed: u64, + last_success: Option, + avg_duration: u64, +} + +pub struct ConsumerMetrics { + consumers: Mutex>, +} + +impl ConsumerMetrics { + pub fn new() -> Self { + Self { + consumers: Mutex::new(HashMap::new()), + } + } + + pub fn record_success(&self, name: &str, duration: u64, _result: &str) { + let mut consumers = self.consumers.lock().unwrap(); + let state = consumers.entry(name.to_string()).or_default(); + let timestamp = super::now_unix(); + + state.total_processed += 1; + state.last_success = Some(timestamp); + + let prev_total = state.total_processed - 1; + state.avg_duration = (state.avg_duration * prev_total + duration) / state.total_processed; + } +} + +impl MetricsProvider for ConsumerMetrics { + fn register(&self, registry: &mut Registry) { + let processed = Family::::default(); + let last_success_at = Family::::default(); + let avg_duration = Family::::default(); + + let consumers = self.consumers.lock().unwrap(); + for (name, state) in consumers.iter() { + let labels = ConsumerLabels { consumer: name.clone() }; + + processed.get_or_create(&labels).set(state.total_processed as i64); + if let Some(ts) = state.last_success { + last_success_at.get_or_create(&labels).set(ts as i64); + } + avg_duration.get_or_create(&labels).set(state.avg_duration as i64); + } + + registry.register("consumer_processed", "Messages processed", processed); + registry.register("consumer_last_success_at", "Last successful processing (unix timestamp)", last_success_at); + registry.register("consumer_avg_duration_milliseconds", "Average processing duration in milliseconds", avg_duration); + } +} diff --git a/core/apps/daemon/src/metrics/job.rs b/core/apps/daemon/src/metrics/job.rs new file mode 100644 index 0000000000..36a48621d7 --- /dev/null +++ b/core/apps/daemon/src/metrics/job.rs @@ -0,0 +1,76 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use prometheus_client::encoding::EncodeLabelSet; +use prometheus_client::metrics::family::Family; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client::registry::Registry; + +use super::MetricsProvider; + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct JobLabels { + service: String, + job_name: String, +} + +#[derive(Default)] +struct JobState { + interval: u64, + duration: u64, + last_success: Option, +} + +pub struct JobMetrics { + service: String, + jobs: Mutex>, +} + +impl JobMetrics { + pub fn new(service: &str) -> Self { + Self { + service: service.to_string(), + jobs: Mutex::new(HashMap::new()), + } + } + + pub fn report(&self, name: &str, interval: u64, duration: u64, success: bool) { + let mut jobs = self.jobs.lock().unwrap(); + let state = jobs.entry(name.to_string()).or_default(); + + state.interval = interval; + state.duration = duration; + + if success { + let timestamp = super::now_unix(); + state.last_success = Some(timestamp); + } + } +} + +impl MetricsProvider for JobMetrics { + fn register(&self, registry: &mut Registry) { + let last_success_at = Family::::default(); + let interval = Family::::default(); + let duration = Family::::default(); + + let jobs = self.jobs.lock().unwrap(); + for (name, state) in jobs.iter() { + let labels = JobLabels { + service: self.service.clone(), + job_name: name.clone(), + }; + + interval.get_or_create(&labels).set(state.interval as i64); + duration.get_or_create(&labels).set(state.duration as i64); + + if let Some(ts) = state.last_success { + last_success_at.get_or_create(&labels).set(ts as i64); + } + } + + registry.register("job_last_success_at", "Last successful job run (unix timestamp)", last_success_at); + registry.register("job_interval_seconds", "Job interval in seconds", interval); + registry.register("job_duration_milliseconds", "Last job duration in milliseconds", duration); + } +} diff --git a/core/apps/daemon/src/metrics/mod.rs b/core/apps/daemon/src/metrics/mod.rs new file mode 100644 index 0000000000..4ddb03da33 --- /dev/null +++ b/core/apps/daemon/src/metrics/mod.rs @@ -0,0 +1,44 @@ +pub mod consumer; +pub mod job; +pub mod parser; + +use std::sync::Arc; +use std::time::SystemTime; + +use metrics::MetricsRegistry; +use prometheus_client::registry::Registry; +use rocket::response::content::RawText; +use rocket::{State, get}; + +pub fn now_unix() -> u64 { + SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() +} + +pub trait MetricsProvider: Send + Sync { + fn register(&self, registry: &mut Registry); +} + +pub struct Metrics { + providers: Vec>, +} + +impl Metrics { + pub fn new(providers: Vec>) -> Self { + Self { providers } + } +} + +impl MetricsProvider for Metrics { + fn register(&self, registry: &mut Registry) { + for provider in &self.providers { + provider.register(registry); + } + } +} + +#[get("/")] +pub fn get_metrics(provider: &State>) -> RawText { + let mut registry = MetricsRegistry::new(); + provider.register(registry.registry_mut()); + RawText(registry.encode()) +} diff --git a/core/apps/daemon/src/metrics/parser.rs b/core/apps/daemon/src/metrics/parser.rs new file mode 100644 index 0000000000..359e066a9e --- /dev/null +++ b/core/apps/daemon/src/metrics/parser.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use prometheus_client::encoding::EncodeLabelSet; +use prometheus_client::metrics::family::Family; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client::registry::Registry; + +use super::MetricsProvider; + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct ParserLabels { + chain: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct TransactionTypeLabels { + chain: String, + transaction_type: String, +} + +#[derive(Default)] +struct ParserState { + current_block: i64, + latest_block: i64, + is_enabled: bool, + updated_at: i64, + transactions: HashMap, +} + +pub struct ParserMetrics { + chains: Mutex>, +} + +impl ParserMetrics { + pub fn new() -> Self { + Self { + chains: Mutex::new(HashMap::new()), + } + } + + pub fn update_state(&self, chain: &str, current_block: i64, latest_block: i64, is_enabled: bool) { + let mut chains = self.chains.lock().unwrap(); + let state = chains.entry(chain.to_string()).or_default(); + state.current_block = current_block; + state.latest_block = latest_block; + state.is_enabled = is_enabled; + state.updated_at = super::now_unix() as i64; + } + + pub fn record_transactions(&self, chain: &str, transactions: &[(String, u64)]) { + let mut chains = self.chains.lock().unwrap(); + let state = chains.entry(chain.to_string()).or_default(); + for (transaction_type, count) in transactions { + *state.transactions.entry(transaction_type.clone()).or_default() += count; + } + } +} + +impl MetricsProvider for ParserMetrics { + fn register(&self, registry: &mut Registry) { + let latest_block = Family::::default(); + let current_block = Family::::default(); + let is_enabled = Family::::default(); + let updated_at = Family::::default(); + let transactions = Family::::default(); + + let chains = self.chains.lock().unwrap(); + for (chain, state) in chains.iter() { + let labels = ParserLabels { chain: chain.clone() }; + + current_block.get_or_create(&labels).set(state.current_block); + latest_block.get_or_create(&labels).set(state.latest_block); + is_enabled.get_or_create(&labels).set(state.is_enabled as i64); + updated_at.get_or_create(&labels).set(state.updated_at); + + for (transaction_type, count) in &state.transactions { + let type_labels = TransactionTypeLabels { + chain: chain.clone(), + transaction_type: transaction_type.clone(), + }; + transactions.get_or_create(&type_labels).set(*count as i64); + } + } + + registry.register("parser_state_latest_block", "Parser latest block", latest_block); + registry.register("parser_state_current_block", "Parser current block", current_block); + registry.register("parser_state_is_enabled", "Parser is enabled", is_enabled); + registry.register("parser_state_updated_at", "Parser updated at", updated_at); + registry.register("parser_transactions_total", "Transactions parsed total", transactions); + } +} diff --git a/core/apps/daemon/src/model.rs b/core/apps/daemon/src/model.rs new file mode 100644 index 0000000000..bfed004d2e --- /dev/null +++ b/core/apps/daemon/src/model.rs @@ -0,0 +1,141 @@ +use primitives::Chain; +use std::str::FromStr; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; + +#[derive(Debug, Clone, PartialEq, AsRefStr, EnumString, EnumIter)] +#[strum(serialize_all = "snake_case")] +pub enum ConsumerService { + Store, + Indexer, + Notifications, + Rewards, + Support, + Fiat, +} + +impl ConsumerService { + pub fn all() -> Vec { + Self::iter().collect() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, AsRefStr, EnumString)] +#[strum(serialize_all = "snake_case")] +#[allow(clippy::enum_variant_names)] +pub enum IndexerConsumer { + FetchAssets, + FetchPrices, + FetchBlocks, + FetchTokenAssociations, + FetchCoinAssociations, + FetchNftAssociations, + FetchNftAssets, + FetchAddressTransactions, +} + +#[derive(Debug, Clone)] +pub struct ConsumerOptions { + pub service: Option, + pub indexer: Option, +} + +#[derive(Debug, Clone, Copy, AsRefStr, EnumString, EnumIter, PartialEq, Eq)] +#[strum(serialize_all = "snake_case")] +pub enum WorkerService { + Alerter, + Prices, + Fiat, + Assets, + System, + Search, + Rewards, + Transactions, + Perpetuals, +} + +impl WorkerService { + pub fn all() -> Vec { + Self::iter().collect() + } +} + +#[derive(Debug, Clone)] +pub struct WorkerOptions { + pub service: Option, + pub job: Option, +} + +#[derive(Debug, Clone, AsRefStr)] +#[strum(serialize_all = "snake_case")] +pub enum DaemonService { + Setup, + SetupDev, + #[strum(serialize = "worker")] + Worker(WorkerOptions), + #[strum(serialize = "parser")] + Parser(Option), + #[strum(serialize = "consumer")] + Consumer(ConsumerOptions), +} + +impl DaemonService { + pub fn name(&self) -> String { + match self { + DaemonService::Setup => "setup".to_owned(), + DaemonService::SetupDev => "setup_dev".to_owned(), + DaemonService::Worker(opts) => match (opts.service, opts.job.as_deref()) { + (Some(service), Some(job)) => format!("worker {} {}", service.as_ref(), job), + (Some(service), None) => format!("worker {}", service.as_ref()), + (None, _) => "worker all".to_owned(), + }, + DaemonService::Parser(chain) => match chain { + Some(chain) => format!("parser {}", chain.as_ref()), + None => "parser".to_owned(), + }, + DaemonService::Consumer(opts) => match (&opts.service, opts.indexer) { + (Some(service), Some(indexer)) => format!("consumer {} {}", service.as_ref(), indexer.as_ref()), + (Some(service), None) => format!("consumer {}", service.as_ref()), + (None, _) => "consumer all".to_owned(), + }, + } + } +} + +impl FromStr for DaemonService { + type Err = String; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split_whitespace().collect(); + let name = parts.first().copied().ok_or_else(|| "Empty service name".to_string())?; + + match name { + "setup" => Ok(DaemonService::Setup), + "setup_dev" => Ok(DaemonService::SetupDev), + "worker" => { + let service = parts.get(1).map(|s| WorkerService::from_str(s).map_err(|_| format!("Invalid worker: {s}"))).transpose()?; + let job = parts.get(2).map(|s| (*s).to_owned()); + Ok(DaemonService::Worker(WorkerOptions { service, job })) + } + "parser" => { + let chain = parts.get(1).map(|s| Chain::from_str(s).map_err(|_| format!("Invalid chain: {s}"))).transpose()?; + Ok(DaemonService::Parser(chain)) + } + "consumer" => { + let service = parts + .get(1) + .map(|s| ConsumerService::from_str(s).map_err(|_| format!("Invalid consumer: {s}"))) + .transpose()?; + let indexer = if matches!(service, Some(ConsumerService::Indexer)) { + parts + .get(2) + .map(|s| IndexerConsumer::from_str(s).map_err(|_| format!("Invalid indexer consumer: {s}"))) + .transpose()? + } else { + None + }; + Ok(DaemonService::Consumer(ConsumerOptions { service, indexer })) + } + _ => Err(format!("Unknown service: {name}")), + } + } +} diff --git a/core/apps/daemon/src/parser/mod.rs b/core/apps/daemon/src/parser/mod.rs new file mode 100644 index 0000000000..75ea54560e --- /dev/null +++ b/core/apps/daemon/src/parser/mod.rs @@ -0,0 +1,309 @@ +mod parser_options; +mod parser_state; +mod plan; + +pub use parser_options::ParserOptions; +use parser_state::ParserStateService; + +use std::{ + error::Error, + sync::Arc, + time::{Duration, Instant}, +}; + +use crate::health::HealthState; +use crate::metrics::parser::ParserMetrics; +use crate::reporters::parser::ParserReporter; +use chain_traits::ChainTraits; +use gem_tracing::{DurationMs, error_with_fields, info_with_fields}; +use primitives::Chain; +use settings::Settings; +use std::str::FromStr; +use streamer::{StreamProducer, StreamProducerConfig, StreamProducerQueue, TransactionsPayload}; + +use crate::shutdown::{self, ShutdownReceiver}; +use plan::{BlockPlan, BlockPlanKind, plan_next_block, should_reload_catchup, timeout_for_state}; +use storage::{Database, models::ParserStateRow}; + +pub struct Parser { + chain: Chain, + provider: Box, + stream_producer: StreamProducer, + state_service: ParserStateService, + reporter: ParserReporter, + options: ParserOptions, + shutdown_rx: ShutdownReceiver, +} + +impl Parser { + pub fn new( + provider: Box, + stream_producer: StreamProducer, + database: Database, + parser_metrics: Arc, + options: ParserOptions, + shutdown_rx: ShutdownReceiver, + ) -> Self { + let chain = provider.get_chain(); + let state_service = ParserStateService::new(chain, database); + let reporter = ParserReporter::new(chain, parser_metrics); + Self { + chain, + provider, + stream_producer, + state_service, + reporter, + options, + shutdown_rx, + } + } + + fn is_shutdown(&self) -> bool { + *self.shutdown_rx.borrow() + } + + async fn sleep_or_shutdown(&self, duration: Duration) -> bool { + shutdown::sleep_or_shutdown(duration, &self.shutdown_rx).await + } + + async fn wait_if_disabled(&self, state: &ParserStateRow, timeout: Duration) -> bool { + if state.is_enabled { + true + } else { + self.sleep_or_shutdown(timeout).await; + false + } + } + + async fn get_latest_block(&self, state: &ParserStateRow) -> Result> { + let latest_block = self.provider.get_block_latest_number().await? as i64; + let _ = self.state_service.set_latest_block(latest_block); + + if state.current_block == 0 { + let _ = self.state_service.set_current_block(latest_block); + } + + Ok(latest_block) + } + + async fn execute_plan(&self, plan: BlockPlan, state: &ParserStateRow, timeout: Duration) -> Result> { + let start = Instant::now(); + let blocks_desc = format!("{:?}", plan.range.blocks); + + match plan.kind { + BlockPlanKind::Enqueue => { + self.stream_producer.publish_blocks(self.chain, &plan.range.blocks).await?; + let _ = self.state_service.set_current_block(plan.range.end_block); + + info_with_fields!( + "block add to queue", + chain = self.chain.as_ref(), + blocks = blocks_desc, + remaining = plan.range.remaining, + duration = DurationMs(start.elapsed()) + ); + return Ok(true); + } + BlockPlanKind::Parse => {} + } + + match self.parse_blocks(plan.range.blocks).await { + Ok(result) => { + let _ = self.state_service.set_current_block(plan.range.end_block); + + info_with_fields!( + "block complete", + chain = self.chain.as_ref(), + blocks = blocks_desc, + transactions = result, + remaining = plan.range.remaining, + duration = DurationMs(start.elapsed()) + ); + } + Err(err) => { + error_with_fields!("parser parse_block", &*err, chain = self.chain.as_ref(), blocks = blocks_desc); + self.sleep_or_shutdown(timeout).await; + return Ok(false); + } + } + + if should_reload_catchup(plan.range.remaining, self.options.catchup_reload_interval) { + return Ok(false); + } + if state.timeout_between_blocks > 0 && self.sleep_or_shutdown(Duration::from_millis(state.timeout_between_blocks as u64)).await { + return Ok(false); + } + + Ok(true) + } + + async fn process_blocks(&self, timeout: Duration) -> Result<(), Box> { + loop { + if self.is_shutdown() { + break; + } + + let state = self.state_service.get_state()?; + + let Some(plan) = plan_next_block(&state, state.current_block, state.latest_block) else { + break; + }; + + if !self.execute_plan(plan, &state, timeout).await? { + break; + } + } + + Ok(()) + } + + pub async fn start(&self) -> Result<(), Box> { + loop { + if self.is_shutdown() { + info_with_fields!("shutdown requested", chain = self.chain.as_ref()); + break; + } + + let state = self.state_service.get_state()?; + self.reporter.update_state(state.current_block, state.latest_block, state.is_enabled); + let timeout = timeout_for_state(&state, self.options.min_check, self.options.max_check); + + if !self.wait_if_disabled(&state, timeout).await { + continue; + } + + let latest_block = match self.get_latest_block(&state).await { + Ok(block) => block, + Err(err) => { + error_with_fields!("parser latest_block", &*err, chain = self.chain.as_ref()); + self.sleep_or_shutdown(self.options.error_interval).await; + continue; + } + }; + + if state.current_block + state.await_blocks as i64 >= latest_block { + info_with_fields!( + "parser ahead", + chain = self.chain.as_ref(), + current_block = state.current_block, + latest_block = latest_block, + await_blocks = state.await_blocks, + next_check = DurationMs(timeout) + ); + self.sleep_or_shutdown(timeout).await; + continue; + } + + self.process_blocks(timeout).await?; + } + + info_with_fields!("parser stopped", chain = self.chain.as_ref()); + + Ok(()) + } + + async fn parse_blocks(&self, blocks: Vec) -> Result> { + let transactions = self.provider.get_transactions_in_blocks(blocks.clone()).await?; + if transactions.is_empty() { + return Ok(0); + } + self.reporter.record_transactions(&transactions); + let count = transactions.len(); + let payload = TransactionsPayload::new_with_notify(self.chain, blocks, transactions); + self.stream_producer.publish_transactions(payload).await?; + Ok(count) + } +} + +pub async fn run(settings: Settings, chain: Option, health_state: Arc, parser_metrics: Arc) -> Result<(), Box> { + let database = Database::new(&settings.postgres.url, settings.postgres.pool); + + let config = storage::ConfigCacher::new(database.clone()); + let catchup_reload_interval = config.get_i64(primitives::ConfigKey::ParserCatchupReloadInterval)?; + let min_check = config.get_duration(primitives::ConfigKey::ParserMinCheckInterval)?; + let max_check = config.get_duration(primitives::ConfigKey::ParserMaxCheckInterval)?; + let error_interval = config.get_duration(primitives::ConfigKey::ParserErrorInterval)?; + + let chains: Vec = if let Some(chain) = chain { + vec![chain] + } else { + database + .parser_state()? + .get_parser_states()? + .into_iter() + .flat_map(|x| Chain::from_str(x.chain.as_ref())) + .collect() + }; + + info_with_fields!("parser init", chains = format!("{:?}", chains)); + + let (shutdown_tx, shutdown_rx) = shutdown::channel(); + let shutdown_timeout = settings.parser.shutdown.timeout; + + let signal_handle = shutdown::spawn_signal_handler(shutdown_tx); + + let mut handles = Vec::new(); + + for chain in chains { + let database = database.clone(); + let parser_metrics = parser_metrics.clone(); + let shutdown_rx = shutdown_rx.clone(); + let settings = settings.clone(); + + let provider = settings_chain::ProviderFactory::new_from_settings_with_user_agent(chain, &settings, &settings::service_user_agent("parser", None)); + + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, format!("parser_{chain}").as_str(), shutdown_rx.clone()).await?; + + let options = ParserOptions { + timeout: settings.parser.timeout, + catchup_reload_interval, + min_check, + max_check, + error_interval, + }; + + handles.push(tokio::spawn(async move { + run_parser(database, parser_metrics, stream_producer, provider, options, shutdown_rx).await; + })); + } + + health_state.set_ready(); + info_with_fields!("parsers ready", chains = handles.len()); + + signal_handle.await.ok(); + info_with_fields!("waiting for parser shutdown", tasks = handles.len()); + let _ = shutdown::wait_with_timeout(handles, shutdown_timeout).await; + + info_with_fields!("all parsers stopped", status = "ok"); + Ok(()) +} + +async fn run_parser( + database: Database, + parser_metrics: Arc, + stream_producer: StreamProducer, + provider: Box, + options: ParserOptions, + shutdown_rx: ShutdownReceiver, +) { + let chain = provider.get_chain(); + let timeout = options.timeout; + + let parser = Parser::new(provider, stream_producer, database, parser_metrics, options, shutdown_rx.clone()); + + loop { + if *shutdown_rx.borrow() { + break; + } + + if let Err(e) = parser.start().await { + error_with_fields!("parser error", &*e, chain = chain.as_ref()); + + if shutdown::sleep_or_shutdown(timeout, &shutdown_rx).await { + break; + } + } + } +} diff --git a/core/apps/daemon/src/parser/parser_options.rs b/core/apps/daemon/src/parser/parser_options.rs new file mode 100644 index 0000000000..f6644f10dd --- /dev/null +++ b/core/apps/daemon/src/parser/parser_options.rs @@ -0,0 +1,10 @@ +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct ParserOptions { + pub timeout: Duration, + pub catchup_reload_interval: i64, + pub min_check: Duration, + pub max_check: Duration, + pub error_interval: Duration, +} diff --git a/core/apps/daemon/src/parser/parser_state.rs b/core/apps/daemon/src/parser/parser_state.rs new file mode 100644 index 0000000000..9b667b669c --- /dev/null +++ b/core/apps/daemon/src/parser/parser_state.rs @@ -0,0 +1,29 @@ +use std::error::Error; + +use primitives::Chain; +use storage::{Database, models::ParserStateRow}; + +pub struct ParserStateService { + chain: Chain, + database: Database, +} + +impl ParserStateService { + pub fn new(chain: Chain, database: Database) -> Self { + Self { chain, database } + } + + pub fn get_state(&self) -> Result> { + Ok(self.database.parser_state()?.get_parser_state(self.chain)?) + } + + pub fn set_current_block(&self, block: i64) -> Result<(), Box> { + self.database.parser_state()?.set_parser_state_current_block(self.chain, block)?; + Ok(()) + } + + pub fn set_latest_block(&self, block: i64) -> Result<(), Box> { + self.database.parser_state()?.set_parser_state_latest_block(self.chain, block)?; + Ok(()) + } +} diff --git a/core/apps/daemon/src/parser/plan.rs b/core/apps/daemon/src/parser/plan.rs new file mode 100644 index 0000000000..9730064163 --- /dev/null +++ b/core/apps/daemon/src/parser/plan.rs @@ -0,0 +1,161 @@ +use std::cmp; +use std::time::Duration; + +use chrono::Utc; +use storage::models::ParserStateRow; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum BlockPlanKind { + Enqueue, + Parse, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct BlockRange { + pub blocks: Vec, + pub end_block: i64, + pub remaining: i64, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct BlockPlan { + pub range: BlockRange, + pub kind: BlockPlanKind, +} + +pub fn timeout_for_state(state: &ParserStateRow, min_check: Duration, max_check: Duration) -> Duration { + let block_time = Duration::from_millis(state.block_time as u64); + if block_time.is_zero() { + return cmp::max(Duration::from_millis(state.timeout_latest_block as u64), min_check); + } + + let elapsed = Utc::now().naive_utc().signed_duration_since(state.updated_at).num_milliseconds().max(0) as u64; + + let remaining = block_time.saturating_sub(Duration::from_millis(elapsed)); + let upper = cmp::max(cmp::min(block_time, max_check), min_check); + remaining.clamp(min_check, upper) +} + +pub fn should_reload_catchup(remaining: i64, interval: i64) -> bool { + interval > 0 && remaining % interval == 0 +} + +pub fn plan_next_block(state: &ParserStateRow, current_block: i64, latest_block: i64) -> Option { + let start_block = current_block + 1; + let end_block = cmp::min(start_block + state.parallel_blocks as i64 - 1, latest_block - state.await_blocks as i64); + if end_block < start_block { + return None; + } + let blocks = (start_block..=end_block).map(|b| b as u64).collect::>(); + let remaining = latest_block - end_block - state.await_blocks as i64; + let kind = if let Some(queue_behind_blocks) = state.queue_behind_blocks + && remaining > queue_behind_blocks as i64 + { + BlockPlanKind::Enqueue + } else { + BlockPlanKind::Parse + }; + + Some(BlockPlan { + range: BlockRange { blocks, end_block, remaining }, + kind, + }) +} + +#[cfg(test)] +mod tests { + use super::{BlockPlanKind, plan_next_block, should_reload_catchup, timeout_for_state}; + use chrono::Utc; + use primitives::Chain; + use std::time::Duration; + use storage::models::ParserStateRow; + use storage::sql_types::ChainRow; + + fn state(await_blocks: i32, parallel_blocks: i32, timeout_latest_block: i32, queue_behind_blocks: Option) -> ParserStateRow { + ParserStateRow { + chain: ChainRow::from(Chain::Ethereum), + current_block: 0, + latest_block: 0, + await_blocks, + timeout_between_blocks: 0, + timeout_latest_block, + parallel_blocks, + is_enabled: true, + updated_at: chrono::DateTime::from_timestamp(0, 0).unwrap().naive_utc(), + queue_behind_blocks, + block_time: 0, + } + } + + const MIN: Duration = Duration::from_secs(1); + const MAX: Duration = Duration::from_secs(8); + + #[test] + fn test_timeout_for_state_no_block_time() { + let s = state(1, 1, 500, None); + assert_eq!(timeout_for_state(&s, MIN, MAX), MIN); + assert_eq!(timeout_for_state(&s, Duration::from_millis(100), MAX), Duration::from_millis(500)); + } + + #[test] + fn test_timeout_for_state_uses_remaining_block_time() { + let mut s = state(1, 1, 0, None); + s.block_time = 12_000; + s.updated_at = Utc::now().naive_utc() - chrono::Duration::seconds(4); + let timeout = timeout_for_state(&s, MIN, MAX); + assert!(timeout >= Duration::from_secs(7) && timeout <= Duration::from_secs(9)); + } + + #[test] + fn test_timeout_for_state_caps_at_max() { + let mut s = state(1, 1, 0, None); + s.block_time = 600_000; + s.updated_at = Utc::now().naive_utc(); + assert_eq!(timeout_for_state(&s, MIN, MAX), MAX); + } + + #[test] + fn test_timeout_for_state_overdue_block() { + let mut s = state(1, 1, 0, None); + s.block_time = 10_000; + s.updated_at = Utc::now().naive_utc() - chrono::Duration::seconds(15); + assert_eq!(timeout_for_state(&s, MIN, MAX), MIN); + } + + #[test] + fn test_should_reload_catchup_respects_interval() { + assert!(!should_reload_catchup(10, 0)); + assert!(should_reload_catchup(10, 5)); + assert!(!should_reload_catchup(11, 5)); + } + + #[test] + fn test_plan_next_block_returns_none_when_no_blocks() { + let state = state(5, 3, 0, None); + let plan = plan_next_block(&state, 10, 12); + assert!(plan.is_none()); + } + + #[test] + fn test_plan_next_block_builds_expected_blocks() { + let state = state(1, 3, 0, None); + let plan = plan_next_block(&state, 5, 10).unwrap(); + assert_eq!(plan.range.blocks, vec![6, 7, 8]); + assert_eq!(plan.range.end_block, 8); + assert_eq!(plan.range.remaining, 1); + if let BlockPlanKind::Parse = plan.kind { + } else { + panic!("expected parse plan"); + } + } + + #[test] + fn test_plan_next_block_enqueues_when_behind() { + let state = state(1, 3, 0, Some(2)); + let plan = plan_next_block(&state, 5, 20).unwrap(); + if let BlockPlanKind::Enqueue = plan.kind { + } else { + panic!("expected enqueue plan"); + } + } +} diff --git a/core/apps/daemon/src/pusher/mod.rs b/core/apps/daemon/src/pusher/mod.rs new file mode 100644 index 0000000000..0966cf6005 --- /dev/null +++ b/core/apps/daemon/src/pusher/mod.rs @@ -0,0 +1,3 @@ +#[allow(clippy::module_inception)] +mod pusher; +pub use pusher::Pusher; diff --git a/core/apps/daemon/src/pusher/pusher.rs b/core/apps/daemon/src/pusher/pusher.rs new file mode 100644 index 0000000000..417a69b05c --- /dev/null +++ b/core/apps/daemon/src/pusher/pusher.rs @@ -0,0 +1,195 @@ +use std::error::Error; + +use localizer::LanguageLocalizer; +use number_formatter::{ValueFormatter, ValueStyle}; +use primitives::{ + AddressFormatStyle, AddressFormatter, Asset, AssetVecExt, Chain, DeviceSubscription, FiatQuoteType, GorushNotification, PushNotification, PushNotificationTransaction, + PushNotificationTypes, Transaction, TransactionNFTTransferMetadata, TransactionPerpetualMetadata, TransactionSwapMetadata, TransactionType, +}; +use storage::{Database, ScanAddressesRepository}; + +use api_connector::pusher::model::Message; + +pub struct Pusher { + database: Database, +} + +fn format_currency(value: f64) -> String { + format!("${}", ValueFormatter::format_f64(ValueStyle::Auto, value.abs())) +} + +impl Pusher { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub fn get_address(&self, chain: Chain, address: &str) -> Result> { + let result = self.database.scan_addresses()?.get_scan_address(chain, address); + match result { + Ok(address) => Ok(address.name.unwrap_or_default()), + Err(_) => Ok(AddressFormatter::format(address, Some(chain), AddressFormatStyle::Short)), + } + } + + pub fn fiat_transaction_message( + localizer: &LanguageLocalizer, + quote_type: &FiatQuoteType, + provider_name: &str, + asset: &Asset, + crypto_value: &str, + ) -> Result> { + let crypto_amount = ValueFormatter::format_with_symbol(ValueStyle::Auto, crypto_value, asset.decimals, &asset.symbol)?; + let title = match quote_type { + FiatQuoteType::Buy => localizer.notification_fiat_purchase_title(&crypto_amount), + FiatQuoteType::Sell => localizer.notification_fiat_sale_title(&crypto_amount), + }; + Ok(Message { + title, + message: Some(localizer.notification_received_description(provider_name)), + }) + } + + pub fn message(&self, localizer: LanguageLocalizer, transaction: &Transaction, address: &str, assets: &Vec) -> Result> { + let asset = assets.asset_result(transaction.asset_id.clone())?; + let amount = ValueFormatter::format_with_symbol(ValueStyle::Auto, transaction.value.as_str(), asset.decimals, &asset.symbol)?; + let chain = transaction.asset_id.chain; + + let to_address = self.get_address(chain, transaction.to.as_str())?; + let from_address = self.get_address(chain, transaction.from.as_str())?; + + match transaction.transaction_type { + TransactionType::Transfer | TransactionType::SmartContractCall => { + let is_sent = transaction.is_sent(address.to_string()); + let title = localizer.notification_transfer_title(is_sent, &amount); + let message = localizer.notification_transfer_description(is_sent, to_address.as_str(), from_address.as_str()); + Ok(Message { title, message: Some(message) }) + } + TransactionType::TransferNFT => { + let metadata = transaction.metadata.clone().ok_or("Missing metadata")?; + let metadata: TransactionNFTTransferMetadata = serde_json::from_value(metadata)?; + let nft_asset_id = metadata.asset_id; + let name = if let Some(name) = metadata.name { + name + } else if nft_asset_id.token_id.len() < 6 { + format!("#{}", nft_asset_id.token_id) + } else { + format!("#{}...", nft_asset_id.token_id.get(..6).unwrap_or(&nft_asset_id.token_id)) + }; + let is_sent = transaction.is_sent(address.to_string()); + let title = localizer.notification_nft_transfer_title(is_sent, &name); + let message = localizer.notification_transfer_description(is_sent, to_address.as_str(), from_address.as_str()); + Ok(Message { title, message: Some(message) }) + } + TransactionType::TokenApproval => Ok(Message { + title: localizer.notification_token_approval_title(asset.symbol.as_str()), + message: Some(localizer.notification_sent_description(&to_address)), + }), + TransactionType::StakeDelegate | TransactionType::EarnDeposit => Ok(Message { + title: localizer.notification_stake_title(&amount), + message: Some(localizer.notification_sent_description(&to_address)), + }), + TransactionType::StakeUndelegate => Ok(Message { + title: localizer.notification_unstake_title(&amount), + message: Some(localizer.notification_received_description(&to_address)), + }), + TransactionType::StakeRedelegate => Ok(Message { + title: localizer.notification_redelegate_title(&amount), + message: Some(localizer.notification_sent_description(&to_address)), + }), + TransactionType::StakeRewards => Ok(Message { + title: localizer.notification_claim_rewards_title(&amount), + message: None, + }), + TransactionType::StakeWithdraw | TransactionType::EarnWithdraw => Ok(Message { + title: localizer.notification_withdraw_title(&amount), + message: Some(localizer.notification_received_description(&to_address)), + }), + TransactionType::Swap => { + let metadata = transaction.metadata.clone().ok_or("Missing metadata")?; + let metadata: TransactionSwapMetadata = serde_json::from_value(metadata)?; + let from_asset = assets.asset_result(metadata.from_asset.clone())?; + let to_asset = assets.asset_result(metadata.to_asset.clone())?; + let from_amount = ValueFormatter::format_with_symbol(ValueStyle::Auto, &metadata.from_value, from_asset.decimals, &from_asset.symbol)?; + let to_amount = ValueFormatter::format_with_symbol(ValueStyle::Auto, &metadata.to_value, to_asset.decimals, &to_asset.symbol)?; + + Ok(Message { + title: localizer.notification_swap_title(from_asset.symbol.as_str(), to_asset.symbol.as_str()), + message: Some(localizer.notification_swap_description(&from_amount, &to_amount)), + }) + } + TransactionType::PerpetualOpenPosition | TransactionType::PerpetualClosePosition => { + let metadata: TransactionPerpetualMetadata = serde_json::from_value(transaction.metadata.clone().ok_or("Missing metadata")?)?; + let coin = &asset.symbol; + let price = ValueFormatter::format_f64_currency(ValueStyle::Auto, metadata.price, "$"); + let title = match metadata.direction { + primitives::PerpetualDirection::Long => localizer.notification_perpetual_long_title(coin), + primitives::PerpetualDirection::Short => localizer.notification_perpetual_short_title(coin), + }; + let description = if transaction.transaction_type == TransactionType::PerpetualOpenPosition { + localizer.notification_perpetual_open_description(&price) + } else { + let pnl = format_currency(metadata.pnl); + if metadata.pnl >= 0.0 { + localizer.notification_perpetual_close_positive_description(&pnl) + } else { + localizer.notification_perpetual_close_negative_description(&pnl) + } + }; + Ok(Message { + title, + message: Some(description), + }) + } + TransactionType::AssetActivation | TransactionType::PerpetualModifyPosition => todo!(), + TransactionType::StakeFreeze => Ok(Message { + title: localizer.notification_freeze_title(&amount), + message: None, + }), + TransactionType::StakeUnfreeze => Ok(Message { + title: localizer.notification_unfreeze_title(&amount), + message: None, + }), + } + } + + pub async fn get_messages( + &self, + subscription: &DeviceSubscription, + transaction: Transaction, + assets: Vec, + ) -> Result, Box> { + let transaction = transaction.finalize(vec![subscription.address.clone()]).without_utxo(); + + let localizer = LanguageLocalizer::new_with_language(subscription.device.locale.as_str()); + let message = self.message(localizer, &transaction, &subscription.address, &assets)?; + + let notification_transaction = PushNotificationTransaction { + wallet_id: subscription.wallet_id.clone(), + transaction_id: transaction.id.to_string(), + transaction: transaction.clone(), + asset_id: transaction.asset_id.clone(), + }; + let data = PushNotification { + notification_type: PushNotificationTypes::Transaction, + data: serde_json::to_value(¬ification_transaction).ok(), + }; + + Ok( + GorushNotification::from_device(subscription.device.clone(), message.title, message.message.unwrap_or_default(), data) + .into_iter() + .collect(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_currency() { + assert_eq!(format_currency(5.50), "$5.50"); + assert_eq!(format_currency(-3.25), "$3.25"); + assert_eq!(format_currency(0.0), "$0"); + } +} diff --git a/core/apps/daemon/src/reporters/consumer.rs b/core/apps/daemon/src/reporters/consumer.rs new file mode 100644 index 0000000000..e1b7e7cc8d --- /dev/null +++ b/core/apps/daemon/src/reporters/consumer.rs @@ -0,0 +1,23 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use streamer::ConsumerStatusReporter; + +use crate::metrics::consumer::ConsumerMetrics; + +pub struct ConsumerReporter { + metrics: Arc, +} + +impl ConsumerReporter { + pub fn new(metrics: Arc) -> Self { + Self { metrics } + } +} + +#[async_trait] +impl ConsumerStatusReporter for ConsumerReporter { + async fn report_success(&self, name: &str, duration: u64, result: &str) { + self.metrics.record_success(name, duration, result); + } +} diff --git a/core/apps/daemon/src/reporters/job.rs b/core/apps/daemon/src/reporters/job.rs new file mode 100644 index 0000000000..bc9f2e6c03 --- /dev/null +++ b/core/apps/daemon/src/reporters/job.rs @@ -0,0 +1,23 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use job_runner::JobStatusReporter; + +use crate::metrics::job::JobMetrics; + +pub struct JobReporter { + metrics: Arc, +} + +impl JobReporter { + pub fn new(metrics: Arc) -> Self { + Self { metrics } + } +} + +#[async_trait] +impl JobStatusReporter for JobReporter { + async fn report(&self, name: &str, interval: u64, duration: u64, success: bool) { + self.metrics.report(name, interval, duration, success); + } +} diff --git a/core/apps/daemon/src/reporters/mod.rs b/core/apps/daemon/src/reporters/mod.rs new file mode 100644 index 0000000000..ebd3da2f2f --- /dev/null +++ b/core/apps/daemon/src/reporters/mod.rs @@ -0,0 +1,3 @@ +pub mod consumer; +pub mod job; +pub mod parser; diff --git a/core/apps/daemon/src/reporters/parser.rs b/core/apps/daemon/src/reporters/parser.rs new file mode 100644 index 0000000000..ce9a0d20e1 --- /dev/null +++ b/core/apps/daemon/src/reporters/parser.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use primitives::{Chain, Transaction}; + +use crate::metrics::parser::ParserMetrics; + +pub struct ParserReporter { + chain: Chain, + metrics: Arc, +} + +impl ParserReporter { + pub fn new(chain: Chain, metrics: Arc) -> Self { + Self { chain, metrics } + } + + pub fn update_state(&self, current_block: i64, latest_block: i64, is_enabled: bool) { + self.metrics.update_state(self.chain.as_ref(), current_block, latest_block, is_enabled); + } + + pub fn record_transactions(&self, transactions: &[Transaction]) { + let mut counts: HashMap = HashMap::new(); + for tx in transactions { + *counts.entry(tx.transaction_type.as_ref().to_string()).or_default() += 1; + } + let entries: Vec<_> = counts.into_iter().collect(); + self.metrics.record_transactions(self.chain.as_ref(), &entries); + } +} diff --git a/core/apps/daemon/src/setup/mod.rs b/core/apps/daemon/src/setup/mod.rs new file mode 100644 index 0000000000..e7af7be61e --- /dev/null +++ b/core/apps/daemon/src/setup/mod.rs @@ -0,0 +1,525 @@ +mod scan_addresses; + +use self::scan_addresses::setup_scan_addresses; +use chrono::Utc; +use gem_tracing::info_with_fields; +use primitives::{ + Asset, AssetId, AssetTag, Chain, ChartTimeframe, ConfigKey, ConfigParamKey, FiatProviderName, FiatQuoteType, FiatTransaction, FiatTransactionStatus, NFTChain, + NotificationType, PlatformStore as PrimitivePlatformStore, PriceAlert, PriceAlertDirection, PriceId, PriceProvider, WebhookKind, +}; +use search_index::{INDEX_CONFIGS, INDEX_PRIMARY_KEY, SearchIndexClient}; +use settings::Settings; +use std::collections::HashSet; +use storage::Database; +use storage::models::{ + ChartRow, ConfigRow, FiatAssetRow, FiatProviderCountryRow, FiatRateRow, NewFiatTransactionRow, NewWebhookEndpointRow, PriceAssetRow, UpdateDeviceRow, price::NewPriceRow, +}; +use storage::sql_types::{Platform, PlatformStore}; +use storage::{ + AssetsRepository, ChainsRepository, ChartsRepository, ConfigRepository, DevicesRepository, MigrationsRepository, NewNotificationRow, NewWalletRow, NotificationsRepository, + PriceAlertsRepository, PricesProvidersRepository, PricesRepository, ReleasesRepository, RewardsRepository, TagRepository, WalletSource, WalletType, WalletsRepository, + WebhooksRepository, +}; +use streamer::{ExchangeKind, ExchangeName, QueueName, StreamProducer, StreamProducerConfig}; + +pub async fn run_setup(settings: Settings) -> Result<(), Box> { + info_with_fields!("setup", step = "init"); + + let database: Database = Database::new(&settings.postgres.url, settings.postgres.pool); + + setup_database(&database)?; + setup_scan_addresses(&database)?; + setup_search_index(&settings).await?; + setup_queues(&settings).await?; + + info_with_fields!("setup", step = "complete"); + Ok(()) +} + +fn setup_database(database: &Database) -> Result<(), Box> { + database.migrations()?.run_migrations().unwrap(); + info_with_fields!("setup", step = "postgres migrations complete"); + + let chains = Chain::all(); + info_with_fields!("setup", step = "chains", chains = format!("{:?}", chains)); + + info_with_fields!("setup", step = "add chains"); + let _ = database.chains()?.add_chains(chains.clone()); + + info_with_fields!("setup", step = "parser state"); + for chain in chains.iter().copied() { + let _ = database.parser_state()?.add_parser_state(chain, chain.block_time() as i32); + } + + info_with_fields!("setup", step = "assets"); + let assets = chains.into_iter().map(|x| Asset::from_chain(x).as_basic_primitive()).collect::>(); + let _ = database.assets()?.add_assets(assets); + + info_with_fields!("setup", step = "fiat providers"); + let providers = FiatProviderName::all() + .into_iter() + .map(storage::models::FiatProviderRow::from_primitive) + .collect::>(); + let _ = database.fiat()?.add_fiat_providers(providers); + + info_with_fields!("setup", step = "webhook endpoints"); + let _ = database.webhooks()?.add_webhook_endpoints(webhook_endpoints()); + + info_with_fields!("setup", step = "releases"); + let releases = PrimitivePlatformStore::all() + .into_iter() + .map(|x| storage::models::ReleaseRow { + platform_store: x.into(), + version: "1.0.0".to_string(), + upgrade_required: false, + update_enabled: true, + }) + .collect::>(); + let _ = database.releases()?.add_releases(releases); + + info_with_fields!("setup", step = "assets tags"); + let assets_tags = AssetTag::all().into_iter().map(storage::models::TagRow::from_primitive).collect::>(); + let _ = database.tag()?.add_tags(assets_tags); + + info_with_fields!("setup", step = "prices providers"); + let providers = PriceProvider::all() + .into_iter() + .map(|p| storage::models::PriceProviderConfigRow::new(p, p == PriceProvider::primary())) + .collect::>(); + let _ = database.prices_providers()?.add_prices_providers(providers); + + info_with_fields!("setup", step = "config"); + let configs: Vec = ConfigKey::all().into_iter().map(ConfigRow::from_primitive).collect(); + let _ = database.client()?.add_config(configs); + + info_with_fields!("setup", step = "param config"); + let param_configs: Vec = ConfigParamKey::all().into_iter().map(ConfigRow::from_param).collect(); + let _ = database.client()?.add_config(param_configs); + + info_with_fields!("setup", step = "cleanup stale config keys"); + let valid: HashSet = ConfigKey::all() + .into_iter() + .map(|k| k.as_ref().to_string()) + .chain(ConfigParamKey::all().into_iter().map(|k| k.key())) + .collect(); + let stale: Vec = database.client()?.get_config_keys()?.into_iter().filter(|k| !valid.contains(k)).collect(); + if !stale.is_empty() { + info_with_fields!("setup", step = "delete stale config keys", count = stale.len(), keys = format!("{:?}", stale)); + let _ = database.client()?.delete_keys(stale); + } + + Ok(()) +} + +fn webhook_endpoints() -> Vec { + let endpoints = [(WebhookKind::Transactions, "dynode"), (WebhookKind::Support, "chatwoot")] + .into_iter() + .map(|(kind, sender)| NewWebhookEndpointRow::new(kind, sender)); + + let fiat = FiatProviderName::all() + .into_iter() + .map(|provider| NewWebhookEndpointRow::new(WebhookKind::Fiat, provider.id())); + + endpoints.chain(fiat).collect() +} + +async fn setup_search_index(settings: &Settings) -> Result<(), Box> { + info_with_fields!( + "setup", + step = "search index", + indexes = format!("{:?}", INDEX_CONFIGS.iter().map(|c| c.name).collect::>()) + ); + + let search_index_client = SearchIndexClient::new(&settings.meilisearch.url, settings.meilisearch.key.as_str()); + search_index_client.setup(INDEX_CONFIGS, INDEX_PRIMARY_KEY).await.unwrap(); + + Ok(()) +} + +async fn setup_queues(settings: &Settings) -> Result<(), Box> { + info_with_fields!("setup", step = "queues"); + + let chain_queues = QueueName::chain_queues(); + let non_chain_queues: Vec<_> = QueueName::all().into_iter().filter(|q| !chain_queues.contains(q)).collect(); + let exchanges = ExchangeName::all(); + let chains = Chain::all(); + + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, "setup", streamer::no_shutdown()).await.unwrap(); + let _ = stream_producer.declare_queues(non_chain_queues).await; + let _ = stream_producer.declare_exchanges(exchanges.clone()).await; + + info_with_fields!( + "setup", + step = "queue exchanges for chain-based consumers", + queues = format!("{:?}", chain_queues.iter().map(|q| q.to_string()).collect::>()), + chains = format!("{:?}", chains) + ); + + for queue in &chain_queues { + let exchange_name = format!("{}_exchange", queue); + let _ = stream_producer.declare_exchange(&exchange_name, ExchangeKind::Topic).await; + for chain in queue_supported_chains(queue, &chains) { + let _ = stream_producer.bind_queue_routing_key(queue.clone(), chain.as_ref()).await; + } + } + + for exchange in &exchanges { + let exchange_queues = exchange.queues(); + if exchange_queues.is_empty() { + continue; + } + info_with_fields!( + "setup", + step = "exchange bindings", + exchange = exchange.to_string(), + queues = format!("{:?}", exchange_queues.iter().map(|q| q.to_string()).collect::>()) + ); + for queue in &exchange_queues { + for chain in queue_supported_chains(queue, &chains) { + let queue_name = format!("{}.{}", queue, chain.as_ref()); + let _ = stream_producer.bind_queue(&queue_name, &exchange.to_string(), chain.as_ref()).await; + } + } + } + + Ok(()) +} + +fn queue_supported_chains(queue: &QueueName, all_chains: &[Chain]) -> Vec { + match queue { + QueueName::FetchNftAssociations => NFTChain::all().into_iter().map(Into::into).collect(), + _ => all_chains.to_vec(), + } +} + +pub async fn run_setup_dev(settings: Settings) -> Result<(), Box> { + info_with_fields!("setup_dev", step = "init"); + + let database: Database = Database::new(&settings.postgres.url, settings.postgres.pool); + + setup_dev_currency(&database)?; + setup_dev_devices(&database)?; + setup_dev_assets(&database)?; + + info_with_fields!("setup_dev", step = "complete"); + Ok(()) +} + +fn setup_dev_currency(database: &Database) -> Result<(), Box> { + info_with_fields!("setup_dev", step = "add currency"); + + let fiat_rate = FiatRateRow { + id: "USD".to_string(), + name: "US Dollar".to_string(), + rate: 1.0, + }; + + info_with_fields!("setup_dev", step = "add rate", currency = "USD"); + database.fiat()?.set_fiat_rates(vec![fiat_rate])?; + + Ok(()) +} + +fn setup_dev_devices(database: &Database) -> Result<(), Box> { + info_with_fields!("setup_dev", step = "add devices"); + + let ios_device_id = "0".repeat(64); + let android_device_id = "1".repeat(64); + + let ios_device = UpdateDeviceRow { + device_id: ios_device_id.clone(), + platform: Platform::IOS, + platform_store: PlatformStore::AppStore, + token: "test_token".to_string(), + locale: "en".to_string(), + currency: "USD".to_string(), + is_push_enabled: true, + is_price_alerts_enabled: true, + version: "1.0.0".to_string(), + subscriptions_version: 1, + os: "iOS 18".to_string(), + model: "iPhone 16".to_string(), + }; + + let android_device = UpdateDeviceRow { + device_id: android_device_id.clone(), + platform: Platform::Android, + platform_store: PlatformStore::GooglePlay, + token: "test_token_android".to_string(), + locale: "en".to_string(), + currency: "USD".to_string(), + is_push_enabled: true, + is_price_alerts_enabled: true, + version: "1.0.0".to_string(), + subscriptions_version: 1, + os: "Android 15".to_string(), + model: "Pixel 9".to_string(), + }; + + database.devices()?.add_device(ios_device)?; + info_with_fields!("setup_dev", step = "device added", device_id = ios_device_id.as_str()); + + database.devices()?.add_device(android_device)?; + info_with_fields!("setup_dev", step = "device added", device_id = android_device_id.as_str()); + + let ios_device_row_id = database.devices()?.get_device_row_id(&ios_device_id)?; + let android_device_row_id = database.devices()?.get_device_row_id(&android_device_id)?; + + let wallet_address = "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"; + + info_with_fields!("setup_dev", step = "add wallet"); + let wallet_identifier = format!("multicoin_{}", wallet_address); + let new_wallet = NewWalletRow { + identifier: wallet_identifier, + wallet_type: WalletType::Multicoin, + source: WalletSource::Create, + }; + let wallet = database.wallets()?.get_or_create_wallet(new_wallet)?; + info_with_fields!("setup_dev", step = "wallet added", wallet_id = wallet.id); + + info_with_fields!("setup_dev", step = "add wallet subscriptions"); + let solana_address = "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H"; + let subscriptions = vec![ + (wallet.id, Chain::Ethereum, wallet_address.to_string()), + (wallet.id, Chain::HyperCore, wallet_address.to_string()), + (wallet.id, Chain::Solana, solana_address.to_string()), + ]; + + let result = WalletsRepository::add_subscriptions(&mut database.wallets()?, ios_device_row_id, subscriptions.clone())?; + info_with_fields!("setup_dev", step = "ios wallet subscription added", count = result); + + let result = WalletsRepository::add_subscriptions(&mut database.wallets()?, android_device_row_id, subscriptions)?; + info_with_fields!("setup_dev", step = "android wallet subscription added", count = result); + + setup_dev_fiat_transactions(database, ios_device_row_id, wallet.id)?; + + info_with_fields!("setup_dev", step = "add rewards"); + let devices = database.wallets()?.get_devices_by_wallet_id(wallet.id)?; + if !devices.is_empty() { + let result = database.rewards()?.create_reward(wallet.id, "gemcoder"); + match result { + Ok((rewards, _)) => info_with_fields!("setup_dev", step = "rewards added", code = rewards.code.unwrap_or_default(), points = rewards.points), + Err(e) => info_with_fields!("setup_dev", step = "rewards skipped (may already exist)", error = e.to_string()), + } + } + + info_with_fields!("setup_dev", step = "add notifications"); + let notifications = vec![ + NewNotificationRow { + wallet_id: wallet.id, + asset_id: None, + notification_type: NotificationType::RewardsEnabled.into(), + metadata: None, + }, + NewNotificationRow { + wallet_id: wallet.id, + asset_id: None, + notification_type: NotificationType::ReferralJoined.into(), + metadata: Some(serde_json::json!({"username": "alice", "points": 100})), + }, + NewNotificationRow { + wallet_id: wallet.id, + asset_id: None, + notification_type: NotificationType::RewardsCodeDisabled.into(), + metadata: None, + }, + NewNotificationRow { + wallet_id: wallet.id, + asset_id: None, + notification_type: NotificationType::RewardsCreateUsername.into(), + metadata: Some(serde_json::json!({"points": 50})), + }, + NewNotificationRow { + wallet_id: wallet.id, + asset_id: None, + notification_type: NotificationType::RewardsInvite.into(), + metadata: Some(serde_json::json!({"username": "bob", "points": 200})), + }, + ]; + let result = database.notifications()?.create_notifications(notifications)?; + info_with_fields!("setup_dev", step = "notifications added", count = result); + + info_with_fields!("setup_dev", step = "add price alerts"); + let price_alerts = vec![ + PriceAlert::new_price(AssetId::from_chain(Chain::Ethereum), "USD".to_string(), 3000.0, PriceAlertDirection::Up), + PriceAlert::new_price(AssetId::from_chain(Chain::Bitcoin), "USD".to_string(), 50000.0, PriceAlertDirection::Down), + ]; + let result = database.price_alerts()?.add_price_alerts(&ios_device_id, price_alerts)?; + info_with_fields!("setup_dev", step = "price alerts added", count = result); + + Ok(()) +} + +fn setup_dev_fiat_transactions(database: &Database, device_id: i32, wallet_id: i32) -> Result<(), Box> { + info_with_fields!("setup_dev", step = "add fiat transactions"); + + let mock = || { + let now = Utc::now(); + + FiatTransaction { + id: "setup-dev-quote-moonpay-pending".to_string(), + asset_id: AssetId::from_chain(Chain::Ethereum), + transaction_type: FiatQuoteType::Buy, + provider: FiatProviderName::MoonPay, + provider_transaction_id: None, + status: FiatTransactionStatus::Pending, + country: Some("US".to_string()), + fiat_amount: 150.0, + fiat_currency: "USD".to_string(), + value: "75000000000000000".to_string(), + transaction_hash: None, + created_at: now, + updated_at: now, + } + }; + + let transactions = [ + FiatTransaction { + provider_transaction_id: None, + ..mock() + }, + FiatTransaction { + id: "setup-dev-quote-mercuryo-complete".to_string(), + provider: FiatProviderName::Mercuryo, + provider_transaction_id: Some("setup-dev-mercuryo-complete".to_string()), + status: FiatTransactionStatus::Complete, + fiat_amount: 320.5, + value: "160000000000000000".to_string(), + transaction_hash: Some("0xsetupdevcomplete".to_string()), + ..mock() + }, + FiatTransaction { + id: "setup-dev-quote-transak-failed".to_string(), + asset_id: AssetId::from_chain(Chain::Solana), + transaction_type: FiatQuoteType::Sell, + provider: FiatProviderName::Transak, + provider_transaction_id: Some("setup-dev-transak-failed".to_string()), + status: FiatTransactionStatus::Failed, + fiat_amount: 95.25, + value: "500000000".to_string(), + ..mock() + }, + ]; + + let evm_address_id = database.wallets()?.subscriptions_wallet_address_for_chain(device_id, wallet_id, Chain::Ethereum)?.id; + let solana_address_id = database.wallets()?.subscriptions_wallet_address_for_chain(device_id, wallet_id, Chain::Solana)?.id; + + let mut fiat = database.fiat()?; + let transaction_rows = vec![ + NewFiatTransactionRow::new(transactions[0].clone(), device_id, wallet_id, evm_address_id), + NewFiatTransactionRow::new(transactions[1].clone(), device_id, wallet_id, evm_address_id), + NewFiatTransactionRow::new(transactions[2].clone(), device_id, wallet_id, solana_address_id), + ]; + + let mut count = 0; + for row in transaction_rows { + count += fiat.add_fiat_transaction(row)?; + } + + info_with_fields!("setup_dev", step = "fiat transactions added", count = count); + Ok(()) +} + +fn setup_dev_assets(database: &Database) -> Result<(), Box> { + info_with_fields!("setup_dev", step = "add assets"); + + let assets = Chain::all().into_iter().map(|x| Asset::from_chain(x).as_basic_primitive()).collect::>(); + let _ = database.assets()?.add_assets(assets); + + info_with_fields!("setup_dev", step = "add fiat assets"); + + let bitcoin_asset_id = AssetId::from_chain(Chain::Bitcoin); + let ethereum_asset_id = AssetId::from_chain(Chain::Ethereum); + let smartchain_asset_id = AssetId::from_chain(Chain::SmartChain); + + let fiat_asset = |provider: FiatProviderName, code: &str, symbol: &str, network: &str, asset_id: &AssetId| FiatAssetRow { + id: format!("{}_{}", provider.id(), code).to_lowercase(), + asset_id: Some(asset_id.into()), + provider: provider.into(), + code: code.to_string(), + symbol: symbol.to_string(), + network: Some(network.to_string()), + token_id: None, + is_enabled: true, + is_enabled_by_provider: true, + is_buy_enabled: true, + is_sell_enabled: true, + buy_limits: None, + sell_limits: None, + unsupported_countries: None, + }; + + let fiat_assets = vec![ + fiat_asset(FiatProviderName::MoonPay, "eth", "ETH", "ethereum", ðereum_asset_id), + fiat_asset(FiatProviderName::Mercuryo, "ETH", "ETH", "ETHEREUM", ðereum_asset_id), + fiat_asset(FiatProviderName::MoonPay, "bnb_bsc", "BNB", "binance_smart_chain", &smartchain_asset_id), + fiat_asset(FiatProviderName::Mercuryo, "BNB", "BNB", "BINANCESMARTCHAIN", &smartchain_asset_id), + fiat_asset(FiatProviderName::Paybis, "ETH", "ETH", "ethereum", ðereum_asset_id), + ]; + + let result = database.fiat()?.add_fiat_assets(fiat_assets)?; + info_with_fields!("setup_dev", step = "fiat assets added", count = result); + + info_with_fields!("setup_dev", step = "add fiat provider countries"); + + let fiat_countries: Vec = FiatProviderName::all() + .into_iter() + .map(|provider| { + let id = provider.id(); + FiatProviderCountryRow { + id: format!("{}_us", id), + provider: provider.into(), + alpha2: "US".to_string(), + is_allowed: true, + } + }) + .collect(); + + let result = database.fiat()?.add_fiat_providers_countries(fiat_countries)?; + info_with_fields!("setup_dev", step = "fiat provider countries added", count = result); + + info_with_fields!("setup_dev", step = "add prices and charts"); + let now = chrono::Utc::now().naive_utc(); + let coins: Vec<(&str, &AssetId, f64)> = vec![ + (Chain::Bitcoin.as_ref(), &bitcoin_asset_id, 60000.0), + (Chain::Ethereum.as_ref(), ðereum_asset_id, 2000.0), + ]; + + let prices: Vec = coins + .iter() + .map(|(coin_id, _, base_price)| NewPriceRow::with_market_data(PriceProvider::primary(), coin_id.to_string(), None, Some(*base_price), None)) + .collect(); + + let price_assets: Vec = coins + .iter() + .map(|(coin_id, asset_id, _)| PriceAssetRow::new((*asset_id).clone(), PriceProvider::primary(), coin_id)) + .collect(); + + let result = database.prices()?.add_prices(prices)?; + info_with_fields!("setup_dev", step = "prices added", count = result); + + let result = database.prices()?.set_prices_assets(price_assets)?; + info_with_fields!("setup_dev", step = "prices_assets added", count = result); + + for (idx, (coin_id, _, base_price)) in coins.iter().enumerate() { + let seed = (idx + 1) as f64; + let gen_price = |i: f64, scale: f64| (base_price + ((i * 0.3 + seed * 7.0).sin() + (i * 0.07).cos()) * base_price * scale).max(base_price * 0.1); + let price_id = PriceId::id_for(PriceProvider::primary(), coin_id); + + let hourly: Vec = (0i64..720) + .map(|h| ChartRow::new(price_id.clone(), gen_price(h as f64, 0.1), now - chrono::Duration::hours(h))) + .collect(); + + let daily: Vec = (30i64..1825) + .map(|d| ChartRow::new(price_id.clone(), gen_price(d as f64, 0.15), now - chrono::Duration::days(d))) + .collect(); + + database.charts()?.add_charts(ChartTimeframe::Hourly, hourly)?; + database.charts()?.add_charts(ChartTimeframe::Daily, daily)?; + } + info_with_fields!("setup_dev", step = "charts added"); + + Ok(()) +} diff --git a/core/apps/daemon/src/setup/scan_addresses.rs b/core/apps/daemon/src/setup/scan_addresses.rs new file mode 100644 index 0000000000..8a7716eb6e --- /dev/null +++ b/core/apps/daemon/src/setup/scan_addresses.rs @@ -0,0 +1,86 @@ +use gem_evm::{ + across::deployment::AcrossDeployment, + uniswap::deployment::{ + get_uniswap_permit2_by_chain, + v3::{ + get_aerodrome_router_deployment_by_chain, get_oku_deployment_by_chain, get_pancakeswap_router_deployment_by_chain, get_uniswap_router_deployment_by_chain, + get_wagmi_router_deployment_by_chain, + }, + v4::get_uniswap_deployment_by_chain, + }, +}; +use gem_tracing::info_with_fields; +use primitives::{Chain, ScanAddress, SwapProvider}; +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use storage::{Database, ScanAddressesRepository}; + +pub fn setup_scan_addresses(database: &Database) -> Result<(), Box> { + let mut values = HashMap::new(); + + for chain in Chain::all() { + let uniswap_permit2 = get_uniswap_permit2_by_chain(&chain); + let uniswap_v3 = get_uniswap_router_deployment_by_chain(&chain); + let uniswap_v4 = get_uniswap_deployment_by_chain(&chain); + let pancakeswap = get_pancakeswap_router_deployment_by_chain(&chain); + let oku = get_oku_deployment_by_chain(&chain); + let wagmi = get_wagmi_router_deployment_by_chain(&chain); + let aerodrome = get_aerodrome_router_deployment_by_chain(&chain); + + if let Some(address) = uniswap_permit2 { + values + .entry((chain, address.to_string())) + .or_insert_with(|| ScanAddress::contract(chain, address, SwapProvider::UniswapV3.name())); + } + + for (provider, address) in [ + (SwapProvider::UniswapV3, uniswap_v3.as_ref().map(|deployment| deployment.universal_router)), + (SwapProvider::UniswapV4, uniswap_v4.as_ref().map(|deployment| deployment.universal_router)), + (SwapProvider::PancakeswapV3, pancakeswap.as_ref().map(|deployment| deployment.universal_router)), + (SwapProvider::Oku, oku.as_ref().map(|deployment| deployment.universal_router)), + (SwapProvider::Wagmi, wagmi.as_ref().map(|deployment| deployment.universal_router)), + (SwapProvider::Aerodrome, aerodrome.as_ref().map(|deployment| deployment.universal_router)), + ] { + if let Some(address) = address { + values + .entry((chain, address.to_string())) + .or_insert_with(|| ScanAddress::contract(chain, address, provider.name())); + } + } + + for (provider, address) in [ + (SwapProvider::PancakeswapV3, pancakeswap.as_ref().map(|deployment| deployment.permit2)), + (SwapProvider::Oku, oku.as_ref().map(|deployment| deployment.permit2)), + (SwapProvider::Wagmi, wagmi.as_ref().map(|deployment| deployment.permit2)), + ] { + if let Some(address) = address { + values + .entry((chain, address.to_string())) + .or_insert_with(|| ScanAddress::contract(chain, address, provider.name())); + } + } + + if let Some(deployment) = AcrossDeployment::deployment_by_chain(&chain) { + values + .entry((chain, deployment.spoke_pool.to_string())) + .or_insert_with(|| ScanAddress::contract(chain, deployment.spoke_pool, SwapProvider::Across.name())); + } + } + + let count = values.len(); + let addresses = values.keys().map(|(_, address)| address.clone()).collect(); + let existing = database + .scan_addresses()? + .get_scan_addresses_by_addresses(addresses)? + .into_iter() + .map(|row| (row.chain.0, row.address)) + .collect::>(); + let values = values + .into_iter() + .filter_map(|(key, value)| (!existing.contains(&key)).then_some(value)) + .collect::>(); + let inserted = if values.is_empty() { 0 } else { database.scan_addresses()?.add_scan_addresses(values)? }; + + info_with_fields!("setup", step = "scan addresses", count = count, inserted = inserted); + Ok(()) +} diff --git a/core/apps/daemon/src/shutdown.rs b/core/apps/daemon/src/shutdown.rs new file mode 100644 index 0000000000..e36ebabc9b --- /dev/null +++ b/core/apps/daemon/src/shutdown.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; +use std::time::Duration; + +use gem_tracing::info_with_fields; +use tokio::sync::watch; + +pub use job_runner::sleep_or_shutdown; + +pub type ShutdownSender = Arc>; +pub type ShutdownReceiver = watch::Receiver; + +pub fn channel() -> (ShutdownSender, ShutdownReceiver) { + let (tx, rx) = watch::channel(false); + (Arc::new(tx), rx) +} + +pub fn spawn_signal_handler(shutdown_tx: ShutdownSender) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let signal = wait_for_signal().await; + info_with_fields!("shutdown signal received", signal = signal, status = "ok"); + let _ = shutdown_tx.send(true); + }) +} + +pub async fn wait_with_timeout(handles: Vec>, timeout: Duration) -> bool { + tokio::time::timeout(timeout, futures::future::join_all(handles)).await.is_ok() +} + +async fn wait_for_signal() -> &'static str { + let ctrl_c = tokio::signal::ctrl_c(); + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => "SIGINT", + _ = terminate => "SIGTERM", + } +} diff --git a/core/apps/daemon/src/worker/alerter/mod.rs b/core/apps/daemon/src/worker/alerter/mod.rs new file mode 100644 index 0000000000..71c19dbe63 --- /dev/null +++ b/core/apps/daemon/src/worker/alerter/mod.rs @@ -0,0 +1,66 @@ +mod price_alerts_sender; +mod staking_rewards_notifier; + +use std::error::Error; +use std::sync::Arc; + +use cacher::CacherClient; +use job_runner::{JobHandle, ShutdownReceiver}; +use price_alerts_sender::PriceAlertSender; +use pricer::PriceAlertClient; +use primitives::{Chain, ConfigKey}; +use settings::service_user_agent; +use settings_chain::ChainProviders; +use staking_rewards_notifier::{StakeRewardsConfig, StakingRewardsNotifier}; +use storage::ConfigCacher; +use streamer::{StreamProducer, StreamProducerConfig}; + +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::WorkerJob; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let config = ConfigCacher::new(database.clone()); + let cacher = CacherClient::new(&settings.redis.url).await; + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, "send_price_alerts", shutdown_rx.clone()).await?; + let stake_rewards_config = StakeRewardsConfig { + threshold: config.get_f64(ConfigKey::AlerterStakeRewardsThreshold)?, + lookback: config.get_duration(ConfigKey::AlerterStakeRewardsLookback)?, + }; + let chain_providers = Arc::new(ChainProviders::from_settings(&settings, &service_user_agent("daemon", Some("stake_rewards")))); + + ctx.plan_builder(WorkerService::Alerter, &config, shutdown_rx) + .job(WorkerJob::AlertPriceAlerts, { + let database = database.clone(); + let stream_producer = stream_producer.clone(); + move |_| { + let database = database.clone(); + let stream_producer = stream_producer.clone(); + async move { + let price_alert_client = PriceAlertClient::new(database.clone()); + PriceAlertSender::new(database, price_alert_client, stream_producer).run_observer().await + } + } + }) + .jobs(WorkerJob::AlertStakeRewards, Chain::stakeable(), |chain, _| { + let chain_providers = chain_providers.clone(); + let database = database.clone(); + let cacher = cacher.clone(); + let stream_producer = stream_producer.clone(); + move |_| { + let chain_providers = chain_providers.clone(); + let database = database.clone(); + let cacher = cacher.clone(); + let stream_producer = stream_producer.clone(); + async move { + let notifier = StakingRewardsNotifier::new(chain_providers, database, stake_rewards_config, cacher, stream_producer); + notifier.check_chain(chain).await + } + } + }) + .finish() +} diff --git a/core/apps/daemon/src/worker/alerter/price_alerts_sender.rs b/core/apps/daemon/src/worker/alerter/price_alerts_sender.rs new file mode 100644 index 0000000000..19760192a3 --- /dev/null +++ b/core/apps/daemon/src/worker/alerter/price_alerts_sender.rs @@ -0,0 +1,44 @@ +use pricer::PriceAlertClient; +use pricer::price_alert_client::PriceAlertRules; +use primitives::ConfigKey; +use storage::{ConfigCacher, Database}; +use streamer::{NotificationsPayload, StreamProducer, StreamProducerQueue}; + +pub struct PriceAlertSender { + config: ConfigCacher, + price_alert_client: PriceAlertClient, + stream_producer: StreamProducer, +} + +impl PriceAlertSender { + pub fn new(database: Database, price_alert_client: PriceAlertClient, stream_producer: StreamProducer) -> Self { + let config = ConfigCacher::new(database); + Self { + config, + price_alert_client, + stream_producer, + } + } + + pub async fn run_observer(&self) -> Result> { + let notification_cooldown = self.config.get_duration(ConfigKey::AlerterPriceAlertsCooldown)?; + let price_change_threshold = self.config.get_f64(ConfigKey::AlerterPriceAlertsThreshold)?; + let rank_divisor = self.config.get_f64(ConfigKey::AlerterPriceAlertsRankDivisor)?; + let milestones = self.config.get_vec::(ConfigKey::AlerterPriceAlertsMilestones)?; + let primary_price_max_age = self.config.get_duration(ConfigKey::PricePrimaryMaxAge)?; + + let rules = PriceAlertRules { + notification_cooldown, + price_change_threshold, + rank_divisor, + milestones, + }; + + let price_alert_notifications = self.price_alert_client.get_devices_to_alert(rules, primary_price_max_age).await?; + let notifications = self.price_alert_client.get_notifications_for_price_alerts(price_alert_notifications); + self.stream_producer + .publish_notifications_price_alerts(NotificationsPayload::new(notifications.clone())) + .await?; + Ok(notifications.len()) + } +} diff --git a/core/apps/daemon/src/worker/alerter/staking_rewards_notifier.rs b/core/apps/daemon/src/worker/alerter/staking_rewards_notifier.rs new file mode 100644 index 0000000000..9d55b7b8c8 --- /dev/null +++ b/core/apps/daemon/src/worker/alerter/staking_rewards_notifier.rs @@ -0,0 +1,99 @@ +use std::error::Error; +use std::sync::Arc; +use std::time::Duration; + +use cacher::CacheKey; +use cacher::CacherClient; +use gem_tracing::info_with_fields; +use localizer::LanguageLocalizer; +use num_bigint::BigUint; +use number_formatter::{BigNumberFormatter, ValueFormatter, ValueStyle}; +use primitives::{Asset, Chain, DelegationBase, DeviceSubscription, GorushNotification, PushNotification, TransactionType}; +use settings_chain::ChainProviders; +use storage::{Database, TransactionsRepository, WalletsRepository}; +use streamer::{NotificationsPayload, StreamProducer, StreamProducerQueue}; + +#[derive(Clone, Copy)] +pub struct StakeRewardsConfig { + pub threshold: f64, + pub lookback: Duration, +} + +pub struct StakingRewardsNotifier { + chain_providers: Arc, + database: Database, + config: StakeRewardsConfig, + cacher: CacherClient, + stream_producer: StreamProducer, +} + +impl StakingRewardsNotifier { + pub fn new(chain_providers: Arc, database: Database, config: StakeRewardsConfig, cacher: CacherClient, stream_producer: StreamProducer) -> Self { + Self { + chain_providers, + database, + config, + cacher, + stream_producer, + } + } + + pub async fn check_chain(&self, chain: Chain) -> Result> { + let since = chrono::Utc::now().naive_utc() - chrono::Duration::from_std(self.config.lookback)?; + let kinds = TransactionType::staking_types().into_iter().map(Into::into).collect(); + let addresses = self.database.transactions()?.get_addresses_by_chain_and_kind(chain.as_ref(), kinds, since)?; + + let mut notified = 0; + for address in &addresses { + match self.process_address(chain, address).await { + Ok(true) => notified += 1, + Ok(false) => {} + Err(e) => { + gem_tracing::error("staking rewards notifier", e.as_ref()); + } + } + } + + info_with_fields!("staking rewards notifier", chain = chain.as_ref(), addresses = addresses.len(), notified = notified); + Ok(notified) + } + + async fn process_address(&self, chain: Chain, address: &str) -> Result> { + let subscriptions = self.database.wallets()?.get_subscriptions_by_chain_addresses(chain, vec![address.to_string()])?; + if subscriptions.is_empty() { + return Ok(false); + } + + if !self.cacher.can_process_cached(CacheKey::AlerterStakeRewards(chain.as_ref(), address)).await? { + return Ok(false); + } + + let delegations = self.chain_providers.get_staking_delegations(chain, address.to_string()).await?; + + let total_staked = DelegationBase::total_active_balance(&delegations); + let total_rewards = DelegationBase::total_active_rewards(&delegations); + if total_staked == BigUint::from(0u32) || total_rewards == BigUint::from(0u32) { + return Ok(false); + } + + if BigNumberFormatter::ratio(&total_rewards, &total_staked) < self.config.threshold { + return Ok(false); + } + + let asset = Asset::from_chain(chain); + let rewards_value = ValueFormatter::format(ValueStyle::Auto, &total_rewards.to_string(), asset.decimals)?; + + let notifications: Vec<_> = subscriptions.into_iter().filter_map(|sub| Self::create_notification(sub, &rewards_value, &asset)).collect(); + + self.stream_producer.publish_notifications_observers(NotificationsPayload::new(notifications)).await?; + + Ok(true) + } + + fn create_notification(sub: DeviceSubscription, rewards_value: &str, asset: &Asset) -> Option { + let localizer = LanguageLocalizer::new_with_language(&sub.device.locale); + let notification = localizer.notification_stake_rewards(rewards_value, &asset.name); + let push = PushNotification::new_stake(sub.wallet_id, asset.id.clone()); + GorushNotification::from_device(sub.device, notification.title, notification.description, push) + } +} diff --git a/core/apps/daemon/src/worker/assets/asset_rank_updater.rs b/core/apps/daemon/src/worker/assets/asset_rank_updater.rs new file mode 100644 index 0000000000..97a67fe15e --- /dev/null +++ b/core/apps/daemon/src/worker/assets/asset_rank_updater.rs @@ -0,0 +1,59 @@ +use primitives::{AssetId, asset_score::AssetRank}; +use std::error::Error; +use storage::{AssetUpdate, AssetsRepository, Database}; + +pub struct AssetRankUpdater { + database: Database, +} + +struct SuspiciousAsset { + name: String, + symbol: String, +} + +impl SuspiciousAsset { + fn new(name: String, symbol: String) -> Self { + SuspiciousAsset { name, symbol } + } +} + +impl AssetRankUpdater { + pub fn new(database: Database) -> Self { + AssetRankUpdater { database } + } + + pub async fn update_suspicious_assets(&self) -> Result> { + let assets = self.database.assets()?.get_assets_all()?; + let asset_ids: Vec = assets + .into_iter() + .filter(|x| is_suspicious(x.score.rank, &x.asset.name, &x.asset.symbol)) + .map(|x| x.asset.id) + .collect(); + + let updates = vec![AssetUpdate::Rank(AssetRank::Fraudulent.threshold()), AssetUpdate::IsEnabled(false)]; + Ok(self.database.assets()?.update_assets(asset_ids, updates)?) + } +} + +fn is_suspicious(rank: i32, name: &str, symbol: &str) -> bool { + let suspicious_assets = [ + SuspiciousAsset::new("Tether".to_string(), "USDT".to_string()), + SuspiciousAsset::new("Tether USD".to_string(), "USDT".to_string()), + SuspiciousAsset::new("Tether USD".to_string(), "$USD₮".to_string()), + SuspiciousAsset::new("USD Coin".to_string(), "USDC".to_string()), + ]; + rank <= 15 && suspicious_assets.iter().any(|x| x.name == name && x.symbol == symbol) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_suspicious() { + assert!(is_suspicious(10, "Tether", "USDT")); + + assert!(!is_suspicious(25, "Tether", "USDT")); + assert!(!is_suspicious(10, "Bitcoin", "BTC")); + } +} diff --git a/core/apps/daemon/src/worker/assets/assets_has_price_updater.rs b/core/apps/daemon/src/worker/assets/assets_has_price_updater.rs new file mode 100644 index 0000000000..20fd7eb945 --- /dev/null +++ b/core/apps/daemon/src/worker/assets/assets_has_price_updater.rs @@ -0,0 +1,41 @@ +use primitives::AssetId; +use std::collections::HashSet; +use std::error::Error; +use storage::{AssetFilter, AssetUpdate, AssetsRepository, Database, PricesRepository}; + +pub struct AssetsHasPriceUpdater { + database: Database, +} + +impl AssetsHasPriceUpdater { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub async fn update(&self) -> Result<(usize, usize), Box> { + let eligible: HashSet = self.database.prices()?.get_prices_assets()?.into_iter().map(|a| a.asset_id.0).collect(); + + let current: HashSet = self + .database + .assets()? + .get_assets_by_filter(vec![AssetFilter::HasPrice(true)])? + .into_iter() + .map(|a| a.asset.id) + .collect(); + + let additions: Vec = eligible.difference(¤t).cloned().collect(); + let removals: Vec = current.difference(&eligible).cloned().collect(); + + let additions_len = additions.len(); + let removals_len = removals.len(); + + if !additions.is_empty() { + self.database.assets()?.update_assets(additions, vec![AssetUpdate::HasPrice(true)])?; + } + if !removals.is_empty() { + self.database.assets()?.update_assets(removals, vec![AssetUpdate::HasPrice(false)])?; + } + + Ok((additions_len, removals_len)) + } +} diff --git a/core/apps/daemon/src/worker/assets/assets_images_updater.rs b/core/apps/daemon/src/worker/assets/assets_images_updater.rs new file mode 100644 index 0000000000..70a6ab1310 --- /dev/null +++ b/core/apps/daemon/src/worker/assets/assets_images_updater.rs @@ -0,0 +1,45 @@ +use api_connector::StaticAssetsClient; +use primitives::{AssetId, Chain}; +use std::collections::HashSet; +use std::error::Error; +use storage::{AssetFilter, AssetUpdate, AssetsRepository, Database}; + +pub struct AssetsImagesUpdater { + client: StaticAssetsClient, + database: Database, +} + +impl AssetsImagesUpdater { + pub fn new(client: StaticAssetsClient, database: Database) -> Self { + Self { client, database } + } + + pub async fn update_chain(&self, chain: Chain) -> Result<(usize, usize), Box> { + let mut assets = self.client.get_assets_list(chain).await?; + assets.push(chain.as_asset_id()); + let new: HashSet = assets.into_iter().collect(); + + let current: HashSet = self + .database + .assets()? + .get_assets_by_filter(vec![AssetFilter::HasImage(true), AssetFilter::Chain(chain.as_ref().to_string())])? + .into_iter() + .map(|a| a.asset.id) + .collect(); + + let additions: Vec = new.difference(¤t).cloned().collect(); + let removals: Vec = current.difference(&new).cloned().collect(); + + let additions_len = additions.len(); + let removals_len = removals.len(); + + if !additions.is_empty() { + self.database.assets()?.update_assets(additions, vec![AssetUpdate::HasImage(true)])?; + } + if !removals.is_empty() { + self.database.assets()?.update_assets(removals, vec![AssetUpdate::HasImage(false)])?; + } + + Ok((additions_len, removals_len)) + } +} diff --git a/core/apps/daemon/src/worker/assets/mod.rs b/core/apps/daemon/src/worker/assets/mod.rs new file mode 100644 index 0000000000..454c7d2561 --- /dev/null +++ b/core/apps/daemon/src/worker/assets/mod.rs @@ -0,0 +1,116 @@ +mod asset_rank_updater; +mod assets_has_price_updater; +mod assets_images_updater; +mod perpetual_updater; +mod staking_apy_updater; +mod usage_rank_updater; +mod validator_scanner; + +use std::error::Error; +use std::sync::Arc; + +use api_connector::StaticAssetsClient; +use asset_rank_updater::AssetRankUpdater; +use assets_has_price_updater::AssetsHasPriceUpdater; +use assets_images_updater::AssetsImagesUpdater; +use job_runner::{JobHandle, ShutdownReceiver}; +use perpetual_updater::PerpetualUpdater; +use primitives::Chain; +use settings::service_user_agent; +use settings_chain::ChainProviders; +use staking_apy_updater::StakeApyUpdater; +use storage::ConfigCacher; +use usage_rank_updater::UsageRankUpdater; +use validator_scanner::ValidatorScanner; + +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::WorkerJob; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let config = ConfigCacher::new(database.clone()); + ctx.plan_builder(WorkerService::Assets, &config, shutdown_rx) + .job(WorkerJob::UpdateSuspiciousAssetRanks, { + let database = database.clone(); + move |_| { + let suspicious_updater = AssetRankUpdater::new(database.clone()); + async move { suspicious_updater.update_suspicious_assets().await } + } + }) + .jobs(WorkerJob::UpdatePerpetuals, PerpetualUpdater::chains(), |chain, _| { + let chain = *chain; + let settings = settings.clone(); + let database = database.clone(); + move |_| { + let settings = settings.clone(); + let database = database.clone(); + async move { + let updater = PerpetualUpdater::new((*settings.as_ref()).clone(), database.clone()); + updater.update_chain(chain).await + } + } + }) + .job(WorkerJob::UpdateUsageRanks, { + let database = database.clone(); + move |_| { + let updater = UsageRankUpdater::new(database.clone()); + async move { updater.update_usage_ranks().await } + } + }) + .jobs(WorkerJob::UpdateAssetsImages, Chain::all(), |chain, _| { + let static_assets_client = StaticAssetsClient::new(&settings.assets.url); + let database = database.clone(); + move |_| { + let updater = AssetsImagesUpdater::new(static_assets_client.clone(), database.clone()); + async move { updater.update_chain(chain).await } + } + }) + .job(WorkerJob::UpdateAssetsHasPrice, { + let database = database.clone(); + move |_| { + let updater = AssetsHasPriceUpdater::new(database.clone()); + async move { updater.update().await } + } + }) + .jobs(WorkerJob::UpdateStakeApy, Chain::stakeable(), { + let settings = settings.clone(); + let database = database.clone(); + move |chain, _| { + let providers = Arc::new(ChainProviders::for_chain(chain, &settings, &service_user_agent("daemon", Some("staking_apy")))); + let database = database.clone(); + move |_| { + let updater = StakeApyUpdater::new(providers.clone(), database.clone()); + async move { updater.update_chain(chain).await } + } + } + }) + .jobs(WorkerJob::UpdateChainValidators, Chain::stakeable(), { + let settings = settings.clone(); + let database = database.clone(); + move |chain, _| { + let providers = Arc::new(ChainProviders::for_chain(chain, &settings, &service_user_agent("daemon", Some("scan_validators")))); + let database = database.clone(); + move |_| { + let scanner = ValidatorScanner::new(providers.clone(), database.clone()); + async move { scanner.update_validators_for_chain(chain).await } + } + } + }) + .jobs(WorkerJob::UpdateValidatorsFromStaticAssets, [Chain::Tron, Chain::SmartChain], { + let settings = settings.clone(); + let database = database.clone(); + move |chain, _| { + let providers = Arc::new(ChainProviders::for_chain(chain, &settings, &service_user_agent("daemon", Some("scan_static_assets")))); + let assets_url = settings.assets.url.clone(); + let database = database.clone(); + move |_| { + let scanner = ValidatorScanner::new(providers.clone(), database.clone()); + let assets_url = assets_url.clone(); + async move { scanner.update_validators_from_static_assets_for_chain(chain, &assets_url).await } + } + } + }) + .finish() +} diff --git a/core/apps/daemon/src/worker/assets/perpetual_updater.rs b/core/apps/daemon/src/worker/assets/perpetual_updater.rs new file mode 100644 index 0000000000..66c53cd740 --- /dev/null +++ b/core/apps/daemon/src/worker/assets/perpetual_updater.rs @@ -0,0 +1,49 @@ +use std::error::Error; + +use gem_tracing::error_with_fields; +use primitives::{Chain, asset_score::AssetRank}; +use settings::{Settings, service_user_agent}; +use settings_chain::ProviderFactory; +use storage::models::NewPerpetualRow; +use storage::{AssetUpdate, AssetsRepository, Database, PerpetualsRepository}; + +pub struct PerpetualUpdater { + settings: Settings, + database: Database, +} + +impl PerpetualUpdater { + pub fn new(settings: Settings, database: Database) -> Self { + Self { settings, database } + } + + pub fn chains() -> &'static [Chain] { + &[Chain::HyperCore] + } + + pub async fn update_chain(&self, chain: Chain) -> Result> { + let provider = ProviderFactory::new_from_settings_with_user_agent(chain, &self.settings, &service_user_agent("daemon", Some("perpetual_updater"))); + let perpetuals_data = provider.get_perpetuals_data().await?; + + let assets = perpetuals_data.iter().map(|x| x.asset.clone()).collect::>(); + let asset_ids = assets.iter().map(|x| x.id.clone()).collect::>(); + let perpetuals = perpetuals_data.into_iter().map(|x| NewPerpetualRow::from_primitive(x.perpetual)).collect::>(); + + self.database.assets()?.upsert_assets(assets)?; + self.database.assets()?.update_assets( + asset_ids, + vec![ + AssetUpdate::Rank(AssetRank::Unknown.threshold()), + AssetUpdate::IsEnabled(false), + AssetUpdate::IsSwappable(false), + AssetUpdate::IsBuyable(false), + AssetUpdate::IsSellable(false), + ], + )?; + + if let Err(e) = self.database.perpetuals()?.perpetuals_update(perpetuals.clone()) { + error_with_fields!("failed perpetuals update", &e, chain = chain.as_ref()); + } + Ok(perpetuals.len()) + } +} diff --git a/core/apps/daemon/src/worker/assets/staking_apy_updater.rs b/core/apps/daemon/src/worker/assets/staking_apy_updater.rs new file mode 100644 index 0000000000..b84fbb10de --- /dev/null +++ b/core/apps/daemon/src/worker/assets/staking_apy_updater.rs @@ -0,0 +1,26 @@ +use std::error::Error; +use std::sync::Arc; + +use primitives::Chain; +use settings_chain::ChainProviders; +use storage::{AssetUpdate, AssetsRepository, Database}; + +pub struct StakeApyUpdater { + chain_providers: Arc, + database: Database, +} + +impl StakeApyUpdater { + pub fn new(chain_providers: Arc, database: Database) -> Self { + Self { chain_providers, database } + } + + pub async fn update_chain(&self, chain: Chain) -> Result> { + let apy = self.chain_providers.get_staking_apy(chain).await?; + let rounded = (apy * 100.0).round() / 100.0; + self.database + .assets()? + .update_assets(vec![chain.as_asset_id()], vec![AssetUpdate::StakingApr(Some(rounded))])?; + Ok(rounded) + } +} diff --git a/core/apps/daemon/src/worker/assets/usage_rank_updater.rs b/core/apps/daemon/src/worker/assets/usage_rank_updater.rs new file mode 100644 index 0000000000..551be8a20f --- /dev/null +++ b/core/apps/daemon/src/worker/assets/usage_rank_updater.rs @@ -0,0 +1,126 @@ +use primitives::AssetId; +use std::collections::HashMap; +use std::error::Error; +use storage::{AssetUsageRankRow, AssetsUsageRanksRepository, Database, TransactionsRepository}; + +pub struct UsageRankUpdater { + database: Database, +} + +impl UsageRankUpdater { + pub fn new(database: Database) -> Self { + UsageRankUpdater { database } + } + + pub async fn update_usage_ranks(&self) -> Result> { + let now = chrono::Utc::now().naive_utc(); + let thirty_days_ago = now - chrono::Duration::days(30); + + let counts_1h = self.database.transactions()?.get_asset_usage_counts(now - chrono::Duration::hours(1))?; + let counts_24h = self.database.transactions()?.get_asset_usage_counts(now - chrono::Duration::days(1))?; + let counts_7d = self.database.transactions()?.get_asset_usage_counts(now - chrono::Duration::days(7))?; + let counts_30d = self.database.transactions()?.get_asset_usage_counts(thirty_days_ago)?; + + let usage_ranks = calculate_usage_ranks(&counts_1h, &counts_24h, &counts_7d, &counts_30d); + let rows: Vec = usage_ranks + .into_iter() + .map(|(asset_id, usage_rank)| AssetUsageRankRow { + asset_id: asset_id.into(), + usage_rank, + }) + .collect(); + + self.database.assets_usage_ranks()?.delete_usage_ranks_before(thirty_days_ago)?; + Ok(self.database.assets_usage_ranks()?.upsert_usage_ranks(rows)?) + } +} + +fn calculate_usage_ranks(counts_1h: &[(AssetId, i64)], counts_24h: &[(AssetId, i64)], counts_7d: &[(AssetId, i64)], counts_30d: &[(AssetId, i64)]) -> Vec<(AssetId, i32)> { + let mut raw_scores: HashMap = HashMap::new(); + + for (asset_id, count) in counts_1h { + *raw_scores.entry(asset_id.clone()).or_insert(0) += count * 250; + } + for (asset_id, count) in counts_24h { + *raw_scores.entry(asset_id.clone()).or_insert(0) += count * 100; + } + for (asset_id, count) in counts_7d { + *raw_scores.entry(asset_id.clone()).or_insert(0) += count * 10; + } + for (asset_id, count) in counts_30d { + *raw_scores.entry(asset_id.clone()).or_insert(0) += count; + } + + if raw_scores.is_empty() { + return vec![]; + } + + let mut scores: Vec<(AssetId, i64)> = raw_scores.into_iter().collect(); + scores.sort_by_key(|a| a.1); + + let total = scores.len() as f64; + scores + .into_iter() + .enumerate() + .map(|(position, (asset_id, _))| { + let percentile = ((position as f64 + 1.0) / total * 100.0).round() as i32; + (asset_id, percentile.min(100)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::Chain; + + #[test] + fn test_calculate_usage_ranks_empty() { + let result = calculate_usage_ranks(&[], &[], &[], &[]); + assert!(result.is_empty()); + } + + #[test] + fn test_calculate_usage_ranks_single() { + let counts_1h = vec![(Chain::Bitcoin.as_asset_id(), 10)]; + let result = calculate_usage_ranks(&counts_1h, &[], &[], &[]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, Chain::Bitcoin.as_asset_id()); + assert_eq!(result[0].1, 100); + } + + #[test] + fn test_calculate_usage_ranks_multiple() { + let counts_1h = vec![(Chain::Bitcoin.as_asset_id(), 100), (Chain::Ethereum.as_asset_id(), 10), (Chain::Solana.as_asset_id(), 1)]; + let result = calculate_usage_ranks(&counts_1h, &[], &[], &[]); + assert_eq!(result.len(), 3); + + let asset1_rank = result.iter().find(|(id, _)| *id == Chain::Bitcoin.as_asset_id()).map(|(_, r)| *r).unwrap(); + let asset2_rank = result.iter().find(|(id, _)| *id == Chain::Ethereum.as_asset_id()).map(|(_, r)| *r).unwrap(); + let asset3_rank = result.iter().find(|(id, _)| *id == Chain::Solana.as_asset_id()).map(|(_, r)| *r).unwrap(); + + assert_eq!(asset3_rank, 33); + assert_eq!(asset2_rank, 67); + assert_eq!(asset1_rank, 100); + } + + #[test] + fn test_calculate_usage_ranks_weighted() { + let counts_1h = vec![(Chain::Bitcoin.as_asset_id(), 2)]; + let counts_24h = vec![(Chain::Ethereum.as_asset_id(), 25)]; + let counts_7d = vec![(Chain::Solana.as_asset_id(), 300)]; + let counts_30d = vec![(Chain::SmartChain.as_asset_id(), 4000)]; + let result = calculate_usage_ranks(&counts_1h, &counts_24h, &counts_7d, &counts_30d); + + let asset1_rank = result.iter().find(|(id, _)| *id == Chain::Bitcoin.as_asset_id()).map(|(_, r)| *r).unwrap(); + let asset2_rank = result.iter().find(|(id, _)| *id == Chain::Ethereum.as_asset_id()).map(|(_, r)| *r).unwrap(); + let asset3_rank = result.iter().find(|(id, _)| *id == Chain::Solana.as_asset_id()).map(|(_, r)| *r).unwrap(); + let asset4_rank = result.iter().find(|(id, _)| *id == Chain::SmartChain.as_asset_id()).map(|(_, r)| *r).unwrap(); + + // Scores: asset1=500, asset2=2500, asset3=3000, asset4=4000 + assert_eq!(asset1_rank, 25); + assert_eq!(asset2_rank, 50); + assert_eq!(asset3_rank, 75); + assert_eq!(asset4_rank, 100); + } +} diff --git a/core/apps/daemon/src/worker/assets/validator_scanner.rs b/core/apps/daemon/src/worker/assets/validator_scanner.rs new file mode 100644 index 0000000000..75351ae370 --- /dev/null +++ b/core/apps/daemon/src/worker/assets/validator_scanner.rs @@ -0,0 +1,35 @@ +use api_connector::StaticAssetsClient; +use primitives::{Chain, StakeValidator}; +use settings_chain::ChainProviders; +use std::error::Error; +use std::sync::Arc; +use storage::{Database, ScanAddressesRepository}; + +pub struct ValidatorScanner { + chain_providers: Arc, + database: Database, +} + +impl ValidatorScanner { + pub fn new(chain_providers: Arc, database: Database) -> Self { + Self { chain_providers, database } + } + + pub async fn update_validators_for_chain(&self, chain: Chain) -> Result> { + let validators = self.chain_providers.get_validators(chain).await?; + let addresses: Vec<_> = validators.into_iter().filter_map(|v| v.as_scan_address(chain)).collect(); + let count = addresses.len(); + self.database.scan_addresses()?.add_scan_addresses(addresses)?; + Ok(count) + } + + pub async fn update_validators_from_static_assets_for_chain(&self, chain: Chain, assets_url: &str) -> Result> { + let static_assets_client = StaticAssetsClient::new(assets_url); + let static_validators = static_assets_client.get_validators(chain).await?; + let validators: Vec<_> = static_validators.into_iter().map(|v| StakeValidator::new(v.id, v.name)).collect(); + let addresses: Vec<_> = validators.into_iter().filter_map(|v| v.as_scan_address(chain)).collect(); + let count = addresses.len(); + self.database.scan_addresses()?.add_scan_addresses(addresses)?; + Ok(count) + } +} diff --git a/core/apps/daemon/src/worker/context.rs b/core/apps/daemon/src/worker/context.rs new file mode 100644 index 0000000000..8c85ccdc9b --- /dev/null +++ b/core/apps/daemon/src/worker/context.rs @@ -0,0 +1,38 @@ +use crate::model::WorkerService; +use crate::shutdown::ShutdownReceiver; +use crate::worker::plan::JobPlanBuilder; +use crate::worker::runtime::WorkerRuntime; +use settings::Settings; +use std::sync::Arc; +use storage::{ConfigCacher, Database}; + +#[derive(Clone)] +pub struct WorkerContext { + settings: Arc, + database: Database, + runtime: WorkerRuntime, + job_filter: Option, +} + +impl WorkerContext { + pub fn new(settings: Arc, database: Database, runtime: WorkerRuntime, job_filter: Option) -> Self { + Self { + settings, + database, + runtime, + job_filter, + } + } + + pub fn settings(&self) -> Arc { + self.settings.clone() + } + + pub fn database(&self) -> Database { + self.database.clone() + } + + pub fn plan_builder<'a>(&self, worker: WorkerService, config: &'a ConfigCacher, shutdown_rx: ShutdownReceiver) -> JobPlanBuilder<'a> { + JobPlanBuilder::with_config(worker, self.runtime.plan(shutdown_rx), config).filter(self.job_filter.clone()) + } +} diff --git a/core/apps/daemon/src/worker/fiat/fiat_assets_updater.rs b/core/apps/daemon/src/worker/fiat/fiat_assets_updater.rs new file mode 100644 index 0000000000..7b75549da5 --- /dev/null +++ b/core/apps/daemon/src/worker/fiat/fiat_assets_updater.rs @@ -0,0 +1,160 @@ +use chrono::{Duration, Utc}; +use fiat::{FiatProvider, model::FiatProviderAsset}; +use gem_tracing::info_with_fields; +use primitives::{AssetId, AssetTag, Diff, FiatProviderName, currency::Currency}; +use storage::{AssetFilter, AssetUpdate, FiatAssetFilter, FiatAssetRowsExt}; +use storage::{AssetsRepository, Database, TagRepository}; + +#[derive(Clone, Copy)] +enum FiatAssetDirection { + Buy, + Sell, +} + +pub struct FiatAssetsUpdater { + database: Database, + providers: Vec>, +} + +impl FiatAssetsUpdater { + pub fn new(database: Database, providers: Vec>) -> Self { + Self { database, providers } + } + + pub async fn update_buyable_assets(&self) -> Result> { + let enabled_asset_ids = self.enabled_fiat_asset_ids(FiatAssetDirection::Buy)?; + let buyable_assets_ids = self + .database + .assets()? + .get_assets_by_filter(vec![AssetFilter::IsBuyable(true)])? + .into_iter() + .map(|x| x.asset.id) + .collect::>(); + let result = Diff::compare(buyable_assets_ids, enabled_asset_ids); + + self.database.assets()?.update_assets(result.missing.clone(), vec![AssetUpdate::IsBuyable(true)])?; + self.database.assets()?.update_assets(result.different.clone(), vec![AssetUpdate::IsBuyable(false)])?; + + Ok(result.missing.len() + result.different.len()) + } + + pub async fn update_sellable_assets(&self) -> Result> { + let enabled_asset_ids = self.enabled_fiat_asset_ids(FiatAssetDirection::Sell)?; + let sellable_assets_ids = self + .database + .assets()? + .get_assets_by_filter(vec![AssetFilter::IsSellable(true)])? + .into_iter() + .filter(|x| x.score.rank > 25) + .map(|x| x.asset.id) + .collect::>(); + + let result = Diff::compare(sellable_assets_ids, enabled_asset_ids); + self.database.assets()?.update_assets(result.missing.clone(), vec![AssetUpdate::IsSellable(true)])?; + self.database.assets()?.update_assets(result.different.clone(), vec![AssetUpdate::IsSellable(false)])?; + + Ok(result.missing.len() + result.different.len()) + } + + fn enabled_fiat_asset_ids(&self, direction: FiatAssetDirection) -> Result, Box> { + Ok(self.database.fiat()?.get_fiat_assets_by_filter(Self::fiat_asset_filters(direction))?.asset_ids()) + } + + fn fiat_asset_filters(direction: FiatAssetDirection) -> Vec { + [ + FiatAssetFilter::HasAssetId, + FiatAssetFilter::IsEnabled(true), + FiatAssetFilter::IsEnabledByProvider(true), + FiatAssetFilter::ProviderEnabled(true), + ] + .into_iter() + .chain(match direction { + FiatAssetDirection::Buy => [FiatAssetFilter::IsBuyEnabled(true), FiatAssetFilter::ProviderBuyEnabled(true)], + FiatAssetDirection::Sell => [FiatAssetFilter::IsSellEnabled(true), FiatAssetFilter::ProviderSellEnabled(true)], + }) + .collect() + } + + pub async fn update_trending_fiat_assets(&self) -> Result> { + let from = Utc::now() - Duration::days(30); + let mut fiat_client = self.database.fiat()?; + let asset_ids = fiat_client.fiat().get_fiat_assets_popular(from.naive_utc(), 30)?; + Ok(self.database.tag()?.set_assets_tags_for_tag(AssetTag::TrendingFiatPurchase.as_ref(), asset_ids)?) + } + + fn get_provider(&self, provider_name: FiatProviderName) -> Result<&(dyn FiatProvider + Send + Sync), Box> { + self.providers + .iter() + .find(|p| p.name() == provider_name) + .map(|p| p.as_ref()) + .ok_or_else(|| format!("Provider {} not found", provider_name.id()).into()) + } + + pub async fn update_fiat_assets_for(&self, provider_name: FiatProviderName) -> Result> { + let provider = self.get_provider(provider_name)?; + + let payment_methods = provider.payment_methods().await; + let payment_methods_json = serde_json::to_value(&payment_methods)?; + self.database.fiat()?.update_fiat_provider_payment_methods(provider_name, payment_methods_json)?; + + let assets = provider.get_assets().await?; + let asset_count = assets.len(); + + let validated_assets: Vec<(FiatProviderAsset, Option)> = assets + .into_iter() + .map(|fiat_asset| { + ( + fiat_asset.clone(), + fiat_asset + .asset_id() + .filter(|id| self.database.assets().ok().and_then(|mut c| c.get_asset(id).ok()).is_some()), + ) + }) + .collect(); + + let assets = validated_assets + .into_iter() + .map(|(fiat_asset, asset)| self.map_fiat_asset(fiat_asset, asset)) + .collect::>(); + + let insert_assets = assets + .into_iter() + .map(storage::models::FiatAssetRow::from_primitive) + .collect::, _>>()?; + + if !insert_assets.is_empty() { + self.database.fiat()?.add_fiat_assets(insert_assets)?; + } + + info_with_fields!("fiat update assets", provider = provider_name.id(), assets = asset_count); + + Ok(asset_count) + } + + pub async fn update_fiat_countries_for(&self, provider_name: FiatProviderName) -> Result> { + let provider = self.get_provider(provider_name)?; + let countries = provider.get_countries().await?; + let country_count = countries.len(); + let country_rows = countries.into_iter().map(storage::models::FiatProviderCountryRow::from_primitive).collect::>(); + self.database.fiat()?.add_fiat_providers_countries(country_rows)?; + info_with_fields!("fiat update countries", provider = provider_name.id(), countries = country_count); + Ok(country_count) + } + + fn map_fiat_asset(&self, fiat_asset: FiatProviderAsset, asset_id: Option) -> primitives::FiatAsset { + primitives::FiatAsset { + id: fiat_asset.id, + asset_id, + provider: fiat_asset.provider, + symbol: fiat_asset.symbol, + network: fiat_asset.network, + token_id: fiat_asset.token_id, + enabled: fiat_asset.enabled, + is_buy_enabled: fiat_asset.is_buy_enabled, + is_sell_enabled: fiat_asset.is_sell_enabled, + unsupported_countries: fiat_asset.unsupported_countries.unwrap_or_default(), + buy_limits: fiat_asset.buy_limits.into_iter().filter(|x| x.currency == Currency::USD).collect::>(), // stored usd only for now + sell_limits: fiat_asset.sell_limits.into_iter().filter(|x| x.currency == Currency::USD).collect::>(), // stored usd only for now + } + } +} diff --git a/core/apps/daemon/src/worker/fiat/fiat_rates_updater.rs b/core/apps/daemon/src/worker/fiat/fiat_rates_updater.rs new file mode 100644 index 0000000000..ce44c78103 --- /dev/null +++ b/core/apps/daemon/src/worker/fiat/fiat_rates_updater.rs @@ -0,0 +1,19 @@ +use coingecko::CoinGeckoClient; +use pricer::PriceClient; +use std::error::Error; + +pub struct FiatRatesUpdater { + client: CoinGeckoClient, + price_client: PriceClient, +} + +impl FiatRatesUpdater { + pub fn new(client: CoinGeckoClient, price_client: PriceClient) -> Self { + Self { client, price_client } + } + + pub async fn update(&self) -> Result> { + let rates = self.client.get_fiat_rates().await?; + self.price_client.set_fiat_rates(rates).await + } +} diff --git a/core/apps/daemon/src/worker/fiat/mod.rs b/core/apps/daemon/src/worker/fiat/mod.rs new file mode 100644 index 0000000000..b42d9fe95c --- /dev/null +++ b/core/apps/daemon/src/worker/fiat/mod.rs @@ -0,0 +1,97 @@ +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::WorkerJob; +use cacher::CacherClient; +use coingecko::CoinGeckoClient; +use fiat::FiatProviderFactory; +use fiat_assets_updater::FiatAssetsUpdater; +use fiat_rates_updater::FiatRatesUpdater; +use job_runner::{JobHandle, ShutdownReceiver}; +use pricer::PriceClient; +use primitives::FiatProviderName; +use std::error::Error; +use storage::ConfigCacher; + +mod fiat_assets_updater; +mod fiat_rates_updater; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let config = ConfigCacher::new(database.clone()); + + let cacher_client = CacherClient::new(&settings.redis.url).await; + + ctx.plan_builder(WorkerService::Fiat, &config, shutdown_rx) + .job(WorkerJob::UpdateFiatRates, { + let settings = settings.clone(); + let database = database.clone(); + let cacher_client = cacher_client.clone(); + move |_| { + let settings = settings.clone(); + let database = database.clone(); + let cacher_client = cacher_client.clone(); + async move { + let client = CoinGeckoClient::new(&settings.coingecko.key.secret); + let price_client = PriceClient::new(database, cacher_client); + FiatRatesUpdater::new(client, price_client).update().await + } + } + }) + .jobs(WorkerJob::UpdateFiatAssets, FiatProviderName::all(), |provider, _| { + let settings = settings.clone(); + let database = database.clone(); + move |_| { + let settings = settings.clone(); + let database = database.clone(); + let provider = provider; + async move { + let providers = FiatProviderFactory::new_providers((*settings).clone()); + let fiat_assets_updater = FiatAssetsUpdater::new(database.clone(), providers); + fiat_assets_updater.update_fiat_assets_for(provider).await + } + } + }) + .jobs(WorkerJob::UpdateFiatProviderCountries, FiatProviderName::all(), |provider, _| { + let settings = settings.clone(); + let database = database.clone(); + move |_| { + let settings = settings.clone(); + let database = database.clone(); + let provider = provider; + async move { + let providers = FiatProviderFactory::new_providers((*settings).clone()); + let fiat_assets_updater = FiatAssetsUpdater::new(database.clone(), providers); + fiat_assets_updater.update_fiat_countries_for(provider).await + } + } + }) + .job(WorkerJob::UpdateFiatBuyableAssets, { + let settings = settings.clone(); + let database = database.clone(); + move |_| { + let providers = FiatProviderFactory::new_providers((*settings).clone()); + let fiat_assets_updater = FiatAssetsUpdater::new(database.clone(), providers); + async move { fiat_assets_updater.update_buyable_assets().await } + } + }) + .job(WorkerJob::UpdateFiatSellableAssets, { + let settings = settings.clone(); + let database = database.clone(); + move |_| { + let providers = FiatProviderFactory::new_providers((*settings).clone()); + let fiat_assets_updater = FiatAssetsUpdater::new(database.clone(), providers); + async move { fiat_assets_updater.update_sellable_assets().await } + } + }) + .job(WorkerJob::UpdateTrendingFiatAssets, { + let settings = settings.clone(); + let database = database.clone(); + move |_| { + let providers = FiatProviderFactory::new_providers((*settings).clone()); + let fiat_assets_updater = FiatAssetsUpdater::new(database.clone(), providers); + async move { fiat_assets_updater.update_trending_fiat_assets().await } + } + }) + .finish() +} diff --git a/core/apps/daemon/src/worker/job_schedule.rs b/core/apps/daemon/src/worker/job_schedule.rs new file mode 100644 index 0000000000..6e76ff9eef --- /dev/null +++ b/core/apps/daemon/src/worker/job_schedule.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use cacher::CacherClient; +use job_runner::{JobContext, JobError, JobSchedule, RunDecision}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub struct CacherJobTracker { + cacher: CacherClient, + service: String, +} + +impl CacherJobTracker { + pub fn new(cacher: CacherClient, service: &str) -> Self { + Self { + cacher, + service: service.to_string(), + } + } + + fn job_key(&self, job_name: &str) -> String { + format!("{}:{}", self.service, job_name) + } + + async fn get_last_success(&self, job_name: &str) -> Option { + let key = self.job_key(job_name); + let cache_key = cacher::CacheKey::JobStatus(&key); + self.cacher.get_value(&cache_key.key()).await.ok() + } +} + +#[async_trait] +impl JobSchedule for CacherJobTracker { + async fn evaluate(&self, job_name: &str, interval: Duration, now: SystemTime) -> Result { + let last_success_at = self.get_last_success(job_name).await; + let ctx = JobContext { last_success_at }; + + if let Some(last_success) = last_success_at { + let last_success_time = UNIX_EPOCH + Duration::from_secs(last_success); + let elapsed = now.duration_since(last_success_time).unwrap_or_default(); + if elapsed < interval { + return Ok(RunDecision::Wait(interval - elapsed)); + } + } + Ok(RunDecision::Run(ctx)) + } + + async fn mark_success(&self, job_name: &str, timestamp: SystemTime) -> Result<(), JobError> { + let seconds = timestamp.duration_since(UNIX_EPOCH).map_err(|err| Box::new(err) as JobError)?.as_secs(); + let key = self.job_key(job_name); + let cache_key = cacher::CacheKey::JobStatus(&key); + self.cacher.set_cached(cache_key, &seconds).await + } +} diff --git a/core/apps/daemon/src/worker/jobs.rs b/core/apps/daemon/src/worker/jobs.rs new file mode 100644 index 0000000000..34cff3dacc --- /dev/null +++ b/core/apps/daemon/src/worker/jobs.rs @@ -0,0 +1,275 @@ +use crate::model::WorkerService; +use primitives::{Chain, ConfigKey, ConfigParamKey, FiatProviderName, PlatformStore, PriceProvider}; +use std::error::Error; +use std::time::Duration; +use storage::ConfigCacher; +use strum::AsRefStr; + +#[derive(Clone, Debug)] +enum JobInterval { + Config(ConfigKey), +} + +impl JobInterval { + fn resolve(self, config: Option<&ConfigCacher>) -> Result> { + match self { + JobInterval::Config(key) => { + let cfg = config.ok_or_else(|| format!("ConfigCacher required for {:?}", key))?; + Ok(cfg.get_duration(key)?) + } + } + } +} + +#[derive(Clone, Debug)] +struct JobSpec { + worker: WorkerService, + interval: JobInterval, +} + +impl JobSpec { + const fn new(worker: WorkerService, interval: JobInterval) -> Self { + Self { worker, interval } + } +} + +pub trait JobLabel { + fn job_label(&self) -> String; +} + +impl JobLabel for str { + fn job_label(&self) -> String { + self.to_string() + } +} + +impl JobLabel for String { + fn job_label(&self) -> String { + self.clone() + } +} + +impl JobLabel for &T +where + T: JobLabel + ?Sized, +{ + fn job_label(&self) -> String { + (*self).job_label() + } +} + +impl JobLabel for Chain { + fn job_label(&self) -> String { + self.as_ref().to_string() + } +} + +impl JobLabel for FiatProviderName { + fn job_label(&self) -> String { + self.as_ref().to_string() + } +} + +impl JobLabel for PlatformStore { + fn job_label(&self) -> String { + self.as_ref().to_string() + } +} + +impl JobLabel for primitives::SwapProvider { + fn job_label(&self) -> String { + self.as_ref().to_string() + } +} + +impl JobLabel for PriceProvider { + fn job_label(&self) -> String { + self.id().to_string() + } +} + +fn compose_job_name(base: &str, label: Option<&str>) -> String { + match label.map(str::trim).filter(|value| !value.is_empty()) { + Some(suffix) => format!("{base}.{suffix}"), + None => base.to_string(), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, AsRefStr)] +#[strum(serialize_all = "snake_case")] +pub enum WorkerJob { + AlertPriceAlerts, + UpdateSuspiciousAssetRanks, + UpdateStakeApy, + UpdatePerpetuals, + UpdateUsageRanks, + UpdateAssetsImages, + UpdateAssetsHasPrice, + CleanupStaleDeviceSubscriptions, + ObserveInactiveDevices, + UpdateFiatAssets, + UpdateFiatProviderCountries, + UpdateFiatBuyableAssets, + UpdateFiatSellableAssets, + UpdateTrendingFiatAssets, + UpdateAssetsIndex, + UpdatePerpetualsIndex, + UpdateNftsIndex, + CleanupProcessedTransactions, + UpdateStoreVersion, + UpdateChainValidators, + UpdateValidatorsFromStaticAssets, + CheckRewardsAbuse, + CheckRewardsEligibility, + CleanupOutdatedAssets, + UpdateFiatRates, + UpdatePricesTop, + UpdatePricesHigh, + UpdatePricesLow, + UpdatePrices, + AggregateHourlyCharts, + AggregateDailyCharts, + CleanupChartsRaw, + CleanupChartsHourly, + UpdateMarkets, + UpdatePricesMetrics, + UpdateChartsHistory, + UpdateObservedPrices, + UpdatePricesAssets, + UpdatePricesAssetsNew, + UpdatePricesAssetsMetadata, + PublishMissingPrices, + UpdateInTransitTransactions, + UpdatePendingTransactions, + UpdateSwapVaultAddresses, + AlertStakeRewards, + ClassifyPerpetualAddresses, + ObservePerpetualActiveAddresses, + ObservePerpetualPriorityAddresses, + RefreshPerpetualTrackedAddresses, +} + +impl WorkerJob { + fn spec(&self) -> JobSpec { + use WorkerJob::*; + match self { + AlertPriceAlerts => JobSpec::new(WorkerService::Alerter, JobInterval::Config(ConfigKey::AlerterPriceAlertsTimer)), + UpdateSuspiciousAssetRanks => JobSpec::new(WorkerService::Assets, JobInterval::Config(ConfigKey::AssetsTimerUpdateSuspicious)), + UpdateStakeApy => JobSpec::new(WorkerService::Assets, JobInterval::Config(ConfigKey::AssetsTimerUpdateStakeApy)), + UpdatePerpetuals => JobSpec::new(WorkerService::Assets, JobInterval::Config(ConfigKey::AssetsTimerUpdatePerpetuals)), + UpdateUsageRanks => JobSpec::new(WorkerService::Assets, JobInterval::Config(ConfigKey::AssetsTimerUpdateUsageRank)), + UpdateAssetsImages => JobSpec::new(WorkerService::Assets, JobInterval::Config(ConfigKey::AssetsTimerUpdateImages)), + UpdateAssetsHasPrice => JobSpec::new(WorkerService::Assets, JobInterval::Config(ConfigKey::AssetsTimerUpdateHasPrice)), + CleanupStaleDeviceSubscriptions => JobSpec::new(WorkerService::System, JobInterval::Config(ConfigKey::DeviceTimerUpdater)), + ObserveInactiveDevices => JobSpec::new(WorkerService::System, JobInterval::Config(ConfigKey::DeviceTimerInactiveObserver)), + UpdateFiatAssets => JobSpec::new(WorkerService::Fiat, JobInterval::Config(ConfigKey::FiatTimerUpdateAssets)), + UpdateFiatProviderCountries => JobSpec::new(WorkerService::Fiat, JobInterval::Config(ConfigKey::FiatTimerUpdateProviderCountries)), + UpdateFiatBuyableAssets => JobSpec::new(WorkerService::Fiat, JobInterval::Config(ConfigKey::FiatTimerUpdateBuyableAssets)), + UpdateFiatSellableAssets => JobSpec::new(WorkerService::Fiat, JobInterval::Config(ConfigKey::FiatTimerUpdateSellableAssets)), + UpdateTrendingFiatAssets => JobSpec::new(WorkerService::Fiat, JobInterval::Config(ConfigKey::FiatTimerUpdateTrending)), + UpdateAssetsIndex => JobSpec::new(WorkerService::Search, JobInterval::Config(ConfigKey::SearchAssetsUpdateInterval)), + UpdatePerpetualsIndex => JobSpec::new(WorkerService::Search, JobInterval::Config(ConfigKey::SearchPerpetualsUpdateInterval)), + UpdateNftsIndex => JobSpec::new(WorkerService::Search, JobInterval::Config(ConfigKey::SearchNftsUpdateInterval)), + CleanupProcessedTransactions => JobSpec::new(WorkerService::System, JobInterval::Config(ConfigKey::TransactionTimerCleanup)), + UpdateStoreVersion => JobSpec::new(WorkerService::System, JobInterval::Config(ConfigKey::VersionTimerUpdateStoreVersions)), + UpdateChainValidators => JobSpec::new(WorkerService::Assets, JobInterval::Config(ConfigKey::ScanTimerUpdateValidators)), + UpdateValidatorsFromStaticAssets => JobSpec::new(WorkerService::Assets, JobInterval::Config(ConfigKey::ScanTimerUpdateValidatorsStatic)), + CheckRewardsAbuse => JobSpec::new(WorkerService::Rewards, JobInterval::Config(ConfigKey::RewardsTimerAbuseChecker)), + CheckRewardsEligibility => JobSpec::new(WorkerService::Rewards, JobInterval::Config(ConfigKey::RewardsTimerEligibilityChecker)), + CleanupOutdatedAssets => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerCleanOutdated)), + UpdateFiatRates => JobSpec::new(WorkerService::Fiat, JobInterval::Config(ConfigKey::PriceTimerFiatRates)), + UpdatePricesTop => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerTopMarketCap)), + UpdatePricesHigh => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerHighMarketCap)), + UpdatePricesLow => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerLowMarketCap)), + UpdatePrices => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerPrices)), + AggregateHourlyCharts => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerChartsHourly)), + AggregateDailyCharts => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerChartsDaily)), + CleanupChartsRaw => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerCleanupChartsRaw)), + CleanupChartsHourly => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerCleanupChartsHourly)), + UpdateMarkets => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerMarkets)), + UpdatePricesMetrics => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerMetrics)), + UpdateChartsHistory => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerChartsHistory)), + UpdateObservedPrices => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceObservedFetchInterval)), + UpdatePricesAssets => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerAssets)), + UpdatePricesAssetsNew => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerAssetsNew)), + UpdatePricesAssetsMetadata => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceTimerAssetsMetadata)), + PublishMissingPrices => JobSpec::new(WorkerService::Prices, JobInterval::Config(ConfigKey::PriceMissingPublishInterval)), + UpdateInTransitTransactions => JobSpec::new(WorkerService::Transactions, JobInterval::Config(ConfigKey::TransactionTimerInTransitUpdate)), + UpdatePendingTransactions => JobSpec::new(WorkerService::Transactions, JobInterval::Config(ConfigKey::TransactionTimerPendingUpdate)), + UpdateSwapVaultAddresses => JobSpec::new(WorkerService::Transactions, JobInterval::Config(ConfigKey::TransactionTimerSwapVaultAddresses)), + AlertStakeRewards => JobSpec::new(WorkerService::Alerter, JobInterval::Config(ConfigKey::AlerterStakeRewardsTimer)), + ClassifyPerpetualAddresses => JobSpec::new(WorkerService::Perpetuals, JobInterval::Config(ConfigKey::PerpetualClassifierInterval)), + ObservePerpetualActiveAddresses => JobSpec::new(WorkerService::Perpetuals, JobInterval::Config(ConfigKey::PerpetualObserverInterval)), + ObservePerpetualPriorityAddresses => JobSpec::new(WorkerService::Perpetuals, JobInterval::Config(ConfigKey::PerpetualPriorityObserverInterval)), + RefreshPerpetualTrackedAddresses => JobSpec::new(WorkerService::Perpetuals, JobInterval::Config(ConfigKey::PerpetualAddressRefreshInterval)), + } + } + + pub fn worker(&self) -> WorkerService { + self.spec().worker + } + + fn interval(&self) -> JobInterval { + self.spec().interval + } +} + +#[derive(Clone, Debug)] +pub struct JobVariant { + job: WorkerJob, + label: Option, + override_interval: Option, +} + +impl JobVariant { + pub fn new(job: WorkerJob) -> Self { + Self { + job, + label: None, + override_interval: None, + } + } + + pub fn labeled(job: WorkerJob, label: impl JobLabel) -> Self { + Self { + job, + label: Some(label.job_label()), + override_interval: None, + } + } + + pub fn every(mut self, interval: Duration) -> Self { + self.override_interval = Some(interval); + self + } + + pub fn with_param_duration(self, config: &ConfigCacher, key: &ConfigParamKey) -> Result { + Ok(self.every(config.get_param_duration(key)?)) + } + + pub fn name(&self) -> String { + job_name(self.job, self.label.as_deref()) + } + + pub fn worker(&self) -> WorkerService { + self.job.worker() + } + + pub fn resolve_interval(&self, config: Option<&ConfigCacher>) -> Result> { + if let Some(duration) = self.override_interval { + Ok(duration) + } else { + self.job.interval().resolve(config) + } + } +} + +impl From for JobVariant { + fn from(job: WorkerJob) -> Self { + JobVariant::new(job) + } +} + +pub fn job_name(job: WorkerJob, label: Option<&str>) -> String { + compose_job_name(job.as_ref(), label) +} diff --git a/core/apps/daemon/src/worker/mod.rs b/core/apps/daemon/src/worker/mod.rs new file mode 100644 index 0000000000..dda174b39b --- /dev/null +++ b/core/apps/daemon/src/worker/mod.rs @@ -0,0 +1,38 @@ +pub mod alerter; +pub mod assets; +pub mod context; +pub mod fiat; +pub mod job_schedule; +pub mod jobs; +pub mod perpetuals; +pub mod plan; +pub mod prices; +pub mod rewards; +pub mod runtime; +pub mod search; +pub mod system; +pub mod transactions; + +use std::error::Error; + +use job_runner::JobHandle; + +use crate::model::WorkerService; +use crate::shutdown::ShutdownReceiver; +use crate::worker::context::WorkerContext; + +impl WorkerService { + pub async fn run_jobs(self, ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + match self { + WorkerService::Alerter => alerter::jobs(ctx, shutdown_rx).await, + WorkerService::Prices => prices::jobs(ctx, shutdown_rx).await, + WorkerService::Fiat => fiat::jobs(ctx, shutdown_rx).await, + WorkerService::Assets => assets::jobs(ctx, shutdown_rx).await, + WorkerService::System => system::jobs(ctx, shutdown_rx).await, + WorkerService::Search => search::jobs(ctx, shutdown_rx).await, + WorkerService::Rewards => rewards::jobs(ctx, shutdown_rx).await, + WorkerService::Transactions => transactions::jobs(ctx, shutdown_rx).await, + WorkerService::Perpetuals => perpetuals::jobs(ctx, shutdown_rx).await, + } + } +} diff --git a/core/apps/daemon/src/worker/perpetuals/mod.rs b/core/apps/daemon/src/worker/perpetuals/mod.rs new file mode 100644 index 0000000000..855329c62b --- /dev/null +++ b/core/apps/daemon/src/worker/perpetuals/mod.rs @@ -0,0 +1,71 @@ +mod perpetual_address_refresher; +mod perpetual_classifier; +mod perpetual_observer; + +use cacher::CacherClient; +use job_runner::{JobHandle, ShutdownReceiver}; +use perpetual_address_refresher::PerpetualAddressRefresher; +use perpetual_classifier::{PerpetualPositionClassifier, PerpetualPriorityConfig}; +use perpetual_observer::PerpetualPositionObserver; +use primitives::{Chain, ConfigKey}; +use settings_chain::ChainProviders; +use std::error::Error; +use std::sync::Arc; +use storage::ConfigCacher; +use streamer::{StreamProducer, StreamProducerConfig}; + +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::WorkerJob; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let config = ConfigCacher::new(database.clone()); + + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, "perpetuals_worker", shutdown_rx.clone()).await?; + let cacher = CacherClient::new(&settings.redis.url).await; + + let providers = Arc::new(ChainProviders::from_settings( + &settings, + &settings::service_user_agent("daemon", Some("perpetual_observer")), + )); + let priority_config = PerpetualPriorityConfig { + trigger_bps: config.get_i64(ConfigKey::PerpetualPriorityTriggerBps)?, + liquidation_bps: config.get_i64(ConfigKey::PerpetualPriorityLiquidationBps)?, + }; + let refresher = Arc::new(PerpetualAddressRefresher::new(providers.clone(), database.clone(), cacher.clone())); + + ctx.plan_builder(WorkerService::Perpetuals, &config, shutdown_rx) + .jobs(WorkerJob::ClassifyPerpetualAddresses, Chain::perpetual_chains(), |chain, _| { + let classifier = Arc::new(PerpetualPositionClassifier::new(chain, providers.clone(), cacher.clone(), priority_config)); + move |_| { + let classifier = classifier.clone(); + async move { classifier.classify().await } + } + }) + .jobs(WorkerJob::ObservePerpetualActiveAddresses, Chain::perpetual_chains(), |chain, _| { + let observer = Arc::new(PerpetualPositionObserver::new(chain, providers.clone(), cacher.clone(), stream_producer.clone())); + move |_| { + let observer = observer.clone(); + async move { observer.observe_active().await } + } + }) + .jobs(WorkerJob::ObservePerpetualPriorityAddresses, Chain::perpetual_chains(), |chain, _| { + let observer = Arc::new(PerpetualPositionObserver::new(chain, providers.clone(), cacher.clone(), stream_producer.clone())); + move |_| { + let observer = observer.clone(); + async move { observer.observe_priority().await } + } + }) + .jobs(WorkerJob::RefreshPerpetualTrackedAddresses, Chain::perpetual_chains(), |chain, _| { + let refresher = refresher.clone(); + move |_| { + let refresher = refresher.clone(); + async move { refresher.update(chain).await } + } + }) + .finish() +} diff --git a/core/apps/daemon/src/worker/perpetuals/perpetual_address_refresher.rs b/core/apps/daemon/src/worker/perpetuals/perpetual_address_refresher.rs new file mode 100644 index 0000000000..c1d1c6be90 --- /dev/null +++ b/core/apps/daemon/src/worker/perpetuals/perpetual_address_refresher.rs @@ -0,0 +1,46 @@ +use std::collections::HashSet; +use std::error::Error; +use std::sync::Arc; + +use cacher::{CacheKey, CacherClient}; +use gem_tracing::info_with_fields; +use primitives::Chain; +use settings_chain::ChainProviders; +use storage::{Database, WalletsRepository}; + +pub struct PerpetualAddressRefresher { + providers: Arc, + database: Database, + cacher: CacherClient, +} + +impl PerpetualAddressRefresher { + pub fn new(providers: Arc, database: Database, cacher: CacherClient) -> Self { + Self { providers, database, cacher } + } + + pub async fn update(&self, chain: Chain) -> Result> { + let referred_addresses = self.providers.get_perpetual_referred_addresses(chain).await?; + let referred_count = referred_addresses.len(); + let key = CacheKey::PerpetualTrackedAddresses(chain.as_ref()); + + let tracked_addresses: Vec = if referred_addresses.is_empty() { + vec![] + } else { + self.database + .wallets()? + .get_subscriptions_by_chain_addresses(chain, referred_addresses)? + .into_iter() + .map(|s| s.address) + .collect::>() + .into_iter() + .collect() + }; + + self.cacher.set_cached(key, &tracked_addresses).await?; + + info_with_fields!("perpetual_refresher", chain = chain.as_ref(), referred = referred_count, tracked = tracked_addresses.len()); + + Ok(tracked_addresses.len()) + } +} diff --git a/core/apps/daemon/src/worker/perpetuals/perpetual_classifier.rs b/core/apps/daemon/src/worker/perpetuals/perpetual_classifier.rs new file mode 100644 index 0000000000..d4a1259cba --- /dev/null +++ b/core/apps/daemon/src/worker/perpetuals/perpetual_classifier.rs @@ -0,0 +1,192 @@ +use std::collections::HashSet; +use std::error::Error; +use std::sync::Arc; + +use cacher::{CacheKey, CacherClient}; +use gem_tracing::{error_with_fields, info_with_fields}; +use primitives::{Chain, PerpetualPosition}; +use settings_chain::ChainProviders; + +#[derive(Clone, Copy)] +pub struct PerpetualPriorityConfig { + pub trigger_bps: i64, + pub liquidation_bps: i64, +} + +pub struct PerpetualPositionClassifier { + chain: Chain, + providers: Arc, + cacher: CacherClient, + priority_config: PerpetualPriorityConfig, +} + +impl PerpetualPositionClassifier { + pub fn new(chain: Chain, providers: Arc, cacher: CacherClient, priority_config: PerpetualPriorityConfig) -> Self { + Self { + chain, + providers, + cacher, + priority_config, + } + } + + pub async fn classify(&self) -> Result> { + let addresses = self.get_addresses(CacheKey::PerpetualTrackedAddresses(self.chain.as_ref())).await?; + let current_active = self.get_address_set(CacheKey::PerpetualActiveAddresses(self.chain.as_ref())).await?; + let current_priority = self.get_address_set(CacheKey::PerpetualPriorityAddresses(self.chain.as_ref())).await?; + + let mut active_addresses = Vec::new(); + let mut priority_addresses = Vec::new(); + + for address in &addresses { + match self.providers.get_perpetual_positions(self.chain, address.clone()).await { + Ok(summary) => { + if !summary.positions.is_empty() { + active_addresses.push(address.clone()); + if summary.positions.iter().any(|p| is_priority_position(p, self.priority_config)) { + priority_addresses.push(address.clone()); + } + } + } + Err(error) => { + if current_active.contains(address.as_str()) { + active_addresses.push(address.clone()); + } + if current_priority.contains(address.as_str()) { + priority_addresses.push(address.clone()); + } + error_with_fields!("perpetual_classifier", &*error, chain = self.chain.as_ref(), address = address); + } + } + } + + self.cacher.set_cached(CacheKey::PerpetualActiveAddresses(self.chain.as_ref()), &active_addresses).await?; + self.cacher + .set_cached(CacheKey::PerpetualPriorityAddresses(self.chain.as_ref()), &priority_addresses) + .await?; + + info_with_fields!( + "perpetual_classifier", + chain = self.chain.as_ref(), + tracked = addresses.len(), + active = active_addresses.len(), + priority = priority_addresses.len() + ); + + Ok(addresses.len()) + } + + async fn get_addresses(&self, key: CacheKey<'_>) -> Result, Box> { + Ok(self.cacher.get_cached_optional::>(key).await?.unwrap_or_default()) + } + + async fn get_address_set(&self, key: CacheKey<'_>) -> Result, Box> { + Ok(self.get_addresses(key).await?.into_iter().collect()) + } +} + +fn bps_to_ratio(bps: i64) -> f64 { + bps as f64 / 10_000.0 +} + +fn is_priority_position(position: &PerpetualPosition, config: PerpetualPriorityConfig) -> bool { + let Some(mark_price) = current_mark_price(position) else { + return false; + }; + + let near = |target: f64, threshold_bps: i64| relative_distance(mark_price, target).is_some_and(|d| d <= bps_to_ratio(threshold_bps)); + + let near_liquidation = position.liquidation_price.is_some_and(|p| near(p, config.liquidation_bps)); + let near_auto_close = + position.take_profit.as_ref().is_some_and(|o| near(o.price, config.trigger_bps)) || position.stop_loss.as_ref().is_some_and(|o| near(o.price, config.trigger_bps)); + + near_liquidation || near_auto_close +} + +fn current_mark_price(position: &PerpetualPosition) -> Option { + if position.size > 0.0 && position.size_value > 0.0 { + Some(position.size_value / position.size) + } else { + None + } +} + +fn relative_distance(current: f64, target: f64) -> Option { + if current <= 0.0 || target <= 0.0 { + None + } else { + Some((target - current).abs() / current) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{AssetId, Chain, PerpetualDirection, PerpetualId, PerpetualMarginType, PerpetualOrderType, PerpetualProvider, PerpetualTriggerOrder}; + + fn config() -> PerpetualPriorityConfig { + PerpetualPriorityConfig { + trigger_bps: 100, + liquidation_bps: 600, + } + } + + fn position() -> PerpetualPosition { + PerpetualPosition { + id: "1".to_string(), + perpetual_id: PerpetualId::new(PerpetualProvider::Hypercore, "BTC"), + asset_id: AssetId::from_token(Chain::HyperCore, "perpetual::BTC"), + size: 1.0, + size_value: 100.0, + leverage: 5, + entry_price: 100.0, + liquidation_price: Some(95.0), + margin_type: PerpetualMarginType::Cross, + direction: PerpetualDirection::Long, + margin_amount: 20.0, + take_profit: None, + stop_loss: None, + pnl: 0.0, + funding: None, + } + } + + #[test] + fn test_near_liquidation() { + assert!(is_priority_position(&position(), config())); + } + + #[test] + fn test_near_auto_close() { + let mut position = position(); + position.liquidation_price = Some(50.0); + position.take_profit = Some(PerpetualTriggerOrder { + price: 100.5, + order_type: PerpetualOrderType::Limit, + order_id: "1".to_string(), + }); + let config = PerpetualPriorityConfig { + trigger_bps: 100, + liquidation_bps: 100, + }; + + assert!(is_priority_position(&position, config)); + } + + #[test] + fn test_not_priority_when_far() { + let mut position = position(); + position.liquidation_price = Some(50.0); + position.stop_loss = Some(PerpetualTriggerOrder { + price: 80.0, + order_type: PerpetualOrderType::Market, + order_id: "2".to_string(), + }); + let config = PerpetualPriorityConfig { + trigger_bps: 100, + liquidation_bps: 100, + }; + + assert!(!is_priority_position(&position, config)); + } +} diff --git a/core/apps/daemon/src/worker/perpetuals/perpetual_observer.rs b/core/apps/daemon/src/worker/perpetuals/perpetual_observer.rs new file mode 100644 index 0000000000..236b85fbb2 --- /dev/null +++ b/core/apps/daemon/src/worker/perpetuals/perpetual_observer.rs @@ -0,0 +1,90 @@ +use std::collections::HashSet; +use std::error::Error; +use std::sync::Arc; + +use cacher::{CacheKey, CacherClient}; +use chain_traits::TransactionsRequest; +use gem_tracing::{error_with_fields, info_with_fields}; +use primitives::Chain; +use settings_chain::ChainProviders; +use streamer::steam_producer_queue::StreamProducerQueue; +use streamer::{StreamProducer, TransactionsPayload}; + +pub struct PerpetualPositionObserver { + chain: Chain, + providers: Arc, + cacher: CacherClient, + stream_producer: StreamProducer, +} + +impl PerpetualPositionObserver { + pub fn new(chain: Chain, providers: Arc, cacher: CacherClient, stream_producer: StreamProducer) -> Self { + Self { + chain, + providers, + cacher, + stream_producer, + } + } + + pub async fn observe_active(&self) -> Result> { + let active = self.get_addresses(CacheKey::PerpetualActiveAddresses(self.chain.as_ref())).await?; + let priority = self.get_addresses(CacheKey::PerpetualPriorityAddresses(self.chain.as_ref())).await?; + let excluded: HashSet<&str> = priority.iter().map(String::as_str).collect(); + let addresses: Vec<_> = active.into_iter().filter(|a| !excluded.contains(a.as_str())).collect(); + + self.observe_addresses("active", &addresses).await + } + + pub async fn observe_priority(&self) -> Result> { + let addresses = self.get_addresses(CacheKey::PerpetualPriorityAddresses(self.chain.as_ref())).await?; + + self.observe_addresses("priority", &addresses).await + } + + async fn get_addresses(&self, key: CacheKey<'_>) -> Result, Box> { + Ok(self.cacher.get_cached_optional::>(key).await?.unwrap_or_default()) + } + + async fn observe_addresses(&self, tier: &str, addresses: &[String]) -> Result> { + let mut total_transactions = 0; + for address in addresses { + match self.observe_address(address).await { + Ok(count) => total_transactions += count, + Err(error) => error_with_fields!("perpetual_observer", &*error, chain = self.chain.as_ref(), address = address), + } + } + + if !addresses.is_empty() { + info_with_fields!( + "perpetual_observer", + tier = tier, + chain = self.chain.as_ref(), + addresses = addresses.len(), + transactions = total_transactions + ); + } + + Ok(addresses.len()) + } + + async fn observe_address(&self, address: &str) -> Result> { + let checkpoint = CacheKey::PerpetualObserverCheckpoint(self.chain.as_ref(), address); + let checkpoint_key = checkpoint.key(); + let now = chrono::Utc::now().timestamp() as u64; + let from_timestamp: u64 = self.cacher.get_value_optional(&checkpoint_key).await?.unwrap_or(now); + + let request = TransactionsRequest::new(address.to_string()).with_from_timestamp(Some(from_timestamp)); + let transactions = self.providers.get_transactions_by_address(self.chain, request).await?; + + let count = transactions.len(); + if !transactions.is_empty() { + let payload = TransactionsPayload::new_with_notify(self.chain, vec![], transactions); + self.stream_producer.publish_transactions(payload).await?; + } + + self.cacher.set_value_with_ttl(&checkpoint_key, now.to_string(), checkpoint.ttl()).await?; + + Ok(count) + } +} diff --git a/core/apps/daemon/src/worker/plan.rs b/core/apps/daemon/src/worker/plan.rs new file mode 100644 index 0000000000..bdf047a9ea --- /dev/null +++ b/core/apps/daemon/src/worker/plan.rs @@ -0,0 +1,157 @@ +use crate::model::WorkerService; +use crate::worker::jobs::{JobLabel, JobVariant, WorkerJob}; +use job_runner::{JobContext, JobError, JobHandle, JobPlan}; +use std::error::Error; +use std::fmt::Debug; +use std::future::Future; +use std::time::Duration; +use storage::ConfigCacher; + +type PlanResult = Result>; + +pub struct JobPlanBuilder<'a> { + worker: WorkerService, + plan: PlanResult, + config: Option<&'a ConfigCacher>, + filter: Option, +} + +impl<'a> JobPlanBuilder<'a> { + pub fn with_config(worker: WorkerService, plan: JobPlan, config: &'a ConfigCacher) -> Self { + Self { + worker, + plan: Ok(plan), + config: Some(config), + filter: None, + } + } + + pub fn filter(mut self, filter: Option) -> Self { + self.filter = filter; + self + } + + pub fn job(self, job: J, job_fn: F) -> Self + where + J: Into, + F: Fn(JobContext) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + R: Debug + Send + Sync + 'static, + { + let config = self.config; + let filter = self.filter.clone(); + let plan = self.plan.and_then(|plan| { + let variant: JobVariant = job.into(); + if variant.worker() != self.worker { + return Err(format!("job {} belongs to {:?} worker but builder is {:?}", variant.name(), variant.worker(), self.worker).into()); + } + if !should_include(&variant.name(), filter.as_deref()) { + return Ok(plan); + } + let interval = variant.resolve_interval(config)?; + Ok(plan.job(variant.name(), interval, job_fn)) + }); + Self { + worker: self.worker, + plan, + config, + filter: self.filter, + } + } + + pub fn jobs(self, job: WorkerJob, items: Items, build_job: Builder) -> Self + where + Items: IntoIterator, + Item: JobLabel + Clone + Send + Sync + 'static, + Builder: Fn(Item, JobVariant) -> F, + F: Fn(JobContext) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + R: Debug + Send + Sync + 'static, + { + self.build_jobs(job, items, |_, _| Ok(None), build_job) + } + + pub fn jobs_with_config(self, job: WorkerJob, items: Items, config_key: K, build_job: Builder) -> Self + where + Items: IntoIterator, + Item: JobLabel + Clone + Send + Sync + 'static, + K: Fn(Item) -> primitives::ConfigParamKey, + Builder: Fn(Item, JobVariant) -> F, + F: Fn(JobContext) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + R: Debug + Send + Sync + 'static, + { + let config = self.config; + self.build_jobs( + job, + items, + move |item, _| { + let param = config_key(item); + let cfg = config.ok_or("ConfigCacher required for jobs_with_config")?; + Ok(Some(cfg.get_param_duration(¶m)?)) + }, + build_job, + ) + } + + fn build_jobs(self, job: WorkerJob, items: Items, modify_interval: V, build_job: Builder) -> Self + where + Items: IntoIterator, + Item: JobLabel + Clone + Send + Sync + 'static, + V: Fn(Item, &JobVariant) -> Result, Box>, + Builder: Fn(Item, JobVariant) -> F, + F: Fn(JobContext) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + R: Debug + Send + Sync + 'static, + { + let config = self.config; + let filter = self.filter.clone(); + let plan = self.plan.and_then(|plan| { + if job.worker() != self.worker { + return Err(format!("job {} belongs to {:?} worker but builder is {:?}", job.as_ref(), job.worker(), self.worker).into()); + } + items.into_iter().try_fold(plan, |current, item| { + let variant = JobVariant::labeled(job, item.job_label()); + let variant = match modify_interval(item.clone(), &variant)? { + Some(duration) => variant.every(duration), + None => variant, + }; + if !should_include(&variant.name(), filter.as_deref()) { + return Ok(current); + } + let interval = variant.resolve_interval(config)?; + let job_fn = build_job(item, variant.clone()); + Ok(current.job(variant.name(), interval, job_fn)) + }) + }); + Self { + worker: self.worker, + plan, + config, + filter: self.filter, + } + } + + pub fn finish(self) -> Result, Box> { + self.plan.map(JobPlan::finish) + } +} + +fn should_include(name: &str, filter: Option<&str>) -> bool { + filter.is_none_or(|f| f.is_empty() || name.contains(f)) +} + +#[cfg(test)] +mod tests { + use super::should_include; + + #[test] + fn test_should_include() { + assert!(should_include("publish_missing_prices", None)); + assert!(should_include("publish_missing_prices", Some(""))); + assert!(should_include("publish_missing_prices", Some("missing"))); + assert!(should_include("update_prices_top.coingecko", Some("coingecko"))); + assert!(!should_include("update_prices_top.coingecko", Some("pyth"))); + assert!(!should_include("publish_missing_prices", Some("update_prices"))); + } +} diff --git a/core/apps/daemon/src/worker/prices/charts_updater.rs b/core/apps/daemon/src/worker/prices/charts_updater.rs new file mode 100644 index 0000000000..4c3bc46edd --- /dev/null +++ b/core/apps/daemon/src/worker/prices/charts_updater.rs @@ -0,0 +1,181 @@ +use std::collections::HashSet; +use std::error::Error; +use std::sync::Arc; +use std::time::Duration; + +use cacher::{CacheKey, CacherClient}; +use chrono::{DateTime, Utc}; +use gem_tracing::info_with_fields; +use pricer::PriceClient; +use prices::PriceAssetsProvider; +use primitives::{ChartTimeframe, ChartValue, SECONDS_PER_DAY, SECONDS_PER_HOUR}; +use storage::database::prices::PriceFilter; +use storage::models::{ChartRow, PriceRow}; +use storage::{ChartsRepository, Database, PricesRepository}; + +#[derive(Clone)] +pub struct ChartsUpdater { + prices_client: PriceClient, +} + +impl ChartsUpdater { + pub fn new(prices_client: PriceClient) -> Self { + Self { prices_client } + } + + pub async fn aggregate_charts(&self, timeframe: ChartTimeframe) -> Result> { + self.prices_client.aggregate_charts(timeframe).await + } + + pub async fn delete_charts(&self, timeframe: ChartTimeframe, before: chrono::NaiveDateTime) -> Result> { + self.prices_client.delete_charts(timeframe, before).await + } +} + +#[derive(Clone, Copy)] +pub struct ChartsHistoryConfig { + pub hourly_duration: Duration, +} + +pub struct ChartsHistoryUpdater { + provider: Arc, + database: Database, + cacher: CacherClient, + config: ChartsHistoryConfig, +} + +impl ChartsHistoryUpdater { + pub fn new(provider: Arc, database: Database, cacher: CacherClient, config: ChartsHistoryConfig) -> Self { + Self { + provider, + database, + cacher, + config, + } + } + + pub async fn update(&self) -> Result> { + let provider = self.provider.provider(); + let provider_id = provider.id(); + + let synced: HashSet = self + .cacher + .get_set_members_cached(vec![CacheKey::ChartsHistory(provider_id).key()]) + .await? + .into_iter() + .collect(); + let prices: Vec = self + .database + .prices()? + .get_prices_by_filter(vec![PriceFilter::Provider(provider)])? + .into_iter() + .filter(|p| !synced.contains(&p.id.to_string())) + .collect(); + + for price in &prices { + let provider_price_id = price.provider_price_id(); + let price_id = price.id.to_string(); + info_with_fields!("charts history sync started", price_id = price_id.clone()); + let daily = self + .sync( + price, + "daily", + ChartTimeframe::Daily, + SECONDS_PER_DAY as i64, + self.provider.get_charts_daily(provider_price_id), + ) + .await?; + let hourly = self + .sync( + price, + "hourly", + ChartTimeframe::Hourly, + SECONDS_PER_HOUR as i64, + self.provider.get_charts_hourly(provider_price_id, self.config.hourly_duration), + ) + .await?; + let has_history = daily.received + hourly.received > 0; + let extremes_updates = if has_history { + self.database.prices()?.update_extremes_for_price(&price_id)? + } else { + 0 + }; + if has_history { + self.cacher.add_to_set_cached(CacheKey::ChartsHistory(provider_id), std::slice::from_ref(&price_id)).await?; + } + info_with_fields!( + "charts history sync finished", + price_id = price_id, + daily_received = daily.received, + daily_inserted = daily.inserted, + hourly_received = hourly.received, + hourly_inserted = hourly.inserted, + extremes_updates = extremes_updates, + marked_synced = has_history + ); + } + + info_with_fields!("charts history", provider = provider_id, synced = prices.len()); + Ok(prices.len()) + } + + async fn sync( + &self, + price: &PriceRow, + label: &'static str, + timeframe: ChartTimeframe, + bucket_size_seconds: i64, + future: impl std::future::Future, Box>>, + ) -> Result> { + let values = future.await.inspect_err(|error| { + info_with_fields!("charts history get failed", price_id = price.id.to_string(), kind = label, error = error.to_string()); + })?; + let price_id = price.id.to_string(); + let rows = bucketed_chart_rows(&price_id, &values, bucket_size_seconds); + Ok(HistorySyncStats { + received: values.len(), + inserted: self.database.charts()?.add_charts(timeframe, rows)?, + }) + } +} + +#[derive(Clone, Copy, Debug)] +struct HistorySyncStats { + received: usize, + inserted: usize, +} + +fn bucketed_chart_rows(price_id: &str, values: &[ChartValue], bucket_size_seconds: i64) -> Vec { + values + .iter() + .filter_map(|value| { + let bucket = i64::from(value.timestamp).div_euclid(bucket_size_seconds) * bucket_size_seconds; + let created_at = DateTime::::from_timestamp(bucket, 0)?.naive_utc(); + Some(ChartRow::new(price_id.to_string(), value.value as f64, created_at)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::slice; + + use super::*; + + #[test] + fn test_chart_rows_are_bucketed() { + let value = ChartValue { + timestamp: 1_713_774_896, + value: 123.45, + }; + let hourly = bucketed_chart_rows("bitcoin", slice::from_ref(&value), SECONDS_PER_HOUR as i64).remove(0); + let daily = bucketed_chart_rows("bitcoin", &[value], SECONDS_PER_DAY as i64).remove(0); + + assert_eq!(hourly.coin_id, "bitcoin"); + assert_eq!(hourly.price, 123.45_f32 as f64); + assert_eq!(hourly.created_at, DateTime::::from_timestamp(1_713_772_800, 0).unwrap().naive_utc()); + assert_eq!(daily.coin_id, "bitcoin"); + assert_eq!(daily.price, 123.45_f32 as f64); + assert_eq!(daily.created_at, DateTime::::from_timestamp(1_713_744_000, 0).unwrap().naive_utc()); + } +} diff --git a/core/apps/daemon/src/worker/prices/markets_updater.rs b/core/apps/daemon/src/worker/prices/markets_updater.rs new file mode 100644 index 0000000000..dcf52a5f75 --- /dev/null +++ b/core/apps/daemon/src/worker/prices/markets_updater.rs @@ -0,0 +1,76 @@ +use std::{error::Error, vec}; + +use coingecko::{CoinGeckoClient, model::Global}; +use pricer::MarketsClient; +use primitives::{AssetTag, Chain, MarketDominance, Markets, PriceProvider}; + +pub struct MarketsUpdater { + markets_client: MarketsClient, + coin_gecko_client: CoinGeckoClient, +} + +impl MarketsUpdater { + pub fn new(markets_client: MarketsClient, coin_gecko_client: CoinGeckoClient) -> Self { + Self { + markets_client, + coin_gecko_client, + } + } + + pub async fn update_markets(&self) -> Result> { + let global = self.coin_gecko_client.get_global().await?; + let trending = self.coin_gecko_client.get_search_trending().await?; + let top_gainers_losers = self.coin_gecko_client.get_top_gainers_losers().await?; + + let provider = PriceProvider::Coingecko; + let trending = self.markets_client.get_asset_ids_for_provider_price_ids(provider, trending.get_coins_ids()).await?; + let gainers = self + .markets_client + .get_asset_ids_for_provider_price_ids(provider, top_gainers_losers.get_gainers_ids()) + .await?; + let losers = self + .markets_client + .get_asset_ids_for_provider_price_ids(provider, top_gainers_losers.get_losers_ids()) + .await?; + let dominance = self.dominance(global.clone()); + + let _ = self.markets_client.set_asset_ids_for_tag(AssetTag::Trending, trending); + let _ = self.markets_client.set_asset_ids_for_tag(AssetTag::Gainers, gainers); + let _ = self.markets_client.set_asset_ids_for_tag(AssetTag::Losers, losers); + + let assets = self.markets_client.get_market_assets()?; + + let markets = Markets { + market_cap: global.total_market_cap.usd as f32, + market_cap_change_percentage_24h: global.market_cap_change_percentage_24h_usd as f32, + assets, + dominance, + total_volume_24h: global.total_volume.usd as f32, + }; + + self.markets_client.set_markets(markets).await?; + + Ok(1) + } + + fn dominance(&self, global: Global) -> Vec { + vec![ + MarketDominance { + asset_id: Chain::Bitcoin.to_string(), + dominance: *global.market_cap_percentage.get("btc").unwrap_or(&0.0) as f32, + }, + MarketDominance { + asset_id: Chain::Ethereum.to_string(), + dominance: *global.market_cap_percentage.get("eth").unwrap_or(&0.0) as f32, + }, + MarketDominance { + asset_id: Chain::Solana.to_string(), + dominance: *global.market_cap_percentage.get("sol").unwrap_or(&0.0) as f32, + }, + MarketDominance { + asset_id: Chain::SmartChain.to_string(), + dominance: *global.market_cap_percentage.get("bnb").unwrap_or(&0.0) as f32, + }, + ] + } +} diff --git a/core/apps/daemon/src/worker/prices/missing_prices_publisher.rs b/core/apps/daemon/src/worker/prices/missing_prices_publisher.rs new file mode 100644 index 0000000000..d32810e690 --- /dev/null +++ b/core/apps/daemon/src/worker/prices/missing_prices_publisher.rs @@ -0,0 +1,61 @@ +use std::collections::HashSet; +use std::error::Error; + +use gem_tracing::info_with_fields; +use primitives::AssetId; +use storage::{AssetsUsageRanksRepository, Database, PricesRepository}; +use streamer::{StreamProducer, StreamProducerQueue}; + +const MAX_ASSETS_PER_RUN: usize = 1; + +pub struct MissingPricesPublisher { + database: Database, + stream_producer: StreamProducer, +} + +impl MissingPricesPublisher { + pub fn new(database: Database, stream_producer: StreamProducer) -> Self { + Self { database, stream_producer } + } + + pub async fn update(&self) -> Result> { + let ranks: Vec<(AssetId, i32)> = self + .database + .assets_usage_ranks()? + .get_all_usage_ranks()? + .into_iter() + .map(|row| (row.asset_id.0, row.usage_rank)) + .collect(); + let priced: HashSet = self.database.prices()?.get_prices_assets()?.into_iter().map(|row| row.asset_id.0).collect(); + let asset_ids: Vec = missing_assets(ranks, &priced).into_iter().take(MAX_ASSETS_PER_RUN).collect(); + let count = asset_ids.len(); + self.stream_producer.publish_fetch_prices_assets(asset_ids.clone()).await?; + for asset_id in &asset_ids { + info_with_fields!("publish missing prices", asset_id = asset_id.to_string()); + } + Ok(count) + } +} + +fn missing_assets(ranks: Vec<(AssetId, i32)>, priced: &HashSet) -> Vec { + let mut candidates: Vec<(AssetId, i32)> = ranks.into_iter().filter(|(id, _)| !priced.contains(id)).collect(); + candidates.sort_by_key(|(_, rank)| std::cmp::Reverse(*rank)); + candidates.into_iter().map(|(id, _)| id).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::Chain; + + #[test] + fn test_missing_assets() { + let priced = Chain::Ethereum.as_asset_id(); + let unpriced_high = Chain::Solana.as_asset_id(); + let unpriced_low = Chain::Bitcoin.as_asset_id(); + let ranks = vec![(unpriced_high.clone(), 80), (priced.clone(), 100), (unpriced_low.clone(), 20)]; + let priced: HashSet = [priced].into_iter().collect(); + + assert_eq!(missing_assets(ranks, &priced), vec![unpriced_high, unpriced_low]); + } +} diff --git a/core/apps/daemon/src/worker/prices/mod.rs b/core/apps/daemon/src/worker/prices/mod.rs new file mode 100644 index 0000000000..a8d1aada2c --- /dev/null +++ b/core/apps/daemon/src/worker/prices/mod.rs @@ -0,0 +1,382 @@ +mod charts_updater; +mod markets_updater; +mod missing_prices_publisher; +mod observed_prices_updater; +mod prices_cleanup_updater; +mod prices_metrics_updater; +pub mod prices_updater; + +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::{JobVariant, WorkerJob}; +use crate::worker::plan::JobPlanBuilder; +use std::error::Error; +use std::future::Future; +use std::sync::Arc; + +use cacher::CacherClient; +use charts_updater::{ChartsHistoryConfig, ChartsHistoryUpdater, ChartsUpdater}; +use coingecko::CoinGeckoClient; +use job_runner::{JobHandle, ShutdownReceiver}; +use markets_updater::MarketsUpdater; +use missing_prices_publisher::MissingPricesPublisher; +use observed_prices_updater::{ObservedPricesConfig, ObservedPricesUpdater}; +use pricer::{MarketsClient, PriceClient, PriceProviders, build_price_providers}; +use prices::{PriceAssetsProvider, PriceProvider, PriceProviderEndpoints}; +use prices_cleanup_updater::PricesCleanupUpdater; +use prices_metrics_updater::PricesMetricsUpdater; +use prices_updater::PricesUpdater; +use primitives::{ChartTimeframe, ConfigKey, ConfigParamKey}; +use settings::Settings; +use storage::repositories::prices_providers_repository::PricesProvidersRepository; +use storage::{ConfigCacher, Database}; +use streamer::{StreamProducer, StreamProducerConfig}; + +pub type AssetsProviders = Arc; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let cacher_client = CacherClient::new(&settings.redis.url).await; + let config = Arc::new(ConfigCacher::new(database.clone())); + let producer_assets = stream_producer(&settings, "prices_provider_assets").await?; + let producer_prices = stream_producer(&settings, "prices_provider_prices").await?; + let enabled_providers: Vec = database + .prices_providers()? + .get_prices_providers()? + .into_iter() + .filter(|p| p.enabled) + .map(|p| p.id.0) + .collect(); + let endpoints = price_provider_endpoints(&settings); + let assets_providers: AssetsProviders = Arc::new(build_price_providers(&endpoints, enabled_providers.iter().copied())); + let price_client = PriceClient::new(database.clone(), cacher_client.clone()); + + let builder = ctx.plan_builder(WorkerService::Prices, config.as_ref(), shutdown_rx); + let builder = add_platform_jobs(builder, &database, &cacher_client, &price_client, &config, &assets_providers, producer_prices.clone())?; + enabled_providers + .into_iter() + .try_fold(builder, |builder, provider| { + add_provider_jobs( + builder, + &database, + &cacher_client, + &price_client, + &settings, + &config, + provider, + assets_providers[&provider].clone(), + &producer_assets, + &producer_prices, + ) + })? + .finish() +} + +#[allow(clippy::too_many_arguments)] +fn add_platform_jobs<'a>( + builder: JobPlanBuilder<'a>, + database: &Database, + cacher_client: &CacherClient, + price_client: &PriceClient, + config: &Arc, + providers: &AssetsProviders, + producer: StreamProducer, +) -> Result, Box> { + Ok(builder + .job( + WorkerJob::AggregateHourlyCharts, + charts_job(database, cacher_client, config, ChartsAction::Aggregate(ChartTimeframe::Hourly)), + ) + .job( + WorkerJob::AggregateDailyCharts, + charts_job(database, cacher_client, config, ChartsAction::Aggregate(ChartTimeframe::Daily)), + ) + .job( + WorkerJob::CleanupChartsRaw, + charts_job(database, cacher_client, config, ChartsAction::Delete(ChartTimeframe::Raw)), + ) + .job( + WorkerJob::CleanupChartsHourly, + charts_job(database, cacher_client, config, ChartsAction::Delete(ChartTimeframe::Hourly)), + ) + .job(WorkerJob::UpdateObservedPrices, { + let cacher_client = cacher_client.clone(); + let database = database.clone(); + let price_client = price_client.clone(); + let config = config.clone(); + let providers = providers.clone(); + let producer = producer.clone(); + move |_| { + let cacher_client = cacher_client.clone(); + let database = database.clone(); + let price_client = price_client.clone(); + let config = config.clone(); + let providers = providers.clone(); + let producer = producer.clone(); + async move { + let observed_config = ObservedPricesConfig { + max_assets: config.get_usize(ConfigKey::PriceObservedMaxAssets)?, + min_observers: config.get_usize(ConfigKey::PriceObservedMinObservers)?, + primary_price_max_age: config.get_duration(ConfigKey::PricePrimaryMaxAge)?, + }; + ObservedPricesUpdater::new(cacher_client, database, price_client, providers, producer, observed_config) + .update() + .await + } + } + }) + .job(WorkerJob::PublishMissingPrices, { + let database = database.clone(); + let producer = producer.clone(); + move |_| { + let database = database.clone(); + let producer = producer.clone(); + async move { MissingPricesPublisher::new(database, producer).update().await } + } + })) +} + +#[allow(clippy::too_many_arguments)] +fn add_provider_jobs<'a>( + builder: JobPlanBuilder<'a>, + database: &Database, + cacher_client: &CacherClient, + price_client: &PriceClient, + settings: &Settings, + config: &Arc, + kind: PriceProvider, + provider: Arc, + producer_assets: &StreamProducer, + producer_prices: &StreamProducer, +) -> Result, Box> { + let mut builder = builder; + builder = add_updater_job( + builder, + database, + price_client, + &provider, + producer_assets, + config, + kind, + WorkerJob::UpdatePricesAssets, + ConfigParamKey::PriceProviderAssetsDuration(kind), + |u| async move { u.update_assets().await }, + )?; + builder = add_updater_job( + builder, + database, + price_client, + &provider, + producer_assets, + config, + kind, + WorkerJob::UpdatePricesAssetsNew, + ConfigParamKey::PriceProviderAssetsNewDuration(kind), + |u| async move { u.update_assets_new().await }, + )?; + builder = add_updater_job( + builder, + database, + price_client, + &provider, + producer_assets, + config, + kind, + WorkerJob::UpdatePricesAssetsMetadata, + ConfigParamKey::PriceProviderAssetsMetadataDuration(kind), + |u| async move { u.update_assets_metadata().await }, + )?; + + let cleanup_variant = JobVariant::labeled(WorkerJob::CleanupOutdatedAssets, kind).with_param_duration(config, &ConfigParamKey::PriceProviderCleanOutdatedDuration(kind))?; + builder = builder.job(cleanup_variant, { + let database = database.clone(); + let cacher_client = cacher_client.clone(); + let config = config.clone(); + move |_| { + let updater = PricesCleanupUpdater::new(database.clone(), cacher_client.clone(), config.clone(), kind); + async move { updater.update().await } + } + }); + + let metrics_variant = JobVariant::labeled(WorkerJob::UpdatePricesMetrics, kind).with_param_duration(config, &ConfigParamKey::PriceProviderMetricsDuration(kind))?; + builder = builder.job(metrics_variant, { + let database = database.clone(); + move |_| { + let updater = PricesMetricsUpdater::new(database.clone(), kind); + async move { updater.update().await } + } + }); + + builder = builder.job( + JobVariant::labeled(WorkerJob::UpdateChartsHistory, kind), + charts_history_job( + database, + cacher_client, + provider.clone(), + ChartsHistoryConfig { + hourly_duration: config.get_param_duration(&ConfigParamKey::PriceProviderChartsHourlyDuration(kind))?, + }, + ), + ); + + builder = match kind { + PriceProvider::Coingecko => builder + .job( + JobVariant::labeled(WorkerJob::UpdatePricesTop, kind), + provider_job(database, price_client, provider.clone(), producer_prices.clone(), |u| async move { + u.update_prices_window(0, 500).await + }), + ) + .job( + JobVariant::labeled(WorkerJob::UpdatePricesHigh, kind), + provider_job(database, price_client, provider.clone(), producer_prices.clone(), |u| async move { + u.update_prices_window(500, 2500).await + }), + ) + .job( + JobVariant::labeled(WorkerJob::UpdatePricesLow, kind), + provider_job(database, price_client, provider.clone(), producer_prices.clone(), |u| async move { + u.update_prices_window(3000, usize::MAX).await + }), + ) + .job(WorkerJob::UpdateMarkets, { + let coingecko = CoinGeckoClient::new(&settings.coingecko.key.secret); + let markets_client = MarketsClient::new(database.clone(), cacher_client.clone()); + move |_| { + let updater = MarketsUpdater::new(markets_client.clone(), coingecko.clone()); + Box::pin(async move { updater.update_markets().await }) + } + }), + PriceProvider::Pyth | PriceProvider::Jupiter | PriceProvider::DefiLlama => add_updater_job( + builder, + database, + price_client, + &provider, + producer_prices, + config, + kind, + WorkerJob::UpdatePrices, + ConfigParamKey::PriceProviderPricesDuration(kind), + |u| async move { u.update_prices_all().await }, + )?, + }; + Ok(builder) +} + +#[allow(clippy::too_many_arguments)] +fn add_updater_job<'a, F, Fut>( + builder: JobPlanBuilder<'a>, + database: &Database, + price_client: &PriceClient, + provider: &Arc, + producer: &StreamProducer, + config: &ConfigCacher, + kind: PriceProvider, + job: WorkerJob, + interval: ConfigParamKey, + run: F, +) -> Result, Box> +where + F: Fn(PricesUpdater) -> Fut + Clone + Send + Sync + 'static, + Fut: Future>> + Send + 'static, +{ + let variant = JobVariant::labeled(job, kind).with_param_duration(config, &interval)?; + Ok(builder.job(variant, provider_job(database, price_client, provider.clone(), producer.clone(), run))) +} + +fn price_provider_endpoints(settings: &Settings) -> PriceProviderEndpoints { + PriceProviderEndpoints { + coingecko_api_key: settings.coingecko.key.secret.clone(), + pyth_url: settings.prices.pyth.url.clone(), + jupiter_url: settings.prices.jupiter.url.clone(), + defillama_url: settings.prices.defillama.url.clone(), + } +} + +pub fn price_providers(settings: &Settings) -> PriceProviders { + build_price_providers(&price_provider_endpoints(settings), PriceProvider::all()) +} + +fn charts_history_job( + database: &Database, + cacher: &CacherClient, + provider: Arc, + config: ChartsHistoryConfig, +) -> impl Fn(job_runner::JobContext) -> futures::future::BoxFuture<'static, Result>> + Clone + Send + Sync + 'static { + let database = database.clone(); + let cacher = cacher.clone(); + move |_| { + let provider = provider.clone(); + let database = database.clone(); + let cacher = cacher.clone(); + Box::pin(async move { ChartsHistoryUpdater::new(provider, database, cacher, config).update().await }) + } +} + +fn provider_job( + database: &Database, + price_client: &PriceClient, + provider: Arc, + producer: StreamProducer, + run: F, +) -> impl Fn(job_runner::JobContext) -> futures::future::BoxFuture<'static, Result>> + Clone + Send + Sync + 'static +where + F: Fn(PricesUpdater) -> Fut + Clone + Send + Sync + 'static, + Fut: Future>> + Send + 'static, +{ + let database = database.clone(); + let price_client = price_client.clone(); + move |_| { + let database = database.clone(); + let price_client = price_client.clone(); + let provider = provider.clone(); + let producer = producer.clone(); + let run = run.clone(); + Box::pin(async move { run(PricesUpdater::new(provider, database, price_client, producer)).await }) + } +} + +#[derive(Clone, Copy)] +enum ChartsAction { + Aggregate(ChartTimeframe), + Delete(ChartTimeframe), +} + +fn charts_job( + database: &Database, + cacher: &CacherClient, + config: &Arc, + action: ChartsAction, +) -> impl Fn(job_runner::JobContext) -> futures::future::BoxFuture<'static, Result>> + Clone + Send + Sync + 'static { + let updater = ChartsUpdater::new(PriceClient::new(database.clone(), cacher.clone())); + let config = config.clone(); + move |_| { + let updater = updater.clone(); + let config = config.clone(); + Box::pin(async move { + match action { + ChartsAction::Aggregate(tf) => updater.aggregate_charts(tf).await, + ChartsAction::Delete(tf) => { + let retention = config.get_duration(charts_retention_key(tf))?; + let before = (chrono::Utc::now() - chrono::Duration::from_std(retention)?).naive_utc(); + updater.delete_charts(tf, before).await + } + } + }) + } +} + +fn charts_retention_key(timeframe: ChartTimeframe) -> ConfigKey { + match timeframe { + ChartTimeframe::Raw => ConfigKey::PriceChartsRetentionRaw, + ChartTimeframe::Hourly => ConfigKey::PriceChartsRetentionHourly, + ChartTimeframe::Daily => ConfigKey::PriceChartsRetentionDaily, + } +} + +async fn stream_producer(settings: &Settings, name: &str) -> Result> { + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + StreamProducer::new(&config, name, streamer::no_shutdown()).await +} diff --git a/core/apps/daemon/src/worker/prices/observed_prices_updater.rs b/core/apps/daemon/src/worker/prices/observed_prices_updater.rs new file mode 100644 index 0000000000..4ff711cf85 --- /dev/null +++ b/core/apps/daemon/src/worker/prices/observed_prices_updater.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; +use std::error::Error; +use std::time::Duration; + +use cacher::{CacheKey, CacherClient}; +use pricer::PriceClient; +use prices::AssetPriceMapping; +use primitives::{AssetId, PriceProvider}; +use storage::{Database, PricesRepository}; +use streamer::StreamProducer; + +use crate::worker::prices::{AssetsProviders, prices_updater::PricesUpdater}; + +#[derive(Clone, Copy)] +pub struct ObservedPricesConfig { + pub max_assets: usize, + pub min_observers: usize, + pub primary_price_max_age: Duration, +} + +pub struct ObservedPricesUpdater { + cacher_client: CacherClient, + database: Database, + price_client: PriceClient, + providers: AssetsProviders, + stream_producer: StreamProducer, + config: ObservedPricesConfig, +} + +impl ObservedPricesUpdater { + pub fn new( + cacher_client: CacherClient, + database: Database, + price_client: PriceClient, + providers: AssetsProviders, + stream_producer: StreamProducer, + config: ObservedPricesConfig, + ) -> Self { + Self { + cacher_client, + database, + price_client, + providers, + stream_producer, + config, + } + } + + pub async fn update(&self) -> Result> { + let asset_ids: Vec = self.get_observed_assets().await?.into_iter().filter_map(|id| AssetId::new(&id)).collect(); + if asset_ids.is_empty() { + return Ok(0); + } + + let mut by_provider: HashMap> = HashMap::new(); + for (asset_id, row) in self.database.prices()?.get_primary_prices(&asset_ids, self.config.primary_price_max_age)? { + by_provider + .entry(row.provider_value()) + .or_default() + .push(AssetPriceMapping::new(asset_id, row.provider_price_id().to_string())); + } + + let mut total = 0; + for (provider, mappings) in by_provider { + let Some(instance) = self.providers.get(&provider).cloned() else { + continue; + }; + total += PricesUpdater::new(instance, self.database.clone(), self.price_client.clone(), self.stream_producer.clone()) + .update_prices(mappings) + .await?; + } + Ok(total) + } + + async fn get_observed_assets(&self) -> Result, Box> { + let key = CacheKey::ObservedAssets; + self.cacher_client + .sorted_set_range_by_score(&key.key(), self.config.min_observers as f64, f64::INFINITY, self.config.max_assets) + .await + } +} diff --git a/core/apps/daemon/src/worker/prices/prices_cleanup_updater.rs b/core/apps/daemon/src/worker/prices/prices_cleanup_updater.rs new file mode 100644 index 0000000000..3bfc289f12 --- /dev/null +++ b/core/apps/daemon/src/worker/prices/prices_cleanup_updater.rs @@ -0,0 +1,43 @@ +use cacher::{CacheKey, CacherClient}; +use chrono::Utc; +use primitives::{ConfigKey, PriceProvider}; +use std::error::Error; +use std::sync::Arc; +use storage::ConfigCacher; +use storage::database::prices::PriceFilter; +use storage::{Database, PricesRepository}; + +pub struct PricesCleanupUpdater { + database: Database, + cacher: CacherClient, + config: Arc, + provider: PriceProvider, +} + +impl PricesCleanupUpdater { + pub fn new(database: Database, cacher: CacherClient, config: Arc, provider: PriceProvider) -> Self { + Self { + database, + cacher, + config, + provider, + } + } + + pub async fn update(&self) -> Result> { + let cutoff = (Utc::now() - chrono::Duration::from_std(self.config.get_duration(ConfigKey::PriceOutdated)?)?).naive_utc(); + let ids: Vec = self + .database + .prices()? + .get_prices_by_filter(vec![PriceFilter::Provider(self.provider), PriceFilter::UpdatedBefore(cutoff)])? + .into_iter() + .map(|p| p.id.to_string()) + .collect(); + if ids.is_empty() { + return Ok(0); + } + let deleted = self.database.prices()?.delete_prices(ids.clone())?; + self.cacher.remove_from_set_cached(CacheKey::ChartsHistory(self.provider.id()), &ids).await?; + Ok(deleted) + } +} diff --git a/core/apps/daemon/src/worker/prices/prices_metrics_updater.rs b/core/apps/daemon/src/worker/prices/prices_metrics_updater.rs new file mode 100644 index 0000000000..d87938e85b --- /dev/null +++ b/core/apps/daemon/src/worker/prices/prices_metrics_updater.rs @@ -0,0 +1,53 @@ +use chrono::{Duration, Utc}; +use primitives::PriceProvider; +use std::collections::HashMap; +use std::error::Error; +use storage::database::prices::PriceFilter; +use storage::{ChartFilter, ChartsRepository, Database, PriceUpdate, PricesRepository}; + +pub struct PricesMetricsUpdater { + database: Database, + provider: PriceProvider, +} + +impl PricesMetricsUpdater { + pub fn new(database: Database, provider: PriceProvider) -> Self { + Self { database, provider } + } + + pub async fn update(&self) -> Result> { + if self.provider.supports_price_change_24h() { + return Ok(0); + } + let rows = self.database.prices()?.get_prices_by_filter(vec![PriceFilter::Provider(self.provider)])?; + if rows.is_empty() { + return Ok(0); + } + + let now = Utc::now(); + let upper = (now - Duration::hours(24)).naive_utc(); + let lower = (now - Duration::hours(25)).naive_utc(); + let price_ids: Vec = rows.iter().map(|p| p.id.to_string()).collect(); + let prices_24h_ago: HashMap = self + .database + .charts()? + .get_charts_by_filter(vec![ChartFilter::CreatedBefore(upper), ChartFilter::CreatedAfter(lower), ChartFilter::PriceIds(price_ids)])? + .into_iter() + .collect(); + + let mut updated = 0; + for row in rows { + if row.price == 0.0 { + continue; + } + let price_id = row.id.to_string(); + let prev = prices_24h_ago.get(&price_id).copied().unwrap_or(0.0); + if prev == 0.0 { + continue; + } + let change = (row.price - prev) / prev * 100.0; + updated += self.database.prices()?.update_prices(vec![price_id], vec![PriceUpdate::PriceChangePercentage24h(change)])?; + } + Ok(updated) + } +} diff --git a/core/apps/daemon/src/worker/prices/prices_updater.rs b/core/apps/daemon/src/worker/prices/prices_updater.rs new file mode 100644 index 0000000000..4890f53291 --- /dev/null +++ b/core/apps/daemon/src/worker/prices/prices_updater.rs @@ -0,0 +1,192 @@ +use gem_tracing::info_with_fields; +use pricer::PriceClient; +use prices::{AssetPriceFull, AssetPriceMapping, PriceAssetsProvider, PriceProviderAsset, PriceProviderAssetMetadata}; +use primitives::{AssetId, PriceData}; +use std::collections::HashMap; +use std::sync::Arc; +use storage::database::prices::PriceFilter; +use storage::models::{AssetRow, PriceRow}; +use storage::{AssetUpdate, AssetsLinksRepository, AssetsRepository, Database, PricesRepository}; +use streamer::{PricesPayload, StreamProducer, StreamProducerQueue}; + +const BATCH_SIZE: usize = 1000; + +pub struct PricesUpdater { + provider: Arc, + database: Database, + price_client: PriceClient, + stream_producer: StreamProducer, +} + +impl PricesUpdater { + pub fn new(provider: Arc, database: Database, price_client: PriceClient, stream_producer: StreamProducer) -> Self { + Self { + provider, + database, + price_client, + stream_producer, + } + } + + pub async fn update_assets(&self) -> Result> { + self.save_assets(self.provider.get_assets().await?).await + } + + pub async fn update_assets_new(&self) -> Result> { + self.save_assets(self.provider.get_assets_new().await?).await + } + + pub async fn update_assets_metadata(&self) -> Result> { + let provider = self.provider.provider(); + let prices = self.database.prices()?.get_prices_by_filter(vec![PriceFilter::Provider(provider)])?; + let mappings = self.get_asset_price_mappings(prices)?; + if mappings.is_empty() { + return Ok(0); + } + + let mut updated = 0; + for chunk in mappings.chunks(BATCH_SIZE) { + updated += self.save_assets_metadata(self.provider.get_assets_metadata(chunk.to_vec()).await?)?; + } + + info_with_fields!("update prices assets metadata", provider = provider.id(), count = updated); + Ok(updated) + } + + pub async fn update_prices_all(&self) -> Result> { + let provider = self.provider.provider(); + let prices = self.database.prices()?.get_prices_by_filter(vec![PriceFilter::Provider(provider)])?; + let mappings = self.get_asset_price_mappings(prices)?; + self.update_prices(mappings).await + } + + pub async fn update_prices_window(&self, offset: usize, limit: usize) -> Result> { + let provider = self.provider.provider(); + let prices: Vec = self + .database + .prices()? + .get_prices_by_filter(vec![PriceFilter::Provider(provider)])? + .into_iter() + .skip(offset) + .take(limit) + .collect(); + if prices.is_empty() { + return Ok(0); + } + let mappings = self.get_asset_price_mappings(prices)?; + self.update_prices(mappings).await + } + + pub async fn update_prices(&self, mappings: Vec) -> Result> { + if mappings.is_empty() { + return Ok(0); + } + self.publish_prices(self.provider.get_prices(mappings).await?).await + } + + fn get_asset_price_mappings(&self, prices: Vec) -> Result, Box> { + if prices.is_empty() { + return Ok(vec![]); + } + + let provider_price_ids_by_price_id: HashMap = prices.into_iter().map(|price| (price.id.to_string(), price.provider_price_id().to_string())).collect(); + let asset_rows = self + .database + .prices()? + .get_prices_assets_for_price_ids(provider_price_ids_by_price_id.keys().cloned().collect())?; + + Ok(asset_rows + .into_iter() + .filter_map(|row| { + provider_price_ids_by_price_id + .get(&row.price_id.to_string()) + .cloned() + .map(|provider_price_id| AssetPriceMapping::new(row.asset_id.0, provider_price_id)) + }) + .collect()) + } + + async fn save_assets(&self, assets: Vec) -> Result> { + let provider = self.provider.provider(); + let mut saved = 0; + let mut queued = 0; + + for chunk in assets.chunks(BATCH_SIZE) { + let asset_ids: Vec = chunk.iter().map(|a| a.mapping.asset_id.clone()).collect(); + let existing: HashMap = self.database.assets()?.get_assets_rows(asset_ids)?.into_iter().map(|a| (a.id.clone(), a)).collect(); + let (known, missing): (Vec<&PriceProviderAsset>, Vec<&PriceProviderAsset>) = chunk.iter().partition(|a| existing.contains_key(&a.mapping.asset_id.to_string())); + + if !missing.is_empty() { + self.stream_producer + .publish_fetch_assets(missing.iter().map(|a| a.mapping.asset_id.clone()).collect()) + .await?; + queued += missing.len(); + } + if known.is_empty() { + continue; + } + + let assets_by_id: HashMap = known.iter().map(|a| (a.mapping.asset_id.to_string(), (*a).clone())).collect(); + let prices: Vec = assets_by_id.values().cloned().map(|a| AssetPriceFull::from_provider_asset(a, provider)).collect(); + saved += self.price_client.save_prices(provider, &prices)?; + + let supply_updates: Vec<_> = assets_by_id.iter().filter_map(|(id, asset)| asset_supply_update(asset, existing.get(id)?)).collect(); + self.store_asset_updates(supply_updates)?; + } + + info_with_fields!("update prices assets", provider = provider.id(), saved = saved, queued_for_fetch = queued); + Ok(saved) + } + + async fn publish_prices(&self, prices: Vec) -> Result> { + if prices.is_empty() { + return Ok(0); + } + let provider = self.provider.provider(); + + let payload: Vec = prices + .iter() + .map(AssetPriceFull::as_price_data) + .map(|data| (data.id.clone(), data)) + .collect::>() + .into_values() + .collect(); + let count = payload.len(); + for chunk in payload.chunks(BATCH_SIZE) { + self.stream_producer.publish_prices(PricesPayload::new(chunk.to_vec())).await?; + } + + info_with_fields!("update prices", provider = provider.id(), count = count); + Ok(count) + } + + fn save_assets_metadata(&self, metadata: Vec) -> Result> { + let metadata_by_asset_id: HashMap = + metadata.into_iter().map(|asset_metadata| (asset_metadata.asset_id.to_string(), asset_metadata)).collect(); + for asset_metadata in metadata_by_asset_id.values() { + self.database + .assets()? + .update_assets(vec![asset_metadata.asset_id.clone()], vec![AssetUpdate::Rank(asset_metadata.rank)])?; + self.database.assets_links()?.add_assets_links(&asset_metadata.asset_id, asset_metadata.links.clone())?; + } + Ok(metadata_by_asset_id.len()) + } + + fn store_asset_updates(&self, updates: Vec<(AssetId, AssetUpdate)>) -> Result<(), Box> { + for (asset_id, update) in updates { + self.database.assets()?.update_assets(vec![asset_id], vec![update])?; + } + Ok(()) + } +} + +fn asset_supply_update(asset: &PriceProviderAsset, current: &AssetRow) -> Option<(AssetId, AssetUpdate)> { + let market = asset.market.as_ref()?; + let circulating = market.circulating_supply.filter(|v| *v > 0.0).or(current.circulating_supply); + let total = market.total_supply.filter(|v| *v > 0.0).or(current.total_supply); + let max = market.max_supply.filter(|v| *v > 0.0).or(current.max_supply); + if circulating == current.circulating_supply && total == current.total_supply && max == current.max_supply { + return None; + } + Some((asset.mapping.asset_id.clone(), AssetUpdate::supply(circulating, total, max)?)) +} diff --git a/core/apps/daemon/src/worker/rewards/mod.rs b/core/apps/daemon/src/worker/rewards/mod.rs new file mode 100644 index 0000000000..167be90750 --- /dev/null +++ b/core/apps/daemon/src/worker/rewards/mod.rs @@ -0,0 +1,50 @@ +mod rewards_abuse_checker; +mod rewards_eligibility_checker; + +use std::error::Error; + +use job_runner::{JobHandle, ShutdownReceiver}; +use rewards_abuse_checker::RewardsAbuseChecker; +use rewards_eligibility_checker::RewardsEligibilityChecker; +use storage::ConfigCacher; +use streamer::{StreamProducer, StreamProducerConfig}; + +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::WorkerJob; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let config = ConfigCacher::new(database.clone()); + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, "rewards_worker", shutdown_rx.clone()).await?; + + ctx.plan_builder(WorkerService::Rewards, &config, shutdown_rx) + .job(WorkerJob::CheckRewardsAbuse, { + let database = database.clone(); + let stream_producer = stream_producer.clone(); + move |_| { + let database = database.clone(); + let stream_producer = stream_producer.clone(); + async move { + let checker = RewardsAbuseChecker::new(database, stream_producer); + checker.check().await + } + } + }) + .job(WorkerJob::CheckRewardsEligibility, { + let database = database.clone(); + let stream_producer = stream_producer.clone(); + move |_| { + let database = database.clone(); + let stream_producer = stream_producer.clone(); + async move { + let checker = RewardsEligibilityChecker::new(database, stream_producer); + checker.check().await + } + } + }) + .finish() +} diff --git a/core/apps/daemon/src/worker/rewards/rewards_abuse_checker.rs b/core/apps/daemon/src/worker/rewards/rewards_abuse_checker.rs new file mode 100644 index 0000000000..93d6c94c79 --- /dev/null +++ b/core/apps/daemon/src/worker/rewards/rewards_abuse_checker.rs @@ -0,0 +1,550 @@ +use gem_tracing::info_with_fields; +use primitives::rewards::RewardStatus; +use primitives::{ConfigKey, NaiveDateTimeExt, now}; +use std::error::Error; +use storage::{AbusePatterns, ConfigCacher, Database, RiskSignalsRepository}; +use streamer::{RewardsNotificationPayload, StreamProducer, StreamProducerQueue}; + +struct AbuseDetectionConfig { + disable_threshold: i64, + attempt_penalty: i64, + verified_threshold_multiplier: f64, + lookback: std::time::Duration, + min_referrals_to_evaluate: i64, + country_rotation_threshold: i64, + country_rotation_penalty: i64, + ring_referrers_per_device_threshold: i64, + ring_referrers_per_fingerprint_threshold: i64, + ring_penalty: i64, + device_farming_threshold: i64, + device_farming_penalty: i64, + velocity_window: std::time::Duration, + velocity_divisor: i64, + velocity_penalty: i64, + referral_per_user_daily: i64, + verified_multiplier: i64, + trusted_multiplier: i64, + disabled_referrer_penalty: i64, +} + +struct AbuseEvaluation { + username: String, + status: RewardStatus, + referrals: i64, + attempts: i64, + risk_score: i64, + patterns: AbusePatterns, + score: AbuseScoreBreakdown, + pattern_penalty: PatternPenaltyBreakdown, + referrer_disabled: bool, + disabled_referrer_penalty: f64, + threshold: f64, + abuse_score: f64, + abuse_percent: f64, +} + +struct AbuseScoreBreakdown { + base_score: f64, + risk_score_per_referral: f64, + attempts_per_referral: f64, + attempt_penalty_score: f64, +} + +struct PatternPenaltyBreakdown { + country_rotation_penalty: f64, + ring_penalty: f64, + device_farming_penalty: f64, + velocity_penalty: f64, +} + +impl PatternPenaltyBreakdown { + fn total(&self) -> f64 { + self.country_rotation_penalty + self.ring_penalty + self.device_farming_penalty + self.velocity_penalty + } +} + +pub struct RewardsAbuseChecker { + database: Database, + config: ConfigCacher, + stream_producer: StreamProducer, +} + +impl RewardsAbuseChecker { + pub fn new(database: Database, stream_producer: StreamProducer) -> Self { + let config = ConfigCacher::new(database.clone()); + Self { + database, + config, + stream_producer, + } + } + + pub async fn check(&self) -> Result> { + let mut client = self.database.client()?; + let config = self.load_config()?; + let since = now().ago(config.lookback); + + let usernames = client.get_referrer_usernames_with_referrals(since, config.min_referrals_to_evaluate)?; + + let mut evaluations: Vec = usernames.iter().filter_map(|username| self.evaluate_user(username, &config).ok()).collect(); + + evaluations.sort_by(|a, b| b.abuse_percent.partial_cmp(&a.abuse_percent).unwrap_or(std::cmp::Ordering::Equal)); + + let (high_risk, low_risk): (Vec<_>, Vec<_>) = evaluations.iter().partition(|e| e.abuse_percent >= 50.0); + + for eval in &high_risk { + Self::log_evaluation(eval); + } + + if !low_risk.is_empty() { + let summary: Vec = low_risk.iter().map(|e| format!("{}({:.0}%)", e.username, e.abuse_percent)).collect(); + info_with_fields!("abuse evaluation summary", users = summary.join(", ")); + } + + let mut disabled_count = 0; + for eval in evaluations { + if eval.abuse_score >= eval.threshold + && let Some(event_id) = self.disable_user(&eval)? + { + self.stream_producer.publish_rewards_events(vec![RewardsNotificationPayload::new(event_id)]).await?; + disabled_count += 1; + } + } + + Ok(disabled_count) + } + + fn evaluate_user(&self, username: &str, config: &AbuseDetectionConfig) -> Result> { + let mut client = self.database.client()?; + + let status = client.rewards().get_status_by_username(username)?; + let since = now().ago(config.lookback); + let velocity_window_secs = config.velocity_window.as_secs() as i64; + + let referral_count = client.rewards().count_referrals_since(username, since)?; + let attempt_count = client.count_attempts_for_referrer(username, since)?; + let risk_score_sum = client.sum_risk_scores_for_referrer(username, since)?; + + let patterns = client.get_abuse_patterns_for_referrer(username, since, velocity_window_secs)?; + let score = calculate_abuse_score_breakdown(risk_score_sum, attempt_count, referral_count, config); + let pattern_penalty = calculate_pattern_penalty_breakdown(&patterns, config, &status); + + let referrer_disabled = client + .rewards() + .get_referrer_username(username) + .ok() + .flatten() + .and_then(|referrer| client.rewards().get_status_by_username(&referrer).ok()) + .is_some_and(|s| s == RewardStatus::Disabled); + let disabled_referrer_penalty = if referrer_disabled { config.disabled_referrer_penalty as f64 } else { 0.0 }; + + let abuse_score = score.base_score + pattern_penalty.total() + disabled_referrer_penalty; + let threshold = calculate_abuse_threshold(config, &status); + let abuse_percent = (abuse_score / threshold * 100.0).min(100.0); + + Ok(AbuseEvaluation { + username: username.to_string(), + status, + referrals: referral_count, + attempts: attempt_count, + risk_score: risk_score_sum, + patterns, + score, + pattern_penalty, + referrer_disabled, + disabled_referrer_penalty, + threshold, + abuse_score, + abuse_percent, + }) + } + + fn log_evaluation(eval: &AbuseEvaluation) { + info_with_fields!( + "abuse evaluation", + username = eval.username, + status = eval.status.as_ref(), + referrals = eval.referrals.to_string(), + attempts = eval.attempts.to_string(), + risk_score = eval.risk_score.to_string(), + countries_per_device = eval.patterns.max_countries_per_device.to_string(), + referrers_per_device = eval.patterns.max_referrers_per_device.to_string(), + referrers_per_fingerprint = eval.patterns.max_referrers_per_fingerprint.to_string(), + devices_per_ip = eval.patterns.max_devices_per_ip.to_string(), + velocity_burst = eval.patterns.signals_in_velocity_window.to_string(), + referrer_disabled = eval.referrer_disabled.to_string(), + base_score = format!("{:.2}", eval.score.base_score), + risk_score_per_referral = format!("{:.2}", eval.score.risk_score_per_referral), + attempts_per_referral = format!("{:.2}", eval.score.attempts_per_referral), + attempt_penalty_score = format!("{:.2}", eval.score.attempt_penalty_score), + country_rotation_penalty = format!("{:.0}", eval.pattern_penalty.country_rotation_penalty), + ring_penalty = format!("{:.0}", eval.pattern_penalty.ring_penalty), + device_farming_penalty = format!("{:.0}", eval.pattern_penalty.device_farming_penalty), + velocity_penalty = format!("{:.0}", eval.pattern_penalty.velocity_penalty), + pattern_penalty = format!("{:.0}", eval.pattern_penalty.total()), + disabled_referrer_penalty = format!("{:.0}", eval.disabled_referrer_penalty), + abuse_threshold = format!("{:.0}", eval.threshold), + abuse_score = format!("{:.0}", eval.abuse_score), + abuse_percent = format!("{:.0}%", eval.abuse_percent) + ); + } + + fn disable_user(&self, eval: &AbuseEvaluation) -> Result, Box> { + let mut client = self.database.client()?; + + info_with_fields!( + "disabled user for abuse", + username = eval.username, + abuse_score = format!("{:.0}", eval.abuse_score), + threshold = format!("{:.0}", eval.threshold) + ); + + let reason = "Auto-disabled due to abuse detection"; + let comment = format!( + "abuse_score={:.0}, threshold={:.0}, base_score={:.2}, risk_scores={}, attempts={}, referrals={}, risk_score/referral={:.2}, attempts/referral={:.2}, attempt_penalty_score={:.2}, pattern_penalty={:.0}, country_rotation_penalty={:.0}, ring_penalty={:.0}, device_farming_penalty={:.0}, velocity_penalty={:.0}, disabled_referrer_penalty={:.0}, countries/device={}, referrers/device={}, referrers/fingerprint={}, devices/ip={}, velocity_burst={}, referrer_disabled={}", + eval.abuse_score, + eval.threshold, + eval.score.base_score, + eval.risk_score, + eval.attempts, + eval.referrals, + eval.score.risk_score_per_referral, + eval.score.attempts_per_referral, + eval.score.attempt_penalty_score, + eval.pattern_penalty.total(), + eval.pattern_penalty.country_rotation_penalty, + eval.pattern_penalty.ring_penalty, + eval.pattern_penalty.device_farming_penalty, + eval.pattern_penalty.velocity_penalty, + eval.disabled_referrer_penalty, + eval.patterns.max_countries_per_device, + eval.patterns.max_referrers_per_device, + eval.patterns.max_referrers_per_fingerprint, + eval.patterns.max_devices_per_ip, + eval.patterns.signals_in_velocity_window, + eval.referrer_disabled + ); + let event_id = client.rewards().disable_rewards(&eval.username, reason, &comment)?; + + Ok(Some(event_id)) + } + + fn load_config(&self) -> Result { + Ok(AbuseDetectionConfig { + disable_threshold: self.config.get_i64(ConfigKey::ReferralAbuseDisableThreshold)?, + attempt_penalty: self.config.get_i64(ConfigKey::ReferralAbuseAttemptPenalty)?, + verified_threshold_multiplier: self.config.get_f64(ConfigKey::ReferralAbuseVerifiedThresholdMultiplier)?, + lookback: self.config.get_duration(ConfigKey::ReferralAbuseLookback)?, + min_referrals_to_evaluate: self.config.get_i64(ConfigKey::ReferralAbuseMinReferralsToEvaluate)?, + country_rotation_threshold: self.config.get_i64(ConfigKey::ReferralAbuseCountryRotationThreshold)?, + country_rotation_penalty: self.config.get_i64(ConfigKey::ReferralAbuseCountryRotationPenalty)?, + ring_referrers_per_device_threshold: self.config.get_i64(ConfigKey::ReferralAbuseRingReferrersPerDeviceThreshold)?, + ring_referrers_per_fingerprint_threshold: self.config.get_i64(ConfigKey::ReferralAbuseRingReferrersPerFingerprintThreshold)?, + ring_penalty: self.config.get_i64(ConfigKey::ReferralAbuseRingPenalty)?, + device_farming_threshold: self.config.get_i64(ConfigKey::ReferralAbuseDeviceFarmingThreshold)?, + device_farming_penalty: self.config.get_i64(ConfigKey::ReferralAbuseDeviceFarmingPenalty)?, + velocity_window: self.config.get_duration(ConfigKey::ReferralAbuseVelocityWindow)?, + velocity_divisor: self.config.get_i64(ConfigKey::ReferralAbuseVelocityDivisor)?, + velocity_penalty: self.config.get_i64(ConfigKey::ReferralAbuseVelocityPenaltyPerSignal)?, + referral_per_user_daily: self.config.get_i64(ConfigKey::ReferralPerUserDaily)?, + verified_multiplier: self.config.get_i64(ConfigKey::ReferralVerifiedMultiplier)?, + trusted_multiplier: self.config.get_i64(ConfigKey::ReferralTrustedMultiplier)?, + disabled_referrer_penalty: self.config.get_i64(ConfigKey::ReferralAbuseDisabledReferrerPenalty)?, + }) + } +} + +#[cfg(test)] +fn calculate_abuse_score(risk_score_sum: i64, attempt_count: i64, referral_count: i64, config: &AbuseDetectionConfig) -> f64 { + calculate_abuse_score_breakdown(risk_score_sum, attempt_count, referral_count, config).base_score +} + +fn calculate_abuse_score_breakdown(risk_score_sum: i64, attempt_count: i64, referral_count: i64, config: &AbuseDetectionConfig) -> AbuseScoreBreakdown { + let referrals = referral_count.max(1) as f64; + let risk_score_per_referral = risk_score_sum as f64 / referrals; + let attempts_per_referral = attempt_count as f64 / referrals; + let attempt_penalty_score = attempts_per_referral * config.attempt_penalty as f64; + AbuseScoreBreakdown { + base_score: risk_score_per_referral + attempt_penalty_score, + risk_score_per_referral, + attempts_per_referral, + attempt_penalty_score, + } +} + +fn calculate_abuse_threshold(config: &AbuseDetectionConfig, status: &RewardStatus) -> f64 { + let multiplier = if *status == RewardStatus::Trusted { + config.trusted_multiplier as f64 + } else if status.is_verified() { + config.verified_threshold_multiplier + } else { + 1.0 + }; + config.disable_threshold as f64 * multiplier +} + +#[cfg(test)] +fn calculate_pattern_penalty(patterns: &AbusePatterns, config: &AbuseDetectionConfig, status: &RewardStatus) -> f64 { + calculate_pattern_penalty_breakdown(patterns, config, status).total() +} + +fn calculate_pattern_penalty_breakdown(patterns: &AbusePatterns, config: &AbuseDetectionConfig, status: &RewardStatus) -> PatternPenaltyBreakdown { + let country_rotation_penalty = if patterns.max_countries_per_device >= config.country_rotation_threshold { + config.country_rotation_penalty as f64 + } else { + 0.0 + }; + + let ring_penalty = if patterns.max_referrers_per_device >= config.ring_referrers_per_device_threshold + || patterns.max_referrers_per_fingerprint >= config.ring_referrers_per_fingerprint_threshold + { + config.ring_penalty as f64 + } else { + 0.0 + }; + + let device_farming_penalty = if patterns.max_devices_per_ip >= config.device_farming_threshold { + config.device_farming_penalty as f64 + } else { + 0.0 + }; + + let mut velocity_penalty = 0.0; + let multiplier = if *status == RewardStatus::Trusted { + config.trusted_multiplier + } else if status.is_verified() { + config.verified_multiplier + } else { + 1 + }; + let daily_limit = config.referral_per_user_daily * multiplier; + let velocity_threshold = daily_limit / config.velocity_divisor.max(1); + if patterns.signals_in_velocity_window >= velocity_threshold { + let over_threshold = patterns.signals_in_velocity_window - velocity_threshold + 1; + velocity_penalty = (over_threshold * config.velocity_penalty) as f64; + } + + PatternPenaltyBreakdown { + country_rotation_penalty, + ring_penalty, + device_farming_penalty, + velocity_penalty, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> AbuseDetectionConfig { + AbuseDetectionConfig { + disable_threshold: 200, + attempt_penalty: 15, + verified_threshold_multiplier: 2.0, + lookback: std::time::Duration::from_secs(7 * 86400), + min_referrals_to_evaluate: 2, + country_rotation_threshold: 2, + country_rotation_penalty: 50, + ring_referrers_per_device_threshold: 2, + ring_referrers_per_fingerprint_threshold: 2, + ring_penalty: 80, + device_farming_threshold: 5, + device_farming_penalty: 10, + velocity_window: std::time::Duration::from_secs(300), + velocity_divisor: 2, + velocity_penalty: 100, + referral_per_user_daily: 5, + verified_multiplier: 2, + trusted_multiplier: 3, + disabled_referrer_penalty: 80, + } + } + + #[test] + fn test_abuse_score() { + assert_eq!(calculate_abuse_score(100, 5, 1, &config()), 175.0); + assert_eq!(calculate_abuse_score(0, 10, 1, &config()), 150.0); + assert_eq!(calculate_abuse_score(200, 0, 1, &config()), 200.0); + assert_eq!(calculate_abuse_score(100, 5, 10, &config()), 17.5); + assert_eq!(calculate_abuse_score(0, 10, 10, &config()), 15.0); + assert_eq!(calculate_abuse_score(200, 0, 10, &config()), 20.0); + } + + #[test] + fn test_abuse_score_breakdown() { + let score = calculate_abuse_score_breakdown(44, 0, 2, &config()); + assert_eq!(score.risk_score_per_referral, 22.0); + assert_eq!(score.attempts_per_referral, 0.0); + assert_eq!(score.attempt_penalty_score, 0.0); + assert_eq!(score.base_score, 22.0); + } + + #[test] + fn test_abuse_threshold() { + assert_eq!(calculate_abuse_threshold(&config(), &RewardStatus::Unverified), 200.0); + assert_eq!(calculate_abuse_threshold(&config(), &RewardStatus::Verified), 400.0); + assert_eq!(calculate_abuse_threshold(&config(), &RewardStatus::Trusted), 600.0); + } + + #[test] + fn test_pattern_penalty() { + let config = config(); + let base = AbusePatterns { + max_countries_per_device: 1, + max_referrers_per_device: 1, + max_referrers_per_fingerprint: 1, + max_devices_per_ip: 2, + signals_in_velocity_window: 0, + }; + + assert_eq!(calculate_pattern_penalty(&base, &config, &RewardStatus::Unverified), 0.0); + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + max_countries_per_device: 2, + ..base + }, + &config, + &RewardStatus::Unverified + ), + 50.0 + ); + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + max_referrers_per_device: 2, + ..base + }, + &config, + &RewardStatus::Unverified + ), + 80.0 + ); + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + max_referrers_per_fingerprint: 2, + ..base + }, + &config, + &RewardStatus::Unverified + ), + 80.0 + ); + assert_eq!( + calculate_pattern_penalty(&AbusePatterns { max_devices_per_ip: 5, ..base }, &config, &RewardStatus::Unverified), + 10.0 + ); + + // Normal user: daily_limit=5, divisor=2, velocity_threshold=2 + // 1 signal doesn't trigger, 2 signals trigger: (2-2+1)*100 = 100 + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + signals_in_velocity_window: 1, + ..base + }, + &config, + &RewardStatus::Unverified + ), + 0.0 + ); + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + signals_in_velocity_window: 2, + ..base + }, + &config, + &RewardStatus::Unverified + ), + 100.0 + ); + + // Verified user: daily_limit=10, divisor=2, velocity_threshold=5 + // 4 signals don't trigger, 5 signals trigger: (5-5+1)*100 = 100 + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + signals_in_velocity_window: 4, + ..base + }, + &config, + &RewardStatus::Verified + ), + 0.0 + ); + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + signals_in_velocity_window: 5, + ..base + }, + &config, + &RewardStatus::Verified + ), + 100.0 + ); + + // Trusted user: daily_limit=15, divisor=2, velocity_threshold=7 + // 6 signals don't trigger, 7 signals trigger: (7-7+1)*100 = 100 + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + signals_in_velocity_window: 6, + ..base + }, + &config, + &RewardStatus::Trusted + ), + 0.0 + ); + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + signals_in_velocity_window: 7, + ..base + }, + &config, + &RewardStatus::Trusted + ), + 100.0 + ); + + // Combined: 50 + 80 + 10 + (5-2+1)*100 = 540 + assert_eq!( + calculate_pattern_penalty( + &AbusePatterns { + max_countries_per_device: 5, + max_referrers_per_device: 4, + max_referrers_per_fingerprint: 3, + max_devices_per_ip: 10, + signals_in_velocity_window: 5, + }, + &config, + &RewardStatus::Unverified + ), + 540.0 + ); + } + + #[test] + fn test_pattern_penalty_breakdown() { + let penalties = calculate_pattern_penalty_breakdown( + &AbusePatterns { + max_countries_per_device: 2, + max_referrers_per_device: 2, + max_referrers_per_fingerprint: 1, + max_devices_per_ip: 5, + signals_in_velocity_window: 2, + }, + &config(), + &RewardStatus::Unverified, + ); + assert_eq!(penalties.country_rotation_penalty, 50.0); + assert_eq!(penalties.ring_penalty, 80.0); + assert_eq!(penalties.device_farming_penalty, 10.0); + assert_eq!(penalties.velocity_penalty, 100.0); + assert_eq!(penalties.total(), 240.0); + } +} diff --git a/core/apps/daemon/src/worker/rewards/rewards_eligibility_checker.rs b/core/apps/daemon/src/worker/rewards/rewards_eligibility_checker.rs new file mode 100644 index 0000000000..f8d302b298 --- /dev/null +++ b/core/apps/daemon/src/worker/rewards/rewards_eligibility_checker.rs @@ -0,0 +1,87 @@ +use std::error::Error; + +use gem_tracing::{error_with_fields, info_with_fields}; +use primitives::{ConfigKey, NaiveDateTimeExt, RewardStatus, now}; +use storage::{ConfigCacher, Database, RewardsEligibilityConfig, RewardsFilter, RewardsRepository}; +use streamer::{RewardsNotificationPayload, StreamProducer, StreamProducerQueue}; + +pub struct RewardsEligibilityChecker { + database: Database, + config: ConfigCacher, + stream_producer: StreamProducer, +} + +impl RewardsEligibilityChecker { + pub fn new(database: Database, stream_producer: StreamProducer) -> Self { + let config = ConfigCacher::new(database.clone()); + Self { + database, + config, + stream_producer, + } + } + + pub async fn check(&self) -> Result> { + let promotion_limit = self.config.get_i64(ConfigKey::RewardsEligibilityPromotionLimit)?; + let active_duration = self.config.get_duration(ConfigKey::RewardsEligibilityActiveDuration)?; + let eligibility = RewardsEligibilityConfig { + activity_cutoff: now().ago(active_duration), + transactions_required: self.config.get_i64(ConfigKey::RewardsEligibilityTransactionsCount)?, + }; + + let usernames = self + .database + .rewards()? + .get_rewards_by_filter(vec![RewardsFilter::Statuses(vec![RewardStatus::Unverified])])? + .into_iter() + .map(|reward| reward.username) + .collect::>(); + let mut promoted = 0; + + for username in usernames { + if promoted >= promotion_limit as usize { + break; + } + + let result = match self.evaluate_and_promote(&username, eligibility).await { + Ok(result) => result, + Err(error) => { + error_with_fields!("rewards eligibility check failed", &*error, username = username); + continue; + } + }; + + if result { + promoted += 1; + } + } + + Ok(promoted) + } + + async fn evaluate_and_promote(&self, username: &str, eligibility: RewardsEligibilityConfig) -> Result> { + let Some(wallet_id) = self.database.rewards()?.check_eligibility(username, eligibility)? else { + return Ok(false); + }; + + let reward_event_ids = self.database.rewards()?.promote_to_verified(username)?; + + info_with_fields!( + "rewards eligibility promoted user", + username = username, + wallet_id = wallet_id, + events = reward_event_ids.len() + ); + + self.publish_promotion(reward_event_ids).await?; + Ok(true) + } + + async fn publish_promotion(&self, reward_event_ids: Vec) -> Result<(), Box> { + self.stream_producer + .publish_rewards_events(reward_event_ids.into_iter().map(RewardsNotificationPayload::new).collect()) + .await?; + + Ok(()) + } +} diff --git a/core/apps/daemon/src/worker/runtime.rs b/core/apps/daemon/src/worker/runtime.rs new file mode 100644 index 0000000000..2cd4316d84 --- /dev/null +++ b/core/apps/daemon/src/worker/runtime.rs @@ -0,0 +1,18 @@ +use job_runner::{JobPlan, JobSchedule, JobStatusReporter, ShutdownReceiver}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct WorkerRuntime { + reporter: Arc, + schedule: Arc, +} + +impl WorkerRuntime { + pub fn new(reporter: Arc, schedule: Arc) -> Self { + Self { reporter, schedule } + } + + pub fn plan(&self, shutdown_rx: ShutdownReceiver) -> JobPlan { + JobPlan::new(self.reporter.clone(), shutdown_rx, self.schedule.clone()) + } +} diff --git a/core/apps/daemon/src/worker/search/assets_index_updater.rs b/core/apps/daemon/src/worker/search/assets_index_updater.rs new file mode 100644 index 0000000000..13c7fe851e --- /dev/null +++ b/core/apps/daemon/src/worker/search/assets_index_updater.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; +use std::time::Duration; + +use super::sync::{SearchSyncClient, SearchSyncResult}; +use primitives::ConfigKey; +use search_index::{ASSETS_INDEX_NAME, AssetDocument, SearchIndexClient, sanitize_index_primary_id}; +use storage::models::PriceAssetDataRow; +use storage::{AssetsUsageRanksRepository, AssetsWithPricesFilter, Database, PricesRepository, TagRepository}; + +pub struct AssetsIndexUpdater { + database: Database, + sync_client: SearchSyncClient, + primary_price_max_age: Duration, +} + +impl AssetsIndexUpdater { + pub fn new(database: Database, search_index: &SearchIndexClient, primary_price_max_age: Duration) -> Self { + Self { + sync_client: SearchSyncClient::new(database.clone(), search_index), + database, + primary_price_max_age, + } + } + + pub async fn update(&self) -> Result> { + let sync = self.sync_client.for_key(ConfigKey::SearchAssetsLastUpdatedAt)?; + let filters = sync.since().map(AssetsWithPricesFilter::UpdatedSince).into_iter().collect(); + let prices = self.database.prices()?.get_assets_with_prices_by_filter(filters, self.primary_price_max_age)?; + + if prices.is_empty() { + return sync.write(ASSETS_INDEX_NAME, Vec::::new()).await; + } + + let assets_tags = self.database.tag()?.get_assets_tags()?; + let usage_ranks = self.database.assets_usage_ranks()?.get_all_usage_ranks()?; + + let assets_tags_map: HashMap> = assets_tags.into_iter().fold(HashMap::new(), |mut acc, tag| { + acc.entry(tag.asset_id.to_string()).or_default().push(tag.tag_id); + acc + }); + let usage_ranks_map: HashMap = usage_ranks.into_iter().map(|r| (r.asset_id.to_string(), r.usage_rank)).collect(); + + let documents = Self::build_documents(prices.iter(), &assets_tags_map, &usage_ranks_map); + + sync.write(ASSETS_INDEX_NAME, documents).await + } + + fn build_documents<'a>( + prices: impl IntoIterator, + assets_tags_map: &HashMap>, + usage_ranks_map: &HashMap, + ) -> Vec { + prices + .into_iter() + .map(|x| { + let asset_id = x.asset.id.as_str(); + let usage_rank = usage_ranks_map.get(asset_id).copied().unwrap_or(0); + AssetDocument { + id: sanitize_index_primary_id(asset_id), + asset: x.asset.as_primitive(), + properties: x.asset.clone().as_property_primitive(), + score: x.asset.clone().as_score_primitive(), + usage_rank, + market: x.price.as_ref().map(|price| price.as_market_primitive(&x.asset)), + tags: assets_tags_map.get(asset_id).cloned(), + } + }) + .collect() + } +} diff --git a/core/apps/daemon/src/worker/search/mod.rs b/core/apps/daemon/src/worker/search/mod.rs new file mode 100644 index 0000000000..fb8ad82bde --- /dev/null +++ b/core/apps/daemon/src/worker/search/mod.rs @@ -0,0 +1,51 @@ +mod assets_index_updater; +mod nfts_index_updater; +mod perpetuals_index_updater; +mod sync; + +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::WorkerJob; +use assets_index_updater::AssetsIndexUpdater; +use job_runner::{JobHandle, ShutdownReceiver}; +use nfts_index_updater::NftsIndexUpdater; +use perpetuals_index_updater::PerpetualsIndexUpdater; +use primitives::ConfigKey; +use search_index::SearchIndexClient; +use std::error::Error; +use storage::ConfigCacher; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let search_index_client = SearchIndexClient::new(&settings.meilisearch.url, settings.meilisearch.key.as_str()); + let config = ConfigCacher::new(database.clone()); + + let primary_price_max_age = config.get_duration(ConfigKey::PricePrimaryMaxAge)?; + ctx.plan_builder(WorkerService::Search, &config, shutdown_rx) + .job(WorkerJob::UpdateAssetsIndex, { + let database = database.clone(); + let search_index_client = search_index_client.clone(); + move |_| { + let updater = AssetsIndexUpdater::new(database.clone(), &search_index_client, primary_price_max_age); + async move { updater.update().await } + } + }) + .job(WorkerJob::UpdatePerpetualsIndex, { + let database = database.clone(); + let search_index_client = search_index_client.clone(); + move |_| { + let updater = PerpetualsIndexUpdater::new(database.clone(), &search_index_client); + async move { updater.update().await } + } + }) + .job(WorkerJob::UpdateNftsIndex, { + let database = database.clone(); + let search_index_client = search_index_client.clone(); + move |_| { + let updater = NftsIndexUpdater::new(database.clone(), &search_index_client); + async move { updater.update().await } + } + }) + .finish() +} diff --git a/core/apps/daemon/src/worker/search/nfts_index_updater.rs b/core/apps/daemon/src/worker/search/nfts_index_updater.rs new file mode 100644 index 0000000000..4aa0d1afcf --- /dev/null +++ b/core/apps/daemon/src/worker/search/nfts_index_updater.rs @@ -0,0 +1,33 @@ +use super::sync::{SearchSyncClient, SearchSyncResult}; +use primitives::ConfigKey; +use search_index::{NFTDocument, NFTS_INDEX_NAME, SearchIndexClient}; +use storage::models::NftCollectionRow; +use storage::{Database, NftCollectionFilter, NftRepository}; + +pub struct NftsIndexUpdater { + database: Database, + sync_client: SearchSyncClient, +} + +impl NftsIndexUpdater { + pub fn new(database: Database, search_index: &SearchIndexClient) -> Self { + Self { + sync_client: SearchSyncClient::new(database.clone(), search_index), + database, + } + } + + pub async fn update(&self) -> Result> { + let sync = self.sync_client.for_key(ConfigKey::SearchNftsLastUpdatedAt)?; + let filters = sync.since().map(NftCollectionFilter::UpdatedSince).into_iter().collect(); + let collections = self.database.nft()?.get_nft_collections_by_filter(filters)?; + + let documents = Self::build_documents(collections.iter()); + + sync.write(NFTS_INDEX_NAME, documents).await + } + + fn build_documents<'a>(collections: impl IntoIterator) -> Vec { + collections.into_iter().map(|c| NFTDocument::from(c.as_primitive(vec![]))).collect() + } +} diff --git a/core/apps/daemon/src/worker/search/perpetuals_index_updater.rs b/core/apps/daemon/src/worker/search/perpetuals_index_updater.rs new file mode 100644 index 0000000000..d65a599661 --- /dev/null +++ b/core/apps/daemon/src/worker/search/perpetuals_index_updater.rs @@ -0,0 +1,47 @@ +use std::collections::HashMap; + +use super::sync::{SearchSyncClient, SearchSyncResult}; +use primitives::ConfigKey; +use search_index::{PERPETUALS_INDEX_NAME, PerpetualDocument, SearchIndexClient}; +use storage::models::{AssetRow, PerpetualRow}; +use storage::{AssetsRepository, Database, PerpetualFilter, PerpetualsRepository}; + +pub struct PerpetualsIndexUpdater { + database: Database, + sync_client: SearchSyncClient, +} + +impl PerpetualsIndexUpdater { + pub fn new(database: Database, search_index: &SearchIndexClient) -> Self { + Self { + sync_client: SearchSyncClient::new(database.clone(), search_index), + database, + } + } + + pub async fn update(&self) -> Result> { + let sync = self.sync_client.for_key(ConfigKey::SearchPerpetualsLastUpdatedAt)?; + let filters = sync.since().map(PerpetualFilter::UpdatedSince).into_iter().collect(); + let perpetuals = self.database.perpetuals()?.get_perpetuals_by_filter(filters)?; + + if perpetuals.is_empty() { + return sync.write(PERPETUALS_INDEX_NAME, Vec::::new()).await; + } + + let asset_ids = perpetuals.iter().map(|p| p.asset_id.0.clone()).collect::>(); + let assets = self.database.assets()?.get_assets_rows(asset_ids)?; + + let assets_map: HashMap = assets.into_iter().map(|a| (a.id.to_string(), a)).collect(); + + let documents = Self::build_documents(perpetuals.iter(), &assets_map); + + sync.write(PERPETUALS_INDEX_NAME, documents).await + } + + fn build_documents<'a>(perpetuals: impl IntoIterator, assets_map: &HashMap) -> Vec { + perpetuals + .into_iter() + .filter_map(|p| assets_map.get(&p.asset_id.to_string()).map(|a| (p.as_primitive(), a.as_primitive()).into())) + .collect() + } +} diff --git a/core/apps/daemon/src/worker/search/sync.rs b/core/apps/daemon/src/worker/search/sync.rs new file mode 100644 index 0000000000..d52b12bdd1 --- /dev/null +++ b/core/apps/daemon/src/worker/search/sync.rs @@ -0,0 +1,94 @@ +use chrono::{NaiveDateTime, Utc}; +use primitives::ConfigKey; +use search_index::SearchIndexClient; +use serde::Serialize; +use std::error::Error; +use std::fmt; +use storage::{ConfigCacher, Database}; + +#[derive(Clone, Copy)] +pub enum SearchSyncAction { + ReplaceIndex, + IncrementalUpdate, +} + +impl fmt::Display for SearchSyncAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SearchSyncAction::ReplaceIndex => write!(f, "replace"), + SearchSyncAction::IncrementalUpdate => write!(f, "incremental"), + } + } +} + +#[derive(Clone, Copy)] +pub struct SearchSyncResult { + action: SearchSyncAction, + indexed_documents: usize, +} + +impl fmt::Debug for SearchSyncResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} indexed_documents={}", self.action, self.indexed_documents) + } +} + +pub struct SearchSyncClient { + config: ConfigCacher, + search_index: SearchIndexClient, +} + +impl SearchSyncClient { + pub fn new(database: Database, search_index: &SearchIndexClient) -> Self { + Self { + config: ConfigCacher::new(database), + search_index: search_index.clone(), + } + } + + pub fn for_key(&self, key: ConfigKey) -> Result, Box> { + Ok(IndexSync { + client: self, + key: key.clone(), + last_updated_at: self.config.get_datetime(key)?, + now: Utc::now().naive_utc(), + }) + } +} + +pub struct IndexSync<'a> { + client: &'a SearchSyncClient, + key: ConfigKey, + last_updated_at: NaiveDateTime, + now: NaiveDateTime, +} + +impl IndexSync<'_> { + pub fn action(&self) -> SearchSyncAction { + if self.should_replace_index() { + SearchSyncAction::ReplaceIndex + } else { + SearchSyncAction::IncrementalUpdate + } + } + + pub fn should_replace_index(&self) -> bool { + self.last_updated_at.and_utc().timestamp() == 0 + } + + pub fn since(&self) -> Option { + if self.should_replace_index() { None } else { Some(self.last_updated_at) } + } + + pub async fn write(self, index: &str, documents: Vec) -> Result> { + let action = self.action(); + let indexed_documents = if self.should_replace_index() { + self.client.search_index.replace_documents(index, documents).await? + } else { + self.client.search_index.index_documents(index, documents).await? + }; + + self.client.config.set_datetime(self.key, self.now)?; + Ok(SearchSyncResult { action, indexed_documents }) + } +} diff --git a/core/apps/daemon/src/worker/system/device_updater.rs b/core/apps/daemon/src/worker/system/device_updater.rs new file mode 100644 index 0000000000..c258dc4587 --- /dev/null +++ b/core/apps/daemon/src/worker/system/device_updater.rs @@ -0,0 +1,16 @@ +use std::error::Error; +use storage::{Database, DevicesRepository}; + +pub struct DeviceUpdater { + database: Database, +} + +impl DeviceUpdater { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub async fn update(&self) -> Result> { + Ok(self.database.devices()?.delete_devices_subscriptions_after_days(120)?) + } +} diff --git a/core/apps/daemon/src/worker/system/mod.rs b/core/apps/daemon/src/worker/system/mod.rs new file mode 100644 index 0000000000..e238a17058 --- /dev/null +++ b/core/apps/daemon/src/worker/system/mod.rs @@ -0,0 +1,73 @@ +mod device_updater; +mod model; +mod observers; +mod transaction_cleanup; +mod version_updater; + +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::WorkerJob; +use cacher::CacherClient; +use device_updater::DeviceUpdater; +use job_runner::{JobHandle, ShutdownReceiver}; +use observers::InactiveDevicesObserver; +use primitives::ConfigKey; +use std::error::Error; +use storage::ConfigCacher; +use streamer::{StreamProducer, StreamProducerConfig}; +use transaction_cleanup::{TransactionCleanup, TransactionCleanupConfig}; +use version_updater::VersionUpdater; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let config = ConfigCacher::new(database.clone()); + let cacher_client = CacherClient::new(settings.redis.url.as_str()).await; + + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, "observe_inactive_devices", shutdown_rx.clone()).await?; + + ctx.plan_builder(WorkerService::System, &config, shutdown_rx) + .job(WorkerJob::CleanupProcessedTransactions, { + let cleanup_config = TransactionCleanupConfig { + address_max_count: config.get_i64(ConfigKey::TransactionCleanupAddressMaxCount)?, + address_limit: config.get_usize(ConfigKey::TransactionCleanupAddressLimit)?, + lookback: config.get_duration(ConfigKey::TransactionCleanupLookback)?, + }; + let transaction_cleanup = TransactionCleanup::new(database.clone(), cleanup_config); + move |_| { + let transaction_cleanup = transaction_cleanup.clone(); + async move { transaction_cleanup.cleanup().await } + } + }) + .job(WorkerJob::CleanupStaleDeviceSubscriptions, { + let database = database.clone(); + move |_| { + let device_updater = DeviceUpdater::new(database.clone()); + async move { device_updater.update().await } + } + }) + .job(WorkerJob::ObserveInactiveDevices, { + let database = database.clone(); + let stream_producer = stream_producer.clone(); + move |_| { + let database = database.clone(); + let stream_producer = stream_producer.clone(); + let cacher_client = cacher_client.clone(); + async move { + let observer = InactiveDevicesObserver::new(database, cacher_client, stream_producer); + observer.observe().await + } + } + }) + .jobs(WorkerJob::UpdateStoreVersion, VersionUpdater::stores(), |store, _| { + let store = *store; + let database = database.clone(); + move |_| { + let updater = VersionUpdater::new(database.clone()); + async move { updater.update_store(store).await } + } + }) + .finish() +} diff --git a/core/apps/daemon/src/worker/system/model.rs b/core/apps/daemon/src/worker/system/model.rs new file mode 100644 index 0000000000..49e403d7a6 --- /dev/null +++ b/core/apps/daemon/src/worker/system/model.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ITunesLookupResponse { + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ITunesLoopUpResult { + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubRepository { + pub name: String, + pub draft: bool, + pub prerelease: bool, + pub assets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubRepositoryAsset { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SamsungStoreDetail { + #[serde(rename = "DetailMain")] + pub details: Option, + #[serde(rename = "errMsg")] + pub error_message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SamsungStoreDetails { + #[serde(rename = "contentBinaryVersion")] + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaStoreRelease { + pub version_name: String, +} diff --git a/core/apps/daemon/src/worker/system/observers/inactive_devices_observer.rs b/core/apps/daemon/src/worker/system/observers/inactive_devices_observer.rs new file mode 100644 index 0000000000..c287b2e700 --- /dev/null +++ b/core/apps/daemon/src/worker/system/observers/inactive_devices_observer.rs @@ -0,0 +1,45 @@ +use cacher::{CacheKey, CacherClient}; +use localizer::LanguageLocalizer; +use primitives::{Asset, Chain, GorushNotification, PushNotification}; +use std::error::Error; +use storage::{Database, DevicesRepository, WalletsRepository}; +use streamer::{NotificationsPayload, StreamProducer, StreamProducerQueue}; + +pub struct InactiveDevicesObserver { + database: Database, + cacher: CacherClient, + stream_producer: StreamProducer, +} + +impl InactiveDevicesObserver { + pub fn new(database: Database, cacher: CacherClient, stream_producer: StreamProducer) -> Self { + Self { + database, + cacher, + stream_producer, + } + } + + pub async fn observe(&self) -> Result> { + // 7 days to 14 days + let devices = self.database.devices()?.devices_inactive_days(10, 14, Some(true))?; + for device in &devices { + let device_row_id = self.database.devices()?.get_device_row_id(&device.id)?; + let subscriptions = self.database.wallets()?.get_subscriptions(device_row_id)?; + if subscriptions.is_empty() { + continue; + } + if !self.cacher.can_process_cached(CacheKey::InactiveDeviceObserver(&device.id)).await? { + continue; + } + let language_localizer = LanguageLocalizer::new_with_language(device.locale.as_str()); + let asset = Asset::from_chain(Chain::Bitcoin); + let (title, description) = language_localizer.notification_onboarding_buy_asset(&asset.name); + if let Some(notification) = GorushNotification::from_device(device.clone(), title, description, PushNotification::new_buy_asset(asset.id)) { + self.stream_producer.publish_notifications_observers(NotificationsPayload::new(vec![notification])).await?; + } + } + + Ok(devices.len()) + } +} diff --git a/core/apps/daemon/src/worker/system/observers/mod.rs b/core/apps/daemon/src/worker/system/observers/mod.rs new file mode 100644 index 0000000000..8b1b314539 --- /dev/null +++ b/core/apps/daemon/src/worker/system/observers/mod.rs @@ -0,0 +1,2 @@ +pub mod inactive_devices_observer; +pub use inactive_devices_observer::*; diff --git a/core/apps/daemon/src/worker/system/transaction_cleanup.rs b/core/apps/daemon/src/worker/system/transaction_cleanup.rs new file mode 100644 index 0000000000..26b547384d --- /dev/null +++ b/core/apps/daemon/src/worker/system/transaction_cleanup.rs @@ -0,0 +1,63 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::time::Duration; + +use chrono::Utc; +use storage::models::SubscriptionAddressExcludeRow; +use storage::{Database, TransactionsRepository, WalletsRepository}; + +#[derive(Clone)] +pub struct TransactionCleanupConfig { + pub address_max_count: i64, + pub address_limit: usize, + pub lookback: Duration, +} + +#[derive(Clone)] +pub struct TransactionCleanup { + database: Database, + config: TransactionCleanupConfig, +} + +impl TransactionCleanup { + pub fn new(database: Database, config: TransactionCleanupConfig) -> Self { + Self { database, config } + } + + pub async fn cleanup(&self) -> Result, Box> { + let since = (Utc::now() - self.config.lookback).naive_utc(); + + let heavy_addresses = self + .database + .transactions()? + .get_transactions_addresses(self.config.address_max_count, self.config.address_limit as i64, since)?; + + if heavy_addresses.is_empty() { + return Ok(HashMap::new()); + } + + let subscriptions_exclude: Vec<_> = heavy_addresses + .iter() + .map(|x| SubscriptionAddressExcludeRow { + address: x.address.clone(), + chain: x.chain_id.clone(), + }) + .collect(); + self.database.wallets()?.add_subscriptions_exclude_addresses(subscriptions_exclude)?; + + let addresses: Vec = heavy_addresses.into_iter().map(|x| x.address).collect(); + let total_addresses = addresses.len(); + + let affected_transaction_ids = self.database.transactions()?.delete_transactions_addresses(addresses)?; + let total_transactions_addresses = affected_transaction_ids.len(); + + let unique_ids: Vec = affected_transaction_ids.into_iter().collect::>().into_iter().collect(); + let total_deleted_transactions = self.database.transactions()?.delete_orphaned_transactions(unique_ids)?; + + Ok(HashMap::from([ + ("addresses".to_string(), total_addresses), + ("transactions_addresses".to_string(), total_transactions_addresses), + ("transactions_deleted".to_string(), total_deleted_transactions), + ])) + } +} diff --git a/core/apps/daemon/src/worker/system/version_updater.rs b/core/apps/daemon/src/worker/system/version_updater.rs new file mode 100644 index 0000000000..7d2a6121f0 --- /dev/null +++ b/core/apps/daemon/src/worker/system/version_updater.rs @@ -0,0 +1,99 @@ +use primitives::{GEM_ANDROID_PACKAGE_ID, GEM_IOS_BUNDLE_ID, PlatformStore, config::Release}; +use std::error::Error; +use storage::{Database, ReleasesRepository, models::ReleaseRow}; + +use super::model::{GitHubRepository, ITunesLookupResponse, SamsungStoreDetail, SolanaStoreRelease}; + +pub struct VersionUpdater { + database: Database, +} + +impl VersionUpdater { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub fn stores() -> &'static [PlatformStore] { + &[ + PlatformStore::AppStore, + PlatformStore::ApkUniversal, + PlatformStore::SamsungStore, + PlatformStore::SolanaStore, + ] + } + + pub async fn update_store(&self, store: PlatformStore) -> Result> { + let version = self.get_store_version(store).await?; + + if !self.database.releases()?.is_update_enabled(store)? { + return Ok(version); + } + + let current = self.get_current_version(store)?; + if current.as_ref() != Some(&version) { + self.set_release(Release::new(store, version.clone(), false))?; + } + + Ok(version) + } + + fn get_current_version(&self, store: PlatformStore) -> Result, Box> { + let releases = self.database.releases()?.get_releases()?; + let version = releases.into_iter().find(|r| r.platform_store.0 == store).map(|r| r.version); + Ok(version) + } + + async fn get_store_version(&self, store: PlatformStore) -> Result> { + match store { + PlatformStore::AppStore => self.get_app_store_version().await, + PlatformStore::ApkUniversal => self.get_github_version().await, + PlatformStore::SamsungStore => self.get_samsung_version().await, + PlatformStore::SolanaStore => self.get_solana_store_version().await, + _ => Err(format!("unsupported store: {:?}", store).into()), + } + } + + fn set_release(&self, release: Release) -> Result<(), Box> { + let row = ReleaseRow::from_primitive(release); + self.database.releases()?.update_release(row)?; + Ok(()) + } + + async fn get_app_store_version(&self) -> Result> { + let url = format!("https://itunes.apple.com/lookup?bundleId={GEM_IOS_BUNDLE_ID}"); + let response = reqwest::get(url).await?.json::().await?; + response.results.first().map(|r| r.version.clone()).ok_or_else(|| "no results".into()) + } + + async fn get_github_version(&self) -> Result> { + let url = "https://api.github.com/repos/gemwalletcom/wallet/releases"; + let response = reqwest::Client::builder() + .user_agent("gem-daemon") + .build()? + .get(url) + .send() + .await? + .json::>() + .await?; + response + .into_iter() + .find(|x| !x.draft && !x.prerelease && x.assets.iter().any(|a| a.name.contains("gem_wallet_universal_"))) + .map(|r| r.name) + .ok_or_else(|| "no releases".into()) + } + + async fn get_samsung_version(&self) -> Result> { + let url = format!("https://galaxystore.samsung.com/api/detail/{GEM_ANDROID_PACKAGE_ID}"); + let response = reqwest::get(url).await?.json::().await?; + match response.details { + Some(details) => Ok(details.version), + None => Err(response.error_message.unwrap_or_else(|| "no version found".to_string()).into()), + } + } + + async fn get_solana_store_version(&self) -> Result> { + let url = format!("https://publish.solanamobile.com/api/{GEM_ANDROID_PACKAGE_ID}/release"); + let response = reqwest::get(url).await?.json::().await?; + Ok(response.version_name) + } +} diff --git a/core/apps/daemon/src/worker/transactions/in_transit_updater.rs b/core/apps/daemon/src/worker/transactions/in_transit_updater.rs new file mode 100644 index 0000000000..72fec27164 --- /dev/null +++ b/core/apps/daemon/src/worker/transactions/in_transit_updater.rs @@ -0,0 +1,194 @@ +use std::error::Error; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{NaiveDateTime, Utc}; +use gem_tracing::{DurationMs, error_with_fields, info_with_fields}; +use primitives::swap::{SwapResult, SwapStatus}; +use primitives::{Chain, TransactionSwapMetadata, TransactionType}; +use storage::models::TransactionRow; +use storage::{Database, TransactionFilter, TransactionState, TransactionUpdate, TransactionsRepository}; +use streamer::{StreamProducer, StreamProducerQueue, TransactionsPayload}; +use swapper::cross_chain::{self, DepositAddressMap}; +use swapper::swapper::GemSwapper; + +use crate::client::SwapVaultAddressClient; + +#[derive(Clone, Copy)] +pub struct InTransitConfig { + pub timeout: Duration, + pub query_limit: i64, +} + +pub struct InTransitUpdater { + database: Database, + config: InTransitConfig, + swapper: Arc, + stream_producer: StreamProducer, + vault_client: SwapVaultAddressClient, +} + +impl InTransitUpdater { + pub fn new(database: Database, config: InTransitConfig, swapper: Arc, stream_producer: StreamProducer, vault_client: SwapVaultAddressClient) -> Self { + Self { + database, + config, + swapper, + stream_producer, + vault_client, + } + } + + pub async fn update(&self) -> Result> { + let transactions = self + .database + .transactions()? + .get_transactions_by_filter(vec![TransactionFilter::States(vec![TransactionState::InTransit])], self.config.query_limit)?; + + if transactions.is_empty() { + return Ok(0); + } + + let vault_addresses = self.vault_client.get_deposit_address_map().await?; + let cutoff = (Utc::now() - self.config.timeout).naive_utc(); + let mut updated = 0; + + for transaction in &transactions { + if self.process_transaction(transaction, cutoff, &vault_addresses).await? { + updated += 1; + } + } + + Ok(updated) + } + + async fn process_transaction(&self, row: &TransactionRow, cutoff: NaiveDateTime, vault_addresses: &DepositAddressMap) -> Result> { + let chain = row.chain(); + let transaction = row.as_primitive(row.get_addresses()); + let elapsed = DurationMs((Utc::now().naive_utc() - row.created_at).to_std().unwrap_or_default()); + + let provider = cross_chain::swap_provider_with_vault_addresses(&transaction, vault_addresses); + let provider_name = provider.map(|p| p.as_ref().to_string()).unwrap_or_default(); + let result = match provider { + Some(provider) => match self.swapper.get_swap_result(chain, provider, &row.hash).await { + Ok(r) => r, + Err(err) => { + error_with_fields!( + "in_transit check failed", + &err as &dyn Error, + chain = chain.as_ref(), + hash = row.hash, + provider = provider_name, + elapsed = elapsed + ); + if row.created_at < cutoff { + info_with_fields!("in_transit timed out", chain = chain.as_ref(), hash = row.hash, provider = provider_name, elapsed = elapsed); + self.save_and_publish(chain, row, &TransactionState::Failed, None).await?; + return Ok(true); + } + return Ok(false); + } + }, + None => SwapResult { + status: SwapStatus::Pending, + metadata: None, + }, + }; + let Some((state, metadata)) = resolve_status(&result, row.created_at, cutoff) else { + info_with_fields!("in_transit pending", chain = chain.as_ref(), hash = row.hash, provider = provider_name, elapsed = elapsed); + return Ok(false); + }; + + info_with_fields!("in_transit confirmed", chain = chain.as_ref(), hash = row.hash, state = state.as_ref(), elapsed = elapsed); + + let metadata = metadata.and_then(|m| serde_json::to_value(m).ok()); + self.save_and_publish(chain, row, &state, metadata).await?; + Ok(true) + } + + async fn save_and_publish( + &self, + chain: Chain, + row: &TransactionRow, + state: &TransactionState, + metadata: Option, + ) -> Result<(), Box> { + let mut updates = vec![TransactionUpdate::State(state.clone()), TransactionUpdate::Kind(TransactionType::Swap.into())]; + if let Some(ref json) = metadata { + updates.push(TransactionUpdate::Metadata(json.clone())); + } + self.database.transactions()?.update_transaction(chain.as_ref(), &row.hash, updates)?; + + let transaction = row.as_primitive(row.get_addresses()).with_swap_state(state.clone().into(), metadata.clone()); + self.stream_producer + .publish_transactions(TransactionsPayload::new_with_notify(chain, vec![], vec![transaction])) + .await?; + Ok(()) + } +} + +fn resolve_status(result: &SwapResult, created_at: NaiveDateTime, cutoff: NaiveDateTime) -> Option<(TransactionState, Option)> { + let metadata = result.metadata.clone(); + match result.status.transaction_state() { + Some(state) => Some((state.into(), metadata)), + None if created_at < cutoff => Some((TransactionState::Failed, metadata)), + None => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::TransactionState as PrimitiveTransactionState; + + fn swap_result(status: SwapStatus, metadata: Option) -> SwapResult { + SwapResult { status, metadata } + } + + fn swap_metadata(provider: &str, from_value: &str, to_value: &str) -> TransactionSwapMetadata { + TransactionSwapMetadata { + from_asset: "bitcoin".into(), + from_value: from_value.to_string(), + to_asset: "ethereum".into(), + to_value: to_value.to_string(), + provider: Some(provider.to_string()), + } + } + + #[test] + fn test_resolve_status_completed() { + let now = Utc::now().naive_utc(); + let (state, _) = resolve_status(&swap_result(SwapStatus::Completed, None), now, now).unwrap(); + assert_eq!(*state, PrimitiveTransactionState::Confirmed); + } + + #[test] + fn test_resolve_status_failed() { + let now = Utc::now().naive_utc(); + let (state, _) = resolve_status(&swap_result(SwapStatus::Failed, None), now, now).unwrap(); + assert_eq!(*state, PrimitiveTransactionState::Failed); + } + + #[test] + fn test_resolve_status_pending_within_timeout() { + let now = Utc::now().naive_utc(); + let cutoff = (Utc::now() - Duration::from_secs(3600)).naive_utc(); + assert!(resolve_status(&swap_result(SwapStatus::Pending, None), now, cutoff).is_none()); + } + + #[test] + fn test_resolve_status_pending_past_timeout() { + let cutoff = Utc::now().naive_utc(); + let created_at = (Utc::now() - Duration::from_secs(7200)).naive_utc(); + let (state, _) = resolve_status(&swap_result(SwapStatus::Pending, None), created_at, cutoff).unwrap(); + assert_eq!(*state, PrimitiveTransactionState::Failed); + } + + #[test] + fn test_resolve_status_metadata_from_result() { + let now = Utc::now().naive_utc(); + let metadata = swap_metadata("thorchain", "50000", "2500"); + let (_, resolved) = resolve_status(&swap_result(SwapStatus::Completed, Some(metadata)), now, now).unwrap(); + assert_eq!(resolved.unwrap().from_value, "50000"); + } +} diff --git a/core/apps/daemon/src/worker/transactions/mod.rs b/core/apps/daemon/src/worker/transactions/mod.rs new file mode 100644 index 0000000000..cc4be6a575 --- /dev/null +++ b/core/apps/daemon/src/worker/transactions/mod.rs @@ -0,0 +1,81 @@ +mod in_transit_updater; +mod pending_transactions_updater; +mod vault_addresses_updater; + +use cacher::CacherClient; +use in_transit_updater::{InTransitConfig, InTransitUpdater}; +use job_runner::{JobHandle, ShutdownReceiver}; +use pending_transactions_updater::PendingTransactionsUpdater; +use primitives::{ConfigKey, ConfigParamKey, SwapProvider}; +use settings::service_user_agent; +use settings_chain::{ChainProviders, ProviderFactory}; +use std::error::Error; +use std::sync::Arc; +use storage::ConfigCacher; +use streamer::{StreamProducer, StreamProducerConfig}; +use swapper::NativeProvider; +use swapper::swapper::GemSwapper; +use vault_addresses_updater::VaultAddressesUpdater; + +use crate::client::SwapVaultAddressClient; +use crate::model::WorkerService; +use crate::worker::context::WorkerContext; +use crate::worker::jobs::WorkerJob; + +pub async fn jobs(ctx: WorkerContext, shutdown_rx: ShutdownReceiver) -> Result, Box> { + let database = ctx.database(); + let settings = ctx.settings(); + let config = ConfigCacher::new(database.clone()); + + let in_transit_config = InTransitConfig { + timeout: config.get_duration(ConfigKey::TransactionInTransitTimeout)?, + query_limit: config.get_i64(ConfigKey::TransactionInTransitQueryLimit)?, + }; + + let endpoints = ProviderFactory::get_chain_endpoints(&settings); + let providers = Arc::new(ChainProviders::from_settings(&settings, &service_user_agent("daemon", Some("transactions")))); + let swapper = Arc::new(GemSwapper::new(Arc::new(NativeProvider::new_with_endpoints(endpoints)))); + + let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); + let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); + let stream_producer = StreamProducer::new(&rabbitmq_config, "transactions_worker", shutdown_rx.clone()).await?; + let cacher = CacherClient::new(&settings.redis.url).await; + let pending_updater = Arc::new(PendingTransactionsUpdater::new( + providers.clone(), + cacher.clone(), + stream_producer.clone(), + database.clone(), + )); + + ctx.plan_builder(WorkerService::Transactions, &config, shutdown_rx) + .job(WorkerJob::UpdateInTransitTransactions, { + let database = database.clone(); + let swapper = swapper.clone(); + let stream_producer = stream_producer.clone(); + let vault_client = SwapVaultAddressClient::new(cacher.clone()); + move |_| { + let updater = InTransitUpdater::new(database.clone(), in_transit_config, swapper.clone(), stream_producer.clone(), vault_client.clone()); + async move { updater.update().await } + } + }) + .job(WorkerJob::UpdatePendingTransactions, { + let updater = pending_updater.clone(); + move |_| { + let updater = updater.clone(); + async move { updater.update().await } + } + }) + .jobs_with_config( + WorkerJob::UpdateSwapVaultAddresses, + SwapProvider::cross_chain_providers(), + ConfigParamKey::SwapperVaultAddresses, + |provider, _| { + let updater = Arc::new(VaultAddressesUpdater::new(swapper.clone(), cacher.clone())); + move |ctx| { + let updater = updater.clone(); + async move { updater.update(provider, ctx.last_success_at).await } + } + }, + ) + .finish() +} diff --git a/core/apps/daemon/src/worker/transactions/pending_transactions_updater.rs b/core/apps/daemon/src/worker/transactions/pending_transactions_updater.rs new file mode 100644 index 0000000000..81a11f4cc1 --- /dev/null +++ b/core/apps/daemon/src/worker/transactions/pending_transactions_updater.rs @@ -0,0 +1,157 @@ +use std::error::Error; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use cacher::{CacheKey, CacherClient}; +use gem_tracing::{DurationMs, error_with_fields, info_with_fields}; +use primitives::{Chain, TransactionId, chain_transaction_timeout}; +use settings_chain::ChainProviders; +use storage::{Database, TransactionsRepository}; +use streamer::{StreamProducer, StreamProducerQueue, TransactionsPayload}; + +pub struct PendingTransactionsUpdater { + providers: Arc, + cacher: CacherClient, + stream_producer: StreamProducer, + database: Database, +} + +impl PendingTransactionsUpdater { + pub fn new(providers: Arc, cacher: CacherClient, stream_producer: StreamProducer, database: Database) -> Self { + Self { + providers, + cacher, + stream_producer, + database, + } + } + + pub async fn update(&self) -> Result> { + let mut updated = 0; + for chain in Chain::all() { + if !self.has_pending_transactions(chain).await? { + continue; + } + updated += self.update_chain(chain).await?; + } + + Ok(updated) + } + + async fn update_chain(&self, chain: Chain) -> Result> { + let pending_key = CacheKey::PendingTransactions(chain.as_ref()); + let identifiers = self.cacher.sorted_set_range_with_scores(&pending_key.key(), 0, -1).await?; + + if identifiers.is_empty() { + return Ok(0); + } + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs_f64(); + let mut count = 0; + + for (identifier, expires_at) in identifiers { + if self.process_identifier(chain, &identifier, expires_at, now).await? { + count += self.remove_pending_transaction(chain, &identifier).await?; + } + } + + Ok(count) + } + + async fn process_identifier(&self, chain: Chain, identifier: &str, expires_at: f64, now: f64) -> Result> { + let elapsed = DurationMs(pending_transaction_elapsed(chain, expires_at, now)); + let transaction_id = TransactionId::new(chain, identifier.to_string()); + + if expires_at <= now { + info_with_fields!("pending transaction expired", chain = chain.as_ref(), identifier = identifier, elapsed = elapsed); + return Ok(true); + } + + if self.database.transactions()?.get_transaction_exists(&transaction_id)? { + info_with_fields!("pending transaction already stored", chain = chain.as_ref(), identifier = identifier, elapsed = elapsed); + return Ok(true); + } + + let start = Instant::now(); + match self.providers.get_transaction_by_hash(chain, identifier.to_string()).await { + Ok(Some(transaction)) => { + info_with_fields!( + "pending transaction load success", + chain = chain.as_ref(), + identifier = identifier, + elapsed = elapsed, + latency = DurationMs(start.elapsed()) + ); + self.stream_producer + .publish_transactions(TransactionsPayload::new_with_notify(chain, vec![], vec![transaction])) + .await?; + Ok(true) + } + Ok(None) => { + info_with_fields!( + "pending transaction not loaded", + chain = chain.as_ref(), + identifier = identifier, + elapsed = elapsed, + latency = DurationMs(start.elapsed()) + ); + Ok(false) + } + Err(err) => { + error_with_fields!( + "pending transaction load failed", + &*err, + chain = chain.as_ref(), + identifier = identifier, + elapsed = elapsed, + latency = DurationMs(start.elapsed()) + ); + Ok(false) + } + } + } + + async fn remove_pending_transaction(&self, chain: Chain, identifier: &str) -> Result> { + self.cacher + .remove_from_sorted_set_cached(CacheKey::PendingTransactions(chain.as_ref()), &[identifier.to_string()]) + .await + } + + async fn has_pending_transactions(&self, chain: Chain) -> Result> { + let pending_key = CacheKey::PendingTransactions(chain.as_ref()); + let pending_count = self.cacher.sorted_set_card(&pending_key.key()).await?; + Ok(pending_count > 0) + } +} + +fn pending_transaction_elapsed(chain: Chain, expires_at: f64, now: f64) -> Duration { + let timeout = f64::from(chain_transaction_timeout(chain)) / 1000.0; + let added_at = expires_at - timeout; + Duration::from_secs_f64((now - added_at).max(0.0)) +} + +#[cfg(test)] +mod tests { + use super::pending_transaction_elapsed; + use std::time::Duration; + + use primitives::{Chain, chain_transaction_timeout}; + + #[test] + fn test_pending_transaction_elapsed_uses_added_at() { + let chain = Chain::Ethereum; + let expires_at = 10_000.0; + let now = expires_at - f64::from(chain_transaction_timeout(chain) / 1000) + 42.0; + + assert_eq!(pending_transaction_elapsed(chain, expires_at, now), Duration::from_secs(42)); + } + + #[test] + fn test_pending_transaction_elapsed_is_zero_before_added_at() { + let chain = Chain::Xrp; + let expires_at = 10_000.0; + let now = expires_at - f64::from(chain_transaction_timeout(chain) / 1000) - 1.0; + + assert_eq!(pending_transaction_elapsed(chain, expires_at, now), Duration::ZERO); + } +} diff --git a/core/apps/daemon/src/worker/transactions/vault_addresses_updater.rs b/core/apps/daemon/src/worker/transactions/vault_addresses_updater.rs new file mode 100644 index 0000000000..a9566075fb --- /dev/null +++ b/core/apps/daemon/src/worker/transactions/vault_addresses_updater.rs @@ -0,0 +1,27 @@ +use std::error::Error; +use std::sync::Arc; + +use cacher::{CacheKey, CacherClient}; +use primitives::SwapProvider; +use swapper::swapper::GemSwapper; + +pub struct VaultAddressesUpdater { + swapper: Arc, + cacher: CacherClient, +} + +impl VaultAddressesUpdater { + pub fn new(swapper: Arc, cacher: CacherClient) -> Self { + Self { swapper, cacher } + } + + pub async fn update(&self, provider: SwapProvider, from_timestamp: Option) -> Result> { + let vault_addresses = self.swapper.get_vault_addresses(&provider, from_timestamp).await?; + let deposit_count = self + .cacher + .add_to_set_cached(CacheKey::SwapDepositAddresses(provider.as_ref()), &vault_addresses.deposit) + .await?; + let send_count = self.cacher.add_to_set_cached(CacheKey::SwapSendAddresses(provider.as_ref()), &vault_addresses.send).await?; + Ok(deposit_count + send_count) + } +} diff --git a/core/apps/dynode/CLAUDE.md b/core/apps/dynode/CLAUDE.md new file mode 100644 index 0000000000..904fb11199 --- /dev/null +++ b/core/apps/dynode/CLAUDE.md @@ -0,0 +1,43 @@ +# Dynode + +A high-performance blockchain proxy service written in Rust that routes requests to multiple blockchain nodes with automatic failover and load balancing. + +## What it does + +- **Multi-chain proxy**: Supports 9 blockchains (Ethereum, Bitcoin, Solana, Cosmos, Ton, Tron, Aptos, Sui, XRP, Near) +- **Intelligent routing**: Monitors node health and automatically switches to the best performing node +- **Metrics collection**: Exposes Prometheus metrics for monitoring proxy performance and node status +- **Block synchronization**: Tracks latest block numbers to ensure nodes are synchronized + +## Architecture + +- `main.rs` - Starts a single HTTP server for proxy, auth, health, and metrics +- `node_service.rs` - Manages node health monitoring and failover logic +- `proxy_request_service.rs` - Handles incoming requests and proxies to blockchain nodes +- `chain_service/` - Blockchain-specific API implementations +- `metrics.rs` - Prometheus metrics collection +- `config.rs` - YAML configuration management + +## Configuration + +Uses `config.yml` to define: +- Blockchain domains and their RPC endpoints +- Node health check intervals and block delay thresholds +- Custom headers and URL overrides per endpoint + +## Commands + +```bash +cargo build # Build the project +cargo run # Start the proxy service +cargo test # Run tests +cargo clippy # Code quality checks +``` + +## Metrics + +Exposes metrics on `/metrics` endpoint including: +- Request counts by host and method +- Response latency histograms +- Current active node per domain +- Block height tracking diff --git a/core/apps/dynode/Cargo.toml b/core/apps/dynode/Cargo.toml new file mode 100644 index 0000000000..73314474b3 --- /dev/null +++ b/core/apps/dynode/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "dynode" +version = { workspace = true } +edition = { workspace = true } + +[features] +testkit = [] + +[dependencies] +config = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +futures = { workspace = true } +prometheus-client = { workspace = true } +reqwest = { workspace = true } +rocket = { workspace = true } +primitives = { path = "../../crates/primitives" } +gem_tracing = { path = "../../crates/tracing" } +gem_client = { path = "../../crates/gem_client" } +metrics = { path = "../../crates/metrics" } +settings_chain = { path = "../../crates/settings_chain" } +serde_serializers = { path = "../../crates/serde_serializers" } + +url = { workspace = true } +gem_auth = { path = "../../crates/gem_auth", features = ["client"] } diff --git a/core/apps/dynode/chains.yml b/core/apps/dynode/chains.yml new file mode 100644 index 0000000000..e2133a54c0 --- /dev/null +++ b/core/apps/dynode/chains.yml @@ -0,0 +1,81 @@ +chains: + # Ethereum + - chain: ethereum + overrides: + - rpc_method: eth_chainId + url: https://public-eth.nownodes.io + urls: + - url: https://rpc.ankr.com/eth + - url: https://eth.llamarpc.com + - url: https://rpc.ankr.com/eth + + # Optimism + - chain: optimism + urls: + - url: https://mainnet.optimism.io + + # Bitcoin - default + - chain: bitcoin + urls: + - url: https://blockbook.btc.zelcore.io + + # Litecoin + - chain: litecoin + poll_interval_seconds: 15 + urls: + - url: https://1blockbook.lt1c.zelcore.io + - url: https://blockbook.ltc.zelcore.io + + # Solana + - chain: solana + urls: + - url: https://api.mainnet-beta.solana.com + - url: https://api.tatum.io/v3/blockchain/node/solana-mainnet + + # Cosmos + - chain: cosmos + urls: + - url: https://cosmos-rest.publicnode.com + + # TON + - chain: ton + urls: + - url: https://toncenter.com + - url: https://toncenter.com + + # Tron + - chain: tron + overrides: + - path: /wallet/getchainparameters + url: https://api.frankfurt.trongrid.io + urls: + - url: https://api.trongrid.io + + # Aptos + - chain: aptos + urls: + - url: https://fullnode.mainnet.aptoslabs.com + + # Sui + - chain: sui + urls: + - url: https://fullnode.mainnet.sui.io + + # XRP + - chain: xrp + poll_interval_seconds: 30 + urls: + - url: https://s1.ripple.com:51234 + - url: https://s2.ripple.com:51234 + + # NEAR + - chain: near + poll_interval_seconds: 60 + urls: + - url: https://rpc.mainnet.near.org + - url: https://free.rpc.fastnear.com + + # Hypercore + - chain: hypercore + urls: + - url: https://api.hyperliquid.xyz diff --git a/core/apps/dynode/config.yml b/core/apps/dynode/config.yml new file mode 100644 index 0000000000..16106b0ce2 --- /dev/null +++ b/core/apps/dynode/config.yml @@ -0,0 +1,91 @@ +port: 3000 +address: 0.0.0.0 +metrics: + prefix: dynode + +jwt: + secret: "" + +webhook: + enabled: false + url: "" + timeout: 2s + +monitoring: + enabled: false + poll_interval: 10m + max_sync_delay: 24s + max_sync_blocks: 20 + latency_threshold: 250ms + latency_threshold_percent: 20 + +request: + timeout: 3s + +headers: + forward: + - accept + - content-type + - content-encoding + - content-length + - te + domains: + public-eth.nownodes.io: + - user-agent + +retry: + enabled: true + max_attempts: 3 + errors: + status_codes: [403, 429, 502, 503, 504] + error_messages: + - "daily request limit" + - "rate limit" + - "Exceeded the quota usage" + - "Unauthorized" + +cache: + max_memory_mb: 1024 + rules: + ethereum: + - rpc_method: eth_chainId + ttl: 1h + tron: + - path: /wallet/getchainparameters + method: GET + ttl: 1d + - path: /wallet/listwitnesses + method: GET + ttl: 1d + - path: /wallet/getaccount + method: POST + inflight: true + - path: /wallet/getaccountresource + method: POST + inflight: true + - path: /wallet/getReward + method: POST + inflight: true + solana: + - rpc_method: getVoteAccounts + ttl: 1h + cosmos: + - path: /cosmos/staking/v1beta1/validators + method: GET + ttl: 1d + bitcoin: + - path: /api/v2/block/** + method: GET + ttl: 0s + hypercore: + - path: /info + method: POST + ttl: 1h + params: + type: metaAndAssetCtxs + aptos: + - path: /v1/view + method: POST + ttl: 1h + params: + function: "0x1::delegation_pool::operator_commission_percentage" diff --git a/core/apps/dynode/justfile b/core/apps/dynode/justfile new file mode 100644 index 0000000000..1aeff62610 --- /dev/null +++ b/core/apps/dynode/justfile @@ -0,0 +1,49 @@ +# List available recipes +default: + @just --list + +# Build the project +build: + cargo build + +# Build the project in release mode +build-release: + cargo build --release + +# Run unit tests +test: + cargo test --lib --bins + +# Check code compilation without building +check: + cargo check + +# Run clippy for linting +lint: + cargo clippy -- -W clippy::all + +# Format code +fmt: + cargo fmt + +# Clean build artifacts +clean: + cargo clean + +# Run the application +run: + cargo run + +# Run tests with output +test-verbose: + cargo test --lib --bins -- --nocapture + +# Build and test everything +ci: check lint test + +# Watch for changes and run tests +watch-test: + cargo watch -x test + +# Check, build, and test +all: check build test diff --git a/core/apps/dynode/src/auth.rs b/core/apps/dynode/src/auth.rs new file mode 100644 index 0000000000..2e6ce8f5a2 --- /dev/null +++ b/core/apps/dynode/src/auth.rs @@ -0,0 +1,40 @@ +use crate::config::JwtConfig; +use crate::metrics::Metrics; +use gem_auth::verify_device_token; +use primitives::AuthStatus; +use rocket::http::Status; +use rocket::request::FromRequest; +use rocket::response::{self, Responder, Response}; +use rocket::{Request, State}; + +pub struct BearerToken(Option); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for BearerToken { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome { + let token = request.headers().get_one("Authorization").and_then(|h| h.strip_prefix("Bearer ")).map(|t| t.to_string()); + rocket::request::Outcome::Success(BearerToken(token)) + } +} + +pub struct AuthResponse(AuthStatus); + +impl<'r> Responder<'r, 'static> for AuthResponse { + fn respond_to(self, _request: &'r Request<'_>) -> response::Result<'static> { + let status = match self.0 { + AuthStatus::Valid => Status::Ok, + AuthStatus::Invalid => Status::Unauthorized, + }; + Response::build().status(status).ok() + } +} + +#[rocket::get("/auth")] +pub async fn auth_endpoint(bearer: BearerToken, metrics: &State, jwt: &State) -> AuthResponse { + let is_valid = bearer.0.as_deref().is_some_and(|token| verify_device_token(token, &jwt.secret).is_ok()); + let status = if is_valid { AuthStatus::Valid } else { AuthStatus::Invalid }; + metrics.add_auth_request(status.as_ref()); + AuthResponse(status) +} diff --git a/core/apps/dynode/src/cache/memory.rs b/core/apps/dynode/src/cache/memory.rs new file mode 100644 index 0000000000..af204fc816 --- /dev/null +++ b/core/apps/dynode/src/cache/memory.rs @@ -0,0 +1,349 @@ +use crate::config::{CacheConfig, CacheRule}; +use crate::jsonrpc_types::{JsonRpcCall, JsonRpcRequest, RequestType}; +use crate::proxy::CachedResponse; +use primitives::Chain; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; + +use super::CacheProvider; +use super::types::CacheEntry; + +#[derive(Debug, Clone)] +pub struct MemoryCache { + caches: Arc>>>>, + config: CacheConfig, +} + +impl MemoryCache { + pub fn new(config: CacheConfig) -> Self { + let mut caches = HashMap::new(); + for chain_name in config.rules.keys() { + caches.insert(chain_name.clone(), Arc::new(RwLock::new(HashMap::new()))); + } + Self { caches: Arc::new(caches), config } + } + + fn max_size_per_chain(&self) -> usize { + let chain_count = self.caches.len().max(1); + (self.config.max_memory_mb * 1_000_000) / chain_count + } + + fn evict_if_needed(cache: &mut HashMap, max_size: usize) { + let mut size = 0; + cache.retain(|_, entry| { + if entry.is_expired() { + false + } else { + size += entry.size(); + true + } + }); + + if size <= max_size { + return; + } + + let mut valid_entries: Vec<_> = cache.iter().map(|(key, entry)| (key.clone(), entry.created_at)).collect(); + valid_entries.sort_unstable_by_key(|(_, created)| *created); + + for (key, _) in valid_entries { + if size <= max_size { + break; + } + if let Some(entry) = cache.remove(&key) { + size -= entry.size(); + } + } + } + + fn get_cache_rules(&self, chain: &Chain) -> &[CacheRule] { + static EMPTY: &[CacheRule] = &[]; + self.config.rules.get(chain.as_ref()).map(|v| v.as_slice()).unwrap_or(EMPTY) + } + + fn rule_for_request<'a>(&'a self, chain: &Chain, request_type: &RequestType) -> Option<&'a CacheRule> { + self.get_cache_rules(chain).iter().find(|rule| match request_type { + RequestType::Regular { path, method, body } => rule.matches_path_request(path, method, Some(body.as_slice())), + RequestType::JsonRpc(JsonRpcRequest::Single(call)) => rule.matches_rpc(&call.method), + RequestType::JsonRpc(JsonRpcRequest::Batch(_)) => false, + }) + } +} + +impl CacheProvider for MemoryCache { + async fn get(&self, chain: &Chain, key: &str) -> Option { + let cache = self.caches.get(chain.as_ref())?; + let read_guard = cache.read().await; + let entry = read_guard.get(key)?; + if entry.is_expired() { + drop(read_guard); + cache.write().await.remove(key); + return None; + } + Some(entry.response.clone()) + } + + async fn set(&self, chain: &Chain, key: String, response: CachedResponse, ttl: Duration) { + if let Some(cache) = self.caches.get(chain.as_ref()) { + let entry = CacheEntry::new(response, ttl); + let mut guard = cache.write().await; + guard.insert(key, entry); + Self::evict_if_needed(&mut guard, self.max_size_per_chain()); + } + } + + fn should_cache(&self, chain: &Chain, path: &str, method: &str, body: Option<&[u8]>) -> Option { + self.get_cache_rules(chain).iter().find_map(|rule| rule.matches_path(path, method, body)) + } + + fn should_cache_request(&self, chain: &Chain, request_type: &RequestType) -> Option { + self.rule_for_request(chain, request_type).and_then(|rule| rule.ttl) + } + + fn should_cache_call(&self, chain: &Chain, call: &JsonRpcCall) -> Option { + self.get_cache_rules(chain).iter().find_map(|rule| rule.matches_rpc_method(&call.method)) + } + + fn should_inflight_request(&self, chain: &Chain, request_type: &RequestType) -> bool { + self.rule_for_request(chain, request_type).is_some_and(|rule| match request_type { + RequestType::Regular { path, method, body } => rule.matches_path_inflight(path, method, Some(body.as_slice())), + RequestType::JsonRpc(JsonRpcRequest::Single(_)) => false, + RequestType::JsonRpc(JsonRpcRequest::Batch(_)) => false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::constants::JSON_CONTENT_TYPE; + use reqwest::StatusCode; + use std::collections::HashMap; + + fn create_test_config() -> CacheConfig { + let mut rules = HashMap::new(); + rules.insert( + "ethereum".to_string(), + vec![ + CacheRule { + path: Some("/api/v1/data".to_string()), + method: Some("GET".to_string()), + rpc_method: None, + ttl: Some(Duration::from_secs(300)), + inflight: false, + params: HashMap::new(), + }, + CacheRule { + path: None, + method: None, + rpc_method: Some("eth_blockNumber".to_string()), + ttl: Some(Duration::from_secs(60)), + inflight: false, + params: HashMap::new(), + }, + ], + ); + + CacheConfig { max_memory_mb: 64, rules } + } + + #[tokio::test] + async fn test_set_and_get_cache() { + let config = create_test_config(); + let cache = MemoryCache::new(config); + let chain = Chain::Ethereum; + + let response = CachedResponse::new(b"test".to_vec(), StatusCode::OK.as_u16(), JSON_CONTENT_TYPE.to_string(), Duration::from_secs(60)); + cache.set(&chain, "test_key".to_string(), response.clone(), Duration::from_secs(60)).await; + + let cached = cache.get(&chain, "test_key").await.unwrap(); + assert_eq!(cached.body, response.body); + assert_eq!(cached.status, response.status); + } + + #[test] + fn test_should_cache_path_rule() { + let config = create_test_config(); + let cache = MemoryCache::new(config.clone()); + let chain = Chain::Ethereum; + + let ttl = cache.should_cache(&chain, "/api/v1/data", "GET", None); + assert_eq!(ttl, Some(Duration::from_secs(300))); + + let ttl = cache.should_cache(&chain, "/api/v1/data", "POST", None); + assert_eq!(ttl, None); + } + + #[test] + fn test_should_cache_with_params() { + let mut config = create_test_config(); + if let Some(rules) = config.rules.get_mut("ethereum") { + let mut params = HashMap::new(); + params.insert("type".to_string(), serde_json::json!("metaAndAssetCtxs")); + + rules.push(CacheRule { + path: Some("/info".to_string()), + method: Some("POST".to_string()), + rpc_method: None, + ttl: Some(Duration::from_secs(200)), + inflight: false, + params, + }); + } + + let cache = MemoryCache::new(config); + let chain = Chain::Ethereum; + + let matching_body = r#"{"type":"metaAndAssetCtxs"}"#.as_bytes().to_vec(); + let ttl = cache.should_cache(&chain, "/info", "POST", Some(matching_body.as_slice())); + assert_eq!(ttl, Some(Duration::from_secs(200))); + + let non_matching_body = r#"{"type":"other"}"#.as_bytes().to_vec(); + let ttl = cache.should_cache(&chain, "/info", "POST", Some(non_matching_body.as_slice())); + assert_eq!(ttl, None); + + let ttl = cache.should_cache(&chain, "/info", "POST", None); + assert_eq!(ttl, None); + } + + #[test] + fn test_should_cache_request() { + let config = create_test_config(); + let cache = MemoryCache::new(config.clone()); + let chain = Chain::Ethereum; + + let request = RequestType::JsonRpc(JsonRpcRequest::Single(JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "eth_blockNumber".to_string(), + params: serde_json::json!([]), + id: 1, + })); + + let ttl = cache.should_cache_request(&chain, &request); + assert_eq!(ttl, Some(Duration::from_secs(60))); + } + + #[test] + fn test_should_cache_call() { + let config = create_test_config(); + let cache = MemoryCache::new(config.clone()); + let chain = Chain::Ethereum; + + let call = JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "eth_blockNumber".to_string(), + params: serde_json::json!([]), + id: 1, + }; + + let ttl = cache.should_cache_call(&chain, &call); + assert_eq!(ttl, Some(Duration::from_secs(60))); + } + + #[test] + fn test_should_cache_with_function_params() { + let mut config = create_test_config(); + let mut aptos_rules = Vec::new(); + let mut params = HashMap::new(); + params.insert("function".to_string(), serde_json::json!("0x1::delegation_pool::operator_commission_percentage")); + + aptos_rules.push(CacheRule { + path: Some("/v1/view".to_string()), + method: Some("POST".to_string()), + rpc_method: None, + ttl: Some(Duration::from_secs(3600)), + inflight: false, + params, + }); + + config.rules.insert("aptos".to_string(), aptos_rules); + let cache = MemoryCache::new(config); + let chain = Chain::Aptos; + + let body1 = r#"{ + "function": "0x1::delegation_pool::operator_commission_percentage", + "type_arguments": [], + "arguments": ["0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e"] + }"# + .as_bytes() + .to_vec(); + + let ttl = cache.should_cache(&chain, "/v1/view", "POST", Some(body1.as_slice())); + assert_eq!(ttl, Some(Duration::from_secs(3600))); + + let body2 = r#"{ + "function": "0x1::delegation_pool::operator_commission_percentage", + "type_arguments": [], + "arguments": ["0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"] + }"# + .as_bytes() + .to_vec(); + + let ttl = cache.should_cache(&chain, "/v1/view", "POST", Some(body2.as_slice())); + assert_eq!(ttl, Some(Duration::from_secs(3600))); + + let body3 = r#"{ + "function": "0x1::other_module::other_function", + "type_arguments": [], + "arguments": ["0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e"] + }"# + .as_bytes() + .to_vec(); + + let ttl = cache.should_cache(&chain, "/v1/view", "POST", Some(body3.as_slice())); + assert_eq!(ttl, None); + } + + #[tokio::test] + async fn test_eviction() { + let mut rules = HashMap::new(); + rules.insert("ethereum".to_string(), vec![]); + + let config = CacheConfig { + max_memory_mb: 0, // Force eviction on any insert + rules, + }; + let cache = MemoryCache::new(config); + let chain = Chain::Ethereum; + + // Insert first entry + let response1 = CachedResponse::new(b"first".to_vec(), StatusCode::OK.as_u16(), JSON_CONTENT_TYPE.to_string(), Duration::from_secs(60)); + cache.set(&chain, "key1".to_string(), response1, Duration::from_secs(60)).await; + + // Insert second entry - should evict first due to max_memory_mb = 0 + let response2 = CachedResponse::new(b"second".to_vec(), StatusCode::OK.as_u16(), JSON_CONTENT_TYPE.to_string(), Duration::from_secs(60)); + cache.set(&chain, "key2".to_string(), response2, Duration::from_secs(60)).await; + + // First key should be evicted + assert!(cache.get(&chain, "key1").await.is_none()); + // Second key might also be evicted depending on size, but let's just verify eviction happened + } + + #[test] + fn test_should_inflight_request() { + let mut config = create_test_config(); + config.rules.insert( + "tron".to_string(), + vec![CacheRule { + path: Some("/wallet/getaccount".to_string()), + method: Some("POST".to_string()), + rpc_method: None, + ttl: None, + inflight: true, + params: HashMap::new(), + }], + ); + let cache = MemoryCache::new(config); + let chain = Chain::Tron; + let request_type = RequestType::Regular { + path: "/wallet/getaccount".to_string(), + method: "POST".to_string(), + body: br#"{"address":"T...","visible":true}"#.to_vec(), + }; + + assert!(cache.should_inflight_request(&chain, &request_type)); + assert_eq!(cache.should_cache_request(&chain, &request_type), None); + } +} diff --git a/core/apps/dynode/src/cache/mod.rs b/core/apps/dynode/src/cache/mod.rs new file mode 100644 index 0000000000..28ea5c27ef --- /dev/null +++ b/core/apps/dynode/src/cache/mod.rs @@ -0,0 +1,21 @@ +mod memory; +mod types; + +use crate::jsonrpc_types::{JsonRpcCall, RequestType}; +use crate::proxy::CachedResponse; +use primitives::Chain; +use std::future::Future; +use std::time::Duration; + +pub use memory::MemoryCache; + +pub trait CacheProvider: Send + Sync { + fn get(&self, chain: &Chain, key: &str) -> impl Future> + Send; + fn set(&self, chain: &Chain, key: String, response: CachedResponse, ttl: Duration) -> impl Future + Send; + fn should_cache(&self, chain: &Chain, path: &str, method: &str, body: Option<&[u8]>) -> Option; + fn should_cache_request(&self, chain: &Chain, request_type: &RequestType) -> Option; + fn should_cache_call(&self, chain: &Chain, call: &JsonRpcCall) -> Option; + fn should_inflight_request(&self, chain: &Chain, request_type: &RequestType) -> bool; +} + +pub type RequestCache = MemoryCache; diff --git a/core/apps/dynode/src/cache/types.rs b/core/apps/dynode/src/cache/types.rs new file mode 100644 index 0000000000..93f1adab89 --- /dev/null +++ b/core/apps/dynode/src/cache/types.rs @@ -0,0 +1,63 @@ +use crate::proxy::CachedResponse; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub struct CacheEntry { + pub response: CachedResponse, + pub expires_at: Option, + pub created_at: Instant, +} + +impl CacheEntry { + pub fn new(response: CachedResponse, ttl: Duration) -> Self { + let expires_at = if ttl.is_zero() { None } else { Some(Instant::now() + ttl) }; + Self { + response, + expires_at, + created_at: Instant::now(), + } + } + + pub fn is_expired(&self) -> bool { + self.expires_at.is_some_and(|exp| Instant::now() > exp) + } + + pub fn size(&self) -> usize { + self.response.body.len() + self.response.content_type.len() + 64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::constants::JSON_CONTENT_TYPE; + use reqwest::StatusCode; + + #[test] + fn test_cache_entry_with_ttl() { + let response = CachedResponse::new(b"test".to_vec(), StatusCode::OK.as_u16(), JSON_CONTENT_TYPE.to_string(), Duration::from_secs(60)); + let entry = CacheEntry::new(response, Duration::from_secs(60)); + + assert!(entry.expires_at.is_some()); + assert!(!entry.is_expired()); + } + + #[test] + fn test_cache_entry_without_ttl() { + let response = CachedResponse::new(b"test".to_vec(), StatusCode::OK.as_u16(), JSON_CONTENT_TYPE.to_string(), Duration::ZERO); + let entry = CacheEntry::new(response, Duration::ZERO); + + assert!(entry.expires_at.is_none()); + assert!(!entry.is_expired()); + } + + #[test] + fn test_cache_entry_size() { + let body = b"hello world".to_vec(); + let content_type = "application/json".to_string(); + let response = CachedResponse::new(body.clone(), StatusCode::OK.as_u16(), content_type.clone(), Duration::from_secs(60)); + let entry = CacheEntry::new(response, Duration::from_secs(60)); + + assert_eq!(entry.size(), body.len() + content_type.len() + 64); + } +} diff --git a/core/apps/dynode/src/config/cache.rs b/core/apps/dynode/src/config/cache.rs new file mode 100644 index 0000000000..5cef40b3a2 --- /dev/null +++ b/core/apps/dynode/src/config/cache.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; +use serde_serializers::duration; + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct CacheConfig { + #[serde(default)] + pub max_memory_mb: usize, + #[serde(default)] + pub rules: HashMap>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CacheRule { + pub path: Option, + pub method: Option, + pub rpc_method: Option, + #[serde(default, alias = "ttl_seconds", deserialize_with = "duration::deserialize_option")] + pub ttl: Option, + #[serde(default)] + pub inflight: bool, + #[serde(default)] + pub params: HashMap, +} + +impl CacheRule { + pub fn matches_path(&self, path: &str, method: &str, body: Option<&[u8]>) -> Option { + self.matches_path_request(path, method, body).then_some(self.ttl).flatten() + } + + pub fn matches_rpc_method(&self, rpc_method: &str) -> Option { + self.matches_rpc(rpc_method).then_some(self.ttl).flatten() + } + + pub fn matches_path_inflight(&self, path: &str, method: &str, body: Option<&[u8]>) -> bool { + self.inflight && self.matches_path_request(path, method, body) + } + + pub fn matches_rpc_method_inflight(&self, rpc_method: &str) -> bool { + self.inflight && self.matches_rpc(rpc_method) + } + + pub(crate) fn matches_path_request(&self, path: &str, method: &str, body: Option<&[u8]>) -> bool { + let Some(rule_method) = self.method.as_ref() else { + return false; + }; + if method != rule_method { + return false; + } + + let Some(rule_path) = self.path.as_ref() else { + return false; + }; + let path_without_query = path.split('?').next().unwrap_or(path); + path_without_query == rule_path && self.matches_body(body) + } + + pub(crate) fn matches_rpc(&self, rpc_method: &str) -> bool { + self.rpc_method.as_ref().is_some_and(|m| m == rpc_method) + } + + fn matches_body(&self, body: Option<&[u8]>) -> bool { + if self.params.is_empty() { + return true; + } + + let Some(body_bytes) = body else { + return false; + }; + + let Ok(value) = serde_json::from_slice::(body_bytes) else { + return false; + }; + + let Some(object) = value.as_object() else { + return false; + }; + + self.params.iter().all(|(key, expected)| object.get(key).map(|actual| actual == expected).unwrap_or(false)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_matches_path() { + let rule = CacheRule { + path: Some("/api/data".to_string()), + method: Some("GET".to_string()), + rpc_method: None, + ttl: Some(Duration::from_secs(60)), + inflight: false, + params: HashMap::new(), + }; + + assert_eq!(rule.matches_path("/api/data", "GET", None), Some(Duration::from_secs(60))); + assert_eq!(rule.matches_path("/api/data?q=1", "GET", None), Some(Duration::from_secs(60))); + assert_eq!(rule.matches_path("/api/data", "POST", None), None); + assert_eq!(rule.matches_path("/other", "GET", None), None); + } + + #[test] + fn test_matches_rpc_method() { + let rule = CacheRule { + path: None, + method: None, + rpc_method: Some("eth_blockNumber".to_string()), + ttl: Some(Duration::from_secs(30)), + inflight: false, + params: HashMap::new(), + }; + + assert_eq!(rule.matches_rpc_method("eth_blockNumber"), Some(Duration::from_secs(30))); + assert_eq!(rule.matches_rpc_method("eth_getBalance"), None); + } + + #[test] + fn test_matches_path_inflight() { + let rule = CacheRule { + path: Some("/wallet/getaccount".to_string()), + method: Some("POST".to_string()), + rpc_method: None, + ttl: None, + inflight: true, + params: HashMap::new(), + }; + + assert!(rule.matches_path_inflight("/wallet/getaccount", "POST", Some(br#"{"address":"abc"}"#))); + assert_eq!(rule.matches_path("/wallet/getaccount", "POST", Some(br#"{"address":"abc"}"#)), None); + } + + #[test] + fn test_ttl_default_none() { + let rule: CacheRule = serde_json::from_value(serde_json::json!({ + "path": "/wallet/getaccount", + "method": "POST", + "inflight": true + })) + .unwrap(); + + assert!(rule.inflight); + assert_eq!(rule.ttl, None); + } + + #[test] + fn test_ttl_duration_string() { + let rule: CacheRule = serde_json::from_value(serde_json::json!({ + "path": "/api/data", + "method": "GET", + "ttl": "1m" + })) + .unwrap(); + + assert_eq!(rule.ttl, Some(Duration::from_secs(60))); + } +} diff --git a/core/apps/dynode/src/config/domain.rs b/core/apps/dynode/src/config/domain.rs new file mode 100644 index 0000000000..54e1d67b00 --- /dev/null +++ b/core/apps/dynode/src/config/domain.rs @@ -0,0 +1,180 @@ +use primitives::Chain; +use serde::Deserialize; +use std::time::Duration; + +use super::NodeMonitoringConfig; +use super::url::{Override, Url}; + +#[derive(Debug, Clone, Deserialize)] +pub struct ChainConfig { + pub chain: Chain, + pub poll_interval_seconds: Option, + pub overrides: Option>, + pub urls: Vec, +} + +impl ChainConfig { + pub fn poll_interval(&self, monitoring_config: &NodeMonitoringConfig) -> Duration { + self.poll_interval_seconds.map(Duration::from_secs).unwrap_or(monitoring_config.poll_interval) + } + + pub fn resolve_url(&self, base_url: &Url, rpc_method: Option<&str>, request_path: Option<&str>) -> Url { + let Some(overrides) = &self.overrides else { + return base_url.clone(); + }; + + for override_config in overrides { + let rpc_matches = override_config + .rpc_method + .as_ref() + .is_none_or(|override_method| Some(override_method.as_str()) == rpc_method); + + let path_matches = override_config.path.as_ref().is_none_or(|override_path| Some(override_path.as_str()) == request_path); + + if rpc_matches && path_matches { + return Url { + url: override_config.url.clone(), + headers: base_url.headers.clone(), + }; + } + } + + base_url.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::config as testkit; + use std::time::Duration; + + fn make_chain_config(poll_interval: Option) -> ChainConfig { + ChainConfig { + chain: primitives::Chain::Ethereum, + poll_interval_seconds: poll_interval, + overrides: None, + urls: vec![], + } + } + + fn make_url(url: &str) -> Url { + Url { + url: url.to_string(), + headers: None, + } + } + + fn make_chain_config_with_overrides(overrides: Vec) -> ChainConfig { + ChainConfig { + chain: primitives::Chain::Ethereum, + poll_interval_seconds: None, + overrides: Some(overrides), + urls: vec![make_url("https://example.com")], + } + } + + fn make_monitoring_config(poll_interval: u64) -> NodeMonitoringConfig { + NodeMonitoringConfig { + poll_interval: Duration::from_secs(poll_interval), + ..testkit::monitoring_config() + } + } + + #[test] + fn poll_interval_uses_chain_override() { + let chain_config = make_chain_config(Some(20)); + let config = make_monitoring_config(45); + assert_eq!(chain_config.poll_interval(&config), Duration::from_secs(20)); + } + + #[test] + fn poll_interval_uses_global_fallback() { + let chain_config = make_chain_config(None); + let config = make_monitoring_config(45); + assert_eq!(chain_config.poll_interval(&config), Duration::from_secs(45)); + } + + #[test] + fn resolve_url_without_override() { + let chain_config = make_chain_config(None); + let base_url = make_url("https://example.com/rpc"); + assert_eq!(chain_config.resolve_url(&base_url, Some("eth_sendTransaction"), None).url, "https://example.com/rpc"); + } + + #[test] + fn resolve_url_with_rpc_method_override() { + let chain_config = make_chain_config_with_overrides(vec![Override { + rpc_method: Some("eth_sendTransaction".to_string()), + path: None, + url: "https://tx-relay.example.com".to_string(), + }]); + let base_url = make_url("https://example.com/rpc"); + assert_eq!(chain_config.resolve_url(&base_url, Some("eth_sendTransaction"), None).url, "https://tx-relay.example.com"); + } + + #[test] + fn resolve_url_with_rpc_method_and_path_override() { + let chain_config = make_chain_config_with_overrides(vec![Override { + rpc_method: Some("eth_sendTransaction".to_string()), + path: None, + url: "https://tx-relay.example.com/tx/submit".to_string(), + }]); + let base_url = make_url("https://example.com/rpc"); + assert_eq!( + chain_config.resolve_url(&base_url, Some("eth_sendTransaction"), None).url, + "https://tx-relay.example.com/tx/submit" + ); + } + + #[test] + fn resolve_url_without_matching_override() { + let chain_config = make_chain_config_with_overrides(vec![Override { + rpc_method: Some("eth_sendTransaction".to_string()), + path: None, + url: "https://tx-relay.example.com".to_string(), + }]); + let base_url = make_url("https://example.com/rpc"); + assert_eq!(chain_config.resolve_url(&base_url, Some("eth_blockNumber"), None).url, "https://example.com/rpc"); + } + + #[test] + fn resolve_url_with_wildcard_override() { + let chain_config = make_chain_config_with_overrides(vec![Override { + rpc_method: None, + path: None, + url: "https://fallback.example.com/v2/rpc".to_string(), + }]); + let base_url = make_url("https://example.com/rpc"); + assert_eq!( + chain_config.resolve_url(&base_url, Some("eth_blockNumber"), None).url, + "https://fallback.example.com/v2/rpc" + ); + } + + #[test] + fn resolve_url_with_path_override() { + let chain_config = make_chain_config_with_overrides(vec![Override { + rpc_method: None, + path: Some("/api/v1/block".to_string()), + url: "https://api.example.com/v2/block".to_string(), + }]); + let base_url = make_url("https://example.com"); + assert_eq!(chain_config.resolve_url(&base_url, None, Some("/api/v1/block")).url, "https://api.example.com/v2/block"); + } + + #[test] + fn resolve_url_preserves_headers() { + let chain_config = make_chain_config_with_overrides(vec![Override { + rpc_method: Some("eth_sendTransaction".to_string()), + path: None, + url: "https://tx-relay.example.com".to_string(), + }]); + let base_url = Url { + url: "https://example.com/rpc".to_string(), + headers: Some(std::collections::HashMap::from([("x-api-key".to_string(), "test123".to_string())])), + }; + let resolved = chain_config.resolve_url(&base_url, Some("eth_sendTransaction"), None); + assert_eq!(resolved.headers.as_ref().unwrap().get("x-api-key").unwrap(), "test123"); + } +} diff --git a/core/apps/dynode/src/config/metrics.rs b/core/apps/dynode/src/config/metrics.rs new file mode 100644 index 0000000000..efdfe51d96 --- /dev/null +++ b/core/apps/dynode/src/config/metrics.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MetricsConfig { + #[serde(default)] + pub prefix: String, +} diff --git a/core/apps/dynode/src/config/mod.rs b/core/apps/dynode/src/config/mod.rs new file mode 100644 index 0000000000..4c96cf351a --- /dev/null +++ b/core/apps/dynode/src/config/mod.rs @@ -0,0 +1,301 @@ +use std::{ + collections::{HashMap, HashSet}, + env, fs, + path::{Path, PathBuf}, + time::Duration, +}; + +use config::{Config, ConfigError, Environment, File}; +use primitives::Chain; +use serde::Deserialize; +use serde_serializers::duration; + +mod cache; +mod domain; +mod metrics; +mod url; + +pub use cache::{CacheConfig, CacheRule}; +pub use domain::ChainConfig; +pub use metrics::MetricsConfig; +pub use url::{NodeResult, Override, Url}; + +#[derive(Debug, Deserialize, Clone)] +pub struct NodeMonitoringConfig { + pub enabled: bool, + #[serde(deserialize_with = "duration::deserialize")] + pub poll_interval: Duration, + #[serde(deserialize_with = "duration::deserialize")] + pub max_sync_delay: Duration, + pub max_sync_blocks: u64, + #[serde(deserialize_with = "duration::deserialize_option")] + pub latency_threshold: Option, + #[serde(default)] + pub latency_threshold_percent: Option, +} + +impl NodeMonitoringConfig { + pub fn block_delay_threshold(&self, chain: Chain) -> u64 { + let block_time_ms = chain.block_time() as u64; + if block_time_ms == 0 { + return 1; + } + let computed = self.max_sync_delay.as_millis() as u64 / block_time_ms; + computed.clamp(1, self.max_sync_blocks) + } + + pub fn is_latency_improvement_significant(&self, old: Duration, new: Duration) -> bool { + if new >= old { + return false; + } + let diff = old - new; + if let Some(threshold) = self.latency_threshold + && diff < threshold + { + return false; + } + if let Some(percent) = self.latency_threshold_percent + && (diff.as_millis() as f64 / old.as_millis() as f64) * 100.0 < percent + { + return false; + } + true + } +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct ErrorMatcherConfig { + pub status_codes: Vec, + pub error_messages: Vec, +} + +impl ErrorMatcherConfig { + pub fn matches_status(&self, status: u16) -> bool { + self.status_codes.contains(&status) + } + + pub fn matches_message(&self, message: &str) -> bool { + if message.is_empty() { + return false; + } + + let message_lower = message.to_ascii_lowercase(); + self.error_messages.iter().any(|pattern| { + let pattern = pattern.trim(); + !pattern.is_empty() && message_lower.contains(&pattern.to_ascii_lowercase()) + }) + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct RetryConfig { + pub enabled: bool, + pub max_attempts: usize, + pub errors: ErrorMatcherConfig, +} + +impl RetryConfig { + pub fn matches_status(&self, status: u16) -> bool { + self.errors.matches_status(status) + } + + pub fn matches_message(&self, message: &str) -> bool { + self.errors.matches_message(message) + } + + pub fn effective_max_attempts(&self, urls_count: usize) -> usize { + if self.max_attempts == 0 { urls_count } else { self.max_attempts } + } +} + +#[cfg(test)] +mod tests { + use super::normalize_matcher; + use crate::testkit::config as testkit; + + #[test] + fn test_should_retry_on_error_message() { + let config = testkit::retry_config(true, vec![], vec!["daily request limit", "rate limit"]); + + assert!(config.matches_message("daily request limit reached - upgrade your account")); + assert!(config.matches_message("rate limit exceeded")); + assert!(config.matches_message("Rate Limit Exceeded")); + assert!(!config.matches_message("internal server error")); + assert!(!config.matches_message("")); + } + + #[test] + fn test_should_retry_on_error_message_empty() { + let config = testkit::retry_config(true, vec![], vec![]); + + assert!(!config.matches_message("daily request limit reached")); + } + + #[test] + fn test_matches_status() { + let config = testkit::retry_config(true, vec![401, 403, 429], vec![]); + assert!(config.matches_status(429)); + assert!(!config.matches_status(500)); + } + + #[test] + fn test_matches_message_case_insensitive_without_normalize() { + let config = testkit::retry_config(true, vec![], vec!["RATE LIMIT"]); + assert!(config.matches_message("rate limit exceeded")); + } + + #[test] + fn test_normalize_patterns() { + let mut config = testkit::retry_config(true, vec![], vec![" Rate Limit ", "", "rate limit"]); + normalize_matcher(&mut config.errors); + assert_eq!(config.errors.error_messages, vec!["rate limit".to_string()]); + assert!(config.matches_message("RATE LIMIT EXCEEDED")); + } + + #[test] + fn test_effective_max_attempts() { + let config_zero = testkit::retry_config(true, vec![], vec![]); + assert_eq!(config_zero.effective_max_attempts(5), 5); + assert_eq!(config_zero.effective_max_attempts(10), 10); + + let config_limited = testkit::retry_config_with_attempts(true, 3, vec![], vec![]); + assert_eq!(config_limited.effective_max_attempts(5), 3); + assert_eq!(config_limited.effective_max_attempts(2), 3); + } + + #[test] + fn test_block_delay_threshold() { + use primitives::Chain; + + let config = testkit::monitoring_config(); + + assert_eq!(config.block_delay_threshold(Chain::Ethereum), 2); + assert_eq!(config.block_delay_threshold(Chain::Bitcoin), 1); + assert_eq!(config.block_delay_threshold(Chain::Solana), 20); + assert_eq!(config.block_delay_threshold(Chain::SmartChain), 20); + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct RequestConfig { + #[serde(deserialize_with = "duration::deserialize")] + pub timeout: Duration, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct HeadersConfig { + pub forward: Vec, + #[serde(default)] + pub domains: HashMap>, +} + +impl HeadersConfig { + pub fn get_domain_headers(&self, host: &str) -> Option<&Vec> { + self.domains.get(host) + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct JwtConfig { + pub secret: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct WebhookConfig { + pub enabled: bool, + pub url: String, + #[serde(deserialize_with = "duration::deserialize")] + pub timeout: Duration, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct NodeConfig { + pub port: u16, + pub address: String, + pub metrics: MetricsConfig, + #[serde(default)] + pub cache: CacheConfig, + pub monitoring: NodeMonitoringConfig, + pub retry: RetryConfig, + pub request: RequestConfig, + pub headers: HeadersConfig, + pub jwt: JwtConfig, + pub webhook: WebhookConfig, +} + +impl NodeConfig { + fn normalize(&mut self) { + normalize_matcher(&mut self.retry.errors); + } +} + +fn normalize_matcher(matcher: &mut ErrorMatcherConfig) { + normalize_error_messages(&mut matcher.error_messages); +} + +fn normalize_error_messages(messages: &mut Vec) { + let mut seen = HashSet::with_capacity(messages.len()); + let mut normalized = Vec::with_capacity(messages.len()); + + for message in messages.drain(..) { + let value = message.trim().to_ascii_lowercase(); + if value.is_empty() { + continue; + } + if seen.insert(value.clone()) { + normalized.push(value); + } + } + + *messages = normalized; +} + +#[derive(Debug, Deserialize)] +struct ChainsFile { + chains: Vec, +} + +pub fn load_config() -> Result<(NodeConfig, HashMap), ConfigError> { + let current_dir = env::current_dir().unwrap(); + + let base_dir = if current_dir.join("config.yml").exists() { + current_dir + } else { + current_dir.join("apps/dynode") + }; + + let mut config: NodeConfig = Config::builder() + .add_source(File::from(base_dir.join("config.yml"))) + .add_source(Environment::default().separator("_")) + .build()? + .try_deserialize()?; + config.normalize(); + + let chains = find_chain_files(&base_dir) + .into_iter() + .map(|path| Config::builder().add_source(File::from(path)).build()?.try_deserialize::()) + .collect::, _>>()? + .into_iter() + .flat_map(|cf| cf.chains) + .map(|c| (c.chain, c)) + .collect(); + + Ok((config, chains)) +} + +fn find_chain_files(base_dir: &Path) -> Vec { + let mut files: Vec = fs::read_dir(base_dir) + .into_iter() + .flatten() + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("chains") && name.ends_with(".yml")) + }) + .collect(); + + files.sort(); + files +} diff --git a/core/apps/dynode/src/config/url.rs b/core/apps/dynode/src/config/url.rs new file mode 100644 index 0000000000..fe27182cfb --- /dev/null +++ b/core/apps/dynode/src/config/url.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use url::Url as UrlParser; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Url { + pub url: String, + pub headers: Option>, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Override { + pub rpc_method: Option, + pub path: Option, + pub url: String, +} + +impl Url { + pub fn host(&self) -> String { + if let Ok(parsed_url) = UrlParser::parse(&self.url) { + parsed_url.host_str().unwrap_or_default().to_string() + } else { + self.url.clone() + } + } +} + +#[derive(Debug, Clone)] +pub struct NodeResult { + pub url: Url, + pub block_number: u64, + pub latency: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_url(url: &str) -> Url { + Url { + url: url.to_string(), + headers: None, + } + } + + #[test] + fn host_parsing() { + assert_eq!(make_url("https://alpha.example.test/status").host(), "alpha.example.test"); + assert_eq!(make_url("http://127.0.0.1:8545").host(), "127.0.0.1"); + assert_eq!(make_url("rpc.provider.local").host(), "rpc.provider.local"); + assert_eq!(make_url(" https://example.com ").host(), "example.com"); + assert_eq!(make_url("wss://node.example.com:443/ws").host(), "node.example.com"); + assert_eq!(make_url("https://fallback.example.com:8080/path").host(), "fallback.example.com"); + } +} diff --git a/core/apps/dynode/src/jsonrpc_types.rs b/core/apps/dynode/src/jsonrpc_types.rs new file mode 100644 index 0000000000..d4f2e74561 --- /dev/null +++ b/core/apps/dynode/src/jsonrpc_types.rs @@ -0,0 +1,383 @@ +use crate::proxy::constants::JSON_CONTENT_TYPE; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JsonRpcCall { + pub jsonrpc: String, + pub method: String, + pub params: Value, + pub id: u64, +} + +impl JsonRpcCall { + pub fn cache_key(&self, host: &str, path: &str) -> String { + let base = format!("{}:POST:{}:{}", host, path, self.method); + + if self.params.is_null() { + base + } else { + let params_str = serde_json::to_string(&self.params).unwrap_or_default(); + format!("{}:{}", base, params_str) + } + } +} + +fn default_jsonrpc_version() -> String { + "2.0".to_string() +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JsonRpcResponse { + #[serde(default = "default_jsonrpc_version")] + pub jsonrpc: String, + pub result: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JsonRpcErrorResponse { + #[serde(default = "default_jsonrpc_version")] + pub jsonrpc: String, + pub error: JsonRpcError, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +impl JsonRpcErrorResponse { + pub fn new(message: &str) -> Self { + Self { + jsonrpc: default_jsonrpc_version(), + error: JsonRpcError { + code: -32603, + message: message.to_string(), + }, + id: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum JsonRpcResult { + Success(JsonRpcResponse), + Error(JsonRpcErrorResponse), +} + +impl JsonRpcResult { + pub fn id(&self) -> Option { + match self { + JsonRpcResult::Success(success) => success.id, + JsonRpcResult::Error(error) => error.id, + } + } +} + +#[derive(Debug, Clone)] +pub enum RequestType { + Regular { path: String, method: String, body: Vec }, + JsonRpc(JsonRpcRequest), +} + +#[derive(Debug, Clone)] +pub enum JsonRpcRequest { + Single(JsonRpcCall), + Batch(Vec), +} + +impl JsonRpcRequest { + pub fn cache_key(&self, host: &str, path: &str) -> Option { + match self { + Self::Single(call) => Some(call.cache_key(host, path)), + Self::Batch(_) => None, + } + } + + pub fn get_calls(&self) -> Vec<&JsonRpcCall> { + match self { + Self::Single(call) => vec![call], + Self::Batch(calls) => calls.iter().collect(), + } + } + + pub fn get_methods_list(&self) -> String { + self.get_methods_for_metrics().join(",") + } + + pub fn get_methods_for_metrics(&self) -> Vec { + match self { + Self::Single(call) => vec![call.method.clone()], + Self::Batch(calls) => calls.iter().map(|call| call.method.clone()).collect(), + } + } +} + +impl RequestType { + pub fn from_request(method: &str, path: String, body: Vec) -> Self { + if method == "POST" { + if let Ok(call) = serde_json::from_slice::(&body) { + return RequestType::JsonRpc(JsonRpcRequest::Single(call)); + } + if let Ok(calls) = serde_json::from_slice::>(&body) + && !calls.is_empty() + { + return RequestType::JsonRpc(JsonRpcRequest::Batch(calls)); + } + } + RequestType::Regular { + path, + method: method.to_string(), + body, + } + } + + pub fn get_methods_for_metrics(&self) -> Vec { + match self { + Self::JsonRpc(json_rpc) => json_rpc.get_methods_for_metrics(), + Self::Regular { path, .. } => vec![path.clone()], + } + } + + pub fn get_methods_list(&self) -> String { + self.get_methods_for_metrics().join(",") + } + + pub fn content_type(&self) -> &'static str { + JSON_CONTENT_TYPE + } + + pub fn cache_key(&self, host: &str, path: &str) -> Option { + match self { + Self::Regular { path, method, body } => { + let mut key = format!("{}:{}:{}", host, method, path); + if let Ok(body_str) = std::str::from_utf8(body) { + key.push(':'); + key.push_str(body_str); + } + Some(key) + } + Self::JsonRpc(json_rpc) => json_rpc.cache_key(host, path), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_cache_key_generation() { + let call = JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "eth_blockNumber".to_string(), + params: json!([]), + id: 1, + }; + + let request = JsonRpcRequest::Single(call); + let key = request.cache_key("example.com", "/rpc").unwrap(); + + assert_eq!(key, "example.com:POST:/rpc:eth_blockNumber:[]"); + } + + #[test] + fn test_cache_key_with_params() { + let call = JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "eth_getBalance".to_string(), + params: json!(["0x123", "latest"]), + id: 1, + }; + + let request = JsonRpcRequest::Single(call); + let key = request.cache_key("example.com", "/rpc").unwrap(); + + assert_eq!(key, "example.com:POST:/rpc:eth_getBalance:[\"0x123\",\"latest\"]"); + } + + #[test] + fn test_cache_key_null_params() { + let call = JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "eth_blockNumber".to_string(), + params: json!(null), + id: 1, + }; + + let request = JsonRpcRequest::Single(call); + let key = request.cache_key("example.com", "/rpc").unwrap(); + + assert_eq!(key, "example.com:POST:/rpc:eth_blockNumber"); + } + + #[test] + fn test_batch_request_parsing() { + let batch_json = r#"[ + {"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}, + {"jsonrpc":"2.0","method":"eth_getBalance","params":["0x123","latest"],"id":2} + ]"#; + + let body = batch_json.as_bytes().to_vec(); + let request_type = RequestType::from_request("POST", "/rpc".to_string(), body); + + match request_type { + RequestType::JsonRpc(JsonRpcRequest::Batch(calls)) => { + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].method, "eth_blockNumber"); + assert_eq!(calls[0].id, 1); + assert_eq!(calls[1].method, "eth_getBalance"); + assert_eq!(calls[1].id, 2); + } + _ => panic!("Expected batch request"), + } + } + + #[test] + fn test_batch_cache_key_returns_none() { + let calls = vec![JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "eth_blockNumber".to_string(), + params: json!([]), + id: 1, + }]; + + let request = JsonRpcRequest::Batch(calls); + assert!(request.cache_key("example.com", "/rpc").is_none()); + } + + #[test] + fn test_jsonrpc_call_cache_key() { + let call = JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "eth_getBalance".to_string(), + params: json!(["0x123", "latest"]), + id: 1, + }; + + let key = call.cache_key("example.com", "/rpc"); + assert_eq!(key, "example.com:POST:/rpc:eth_getBalance:[\"0x123\",\"latest\"]"); + } + + #[test] + fn test_jsonrpc_result_id_extraction() { + let success = JsonRpcResult::Success(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + result: json!({"test": "value"}), + id: Some(123), + }); + + let error = JsonRpcResult::Error(JsonRpcErrorResponse { + jsonrpc: "2.0".to_string(), + error: JsonRpcError { + code: -32602, + message: "Invalid params".to_string(), + }, + id: Some(456), + }); + + assert_eq!(success.id(), Some(123)); + assert_eq!(error.id(), Some(456)); + } + + #[test] + fn test_solana_block_cleaned_up_error() { + let response = r#"{ + "jsonrpc": "2.0", + "error": { + "code": -32001, + "message": "Block 370142484 cleaned up, does not exist on node. First available block: 388259953" + }, + "id": 1 + }"#; + + let result: JsonRpcResult = serde_json::from_str(response).unwrap(); + + match result { + JsonRpcResult::Error(error_response) => { + assert_eq!(error_response.error.code, -32001); + assert!(error_response.error.message.contains("cleaned up")); + assert_eq!(error_response.id, Some(1)); + } + _ => panic!("Expected error response"), + } + } + + #[test] + fn test_batch_with_duplicate_ids() { + let batch_json = r#"[ + {"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}, + {"jsonrpc":"2.0","method":"eth_getBalance","params":["0x123","latest"],"id":1}, + {"jsonrpc":"2.0","method":"eth_gasPrice","params":[],"id":1} + ]"#; + + let body = batch_json.as_bytes().to_vec(); + let request_type = RequestType::from_request("POST", "/rpc".to_string(), body); + + match request_type { + RequestType::JsonRpc(JsonRpcRequest::Batch(calls)) => { + assert_eq!(calls.len(), 3); + assert_eq!(calls[0].id, 1); + assert_eq!(calls[1].id, 1); + assert_eq!(calls[2].id, 1); + assert_eq!(calls[0].method, "eth_blockNumber"); + assert_eq!(calls[1].method, "eth_getBalance"); + assert_eq!(calls[2].method, "eth_gasPrice"); + } + _ => panic!("Expected batch request with duplicate IDs"), + } + } + + #[test] + fn test_regular_request_cache_key_with_different_bodies() { + let body1 = r#"{"type":"metaAndAssetCtxs"}"#.as_bytes().to_vec(); + let body2 = r#"{"type":"spotMeta"}"#.as_bytes().to_vec(); + + let request1 = RequestType::Regular { + path: "/info".to_string(), + method: "POST".to_string(), + body: body1, + }; + + let request2 = RequestType::Regular { + path: "/info".to_string(), + method: "POST".to_string(), + body: body2, + }; + + let key1 = request1.cache_key("example.com", "/info").unwrap(); + let key2 = request2.cache_key("example.com", "/info").unwrap(); + + assert_ne!(key1, key2, "Different request bodies should produce different cache keys"); + assert!(key1.contains(r#"{"type":"metaAndAssetCtxs"}"#)); + assert!(key2.contains(r#"{"type":"spotMeta"}"#)); + } + + #[test] + fn test_batch_positional_mapping() { + let calls = [ + JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "method_a".to_string(), + params: json!([]), + id: 999, + }, + JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "method_b".to_string(), + params: json!([]), + id: 999, + }, + ]; + + assert_eq!(calls[0].id, calls[1].id); + assert_ne!(calls[0].method, calls[1].method); + } +} diff --git a/core/apps/dynode/src/lib.rs b/core/apps/dynode/src/lib.rs new file mode 100644 index 0000000000..05e4c2acf5 --- /dev/null +++ b/core/apps/dynode/src/lib.rs @@ -0,0 +1,11 @@ +pub mod auth; +pub mod cache; +pub mod config; +pub mod jsonrpc_types; +pub mod metrics; +pub mod monitoring; +pub mod proxy; +pub mod response; +#[cfg(any(test, feature = "testkit"))] +pub mod testkit; +pub mod webhook; diff --git a/core/apps/dynode/src/main.rs b/core/apps/dynode/src/main.rs new file mode 100644 index 0000000000..3379f8aa75 --- /dev/null +++ b/core/apps/dynode/src/main.rs @@ -0,0 +1,191 @@ +use std::net::IpAddr; +use std::str::FromStr; +use std::sync::Arc; + +use dynode::auth::auth_endpoint; +use dynode::config::load_config; +use dynode::metrics::Metrics; +use dynode::monitoring::{NodeMonitor, NodeService}; +use dynode::proxy::{ProxyRequestBuilder, ProxyResponse}; +use dynode::response::{ErrorResponse, ProxyRocketResponse}; +use dynode::webhook::DynodeBroadcastWebhookClient; +use gem_tracing::{error_with_fields, info_with_fields}; +use primitives::Chain; +use reqwest::Method; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use rocket::config::Config; +use rocket::data::{Data, ToByteUnit}; +use rocket::http::{Method as RocketMethod, Status}; +use rocket::outcome::Outcome as RequestOutcome; +use rocket::response::content::RawText; +use rocket::route::{Handler, Outcome, Route}; +use rocket::tokio::io::AsyncReadExt; +use rocket::{Request, State}; + +#[derive(Clone)] +struct ProxyHandler; + +#[rocket::async_trait] +impl Handler for ProxyHandler { + async fn handle<'r>(&self, request: &'r Request<'_>, data: Data<'r>) -> Outcome<'r> { + let state_outcome = request.guard::<&State>().await; + let node_service = match state_outcome { + RequestOutcome::Success(state) => state, + RequestOutcome::Error((status, _)) | RequestOutcome::Forward(status) => { + return Outcome::from(request, ErrorResponse::new(status, "Failed to access node service".to_string())); + } + }; + + let method = match Method::from_bytes(request.method().as_str().as_bytes()) { + Ok(method) => method, + Err(_) => return Outcome::from(request, ErrorResponse::new(Status::BadRequest, "Invalid HTTP method".to_string())), + }; + + let uri = request.uri().to_string(); + let chain = match resolve_chain(&uri) { + Some(chain) => chain, + None => return Outcome::from(request, ErrorResponse::new(Status::BadRequest, "Invalid chain".to_string())), + }; + + match process_proxy(chain, method, request, data, node_service.inner()).await { + Ok(response) => Outcome::from(request, ProxyRocketResponse(response)), + Err(err) => Outcome::from(request, err), + } + } +} + +fn proxy_routes() -> Vec { + let methods = [ + RocketMethod::Get, + RocketMethod::Post, + RocketMethod::Put, + RocketMethod::Patch, + RocketMethod::Delete, + RocketMethod::Options, + RocketMethod::Head, + ]; + + methods.into_iter().map(|method| Route::new(method, "/", ProxyHandler)).collect() +} + +#[rocket::get("/metrics")] +async fn metrics_endpoint(metrics: &State) -> RawText { + RawText(metrics.get_metrics()) +} + +#[rocket::get("/health")] +async fn health_endpoint() -> Status { + Status::Ok +} + +#[rocket::get("/")] +async fn root_endpoint() -> &'static str { + "ok" +} + +async fn process_proxy(chain: Chain, method: Method, request: &Request<'_>, data: Data<'_>, node_service: &NodeService) -> Result { + let body = read_request_body(data).await?; + let headers = build_header_map(request)?; + let uri = request.uri().to_string(); + + let proxy_request = match ProxyRequestBuilder::build(method.clone(), headers, body, uri, chain) { + Ok(req) => req, + Err(status) => { + let msg = "Failed to build request".to_string(); + return Err(ErrorResponse::new(status, msg)); + } + }; + + node_service.handle_request(proxy_request).await.map_err(|err| { + let error_msg = err.to_string(); + error_with_fields!("Proxy request failed", err.as_ref(),); + ErrorResponse::new(Status::InternalServerError, error_msg) + }) +} + +async fn read_request_body(data: Data<'_>) -> Result, ErrorResponse> { + let mut stream = data.open(32.mebibytes()); + let mut body = Vec::new(); + stream + .read_to_end(&mut body) + .await + .map_err(|_| ErrorResponse::new(Status::InternalServerError, "Failed to read request body".to_string()))?; + Ok(body) +} + +fn build_header_map(request: &Request<'_>) -> Result { + let mut headers = HeaderMap::new(); + for header in request.headers().iter() { + let name = + HeaderName::from_bytes(header.name().as_str().as_bytes()).map_err(|_| ErrorResponse::new(Status::BadRequest, format!("Invalid header name: {}", header.name())))?; + let value = HeaderValue::from_str(header.value()).map_err(|_| ErrorResponse::new(Status::BadRequest, format!("Invalid header value for {}", header.name())))?; + headers.append(name, value); + } + Ok(headers) +} + +fn resolve_chain(path: &str) -> Option { + let chain_str = path.split('?').next()?.trim_start_matches('/').split('/').next()?; + Chain::from_str(chain_str).ok() +} + +#[rocket::main] +async fn main() -> Result<(), Box> { + let (config, chains) = load_config()?; + + let node_address = IpAddr::from_str(config.address.as_str())?; + let metrics = Metrics::new(config.metrics.clone()); + info_with_fields!("broadcast webhook config", enabled = config.webhook.enabled, url = config.webhook.url.as_str(),); + let broadcast_webhook = DynodeBroadcastWebhookClient::new(config.webhook.clone())?; + let client = gem_client::builder().timeout(config.request.timeout).build()?; + let monitoring_config = config.monitoring.clone(); + let node_service = NodeService::new( + chains, + metrics.clone(), + client, + config.cache.clone(), + config.retry.clone(), + config.headers.clone(), + broadcast_webhook, + ); + if monitoring_config.enabled { + let monitor = NodeMonitor::new( + node_service.chains.clone(), + Arc::clone(&node_service.nodes), + Arc::clone(&node_service.metrics), + monitoring_config, + ); + + rocket::tokio::spawn(async move { + monitor.start_monitoring().await; + }); + } + + info_with_fields!("Server started", node_address = &format!("{}:{}", node_address, config.port), metrics_path = "/metrics",); + + let proxy_server = rocket::custom(Config::figment().merge(("address", node_address)).merge(("port", config.port))) + .manage(node_service) + .manage(metrics.clone()) + .manage(config.jwt) + .mount("/", proxy_routes()) + .mount("/", rocket::routes![health_endpoint, root_endpoint, auth_endpoint, metrics_endpoint]); + + proxy_server.launch().await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_chain() { + assert_eq!(resolve_chain("/solana"), Some(Chain::Solana)); + assert_eq!(resolve_chain("/solana?dkey=abc123"), Some(Chain::Solana)); + assert_eq!(resolve_chain("/solana/rpc?dkey=abc123"), Some(Chain::Solana)); + assert_eq!(resolve_chain("/ethereum/v1/rpc"), Some(Chain::Ethereum)); + assert_eq!(resolve_chain("/invalid"), None); + assert_eq!(resolve_chain(""), None); + } +} diff --git a/core/apps/dynode/src/metrics/mod.rs b/core/apps/dynode/src/metrics/mod.rs new file mode 100644 index 0000000000..902b5f5157 --- /dev/null +++ b/core/apps/dynode/src/metrics/mod.rs @@ -0,0 +1,269 @@ +use crate::config::MetricsConfig; +use metrics::MetricsRegistry; +use prometheus_client::encoding::EncodeLabelSet; +use prometheus_client::metrics::counter::Counter; +use prometheus_client::metrics::family::Family; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client::metrics::histogram::{Histogram, exponential_buckets}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct Metrics { + registry: Arc, + proxy_requests: Family, + proxy_requests_by_method: Family, + proxy_response_latency: Family, + node_host_current: Family, + cache_hits: Family, + cache_misses: Family, + inflight_hits: Family, + inflight_misses: Family, + node_switches: Family, + auth_requests: Family, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +pub struct ProxyRequestLabels { + chain: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +pub struct ProxyRequestByMethodLabels { + chain: String, + method: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +pub struct HostCurrentStateLabels { + chain: String, + host: String, +} + +#[derive(Clone, Hash, PartialEq, Eq, Debug, EncodeLabelSet)] +struct ResponseLabels { + chain: String, + host: String, + method: String, + status: u16, +} + +#[derive(Clone, Hash, PartialEq, Eq, Debug, EncodeLabelSet)] +pub struct CacheLabels { + chain: String, + path: String, +} + +#[derive(Clone, Hash, PartialEq, Eq, Debug, EncodeLabelSet)] +pub struct NodeSwitchLabels { + chain: String, + old_host: String, + new_host: String, + reason: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +pub struct AuthLabels { + auth_status: String, +} + +impl Metrics { + pub fn new(config: MetricsConfig) -> Self { + let proxy_requests = Family::::default(); + let proxy_requests_by_method = Family::::default(); + let proxy_response_latency = Family::::new_with_constructor(|| Histogram::new(exponential_buckets(50.0, 2.0, 6))); + let node_host_current = Family::::default(); + let cache_hits = Family::::default(); + let cache_misses = Family::::default(); + let inflight_hits = Family::::default(); + let inflight_misses = Family::::default(); + let node_switches = Family::::default(); + let auth_requests = Family::::default(); + + let mut metrics_registry = MetricsRegistry::with_prefix(&config.prefix); + let registry = metrics_registry.registry_mut(); + registry.register("proxy_requests", "Proxy requests by host", proxy_requests.clone()); + registry.register( + "proxy_requests_by_method", + "Proxy requests by host and method (HTTP path or RPC method)", + proxy_requests_by_method.clone(), + ); + registry.register( + "proxy_response_latency", + "Proxy responses by host, path, method, and status", + proxy_response_latency.clone(), + ); + registry.register("node_host_current", "Node current host url", node_host_current.clone()); + registry.register("cache_hits", "Cache hits by host and path", cache_hits.clone()); + registry.register("cache_misses", "Cache misses by host and path", cache_misses.clone()); + registry.register("inflight_hits", "In-flight coalescing hits by host and path", inflight_hits.clone()); + registry.register("inflight_misses", "In-flight coalescing misses by host and path", inflight_misses.clone()); + registry.register("node_switches", "Node switches by chain", node_switches.clone()); + registry.register("auth_requests", "Auth requests by status", auth_requests.clone()); + + Self { + registry: Arc::new(metrics_registry), + proxy_requests, + proxy_requests_by_method, + proxy_response_latency, + node_host_current, + cache_hits, + cache_misses, + inflight_hits, + inflight_misses, + node_switches, + auth_requests, + } + } + + pub fn add_proxy_request(&self, chain: &str) { + self.proxy_requests.get_or_create(&ProxyRequestLabels { chain: chain.to_string() }).inc(); + } + + pub fn add_proxy_request_by_method(&self, chain: &str, method: &str) { + let method = self.truncate_method(method); + self.proxy_requests_by_method + .get_or_create(&ProxyRequestByMethodLabels { chain: chain.to_string(), method }) + .inc(); + } + + pub fn add_proxy_request_batch(&self, chain: &str, methods: &[String]) { + self.proxy_requests.get_or_create(&ProxyRequestLabels { chain: chain.to_string() }).inc(); + + for method in methods { + let method = self.truncate_method(method); + self.proxy_requests_by_method + .get_or_create(&ProxyRequestByMethodLabels { chain: chain.to_string(), method }) + .inc(); + } + } + + pub fn add_proxy_response(&self, chain: &str, method: &str, host: &str, status: u16, latency: u128) { + let method = self.truncate_method(method); + self.proxy_response_latency + .get_or_create(&ResponseLabels { + chain: chain.to_string(), + host: host.to_string(), + method, + status, + }) + .observe(latency as f64); + } + + pub fn set_node_host_current(&self, chain: &str, host: &str) { + self.node_host_current + .get_or_create(&HostCurrentStateLabels { + chain: chain.to_string(), + host: host.to_string(), + }) + .set(1); + } + + pub fn add_cache_hit(&self, chain: &str, path: &str) { + let path = self.truncate_path(path); + self.cache_hits.get_or_create(&CacheLabels { chain: chain.to_string(), path }).inc(); + } + + pub fn add_cache_miss(&self, chain: &str, path: &str) { + let path = self.truncate_path(path); + self.cache_misses.get_or_create(&CacheLabels { chain: chain.to_string(), path }).inc(); + } + + pub fn add_inflight_hit(&self, chain: &str, path: &str) { + let path = self.truncate_path(path); + self.inflight_hits.get_or_create(&CacheLabels { chain: chain.to_string(), path }).inc(); + } + + pub fn add_inflight_miss(&self, chain: &str, path: &str) { + let path = self.truncate_path(path); + self.inflight_misses.get_or_create(&CacheLabels { chain: chain.to_string(), path }).inc(); + } + + pub fn add_node_switch(&self, chain: &str, old_host: &str, new_host: &str, reason: &str) { + self.node_switches + .get_or_create(&NodeSwitchLabels { + chain: chain.to_string(), + old_host: old_host.to_string(), + new_host: new_host.to_string(), + reason: reason.to_string(), + }) + .inc(); + } + + pub fn add_auth_request(&self, auth_status: &str) { + self.auth_requests + .get_or_create(&AuthLabels { + auth_status: auth_status.to_string(), + }) + .inc(); + } + + pub fn get_metrics(&self) -> String { + self.registry.encode() + } + + fn truncate_method(&self, method: &str) -> String { + if method.contains('/') { self.truncate_path(method) } else { method.to_string() } + } + + fn truncate_path(&self, path: &str) -> String { + let path_part = path.split_once('?').map(|(p, _)| p).unwrap_or(path); + + path_part + .split('/') + .map(|segment| { + if segment.is_empty() { + segment.to_string() + } else if segment.chars().all(|c| c.is_ascii_digit()) { + ":number".to_string() + } else if segment.len() > 20 { + ":value".to_string() + } else { + segment.to_string() + } + }) + .collect::>() + .join("/") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::MetricsConfig; + + fn create_test_metrics() -> Metrics { + let config = MetricsConfig { prefix: "test".to_string() }; + Metrics::new(config) + } + + #[test] + fn test_truncate_path() { + let metrics = create_test_metrics(); + + let test_cases = vec![ + ("/api/v1/verylongsegmentthatisgreaterthan20characters/data", "/api/v1/:value/data"), + ("/block/12345/transactions", "/block/:number/transactions"), + ("/block/12345/tx/67890", "/block/:number/tx/:number"), + ("/api/v1/data", "/api/v1/data"), + ("/api//data", "/api//data"), + // Query params are stripped + ("/api/v2/block/5897744?page=1", "/api/v2/block/:number"), + ("/thorchain/quote/swap?from=X&to=Y", "/thorchain/quote/swap"), + ]; + + for (input, expected) in test_cases { + let result = metrics.truncate_path(input); + assert_eq!(result, expected, "Failed for input: {}", input); + } + } + + #[test] + fn test_truncate_method() { + let m = create_test_metrics(); + + assert_eq!(m.truncate_method("eth_getBlockByNumber"), "eth_getBlockByNumber"); + assert_eq!(m.truncate_method("eth_getBalance"), "eth_getBalance"); + assert_eq!(m.truncate_method("/api/v1/blocks/by_height/12345"), "/api/v1/blocks/by_height/:number"); + assert_eq!(m.truncate_method("/v1/verylongsegmentthatisgreaterthan20characters"), "/v1/:value"); + } +} diff --git a/core/apps/dynode/src/monitoring/chain_client.rs b/core/apps/dynode/src/monitoring/chain_client.rs new file mode 100644 index 0000000000..b452449d50 --- /dev/null +++ b/core/apps/dynode/src/monitoring/chain_client.rs @@ -0,0 +1,29 @@ +use std::time::Instant; + +use primitives::{Chain, NodeStatusState, NodeType}; +use settings_chain::{ProviderConfig, ProviderFactory}; + +use super::sync::NodeStatusObservation; +use crate::config::Url; + +pub struct ChainClient { + config: ProviderConfig, + url: Url, +} + +impl ChainClient { + pub fn new(chain: Chain, url: Url) -> Self { + let config = ProviderConfig::new(chain, &url.url, NodeType::Default, "", ""); + Self { config, url } + } + + pub async fn fetch_status(&self) -> NodeStatusObservation { + let started_at = Instant::now(); + let state = match ProviderFactory::new_provider(self.config.clone(), "dynode_fetch_status").get_node_status().await { + Ok(status) => NodeStatusState::healthy(status), + Err(err) => NodeStatusState::error(err.to_string()), + }; + + NodeStatusObservation::new(self.url.clone(), state, started_at.elapsed()) + } +} diff --git a/core/apps/dynode/src/monitoring/mod.rs b/core/apps/dynode/src/monitoring/mod.rs new file mode 100644 index 0000000000..e3d7583ce9 --- /dev/null +++ b/core/apps/dynode/src/monitoring/mod.rs @@ -0,0 +1,11 @@ +mod chain_client; +mod service; +mod switch_reason; +mod sync; +mod telemetry; +mod worker; + +pub use crate::config::NodeResult; +pub use service::NodeService; +pub use sync::{NodeStatusObservation, NodeSyncAnalyzer}; +pub use worker::NodeMonitor; diff --git a/core/apps/dynode/src/monitoring/service.rs b/core/apps/dynode/src/monitoring/service.rs new file mode 100644 index 0000000000..14e2979aab --- /dev/null +++ b/core/apps/dynode/src/monitoring/service.rs @@ -0,0 +1,386 @@ +use std::error::Error; +use std::{ + collections::{HashMap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, + sync::Arc, +}; + +use reqwest::StatusCode; +use tokio::sync::RwLock; + +use super::switch_reason::NodeSwitchReason; +use crate::cache::RequestCache; +use crate::config::{CacheConfig, ChainConfig, ErrorMatcherConfig, HeadersConfig, RetryConfig, Url}; +use crate::jsonrpc_types::{JsonRpcErrorResponse, RequestType}; +use crate::metrics::Metrics; +use crate::proxy::constants::JSON_CONTENT_TYPE; +use crate::proxy::proxy_builder::ProxyBuilder; +use crate::proxy::proxy_request::ProxyRequest; +use crate::proxy::response_builder::ResponseBuilder; +use crate::proxy::{NodeDomain, ProxyResponse}; +use crate::webhook::DynodeBroadcastWebhookClient; +use gem_tracing::{DurationMs, info_with_fields}; +use primitives::{Chain, ResponseError, response::ErrorDetail}; +use serde_json::Value; +use settings_chain::BroadcastProviders; + +const NODE_NOT_FOUND: &str = "Node not found"; + +#[derive(Clone)] +pub struct NodeService { + pub chains: HashMap, + pub nodes: Arc>>, + pub metrics: Arc, + pub retry_config: RetryConfig, + proxy_builder: ProxyBuilder, +} + +impl NodeService { + pub fn new( + chains: HashMap, + metrics: Metrics, + client: reqwest::Client, + cache_config: CacheConfig, + retry_config: RetryConfig, + headers_config: HeadersConfig, + broadcast_webhook: DynodeBroadcastWebhookClient, + ) -> Self { + let nodes = chains.values().map(|c| (c.chain, NodeDomain::new(c.urls.first().unwrap().clone(), c.clone()))).collect(); + + let cache = RequestCache::new(cache_config); + let broadcast_providers = Arc::new(BroadcastProviders::from_chains(chains.keys().copied())); + let proxy_builder = ProxyBuilder::new(metrics.clone(), cache, client, headers_config, broadcast_webhook, broadcast_providers); + + Self { + chains, + nodes: Arc::new(RwLock::new(nodes)), + metrics: Arc::new(metrics), + retry_config, + proxy_builder, + } + } + + pub async fn get_node_domain(nodes: &Arc>>, chain: Chain) -> Option { + nodes.read().await.get(&chain).cloned() + } + + pub fn sync_current_node_metric(metrics: &Arc, chain: Chain, url: &Url) { + metrics.set_node_host_current(chain.as_ref(), &url.host()); + } + + pub async fn switch_node_if_current( + nodes: &Arc>>, + metrics: &Arc, + chain_config: &ChainConfig, + expected_current: &Url, + selected: &Url, + reason: &NodeSwitchReason, + ) -> Option<(String, String)> { + let (old_host, new_host) = { + let mut nodes_write = nodes.write().await; + let active_node = nodes_write.get(&chain_config.chain)?; + if active_node.url.url != expected_current.url || active_node.url.url == selected.url { + return None; + } + + let old_host = active_node.url.host(); + let new_host = selected.host(); + nodes_write.insert(chain_config.chain, NodeDomain::new(selected.clone(), chain_config.clone())); + (old_host, new_host) + }; + + Self::sync_current_node_metric(metrics, chain_config.chain, selected); + metrics.add_node_switch(chain_config.chain.as_ref(), &old_host, &new_host, reason.as_str()); + Some((old_host, new_host)) + } + + pub async fn handle_request(&self, request: ProxyRequest) -> Result> { + let chain_config = self.get_chain_config(&request)?; + let Some(urls) = self.resolve_request_urls(chain_config, &request).await else { + return self.node_not_found_response(&request); + }; + if urls.len() == 1 { + let primary = NodeDomain::new(urls[0].clone(), chain_config.clone()); + return self.proxy_builder.handle_request(request, &primary).await; + } + + let retry_enabled = self.retry_config.enabled && urls.len() > 1; + let mut last_error: Option = None; + let mut last_error_data: Option = None; + let max_attempts = if retry_enabled { self.retry_config.effective_max_attempts(urls.len()) } else { 1 }; + + for (index, url) in urls.iter().take(max_attempts).enumerate() { + let node_domain = NodeDomain::new(url.clone(), chain_config.clone()); + let remote_host = url.host(); + if index > 0 { + info_with_fields!( + "Retry attempt", + id = request.id.as_str(), + chain = request.chain.as_ref(), + attempt = index + 1, + remote_host = remote_host.as_str(), + reason = last_error.as_deref().unwrap_or(""), + ); + } + match self.proxy_builder.handle_request(request.clone(), &node_domain).await { + Ok(response) => { + let retry_error = self.matches_response_error_signal(&request, &response, &self.retry_config.errors); + if !retry_error { + return Ok(response); + } + + let upstream_data = serde_json::from_slice::(&response.body).ok(); + if !retry_enabled { + return self.log_and_create_error_response(&request, Some(remote_host.as_str()), &format!("Upstream status code: {}", response.status), upstream_data); + } + last_error = Some(format!("status={}", response.status)); + last_error_data = upstream_data; + } + Err(e) => { + if !retry_enabled { + return Err(e); + } + + let error = e.to_string(); + let request_id = request.id.as_str(); + let chain = request.chain.as_ref(); + let latency = DurationMs(request.elapsed()); + info_with_fields!( + "Upstream error", + id = request_id, + chain = chain, + remote_host = remote_host.as_str(), + error = error.as_str(), + latency = latency, + ); + last_error = Some(error); + } + } + } + + let error_message = last_error + .map(|e| format!("All upstream URLs failed, {}", e)) + .unwrap_or_else(|| "All upstream URLs failed".to_string()); + self.log_and_create_error_response(&request, None, &error_message, last_error_data) + } + + fn get_chain_config(&self, request: &ProxyRequest) -> Result<&ChainConfig, Box> { + self.chains.get(&request.chain).ok_or_else(|| format!("Chain {} not configured", request.chain).into()) + } + + async fn resolve_request_urls(&self, chain_config: &ChainConfig, request: &ProxyRequest) -> Option> { + if chain_config.urls.is_empty() { + return None; + } + if chain_config.urls.len() == 1 { + return Some(vec![chain_config.urls[0].clone()]); + } + + let current_node = NodeService::get_node_domain(&self.nodes, chain_config.chain).await?; + Some(Self::get_ordered_urls(&chain_config.urls, ¤t_node.url, request.id.as_str())) + } + + fn node_not_found_response(&self, request: &ProxyRequest) -> Result> { + self.log_and_create_error_response(request, None, NODE_NOT_FOUND, None) + } + + fn get_ordered_urls(urls: &[Url], current: &Url, request_id: &str) -> Vec { + let mut ordered_urls = urls.to_vec(); + if let Some(current_index) = ordered_urls.iter().position(|url| *url == *current) { + ordered_urls.swap(0, current_index); + } + + Self::rotate_fallback_urls(&mut ordered_urls, request_id); + ordered_urls + } + + fn rotate_fallback_urls(urls: &mut [Url], request_id: &str) { + if urls.len() <= 2 { + return; + } + + let mut hasher = DefaultHasher::new(); + request_id.hash(&mut hasher); + let tail_len = urls.len() - 1; + let offset = (hasher.finish() as usize) % tail_len; + if offset > 0 { + urls[1..].rotate_left(offset); + } + } + + fn matches_response_error_signal(&self, request: &ProxyRequest, response: &ProxyResponse, matcher: &ErrorMatcherConfig) -> bool { + if matcher.matches_status(response.status) { + return true; + } + + match request.request_type() { + RequestType::JsonRpc(_) if response.status == StatusCode::OK.as_u16() => { + if let Ok(error_response) = serde_json::from_slice::(&response.body) { + return matcher.matches_message(&error_response.error.message); + } + false + } + _ => false, + } + } + + fn log_and_create_error_response( + &self, + request: &ProxyRequest, + host: Option<&str>, + error_message: &str, + upstream_data: Option, + ) -> Result> { + let request_id = request.id.as_str(); + let chain = request.chain.as_ref(); + let uri = request.path.as_str(); + let method = request.method.as_str(); + let remote_host = host.unwrap_or("none"); + let latency = DurationMs(request.elapsed()); + let status: u16 = 500; + info_with_fields!( + "Proxy response", + id = request_id, + chain = chain, + remote_host = remote_host, + method = method, + uri = uri, + status = status, + error = error_message, + latency = latency, + ); + + let upstream_headers = ResponseBuilder::create_upstream_headers(host, request.elapsed()); + + let response = match request.request_type() { + RequestType::JsonRpc(_) => serde_json::to_value(JsonRpcErrorResponse::new(error_message))?, + RequestType::Regular { .. } => serde_json::to_value(ResponseError { + error: ErrorDetail { + message: error_message.to_string(), + data: upstream_data, + }, + })?, + }; + + let body = serde_json::to_vec(&response)?; + + ResponseBuilder::build_with_headers(body, StatusCode::INTERNAL_SERVER_ERROR.as_u16(), JSON_CONTENT_TYPE, upstream_headers) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CacheConfig, MetricsConfig, Url}; + use crate::testkit::config as testkit; + use primitives::Chain; + use reqwest::{Method, header, header::HeaderMap}; + + fn create_service(chains: HashMap) -> NodeService { + create_service_with_retry(chains, create_retry_config(false, vec![], vec![])) + } + + fn create_service_with_retry(chains: HashMap, retry_config: RetryConfig) -> NodeService { + let metrics = Metrics::new(MetricsConfig::default()); + let broadcast_webhook = DynodeBroadcastWebhookClient::disabled(); + + NodeService::new( + chains, + metrics, + reqwest::Client::new(), + CacheConfig::default(), + retry_config, + HeadersConfig { + forward: vec![header::CONTENT_TYPE.to_string()], + domains: HashMap::new(), + }, + broadcast_webhook, + ) + } + + fn create_retry_config(enabled: bool, status_codes: Vec, error_messages: Vec<&str>) -> RetryConfig { + testkit::retry_config(enabled, status_codes, error_messages) + } + + fn create_chain_config(chain: Chain, url: &str) -> ChainConfig { + ChainConfig { + chain, + poll_interval_seconds: None, + overrides: None, + urls: vec![Url { + url: url.to_string(), + headers: None, + }], + } + } + + fn create_request(host: &str, chain: Chain) -> ProxyRequest { + ProxyRequest::new( + Method::POST, + HeaderMap::new(), + vec![], + "/".to_string(), + "/".to_string(), + host.to_string(), + "test".to_string(), + chain, + ) + } + + #[test] + fn test_get_chain_config_found() { + let chains = HashMap::from([(Chain::Bitcoin, create_chain_config(Chain::Bitcoin, "https://bitcoin.example.com"))]); + let service = create_service(chains); + let request = create_request("any.host.com", Chain::Bitcoin); + + let result = service.get_chain_config(&request); + assert!(result.is_ok()); + assert_eq!(result.unwrap().chain, Chain::Bitcoin); + } + + #[test] + fn test_get_chain_config_not_found() { + let chains = HashMap::from([(Chain::Bitcoin, create_chain_config(Chain::Bitcoin, "https://bitcoin.example.com"))]); + let service = create_service(chains); + let request = create_request("unknown", Chain::Ethereum); + + let result = service.get_chain_config(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Chain ethereum not configured")); + } + + #[test] + fn test_matches_retry_status_codes() { + let chains = HashMap::from([(Chain::Ethereum, create_chain_config(Chain::Ethereum, "https://ethereum.example.com"))]); + let service = create_service_with_retry(chains, create_retry_config(true, vec![429], vec![])); + + let request = create_request("ethereum.example.com", Chain::Ethereum); + let response = ProxyResponse::new(429, HeaderMap::new(), vec![]); + + assert!(service.matches_response_error_signal(&request, &response, &service.retry_config.errors)); + } + + #[test] + fn test_matches_retry_jsonrpc_messages() { + let chains = HashMap::from([(Chain::Ethereum, create_chain_config(Chain::Ethereum, "https://ethereum.example.com"))]); + let service = create_service_with_retry(chains, create_retry_config(true, vec![], vec!["Exceeded the quota usage"])); + + let request = ProxyRequest::new( + Method::POST, + HeaderMap::new(), + br#"{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}"#.to_vec(), + "/".to_string(), + "/".to_string(), + "ethereum.example.com".to_string(), + "test".to_string(), + Chain::Ethereum, + ); + let response = ProxyResponse::new( + 200, + HeaderMap::new(), + br#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"Exceeded the quota usage"},"id":1}"#.to_vec(), + ); + + assert!(service.matches_response_error_signal(&request, &response, &service.retry_config.errors)); + } +} diff --git a/core/apps/dynode/src/monitoring/switch_reason.rs b/core/apps/dynode/src/monitoring/switch_reason.rs new file mode 100644 index 0000000000..e68c31ed2b --- /dev/null +++ b/core/apps/dynode/src/monitoring/switch_reason.rs @@ -0,0 +1,28 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub enum NodeSwitchReason { + BlockHeight { old_block: u64, new_block: u64 }, + Latency { old_latency_ms: u64, new_latency_ms: u64 }, + CurrentNodeError { message: String }, +} + +impl NodeSwitchReason { + pub fn as_str(&self) -> &'static str { + match self { + Self::BlockHeight { .. } => "block_height", + Self::Latency { .. } => "latency", + Self::CurrentNodeError { .. } => "current_node_error", + } + } +} + +impl fmt::Display for NodeSwitchReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BlockHeight { old_block, new_block } => write!(f, "block_behind:{}", new_block.saturating_sub(*old_block)), + Self::Latency { old_latency_ms, new_latency_ms } => write!(f, "latency:{}ms->{}ms", old_latency_ms, new_latency_ms), + Self::CurrentNodeError { message } => write!(f, "{}", message), + } + } +} diff --git a/core/apps/dynode/src/monitoring/sync.rs b/core/apps/dynode/src/monitoring/sync.rs new file mode 100644 index 0000000000..ae38926c31 --- /dev/null +++ b/core/apps/dynode/src/monitoring/sync.rs @@ -0,0 +1,358 @@ +use std::time::Duration; + +use super::switch_reason::NodeSwitchReason; +use crate::config::{NodeMonitoringConfig, Url}; +use primitives::{Chain, NodeStatusState, NodeSyncStatus}; + +#[derive(Debug, Clone)] +pub struct NodeStatusObservation { + pub url: Url, + pub state: NodeStatusState, + pub latency: Duration, +} + +impl NodeStatusObservation { + pub fn new(url: Url, state: NodeStatusState, latency: Duration) -> Self { + Self { url, state, latency } + } +} + +#[derive(Debug, Clone)] +pub struct NodeSwitchResult { + pub observation: NodeStatusObservation, + pub reason: NodeSwitchReason, +} + +pub struct NodeSyncAnalyzer; + +impl NodeSyncAnalyzer { + pub fn is_node_healthy(observation: &NodeStatusObservation) -> bool { + observation.state.is_healthy() + } + + pub fn select_best_node(current: &Url, observations: &[NodeStatusObservation], monitoring_config: &NodeMonitoringConfig, chain: Chain) -> Option { + let current_observation = observations.iter().find(|o| o.url == *current)?; + + let error_reason = match ¤t_observation.state { + NodeStatusState::Error { message } => Some(NodeSwitchReason::CurrentNodeError { message: message.clone() }), + NodeStatusState::Healthy(_) => None, + }; + + let (candidate, candidate_status) = Self::find_best_candidate(current, observations)?; + + if let Some(reason) = error_reason { + return Some(NodeSwitchResult { + observation: candidate.clone(), + reason, + }); + } + + let current_status = current_observation.state.as_status()?; + let reason = Self::evaluate_switch(current_status, candidate_status, current_observation.latency, candidate.latency, monitoring_config, chain)?; + + Some(NodeSwitchResult { + observation: candidate.clone(), + reason, + }) + } + + fn find_best_candidate<'a>(current: &Url, observations: &'a [NodeStatusObservation]) -> Option<(&'a NodeStatusObservation, &'a NodeSyncStatus)> { + observations + .iter() + .filter(|observation| observation.url != *current) + .filter_map(|observation| match observation.state.as_status() { + Some(status) if status.in_sync => Some((observation, status)), + _ => None, + }) + .max_by(|(left_observation, left_status), (right_observation, right_status)| Self::compare_candidates(left_observation, left_status, right_observation, right_status)) + } + + fn evaluate_switch( + current: &NodeSyncStatus, + new: &NodeSyncStatus, + current_latency: Duration, + new_latency: Duration, + monitoring_config: &NodeMonitoringConfig, + chain: Chain, + ) -> Option { + let old_block = Self::status_height(current); + let new_block = Self::status_height(new); + let block_delay_threshold = monitoring_config.block_delay_threshold(chain); + + if new_block > old_block + block_delay_threshold { + return Some(NodeSwitchReason::BlockHeight { old_block, new_block }); + } + + if current.in_sync && !monitoring_config.is_latency_improvement_significant(current_latency, new_latency) { + return None; + } + + Some(NodeSwitchReason::Latency { + old_latency_ms: current_latency.as_millis() as u64, + new_latency_ms: new_latency.as_millis() as u64, + }) + } + + pub fn format_status_summary(observations: &[NodeStatusObservation]) -> String { + observations + .iter() + .map(|observation| match &observation.state { + NodeStatusState::Healthy(status) => format!( + "{}:in_sync={} latest={} current={} latency={}ms", + observation.url.url, + status.in_sync, + Self::format_optional_number(status.latest_block_number), + Self::format_optional_number(status.current_block_number), + observation.latency.as_millis() + ), + NodeStatusState::Error { message } => format!("{}:error={} latency={}ms", observation.url.url, message, observation.latency.as_millis()), + }) + .collect::>() + .join("; ") + } + + pub fn format_optional_number(value: Option) -> String { + value.map(|v| v.to_string()).unwrap_or_else(|| "unknown".to_string()) + } + + fn compare_candidates( + left_observation: &NodeStatusObservation, + left_status: &NodeSyncStatus, + right_observation: &NodeStatusObservation, + right_status: &NodeSyncStatus, + ) -> std::cmp::Ordering { + let left_height = Self::status_height(left_status); + let right_height = Self::status_height(right_status); + + match left_height.cmp(&right_height) { + std::cmp::Ordering::Equal => right_observation.latency.cmp(&left_observation.latency), + other => other, + } + } + + fn status_height(status: &NodeSyncStatus) -> u64 { + status.current_block_number.or(status.latest_block_number).unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::config as testkit; + use crate::testkit::sync::{healthy_observation, not_in_sync_observation, url}; + + fn config() -> NodeMonitoringConfig { + testkit::monitoring_config() + } + + #[test] + fn is_node_healthy_requires_in_sync() { + let synced = healthy_observation("https://a", Some(100), Some(100), 10); + let not_synced = not_in_sync_observation("https://b", Some(100), Some(90), 10); + let error = NodeStatusObservation::new(url("https://c"), NodeStatusState::error("fail"), Duration::from_millis(10)); + + assert!(NodeSyncAnalyzer::is_node_healthy(&synced)); + assert!(!NodeSyncAnalyzer::is_node_healthy(¬_synced)); + assert!(!NodeSyncAnalyzer::is_node_healthy(&error)); + } + + #[test] + fn selects_highest_block_number() { + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(100), Some(100), 10), + healthy_observation("https://b", Some(120), Some(120), 30), + healthy_observation("https://c", Some(110), Some(110), 5), + ]; + + let result = NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).unwrap(); + assert_eq!(result.observation.url.url, "https://b"); + assert_eq!(result.reason, NodeSwitchReason::BlockHeight { old_block: 100, new_block: 120 }); + } + + #[test] + fn prioritizes_latency_on_equal_height() { + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(120), Some(120), 500), + healthy_observation("https://b", Some(120), Some(120), 400), + healthy_observation("https://c", Some(120), Some(120), 100), + ]; + + let result = NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).unwrap(); + assert_eq!(result.observation.url.url, "https://c"); + assert_eq!( + result.reason, + NodeSwitchReason::Latency { + old_latency_ms: 500, + new_latency_ms: 100 + } + ); + } + + #[test] + fn ignores_unhealthy_nodes() { + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(100), Some(100), 10), + healthy_observation("https://b", Some(120), Some(120), 40), + NodeStatusObservation::new(url("https://c"), NodeStatusState::error("rpc error"), Duration::from_millis(5)), + ]; + + let result = NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).unwrap(); + assert_eq!(result.observation.url.url, "https://b"); + } + + #[test] + fn reports_none_when_no_candidate() { + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(100), Some(100), 10), + NodeStatusObservation::new(url("https://b"), NodeStatusState::error("rpc"), Duration::from_millis(5)), + ]; + + assert!(NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).is_none()); + } + + #[test] + fn switches_when_current_node_has_error() { + let current = url("https://a"); + let observations = vec![ + NodeStatusObservation::new(url("https://a"), NodeStatusState::error("connection failed"), Duration::from_millis(10)), + healthy_observation("https://b", Some(120), Some(120), 40), + ]; + + let result = NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).unwrap(); + assert_eq!(result.observation.url.url, "https://b"); + assert_eq!( + result.reason, + NodeSwitchReason::CurrentNodeError { + message: "connection failed".to_string() + } + ); + } + + #[test] + fn returns_none_when_current_node_not_found() { + let current = url("https://a"); + let observations = vec![healthy_observation("https://b", Some(120), Some(120), 40)]; + + assert!(NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).is_none()); + } + + #[test] + fn returns_none_when_current_has_error_and_no_healthy_candidates() { + let current = url("https://a"); + let observations = vec![ + NodeStatusObservation::new(url("https://a"), NodeStatusState::error("connection failed"), Duration::from_millis(10)), + NodeStatusObservation::new(url("https://b"), NodeStatusState::error("also failed"), Duration::from_millis(20)), + ]; + + assert!(NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).is_none()); + } + + #[test] + fn block_height_within_threshold_returns_latency() { + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(100), Some(100), 500), + healthy_observation("https://b", Some(102), Some(102), 100), + ]; + + let result = NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).unwrap(); + assert_eq!( + result.reason, + NodeSwitchReason::Latency { + old_latency_ms: 500, + new_latency_ms: 100 + } + ); + } + + #[test] + fn block_height_exceeds_threshold_returns_block_height() { + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(100), Some(100), 10), + healthy_observation("https://b", Some(115), Some(115), 30), + ]; + + let result = NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).unwrap(); + assert_eq!(result.reason, NodeSwitchReason::BlockHeight { old_block: 100, new_block: 115 }); + } + + #[test] + fn skips_latency_switch_below_threshold() { + let mut cfg = config(); + cfg.latency_threshold = Some(Duration::from_millis(50)); + cfg.latency_threshold_percent = Some(20.0); + + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(120), Some(120), 200), + healthy_observation("https://b", Some(120), Some(120), 195), + ]; + + assert!(NodeSyncAnalyzer::select_best_node(¤t, &observations, &cfg, Chain::Ethereum).is_none()); + } + + #[test] + fn allows_latency_switch_above_threshold() { + let mut cfg = config(); + cfg.latency_threshold = Some(Duration::from_millis(50)); + cfg.latency_threshold_percent = Some(20.0); + + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(120), Some(120), 400), + healthy_observation("https://b", Some(120), Some(120), 200), + ]; + + let result = NodeSyncAnalyzer::select_best_node(¤t, &observations, &cfg, Chain::Ethereum).unwrap(); + assert_eq!( + result.reason, + NodeSwitchReason::Latency { + old_latency_ms: 400, + new_latency_ms: 200 + } + ); + } + + #[test] + fn skips_latency_switch_below_percent_threshold() { + let mut cfg = config(); + cfg.latency_threshold = Some(Duration::from_millis(10)); + cfg.latency_threshold_percent = Some(30.0); + + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(120), Some(120), 400), + healthy_observation("https://b", Some(120), Some(120), 350), + ]; + + assert!(NodeSyncAnalyzer::select_best_node(¤t, &observations, &cfg, Chain::Ethereum).is_none()); + } + + #[test] + fn skips_switch_to_slower_node() { + let current = url("https://a"); + let observations = vec![ + healthy_observation("https://a", Some(120), Some(120), 300), + healthy_observation("https://b", Some(120), Some(120), 700), + ]; + + assert!(NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).is_none()); + } + + #[test] + fn not_in_sync_switches_to_synced_even_if_slower() { + let current = url("https://a"); + let observations = vec![ + not_in_sync_observation("https://a", Some(100), Some(90), 100), + healthy_observation("https://b", Some(100), Some(100), 500), + ]; + + let result = NodeSyncAnalyzer::select_best_node(¤t, &observations, &config(), Chain::Ethereum).unwrap(); + assert_eq!(result.observation.url.url, "https://b"); + } +} diff --git a/core/apps/dynode/src/monitoring/telemetry.rs b/core/apps/dynode/src/monitoring/telemetry.rs new file mode 100644 index 0000000000..494e55c7c5 --- /dev/null +++ b/core/apps/dynode/src/monitoring/telemetry.rs @@ -0,0 +1,167 @@ +use std::error::Error; + +use gem_tracing::{DurationMs, error_with_fields, error_with_fields_impl, info_with_fields_impl}; +use primitives::NodeStatusState; + +use crate::config::{ChainConfig, Url}; + +use super::sync::{NodeStatusObservation, NodeSwitchResult, NodeSyncAnalyzer}; + +pub struct NodeTelemetry; + +impl NodeTelemetry { + pub fn log_status_debug(chain_config: &ChainConfig, observations: &[NodeStatusObservation]) { + let chain = chain_config.chain.as_ref(); + for observation in observations { + match &observation.state { + NodeStatusState::Healthy(sync_status) => { + let mut fields = vec![("host", observation.url.host())]; + if !sync_status.in_sync { + fields.push(("in_sync", "false".to_string())); + } + + let latency = DurationMs(observation.latency); + let latest = sync_status.latest_block_number; + let current = if sync_status.in_sync { None } else { sync_status.current_block_number }; + + log_info_event("Node check", chain, fields, &latency, latest, current); + } + NodeStatusState::Error { message } => { + let latency = DurationMs(observation.latency); + log_info_event("Node check", chain, [("host", observation.url.host()), ("message", message.clone())], &latency, None, None); + } + } + } + } + + pub fn log_node_healthy(chain_config: &ChainConfig, observation: &NodeStatusObservation) { + let chain = chain_config.chain.as_ref(); + if let NodeStatusState::Healthy(status) = &observation.state { + let mut fields = vec![("host", observation.url.host())]; + if !status.in_sync { + fields.push(("in_sync", "false".to_string())); + } + + let latency = DurationMs(observation.latency); + let latest = status.latest_block_number; + let current = if status.in_sync { None } else { status.current_block_number }; + + log_info_event("Node ok", chain, fields, &latency, latest, current); + } + } + + pub fn log_node_unhealthy(chain_config: &ChainConfig, observation: &NodeStatusObservation) { + let chain = chain_config.chain.as_ref(); + match &observation.state { + NodeStatusState::Healthy(status) => { + let mut fields = vec![("host", observation.url.host())]; + if !status.in_sync { + fields.push(("in_sync", "false".to_string())); + } + + let latency = DurationMs(observation.latency); + let latest = status.latest_block_number; + let current = if status.in_sync { None } else { status.current_block_number }; + let error = std::io::Error::other("Current node not in sync"); + + log_error_event("Node out of sync", &error, chain, fields, &latency, latest, current); + } + NodeStatusState::Error { message } => { + let latency = DurationMs(observation.latency); + let error = std::io::Error::other(message.clone()); + log_error_event( + "Node check error", + &error, + chain, + [("host", observation.url.host()), ("message", message.clone())], + &latency, + None, + None, + ); + } + } + } + + pub fn log_node_switch(chain_config: &ChainConfig, previous: &Url, switch: &NodeSwitchResult) { + let chain = chain_config.chain.as_ref(); + let observation = &switch.observation; + let latency = DurationMs(observation.latency); + let (latest, current) = match &observation.state { + NodeStatusState::Healthy(status) => (status.latest_block_number, if status.in_sync { None } else { status.current_block_number }), + NodeStatusState::Error { .. } => (None, None), + }; + + log_info_event( + "Node switch", + chain, + [("new_host", observation.url.host()), ("old_host", previous.host()), ("reason", switch.reason.to_string())], + &latency, + latest, + current, + ); + } + + pub fn log_no_candidate(chain_config: &ChainConfig, observations: &[NodeStatusObservation]) { + error_with_fields!( + "Node switch unavailable", + &std::io::Error::other("No healthy nodes available"), + chain = chain_config.chain.as_ref(), + statuses = &NodeSyncAnalyzer::format_status_summary(observations), + ); + } + + pub fn log_monitor_error(chain_config: &ChainConfig, err: &dyn std::error::Error) { + error_with_fields!("Node monitor error", err, chain = chain_config.chain.as_ref()); + } + + pub fn log_missing_current(chain_config: &ChainConfig) { + error_with_fields!( + "Node monitor current missing", + &std::io::Error::other("Node not configured"), + chain = chain_config.chain.as_ref(), + ); + } +} + +fn log_info_event(message: &'static str, chain: &str, fields: I, latency: &DurationMs, latest: Option, current: Option) +where + I: IntoIterator, +{ + emit_event(message, chain, fields, latency, latest, current, |msg, slice| info_with_fields_impl(msg, slice)); +} + +fn log_error_event(message: &'static str, err: &dyn Error, chain: &str, fields: I, latency: &DurationMs, latest: Option, current: Option) +where + I: IntoIterator, +{ + emit_event(message, chain, fields, latency, latest, current, |msg, slice| error_with_fields_impl(msg, err, slice)); +} + +fn emit_event( + message: &'static str, + chain: &str, + fields: I, + latency: &DurationMs, + latest: Option, + current: Option, + sink: impl Fn(&'static str, &[(&str, &dyn std::fmt::Display)]), +) where + I: IntoIterator, +{ + let mut values: Vec<(&'static str, String)> = fields.into_iter().collect(); + if let Some(latest) = latest { + values.push(("latest_block", latest.to_string())); + } + if let Some(current) = current { + values.push(("current_block", current.to_string())); + } + + let mut display: Vec<(&str, &dyn std::fmt::Display)> = Vec::with_capacity(values.len() + 2); + display.push(("chain", &chain)); + for (key, value) in &values { + display.push((*key, value)); + } + display.push(("latency", latency)); + + sink(message, &display); +} diff --git a/core/apps/dynode/src/monitoring/worker.rs b/core/apps/dynode/src/monitoring/worker.rs new file mode 100644 index 0000000000..694fb2bd21 --- /dev/null +++ b/core/apps/dynode/src/monitoring/worker.rs @@ -0,0 +1,132 @@ +use std::{collections::HashMap, sync::Arc}; + +use futures::future; +use primitives::Chain; +use tokio::sync::RwLock; +use tokio::time::{Duration, sleep}; + +use super::chain_client::ChainClient; +use super::sync::{NodeStatusObservation, NodeSwitchResult, NodeSyncAnalyzer}; +use super::telemetry::NodeTelemetry; +use crate::config::{ChainConfig, NodeMonitoringConfig, Url}; +use crate::metrics::Metrics; +use crate::monitoring::NodeService; +use crate::proxy::NodeDomain; + +pub struct NodeMonitor { + chains: HashMap, + nodes: Arc>>, + metrics: Arc, + monitoring_config: NodeMonitoringConfig, +} + +impl NodeMonitor { + pub fn new(chains: HashMap, nodes: Arc>>, metrics: Arc, monitoring_config: NodeMonitoringConfig) -> Self { + Self { + chains, + nodes, + metrics, + monitoring_config, + } + } + + pub async fn start_monitoring(&self) { + for (index, chain_config) in self.chains.values().cloned().enumerate() { + if let Some(url) = chain_config.urls.first() { + NodeService::sync_current_node_metric(&self.metrics, chain_config.chain, url); + } + + if chain_config.urls.len() <= 1 { + continue; + } + + let nodes = Arc::clone(&self.nodes); + let metrics = Arc::clone(&self.metrics); + let monitoring_config = self.monitoring_config.clone(); + let poll_interval = chain_config.poll_interval(&monitoring_config); + let initial_delay = Duration::from_millis(((index as u64) + 1) * 250); + + tokio::task::spawn(async move { + sleep(initial_delay).await; + + loop { + if let Err(err) = Self::evaluate_chain(&chain_config, &nodes, &metrics, &monitoring_config).await { + NodeTelemetry::log_monitor_error(&chain_config, err.as_ref()); + } + + sleep(poll_interval).await; + } + }); + } + } + + async fn evaluate_chain( + chain_config: &ChainConfig, + nodes: &Arc>>, + metrics: &Arc, + monitoring_config: &NodeMonitoringConfig, + ) -> Result<(), Box> { + if chain_config.urls.len() <= 1 { + return Ok(()); + } + + let current_node = match NodeService::get_node_domain(nodes, chain_config.chain).await { + Some(node) => node, + None => { + NodeTelemetry::log_missing_current(chain_config); + return Ok(()); + } + }; + + let current_observation = Self::fetch_status(chain_config.chain, current_node.url.clone()).await; + NodeTelemetry::log_status_debug(chain_config, std::slice::from_ref(¤t_observation)); + + if NodeSyncAnalyzer::is_node_healthy(¤t_observation) { + NodeTelemetry::log_node_healthy(chain_config, ¤t_observation); + return Ok(()); + } + + NodeTelemetry::log_node_unhealthy(chain_config, ¤t_observation); + + let fallback_urls: Vec = chain_config.urls.iter().filter(|&url| *url != current_node.url).cloned().collect(); + + if fallback_urls.is_empty() { + NodeTelemetry::log_no_candidate(chain_config, &[]); + return Ok(()); + } + + let fallback_statuses = Self::fetch_statuses(chain_config.chain, fallback_urls).await; + NodeTelemetry::log_status_debug(chain_config, &fallback_statuses); + + let mut all_observations = vec![current_observation]; + all_observations.extend(fallback_statuses); + + match NodeSyncAnalyzer::select_best_node(¤t_node.url, &all_observations, monitoring_config, chain_config.chain) { + Some(switch) => Self::try_switch(chain_config, nodes, metrics, ¤t_node.url, &switch).await, + None => NodeTelemetry::log_no_candidate(chain_config, &all_observations), + } + + Ok(()) + } + + async fn try_switch(chain_config: &ChainConfig, nodes: &Arc>>, metrics: &Arc, current_url: &Url, switch: &NodeSwitchResult) { + let new_url = &switch.observation.url; + if NodeService::switch_node_if_current(nodes, metrics, chain_config, current_url, new_url, &switch.reason) + .await + .is_some() + { + NodeTelemetry::log_node_switch(chain_config, current_url, switch); + } + } + + async fn fetch_statuses(chain: Chain, urls: Vec) -> Vec { + let futures = urls.into_iter().map(move |url| Self::fetch_status(chain, url)); + + future::join_all(futures).await + } + + async fn fetch_status(chain: Chain, url: Url) -> NodeStatusObservation { + let client = ChainClient::new(chain, url); + client.fetch_status().await + } +} diff --git a/core/apps/dynode/src/proxy/constants.rs b/core/apps/dynode/src/proxy/constants.rs new file mode 100644 index 0000000000..6daa72d49c --- /dev/null +++ b/core/apps/dynode/src/proxy/constants.rs @@ -0,0 +1,4 @@ +use reqwest::header; + +pub const JSON_CONTENT_TYPE: &str = "application/json"; +pub const JSON_HEADER: header::HeaderValue = header::HeaderValue::from_static(JSON_CONTENT_TYPE); diff --git a/core/apps/dynode/src/proxy/jsonrpc.rs b/core/apps/dynode/src/proxy/jsonrpc.rs new file mode 100644 index 0000000000..78d24bf8a4 --- /dev/null +++ b/core/apps/dynode/src/proxy/jsonrpc.rs @@ -0,0 +1,361 @@ +use crate::cache::{CacheProvider, RequestCache}; +use crate::jsonrpc_types::{JsonRpcCall, JsonRpcRequest, JsonRpcResponse, JsonRpcResult}; +use crate::metrics::Metrics; +use crate::proxy::CachedResponse; +use crate::proxy::constants::JSON_CONTENT_TYPE; +use crate::proxy::proxy_request::ProxyRequest; +use crate::proxy::request_builder::RequestBuilder; +use crate::proxy::request_url::RequestUrl; +use crate::proxy::response_builder::ResponseBuilder; +use crate::webhook::DynodeBroadcastWebhookClient; +use gem_tracing::{DurationMs, info_with_fields}; +use primitives::Chain; +use reqwest::header::HeaderMap; +use reqwest::{Method, StatusCode}; +use settings_chain::BroadcastProviders; + +use crate::proxy::ProxyResponse; + +pub struct JsonRpcHandler; + +impl JsonRpcHandler { + pub async fn handle_request( + rpc_request: &JsonRpcRequest, + request: &ProxyRequest, + cache: &RequestCache, + metrics: &Metrics, + url: &RequestUrl, + client: &reqwest::Client, + forward_headers: &HeaderMap, + broadcast_webhook: &DynodeBroadcastWebhookClient, + broadcast_providers: &BroadcastProviders, + ) -> Result> { + match rpc_request { + JsonRpcRequest::Single(call) => Self::handle_single_request(call, request, cache, metrics, url, client, forward_headers, broadcast_webhook, broadcast_providers).await, + JsonRpcRequest::Batch(calls) => Self::handle_batch_request(rpc_request, calls, request, metrics, url, client, forward_headers).await, + } + } + + async fn handle_single_request( + call: &JsonRpcCall, + request: &ProxyRequest, + cache: &RequestCache, + metrics: &Metrics, + url: &RequestUrl, + client: &reqwest::Client, + forward_headers: &HeaderMap, + broadcast_webhook: &DynodeBroadcastWebhookClient, + broadcast_providers: &BroadcastProviders, + ) -> Result> { + metrics.add_proxy_request_by_method(request.chain.as_ref(), &call.method); + + let cache_key = call.cache_key(&request.host, &request.path_with_query); + if let Some(_ttl) = cache.should_cache_call(&request.chain, call) { + if let Some(cached) = cache.get(&request.chain, &cache_key).await { + metrics.add_cache_hit(request.chain.as_ref(), &call.method); + let request_id = request.id.as_str(); + info_with_fields!( + "Cache HIT", + id = request_id, + chain = request.chain.as_ref(), + host = request.host.as_str(), + method = call.method.as_str() + ); + + let result = serde_json::from_slice(&cached.body).unwrap_or_default(); + let response = JsonRpcResult::Success(JsonRpcResponse { + jsonrpc: call.jsonrpc.clone(), + result, + id: Some(call.id), + }); + + metrics.add_proxy_response( + request.chain.as_ref(), + &call.method, + url.url.host_str().unwrap_or_default(), + StatusCode::OK.as_u16(), + request.elapsed().as_millis(), + ); + + let upstream_headers = ResponseBuilder::create_upstream_headers(url.url.host_str(), request.elapsed()); + return Self::build_json_response(&response, upstream_headers, StatusCode::OK.as_u16()); + } else { + metrics.add_cache_miss(request.chain.as_ref(), &call.method); + } + } else { + metrics.add_cache_miss(request.chain.as_ref(), &call.method); + } + + let (response, response_status, response_body) = Self::fetch_single_response(call, request, cache, url, client, forward_headers).await?; + + metrics.add_proxy_response( + request.chain.as_ref(), + &call.method, + url.url.host_str().unwrap_or_default(), + response_status, + request.elapsed().as_millis(), + ); + + let request_id = request.id.as_str(); + match &response { + JsonRpcResult::Success(_) => { + info_with_fields!( + "Proxy response", + id = request_id, + chain = request.chain.as_ref(), + remote_host = url.url.host_str().unwrap_or_default(), + method = request.method.as_str(), + uri = request.path.as_str(), + rpc_method = call.method.as_str(), + status = response_status, + latency = DurationMs(request.elapsed()), + ); + } + JsonRpcResult::Error(error_response) => { + info_with_fields!( + "Proxy response", + id = request_id, + chain = request.chain.as_ref(), + remote_host = url.url.host_str().unwrap_or_default(), + method = request.method.as_str(), + uri = request.path.as_str(), + rpc_method = call.method.as_str(), + status = response_status, + latency = DurationMs(request.elapsed()), + error_code = error_response.error.code, + error = error_response.error.message.as_str(), + ); + } + } + + broadcast_webhook.notify_broadcast(request, response_status, &response_body, broadcast_providers); + + let upstream_headers = ResponseBuilder::create_upstream_headers(url.url.host_str(), request.elapsed()); + Self::build_json_response(&response, upstream_headers, response_status) + } + + async fn handle_batch_request( + rpc_request: &JsonRpcRequest, + calls: &[JsonRpcCall], + request: &ProxyRequest, + metrics: &Metrics, + url: &RequestUrl, + client: &reqwest::Client, + forward_headers: &HeaderMap, + ) -> Result> { + for call in calls { + metrics.add_proxy_request_by_method(request.chain.as_ref(), &call.method); + metrics.add_cache_miss(request.chain.as_ref(), &call.method); + } + + let (responses, response_status) = Self::fetch_batch_responses(calls, url, client, &request.method, forward_headers).await?; + + for call in calls { + metrics.add_proxy_response( + request.chain.as_ref(), + &call.method, + url.url.host_str().unwrap_or_default(), + response_status, + request.elapsed().as_millis(), + ); + } + + let rpc_methods = rpc_request.get_methods_list(); + let request_id = request.id.as_str(); + info_with_fields!( + "Proxy response", + id = request_id, + chain = request.chain.as_ref(), + remote_host = url.url.host_str().unwrap_or_default(), + method = request.method.as_str(), + uri = request.path.as_str(), + rpc_method = &rpc_methods, + status = response_status, + latency = DurationMs(request.elapsed()), + ); + + let upstream_headers = ResponseBuilder::create_upstream_headers(url.url.host_str(), request.elapsed()); + Self::build_json_response(&responses, upstream_headers, response_status) + } + + async fn send_jsonrpc_request( + client: &reqwest::Client, + method: &Method, + url: &RequestUrl, + body: Vec, + headers: &HeaderMap, + ) -> Result> { + let request = RequestBuilder::build(method, url, body, headers.clone())?; + Ok(client.execute(request).await?) + } + + // TODO: Temporary dynode override for older app versions. Remove after clients send matching commitment. + fn override_solana_get_latest_blockhash(call: &JsonRpcCall) -> JsonRpcCall { + let mut call = call.clone(); + if let serde_json::Value::Array(items) = &mut call.params + && let Some(serde_json::Value::Object(config)) = items.get_mut(0) + { + config.insert("commitment".to_string(), "finalized".into()); + } + call + } + + async fn fetch_single_response( + call: &JsonRpcCall, + request: &ProxyRequest, + cache: &RequestCache, + url: &RequestUrl, + client: &reqwest::Client, + forward_headers: &HeaderMap, + ) -> Result<(JsonRpcResult, u16, Vec), Box> { + let upstream_call = if request.chain == Chain::Solana && call.method == "getLatestBlockhash" { + Self::override_solana_get_latest_blockhash(call) + } else { + call.clone() + }; + let body = serde_json::to_vec(&upstream_call)?; + let response = Self::send_jsonrpc_request(client, &request.method, url, body, forward_headers).await?; + let status = response.status().as_u16(); + let body_bytes = response.bytes().await?.to_vec(); + + let result: JsonRpcResult = serde_json::from_slice(&body_bytes).map_err(|e| Self::format_parse_error(status, &body_bytes, e))?; + + if status == StatusCode::OK.as_u16() + && let (JsonRpcResult::Success(success), Some(ttl)) = (&result, cache.should_cache_call(&request.chain, call)) + { + let result_bytes = serde_json::to_string(&success.result).unwrap_or_default().into_bytes(); + let size_bytes = result_bytes.len(); + let cached = CachedResponse::new(result_bytes, StatusCode::OK.as_u16(), JSON_CONTENT_TYPE.to_string(), ttl); + let cache_key = call.cache_key(&request.host, &request.path_with_query); + cache.set(&request.chain, cache_key, cached, ttl).await; + + info_with_fields!( + "Cache SET", + id = request.id.as_str(), + chain = request.chain.as_ref(), + host = request.host.as_str(), + method = call.method.as_str(), + ttl_ms = ttl.as_millis(), + size_bytes = size_bytes, + latency = DurationMs(request.elapsed()), + ); + } + + Ok((result, status, body_bytes)) + } + + async fn fetch_batch_responses( + calls: &[JsonRpcCall], + url: &RequestUrl, + client: &reqwest::Client, + method: &Method, + forward_headers: &HeaderMap, + ) -> Result<(serde_json::Value, u16), Box> { + let body = serde_json::to_vec(&calls)?; + let response = Self::send_jsonrpc_request(client, method, url, body, forward_headers).await?; + let status = response.status().as_u16(); + let body_bytes = response.bytes().await?.to_vec(); + let responses: serde_json::Value = serde_json::from_slice(&body_bytes).map_err(|e| Self::format_parse_error(status, &body_bytes, e))?; + Ok((responses, status)) + } + + fn build_json_response(data: &T, headers: HeaderMap, status: u16) -> Result> { + let response_body = serde_json::to_vec(data)?; + ResponseBuilder::build_with_headers(response_body, status, JSON_CONTENT_TYPE, headers) + } + + fn format_parse_error(status: u16, body: &[u8], error: serde_json::Error) -> String { + const MAX_BODY_LEN: usize = 256; + if body.len() <= MAX_BODY_LEN + && let Ok(text) = std::str::from_utf8(body) + { + let body = text.split_whitespace().collect::>().join(" "); + return format!("status={}, body: {}", status, body); + } + format!("status={}, parse error: {}", status, error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_call(id: u64, method: &str) -> JsonRpcCall { + JsonRpcCall { + jsonrpc: "2.0".into(), + method: method.into(), + params: json!([]), + id, + } + } + + #[test] + fn test_single_and_batch_request_distinction() { + let single_call = make_call(1, "eth_blockNumber"); + let batch_calls = vec![make_call(1, "eth_blockNumber"), make_call(2, "eth_gasPrice")]; + + let single_request = JsonRpcRequest::Single(single_call); + let batch_request = JsonRpcRequest::Batch(batch_calls); + + let JsonRpcRequest::Single(_) = single_request else { + panic!("Expected single request"); + }; + let JsonRpcRequest::Batch(_) = batch_request else { + panic!("Expected batch request"); + }; + } + + #[test] + fn test_override_solana_get_latest_blockhash() { + let confirmed_client = JsonRpcCall { + jsonrpc: "2.0".into(), + method: "getLatestBlockhash".into(), + params: json!([{ "commitment": "confirmed" }]), + id: 1, + }; + let result = JsonRpcHandler::override_solana_get_latest_blockhash(&confirmed_client); + assert_eq!(result.params[0]["commitment"], "finalized"); + + let processed_client = JsonRpcCall { + jsonrpc: "2.0".into(), + method: "getLatestBlockhash".into(), + params: json!([{ "commitment": "processed", "minContextSlot": 100 }]), + id: 1, + }; + let result = JsonRpcHandler::override_solana_get_latest_blockhash(&processed_client); + assert_eq!(result.params[0]["commitment"], "finalized"); + assert_eq!(result.params[0]["minContextSlot"], 100); + + let no_params = JsonRpcCall { + jsonrpc: "2.0".into(), + method: "getLatestBlockhash".into(), + params: json!([]), + id: 1, + }; + let result = JsonRpcHandler::override_solana_get_latest_blockhash(&no_params); + assert_eq!(result.params, json!([])); + } + + #[test] + fn test_format_parse_error() { + let err = || serde_json::from_slice::(b"x").unwrap_err(); + + assert_eq!( + JsonRpcHandler::format_parse_error(415, b"Expected Content-Type: application/json", err()), + "status=415, body: Expected Content-Type: application/json" + ); + assert_eq!( + JsonRpcHandler::format_parse_error(400, b"\nBad Request\n", err()), + "status=400, body: Bad Request " + ); + assert_eq!( + JsonRpcHandler::format_parse_error(502, b"Bad Gateway...".repeat(20).as_slice(), err()), + "status=502, parse error: expected value at line 1 column 1" + ); + assert_eq!( + JsonRpcHandler::format_parse_error(500, &[0xff, 0xfe], err()), + "status=500, parse error: expected value at line 1 column 1" + ); + } +} diff --git a/core/apps/dynode/src/proxy/mod.rs b/core/apps/dynode/src/proxy/mod.rs new file mode 100644 index 0000000000..ec6ecfe2b1 --- /dev/null +++ b/core/apps/dynode/src/proxy/mod.rs @@ -0,0 +1,15 @@ +pub mod constants; +pub mod jsonrpc; +pub mod proxy_builder; +pub mod proxy_request; +pub mod proxy_request_builder; +pub mod request_builder; +pub mod request_url; +pub mod response_builder; +pub mod service; +pub mod types; + +pub use proxy_request_builder::ProxyRequestBuilder; +pub use response_builder::ProxyResponse; +pub use service::{NodeDomain, ProxyRequestService}; +pub use types::CachedResponse; diff --git a/core/apps/dynode/src/proxy/proxy_builder.rs b/core/apps/dynode/src/proxy/proxy_builder.rs new file mode 100644 index 0000000000..3f5f99aeed --- /dev/null +++ b/core/apps/dynode/src/proxy/proxy_builder.rs @@ -0,0 +1,61 @@ +use crate::cache::RequestCache; +use crate::config::HeadersConfig; +#[cfg(test)] +use crate::config::MetricsConfig; +use crate::metrics::Metrics; +use crate::proxy::{NodeDomain, ProxyRequestService, proxy_request::ProxyRequest}; +use crate::webhook::DynodeBroadcastWebhookClient; +use settings_chain::BroadcastProviders; +use std::sync::Arc; + +#[derive(Clone)] +pub struct ProxyBuilder { + service: ProxyRequestService, +} + +impl ProxyBuilder { + pub fn new( + metrics: Metrics, + cache: RequestCache, + client: reqwest::Client, + headers_config: HeadersConfig, + broadcast_webhook: DynodeBroadcastWebhookClient, + broadcast_providers: Arc, + ) -> Self { + Self { + service: ProxyRequestService::new(metrics, cache, client, headers_config, broadcast_webhook, broadcast_providers), + } + } + + pub async fn handle_request(&self, request: ProxyRequest, node_domain: &NodeDomain) -> Result> { + self.service.handle_request(request, node_domain).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::CacheConfig; + use primitives::Chain; + use reqwest::header; + use std::collections::HashMap; + + fn create_test_headers_config() -> HeadersConfig { + HeadersConfig { + forward: vec![header::CONTENT_TYPE.to_string()], + domains: HashMap::new(), + } + } + + #[test] + fn test_proxy_builder_creation() { + let metrics = Metrics::new(MetricsConfig::default()); + let cache = RequestCache::new(CacheConfig::default()); + let client = reqwest::Client::new(); + let headers_config = create_test_headers_config(); + let broadcast_webhook = DynodeBroadcastWebhookClient::disabled(); + let broadcast_providers = Arc::new(BroadcastProviders::from_chains([Chain::Ethereum])); + + let _builder = ProxyBuilder::new(metrics, cache, client, headers_config, broadcast_webhook, broadcast_providers); + } +} diff --git a/core/apps/dynode/src/proxy/proxy_request.rs b/core/apps/dynode/src/proxy/proxy_request.rs new file mode 100644 index 0000000000..5cd9c9da21 --- /dev/null +++ b/core/apps/dynode/src/proxy/proxy_request.rs @@ -0,0 +1,113 @@ +use crate::jsonrpc_types::RequestType; +use primitives::Chain; +use reqwest::{Method, header::HeaderMap}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn generate_request_id() -> String { + let count = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{:08x}", count) +} + +#[derive(Debug, Clone)] +pub struct ProxyRequest { + pub id: String, + pub method: Method, + pub headers: HeaderMap, + pub body: Vec, + pub path: String, + pub path_with_query: String, + pub host: String, + pub user_agent: String, + pub chain: Chain, + pub request_start: Instant, + request_type: RequestType, +} + +impl ProxyRequest { + pub fn new(method: Method, headers: HeaderMap, body: Vec, path: String, path_with_query: String, host: String, user_agent: String, chain: Chain) -> Self { + let request_type = RequestType::from_request(method.as_str(), path_with_query.clone(), body.clone()); + Self { + id: generate_request_id(), + method, + headers, + body, + path, + path_with_query, + host, + user_agent, + chain, + request_start: Instant::now(), + request_type, + } + } + + pub fn elapsed(&self) -> std::time::Duration { + self.request_start.elapsed() + } + + pub fn request_type(&self) -> &RequestType { + &self.request_type + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + + #[test] + fn request_creation() { + let ctx = ProxyRequest::new( + Method::GET, + HeaderMap::new(), + vec![], + "/test".to_string(), + "/test?param=1".to_string(), + "example.com".to_string(), + "test-agent".to_string(), + Chain::Ethereum, + ); + + assert_eq!(ctx.method, Method::GET); + assert_eq!(ctx.path, "/test"); + assert_eq!(ctx.host, "example.com"); + assert_eq!(ctx.user_agent, "test-agent"); + assert_eq!(ctx.chain, Chain::Ethereum); + } + + #[test] + fn elapsed_time() { + let ctx = ProxyRequest::new( + Method::GET, + HeaderMap::new(), + vec![], + "/test".to_string(), + "/test".to_string(), + "example.com".to_string(), + "test-agent".to_string(), + Chain::Ethereum, + ); + + thread::sleep(Duration::from_millis(1)); + + let elapsed = ctx.elapsed(); + assert!(elapsed.as_millis() > 0); + } + + #[test] + fn generate_request_id_unique() { + let id1 = super::generate_request_id(); + let id2 = super::generate_request_id(); + let id3 = super::generate_request_id(); + + assert_ne!(id1, id2); + assert_ne!(id2, id3); + assert_eq!(id1.len(), 8); + assert_eq!(id2.len(), 8); + assert!(id1.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/core/apps/dynode/src/proxy/proxy_request_builder.rs b/core/apps/dynode/src/proxy/proxy_request_builder.rs new file mode 100644 index 0000000000..3a4ebdd152 --- /dev/null +++ b/core/apps/dynode/src/proxy/proxy_request_builder.rs @@ -0,0 +1,76 @@ +use crate::proxy::proxy_request::ProxyRequest; +use primitives::Chain; +use reqwest::{Method, header::HeaderMap}; +use rocket::http::Status; +use url::Url; + +pub struct ProxyRequestBuilder; + +impl ProxyRequestBuilder { + pub fn build(method: Method, headers: HeaderMap, body: Vec, uri: String, chain: Chain) -> Result { + let host = Self::extract_host(&headers)?; + let user_agent = Self::extract_user_agent(&headers); + let (path, path_with_query) = Self::prepare_paths(&uri); + + Ok(ProxyRequest::new(method, headers, body, path, path_with_query, host, user_agent, chain)) + } + + fn extract_host(headers: &HeaderMap) -> Result { + let host_header = headers.get(reqwest::header::HOST).and_then(|h| h.to_str().ok()).ok_or(Status::BadRequest)?; + + Ok(Self::parse_hostname(host_header)) + } + + fn extract_user_agent(headers: &HeaderMap) -> String { + headers.get(reqwest::header::USER_AGENT).and_then(|h| h.to_str().ok()).unwrap_or_default().to_string() + } + + fn extract_path(uri: &str) -> String { + uri.split('?').next().unwrap_or(uri).to_string() + } + + fn prepare_paths(uri: &str) -> (String, String) { + (Self::remove_chain_from_path(&Self::extract_path(uri)), Self::remove_chain_from_path(uri)) + } + + fn parse_hostname(host_header: &str) -> String { + let candidate = format!("http://{}", host_header); + Url::parse(&candidate) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .unwrap_or_else(|| host_header.to_string()) + } + + fn remove_chain_from_path(uri: &str) -> String { + let (path_part, query_part) = uri.split_once('?').unwrap_or((uri, "")); + + let remaining = path_part + .trim_start_matches('/') + .split_once('/') + .map(|(_, rest)| format!("/{}", rest)) + .unwrap_or_else(|| "/".to_string()); + + if query_part.is_empty() { remaining } else { format!("{}?{}", remaining, query_part) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_remove_chain_from_path() { + assert_eq!(ProxyRequestBuilder::remove_chain_from_path("/tron/wallet/getchainparameters"), "/wallet/getchainparameters"); + assert_eq!(ProxyRequestBuilder::remove_chain_from_path("/ethereum/v1/some/path"), "/v1/some/path"); + assert_eq!(ProxyRequestBuilder::remove_chain_from_path("/bitcoin"), "/"); + assert_eq!(ProxyRequestBuilder::remove_chain_from_path("/solana?query=1"), "/?query=1"); + assert_eq!(ProxyRequestBuilder::remove_chain_from_path("/chain/path?foo=bar&baz=qux"), "/path?foo=bar&baz=qux"); + } + + #[test] + fn test_parse_hostname() { + assert_eq!(ProxyRequestBuilder::parse_hostname("example.com"), "example.com"); + assert_eq!(ProxyRequestBuilder::parse_hostname("example.com:8080"), "example.com"); + assert_eq!(ProxyRequestBuilder::parse_hostname("localhost:3000"), "localhost"); + } +} diff --git a/core/apps/dynode/src/proxy/request_builder.rs b/core/apps/dynode/src/proxy/request_builder.rs new file mode 100644 index 0000000000..7d4fd3b52d --- /dev/null +++ b/core/apps/dynode/src/proxy/request_builder.rs @@ -0,0 +1,87 @@ +use std::collections::HashSet; +use std::str::FromStr; + +use reqwest::header::{HeaderMap, HeaderName}; +use reqwest::{Method, Request}; + +use super::request_url::RequestUrl; + +pub struct RequestBuilder; + +impl RequestBuilder { + pub fn build(method: &Method, url: &RequestUrl, body: Vec, mut headers: HeaderMap) -> Result> { + Self::apply_url_params(&mut headers, url); + let mut request = Request::new(method.clone(), url.url.clone()); + *request.headers_mut() = headers; + *request.body_mut() = Some(body.into()); + Ok(request) + } + + fn apply_url_params(headers: &mut HeaderMap, url: &RequestUrl) { + for (key, value) in &url.params { + if let (Ok(name), Ok(val)) = (HeaderName::from_str(key), value.parse()) { + headers.append(name, val); + } + } + } + + pub fn filter_headers(original_headers: &HeaderMap, forward_headers: &HashSet) -> HeaderMap { + original_headers + .iter() + .filter_map(|(k, v)| if forward_headers.contains(k) { Some((k.clone(), v.clone())) } else { None }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Url; + use crate::proxy::constants::JSON_CONTENT_TYPE; + use reqwest::Method as HttpMethod; + use reqwest::header; + + fn make_request_url(base: &str, path: &str, header_kv: Option<(&str, &str)>) -> RequestUrl { + let mut url = Url { + url: base.to_string(), + headers: None, + }; + if let Some((k, v)) = header_kv { + url.headers = Some({ + let mut m = std::collections::HashMap::new(); + m.insert(k.to_string(), v.to_string()); + m + }); + } + RequestUrl::from_parts(url, path) + } + + #[test] + fn test_build_with_headers() { + let req_url = make_request_url("https://example.com", "/rpc", Some(("x-api-key", "secret"))); + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static(JSON_CONTENT_TYPE)); + + let req = RequestBuilder::build(&HttpMethod::POST, &req_url, b"{}".to_vec(), headers).expect("build"); + + assert_eq!(req.method(), &HttpMethod::POST); + assert_eq!(req.url().to_string(), "https://example.com/rpc"); + + let headers = req.headers(); + assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), &header::HeaderValue::from_static(JSON_CONTENT_TYPE)); + assert_eq!(headers.get("x-api-key").unwrap(), &header::HeaderValue::from_str("secret").unwrap()); + } + + #[test] + fn test_filter_headers() { + let mut orig_headers = HeaderMap::new(); + orig_headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static(JSON_CONTENT_TYPE)); + orig_headers.insert("x-drop", header::HeaderValue::from_static("dropme")); + + let keep: HashSet = [header::CONTENT_TYPE].into_iter().collect(); + let filtered = RequestBuilder::filter_headers(&orig_headers, &keep); + + assert!(filtered.get("x-drop").is_none()); + assert_eq!(filtered.get(header::CONTENT_TYPE).unwrap(), &header::HeaderValue::from_static(JSON_CONTENT_TYPE)); + } +} diff --git a/core/apps/dynode/src/proxy/request_url.rs b/core/apps/dynode/src/proxy/request_url.rs new file mode 100644 index 0000000000..5efb6fba40 --- /dev/null +++ b/core/apps/dynode/src/proxy/request_url.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +use reqwest::Url as ReqwestUrl; + +use crate::config::Url; + +#[derive(Debug, Clone)] +pub struct RequestUrl { + pub url: ReqwestUrl, + pub params: HashMap, +} + +impl RequestUrl { + pub fn from_parts(url: Url, original_path_and_query: &str) -> RequestUrl { + let path = if original_path_and_query == "/" { + "".to_string() + } else { + original_path_and_query.to_string() + }; + let combined = format!("{}{}", url.url, path); + let resolved = ReqwestUrl::parse(&combined).expect("invalid url"); + + RequestUrl { + url: resolved, + params: url.headers.unwrap_or_default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_uri() { + let url = Url { + url: "https://example.com".to_string(), + headers: Some(HashMap::new()), + }; + let request_url = RequestUrl::from_parts(url, "/path"); + assert_eq!(request_url.url.to_string(), "https://example.com/path"); + assert!(request_url.params.is_empty()); + } + + #[test] + fn test_from_uri_with_headers() { + let mut headers = HashMap::new(); + headers.insert("x-api-key".to_string(), "secret".to_string()); + let url = Url { + url: "https://example.com".to_string(), + headers: Some(headers), + }; + let request_url = RequestUrl::from_parts(url, "/path"); + assert_eq!(request_url.params.get("x-api-key"), Some(&"secret".to_string())); + } +} diff --git a/core/apps/dynode/src/proxy/response_builder.rs b/core/apps/dynode/src/proxy/response_builder.rs new file mode 100644 index 0000000000..ad4abaa0ea --- /dev/null +++ b/core/apps/dynode/src/proxy/response_builder.rs @@ -0,0 +1,75 @@ +use reqwest::StatusCode; +use reqwest::header::{self, HeaderMap, HeaderName, HeaderValue}; +use std::time::Duration; + +use super::constants::{JSON_CONTENT_TYPE, JSON_HEADER}; + +const X_UPSTREAM_HOST: HeaderName = HeaderName::from_static("x-upstream-host"); +const X_UPSTREAM_LATENCY: HeaderName = HeaderName::from_static("x-upstream-latency"); + +#[derive(Debug, Clone)] +pub struct ProxyResponse { + pub status: u16, + pub headers: HeaderMap, + pub body: Vec, +} + +impl ProxyResponse { + pub fn new(status: u16, headers: HeaderMap, body: Vec) -> Self { + Self { status, headers, body } + } +} + +pub struct ResponseBuilder; + +impl ResponseBuilder { + pub fn create_upstream_headers(upstream_host: Option<&str>, latency: Duration) -> HeaderMap { + let mut headers = HeaderMap::new(); + + if let Some(host) = upstream_host { + headers.insert(X_UPSTREAM_HOST, HeaderValue::from_str(host).unwrap_or_else(|_| HeaderValue::from_static("unknown"))); + } + + headers.insert( + X_UPSTREAM_LATENCY, + HeaderValue::from_str(&format!("{}ms", latency.as_millis())).unwrap_or_else(|_| HeaderValue::from_static("0ms")), + ); + + headers + } + + pub fn build_with_headers(data: Vec, status: u16, content_type: &str, additional_headers: HeaderMap) -> Result> { + let mut headers = HeaderMap::new(); + + let content_header = if content_type == JSON_CONTENT_TYPE { + JSON_HEADER.clone() + } else { + HeaderValue::from_str(content_type).unwrap_or_else(|_| JSON_HEADER.clone()) + }; + + headers.insert(header::CONTENT_TYPE, content_header); + headers.extend(additional_headers); + + Ok(ProxyResponse::new(status, headers, data)) + } + + pub fn build_cached_with_headers(cached: super::CachedResponse, additional_headers: HeaderMap) -> ProxyResponse { + let mut headers = HeaderMap::new(); + + let content_header = if cached.content_type == JSON_CONTENT_TYPE { + JSON_HEADER.clone() + } else { + HeaderValue::from_str(&cached.content_type).unwrap_or_else(|_| JSON_HEADER.clone()) + }; + + headers.insert(header::CONTENT_TYPE, content_header); + headers.extend(additional_headers); + + ProxyResponse::new(cached.status, headers, cached.body) + } + + pub fn build_json_response_with_headers(data: &T, headers: HeaderMap) -> Result> { + let response_body = serde_json::to_vec(data)?; + Self::build_with_headers(response_body, StatusCode::OK.as_u16(), JSON_CONTENT_TYPE, headers) + } +} diff --git a/core/apps/dynode/src/proxy/service.rs b/core/apps/dynode/src/proxy/service.rs new file mode 100644 index 0000000000..ffce96bd83 --- /dev/null +++ b/core/apps/dynode/src/proxy/service.rs @@ -0,0 +1,490 @@ +use crate::cache::{CacheProvider, RequestCache}; +use crate::config::{ChainConfig, HeadersConfig, Url}; +use crate::jsonrpc_types::{JsonRpcRequest, JsonRpcResponse, RequestType}; +use crate::metrics::Metrics; +use crate::proxy::CachedResponse; +use crate::proxy::jsonrpc::JsonRpcHandler; +use crate::proxy::proxy_request::ProxyRequest; +use crate::proxy::request_builder::RequestBuilder; +use crate::proxy::request_url::RequestUrl; +use crate::proxy::response_builder::{ProxyResponse, ResponseBuilder}; +use crate::webhook::DynodeBroadcastWebhookClient; +use futures::channel::oneshot; +use gem_tracing::{DurationMs, info_with_fields}; +use primitives::Chain; +use reqwest::Method; +use reqwest::StatusCode; +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName}; +use settings_chain::BroadcastProviders; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; + +struct CacheStoreInfo { + id: String, + chain: Chain, + host: String, + method: String, + path: String, + elapsed: Duration, + request_type: RequestType, +} + +type InflightWaiters = HashMap>>>; + +#[derive(Clone)] +pub struct ProxyRequestService { + pub metrics: Metrics, + pub cache: RequestCache, + pub client: reqwest::Client, + pub forward_headers: Arc>, + pub inflight_requests: Arc>, + pub headers_config: HeadersConfig, + pub broadcast_webhook: DynodeBroadcastWebhookClient, + pub broadcast_providers: Arc, +} + +#[derive(Debug, Clone)] +pub struct NodeDomain { + pub url: Url, + pub config: ChainConfig, +} + +impl NodeDomain { + pub fn new(url: Url, config: ChainConfig) -> Self { + Self { url, config } + } +} + +impl ProxyRequestService { + pub fn new( + metrics: Metrics, + cache: RequestCache, + client: reqwest::Client, + headers_config: HeadersConfig, + broadcast_webhook: DynodeBroadcastWebhookClient, + broadcast_providers: Arc, + ) -> Self { + let forward_headers: Arc> = Arc::new(headers_config.forward.iter().filter_map(|s| HeaderName::from_str(s).ok()).collect()); + + Self { + metrics, + cache, + client, + forward_headers, + inflight_requests: Arc::new(RwLock::new(HashMap::new())), + headers_config, + broadcast_webhook, + broadcast_providers, + } + } + + fn build_headers(&self, host: &str, original: &HeaderMap) -> HeaderMap { + let mut headers = RequestBuilder::filter_headers(original, &self.forward_headers); + + if let Some(names) = self.headers_config.get_domain_headers(host) { + for name in names { + if let Ok(key) = HeaderName::from_str(name) + && let Some(value) = original.get(&key) + { + headers.insert(key, value.clone()); + } + } + } + + headers + } + + fn log_incoming_request(request: &ProxyRequest, request_type: &RequestType) { + let request_id = request.id.as_str(); + let chain = request.chain.as_ref(); + let method = request.method.as_str(); + let uri = request.path.as_str(); + let user_agent = request.user_agent.as_str(); + + match request_type { + RequestType::JsonRpc(_) => { + info_with_fields!( + "Incoming request", + id = request_id, + chain = chain, + method = method, + uri = uri, + rpc_method = &request_type.get_methods_list(), + user_agent = user_agent, + ); + } + RequestType::Regular { .. } => { + info_with_fields!("Incoming request", id = request_id, chain = chain, method = method, uri = uri, user_agent = user_agent,); + } + } + } + + fn add_proxy_response_metrics(metrics: &Metrics, request: &ProxyRequest, methods_for_metrics: &[String], host: &str, status: u16) { + for method_name in methods_for_metrics { + metrics.add_proxy_response(request.chain.as_ref(), method_name, host, status, request.elapsed().as_millis()); + } + } + + pub async fn handle_request(&self, request: ProxyRequest, node_domain: &NodeDomain) -> Result> { + let chain = request.chain; + let request_type = request.request_type(); + + let rpc_method = match request_type { + RequestType::JsonRpc(JsonRpcRequest::Single(call)) => Some(call.method.as_str()), + _ => None, + }; + + let resolved_url = node_domain.config.resolve_url(&node_domain.url, rpc_method, Some(&request.path)); + let url = RequestUrl::from_parts(resolved_url, &request.path_with_query); + let headers = self.build_headers(url.url.host_str().unwrap_or_default(), &request.headers); + + self.metrics.add_proxy_request(request.chain.as_ref()); + + Self::log_incoming_request(&request, request_type); + + let cache_ttl = self.cache.should_cache_request(&chain, request_type); + let cache_key = cache_ttl.and_then(|_| request_type.cache_key(&request.host, &request.path_with_query)); + let inflight_key = self + .cache + .should_inflight_request(&chain, request_type) + .then(|| request_type.cache_key(&request.host, &request.path_with_query)) + .flatten(); + + let methods_for_metrics = request_type.get_methods_for_metrics(); + self.metrics.add_proxy_request_batch(request.chain.as_ref(), &methods_for_metrics); + + if let Some(key) = &cache_key + && let Some(result) = Self::try_cache_hit(&self.cache, key, &request, &url, &self.metrics, request_type, &methods_for_metrics).await + { + return result; + } + + if let Some(key) = &inflight_key + && let Some(result) = Self::try_inflight_hit(&self.inflight_requests, key, &request, &url, &self.metrics, &methods_for_metrics).await + { + return result; + } + + if let RequestType::JsonRpc(rpc_request) = request_type { + return JsonRpcHandler::handle_request( + rpc_request, + &request, + &self.cache, + &self.metrics, + &url, + &self.client, + &headers, + &self.broadcast_webhook, + &self.broadcast_providers, + ) + .await; + } + + let response = match Self::proxy_pass_get_data(request.method.clone(), request.body.clone(), url.clone(), &self.client, headers).await { + Ok(response) => response, + Err(error) => { + if let Some(key) = &inflight_key { + Self::release_inflight(&self.inflight_requests, key, Err(error.to_string())).await; + } + return Err(error); + } + }; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(request_type.content_type()) + .to_string(); + + let upstream_headers = ResponseBuilder::create_upstream_headers(url.url.host_str(), request.elapsed()); + let (processed_response, body_bytes) = match Self::proxy_pass_response(response, &self.forward_headers, upstream_headers).await { + Ok(result) => result, + Err(error) => { + if let Some(key) = &inflight_key { + Self::release_inflight(&self.inflight_requests, key, Err(error.to_string())).await; + } + return Err(error); + } + }; + + let remote_host = url.url.host_str().unwrap_or_default(); + Self::add_proxy_response_metrics(&self.metrics, &request, &methods_for_metrics, remote_host, status); + + self.broadcast_webhook.notify_broadcast(&request, status, &body_bytes, &self.broadcast_providers); + + if let Some(key) = &inflight_key { + Self::release_inflight( + &self.inflight_requests, + key, + Ok(CachedResponse::new(body_bytes.clone(), status, content_type, Duration::ZERO)), + ) + .await; + } + + info_with_fields!( + "Proxy response", + id = request.id.as_str(), + chain = request.chain.as_ref(), + remote_host = remote_host, + method = request.method.as_str(), + uri = request.path.as_str(), + status = status, + latency = DurationMs(request.elapsed()), + ); + + if status == StatusCode::OK.as_u16() + && let (Some(ttl), Some(key)) = (cache_ttl, cache_key) + { + let store_info = CacheStoreInfo { + id: request.id.clone(), + chain: request.chain, + host: request.host.clone(), + method: request.method.to_string(), + path: request.path.clone(), + elapsed: request.elapsed(), + request_type: request_type.clone(), + }; + let cache_clone = self.cache.clone(); + tokio::spawn(async move { + if let Err(err) = Self::store_cache(status, ttl, key, body_bytes, store_info, cache_clone).await { + gem_tracing::error_with_fields!("Failed to store cache", err.as_ref(),); + } + }); + } + + Ok(processed_response) + } + + async fn try_cache_hit( + cache: &RequestCache, + cache_key: &str, + request: &ProxyRequest, + url: &RequestUrl, + metrics: &Metrics, + request_type: &RequestType, + methods_for_metrics: &[String], + ) -> Option>> { + if let Some(cached) = cache.get(&request.chain, cache_key).await { + for method_name in methods_for_metrics { + metrics.add_cache_hit(request.chain.as_ref(), method_name); + } + + info_with_fields!( + "Cache HIT", + id = request.id.as_str(), + chain = request.chain.as_ref(), + host = &request.host, + method = &methods_for_metrics.join(",") + ); + + let upstream_headers = ResponseBuilder::create_upstream_headers(url.url.host_str(), request.elapsed()); + let status = cached.status; + + let response = match request_type { + RequestType::JsonRpc(JsonRpcRequest::Single(original_call)) => { + let data = cached.to_jsonrpc_response(original_call); + ResponseBuilder::build_with_headers(data, cached.status, &cached.content_type, upstream_headers) + } + RequestType::Regular { .. } => Ok(ResponseBuilder::build_cached_with_headers(cached.clone(), upstream_headers)), + RequestType::JsonRpc(JsonRpcRequest::Batch(_)) => return None, + }; + + Self::add_proxy_response_metrics(metrics, request, methods_for_metrics, url.url.host_str().unwrap_or_default(), status); + + Some(response) + } else { + for method_name in methods_for_metrics { + metrics.add_cache_miss(request.chain.as_ref(), method_name); + } + None + } + } + + async fn try_inflight_hit( + inflight_requests: &Arc>, + inflight_key: &str, + request: &ProxyRequest, + url: &RequestUrl, + metrics: &Metrics, + methods_for_metrics: &[String], + ) -> Option>> { + let receiver = { + let mut guard = inflight_requests.write().await; + if let Some(waiters) = guard.get_mut(inflight_key) { + let (sender, receiver) = oneshot::channel(); + waiters.push(sender); + for method_name in methods_for_metrics { + metrics.add_inflight_hit(request.chain.as_ref(), method_name); + } + Some(receiver) + } else { + guard.insert(inflight_key.to_string(), Vec::new()); + for method_name in methods_for_metrics { + metrics.add_inflight_miss(request.chain.as_ref(), method_name); + } + None + } + }; + + let receiver = receiver?; + let cached = match receiver.await { + Ok(Ok(cached)) => cached, + Ok(Err(error)) => return Some(Err(std::io::Error::other(error).into())), + Err(_) => return Some(Err(std::io::Error::other("In-flight request canceled").into())), + }; + + info_with_fields!( + "In-flight HIT", + id = request.id.as_str(), + chain = request.chain.as_ref(), + host = &request.host, + method = &methods_for_metrics.join(",") + ); + + let upstream_headers = ResponseBuilder::create_upstream_headers(url.url.host_str(), request.elapsed()); + Self::add_proxy_response_metrics(metrics, request, methods_for_metrics, url.url.host_str().unwrap_or_default(), cached.status); + Some(Ok(ResponseBuilder::build_cached_with_headers(cached, upstream_headers))) + } + + async fn release_inflight(inflight_requests: &Arc>, inflight_key: &str, result: Result) { + let waiters = { + let mut guard = inflight_requests.write().await; + guard.remove(inflight_key).unwrap_or_default() + }; + for waiter in waiters { + let _ = waiter.send(result.clone()); + } + } + + async fn store_cache( + status: u16, + cache_ttl: Duration, + cache_key: String, + body_bytes: Vec, + info: CacheStoreInfo, + cache: RequestCache, + ) -> Result<(), Box> { + let content_type = info.request_type.content_type().to_string(); + let body_size = body_bytes.len(); + + let cached = match &info.request_type { + RequestType::JsonRpc(_) => { + let json_response = serde_json::from_slice::(&body_bytes)?; + let result_bytes = serde_json::to_string(&json_response.result).unwrap_or_default().into_bytes(); + CachedResponse::new(result_bytes, status, content_type, cache_ttl) + } + RequestType::Regular { .. } => CachedResponse::new(body_bytes, status, content_type, cache_ttl), + }; + + cache.set(&info.chain, cache_key, cached, cache_ttl).await; + + info_with_fields!( + "Cache SET", + id = info.id.as_str(), + chain = info.chain.as_ref(), + host = &info.host, + method = info.method.as_str(), + path = &info.path, + ttl_ms = cache_ttl.as_millis(), + size_bytes = body_size, + latency = DurationMs(info.elapsed), + ); + + Ok(()) + } + + async fn proxy_pass_response( + response: reqwest::Response, + forward_headers: &HashSet, + additional_headers: HeaderMap, + ) -> Result<(ProxyResponse, Vec), Box> { + let resp_headers = response.headers().clone(); + let status = response.status().as_u16(); + let body = response.bytes().await?.to_vec(); + + let mut headers = RequestBuilder::filter_headers(&resp_headers, forward_headers); + headers.extend(additional_headers); + + Ok((ProxyResponse::new(status, headers, body.clone()), body)) + } + + async fn proxy_pass_get_data( + method: Method, + body: Vec, + url: RequestUrl, + client: &reqwest::Client, + headers: HeaderMap, + ) -> Result> { + let request = RequestBuilder::build(&method, &url, body, headers)?; + Ok(client.execute(request).await?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::RequestCache; + use crate::config::{CacheConfig, HeadersConfig, MetricsConfig}; + use crate::metrics::Metrics; + use crate::proxy::constants::JSON_CONTENT_TYPE; + use primitives::Chain; + use reqwest::header; + use settings_chain::BroadcastProviders; + use std::collections::HashMap; + use std::sync::Arc; + + fn create_service(headers_config: HeadersConfig) -> ProxyRequestService { + let metrics = Metrics::new(MetricsConfig::default()); + ProxyRequestService::new( + metrics.clone(), + RequestCache::new(CacheConfig::default()), + reqwest::Client::new(), + headers_config, + DynodeBroadcastWebhookClient::disabled(), + Arc::new(BroadcastProviders::from_chains([Chain::Ethereum])), + ) + } + + #[test] + fn test_build_headers_with_domain_config() { + let mut domains = HashMap::new(); + domains.insert("example.com".to_string(), vec![header::USER_AGENT.to_string()]); + + let service = create_service(HeadersConfig { + forward: vec![header::CONTENT_TYPE.to_string()], + domains, + }); + + let mut original = HeaderMap::new(); + original.insert(header::CONTENT_TYPE, header::HeaderValue::from_static(JSON_CONTENT_TYPE)); + original.insert(header::USER_AGENT, header::HeaderValue::from_static("TestAgent/1.0")); + original.insert("x-drop", header::HeaderValue::from_static("dropped")); + + let headers = service.build_headers("example.com", &original); + + assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), JSON_CONTENT_TYPE); + assert_eq!(headers.get(header::USER_AGENT).unwrap(), "TestAgent/1.0"); + assert!(headers.get("x-drop").is_none()); + } + + #[test] + fn test_build_headers_without_domain_config() { + let service = create_service(HeadersConfig { + forward: vec![header::CONTENT_TYPE.to_string()], + domains: HashMap::new(), + }); + + let mut original = HeaderMap::new(); + original.insert(header::CONTENT_TYPE, header::HeaderValue::from_static(JSON_CONTENT_TYPE)); + original.insert(header::USER_AGENT, header::HeaderValue::from_static("TestAgent/1.0")); + + let headers = service.build_headers("example.com", &original); + + assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), JSON_CONTENT_TYPE); + assert!(headers.get(header::USER_AGENT).is_none()); + } +} diff --git a/core/apps/dynode/src/proxy/types.rs b/core/apps/dynode/src/proxy/types.rs new file mode 100644 index 0000000000..a0907af926 --- /dev/null +++ b/core/apps/dynode/src/proxy/types.rs @@ -0,0 +1,66 @@ +use crate::jsonrpc_types::JsonRpcCall; +use std::str::from_utf8; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct CachedResponse { + pub body: Vec, + pub status: u16, + pub content_type: String, + pub ttl: Duration, +} + +impl CachedResponse { + pub fn new(body: Vec, status: u16, content_type: String, ttl: Duration) -> Self { + Self { body, status, content_type, ttl } + } + + pub fn to_jsonrpc_response(&self, original_call: &JsonRpcCall) -> Vec { + let result_str = from_utf8(&self.body).unwrap_or("null"); + format!(r#"{{"jsonrpc":"{}","result":{},"id":{}}}"#, original_call.jsonrpc, result_str, original_call.id).into_bytes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::constants::JSON_CONTENT_TYPE; + use reqwest::StatusCode; + + #[test] + fn test_to_jsonrpc_response() { + let response = CachedResponse::new( + br#"{"value":123}"#.to_vec(), + StatusCode::OK.as_u16(), + JSON_CONTENT_TYPE.to_string(), + Duration::from_secs(60), + ); + let call = JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "eth_blockNumber".to_string(), + params: serde_json::json!([]), + id: 42, + }; + + let result = response.to_jsonrpc_response(&call); + let result_str = from_utf8(&result).unwrap(); + + assert_eq!(result_str, r#"{"jsonrpc":"2.0","result":{"value":123},"id":42}"#); + } + + #[test] + fn test_to_jsonrpc_response_invalid_utf8() { + let response = CachedResponse::new(vec![0xff, 0xfe], StatusCode::OK.as_u16(), JSON_CONTENT_TYPE.to_string(), Duration::from_secs(60)); + let call = JsonRpcCall { + jsonrpc: "2.0".to_string(), + method: "test".to_string(), + params: serde_json::json!([]), + id: 1, + }; + + let result = response.to_jsonrpc_response(&call); + let result_str = from_utf8(&result).unwrap(); + + assert_eq!(result_str, r#"{"jsonrpc":"2.0","result":null,"id":1}"#); + } +} diff --git a/core/apps/dynode/src/response.rs b/core/apps/dynode/src/response.rs new file mode 100644 index 0000000000..66493ebd34 --- /dev/null +++ b/core/apps/dynode/src/response.rs @@ -0,0 +1,59 @@ +use std::io::Cursor; + +use crate::proxy::ProxyResponse; +use rocket::Request; +use rocket::http::{ContentType, Status}; +use rocket::response::{Responder, Response}; +use serde_json::json; + +pub struct ErrorResponse { + status: Status, + message: String, +} + +impl ErrorResponse { + pub fn new(status: Status, message: String) -> Self { + Self { status, message } + } +} + +#[rocket::async_trait] +impl<'r> Responder<'r, 'static> for ErrorResponse { + fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> { + let body = json!({ + "error": self.status.reason_lossy(), + "message": self.message, + "code": self.status.code + }) + .to_string(); + + Response::build() + .status(self.status) + .header(ContentType::JSON) + .sized_body(body.len(), Cursor::new(body)) + .ok() + } +} + +pub struct ProxyRocketResponse(pub ProxyResponse); + +#[rocket::async_trait] +impl<'r> Responder<'r, 'static> for ProxyRocketResponse { + fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> { + let ProxyResponse { status, headers, body } = self.0; + + let mut builder = Response::build(); + let status = Status::from_code(status).unwrap_or(Status::Ok); + builder.status(status); + + for (name, value) in headers.iter() { + if let Ok(value_str) = value.to_str() { + builder.raw_header(name.as_str().to_string(), value_str.to_string()); + } + } + + let body_len = body.len(); + builder.sized_body(body_len, Cursor::new(body)); + Ok(builder.finalize()) + } +} diff --git a/core/apps/dynode/src/testkit/config.rs b/core/apps/dynode/src/testkit/config.rs new file mode 100644 index 0000000000..76d4dc663f --- /dev/null +++ b/core/apps/dynode/src/testkit/config.rs @@ -0,0 +1,33 @@ +use std::time::Duration; + +use crate::config::{ErrorMatcherConfig, NodeMonitoringConfig, RetryConfig}; + +pub fn monitoring_config() -> NodeMonitoringConfig { + NodeMonitoringConfig { + enabled: true, + poll_interval: Duration::from_secs(600), + max_sync_delay: Duration::from_secs(24), + max_sync_blocks: 20, + latency_threshold: Some(Duration::from_millis(250)), + latency_threshold_percent: Some(20.0), + } +} + +pub fn retry_config(enabled: bool, status_codes: Vec, error_messages: Vec<&str>) -> RetryConfig { + retry_config_with_attempts(enabled, 0, status_codes, error_messages) +} + +pub fn retry_config_with_attempts(enabled: bool, max_attempts: usize, status_codes: Vec, error_messages: Vec<&str>) -> RetryConfig { + RetryConfig { + enabled, + max_attempts, + errors: error_matcher_config(status_codes, error_messages), + } +} + +pub fn error_matcher_config(status_codes: Vec, error_messages: Vec<&str>) -> ErrorMatcherConfig { + ErrorMatcherConfig { + status_codes, + error_messages: error_messages.into_iter().map(|value| value.to_string()).collect(), + } +} diff --git a/core/apps/dynode/src/testkit/mod.rs b/core/apps/dynode/src/testkit/mod.rs new file mode 100644 index 0000000000..e61390ac8d --- /dev/null +++ b/core/apps/dynode/src/testkit/mod.rs @@ -0,0 +1,2 @@ +pub mod config; +pub mod sync; diff --git a/core/apps/dynode/src/testkit/sync.rs b/core/apps/dynode/src/testkit/sync.rs new file mode 100644 index 0000000000..ee84673c50 --- /dev/null +++ b/core/apps/dynode/src/testkit/sync.rs @@ -0,0 +1,23 @@ +use std::time::Duration; + +use primitives::{NodeStatusState, NodeSyncStatus}; + +use crate::config::Url; +use crate::monitoring::NodeStatusObservation; + +pub fn url(host: &str) -> Url { + Url { + url: host.to_string(), + headers: None, + } +} + +pub fn healthy_observation(host: &str, latest: Option, current: Option, latency_ms: u64) -> NodeStatusObservation { + let status = NodeSyncStatus::new(true, latest, current); + NodeStatusObservation::new(url(host), NodeStatusState::healthy(status), Duration::from_millis(latency_ms)) +} + +pub fn not_in_sync_observation(host: &str, latest: Option, current: Option, latency_ms: u64) -> NodeStatusObservation { + let status = NodeSyncStatus::new(false, latest, current); + NodeStatusObservation::new(url(host), NodeStatusState::healthy(status), Duration::from_millis(latency_ms)) +} diff --git a/core/apps/dynode/src/webhook.rs b/core/apps/dynode/src/webhook.rs new file mode 100644 index 0000000000..43603ea095 --- /dev/null +++ b/core/apps/dynode/src/webhook.rs @@ -0,0 +1,160 @@ +use gem_tracing::{error_with_fields, info_with_fields}; +use primitives::{ChainRequest, ChainRequestProtocol, ChainRequestType, TransactionId}; +use settings_chain::BroadcastProviders; + +use crate::config::WebhookConfig; +use crate::jsonrpc_types::{JsonRpcRequest, RequestType}; +use crate::proxy::proxy_request::ProxyRequest; + +#[derive(Debug, Clone)] +pub struct DynodeBroadcastWebhookClient { + enabled: bool, + url: String, + client: reqwest::Client, +} + +impl DynodeBroadcastWebhookClient { + pub fn new(config: WebhookConfig) -> Result { + Ok(Self { + enabled: config.enabled, + url: config.url, + client: reqwest::Client::builder().timeout(config.timeout).build()?, + }) + } + + #[cfg(test)] + pub fn disabled() -> Self { + Self { + enabled: false, + url: String::new(), + client: reqwest::Client::new(), + } + } + + pub fn notify_broadcast(&self, request: &ProxyRequest, response_status: u16, response_body: &[u8], broadcast_providers: &BroadcastProviders) { + if !self.should_notify(request, response_status, broadcast_providers) { + return; + } + + let Some(payload) = self.extract_payload(request, response_body, broadcast_providers) else { + return; + }; + + self.spawn_notify(payload, request.id.clone()); + } + + fn should_notify(&self, request: &ProxyRequest, response_status: u16, broadcast_providers: &BroadcastProviders) -> bool { + self.enabled && !self.url.is_empty() && is_broadcast_request(request, broadcast_providers) && is_success_status(response_status) + } + + fn extract_payload(&self, request: &ProxyRequest, response_body: &[u8], broadcast_providers: &BroadcastProviders) -> Option { + let identifier = broadcast_providers.decode_transaction_broadcast(request.chain, response_body)?; + Some(TransactionId::new(request.chain, identifier)) + } + + fn spawn_notify(&self, payload: TransactionId, request_id: String) { + let url = self.url.clone(); + let client = self.client.clone(); + + tokio::spawn(Self::deliver(client, url, payload, request_id)); + } + + async fn deliver(client: reqwest::Client, url: String, payload: TransactionId, request_id: String) { + let transaction_id = payload.to_string(); + + match client.post(&url).json(&payload).send().await { + Ok(response) => { + if response.status().is_success() { + info_with_fields!("broadcast webhook delivered", transaction_id = transaction_id.as_str(), request_id = request_id.as_str(),); + } else { + info_with_fields!( + "broadcast webhook delivery failed", + transaction_id = transaction_id.as_str(), + request_id = request_id.as_str(), + status = response.status().as_u16(), + ); + } + } + Err(err) => { + error_with_fields!( + "broadcast webhook request failed", + &err, + transaction_id = transaction_id.as_str(), + request_id = request_id.as_str(), + ); + } + } + } +} + +fn is_success_status(status: u16) -> bool { + (200..300).contains(&status) +} + +fn is_broadcast_request(request: &ProxyRequest, broadcast_providers: &BroadcastProviders) -> bool { + let chain_request = match request.request_type() { + RequestType::JsonRpc(JsonRpcRequest::Single(call)) => ChainRequest::new(ChainRequestProtocol::JsonRpc, call.method.as_str(), request.path.as_str(), &request.body), + RequestType::Regular { .. } => ChainRequest::new(ChainRequestProtocol::Http, request.method.as_str(), request.path.as_str(), &request.body), + RequestType::JsonRpc(JsonRpcRequest::Batch(_)) => return false, + }; + + broadcast_providers.classify_request(request.chain, chain_request) == ChainRequestType::Broadcast +} + +#[cfg(test)] +mod tests { + use reqwest::header::HeaderMap; + + use super::*; + use crate::proxy::proxy_request::ProxyRequest; + use primitives::Chain; + use settings_chain::BroadcastProviders; + + fn make_request(chain: Chain, method: reqwest::Method, path: &str, body: &[u8]) -> ProxyRequest { + ProxyRequest::new( + method, + HeaderMap::new(), + body.to_vec(), + path.to_string(), + path.to_string(), + "example.com".to_string(), + "test-agent".to_string(), + chain, + ) + } + + fn broadcast_providers() -> BroadcastProviders { + BroadcastProviders::from_chains([Chain::Ethereum, Chain::Tron]) + } + + #[test] + fn test_detect_broadcast_jsonrpc_single() { + let request = make_request( + Chain::Ethereum, + reqwest::Method::POST, + "/rpc", + br#"{"jsonrpc":"2.0","method":"eth_sendRawTransaction","params":["0xdeadbeef"],"id":1}"#, + ); + + assert!(is_broadcast_request(&request, &broadcast_providers())); + } + + #[test] + fn test_detect_broadcast_batch_jsonrpc_skipped() { + let request = make_request( + Chain::Ethereum, + reqwest::Method::POST, + "/rpc", + br#"[{"jsonrpc":"2.0","method":"eth_sendRawTransaction","params":["0x1"],"id":1},{"jsonrpc":"2.0","method":"eth_sendRawTransaction","params":["0x2"],"id":2}]"#, + ); + + assert!(!is_broadcast_request(&request, &broadcast_providers())); + } + + #[test] + fn test_detect_broadcast_http_path() { + let request = make_request(Chain::Tron, reqwest::Method::POST, "/wallet/broadcasttransaction", br#"{"txID":"abc"}"#); + + assert!(is_broadcast_request(&request, &broadcast_providers())); + } +} diff --git a/core/bin/cli/Cargo.toml b/core/bin/cli/Cargo.toml new file mode 100644 index 0000000000..5890da3c2a --- /dev/null +++ b/core/bin/cli/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cli" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +tokio = { workspace = true } +clap = { version = "4.6.0", features = ["derive"] } + +primitives = { path = "../../crates/primitives" } +settings = { path = "../../crates/settings" } +settings_chain = { path = "../../crates/settings_chain" } diff --git a/core/bin/cli/src/commands/asset.rs b/core/bin/cli/src/commands/asset.rs new file mode 100644 index 0000000000..290825ddcd --- /dev/null +++ b/core/bin/cli/src/commands/asset.rs @@ -0,0 +1,41 @@ +use clap::{Args, Subcommand}; +use primitives::Chain; +use settings_chain::ChainProviders; +use std::error::Error; + +#[derive(Args)] +pub struct AssetCommand { + #[command(subcommand)] + command: AssetSubcommand, +} + +#[derive(Subcommand)] +enum AssetSubcommand { + /// Get token info + Info { + /// Chain name (e.g., solana, ethereum) + chain: Chain, + /// Token ID / contract address + token_id: String, + }, +} + +impl AssetCommand { + pub async fn run(&self, providers: &ChainProviders) -> Result<(), Box> { + match &self.command { + AssetSubcommand::Info { chain, token_id } => self.info(providers, *chain, token_id).await, + } + } + + async fn info(&self, providers: &ChainProviders, chain: Chain, token_id: &str) -> Result<(), Box> { + let asset = providers.get_token_data(chain, token_id.to_string()).await?; + + println!("Asset ID: {}", asset.id); + println!("Name: {}", asset.name); + println!("Symbol: {}", asset.symbol); + println!("Decimals: {}", asset.decimals); + println!("Type: {:?}", asset.asset_type); + + Ok(()) + } +} diff --git a/core/bin/cli/src/commands/balance.rs b/core/bin/cli/src/commands/balance.rs new file mode 100644 index 0000000000..1c23e3399a --- /dev/null +++ b/core/bin/cli/src/commands/balance.rs @@ -0,0 +1,39 @@ +use clap::Args; +use primitives::Chain; +use settings_chain::ChainProviders; +use std::error::Error; + +#[derive(Args)] +pub struct BalanceCommand { + chain: Chain, + address: String, +} + +impl BalanceCommand { + pub async fn run(&self, providers: &ChainProviders) -> Result<(), Box> { + let chain = self.chain; + let address = &self.address; + + match providers.get_balance_coin(chain, address.to_string()).await { + Ok(balance) => println!("{}: {}", balance.asset_id, balance.balance.available), + Err(e) => eprintln!("Coin balance error: {}", e), + } + + match providers.get_balance_assets(chain, address.to_string()).await { + Ok(balances) => { + for balance in balances { + println!("{}: {}", balance.asset_id, balance.balance.available); + } + } + Err(e) => eprintln!("Assets balance error: {}", e), + } + + match providers.get_balance_staking(chain, address.to_string()).await { + Ok(Some(balance)) => println!("{} (staked): {}", balance.asset_id, balance.balance.staked), + Ok(None) => {} + Err(e) => eprintln!("Staking balance error: {}", e), + } + + Ok(()) + } +} diff --git a/core/bin/cli/src/commands/mod.rs b/core/bin/cli/src/commands/mod.rs new file mode 100644 index 0000000000..95d8b004f2 --- /dev/null +++ b/core/bin/cli/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub mod asset; +pub mod balance; diff --git a/core/bin/cli/src/main.rs b/core/bin/cli/src/main.rs new file mode 100644 index 0000000000..49e77398bb --- /dev/null +++ b/core/bin/cli/src/main.rs @@ -0,0 +1,39 @@ +mod commands; + +use clap::{Parser, Subcommand}; +use commands::{asset::AssetCommand, balance::BalanceCommand}; +use settings::Settings; +use settings_chain::ChainProviders; +use std::error::Error; + +#[derive(Parser)] +#[command(name = "cli")] +#[command(about = "Gem Wallet CLI tool")] +#[command(after_help = "Examples: + cli asset info solana Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB + cli balance solana 7v91N7iZ9mNicL8WfG6cgSCKyRXydQjLh6UYBWwm6y1Q +")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Get token info + Asset(AssetCommand), + /// Get address balances + Balance(BalanceCommand), +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + let settings = Settings::new()?; + let providers = ChainProviders::from_settings(&settings, "cli"); + + match cli.command { + Commands::Asset(cmd) => cmd.run(&providers).await, + Commands::Balance(cmd) => cmd.run(&providers).await, + } +} diff --git a/core/bin/gas-bench/Cargo.toml b/core/bin/gas-bench/Cargo.toml new file mode 100644 index 0000000000..05f9956a96 --- /dev/null +++ b/core/bin/gas-bench/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "gas-bench" +version.workspace = true +edition.workspace = true + +[dependencies] +clap = { version = "4.6.0", features = ["derive", "env"] } + +tokio = { workspace = true } +num-bigint = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +prettytable-rs = "^0.10" + +primitives = { path = "../../crates/primitives" } +gemstone = { path = "../../gemstone", features = ["reqwest_provider"] } +gem_evm = { path = "../../crates/gem_evm" } +gem_solana = { path = "../../crates/gem_solana", features = ["reqwest"] } +gem_jsonrpc = { path = "../../crates/gem_jsonrpc", features = ["client"] } +serde_serializers = { path = "../../crates/serde_serializers" } diff --git a/core/bin/gas-bench/src/client.rs b/core/bin/gas-bench/src/client.rs new file mode 100644 index 0000000000..0407d3dc5f --- /dev/null +++ b/core/bin/gas-bench/src/client.rs @@ -0,0 +1,71 @@ +use std::error::Error; + +use gem_evm::fee_calculator::FeeCalculator; +use gem_evm::models::fee::EthereumFeeHistory; +use gem_evm::{ether_conv::EtherConv, jsonrpc::EthereumRpc}; +use gemstone::alien::{AlienProvider, new_alien_client, reqwest_provider::NativeProvider}; +use gemstone::network::JsonRpcClient; +use num_bigint::BigInt; +use primitives::{Chain, PriorityFeeValue, fee::FeePriority}; +use std::fmt::Display; +use std::sync::Arc; + +/// Represents unified gas fee data collected from a source. +#[derive(Debug)] +pub struct GemstoneFeeData { + /// The latest block number. + pub latest_block: u64, + /// The suggested base fee in gwei. + pub suggest_base_fee: String, + /// Gas used ratio for the block, if available (e.g., "50.5%"). + pub gas_used_ratio: Option, + /// A list of priority fees for different priority levels. + pub priority_fees: Vec, +} + +impl Display for GemstoneFeeData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Block: {}, Base Fee: {}", self.latest_block, self.suggest_base_fee)?; + for priority_fee in &self.priority_fees { + write!(f, "{:?}: {}", priority_fee.priority, EtherConv::to_gwei(&priority_fee.value))?; + } + Ok(()) + } +} + +#[derive(Debug)] +pub struct GemstoneClient { + native_provider: Arc, +} + +impl GemstoneClient { + pub fn new(native_provider: Arc) -> Self { + Self { native_provider } + } + + pub async fn fetch_base_priority_fees(&self, blocks: u64, reward_percentiles: Vec, min_priority_fee: u64) -> Result> { + let endpoint = self.native_provider.get_endpoint(Chain::Ethereum)?; + let alien_client = new_alien_client(endpoint, self.native_provider.clone()); + let client = JsonRpcClient::new(alien_client); + let call = EthereumRpc::FeeHistory { blocks, reward_percentiles }; + + let fee_history_data: EthereumFeeHistory = client.request(call).await?; + + let base_fee_for_next = fee_history_data.base_fee_per_gas.last().ok_or("Fee history missing base_fee_per_gas data")?; + + let service = FeeCalculator::new(); + let priorities = vec![FeePriority::Slow, FeePriority::Normal, FeePriority::Fast]; + let calculated_priority_fees = service + .calculate_priority_fees(&fee_history_data, &priorities, BigInt::from(min_priority_fee)) + .map_err(|e| format!("Failed to calculate priority fees: {}", e))?; + + let gas_used_ratio = fee_history_data.gas_used_ratio.last().map(|val_ref| format!("{:.1}%", *val_ref * 100.0)); + + Ok(GemstoneFeeData { + latest_block: fee_history_data.oldest_block + blocks - 1, + suggest_base_fee: EtherConv::to_gwei(base_fee_for_next), + gas_used_ratio, + priority_fees: calculated_priority_fees, + }) + } +} diff --git a/core/bin/gas-bench/src/etherscan.rs b/core/bin/gas-bench/src/etherscan.rs new file mode 100644 index 0000000000..e648b573b3 --- /dev/null +++ b/core/bin/gas-bench/src/etherscan.rs @@ -0,0 +1,91 @@ +// https://api.etherscan.io/api?module=gastracker&action=gasoracle&apikey=YourApiKeyToken + +use crate::client::GemstoneFeeData; +use num_bigint::BigInt; +use primitives::{PriorityFeeValue, fee::FeePriority}; +use serde::Deserialize; +use serde_serializers::deserialize_u64_from_str; +use std::error::Error; + +const ETHERSCAN_API_URL: &str = "https://api.etherscan.io/api"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct EtherscanResult { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub last_block: u64, + pub safe_gas_price: String, + pub propose_gas_price: String, + pub fast_gas_price: String, + #[serde(rename = "suggestBaseFee")] + pub suggest_base_fee: String, + #[serde(rename = "gasUsedRatio")] + pub gas_used_ratio: String, +} + +impl EtherscanResult { + /// Converts the raw Etherscan gas oracle data into the common `GemstoneFeeData` format. + pub fn fee_data(&self) -> GemstoneFeeData { + let base_fee: f64 = self.suggest_base_fee.parse().unwrap(); + let safe_fee: f64 = self.safe_gas_price.parse().unwrap(); + let propose_fee: f64 = self.propose_gas_price.parse().unwrap(); + let fast_fee: f64 = self.fast_gas_price.parse().unwrap(); + + let gas_used_ratio_str = self + .gas_used_ratio + .split(',') + .next() + .and_then(|s| s.trim().parse::().ok()) + .map(|val| format!("{:.1}%", val * 100.0)); + + GemstoneFeeData { + latest_block: self.last_block, + suggest_base_fee: self.suggest_base_fee.clone(), + gas_used_ratio: gas_used_ratio_str, + priority_fees: vec![ + PriorityFeeValue { + priority: FeePriority::Slow, + value: BigInt::from((safe_fee - base_fee) as i64), + }, + PriorityFeeValue { + priority: FeePriority::Normal, + value: BigInt::from((propose_fee - base_fee) as i64), + }, + PriorityFeeValue { + priority: FeePriority::Fast, + value: BigInt::from((fast_fee - base_fee) as i64), + }, + ], + } + } +} + +#[derive(Debug, Deserialize)] +pub struct EtherscanResponse { + pub status: String, + pub message: String, + pub result: EtherscanResult, +} + +pub struct EtherscanClient { + client: reqwest::Client, + api_key: String, +} + +impl EtherscanClient { + pub fn new(api_key: String) -> Self { + Self { + client: reqwest::Client::new(), + api_key, + } + } + + pub async fn fetch_gas_oracle(&self) -> Result> { + let url = format!("{}?module=gastracker&action=gasoracle&apikey={}", ETHERSCAN_API_URL, self.api_key); + let response = self.client.get(&url).send().await?.json::().await?; + if response.status != "1" { + return Err(format!("Etherscan API error: {}", response.message).into()); + } + Ok(response) + } +} diff --git a/core/bin/gas-bench/src/gasflow.rs b/core/bin/gas-bench/src/gasflow.rs new file mode 100644 index 0000000000..cabcb80ecd --- /dev/null +++ b/core/bin/gas-bench/src/gasflow.rs @@ -0,0 +1,77 @@ +// https://api.gasflow.dev/predict + +use num_bigint::BigInt; +use primitives::{PriorityFeeValue, fee::FeePriority}; +use serde::Deserialize; + +use crate::client::GemstoneFeeData; + +const GASFLOW_API_URL: &str = "https://api.gasflow.dev/predict"; + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct PredictedQuantiles { + pub minimum: f64, + pub normal: f64, + pub fast: f64, + pub urgent: f64, + pub critical: f64, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct NetworkMetrics { + pub gas_ratio_5: f64, + pub gas_spikes_25: f64, + pub fee_ewma_10: f64, + pub fee_ewma_25: f64, +} + +#[derive(Debug, Deserialize)] +pub struct GasflowResponse { + pub current_block_number: u64, + pub current_base_fee_gwei: f64, + pub predicted_quantiles: PredictedQuantiles, + pub network_metrics: NetworkMetrics, +} + +impl GasflowResponse { + /// Converts the raw Gasflow API data into the common `GemstoneFeeData` format. + pub fn fee_data(&self) -> GemstoneFeeData { + let gas_used_ratio_str = Some(format!("{:.1}%", self.network_metrics.gas_ratio_5 * 100.0)); + + GemstoneFeeData { + latest_block: self.current_block_number, + suggest_base_fee: self.current_base_fee_gwei.to_string(), + gas_used_ratio: gas_used_ratio_str, + priority_fees: vec![ + PriorityFeeValue { + priority: FeePriority::Slow, + value: BigInt::from(self.predicted_quantiles.minimum as i64), + }, + PriorityFeeValue { + priority: FeePriority::Normal, + value: BigInt::from(self.predicted_quantiles.normal as i64), + }, + PriorityFeeValue { + priority: FeePriority::Fast, + value: BigInt::from(self.predicted_quantiles.fast as i64), + }, + ], + } + } +} + +pub struct GasflowClient { + client: reqwest::Client, +} + +impl GasflowClient { + pub fn new() -> Self { + Self { client: reqwest::Client::new() } + } + + pub async fn fetch_prediction(&self) -> Result { + self.client.get(GASFLOW_API_URL).send().await?.json::().await + } +} diff --git a/core/bin/gas-bench/src/helius/client.rs b/core/bin/gas-bench/src/helius/client.rs new file mode 100644 index 0000000000..21c831955c --- /dev/null +++ b/core/bin/gas-bench/src/helius/client.rs @@ -0,0 +1,39 @@ +use super::{HeliusPriorityFeeOptions, HeliusPriorityFeeParams, HeliusPriorityFeeRequest, HeliusPriorityFeeResponse, HeliusPriorityFees}; +use std::error::Error; + +pub struct HeliusClient { + client: reqwest::Client, + endpoint: String, +} + +impl HeliusClient { + pub fn new(api_key: &str) -> Self { + Self { + client: reqwest::Client::new(), + endpoint: format!("https://mainnet.helius-rpc.com/?api-key={}", api_key), + } + } + + pub async fn fetch_priority_fee_estimate(&self, account_keys: Option>) -> Result> { + let request = HeliusPriorityFeeRequest { + jsonrpc: "2.0", + id: "1", + method: "getPriorityFeeEstimate", + params: vec![HeliusPriorityFeeParams { + account_keys, + options: HeliusPriorityFeeOptions { + include_all_priority_fee_levels: true, + lookback_slots: 150, + }, + }], + }; + + let response = self.client.post(&self.endpoint).json(&request).send().await?; + + let result: HeliusPriorityFeeResponse = response.json().await?; + + let levels = result.result.priority_fee_levels.ok_or("No priority fee levels in response")?; + + Ok(HeliusPriorityFees::from_levels(&levels)) + } +} diff --git a/core/bin/gas-bench/src/helius/mod.rs b/core/bin/gas-bench/src/helius/mod.rs new file mode 100644 index 0000000000..6979124fc0 --- /dev/null +++ b/core/bin/gas-bench/src/helius/mod.rs @@ -0,0 +1,5 @@ +mod client; +mod model; + +pub use client::*; +pub use model::*; diff --git a/core/bin/gas-bench/src/helius/model.rs b/core/bin/gas-bench/src/helius/model.rs new file mode 100644 index 0000000000..63ae5c5764 --- /dev/null +++ b/core/bin/gas-bench/src/helius/model.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct HeliusPriorityFeeRequest { + pub jsonrpc: &'static str, + pub id: &'static str, + pub method: &'static str, + pub params: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HeliusPriorityFeeParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub account_keys: Option>, + pub options: HeliusPriorityFeeOptions, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HeliusPriorityFeeOptions { + pub include_all_priority_fee_levels: bool, + pub lookback_slots: u32, +} + +#[derive(Debug, Deserialize)] +pub struct HeliusPriorityFeeResponse { + pub result: HeliusPriorityFeeResult, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HeliusPriorityFeeResult { + pub priority_fee_levels: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HeliusPriorityFeeLevels { + pub low: f64, + pub medium: f64, + pub high: f64, +} + +#[derive(Debug)] +pub struct HeliusPriorityFees { + pub low: u64, + pub medium: u64, + pub high: u64, +} + +impl HeliusPriorityFees { + pub fn from_levels(levels: &HeliusPriorityFeeLevels) -> Self { + Self { + low: levels.low as u64, + medium: levels.medium as u64, + high: levels.high as u64, + } + } +} diff --git a/core/bin/gas-bench/src/jito/client.rs b/core/bin/gas-bench/src/jito/client.rs new file mode 100644 index 0000000000..5c3aa56ec1 --- /dev/null +++ b/core/bin/gas-bench/src/jito/client.rs @@ -0,0 +1,22 @@ +use super::{JitoTipFloor, JitoTipFloorEntry}; +use std::error::Error; + +const JITO_TIP_FLOOR_URL: &str = "https://bundles.jito.wtf/api/v1/bundles/tip_floor"; + +#[derive(Default)] +pub struct JitoClient { + client: reqwest::Client, +} + +impl JitoClient { + pub fn new() -> Self { + Self::default() + } + + pub async fn fetch_tip_floor(&self) -> Result> { + let response = self.client.get(JITO_TIP_FLOOR_URL).send().await?; + let entries: Vec = response.json().await?; + let entry = entries.first().ok_or("No tip floor data returned from Jito API")?; + Ok(JitoTipFloor::from_entry(entry)) + } +} diff --git a/core/bin/gas-bench/src/jito/mod.rs b/core/bin/gas-bench/src/jito/mod.rs new file mode 100644 index 0000000000..6979124fc0 --- /dev/null +++ b/core/bin/gas-bench/src/jito/mod.rs @@ -0,0 +1,5 @@ +mod client; +mod model; + +pub use client::*; +pub use model::*; diff --git a/core/bin/gas-bench/src/jito/model.rs b/core/bin/gas-bench/src/jito/model.rs new file mode 100644 index 0000000000..ffbf0f8eda --- /dev/null +++ b/core/bin/gas-bench/src/jito/model.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; + +const LAMPORTS_PER_SOL: u64 = 1_000_000_000; +const MICRO_LAMPORTS_PER_LAMPORT: u64 = 1_000_000; + +#[derive(Debug, Deserialize)] +pub struct JitoTipFloorEntry { + pub landed_tips_25th_percentile: f64, + pub landed_tips_50th_percentile: f64, + pub landed_tips_75th_percentile: f64, +} + +#[derive(Debug)] +pub struct JitoTipFloor { + pub p25_lamports: u64, + pub p50_lamports: u64, + pub p75_lamports: u64, +} + +impl JitoTipFloor { + pub fn from_entry(entry: &JitoTipFloorEntry) -> Self { + Self { + p25_lamports: sol_to_lamports(entry.landed_tips_25th_percentile), + p50_lamports: sol_to_lamports(entry.landed_tips_50th_percentile), + p75_lamports: sol_to_lamports(entry.landed_tips_75th_percentile), + } + } +} + +pub fn sol_to_lamports(sol: f64) -> u64 { + (sol * LAMPORTS_PER_SOL as f64) as u64 +} + +pub fn lamports_to_sol(lamports: u64) -> String { + let sol = lamports as f64 / LAMPORTS_PER_SOL as f64; + if sol < 0.000001 { + format!("{:.9}", sol) + } else if sol < 0.001 { + format!("{:.6}", sol) + } else { + format!("{:.4}", sol) + } +} + +pub fn priority_fee_to_lamports(micro_lamports_per_cu: u64, compute_units: u64) -> u64 { + (micro_lamports_per_cu as u128 * compute_units as u128 / MICRO_LAMPORTS_PER_LAMPORT as u128) as u64 +} + +pub fn format_micro_lamports(micro_lamports: u64) -> String { + if micro_lamports >= MICRO_LAMPORTS_PER_LAMPORT { + format!("{:.2}M", micro_lamports as f64 / MICRO_LAMPORTS_PER_LAMPORT as f64) + } else if micro_lamports >= 1_000 { + format!("{:.1}K", micro_lamports as f64 / 1_000.0) + } else { + format!("{}", micro_lamports) + } +} diff --git a/core/bin/gas-bench/src/main.rs b/core/bin/gas-bench/src/main.rs new file mode 100644 index 0000000000..c8c98f9199 --- /dev/null +++ b/core/bin/gas-bench/src/main.rs @@ -0,0 +1,416 @@ +mod client; +mod etherscan; +mod gasflow; +mod helius; +mod jito; +mod solana_client; + +use clap::{Parser, ValueEnum}; +use prettytable::{Cell, Row, Table, format}; +use std::error::Error; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use tokio::time::interval; + +use crate::jito::{format_micro_lamports, lamports_to_sol, priority_fee_to_lamports}; +use crate::{ + client::{GemstoneClient, GemstoneFeeData}, + etherscan::EtherscanClient, + gasflow::GasflowClient, + helius::{HeliusClient, HeliusPriorityFees}, + jito::{JitoClient, JitoTipFloor}, + solana_client::{SolanaFeeData, SolanaGasClient}, +}; +use gem_evm::ether_conv::EtherConv; +use gem_solana::JUPITER_PROGRAM_ID; +use gemstone::alien::reqwest_provider::NativeProvider; +use primitives::fee::FeePriority; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum ChainMode { + Ethereum, + Solana, +} + +#[derive(Debug, Clone)] +struct SourceFeeDetail { + source_name: String, + base_fee: String, + gas_used_ratio: Option, + slow_fee: String, + normal_fee: String, + fast_fee: String, +} + +#[derive(Parser, Debug)] +#[clap( + author, + version, + about, + long_about = "A CLI tool to benchmark gas/priority fees from multiple sources.\n\ +It periodically fetches fee data and displays comparative tables.\n\n\ +For Ethereum: fetches from Gemstone (local node), Etherscan API, and Gasflow API.\n\ +For Solana: fetches priority fees via RPC and compares with Jito tip floor API." +)] +struct Cli { + /// Chain to benchmark + #[clap(short, long, value_enum, default_value_t = ChainMode::Ethereum)] + chain: ChainMode, + + /// Enable debug logging + #[arg(long, short, action = clap::ArgAction::SetTrue)] + debug: bool, + + /// The number of blocks to fetch (Ethereum only) + #[clap(short, long, default_value_t = 4)] + blocks: u64, + + /// The reward percentiles to fetch (Ethereum only) + #[clap(short, long, value_delimiter = ',', default_value = "20,40,60")] + reward_percentiles: Vec, + + /// The minimum priority fee in wei (Ethereum only, default: 0.01 Gwei) + #[clap(short, long, default_value_t = 10000000)] + min_priority_fee: u64, + + /// The Etherscan API key (Ethereum only) + #[clap(long, env = "ETHERSCAN_API_KEY")] + etherscan_api_key: Option, + + /// Compute units for priority fee calculation (Solana only) + #[clap(long, default_value_t = 200_000)] + compute_units: u64, + + /// Skip Jito API comparison (Solana only) + #[clap(long, action = clap::ArgAction::SetTrue)] + skip_jito: bool, + + /// Helius API key for getPriorityFeeEstimate (Solana only) + #[clap(long, env = "HELIUS_API_KEY")] + helius_api_key: Option, +} + +async fn run_ethereum(args: Cli) -> Result<(), Box> { + let etherscan_api_key = args.etherscan_api_key.ok_or("Etherscan API key is required for Ethereum mode")?; + + let mut ticker = interval(Duration::from_secs(6)); + let native_provider = Arc::new(NativeProvider::new().set_debug(args.debug)); + + let mut last_printed_block_opt: Option = None; + let mut block_data: HashMap> = HashMap::new(); + println!( + "gas-bench [Ethereum]: with history blocks: {}, reward percentiles: {:?}", + args.blocks, args.reward_percentiles + ); + + loop { + ticker.tick().await; + if args.debug { + eprintln!("gas-bench: fetching new gas fee data..."); + } + + let gemstone_client_clone = GemstoneClient::new(native_provider.clone()); + let reward_percentiles_clone = args.reward_percentiles.clone(); + let etherscan_api_key_clone = etherscan_api_key.clone(); + + let fee_history_future = gemstone_client_clone.fetch_base_priority_fees(args.blocks, reward_percentiles_clone, args.min_priority_fee); + + let etherscan_future = async move { + let client = EtherscanClient::new(etherscan_api_key_clone); + client.fetch_gas_oracle().await + }; + + let gasflow_future = async { + let client = GasflowClient::new(); + client.fetch_prediction().await + }; + + let (gemstone_res, etherscan_res, gasflow_res) = tokio::join!(fee_history_future, etherscan_future, gasflow_future); + + if args.debug { + eprintln!("gas-bench: processing new fetch cycle, block_data currently has {} entries.", block_data.len()); + } + + let process_fee_data = |source_name: &str, data: &GemstoneFeeData| -> SourceFeeDetail { + let mut slow = "N/A".to_string(); + let mut normal = "N/A".to_string(); + let mut fast = "N/A".to_string(); + for fee_record in &data.priority_fees { + match fee_record.priority { + FeePriority::Slow => slow = EtherConv::to_gwei(&fee_record.value), + FeePriority::Normal => normal = EtherConv::to_gwei(&fee_record.value), + FeePriority::Fast => fast = EtherConv::to_gwei(&fee_record.value), + } + } + SourceFeeDetail { + source_name: source_name.to_string(), + base_fee: data.suggest_base_fee.clone(), + gas_used_ratio: data.gas_used_ratio.clone(), + slow_fee: slow, + normal_fee: normal, + fast_fee: fast, + } + }; + + if let Ok(data) = gemstone_res { + let entry = block_data.entry(data.latest_block).or_default(); + if !entry.iter().any(|d| d.source_name == "Gemstone") { + entry.push(process_fee_data("Gemstone", &data)); + } + } else if let Err(e) = gemstone_res + && args.debug + { + eprintln!("gas-bench: Error fetching Gemstone data: {e:?}"); + } + + if let Ok(data) = etherscan_res { + let fee_data = data.result.fee_data(); + let entry = block_data.entry(fee_data.latest_block).or_default(); + if !entry.iter().any(|d| d.source_name == "Etherscan") { + entry.push(process_fee_data("Etherscan", &fee_data)); + } + } else if let Err(e) = etherscan_res + && args.debug + { + eprintln!("Error fetching Etherscan data: {e:?}"); + } + + if let Ok(data) = gasflow_res { + let fee_data = data.fee_data(); + let entry = block_data.entry(fee_data.latest_block).or_default(); + if !entry.iter().any(|d| d.source_name == "Gasflow") { + entry.push(process_fee_data("Gasflow", &fee_data)); + } + } else if let Err(e) = gasflow_res + && args.debug + { + eprintln!("Error fetching Gasflow data: {e:?}"); + } + + if args.debug { + eprintln!("Debug: Aggregated block_data summary:"); + let mut sorted_debug_keys: Vec<_> = block_data.keys().collect(); + sorted_debug_keys.sort(); + for block_num in sorted_debug_keys { + if let Some(details) = block_data.get(block_num) { + let sources: Vec<&str> = details.iter().map(|d| d.source_name.as_str()).collect(); + eprintln!(" Block {block_num}: {sources:?}"); + } + } + } + + let mut sorted_blocks_in_map: Vec = block_data.keys().cloned().collect(); + sorted_blocks_in_map.sort_unstable(); + + let block_to_print_this_iteration: Option = match last_printed_block_opt { + Some(last_printed) => sorted_blocks_in_map + .into_iter() + .find(|&block_num| block_num > last_printed && block_data.get(&block_num).is_some_and(|details| details.len() >= 2)), + None => sorted_blocks_in_map + .iter() + .find(|&&b_num| block_data.get(&b_num).is_some_and(|details| details.len() >= 2)) + .cloned(), + }; + + if args.debug { + eprintln!("Debug: last_printed_block_opt: {last_printed_block_opt:?}"); + eprintln!("Debug: block_to_print_this_iteration: {block_to_print_this_iteration:?}"); + } + + if let Some(current_block_to_print) = block_to_print_this_iteration { + if args.debug { + eprintln!("Debug: Attempting to print table for block: {current_block_to_print}"); + } + if let Some(details_for_block) = block_data.get(¤t_block_to_print) + && details_for_block.len() >= 2 + { + println!("\n--- Block: {current_block_to_print} ---"); + let mut table = Table::new(); + table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR); + table.add_row(Row::new(vec![ + Cell::new("Source"), + Cell::new("Base Fee (Gwei)"), + Cell::new("Used Gas (%)"), + Cell::new("Slow (Gwei)"), + Cell::new("Normal (Gwei)"), + Cell::new("Fast (Gwei)"), + ])); + + for detail in details_for_block { + table.add_row(Row::new(vec![ + Cell::new(&detail.source_name), + Cell::new(&detail.base_fee), + Cell::new(&detail.gas_used_ratio.clone().unwrap_or_else(|| "N/A".to_string())), + Cell::new(&detail.slow_fee), + Cell::new(&detail.normal_fee), + Cell::new(&detail.fast_fee), + ])); + } + table.printstd(); + last_printed_block_opt = Some(current_block_to_print); + } + } + } +} + +async fn run_solana(args: Cli) -> Result<(), Box> { + let mut ticker = interval(Duration::from_secs(6)); + let native_provider = Arc::new(NativeProvider::new().set_debug(args.debug)); + let solana_client = SolanaGasClient::new(native_provider); + let jito_client = if args.skip_jito { None } else { Some(JitoClient::new()) }; + let helius_client = args.helius_api_key.as_ref().map(|key| HeliusClient::new(key)); + + let mut last_printed_slot: Option = None; + + println!("gas-bench [Solana]: monitoring priority fees and Jito tips"); + println!(" Compute units for fee calculation: {}", args.compute_units); + println!(" Jito comparison: {}", if jito_client.is_some() { "enabled" } else { "disabled" }); + println!(" Helius comparison: {}", if helius_client.is_some() { "enabled" } else { "disabled (set HELIUS_API_KEY)" }); + println!(); + + loop { + ticker.tick().await; + + if args.debug { + eprintln!("gas-bench: fetching Solana fee data..."); + } + + let solana_future = solana_client.fetch_fee_data(); + let jito_future = async { + match &jito_client { + Some(client) => Some(client.fetch_tip_floor().await), + None => None, + } + }; + let helius_future = async { + match &helius_client { + Some(client) => Some(client.fetch_priority_fee_estimate(Some(vec![JUPITER_PROGRAM_ID.to_string()])).await), + None => None, + } + }; + + let (solana_res, jito_res, helius_res) = tokio::join!(solana_future, jito_future, helius_future); + + match solana_res { + Ok(fee_data) => { + if last_printed_slot.is_some_and(|s| s >= fee_data.slot) { + continue; + } + + print_solana_fee_data(&fee_data, &jito_res, &helius_res, args.compute_units); + last_printed_slot = Some(fee_data.slot); + } + Err(e) => { + if args.debug { + eprintln!("gas-bench: Error fetching Solana data: {e:?}"); + } + } + } + } +} + +fn print_solana_fee_data( + fee_data: &SolanaFeeData, + jito_res: &Option>>, + helius_res: &Option>>, + compute_units: u64, +) { + println!("\n--- Slot: {} ---", fee_data.slot); + + let accounts = [ + ("Jupiter", &fee_data.account_fees.jupiter), + ("Orca", &fee_data.account_fees.orca), + ("USDC", &fee_data.account_fees.usdc), + ]; + let active_accounts: Vec<&str> = accounts + .iter() + .filter(|(_, data)| data.as_ref().is_some_and(|d| d.count > 0)) + .map(|(name, _)| *name) + .collect(); + + let jito_available = jito_res.as_ref().is_some_and(|r| r.is_ok()); + let helius_data = helius_res.as_ref().and_then(|r| r.as_ref().ok()); + + if !active_accounts.is_empty() { + print!("Sampling: {} (avg: {} µL/CU)", active_accounts.join(", "), fee_data.raw_fees.avg); + } + if let Some(helius) = helius_data { + print!( + " | Helius: slow={} normal={} fast={}", + format_micro_lamports(helius.low), + format_micro_lamports(helius.medium), + format_micro_lamports(helius.high) + ); + } + println!(); + + let mut table = Table::new(); + table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR); + + let mut header = vec![Cell::new("Level"), Cell::new("Priority (70%)"), Cell::new("Jito Tip (30%)"), Cell::new("Total")]; + if jito_available { + header.push(Cell::new("Jito Floor")); + } + table.add_row(Row::new(header)); + + let levels = [ + ("Slow", fee_data.priority_fees.slow, fee_data.jito_tips.slow), + ("Normal", fee_data.priority_fees.normal, fee_data.jito_tips.normal), + ("Fast", fee_data.priority_fees.fast, fee_data.jito_tips.fast), + ]; + + let jito_data = jito_res.as_ref().and_then(|r| r.as_ref().ok()); + + for (level, priority_fee, jito_tip) in levels.iter() { + let priority_lamports = priority_fee_to_lamports(*priority_fee, compute_units); + let total_lamports = priority_lamports + jito_tip; + let priority_display = format!("{} ({})", format_micro_lamports(*priority_fee), lamports_to_sol(priority_lamports)); + let jito_tip_display = lamports_to_sol(*jito_tip); + let total_display = lamports_to_sol(total_lamports); + + let mut row = vec![Cell::new(level), Cell::new(&priority_display), Cell::new(&jito_tip_display), Cell::new(&total_display)]; + + if let Some(jito) = jito_data { + let jito_floor = match *level { + "Slow" => jito.p25_lamports, + "Normal" => jito.p50_lamports, + "Fast" => jito.p75_lamports, + _ => 0, + }; + let jito_floor_display = lamports_to_sol(jito_floor); + row.push(Cell::new(&jito_floor_display)); + } + + table.add_row(Row::new(row)); + } + + table.printstd(); + + if let Some(Err(e)) = jito_res { + println!(" (Jito API error: {})", e); + } + if let Some(Err(e)) = helius_res { + println!(" (Helius API error: {})", e); + } +} + +async fn run(args: Cli) -> Result<(), Box> { + match args.chain { + ChainMode::Ethereum => run_ethereum(args).await, + ChainMode::Solana => run_solana(args).await, + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Cli::parse(); + if args.debug { + eprintln!("gas-bench: debug mode enabled by CLI flag."); + } + + if let Err(e) = run(args).await { + eprintln!("gas-bench: run error: {e}"); + std::process::exit(1); + } + + Ok(()) +} diff --git a/core/bin/gas-bench/src/solana_client.rs b/core/bin/gas-bench/src/solana_client.rs new file mode 100644 index 0000000000..229bf554fb --- /dev/null +++ b/core/bin/gas-bench/src/solana_client.rs @@ -0,0 +1,139 @@ +use std::error::Error; +use std::sync::Arc; + +use gem_jsonrpc::client::JsonRpcClient; +use gem_solana::models::jito::{FeeStats, calculate_fee_stats}; +use gem_solana::models::prioritization_fee::SolanaPrioritizationFee; +use gem_solana::{JUPITER_PROGRAM_ID, USDC_TOKEN_MINT}; +use gemstone::alien::{AlienProvider, new_alien_client, reqwest_provider::NativeProvider}; +use primitives::Chain; +use serde_json::json; + +pub const ORCA_WHIRLPOOL: &str = "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"; + +const MIN_SLOW_FEE: u64 = 1_000; +const MIN_NORMAL_FEE: u64 = 10_000; +const MIN_FAST_FEE: u64 = 100_000; + +const JITO_TIP_MIN_LAMPORTS: u64 = 10_000; + +#[derive(Debug, Clone)] +pub struct JitoTipEstimates { + pub slow: u64, + pub normal: u64, + pub fast: u64, +} + +#[derive(Debug)] +pub struct SolanaFeeData { + pub slot: u64, + pub priority_fees: PriorityFees, + pub jito_tips: JitoTipEstimates, + pub raw_fees: FeeStats, + pub account_fees: AccountFeeStats, +} + +#[derive(Debug)] +pub struct PriorityFees { + pub slow: u64, + pub normal: u64, + pub fast: u64, +} + +#[derive(Debug, Default)] +pub struct AccountFeeStats { + pub jupiter: Option, + pub orca: Option, + pub usdc: Option, +} + +pub struct SolanaGasClient { + native_provider: Arc, +} + +impl SolanaGasClient { + pub fn new(native_provider: Arc) -> Self { + Self { native_provider } + } + + pub async fn fetch_fee_data(&self) -> Result> { + let endpoint = self.native_provider.get_endpoint(Chain::Solana)?; + let alien_client = new_alien_client(endpoint, self.native_provider.clone()); + let client: JsonRpcClient<_> = JsonRpcClient::new(alien_client); + + let slot: u64 = client.call("getSlot", json!([])).await?; + + let global_fees: Vec = client.call("getRecentPrioritizationFees", json!([])).await?; + + let mut account_fees = AccountFeeStats::default(); + + for (account, name) in [(JUPITER_PROGRAM_ID, "jupiter"), (ORCA_WHIRLPOOL, "orca"), (USDC_TOKEN_MINT, "usdc")] { + let fees: Vec = client.call("getRecentPrioritizationFees", json!([[account]])).await?; + + if !fees.is_empty() { + let values: Vec = fees.iter().map(|f| f.prioritization_fee).collect(); + let stats = calculate_fee_stats(&values); + match name { + "jupiter" => account_fees.jupiter = Some(stats), + "orca" => account_fees.orca = Some(stats), + "usdc" => account_fees.usdc = Some(stats), + _ => {} + } + } + } + + let global_values: Vec = global_fees.iter().map(|f| f.prioritization_fee).collect(); + let raw_fees = calculate_fee_stats(&global_values); + + let effective_stats = get_best_fee_stats(&raw_fees, &account_fees); + let priority_fees = calculate_priority_fees(&effective_stats); + let jito_tips = estimate_jito_tips(&effective_stats); + + Ok(SolanaFeeData { + slot, + priority_fees, + jito_tips, + raw_fees, + account_fees, + }) + } +} + +fn get_best_fee_stats(global: &FeeStats, accounts: &AccountFeeStats) -> FeeStats { + accounts + .jupiter + .as_ref() + .filter(|a| a.count > 0 && a.avg > 0) + .or_else(|| accounts.orca.as_ref().filter(|a| a.count > 0 && a.avg > 0)) + .or_else(|| accounts.usdc.as_ref().filter(|a| a.count > 0 && a.avg > 0)) + .cloned() + .unwrap_or_else(|| global.clone()) +} + +fn calculate_priority_fees(stats: &FeeStats) -> PriorityFees { + PriorityFees { + slow: (stats.median as u64).max(MIN_SLOW_FEE), + normal: (stats.p75 as u64).max(MIN_NORMAL_FEE), + fast: (stats.p90 as u64).max(MIN_FAST_FEE), + } +} + +fn estimate_jito_tips(stats: &FeeStats) -> JitoTipEstimates { + const BASE_SLOW: u64 = 1_000; + const BASE_NORMAL: u64 = 3_000; + const BASE_FAST: u64 = 10_000; + const REFERENCE_FEE: f64 = 10_000.0; + + let congestion_multiplier = if stats.avg > 0 { + let raw_multiplier = stats.avg as f64 / REFERENCE_FEE; + (1.0 + raw_multiplier.sqrt()).clamp(1.0, 10.0) + } else { + 1.0 + }; + + JitoTipEstimates { + slow: ((BASE_SLOW as f64 * congestion_multiplier) as u64).max(JITO_TIP_MIN_LAMPORTS), + normal: ((BASE_NORMAL as f64 * congestion_multiplier) as u64).max(JITO_TIP_MIN_LAMPORTS), + fast: ((BASE_FAST as f64 * congestion_multiplier) as u64).max(JITO_TIP_MIN_LAMPORTS), + } +} diff --git a/core/bin/generate/Cargo.toml b/core/bin/generate/Cargo.toml new file mode 100644 index 0000000000..f9a49615be --- /dev/null +++ b/core/bin/generate/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "generate" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +primitives = { path = "../../crates/primitives" } diff --git a/core/bin/generate/src/main.rs b/core/bin/generate/src/main.rs new file mode 100644 index 0000000000..52f645056f --- /dev/null +++ b/core/bin/generate/src/main.rs @@ -0,0 +1,222 @@ +use primitives::Platform; + +use std::{ + fs::{self, DirEntry}, + path::Path, + process::Command, + vec, +}; + +const ANDROID_PACKAGE_PREFIX: &str = "com.wallet.core"; +const IOS_GENERATED_DIR: &str = "Generated"; +const LANGUAGE_SWIFT: &str = "swift"; +const LANGUAGE_KOTLIN: &str = "kotlin"; +const LANG_KOTLIN_ETX: &str = "kt"; +const LANGUAGE_TYPESCRIPT: &str = "typescript"; +const LANG_TYPESCRIPT_EXT: &str = "ts"; + +#[derive(Clone, Copy)] +enum GeneratorType { + Swift, + Kotlin, + TypeScript, +} + +fn main() { + let folders = vec!["crates/primitives"]; + + let platform_str = std::env::args().nth(1).expect("no platform specified"); + let platform_directory_path = std::env::args().nth(2).expect("no path specified"); + + let generator_type = match platform_str.as_str() { + "web" => GeneratorType::TypeScript, + "ios" => GeneratorType::Swift, + "android" => GeneratorType::Kotlin, + other => panic!("unsupported generator target: {other}"), + }; + + let mut ignored_files: Vec<&'static str> = [ + "lib.rs", + "mod.rs", + "client.rs", + "model.rs", + "address.rs", + "address_formatter.rs", + "big_int_hex.rs", + "hash.rs", + "pubkey.rs", + "ethereum_address.rs", + "keccak.rs", + "number_formatter.rs", + "mode.rs", + "quote.rs", + "slippage.rs", + ] + .to_vec(); + let mut platform_ignored = ignored_files_by_generator(&generator_type); + ignored_files.append(&mut platform_ignored); + + for folder in folders { + let src_path = format!("{folder}/src"); + let paths = get_paths(folder, src_path); + process_paths(paths, folder, &generator_type, &platform_directory_path, &ignored_files); + } +} + +fn process_paths(paths: Vec, _folder: &str, generator_type: &GeneratorType, platform_directory_path: &str, ignored_files: &[&str]) { + for path in paths { + // Example path: + // ./crates/primitives/src/utxo.rs + let vec: Vec<&str> = path.split("/src/").collect(); + if vec.len() < 2 { + continue; + } + + let first_parts: Vec<&str> = vec[0].split('/').collect(); + if first_parts.len() < 2 { + continue; + } + + let module_name = first_parts[1]; + + let directory_paths: Vec<&str> = vec[1].split('/').collect(); + let mut directory_paths_capitalized = directory_paths.iter().filter(|x| !x.starts_with('.')).map(|&x| str_capitlize(x)).collect::>(); + + if directory_paths_capitalized.is_empty() { + continue; + } + + let file_path = directory_paths_capitalized.pop().unwrap(); + + let file_name_original = directory_paths.last().unwrap_or(&""); + let allow_mod_for_swap = matches!(generator_type, GeneratorType::TypeScript) + && *file_name_original == "mod.rs" + && directory_paths.len() >= 2 + && directory_paths[directory_paths.len() - 2] == "swap"; + + if ignored_files.contains(file_name_original) && !allow_mod_for_swap { + continue; + } + let input_path = format!("./{}/src/{}", vec[0], directory_paths.join("/")); + + match generator_type { + GeneratorType::Swift => { + let ios_new_file_name = file_name(&file_path, LANGUAGE_SWIFT); + let ios_new_path = format!("{}/{}", directory_paths_capitalized.join("/"), ios_new_file_name); + let ios_output_path = output_path(Platform::IOS, platform_directory_path, str_capitlize(module_name).as_str(), ios_new_path); + generate_files(LANGUAGE_SWIFT, input_path.as_str(), ios_output_path.as_str(), None); + } + GeneratorType::Kotlin => { + let kt_new_file_name = file_name(&file_path, LANG_KOTLIN_ETX); + let directory_paths_lowercased: Vec = directory_paths_capitalized.iter().map(|x| x.to_lowercase()).collect(); + let kt_new_path = format!("{}/{}", directory_paths_lowercased.join("/"), kt_new_file_name); + let android_output_path = output_path(Platform::Android, platform_directory_path, module_name, kt_new_path.clone()); + let directory_package = directory_paths_lowercased.join("."); + let android_package_name = format!( + "{}.{}{}", + ANDROID_PACKAGE_PREFIX, + module_name, + if directory_package.is_empty() { String::new() } else { format!(".{directory_package}") } + ); + generate_files(LANGUAGE_KOTLIN, input_path.as_str(), android_output_path.as_str(), Some(android_package_name.as_str())); + } + GeneratorType::TypeScript => { + let ts_new_file_name = file_name(&file_path, LANG_TYPESCRIPT_EXT); + let directory_paths_lowercased: Vec = directory_paths_capitalized.iter().map(|x| x.to_lowercase()).collect(); + let ts_new_path = format!("{}/{}", directory_paths_lowercased.join("/"), ts_new_file_name); + let web_output_path = output_path_web(platform_directory_path, module_name, ts_new_path); + generate_files(LANGUAGE_TYPESCRIPT, input_path.as_str(), web_output_path.as_str(), None); + } + } + } +} + +fn output_path(platform: Platform, directory: &str, module_name: &str, path: String) -> String { + match platform { + Platform::IOS => format!("{directory}/{module_name}/Sources/{IOS_GENERATED_DIR}/{path}"), + Platform::Android => format!("{directory}/{module_name}/generated/{path}"), + } +} + +fn output_path_web(directory: &str, module_name: &str, path: String) -> String { + format!("{directory}/{module_name}/{path}") +} + +fn file_name(name: &str, file_extension: &str) -> String { + let split: Vec<&str> = name.split('.').collect(); + let new_split: Vec<&str> = split[0].split('_').collect(); + let new_name = new_split.iter().map(|&x| str_capitlize(x)).collect::>().join(""); + format!("{new_name}.{file_extension}") +} + +fn generate_files(language: &str, input_path: &str, output_path: &str, package_name: Option<&str>) { + if let Some(parent) = Path::new(output_path).parent() { + fs::create_dir_all(parent).unwrap(); + } + + let mut command = Command::new("typeshare"); + command.arg(input_path).arg(format!("--lang={language}")).arg(format!("--output-file={output_path}")); + + if let Some(package_name) = package_name { + command.arg(format!("--java-package={package_name}")); + } + + command.output().unwrap(); +} + +fn get_paths(_folder: &str, path: String) -> Vec { + let paths = match fs::read_dir(&path) { + Ok(paths) => paths, + Err(_) => { + eprintln!("Warning: Could not read directory: {path}"); + return vec![]; + } + }; + let mut result: Vec = vec![]; + + for path in paths { + let dir_entry = path.unwrap(); + if dir_entry.path().is_dir() { + let path_recursive = get_paths(_folder, clear_path(dir_entry)); + result.extend(path_recursive) + } else { + result.push(clear_path(dir_entry)); + } + } + + result +} + +//TODO: Pass from the command +fn ignored_files_by_generator(generator_type: &GeneratorType) -> Vec<&'static str> { + match generator_type { + GeneratorType::Swift => vec!["quote_asset.rs"], + GeneratorType::Kotlin => vec!["asset_data.rs", "quote_asset.rs"], + GeneratorType::TypeScript => vec!["transaction_input_type.rs"], + } +} + +fn clear_path(path: DirEntry) -> String { + format!("{}", path.path().display()) +} + +fn str_capitlize(s: &str) -> String { + format!("{}{}", s[..1].to_string().to_uppercase(), &s[1..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_name() { + assert_eq!(file_name("token.rs", LANGUAGE_SWIFT), "Token.swift"); + assert_eq!(file_name("token_type.rs", LANGUAGE_SWIFT), "TokenType.swift"); + } + + #[test] + fn test_str_capitlize() { + assert_eq!(str_capitlize("balance"), "Balance"); + assert_eq!(str_capitlize("Balance"), "Balance"); + } +} diff --git a/core/bin/img-downloader/Cargo.toml b/core/bin/img-downloader/Cargo.toml new file mode 100644 index 0000000000..8d934a18b7 --- /dev/null +++ b/core/bin/img-downloader/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "img-downloader" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +tokio = { workspace = true } +reqwest = { workspace = true } + +clap = { version = "4.6.0", features = ["derive"] } + +chain_primitives = { path = "../../crates/chain_primitives" } +coingecko = { path = "../../crates/coingecko" } +settings = { path = "../../crates/settings" } diff --git a/core/bin/img-downloader/src/cli_args.rs b/core/bin/img-downloader/src/cli_args.rs new file mode 100644 index 0000000000..853bb2e717 --- /dev/null +++ b/core/bin/img-downloader/src/cli_args.rs @@ -0,0 +1,41 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Args { + /// Path to save images + #[arg(short, long)] + pub folder: String, + + /// Top tokens on coingecko to download + #[arg(short, long, default_value_t = 50)] + pub count: usize, + + /// Starting page for coingecko api + #[arg(short, long, default_value_t = 1)] + pub page: usize, + + /// Page size for coingecko api + #[arg(long, default_value_t = 50)] + pub page_size: usize, + + /// ID of the coin, if this is set, it will only download the image for the coin + #[arg(long, default_value = "")] + pub coin_id: String, + + /// Coin IDs separated by comma to download, exclusive with coin_ids_url + #[arg(long, default_value = "")] + pub coin_ids: String, + + /// Coin list from coingecko. available: trending + #[arg(long, default_value = "")] + pub coin_list: String, + + /// Request delay in milliseconds + #[arg(long, default_value_t = 1000)] + pub delay: u32, + + /// Verbose mode + #[arg(short, long, default_value_t = false)] + pub verbose: bool, +} diff --git a/core/bin/img-downloader/src/main.rs b/core/bin/img-downloader/src/main.rs new file mode 100644 index 0000000000..d0c1f8f887 --- /dev/null +++ b/core/bin/img-downloader/src/main.rs @@ -0,0 +1,166 @@ +mod cli_args; +use cli_args::Args; + +use coingecko::get_chain_for_coingecko_platform_id; +use coingecko::{CoinGeckoClient, CoinInfo}; +use settings::Settings; + +use clap::Parser; +use std::{error::Error, fs, io::Write, path::Path, thread::sleep, time::Duration}; + +/// Assets image downloader from coingecko +struct Downloader { + args: Args, + client: CoinGeckoClient, +} + +impl Downloader { + fn new(args: Args, api_key: String) -> Self { + let client = Self::new_coingecko_client(api_key); + Self { args, client } + } + + fn new_coingecko_client(api_key: String) -> CoinGeckoClient { + CoinGeckoClient::new(api_key.as_str()) + } + + async fn start(&self) -> Result<(), Box> { + println!("==> Save path: {}", self.args.folder); + let folder = Path::new(&self.args.folder); + if !folder.exists() { + fs::create_dir_all(folder)?; + } + + if !self.args.coin_id.is_empty() { + return self.handle_coin_id(self.args.coin_id.as_str(), folder).await; + } + + if !self.args.coin_ids.is_empty() { + return self.handle_coin_ids(self.coin_ids(self.args.coin_ids.clone()), folder).await; + } + + if !self.args.coin_list.is_empty() { + return self.handle_coin_list(self.args.coin_list.clone(), folder).await; + } + + unimplemented!("specify coin_id, coin_ids or coin_list") + } + + fn coin_ids(&self, list: String) -> Vec { + list.split(',').map(|x| x.trim().to_string()).collect() + } + async fn handle_coin_list(&self, list: String, folder: &Path) -> Result<(), Box> { + let ids = match list.as_str() { + "trending" => self.client.get_search_trending().await?.get_coins_ids(), + "top" => self.get_coingecko_top().await?, + "new" => self.client.get_coin_list_new().await?.ids().iter().take(20).cloned().collect(), + _ => { + vec![] + } + }; + self.handle_coin_ids(ids, folder).await + } + + async fn handle_coin_ids(&self, coin_ids: Vec, folder: &Path) -> Result<(), Box> { + for coin_id in coin_ids { + self.handle_coin_id(&coin_id, folder).await?; + sleep(Duration::from_millis(self.args.delay.into())); + } + Ok(()) + } + + async fn get_coingecko_top(&self) -> Result, Box> { + let mut page = self.args.page; + let total_pages = self.args.count.div_ceil(self.args.page_size); + let mut ids: Vec = Vec::new(); + + while page <= total_pages && page > 0 { + let markets = self.client.get_coin_markets(page, self.args.page_size).await?; + for market in markets { + ids.push(market.id.clone()); + } + page += 1; + } + Ok(ids) + } + + async fn handle_coin_id(&self, coin_id: &str, folder: &Path) -> Result<(), Box> { + println!("==> process: {coin_id}"); + let coin_info = self.client.get_coin(coin_id).await?; + if self.is_native_asset(&coin_info) { + return Ok(()); + } + + for (platform, address) in coin_info.platforms.iter().filter(|(k, _)| !k.is_empty()) { + let chain = get_chain_for_coingecko_platform_id(platform); + let Some(address) = address else { + continue; + }; + if chain.is_none() || address.is_empty() { + if self.args.verbose { + println!("<== {platform} not supported, skip"); + } + continue; + } + + let chain = chain.unwrap(); + + if let Some(denom) = chain.as_denom() + && denom == address + { + if self.args.verbose { + println!("<== skip native denom: {denom}"); + } + continue; + } + + let image_url: String = coin_info.image.large.clone(); + if let Some(address_folder) = chain_primitives::format_token_id(chain, address.clone()) { + // build /ethereum/assets/
/logo.png + let mut path = folder.join(chain.to_string()); + path.push("assets"); + path.push(address_folder.clone()); + if path.exists() { + if self.args.verbose { + println!("<== {:?} already exists, skip", &path); + } + continue; + } + fs::create_dir_all(path.clone())?; + + path = path.join("logo.png"); + println!("==> download image for {chain}/{address}"); + println!("==> image url: {image_url}"); + crate::download_image(&image_url, path.to_str().unwrap()).await?; + + sleep(Duration::from_millis(self.args.delay.into())); + } + } + + Ok(()) + } + + fn is_native_asset(&self, coin_info: &CoinInfo) -> bool { + coin_info.platforms.keys().filter(|p| !p.is_empty()).count() == 0 + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = cli_args::Args::parse(); + let api_key = Settings::new().unwrap().coingecko.key.secret; + let downloader = Downloader::new(args, api_key); + + downloader.start().await +} + +async fn download_image(url: &str, path: &str) -> Result<(), Box> { + let response = reqwest::get(url).await?; + if response.status() != 200 { + return Err("<== image not found".into()); + } + let mut file = fs::File::create(path)?; + let bytes = response.bytes().await?; + file.write_all(&bytes)?; + Ok(()) +} diff --git a/core/bin/uniffi-bindgen/Cargo.toml b/core/bin/uniffi-bindgen/Cargo.toml new file mode 100644 index 0000000000..9b74440fa5 --- /dev/null +++ b/core/bin/uniffi-bindgen/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "uniffi-bindgen" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +uniffi = { workspace = true, features = ["cli"] } diff --git a/core/bin/uniffi-bindgen/src/main.rs b/core/bin/uniffi-bindgen/src/main.rs new file mode 100644 index 0000000000..f6cff6cf1d --- /dev/null +++ b/core/bin/uniffi-bindgen/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/core/crates/api_connector/Cargo.toml b/core/crates/api_connector/Cargo.toml new file mode 100644 index 0000000000..5e0cf3d701 --- /dev/null +++ b/core/crates/api_connector/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "api_connector" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +serde = { workspace = true } +reqwest = { workspace = true } +chrono = { workspace = true } +primitives = { path = "../primitives" } + +[dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } diff --git a/core/crates/api_connector/src/app_store_client/client.rs b/core/crates/api_connector/src/app_store_client/client.rs new file mode 100644 index 0000000000..16cad2810f --- /dev/null +++ b/core/crates/api_connector/src/app_store_client/client.rs @@ -0,0 +1,44 @@ +use super::models::{App, AppStoreError, AppStoreResponse, AppStoreReviews}; +pub struct AppStoreClient { + base_url: String, + client: reqwest::Client, +} + +impl Default for AppStoreClient { + fn default() -> Self { + Self::new() + } +} + +impl AppStoreClient { + pub fn new() -> Self { + AppStoreClient { + base_url: "https://itunes.apple.com".to_string(), + client: reqwest::Client::new(), + } + } + + pub async fn lookup(&self, app_id: u64, country: &str) -> Result { + let url = format!("{}/lookup", self.base_url); + let query = [("id", &app_id.to_string()), ("country", &country.to_string())]; + + let response = self.client.get(&url).query(&query).send().await?.json::().await?; + match response.results.first() { + Some(app) => Ok(app.clone()), + None => Err(AppStoreError::AppNotFound), + } + } + + pub async fn search_apps(&self, term: &str, country: &str, limit: u32) -> Result { + let url = format!("{}/search", self.base_url); + let query = [("term", term), ("country", country), ("entity", "software"), ("limit", &limit.to_string())]; + let response = self.client.get(&url).query(&query).send().await?.json::().await?; + Ok(response) + } + + pub async fn reviews(&self, app_id: u64, country: &str) -> Result { + let url = format!("{}/{}/rss/customerreviews/id={}/mostRecent/json", self.base_url, country, app_id); + let response = self.client.get(&url).send().await?.json::().await?; + Ok(response) + } +} diff --git a/core/crates/api_connector/src/app_store_client/mod.rs b/core/crates/api_connector/src/app_store_client/mod.rs new file mode 100644 index 0000000000..04f3e94ba1 --- /dev/null +++ b/core/crates/api_connector/src/app_store_client/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod models; diff --git a/core/crates/api_connector/src/app_store_client/models.rs b/core/crates/api_connector/src/app_store_client/models.rs new file mode 100644 index 0000000000..9cf5b47092 --- /dev/null +++ b/core/crates/api_connector/src/app_store_client/models.rs @@ -0,0 +1,75 @@ +use chrono::NaiveDateTime; +use serde::Deserialize; +#[derive(Debug)] +pub enum AppStoreError { + Request(reqwest::Error), + AppNotFound, +} + +impl From for AppStoreError { + fn from(err: reqwest::Error) -> AppStoreError { + AppStoreError::Request(err) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppStoreResponse { + pub results: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct App { + pub track_id: u64, + pub version: String, + pub user_rating_count: Option, + pub average_user_rating: Option, + pub track_name: String, + pub release_date: NaiveDateTime, + pub current_version_release_date: NaiveDateTime, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppStoreReviews { + pub feed: AppStoreFeed, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppStoreFeed { + pub entry: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum AppStoreReviewEntries { + Single(AppStoreReviewEntry), + Multiple(Vec), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppStoreReviewEntry { + #[serde(rename = "im:rating")] + pub rating: AppStoreReviewLabel, + #[serde(rename = "im:version")] + pub version: AppStoreReviewLabel, + pub id: AppStoreReviewLabel, + pub title: AppStoreReviewLabel, + pub content: AppStoreReviewLabel, + pub author: AppStoreReviewAuthor, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppStoreReviewLabel { + pub label: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppStoreReviewAuthor { + pub name: AppStoreReviewLabel, +} diff --git a/core/crates/api_connector/src/lib.rs b/core/crates/api_connector/src/lib.rs new file mode 100644 index 0000000000..fbf4f4ad09 --- /dev/null +++ b/core/crates/api_connector/src/lib.rs @@ -0,0 +1,7 @@ +pub mod app_store_client; +pub mod pusher; +pub mod static_assets_client; +pub use self::app_store_client::client::AppStoreClient; +pub use self::pusher::client::PusherClient; +pub use self::pusher::model::PushResult; +pub use self::static_assets_client::client::StaticAssetsClient; diff --git a/core/crates/api_connector/src/pusher/client.rs b/core/crates/api_connector/src/pusher/client.rs new file mode 100644 index 0000000000..0292f342df --- /dev/null +++ b/core/crates/api_connector/src/pusher/client.rs @@ -0,0 +1,60 @@ +use super::model::{PushResult, Response}; +use primitives::{GorushNotification, GorushNotifications}; +use reqwest::Client; + +#[derive(Clone, Debug)] +pub struct PusherClient { + url: String, + client: Client, + topic: String, +} + +impl PusherClient { + pub fn new(url: String, topic: String) -> Self { + let client = Client::new(); + Self { url, client, topic } + } + + pub async fn push_notifications(&self, notifications: Vec) -> Result { + let url = format!("{}/api/push", self.url); + let notifications: Vec = notifications + .into_iter() + .filter(|n| !n.tokens.is_empty() && n.tokens.iter().all(|t| !t.is_empty())) + .map(|x| x.clone().with_topic(self.get_topic(x.platform))) + .collect(); + + if notifications.is_empty() { + return Ok(PushResult { + response: Response { + counts: 0, + logs: vec![], + success: "ok".to_string(), + }, + notifications, + }); + } + + let payload = GorushNotifications { + notifications: notifications.clone(), + }; + let response = self.client.post(&url).json(&payload).send().await?.json::().await?; + Ok(PushResult { response, notifications }) + } + + pub async fn is_device_token_valid(&self, token: &str, platform: i32) -> Result { + let notification = GorushNotification::for_token_validation(token.to_string(), platform); + let result = self.push_notifications(vec![notification]).await?; + + let has_invalid_token = result.response.logs.iter().any(|log| log.is_device_invalid()); + Ok(!has_invalid_token) + } + + //Remove in the future + fn get_topic(&self, platform: i32) -> Option { + match platform { + 1 => Some(self.topic.clone()), // ios + 2 => None, + _ => None, + } + } +} diff --git a/core/crates/api_connector/src/pusher/mod.rs b/core/crates/api_connector/src/pusher/mod.rs new file mode 100644 index 0000000000..55a0313c50 --- /dev/null +++ b/core/crates/api_connector/src/pusher/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod model; diff --git a/core/crates/api_connector/src/pusher/model.rs b/core/crates/api_connector/src/pusher/model.rs new file mode 100644 index 0000000000..0b511221ec --- /dev/null +++ b/core/crates/api_connector/src/pusher/model.rs @@ -0,0 +1,106 @@ +use primitives::{FailedNotification, GorushNotification, PushErrorLog}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Response { + pub counts: i32, + pub logs: Vec, + pub success: String, +} + +pub struct PushResult { + pub response: Response, + pub notifications: Vec, +} + +impl PushResult { + pub fn failures(&self) -> Vec { + let token_to_notification: HashMap<&str, &GorushNotification> = self.notifications.iter().flat_map(|n| n.tokens.iter().map(move |t| (t.as_str(), n))).collect(); + + self.response + .logs + .iter() + .filter(|log| !log.error.is_empty()) + .filter_map(|error| { + token_to_notification.get(error.token.as_str()).map(|notification| FailedNotification { + notification: (*notification).clone(), + error: error.clone(), + }) + }) + .collect() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub title: String, + pub message: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::GorushNotification; + + #[test] + fn failures_matches_tokens() { + let response = Response { + counts: 2, + success: "ok".to_string(), + logs: vec![ + PushErrorLog { + token: "token1".to_string(), + error: "BadDeviceToken".to_string(), + }, + PushErrorLog { + token: "token2".to_string(), + error: "Requested entity was not found.".to_string(), + }, + ], + }; + + let result = PushResult { + response, + notifications: vec![GorushNotification::mock_with("token1", "device1"), GorushNotification::mock_with("token2", "device2")], + }; + + let failures = result.failures(); + assert_eq!(failures.len(), 2); + assert_eq!(failures[0].notification.device_id, "device1"); + assert_eq!(failures[0].error.error, "BadDeviceToken"); + assert_eq!(failures[1].notification.device_id, "device2"); + assert_eq!(failures[1].error.error, "Requested entity was not found."); + } + + #[test] + fn failures_filters_invalid() { + let response = Response { + counts: 3, + success: "ok".to_string(), + logs: vec![ + PushErrorLog { + token: "token1".to_string(), + error: "".to_string(), + }, + PushErrorLog { + token: "unmatched".to_string(), + error: "BadDeviceToken".to_string(), + }, + PushErrorLog { + token: "token2".to_string(), + error: "Error".to_string(), + }, + ], + }; + + let result = PushResult { + response, + notifications: vec![GorushNotification::mock_with("token1", "device1"), GorushNotification::mock_with("token2", "device2")], + }; + + let failures = result.failures(); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].error.token, "token2"); + } +} diff --git a/core/crates/api_connector/src/static_assets_client/client.rs b/core/crates/api_connector/src/static_assets_client/client.rs new file mode 100644 index 0000000000..d698a78171 --- /dev/null +++ b/core/crates/api_connector/src/static_assets_client/client.rs @@ -0,0 +1,34 @@ +use super::models::Validator; +use primitives::{AssetId, Chain}; + +#[derive(Clone)] +pub struct StaticAssetsClient { + url: String, + client: reqwest::Client, +} + +impl StaticAssetsClient { + pub fn new(url: &str) -> Self { + Self { + url: url.to_string(), + client: reqwest::Client::new(), + } + } + + pub async fn get_validators(&self, chain: Chain) -> Result, reqwest::Error> { + let url = format!("{}/blockchains/{chain}/validators.json", self.url); + self.client.get(&url).send().await?.json().await + } + + pub async fn get_assets_list(&self, chain: Chain) -> Result, reqwest::Error> { + let url = format!("{}/blockchains/{}/assets.json", self.url, chain.as_ref()); + let response = self.client.get(&url).send().await?; + + if !response.status().is_success() { + return Ok(vec![]); + } + + let addresses: Vec = response.json().await?; + Ok(addresses.into_iter().map(|x| AssetId::from(chain, Some(x))).collect()) + } +} diff --git a/core/crates/api_connector/src/static_assets_client/mod.rs b/core/crates/api_connector/src/static_assets_client/mod.rs new file mode 100644 index 0000000000..04f3e94ba1 --- /dev/null +++ b/core/crates/api_connector/src/static_assets_client/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod models; diff --git a/core/crates/api_connector/src/static_assets_client/models.rs b/core/crates/api_connector/src/static_assets_client/models.rs new file mode 100644 index 0000000000..2e382d916b --- /dev/null +++ b/core/crates/api_connector/src/static_assets_client/models.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Validator { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, +} diff --git a/core/crates/cacher/Cargo.toml b/core/crates/cacher/Cargo.toml new file mode 100644 index 0000000000..da5b087ed9 --- /dev/null +++ b/core/crates/cacher/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "cacher" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +redis = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/core/crates/cacher/src/error.rs b/core/crates/cacher/src/error.rs new file mode 100644 index 0000000000..b830aee9d2 --- /dev/null +++ b/core/crates/cacher/src/error.rs @@ -0,0 +1,48 @@ +#[derive(Debug, Clone)] +pub enum CacheError { + NotFound { resource: &'static str, lookup: String }, + ResourceNotFound(&'static str), + KeyNotFound(String), +} + +impl CacheError { + pub fn not_found(resource: &'static str, lookup: impl Into) -> Self { + Self::NotFound { resource, lookup: lookup.into() } + } + + pub fn not_found_resource(resource: &'static str) -> Self { + Self::ResourceNotFound(resource) + } +} + +impl std::fmt::Display for CacheError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CacheError::NotFound { resource, lookup } => write!(f, "{resource} {lookup} not found"), + CacheError::ResourceNotFound(resource) => write!(f, "{resource} not found"), + CacheError::KeyNotFound(_) => write!(f, "Cache key not found"), + } + } +} + +impl std::error::Error for CacheError {} + +#[cfg(test)] +mod tests { + use super::CacheError; + + #[test] + fn test_cache_error_display_not_found() { + assert_eq!(CacheError::not_found("FiatQuote", "abc").to_string(), "FiatQuote abc not found"); + } + + #[test] + fn test_cache_error_display_resource_not_found() { + assert_eq!(CacheError::not_found_resource("FiatRates").to_string(), "FiatRates not found"); + } + + #[test] + fn test_cache_error_display_key_not_found_does_not_expose_key() { + assert_eq!(CacheError::KeyNotFound("fiat:quote:abc".to_string()).to_string(), "Cache key not found"); + } +} diff --git a/core/crates/cacher/src/keys.rs b/core/crates/cacher/src/keys.rs new file mode 100644 index 0000000000..b4a9ffa7bb --- /dev/null +++ b/core/crates/cacher/src/keys.rs @@ -0,0 +1,149 @@ +const SECONDS_PER_MINUTE: u64 = 60; +const SECONDS_PER_HOUR: u64 = 60 * SECONDS_PER_MINUTE; +const SECONDS_PER_DAY: u64 = 24 * 60 * 60; + +pub enum CacheKey<'a> { + // Referral keys + ReferralIpCheck(&'a str), + + // Username keys + UsernameCreationPerIp(&'a str), + UsernameCreationPerDevice(i32), + UsernameCreationGlobalDaily, + UsernameCreationPerCountryDaily(&'a str), + + // Device keys + InactiveDeviceObserver(&'a str), + + // Fetch consumer keys (chain, address) + FetchCoinAddresses(&'a str, &'a str), + FetchTokenAddresses(&'a str, &'a str), + FetchNftAssetsAddresses(&'a str, &'a str), + FetchAddressTransactions(&'a str, &'a str), + + // Asset keys + FetchAssets(&'a str), + FetchNftAsset(&'a str), + Price(&'a str), + PricerCoinInfo(&'a str), + + // Fiat keys + FiatRates, + FiatQuote(&'a str), + FiatIpCheck(&'a str), + + // Auth keys (device_id, nonce) + AuthNonce(&'a str, &'a str), + + // Address keys + AddressStatus(&'a str, &'a str), + + // Status keys + JobStatus(&'a str), + ConsumerStatus(&'a str), + ParserStatus(&'a str), + + // Pricer keys + Markets, + ObservedAssets, + + SwapDepositAddresses(&'a str), + SwapSendAddresses(&'a str), + + // Charts keys + ChartsHistory(&'a str), + + // Alerter keys + AlerterStakeRewards(&'a str, &'a str), + + // Perpetual keys + PerpetualTrackedAddresses(&'a str), + PerpetualActiveAddresses(&'a str), + PerpetualPriorityAddresses(&'a str), + PerpetualObserverCheckpoint(&'a str, &'a str), + + // Transaction keys + PendingTransactions(&'a str), +} + +pub fn cache_keys<'a, T: AsRef>(items: &'a [T], variant: impl Fn(&'a str) -> CacheKey<'a>) -> Vec { + items.iter().map(|item| variant(item.as_ref()).key()).collect() +} + +impl CacheKey<'_> { + pub fn key(&self) -> String { + match self { + Self::ReferralIpCheck(ip_address) => format!("referral:ip_check:{}", ip_address), + Self::UsernameCreationPerIp(ip_address) => format!("username:ip:{}", ip_address), + Self::UsernameCreationPerDevice(device_id) => format!("username:device:{}", device_id), + Self::UsernameCreationGlobalDaily => "username:global:daily".to_string(), + Self::UsernameCreationPerCountryDaily(country) => format!("username:country:daily:{}", country), + Self::InactiveDeviceObserver(device_id) => format!("device:inactive_observer:{}", device_id), + Self::FetchCoinAddresses(chain, address) => format!("fetch:coin_addresses:{}:{}", chain, address), + Self::FetchTokenAddresses(chain, address) => format!("fetch:token_addresses:{}:{}", chain, address), + Self::FetchNftAssetsAddresses(chain, address) => format!("fetch:nft_assets_addresses:{}:{}", chain, address), + Self::FetchAddressTransactions(chain, address) => format!("fetch:address_transactions:{}:{}", chain, address), + Self::FetchAssets(asset_id) => format!("fetch:assets:{}", asset_id), + Self::FetchNftAsset(asset_id) => format!("fetch:nft_asset:{}", asset_id), + Self::Price(asset_id) => format!("prices:{}", asset_id), + Self::PricerCoinInfo(coin_id) => format!("pricer:coin_info:{}", coin_id), + Self::FiatRates => "fiat:rates".to_string(), + Self::FiatQuote(quote_id) => format!("fiat:quote:{}", quote_id), + Self::FiatIpCheck(ip_address) => format!("fiat:ip_check:{}", ip_address), + Self::AuthNonce(device_id, nonce) => format!("auth:nonce:{}:{}", device_id, nonce), + Self::AddressStatus(chain, address) => format!("address:status:{}:{}", chain, address), + Self::JobStatus(name) => format!("jobs:status:{}", name), + Self::ConsumerStatus(name) => format!("consumers:status:{}", name), + Self::ParserStatus(chain) => format!("parser:status:{}", chain), + Self::Markets => "markets:markets".to_string(), + Self::ObservedAssets => "pricer:observed_assets".to_string(), + Self::SwapDepositAddresses(provider) => format!("swap:deposit_addresses:{}", provider), + Self::SwapSendAddresses(provider) => format!("swap:send_addresses:{}", provider), + Self::ChartsHistory(provider) => format!("charts:history:{}", provider), + Self::AlerterStakeRewards(chain, address) => format!("alerter:stake_rewards:{}:{}", chain, address), + Self::PerpetualTrackedAddresses(chain) => format!("perpetual:tracked_addresses:{}", chain), + Self::PerpetualActiveAddresses(chain) => format!("perpetual:active_addresses:{}", chain), + Self::PerpetualPriorityAddresses(chain) => format!("perpetual:priority_addresses:{}", chain), + Self::PerpetualObserverCheckpoint(chain, address) => format!("perpetual:last_seen:{}:{}", chain, address), + Self::PendingTransactions(chain) => format!("transactions:pending:{}", chain), + } + } + + pub fn ttl(&self) -> u64 { + match self { + Self::ReferralIpCheck(_) => 30 * SECONDS_PER_DAY, + Self::UsernameCreationPerIp(_) => 30 * SECONDS_PER_DAY, + Self::UsernameCreationPerDevice(_) => 30 * SECONDS_PER_DAY, + Self::UsernameCreationGlobalDaily => SECONDS_PER_DAY, + Self::UsernameCreationPerCountryDaily(_) => SECONDS_PER_DAY, + Self::InactiveDeviceObserver(_) => 30 * SECONDS_PER_DAY, + Self::FetchCoinAddresses(_, _) => 7 * SECONDS_PER_DAY, + Self::FetchTokenAddresses(_, _) => 30 * SECONDS_PER_DAY, + Self::FetchNftAssetsAddresses(_, _) => 30 * SECONDS_PER_DAY, + Self::FetchAddressTransactions(_, _) => 30 * SECONDS_PER_DAY, + Self::FetchAssets(_) => 30 * SECONDS_PER_DAY, + Self::FetchNftAsset(_) => SECONDS_PER_HOUR, + Self::Price(_) => 30 * SECONDS_PER_DAY, + Self::PricerCoinInfo(_) => SECONDS_PER_DAY, + Self::FiatRates => SECONDS_PER_DAY, + Self::FiatQuote(_) => 15 * SECONDS_PER_MINUTE, + Self::FiatIpCheck(_) => SECONDS_PER_DAY, + Self::AuthNonce(_, _) => 5 * SECONDS_PER_MINUTE, + Self::AddressStatus(_, _) => 31 * SECONDS_PER_DAY, + Self::JobStatus(_) => 7 * SECONDS_PER_DAY, + Self::ConsumerStatus(_) => 7 * SECONDS_PER_DAY, + Self::ParserStatus(_) => 7 * SECONDS_PER_DAY, + Self::Markets => SECONDS_PER_DAY, + Self::ObservedAssets => 2 * SECONDS_PER_MINUTE, + Self::SwapDepositAddresses(_) => SECONDS_PER_DAY, + Self::SwapSendAddresses(_) => SECONDS_PER_DAY, + Self::ChartsHistory(_) => 10 * 365 * SECONDS_PER_DAY, + Self::AlerterStakeRewards(_, _) => 30 * SECONDS_PER_DAY, + Self::PerpetualTrackedAddresses(_) => 2 * 60 * SECONDS_PER_MINUTE, + Self::PerpetualActiveAddresses(_) => 30 * SECONDS_PER_MINUTE, + Self::PerpetualPriorityAddresses(_) => 30 * SECONDS_PER_MINUTE, + Self::PerpetualObserverCheckpoint(_, _) => 30 * SECONDS_PER_DAY, + Self::PendingTransactions(_) => 30 * SECONDS_PER_DAY, + } + } +} diff --git a/core/crates/cacher/src/lib.rs b/core/crates/cacher/src/lib.rs new file mode 100644 index 0000000000..a6bbaa22ce --- /dev/null +++ b/core/crates/cacher/src/lib.rs @@ -0,0 +1,321 @@ +use std::error::Error; +use std::time::{SystemTime, UNIX_EPOCH}; + +use redis::{AsyncCommands, Client, aio::ConnectionManager}; + +mod error; +mod keys; +pub use error::*; +pub use keys::*; + +#[derive(Clone)] +pub struct CacherClient { + connection: ConnectionManager, +} + +impl CacherClient { + pub async fn new(redis_url: &str) -> Self { + let client = Client::open(redis_url).expect("invalid redis url"); + let connection = ConnectionManager::new(client).await.expect("failed to connect to redis"); + Self { connection } + } + + pub async fn set_values(&self, values: Vec<(String, String)>) -> Result> { + if values.is_empty() { + return Ok(0); + } + self.connection.clone().mset::(values.as_slice()).await?; + Ok(values.len()) + } + + pub async fn set_values_with_publish(&self, values: Vec<(String, String)>, ttl_seconds: i64) -> Result> { + let values = values.into_iter().map(|(key, value)| (key, value, ttl_seconds)).collect(); + self.set_serialized_values_with_ttl_and_publish(values, true).await + } + + pub async fn set_value_with_ttl(&self, key: &str, value: String, seconds: u64) -> Result<(), Box> { + self.set_serialized_value(key, value, Some(seconds)).await + } + + pub async fn set_values_with_ttl(&self, values: Vec<(&str, &T)>, ttl_seconds: i64) -> Result> { + let values = values + .into_iter() + .map(|(key, value)| serde_json::to_string(value).map(|serialized| (key.to_string(), serialized, ttl_seconds))) + .collect::, _>>()?; + self.set_serialized_values_with_ttl(values).await + } + + pub async fn set_value(&self, key: &str, value: &T) -> Result<(), Box> { + self.set_serialized_value(key, serde_json::to_string(value)?, None).await + } + + pub async fn get_value(&self, key: &str) -> Result> { + let value: Option = self.connection.clone().get(key).await?; + match value { + Some(s) => Ok(serde_json::from_str(&s)?), + None => Err(Box::new(CacheError::KeyNotFound(key.to_string()))), + } + } + + pub async fn get_value_optional(&self, key: &str) -> Result, Box> { + let value: Option = self.connection.clone().get(key).await?; + match value { + Some(serialized) => Ok(Some(serde_json::from_str(&serialized)?)), + None => Ok(None), + } + } + + pub async fn get_values(&self, keys: Vec) -> Result> + where + I: serde::de::DeserializeOwned, + T: FromIterator, + { + if keys.is_empty() { + return Ok(std::iter::empty::().collect()); + } + let result: Vec> = self.connection.clone().mget(keys).await?; + let values: T = result.into_iter().flatten().filter_map(|value| serde_json::from_str::(&value).ok()).collect(); + Ok(values) + } + + pub async fn get_or_set_value(&self, key: &str, fetch_fn: F, ttl_seconds: Option) -> Result> + where + T: serde::de::DeserializeOwned + serde::Serialize, + F: FnOnce() -> Fut, + Fut: std::future::Future>>, + { + if let Ok(cached_value) = self.get_value::(key).await { + return Ok(cached_value); + } + + let fresh_value = fetch_fn().await?; + + let serialized = serde_json::to_string(&fresh_value)?; + self.set_serialized_value(key, serialized, ttl_seconds).await?; + + Ok(fresh_value) + } + + pub async fn can_process_now(&self, key: &str, ttl_seconds: u64) -> Result> { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let last_processed: u64 = self.get_or_set_value(key, || async { Ok(now) }, Some(ttl_seconds)).await?; + Ok(last_processed == now) + } + + pub async fn delete(&self, key: &str) -> Result> { + Ok(self.connection.clone().del::<&str, i64>(key).await? > 0) + } + + pub async fn increment(&self, key: &str) -> Result> { + Ok(self.connection.clone().incr(key, 1).await?) + } + + pub async fn increment_with_ttl(&self, key: &str, ttl: i64) -> Result> { + let mut pipe = redis::pipe(); + pipe.atomic(); + pipe.cmd("INCR").arg(key); + pipe.cmd("EXPIRE").arg(key).arg(ttl).arg("NX"); + let results: (i64, i64) = pipe.query_async(&mut self.connection.clone()).await?; + Ok(results.0) + } + + pub async fn get_counter(&self, key: &str) -> Result> { + Ok(self.connection.clone().get::<&str, Option>(key).await?.unwrap_or(0)) + } + + // CacheKey-aware methods + pub async fn set_cached(&self, key: CacheKey<'_>, value: &T) -> Result<(), Box> { + self.set_values_with_ttl(vec![(&key.key(), value)], key.ttl() as i64).await?; + Ok(()) + } + + pub async fn get_cached(&self, key: CacheKey<'_>) -> Result> { + self.get_value(&key.key()).await + } + + pub async fn get_cached_optional(&self, key: CacheKey<'_>) -> Result, Box> { + self.get_value_optional(&key.key()).await + } + + pub async fn set_values_cached(&self, entries: &[(CacheKey<'_>, &T)]) -> Result> { + let values = entries + .iter() + .map(|(key, value)| serde_json::to_string(value).map(|serialized| (key.key(), serialized, key.ttl() as i64))) + .collect::, _>>()?; + self.set_serialized_values_with_ttl(values).await + } + + pub async fn get_or_set_cached(&self, key: CacheKey<'_>, fetch_fn: F) -> Result> + where + T: serde::de::DeserializeOwned + serde::Serialize, + F: FnOnce() -> Fut, + Fut: std::future::Future>>, + { + self.get_or_set_value(&key.key(), fetch_fn, Some(key.ttl())).await + } + + pub async fn increment_cached(&self, key: CacheKey<'_>) -> Result> { + self.increment_with_ttl(&key.key(), key.ttl() as i64).await + } + + pub async fn get_cached_counter(&self, key: CacheKey<'_>) -> Result> { + self.get_counter(&key.key()).await + } + + pub async fn add_to_set_cached(&self, key: CacheKey<'_>, members: &[String]) -> Result> { + if members.is_empty() { + return Ok(0); + } + let key_str = key.key(); + let ttl = key.ttl() as i64; + let mut pipe = redis::pipe(); + pipe.atomic(); + pipe.cmd("SADD").arg(&key_str).arg(members); + pipe.cmd("EXPIRE").arg(&key_str).arg(ttl); + pipe.cmd("SCARD").arg(&key_str); + let (_, _, count): (usize, bool, usize) = pipe.query_async(&mut self.connection.clone()).await?; + Ok(count) + } + + pub async fn remove_from_set_cached(&self, key: CacheKey<'_>, members: &[String]) -> Result> { + if members.is_empty() { + return Ok(0); + } + + Ok(self.connection.clone().srem(key.key(), members).await?) + } + + pub async fn add_to_sorted_set_cached(&self, key: CacheKey<'_>, members: &[(String, f64)]) -> Result> { + if members.is_empty() { + return Ok(0); + } + + let key_str = key.key(); + let ttl = key.ttl() as i64; + let mut pipe = redis::pipe(); + pipe.atomic(); + for (member, score) in members { + pipe.cmd("ZADD").arg(&key_str).arg(score).arg(member).ignore(); + } + pipe.cmd("EXPIRE").arg(&key_str).arg(ttl).ignore(); + pipe.cmd("ZCARD").arg(&key_str); + let (count,): (usize,) = pipe.query_async(&mut self.connection.clone()).await?; + Ok(count) + } + + pub async fn remove_from_sorted_set_cached(&self, key: CacheKey<'_>, members: &[String]) -> Result> { + if members.is_empty() { + return Ok(0); + } + + Ok(redis::cmd("ZREM").arg(key.key()).arg(members).query_async(&mut self.connection.clone()).await?) + } + + pub async fn get_set_members_cached(&self, keys: Vec) -> Result, Box> { + Ok(self.get_set_members_grouped(keys).await?.into_iter().flatten().collect()) + } + + pub async fn get_set_members_grouped(&self, keys: Vec) -> Result>, Box> { + if keys.is_empty() { + return Ok(vec![]); + } + let mut pipe = redis::pipe(); + for key in &keys { + pipe.cmd("SMEMBERS").arg(key); + } + Ok(pipe.query_async(&mut self.connection.clone()).await?) + } + + pub async fn can_process_cached(&self, key: CacheKey<'_>) -> Result> { + self.can_process_now(&key.key(), key.ttl()).await + } + + pub async fn set_i64(&self, key: &str, value: i64, ttl_seconds: u64) -> Result<(), Box> { + self.connection.clone().set_ex::<&str, i64, ()>(key, value, ttl_seconds).await?; + Ok(()) + } + + pub async fn get_i64(&self, key: &str) -> Result, Box> { + Ok(self.connection.clone().get::<&str, Option>(key).await?) + } + + pub async fn sorted_set_incr_with_expire(&self, key: &str, members: &[String], ttl: i64) -> Result<(), Box> { + if members.is_empty() { + return Ok(()); + } + let mut pipe = redis::pipe(); + for member in members { + pipe.cmd("ZINCRBY").arg(key).arg(1).arg(member).ignore(); + } + pipe.cmd("EXPIRE").arg(key).arg(ttl).ignore(); + pipe.query_async::<()>(&mut self.connection.clone()).await?; + Ok(()) + } + + pub async fn publish(&self, channel: &str, value: &T) -> Result<(), Box> { + let message = serde_json::to_string(value)?; + self.connection.clone().publish::<&str, &str, ()>(channel, &message).await?; + Ok(()) + } + + pub async fn keys(&self, pattern: &str) -> Result, Box> { + Ok(redis::cmd("KEYS").arg(pattern).query_async(&mut self.connection.clone()).await?) + } + + pub async fn sorted_set_range_by_score(&self, key: &str, min: f64, max: f64, limit: usize) -> Result, Box> { + Ok(redis::cmd("ZRANGEBYSCORE") + .arg(key) + .arg(min) + .arg(max) + .arg("LIMIT") + .arg(0) + .arg(limit) + .query_async(&mut self.connection.clone()) + .await?) + } + + pub async fn sorted_set_card(&self, key: &str) -> Result> { + Ok(redis::cmd("ZCARD").arg(key).query_async(&mut self.connection.clone()).await?) + } + + pub async fn sorted_set_range_with_scores(&self, key: &str, start: isize, stop: isize) -> Result, Box> { + Ok(redis::cmd("ZRANGE") + .arg(key) + .arg(start) + .arg(stop) + .arg("WITHSCORES") + .query_async(&mut self.connection.clone()) + .await?) + } + + async fn set_serialized_values_with_ttl(&self, values: Vec<(String, String, i64)>) -> Result> { + self.set_serialized_values_with_ttl_and_publish(values, false).await + } + + async fn set_serialized_values_with_ttl_and_publish(&self, values: Vec<(String, String, i64)>, publish: bool) -> Result> { + if values.is_empty() { + return Ok(0); + } + let mut pipe = redis::pipe(); + for (key, serialized, ttl_seconds) in &values { + pipe.cmd("SET").arg(key).arg(serialized).arg("EX").arg(ttl_seconds).ignore(); + if publish { + pipe.cmd("PUBLISH").arg(key).arg(serialized).ignore(); + } + } + pipe.query_async::<()>(&mut self.connection.clone()).await?; + Ok(values.len()) + } + + async fn set_serialized_value(&self, key: &str, serialized: String, ttl_seconds: Option) -> Result<(), Box> { + match ttl_seconds { + Some(ttl) => { + self.connection.clone().set_ex::<&str, String, ()>(key, serialized, ttl).await?; + } + None => { + self.connection.clone().set::<&str, String, ()>(key, serialized).await?; + } + } + Ok(()) + } +} diff --git a/core/crates/chain_primitives/Cargo.toml b/core/crates/chain_primitives/Cargo.toml new file mode 100644 index 0000000000..5e89572619 --- /dev/null +++ b/core/crates/chain_primitives/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "chain_primitives" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +primitives = { path = "../primitives" } +alloy-primitives = { workspace = true } +num-bigint = { workspace = true } diff --git a/core/crates/chain_primitives/src/balance_diff.rs b/core/crates/chain_primitives/src/balance_diff.rs new file mode 100644 index 0000000000..42f25030bf --- /dev/null +++ b/core/crates/chain_primitives/src/balance_diff.rs @@ -0,0 +1,207 @@ +use num_bigint::{BigInt, BigUint}; +use std::collections::HashMap; + +use primitives::{AssetId, TransactionSwapMetadata}; + +/// Address -> Vec +pub type BalanceDiffMap = HashMap>; + +#[derive(Debug)] +pub struct BalanceDiff { + pub asset_id: AssetId, + pub from_value: Option, + pub to_value: Option, + pub diff: BigInt, +} + +pub struct SwapMapper; + +impl SwapMapper { + /// Maps a set of balance changes to swap metadata if they represent a swap transaction + pub fn map_swap(balance_diffs: &[BalanceDiff], fee: &BigUint, native_asset_id: &AssetId, provider: Option) -> Option { + let non_zero_diffs: Vec<&BalanceDiff> = balance_diffs.iter().filter(|diff| diff.diff != BigInt::from(0)).collect(); + + if non_zero_diffs.len() != 2 { + return None; + } + + let first = non_zero_diffs.first()?; + let second = non_zero_diffs.last()?; + + // One should be positive (received), one negative (sent) + if (first.diff > BigInt::from(0)) == (second.diff > BigInt::from(0)) { + return None; + } + + let (sent_diff, received_diff) = if first.diff < BigInt::from(0) { (first, second) } else { (second, first) }; + + let from_value = Self::calculate_actual_value(&sent_diff.diff, &sent_diff.asset_id, fee, native_asset_id); + let to_value = Self::calculate_actual_value(&received_diff.diff, &received_diff.asset_id, fee, native_asset_id); + + // Ignore Mint txs + if from_value == BigUint::from(0u8) { + return None; + } + + Some(TransactionSwapMetadata { + from_asset: sent_diff.asset_id.clone(), + from_value: from_value.to_string(), + to_asset: received_diff.asset_id.clone(), + to_value: to_value.to_string(), + provider, + }) + } + + /// Calculates the actual value of a balance change, accounting for transaction fees + /// For native tokens, we need to subtract the fee from the amount since the balance change includes both the swap amount and the fee payment. + fn calculate_actual_value(amount: &BigInt, asset_id: &AssetId, fee: &BigUint, native_asset_id: &AssetId) -> BigUint { + let magnitude = amount.magnitude(); + if asset_id == native_asset_id && magnitude >= fee { + magnitude - fee + } else { + magnitude.clone() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::Chain; + + #[test] + fn test_detect_simple_swap() { + let native_asset = AssetId::from_chain(Chain::Ethereum); + let token_asset = AssetId::from_token(Chain::Ethereum, "0x123"); + let fee = BigUint::from(1000u32); + + let balance_diffs = vec![ + BalanceDiff { + asset_id: native_asset.clone(), + from_value: Some(BigInt::from(5000)), + to_value: Some(BigInt::from(0)), + diff: BigInt::from(-5000), + }, + BalanceDiff { + asset_id: token_asset.clone(), + from_value: None, + to_value: None, + diff: BigInt::from(100), + }, + ]; + + let swap = SwapMapper::map_swap(&balance_diffs, &fee, &native_asset, Some("Uniswap".to_string())).unwrap(); + + assert_eq!(swap.from_asset, native_asset); + assert_eq!(swap.from_value, "4000"); + assert_eq!(swap.to_asset, token_asset); + assert_eq!(swap.to_value, "100"); + assert_eq!(swap.provider, Some("Uniswap".to_string())); + } + + #[test] + fn test_detect_token_to_token_swap() { + let native_asset = AssetId::from_chain(Chain::Ethereum); + let token_a = AssetId::from_token(Chain::Ethereum, "0x123"); + let token_b = AssetId::from_token(Chain::Ethereum, "0x456"); + let fee = BigUint::from(1000u32); + + let balance_diffs = vec![ + BalanceDiff { + asset_id: token_a.clone(), + from_value: None, + to_value: None, + diff: BigInt::from(-200), + }, + BalanceDiff { + asset_id: token_b.clone(), + from_value: None, + to_value: None, + diff: BigInt::from(150), + }, + ]; + + let swap = SwapMapper::map_swap(&balance_diffs, &fee, &native_asset, Some("Uniswap".to_string())).unwrap(); + + assert_eq!(swap.from_asset, token_a); + assert_eq!(swap.from_value, "200"); + assert_eq!(swap.to_asset, token_b); + assert_eq!(swap.to_value, "150"); + } + + #[test] + fn test_not_a_swap_same_direction() { + let native_asset = AssetId::from_chain(Chain::Ethereum); + let token_asset = AssetId::from_token(Chain::Ethereum, "0x123"); + let fee = BigUint::from(1000u32); + + let balance_diffs = vec![ + BalanceDiff { + asset_id: native_asset.clone(), + from_value: None, + to_value: None, + diff: BigInt::from(5000), + }, + BalanceDiff { + asset_id: token_asset, + from_value: None, + to_value: None, + diff: BigInt::from(100), + }, + ]; + + let swap = SwapMapper::map_swap(&balance_diffs, &fee, &native_asset, Some("Uniswap".to_string())); + + assert!(swap.is_none()); + } + + #[test] + fn test_not_a_swap_wrong_count() { + let native_asset = AssetId::from_chain(Chain::Ethereum); + let fee = BigUint::from(1000u32); + + let balance_diffs = vec![BalanceDiff { + asset_id: native_asset.clone(), + from_value: None, + to_value: None, + diff: BigInt::from(-5000), + }]; + + let swap = SwapMapper::map_swap(&balance_diffs, &fee, &native_asset, Some("Uniswap".to_string())); + + assert!(swap.is_none()); + } + + #[test] + fn test_swap_detection_with_zero_diffs() { + let native_asset = AssetId::from_chain(Chain::Ethereum); + let token_asset = AssetId::from_token(Chain::Ethereum, "0x123"); + let fee = BigUint::from(1000u32); + + let balance_diffs = vec![ + BalanceDiff { + asset_id: native_asset.clone(), + from_value: None, + to_value: None, + diff: BigInt::from(-5000), + }, + BalanceDiff { + asset_id: token_asset.clone(), + from_value: None, + to_value: None, + diff: BigInt::from(100), + }, + BalanceDiff { + asset_id: AssetId::from_token(Chain::Ethereum, "0x789"), + from_value: None, + to_value: None, + diff: BigInt::from(0), + }, + ]; + + let swap = SwapMapper::map_swap(&balance_diffs, &fee, &native_asset, Some("Uniswap".to_string())).unwrap(); + + assert_eq!(swap.from_asset, native_asset); + assert_eq!(swap.to_asset, token_asset); + } +} diff --git a/core/crates/chain_primitives/src/lib.rs b/core/crates/chain_primitives/src/lib.rs new file mode 100644 index 0000000000..a113237cd9 --- /dev/null +++ b/core/crates/chain_primitives/src/lib.rs @@ -0,0 +1,5 @@ +pub mod balance_diff; +pub mod token_id; + +pub use balance_diff::{BalanceDiff, BalanceDiffMap, SwapMapper}; +pub use token_id::format_token_id; diff --git a/core/crates/chain_primitives/src/token_id.rs b/core/crates/chain_primitives/src/token_id.rs new file mode 100644 index 0000000000..bd8f8141c6 --- /dev/null +++ b/core/crates/chain_primitives/src/token_id.rs @@ -0,0 +1,150 @@ +use alloy_primitives::Address; +use primitives::Chain; +use std::str::FromStr; + +pub fn format_token_id(chain: Chain, token_id: String) -> Option { + match chain { + Chain::Ethereum + | Chain::SmartChain + | Chain::Polygon + | Chain::Arbitrum + | Chain::Optimism + | Chain::Base + | Chain::AvalancheC + | Chain::OpBNB + | Chain::Fantom + | Chain::Gnosis + | Chain::Manta + | Chain::Blast + | Chain::ZkSync + | Chain::Linea + | Chain::Mantle + | Chain::Celo + | Chain::World + | Chain::Sonic + | Chain::SeiEvm + | Chain::Abstract + | Chain::Berachain + | Chain::Ink + | Chain::Unichain + | Chain::Hyperliquid + | Chain::HyperCore + | Chain::Plasma + | Chain::Monad + | Chain::XLayer + | Chain::Stable => Address::from_str(&token_id).ok().map(|address| address.to_checksum(None)), + Chain::Solana | Chain::Ton | Chain::Near => Some(token_id), + Chain::Tron => (token_id.len() == 34 && token_id.starts_with('T')).then_some(token_id), + Chain::Xrp => { + if let Some((_, addr)) = token_id.split_once('.') + && addr.starts_with('r') + { + return Some(addr.to_string()); + } + token_id.starts_with('r').then_some(token_id) + } + Chain::Algorand => token_id.parse::().ok().map(|token_id| token_id.to_string()), + Chain::Sui => { + if token_id.len() >= 64 + && token_id.starts_with("0x") + && token_id.matches("::").count() == 2 + && !token_id.starts_with("0x0000000000000000000000000000000000000000000000000000000000000002") + { + Some(token_id) + } else { + None + } + } + Chain::Stellar => { + if let Some((issuer, symbol)) = token_id.split_once("::") { + (issuer.len() == 56 && issuer.starts_with('G') && !symbol.is_empty()).then_some(token_id) + } else { + None + } + } + Chain::Bitcoin + | Chain::BitcoinCash + | Chain::Litecoin + | Chain::Thorchain + | Chain::Cosmos + | Chain::Osmosis + | Chain::Celestia + | Chain::Doge + | Chain::Zcash + | Chain::Aptos + | Chain::Injective + | Chain::Noble + | Chain::Sei + | Chain::Polkadot + | Chain::Cardano => None, + } +} + +#[cfg(test)] +mod tests { + use primitives::asset_constants::{STELLAR_USDC_TOKEN_ID, SUI_WAL_TOKEN_ID, TRON_USDT_TOKEN_ID}; + + use super::*; + + #[test] + fn test_format_token_id_ethereum() { + let chain = Chain::Ethereum; + + let valid_token_id = "0x1234567890abcdef1234567890abcdef12345678".to_string(); + let formatted_valid_token_id = format_token_id(chain, valid_token_id.clone()); + + assert_eq!(formatted_valid_token_id.unwrap(), "0x1234567890AbcdEF1234567890aBcdef12345678"); + assert_eq!(format_token_id(chain, "0x123".to_string()), None); + } + + #[test] + fn test_format_token_id_sui() { + let chain = Chain::Sui; + assert_eq!( + format_token_id(chain, "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string()), + None + ); + assert_eq!(format_token_id(chain, "0x2::sui::SUI".to_string()), None); + assert_eq!(format_token_id(chain, SUI_WAL_TOKEN_ID.to_string()), Some(SUI_WAL_TOKEN_ID.to_string())); + assert_eq!( + format_token_id(chain, "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI".to_string()), + None + ); + } + + #[test] + fn test_format_token_id_tron() { + let chain = Chain::Tron; + + let valid_token_id = TRON_USDT_TOKEN_ID.to_string(); + let formatted_valid_token_id = format_token_id(chain, valid_token_id.clone()); + assert_eq!(formatted_valid_token_id, Some(valid_token_id)); + + assert_eq!(format_token_id(chain, "1234567890123456789012345678901234".to_string()), None); + assert_eq!(format_token_id(chain, "T123".to_string()), None); + } + + #[test] + fn test_format_token_id_xrp() { + let chain = Chain::Xrp; + + assert_eq!( + format_token_id(chain, "534F4C4F00000000000000000000000000000000.rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz".to_string()), + Some("rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz".to_string()) + ); + assert_eq!( + format_token_id(chain, "rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz".to_string()), + Some("rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz".to_string()) + ); + } + + #[test] + fn test_format_token_id_stellar() { + let chain = Chain::Stellar; + + assert_eq!(format_token_id(chain, STELLAR_USDC_TOKEN_ID.to_string()), Some(STELLAR_USDC_TOKEN_ID.to_string())); + assert_eq!(format_token_id(chain, "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN".to_string()), None); + assert_eq!(format_token_id(chain, "invalid".to_string()), None); + assert_eq!(format_token_id(chain, "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN::".to_string()), None); + } +} diff --git a/core/crates/chain_traits/Cargo.toml b/core/crates/chain_traits/Cargo.toml new file mode 100644 index 0000000000..ab42b88e94 --- /dev/null +++ b/core/crates/chain_traits/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "chain_traits" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +primitives = { path = "../primitives" } +async-trait = { workspace = true } +futures = { workspace = true } \ No newline at end of file diff --git a/core/crates/chain_traits/src/lib.rs b/core/crates/chain_traits/src/lib.rs new file mode 100644 index 0000000000..da17d721de --- /dev/null +++ b/core/crates/chain_traits/src/lib.rs @@ -0,0 +1,221 @@ +use std::{error::Error, str}; + +use async_trait::async_trait; +use primitives::chart::ChartCandleStick; +use primitives::perpetual::{PerpetualData, PerpetualPositionsSummary}; +use primitives::portfolio::PerpetualPortfolio; +use primitives::{ + AddressStatus, Asset, AssetBalance, AssetId, BroadcastOptions, Chain, ChainRequest, ChainRequestType, ChartPeriod, DelegationBase, DelegationValidator, FeeRate, + NodeSyncStatus, Transaction, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput, + TransactionStateRequest, TransactionUpdate, UTXO, +}; + +pub struct TransactionsRequest { + pub address: String, + pub asset_id: Option, + pub limit: Option, + pub from_timestamp: Option, +} + +impl TransactionsRequest { + pub fn new(address: String) -> Self { + Self { + address, + asset_id: None, + limit: None, + from_timestamp: None, + } + } + + pub fn with_asset_id(mut self, asset_id: AssetId) -> Self { + self.asset_id = Some(asset_id); + self + } + + pub fn with_limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self + } + + pub fn with_from_timestamp(mut self, from_timestamp: Option) -> Self { + self.from_timestamp = from_timestamp; + self + } +} + +pub trait ChainTraits: + ChainProvider + + ChainBalances + + ChainStaking + + ChainTransactionBroadcast + + ChainTransactions + + ChainTransactionState + + ChainState + + ChainAccount + + ChainPerpetual + + ChainToken + + ChainTransactionLoad + + ChainAddressStatus +{ +} + +pub trait ChainProvider: Send + Sync { + fn get_chain(&self) -> Chain; +} + +pub trait ChainRequestClassifier: Send + Sync { + fn classify_request(&self, _request: ChainRequest<'_>) -> ChainRequestType { + ChainRequestType::Unknown + } +} + +#[async_trait] +pub trait ChainBalances: Send + Sync { + async fn get_balance_coin(&self, _address: String) -> Result> { + Err("Chain does not support balance operations".into()) + } + async fn get_balance_tokens(&self, _address: String, _token_ids: Vec) -> Result, Box> { + Err("Chain does not support balance operations".into()) + } + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Err("Chain does not support balance operations".into()) + } + async fn get_balance_assets(&self, _address: String) -> Result, Box> { + Err("Chain does not support balance operations".into()) + } +} + +#[async_trait] +pub trait ChainStaking: Send + Sync { + async fn get_staking_apy(&self) -> Result, Box> { + Ok(None) + } + + async fn get_staking_validators(&self, _apy: Option) -> Result, Box> { + Ok(vec![]) + } + + async fn get_staking_delegations(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} + +#[async_trait] +pub trait ChainTransactionBroadcast: Send + Sync { + async fn transaction_broadcast(&self, _data: String, _options: BroadcastOptions) -> Result> { + Err("Chain does not support transaction broadcasting".into()) + } +} + +pub trait ChainTransactionDecode: Send + Sync { + fn decode_transaction_broadcast(&self, _response: &str) -> Option { + None + } + + fn decode_transaction_broadcast_bytes(&self, response: &[u8]) -> Option { + str::from_utf8(response).ok().and_then(|response| self.decode_transaction_broadcast(response)) + } +} + +#[async_trait] +pub trait ChainTransactions: Send + Sync { + async fn get_transactions_by_block(&self, _block: u64) -> Result, Box> { + Ok(vec![]) + } + async fn get_transactions_by_address(&self, _request: TransactionsRequest) -> Result, Box> { + Ok(vec![]) + } + + async fn get_transaction_by_hash(&self, _hash: String) -> Result, Box> { + Ok(None) + } + + async fn get_transactions_in_blocks(&self, blocks: Vec) -> Result, Box> { + let futures = blocks.into_iter().map(|x| self.get_transactions_by_block(x)); + let results = futures::future::try_join_all(futures).await?; + Ok(results.into_iter().flatten().collect()) + } +} + +#[async_trait] +pub trait ChainTransactionState: Send + Sync { + async fn get_transaction_status(&self, _request: TransactionStateRequest) -> Result> { + Err("Chain does not support transaction status".into()) + } +} + +#[async_trait] +pub trait ChainState: Send + Sync { + async fn get_chain_id(&self) -> Result>; + async fn get_node_status(&self) -> Result> { + Ok(NodeSyncStatus::in_sync()) + } + async fn get_block_latest_number(&self) -> Result>; +} + +#[async_trait] +pub trait ChainAccount: Send + Sync {} + +#[async_trait] +pub trait ChainPerpetual: Send + Sync { + async fn get_positions(&self, _address: String) -> Result> { + Err("Chain does not support perpetual trading".into()) + } + + async fn get_perpetuals_data(&self) -> Result, Box> { + Err("Chain does not support perpetual trading".into()) + } + + async fn get_perpetual_candlesticks(&self, _symbol: String, _period: ChartPeriod) -> Result, Box> { + Err("Chain does not support perpetual trading".into()) + } + + async fn get_perpetual_portfolio(&self, _address: String) -> Result> { + Err("Chain does not support perpetual portfolio".into()) + } + + async fn get_perpetual_referred_addresses(&self) -> Result, Box> { + Ok(vec![]) + } +} + +#[async_trait] +pub trait ChainToken: Send + Sync { + async fn get_token_data(&self, _token_id: String) -> Result> { + Err("Chain does not support tokens".into()) + } + + fn get_is_token_address(&self, _token_id: &str) -> bool { + false + } +} + +#[async_trait] +pub trait ChainTransactionLoad: Send + Sync { + async fn get_transaction_preload(&self, _input: TransactionPreloadInput) -> Result> { + Ok(TransactionLoadMetadata::None) + } + + async fn get_transaction_load(&self, _input: TransactionLoadInput) -> Result> { + Err("Chain does not support transaction loading".into()) + } + + async fn get_transaction_fee_from_data(&self, _data: String) -> Result> { + Err("Chain does not support transaction fee".into()) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + Err("Chain does not support fee rates".into()) + } + + async fn get_utxos(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} + +#[async_trait] +pub trait ChainAddressStatus: Send + Sync { + async fn get_address_status(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} diff --git a/core/crates/coingecko/Cargo.toml b/core/crates/coingecko/Cargo.toml new file mode 100644 index 0000000000..58ee67e768 --- /dev/null +++ b/core/crates/coingecko/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "coingecko" +edition = { workspace = true } +version = { workspace = true } + +[features] +testkit = [] + +[dependencies] +serde = { workspace = true } +primitives = { path = "../primitives" } +chrono = { workspace = true } +gem_client = { path = "../gem_client", features = ["reqwest"] } +reqwest = { workspace = true } diff --git a/core/crates/coingecko/src/client.rs b/core/crates/coingecko/src/client.rs new file mode 100644 index 0000000000..6471b8f65a --- /dev/null +++ b/core/crates/coingecko/src/client.rs @@ -0,0 +1,193 @@ +use crate::model::{ + Coin, CoinGeckoResponse, CoinIds, CoinInfo, CoinMarket, CoinMarketsQuery, CoinQuery, CointListQuery, Data, ExchangeRates, Global, MarketChart, SearchTrending, TopGainersLosers, +}; +use gem_client::{Client, ClientExt, ReqwestClient, build_path_with_query, retry}; +use primitives::{DEFAULT_FIAT_CURRENCY, FiatRate}; +use reqwest::header::{HeaderMap, HeaderValue}; +use std::error::Error; + +pub const COINGECKO_API_HOST: &str = "api.coingecko.com"; +pub const COINGECKO_API_PRO_HOST: &str = "pro-api.coingecko.com"; +const COINGECKO_API_HEADER_KEY: &str = "x-cg-pro-api-key"; +const USER_AGENT_VALUE: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"; + +#[derive(Debug, Clone)] +pub struct CoinGeckoClient { + client: C, +} + +impl CoinGeckoClient { + pub fn new(api_key: &str) -> Self { + let url = Self::url_for_api_key(api_key.to_string()); + let mut headers = HeaderMap::new(); + headers.insert(reqwest::header::USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE)); + if !api_key.is_empty() { + headers.insert(COINGECKO_API_HEADER_KEY, HeaderValue::from_str(api_key).unwrap()); + } + let reqwest_client = gem_client::builder().default_headers(headers).build().unwrap(); + + let client = ReqwestClient::new(url, reqwest_client); + Self { client } + } + + fn url_for_api_key(api_key: String) -> String { + let host = if !api_key.is_empty() { COINGECKO_API_PRO_HOST } else { COINGECKO_API_HOST }; + format!("https://{}", host) + } +} + +impl CoinGeckoClient { + pub fn new_with_client(client: C) -> Self { + Self { client } + } + + async fn get_json(&self, path: &str) -> Result> + where + T: serde::de::DeserializeOwned + Send, + { + retry( + || async { + let response: CoinGeckoResponse = self.client.get(path).await.map_err(|e| -> Box { Box::new(e) })?; + match response { + CoinGeckoResponse::Success(data) => Ok(data), + CoinGeckoResponse::Error(error) => Err(error.error.into()), + } + }, + 3, + None::) -> bool>, // Use default retry behavior + ) + .await + } + + pub async fn get_global(&self) -> Result> { + Ok(self.get_json::>("/api/v3/global").await?.data) + } + + pub async fn get_search_trending(&self) -> Result> { + let path = "/api/v3/search/trending"; + self.get_json(path).await + } + + pub async fn get_top_gainers_losers(&self) -> Result> { + let path = "/api/v3/coins/top_gainers_losers?vs_currency=usd"; + self.get_json(path).await + } + + pub async fn get_coin_list(&self) -> Result, Box> { + let query = CointListQuery { include_platform: true }; + let path = build_path_with_query("/api/v3/coins/list", &query)?; + self.get_json(&path).await + } + + pub async fn get_coin_list_new(&self) -> Result> { + let path = "/api/v3/coins/list/new"; + self.get_json(path).await + } + + pub async fn get_coin_markets(&self, page: usize, per_page: usize) -> Result, Box> { + let path = build_path_with_query( + "/api/v3/coins/markets", + &CoinMarketsQuery { + vs_currency: "usd", + order: "market_cap_desc", + per_page, + page: Some(page), + sparkline: false, + locale: "en", + ids: None, + include_rehypothecated: true, + }, + )?; + self.get_json(&path).await + } + + pub async fn get_coin_markets_ids(&self, ids: Vec, per_page: usize) -> Result, Box> { + let path = build_path_with_query( + "/api/v3/coins/markets", + &CoinMarketsQuery { + vs_currency: "usd", + order: "market_cap_desc", + per_page, + page: None, + sparkline: false, + locale: "en", + ids: Some(ids.join(",")), + include_rehypothecated: true, + }, + )?; + self.get_json(&path).await + } + + pub async fn get_coin_markets_id(&self, id: &str) -> Result> { + let markets = self.get_coin_markets_ids(vec![id.to_string()], 1).await?; + markets.first().cloned().ok_or_else(|| format!("market {id} not found").into()) + } + + pub async fn get_coin(&self, id: &str) -> Result> { + let query = CoinQuery { + market_data: false, + community_data: true, + tickers: false, + localization: true, + developer_data: true, + }; + let base_path = format!("/api/v3/coins/{}", id); + let path = build_path_with_query(&base_path, &query)?; + self.get_json(&path).await + } + + pub async fn get_coin_by_contract(&self, platform_id: &str, contract_address: &str) -> Result> { + let path = format!("/api/v3/coins/{platform_id}/contract/{contract_address}"); + self.get_json(&path).await + } + + pub async fn get_fiat_rates(&self) -> Result, Box> { + let path = "/api/v3/exchange_rates"; + let rates: ExchangeRates = self.get_json(path).await?; + let usd_rate = rates + .rates + .get(DEFAULT_FIAT_CURRENCY.to_lowercase().as_str()) + .ok_or("Default fiat currency rate not found")? + .value; + + let fiat_rates: Vec = rates + .rates + .into_iter() + .filter(|x| x.1.rate_type == "fiat") + .map(|x| FiatRate { + symbol: x.0.to_uppercase(), + rate: x.1.value / usd_rate, + }) + .collect(); + Ok(fiat_rates) + } + + pub async fn get_all_coin_markets(&self, start_page: Option, per_page: usize, pages: usize) -> Result, Box> { + let mut all_coin_markets = Vec::new(); + let mut page = start_page.unwrap_or(1); + + loop { + let coin_markets = self.get_coin_markets(page, per_page).await?; + + all_coin_markets.extend(coin_markets.clone()); + + if coin_markets.is_empty() || page == pages { + break; + } + + page += 1; + } + + Ok(all_coin_markets) + } + + pub async fn get_market_chart(&self, coin_id: &str, interval: &str, days: &str) -> Result> { + let path = format!("/api/v3/coins/{}/market_chart?vs_currency=usd&days={}&interval={}&precision=full", coin_id, days, interval); + self.get_json(&path).await + } + + pub async fn get_market_chart_auto(&self, coin_id: &str, days: &str) -> Result> { + let path = format!("/api/v3/coins/{}/market_chart?vs_currency=usd&days={}&precision=full", coin_id, days); + self.get_json(&path).await + } +} diff --git a/core/crates/coingecko/src/lib.rs b/core/crates/coingecko/src/lib.rs new file mode 100644 index 0000000000..44f18a83df --- /dev/null +++ b/core/crates/coingecko/src/lib.rs @@ -0,0 +1,12 @@ +pub mod client; +pub mod mapper; +pub mod model; +#[cfg(any(test, feature = "testkit"))] +pub mod testkit; + +pub use self::client::{COINGECKO_API_HOST, COINGECKO_API_PRO_HOST}; +pub use self::mapper::{get_chain_for_coingecko_platform_id, get_chains_for_coingecko_market_id, get_coingecko_market_id_for_chain, get_coingecko_platform_id_for_chain}; +pub use self::model::{Coin, CoinGeckoErrorResponse, CoinGeckoResponse, CoinInfo, CoinMarket}; + +use gem_client::ReqwestClient; +pub type CoinGeckoClient = client::CoinGeckoClient; diff --git a/core/crates/coingecko/src/mapper.rs b/core/crates/coingecko/src/mapper.rs new file mode 100644 index 0000000000..f21d7fbfe9 --- /dev/null +++ b/core/crates/coingecko/src/mapper.rs @@ -0,0 +1,130 @@ +use primitives::chain::Chain; + +const COINGECKO_CHAIN_PLATFORMS: &[(Chain, &str)] = &[ + (Chain::Ethereum, "ethereum"), + (Chain::AvalancheC, "avalanche"), + (Chain::Abstract, "abstract"), + (Chain::Optimism, "optimistic-ethereum"), + (Chain::Base, "base"), + (Chain::Arbitrum, "arbitrum-one"), + (Chain::SmartChain, "binance-smart-chain"), + (Chain::Manta, "manta-pacific"), + (Chain::Tron, "tron"), + (Chain::Polygon, "polygon-pos"), + (Chain::Solana, "solana"), + (Chain::Blast, "blast"), + (Chain::Gnosis, "xdai"), + (Chain::Fantom, "fantom"), + (Chain::Osmosis, "osmosis"), + (Chain::Cosmos, "cosmos"), + (Chain::Aptos, "aptos"), + (Chain::Sui, "sui"), + (Chain::OpBNB, "opbnb"), + (Chain::Mantle, "mantle"), + (Chain::Celo, "celo"), + (Chain::ZkSync, "zksync"), + (Chain::Linea, "linea"), + (Chain::Near, "near"), + (Chain::Ton, "the-open-network"), + (Chain::Algorand, "algorand"), + (Chain::Berachain, "berachain-bera"), + (Chain::Ink, "ink"), + (Chain::Unichain, "unichain"), + (Chain::SeiEvm, "sei-network"), + (Chain::Xrp, "xrp"), + (Chain::Hyperliquid, "hyperevm"), + (Chain::Sonic, "sonic"), + (Chain::Stellar, "stellar"), + (Chain::Plasma, "plasma"), + (Chain::Monad, "monad"), + (Chain::Stable, "stable-2"), +]; + +pub fn get_chains_for_coingecko_market_id(id: &str) -> Vec { + Chain::all().into_iter().filter(|chain| get_coingecko_market_id_for_chain(*chain) == id).collect() +} + +// Full list https://api.coingecko.com/api/v3/asset_platforms +pub fn get_chain_for_coingecko_platform_id(id: &str) -> Option { + COINGECKO_CHAIN_PLATFORMS.iter().find_map(|(chain, platform_id)| (*platform_id == id).then_some(*chain)) +} + +pub fn get_coingecko_platform_id_for_chain(chain: Chain) -> Option<&'static str> { + COINGECKO_CHAIN_PLATFORMS + .iter() + .find_map(|(candidate, platform_id)| (*candidate == chain).then_some(*platform_id)) +} + +pub fn get_coingecko_market_id_for_chain(chain: Chain) -> &'static str { + match chain { + Chain::Bitcoin => "bitcoin", + Chain::BitcoinCash => "bitcoin-cash", + Chain::Litecoin => "litecoin", + Chain::Ethereum + | Chain::Base + | Chain::Arbitrum + | Chain::Optimism + | Chain::ZkSync + | Chain::Blast + | Chain::Linea + | Chain::Manta + | Chain::World + | Chain::Abstract + | Chain::Ink + | Chain::Unichain => "ethereum", + Chain::SmartChain | Chain::OpBNB => "binancecoin", + Chain::Solana => "solana", + Chain::Polygon => "polygon-ecosystem-token", + Chain::Thorchain => "thorchain", + Chain::Cosmos => "cosmos", + Chain::Osmosis => "osmosis", + Chain::Ton => "the-open-network", + Chain::Tron => "tron", + Chain::Doge => "dogecoin", + Chain::Zcash => "zcash", + Chain::Aptos => "aptos", + Chain::AvalancheC => "avalanche-2", + Chain::Sui => "sui", + Chain::Xrp => "ripple", + Chain::Fantom => "fantom", + Chain::Sonic => "sonic-3", + Chain::Gnosis => "xdai", + Chain::Celestia => "celestia", + Chain::Injective => "injective-protocol", + Chain::Sei => "sei-network", + Chain::SeiEvm => "sei-network", + Chain::Noble => "usd-coin", + Chain::Mantle => "mantle", + Chain::Celo => "celo", + Chain::Near => "near", + Chain::Stellar => "stellar", + Chain::Algorand => "algorand", + Chain::Polkadot => "polkadot", + Chain::Cardano => "cardano", + Chain::Berachain => "berachain-bera", + Chain::Hyperliquid => "hyperliquid", + Chain::HyperCore => "hyperliquid", + Chain::Monad => "monad", + Chain::Plasma => "plasma", + Chain::XLayer => "okb", + Chain::Stable => "tether", // gUSDT is the native gas token + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_coingecko_platform_mapping() { + assert_eq!(get_chain_for_coingecko_platform_id("binance-smart-chain"), Some(Chain::SmartChain)); + assert_eq!(get_coingecko_platform_id_for_chain(Chain::SmartChain), Some("binance-smart-chain")); + assert_eq!(get_chain_for_coingecko_platform_id("unknown"), None); + assert_eq!(get_coingecko_platform_id_for_chain(Chain::Bitcoin), None); + + for (chain, platform_id) in COINGECKO_CHAIN_PLATFORMS { + assert_eq!(get_chain_for_coingecko_platform_id(platform_id), Some(*chain)); + assert_eq!(get_coingecko_platform_id_for_chain(*chain), Some(*platform_id)); + } + } +} diff --git a/core/crates/coingecko/src/model.rs b/core/crates/coingecko/src/model.rs new file mode 100644 index 0000000000..c813a80478 --- /dev/null +++ b/core/crates/coingecko/src/model.rs @@ -0,0 +1,220 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Data { + pub data: T, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Global { + pub market_cap_change_percentage_24h_usd: f64, + pub total_market_cap: Total, + pub total_volume: Total, + pub market_cap_percentage: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Total { + pub usd: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Coin { + pub id: String, + pub symbol: String, + pub name: String, + pub platforms: HashMap>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CoinInfo { + pub id: String, + pub symbol: String, + pub name: String, + pub asset_platform_id: Option, + pub preview_listing: bool, + pub market_cap_rank: Option, + pub market_cap_rank_with_rehypothecated: Option, + pub watchlist_portfolio_users: Option, + pub platforms: HashMap>, + pub detail_platforms: HashMap>, + pub links: CoinMarketLinks, + pub community_data: Option, + pub image: Image, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Image { + pub thumb: String, + pub small: String, + pub large: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CommunityData { + pub twitter_followers: Option, +} +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DeveloperData { + pub stars: Option, + pub subscribers: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DetailPlatform { + pub decimal_place: Option, + pub contract_address: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct MarketChart { + pub prices: Vec>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CoinMarket { + pub id: String, + pub symbol: String, + pub name: String, + pub current_price: Option, + pub price_change_percentage_24h: Option, + pub market_cap: Option, + pub fully_diluted_valuation: Option, + pub market_cap_rank: Option, + pub market_cap_rank_with_rehypothecated: Option, + pub total_volume: Option, + pub circulating_supply: Option, + pub total_supply: Option, + pub max_supply: Option, + pub ath: Option, + pub ath_date: Option>, + pub atl: Option, + pub atl_date: Option>, + pub last_updated: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CointListQuery { + pub include_platform: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CoinQuery { + pub market_data: bool, + pub community_data: bool, + pub tickers: bool, + pub localization: bool, + pub developer_data: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CoinMarketsQuery { + pub vs_currency: &'static str, + pub order: &'static str, + pub per_page: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + pub sparkline: bool, + pub locale: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub ids: Option, + pub include_rehypothecated: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CoinMarketLinks { + pub homepage: Vec, + pub blockchain_site: Vec>, + pub chat_url: Vec, + pub subreddit_url: Option, + pub twitter_screen_name: Option, + pub facebook_username: Option, + pub telegram_channel_identifier: Option, + pub repos_url: HashMap>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExchangeRates { + pub rates: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExchangeRate { + pub unit: String, + #[serde(rename = "type")] + pub rate_type: String, + pub name: String, + pub value: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SearchTrending { + pub coins: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TopGainersLosers { + pub top_gainers: CoinIds, + pub top_losers: CoinIds, +} + +impl TopGainersLosers { + pub fn get_gainers_ids(&self) -> Vec { + self.top_gainers.ids() + } + + pub fn get_losers_ids(&self) -> Vec { + self.top_losers.ids() + } +} + +impl SearchTrending { + pub fn get_coins_ids(&self) -> Vec { + self.coins.iter().map(|x| x.item.id.clone()).collect() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SearchTrendingItem { + pub item: CoinId, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CoinId { + pub id: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CoinIds(pub Vec); + +impl CoinIds { + pub fn ids(&self) -> Vec { + self.0.iter().map(|x| x.id.clone()).collect() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CoinGeckoErrorResponse { + pub error: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum CoinGeckoResponse { + Success(T), + Error(CoinGeckoErrorResponse), +} + +impl CoinInfo { + pub fn effective_market_cap_rank(&self) -> Option { + self.market_cap_rank.or(self.market_cap_rank_with_rehypothecated) + } +} + +impl CoinMarket { + pub fn effective_market_cap_rank(&self) -> Option { + self.market_cap_rank.or(self.market_cap_rank_with_rehypothecated) + } +} diff --git a/core/crates/coingecko/src/testkit.rs b/core/crates/coingecko/src/testkit.rs new file mode 100644 index 0000000000..5c668765ec --- /dev/null +++ b/core/crates/coingecko/src/testkit.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; + +use crate::CoinMarket; + +impl CoinMarket { + pub fn mock() -> Self { + Self::mock_with_id("bitcoin") + } + + pub fn mock_with_id(id: &str) -> Self { + Self { + id: id.to_string(), + symbol: "btc".to_string(), + name: "Bitcoin".to_string(), + current_price: Some(0.12), + price_change_percentage_24h: Some(1.5), + market_cap: Some(1000.0), + fully_diluted_valuation: Some(2000.0), + market_cap_rank: Some(100), + market_cap_rank_with_rehypothecated: Some(99), + total_volume: Some(10.0), + circulating_supply: Some(10000.0), + total_supply: Some(20000.0), + max_supply: Some(30000.0), + ath: Some(1.0), + ath_date: Some(DateTime::::UNIX_EPOCH), + atl: Some(0.01), + atl_date: Some(DateTime::::UNIX_EPOCH), + last_updated: Some(DateTime::::UNIX_EPOCH), + } + } +} diff --git a/core/crates/fiat/Cargo.toml b/core/crates/fiat/Cargo.toml new file mode 100644 index 0000000000..0c4438f233 --- /dev/null +++ b/core/crates/fiat/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "fiat" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +reqwest = { workspace = true } +url = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +gem_encoding = { path = "../gem_encoding" } +ring = { workspace = true } +pem-rfc7468 = { workspace = true } +futures = { workspace = true } +async-trait = { workspace = true } +hex = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } + +settings = { path = "../settings" } +storage = { path = "../storage" } +primitives = { path = "../primitives" } +chain_primitives = { path = "../chain_primitives" } +serde_serializers = { path = "../serde_serializers" } +number_formatter = { path = "../number_formatter" } +cacher = { path = "../cacher" } +streamer = { path = "../streamer" } +gem_client = { path = "../gem_client", features = ["reqwest"] } +gem_tracing = { path = "../tracing" } + +[dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } + +[features] +# Feature flag for integration tests +fiat_integration_tests = ["primitives/testkit"] diff --git a/core/crates/fiat/src/client.rs b/core/crates/fiat/src/client.rs new file mode 100644 index 0000000000..0d800a9107 --- /dev/null +++ b/core/crates/fiat/src/client.rs @@ -0,0 +1,441 @@ +use cacher::{CacheKey, CacherClient}; +use std::collections::HashSet; +use std::error::Error; +use std::time::Duration; + +use crate::ip_check_client::{IPAddressInfo, IPCheckClient}; +use crate::{ + CachedFiatQuoteData, FiatCacherClient, FiatProvider, + model::{FiatMapping, FiatMappingMap}, +}; +use futures::future::join_all; +use gem_tracing::{error_with_fields, info_with_fields}; +use number_formatter::BigNumberFormatter; +use primitives::{ + Asset, FiatAssetSymbol, FiatAssets, FiatProvider as PrimitiveFiatProvider, FiatProviderCountry, FiatQuote, FiatQuoteRequest, FiatQuoteType, FiatQuoteUrl, FiatQuoteUrlData, + FiatQuotes, FiatTransaction, PaymentType, +}; +use reqwest::Client as RequestClient; +use storage::{AssetFilter, AssetsRepository, Database, FiatRepository, WalletsRepository}; +use streamer::{FiatWebhook, FiatWebhookPayload, StreamProducer}; + +pub struct FiatClient { + database: Database, + cacher: CacherClient, + fiat_cacher: FiatCacherClient, + providers: Vec>, + ip_check_client: IPCheckClient, + stream_producer: StreamProducer, +} + +impl FiatClient { + pub fn new( + database: Database, + cacher: CacherClient, + providers: Vec>, + ip_check_client: IPCheckClient, + stream_producer: StreamProducer, + ) -> Self { + Self { + database, + cacher: cacher.clone(), + fiat_cacher: FiatCacherClient::new(cacher), + providers, + ip_check_client, + stream_producer, + } + } + + fn provider(&self, provider_name: &str) -> Result<&(dyn FiatProvider + Send + Sync), Box> { + self.providers + .iter() + .find(|provider| provider.name().id() == provider_name) + .map(|provider| provider.as_ref()) + .ok_or_else(|| format!("Provider {} not found", provider_name).into()) + } + + pub fn request_client(timeout: Duration) -> RequestClient { + RequestClient::builder().timeout(timeout).build().unwrap() + } + + pub async fn get_on_ramp_assets(&self) -> Result> { + let assets = self.database.assets()?.get_assets_by_filter(vec![AssetFilter::IsBuyable(true)])?; + Ok(FiatAssets { + version: assets.clone().len() as u32, + asset_ids: assets.into_iter().map(|x| x.asset.id.to_string()).collect::>(), + }) + } + + pub async fn get_off_ramp_assets(&self) -> Result> { + let assets = self.database.assets()?.get_assets_by_filter(vec![AssetFilter::IsSellable(true)])?; + Ok(FiatAssets { + version: assets.clone().len() as u32, + asset_ids: assets.into_iter().map(|x| x.asset.id.to_string()).collect::>(), + }) + } + + pub async fn get_fiat_providers_countries(&self) -> Result, Box> { + Ok(FiatRepository::get_fiat_providers_countries(&mut self.database.fiat()?)?) + } + + pub async fn get_order_status(&self, provider_name: &str, order_id: &str) -> Result> { + let provider = self.provider(provider_name)?; + let update = provider.get_order_status(order_id).await?; + let transaction = self.database.fiat()?.update_fiat_transaction(provider.name(), update)?; + Ok(transaction.as_primitive()?) + } + + pub async fn process_and_publish_webhook(&self, provider_name: &str, webhook_data: serde_json::Value) -> Result> { + let provider = self.provider(provider_name)?; + let provider_id = provider.name().id().to_string(); + let webhook = match provider.process_webhook(webhook_data.clone()).await { + Ok(webhook) => webhook, + Err(e) => { + error_with_fields!("failed to decode fiat webhook", &*e, provider = provider_id); + return Err(e); + } + }; + + let (kind, transaction_id) = match &webhook { + FiatWebhook::OrderId(order_id) => ("order_id", Some(order_id.clone())), + FiatWebhook::Transaction(tx) => ("transaction", tx.provider_transaction_id.clone().or(Some(tx.transaction_id.clone()))), + FiatWebhook::None => ("none", None), + }; + + info_with_fields!( + "received fiat webhook", + provider = provider_id, + kind = kind, + transaction_id = transaction_id.as_deref().unwrap_or("") + ); + + let payload = FiatWebhookPayload::new(provider.name(), webhook_data.clone(), webhook.clone()); + match webhook { + FiatWebhook::OrderId(_) | FiatWebhook::Transaction(_) => { + self.stream_producer.publish(streamer::QueueName::FiatOrderWebhooks, &payload).await?; + info_with_fields!("published fiat webhook", provider = provider_id, transaction_id = transaction_id.as_deref().unwrap_or("")); + } + FiatWebhook::None => { + info_with_fields!("ignored fiat webhook", provider = provider_id); + } + } + Ok(payload) + } + + fn get_fiat_mapping(&self, asset: &Asset, quote_type: &FiatQuoteType) -> Result<(FiatMappingMap, Vec), Box> { + let fiat_assets = self.database.fiat()?.get_fiat_assets_for_asset_id(&asset.id)?; + let providers = self.database.fiat()?.get_fiat_providers()?.into_iter().map(|p| p.as_primitive()).collect(); + + let map: FiatMappingMap = fiat_assets + .into_iter() + .filter(|x| match quote_type { + FiatQuoteType::Buy => x.is_buy_enabled(), + FiatQuoteType::Sell => x.is_sell_enabled(), + }) + .map(|x| { + let provider_id = x.provider.0.id().to_string(); + ( + provider_id, + FiatMapping { + asset: asset.clone(), + asset_symbol: FiatAssetSymbol { + symbol: x.symbol.clone(), + network: x.network.clone(), + }, + unsupported_countries: x.unsupported_countries(), + buy_limits: x.buy_limits(), + sell_limits: x.sell_limits(), + }, + ) + }) + .collect(); + Ok((map, providers)) + } + + pub fn get_providers(&self, provider_id: Option) -> Vec<&(dyn FiatProvider + Send + Sync)> { + self.providers + .iter() + .filter(|x| provider_id.as_deref().is_none_or(|id| x.name().id() == id)) + .map(|x| x.as_ref()) + .collect() + } + + pub async fn get_quotes(&self, request: FiatQuoteRequest) -> Result> { + let asset = self.database.assets()?.get_asset(&request.asset_id)?; + + let fiat_providers_countries = self.get_fiat_providers_countries().await?; + let ip_address_info = match self.get_ip_address(&request.ip_address).await { + Ok(info) => info, + Err(e) => { + return Err(format!("IP address validation failed: {}", e).into()); + } + }; + let (fiat_mapping_map, db_providers) = self.get_fiat_mapping(&asset, &request.quote_type)?; + + let provider_impls = self.get_providers(request.provider_id.clone()); + + let country_code = &ip_address_info.alpha2; + + let futures = provider_impls.into_iter().filter_map(|provider| { + let provider_name = provider.name(); + let provider_id = provider_name.id().to_string(); + let db_provider = db_providers.iter().find(|p| p.id == provider_name)?; + + let countries: HashSet = fiat_providers_countries.iter().filter(|x| x.provider == provider_name).map(|x| x.alpha2.clone()).collect(); + + let mapping = fiat_mapping_map.get(&provider_id); + if !is_provider_eligible(db_provider, &countries, mapping, country_code, &request.quote_type) { + return None; + } + let mapping = mapping?.clone(); + let request = request.clone(); + let asset = asset.clone(); + let db_payment_methods = db_provider.payment_methods.clone(); + let country_code = country_code.clone(); + + Some(async move { + get_provider_quote(provider, &request, &asset, &mapping, &db_payment_methods, country_code) + .await + .map_err(|e| (provider_id, e)) + }) + }); + + let results = join_all(futures).await; + + let mut quotes = Vec::new(); + let mut errors = Vec::new(); + + for result in results { + match result { + Ok(cached_quote) => quotes.push(cached_quote), + Err((provider_id, e)) => errors.push(primitives::FiatQuoteError::new(Some(provider_id), e.to_string())), + } + } + + self.fiat_cacher.set_quotes("es).await?; + + let mut quotes: Vec = quotes.into_iter().map(|x| x.quote).collect(); + let sort_fn = match request.quote_type { + FiatQuoteType::Buy => sort_quotes_by_crypto_amount_desc, + FiatQuoteType::Sell => sort_quotes_by_crypto_amount_asc, + }; + quotes.sort_by(|a, b| sort_fn(a, b, &db_providers)); + + Ok(FiatQuotes { quotes, errors }) + } + + pub async fn get_quote(&self, quote_id: &str) -> Result> { + Ok(self.fiat_cacher.get_quote(quote_id).await?.quote) + } + + pub async fn get_quote_url(&self, quote_id: &str, wallet_id: i32, device_id: i32, ip_address: &str, locale: &str) -> Result> { + let crate::CachedFiatQuoteData { + quote, + asset_symbol, + country_code, + } = self.fiat_cacher.get_quote(quote_id).await?; + let provider = self.provider(quote.provider.id.as_ref())?; + let wallet_address_row = self.database.client()?.subscriptions_wallet_address_for_chain(device_id, wallet_id, quote.asset.chain)?; + let data = FiatQuoteUrlData { + quote, + asset_symbol, + wallet_address: wallet_address_row.address, + ip_address: ip_address.to_string(), + locale: locale.to_string(), + }; + + let url = provider.get_quote_url(data.clone()).await?; + let country = match country_code { + Some(country_code) => Some(country_code), + None => Some(self.get_ip_address(ip_address).await?.alpha2), + }; + let pending_transaction = FiatTransaction::new_pending(&data, country, url.provider_transaction_id.clone()); + let pending_transaction_row = storage::models::NewFiatTransactionRow::new(pending_transaction, device_id, wallet_id, wallet_address_row.id); + + self.database.fiat()?.add_fiat_transaction(pending_transaction_row)?; + + Ok(url) + } + + pub async fn get_ip_address(&self, ip_address: &str) -> Result> { + self.cacher + .get_or_set_cached(CacheKey::FiatIpCheck(ip_address), || self.ip_check_client.get_ip_address(ip_address)) + .await + } +} + +fn is_provider_eligible(db_provider: &PrimitiveFiatProvider, countries: &HashSet, mapping: Option<&FiatMapping>, country_code: &str, quote_type: &FiatQuoteType) -> bool { + let is_enabled = match quote_type { + FiatQuoteType::Buy => db_provider.is_buy_enabled(), + FiatQuoteType::Sell => db_provider.is_sell_enabled(), + }; + if !is_enabled { + return false; + } + let Some(mapping) = mapping else { + return false; + }; + countries.contains(country_code) && !mapping.unsupported_countries.contains_key(country_code) +} + +async fn get_provider_quote( + provider: &(dyn FiatProvider + Send + Sync), + request: &FiatQuoteRequest, + asset: &Asset, + mapping: &FiatMapping, + db_payment_methods: &[PaymentType], + country_code: String, +) -> Result> { + let start = std::time::Instant::now(); + let response = match request.quote_type { + FiatQuoteType::Buy => provider.get_quote_buy(request.clone(), mapping.clone()).await, + FiatQuoteType::Sell => provider.get_quote_sell(request.clone(), mapping.clone()).await, + }?; + + if response.fiat_amount <= 0.0 || response.crypto_amount <= 0.0 { + return Err("Invalid quote amounts".into()); + } + + let latency = start.elapsed().as_millis() as u64; + let payment_methods = if !response.payment_methods.is_empty() { + response.payment_methods + } else if !db_payment_methods.is_empty() { + db_payment_methods.to_vec() + } else { + provider.payment_methods().await + }; + let value = quote_value(asset, response.crypto_amount)?; + let quote = FiatQuote::new( + response.quote_id, + asset.clone(), + provider.name().as_fiat_provider(), + request.quote_type.clone(), + response.fiat_amount, + request.currency.clone(), + response.crypto_amount, + value, + latency, + payment_methods, + ); + Ok(CachedFiatQuoteData { + quote, + asset_symbol: mapping.asset_symbol.clone(), + country_code: Some(country_code), + }) +} + +use primitives::sort_by_priority_then_amount; + +fn sort_quotes_by_crypto_amount_desc(a: &FiatQuote, b: &FiatQuote, providers: &[PrimitiveFiatProvider]) -> std::cmp::Ordering { + sort_by_priority_then_amount(a.provider.id.as_ref(), b.provider.id.as_ref(), &a.crypto_amount, &b.crypto_amount, providers, false) +} + +fn sort_quotes_by_crypto_amount_asc(a: &FiatQuote, b: &FiatQuote, providers: &[PrimitiveFiatProvider]) -> std::cmp::Ordering { + sort_by_priority_then_amount(a.provider.id.as_ref(), b.provider.id.as_ref(), &a.crypto_amount, &b.crypto_amount, providers, true) +} + +fn quote_value(asset: &Asset, crypto_amount: f64) -> Result> { + let amount = format!("{crypto_amount:.precision$}", precision = asset.decimals as usize); + Ok(BigNumberFormatter::value_from_amount(&amount, asset.decimals as u32)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::FiatProviderName; + + fn mock_quote(provider: FiatProviderName, crypto_amount: f64) -> FiatQuote { + let mut quote = FiatQuote::mock(provider); + quote.crypto_amount = crypto_amount; + quote.value = quote_value("e.asset, crypto_amount).unwrap(); + quote + } + + #[test] + fn quote_value_uses_asset_precision_for_small_amounts() { + let asset = Asset::from_chain(primitives::Chain::Ethereum); + assert_eq!(quote_value(&asset, 0.000000000000000001_f64).unwrap(), "1"); + } + + #[test] + fn sort_buy_quotes_by_priority() { + let providers = vec![ + PrimitiveFiatProvider::mock_with_priority(FiatProviderName::MoonPay, 1, None), + PrimitiveFiatProvider::mock_with_priority(FiatProviderName::Mercuryo, 2, None), + PrimitiveFiatProvider::mock_with_priority(FiatProviderName::Transak, 3, None), + ]; + + let mut quotes = [ + mock_quote(FiatProviderName::Paybis, 0.50), + mock_quote(FiatProviderName::MoonPay, 0.45), + mock_quote(FiatProviderName::Flashnet, 0.40), + mock_quote(FiatProviderName::Transak, 0.47), + mock_quote(FiatProviderName::Mercuryo, 0.48), + ]; + quotes.sort_by(|a, b| sort_quotes_by_crypto_amount_desc(a, b, &providers)); + + assert_eq!(quotes[0].provider.id, FiatProviderName::MoonPay); + assert_eq!(quotes[1].provider.id, FiatProviderName::Mercuryo); + assert_eq!(quotes[2].provider.id, FiatProviderName::Transak); + assert_eq!(quotes[3].provider.id, FiatProviderName::Paybis); + assert_eq!(quotes[4].provider.id, FiatProviderName::Flashnet); + } + + #[test] + fn sort_buy_quotes_with_threshold_override() { + let providers = vec![ + PrimitiveFiatProvider::mock_with_priority(FiatProviderName::MoonPay, 1, Some(1000)), + PrimitiveFiatProvider::mock_with_priority(FiatProviderName::Mercuryo, 2, Some(500)), + PrimitiveFiatProvider::mock_with_priority(FiatProviderName::Transak, 3, None), + ]; + + let mut quotes = [ + mock_quote(FiatProviderName::Paybis, 0.52), + mock_quote(FiatProviderName::Transak, 0.60), + mock_quote(FiatProviderName::Mercuryo, 0.48), + mock_quote(FiatProviderName::MoonPay, 0.45), + ]; + quotes.sort_by(|a, b| sort_quotes_by_crypto_amount_desc(a, b, &providers)); + + assert_eq!(quotes[0].provider.id, FiatProviderName::Transak); + assert_eq!(quotes[1].provider.id, FiatProviderName::MoonPay); + assert_eq!(quotes[2].provider.id, FiatProviderName::Mercuryo); + assert_eq!(quotes[3].provider.id, FiatProviderName::Paybis); + } + + #[test] + fn sort_buy_quotes_by_amount_without_priority_override() { + let providers = vec![PrimitiveFiatProvider::mock_with_priority(FiatProviderName::MoonPay, 1, Some(100))]; + + let mut quotes = [ + mock_quote(FiatProviderName::MoonPay, 0.0773), + mock_quote(FiatProviderName::Mercuryo, 0.0759), + mock_quote(FiatProviderName::Transak, 0.07505), + mock_quote(FiatProviderName::Paybis, 0.07721), + ]; + + quotes.sort_by(|a, b| sort_quotes_by_crypto_amount_desc(a, b, &providers)); + + assert_eq!(quotes[0].provider.id, FiatProviderName::MoonPay); + assert_eq!(quotes[1].provider.id, FiatProviderName::Paybis); + assert_eq!(quotes[2].provider.id, FiatProviderName::Mercuryo); + assert_eq!(quotes[3].provider.id, FiatProviderName::Transak); + } + + #[test] + fn sort_sell_quotes_by_crypto_amount_ascending() { + let providers: Vec = vec![]; + + let mut quotes = [ + mock_quote(FiatProviderName::MoonPay, 0.036108), + mock_quote(FiatProviderName::Mercuryo, 0.03311059), + mock_quote(FiatProviderName::Transak, 0.03086637), + ]; + + quotes.sort_by(|a, b| sort_quotes_by_crypto_amount_asc(a, b, &providers)); + + assert_eq!(quotes[0].provider.id, FiatProviderName::Transak); + assert_eq!(quotes[1].provider.id, FiatProviderName::Mercuryo); + assert_eq!(quotes[2].provider.id, FiatProviderName::MoonPay); + } +} diff --git a/core/crates/fiat/src/error.rs b/core/crates/fiat/src/error.rs new file mode 100644 index 0000000000..f7e03e7d29 --- /dev/null +++ b/core/crates/fiat/src/error.rs @@ -0,0 +1,32 @@ +use std::fmt; + +#[derive(Debug, Clone)] +pub enum FiatQuoteError { + MinimumAmount(f64), + InsufficientAmount(f64, f64), + ExcessiveAmount(f64, f64), + UnsupportedCountry(String), + UnsupportedCountryAsset(String, String), + UnsupportedState(String), + AddressNotSubscribed(String), + IpAddressValidationFailed(String), + InvalidRequest(String), +} + +impl fmt::Display for FiatQuoteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MinimumAmount(amount) => write!(f, "Minimum Amount is {}", amount), + Self::InsufficientAmount(amount, min) => write!(f, "Amount {} is below minimum {}", amount, min), + Self::ExcessiveAmount(amount, max) => write!(f, "Amount {} exceeds maximum {}", amount, max), + Self::UnsupportedCountry(country) => write!(f, "Unsupported country: {}", country), + Self::UnsupportedCountryAsset(country, asset) => write!(f, "Unsupported country {} for an asset: {}", country, asset), + Self::UnsupportedState(state) => write!(f, "Unsupported state: {}", state), + Self::AddressNotSubscribed(address) => write!(f, "Address {} is not subscribed", address), + Self::IpAddressValidationFailed(msg) => write!(f, "IP address validation failed: {}", msg), + Self::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg), + } + } +} + +impl std::error::Error for FiatQuoteError {} diff --git a/core/crates/fiat/src/fiat_cacher_client.rs b/core/crates/fiat/src/fiat_cacher_client.rs new file mode 100644 index 0000000000..1703b13439 --- /dev/null +++ b/core/crates/fiat/src/fiat_cacher_client.rs @@ -0,0 +1,35 @@ +use cacher::{CacheError, CacheKey, CacherClient}; +use primitives::{FiatAssetSymbol, FiatQuote}; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedFiatQuoteData { + pub quote: FiatQuote, + #[serde(flatten)] + pub asset_symbol: FiatAssetSymbol, + #[serde(default)] + pub country_code: Option, +} + +pub struct FiatCacherClient { + cacher: CacherClient, +} + +impl FiatCacherClient { + pub fn new(cacher: CacherClient) -> Self { + Self { cacher } + } + + pub async fn set_quotes(&self, cached_quotes: &[CachedFiatQuoteData]) -> Result> { + let entries: Vec<_> = cached_quotes.iter().map(|x| (CacheKey::FiatQuote(&x.quote.id), x)).collect(); + self.cacher.set_values_cached(&entries).await + } + + pub async fn get_quote(&self, quote_id: &str) -> Result> { + match self.cacher.get_cached_optional(CacheKey::FiatQuote(quote_id)).await? { + Some(quote) => Ok(quote), + None => Err(Box::new(CacheError::not_found("FiatQuote", quote_id.to_string()))), + } + } +} diff --git a/core/crates/fiat/src/hmac_signature.rs b/core/crates/fiat/src/hmac_signature.rs new file mode 100644 index 0000000000..183dd5a8e8 --- /dev/null +++ b/core/crates/fiat/src/hmac_signature.rs @@ -0,0 +1,52 @@ +use gem_encoding::{decode_base64, encode_base64}; +use hmac::{Hmac, KeyInit, Mac}; +use sha2::Sha256; + +fn generate_hmac_from_bytes(key_bytes: &[u8], message: &str) -> String { + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC can take key of any size"); + mac.update(message.as_bytes()); + encode_base64(&mac.finalize().into_bytes()) +} + +pub fn generate_hmac_signature(secret_key: &str, message: &str) -> String { + generate_hmac_from_bytes(secret_key.as_bytes(), message) +} + +pub fn generate_hmac_signature_from_base64_key(base64_secret_key: &str, message: &str) -> Option { + let decoded_key = decode_base64(base64_secret_key).ok()?; + Some(generate_hmac_from_bytes(&decoded_key, message)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_hmac_signature() { + let secret = "test_secret"; + let message = "test_message"; + let signature = generate_hmac_signature(secret, message); + assert_eq!(signature, "ZaIJF7XWibQHwbbgx6qd5AIh78SB/+WPJIXFHYIqzs4="); + } + + #[test] + fn test_generate_hmac_signature_from_base64_key() { + let base64_secret = "dGVzdF9zZWNyZXRfa2V5"; // "test_secret_key" in base64 + let message = "?currencyCodeFrom=USD¤cyCodeTo=BTC&flow=buyCrypto&partnerId=test_api_key&requestedAmount=100&requestedAmountType=from&walletAddress=bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"; + let signature = generate_hmac_signature_from_base64_key(base64_secret, message).unwrap(); + + assert!(!signature.is_empty()); + assert!(signature.contains("=") || signature.chars().all(|c| c.is_alphanumeric() || c == '+' || c == '/')); + } + + #[test] + fn test_generate_hmac_signature_from_base64_key_invalid_base64() { + let invalid_base64_secret = "not_valid_base64!!!"; + let message = "test_message"; + let signature = generate_hmac_signature_from_base64_key(invalid_base64_secret, message); + + // Should return None for invalid base64 + assert!(signature.is_none()); + } +} diff --git a/core/crates/fiat/src/ip_check_client.rs b/core/crates/fiat/src/ip_check_client.rs new file mode 100644 index 0000000000..927c09037b --- /dev/null +++ b/core/crates/fiat/src/ip_check_client.rs @@ -0,0 +1,32 @@ +use std::error::Error; + +use serde::{Deserialize, Serialize}; + +use crate::providers::MoonPayClient; + +#[derive(Clone)] +pub struct IPCheckClient { + client: MoonPayClient, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IPAddressInfo { + pub alpha2: String, + pub state: String, + pub ip_address: String, +} + +impl IPCheckClient { + pub fn new(client: MoonPayClient) -> Self { + Self { client } + } + + pub async fn get_ip_address(&self, ip_address: &str) -> Result> { + let data = self.client.get_ip_address(ip_address).await?; + Ok(IPAddressInfo { + alpha2: data.alpha2, + state: data.state, + ip_address: ip_address.to_owned(), + }) + } +} diff --git a/core/crates/fiat/src/lib.rs b/core/crates/fiat/src/lib.rs new file mode 100644 index 0000000000..6654c9b418 --- /dev/null +++ b/core/crates/fiat/src/lib.rs @@ -0,0 +1,57 @@ +pub mod client; +pub mod error; +pub mod fiat_cacher_client; +pub mod hmac_signature; +pub mod ip_check_client; +pub mod model; +pub mod provider; +pub mod providers; +pub mod rsa_signature; +pub mod transaction_info_mapper; + +pub use provider::FiatProvider; + +use crate::providers::{BanxaClient, FlashnetClient, MercuryoClient, MoonPayClient, PaybisClient, TransakClient}; +use settings::Settings; + +pub use client::FiatClient; +pub use fiat_cacher_client::{CachedFiatQuoteData, FiatCacherClient}; +pub use ip_check_client::{IPAddressInfo, IPCheckClient}; +pub use transaction_info_mapper::fiat_transaction_info; + +#[cfg(all(test, feature = "fiat_integration_tests"))] +pub mod testkit; + +pub struct FiatProviderFactory {} +impl FiatProviderFactory { + pub fn new_providers(settings: Settings) -> Vec> { + let request_client = crate::client::FiatClient::request_client(settings.fiat.timeout); + + let moonpay = MoonPayClient::new(request_client.clone(), settings.moonpay.key.public.clone(), settings.moonpay.key.secret.clone()); + let mercuryo = MercuryoClient::new(request_client.clone(), settings.mercuryo.key.public.clone(), settings.mercuryo.key.secret.clone()); + let transak = TransakClient::new( + request_client.clone(), + settings.transak.key.public, + settings.transak.key.secret, + settings.transak.referrer_domain, + ); + let banxa = BanxaClient::new(request_client.clone(), settings.banxa.url, settings.banxa.key.public, settings.banxa.key.secret); + let paybis = PaybisClient::new(request_client.clone(), settings.paybis.key.public, settings.paybis.key.secret); + let flashnet = FlashnetClient::new(request_client.clone(), settings.flashnet.url, settings.flashnet.key.secret, settings.flashnet.key.public); + + vec![ + Box::new(moonpay), + Box::new(mercuryo), + Box::new(transak), + Box::new(banxa), + Box::new(paybis), + Box::new(flashnet), + ] + } + + pub fn new_ip_check_client(settings: Settings) -> IPCheckClient { + let request_client = crate::client::FiatClient::request_client(settings.fiat.timeout); + let moonpay = MoonPayClient::new(request_client.clone(), settings.moonpay.key.public.clone(), settings.moonpay.key.secret.clone()); + IPCheckClient::new(moonpay) + } +} diff --git a/core/crates/fiat/src/model.rs b/core/crates/fiat/src/model.rs new file mode 100644 index 0000000000..82c57a5fb1 --- /dev/null +++ b/core/crates/fiat/src/model.rs @@ -0,0 +1,80 @@ +use chain_primitives::format_token_id; +use primitives::fiat_assets::FiatAssetLimits; +use primitives::{ + Asset, AssetId, Chain, CosmosDenom, FiatAssetSymbol, FiatProviderName, + asset_constants::WORLD_WETH_TOKEN_ID, + contract_constants::{EVM_ZERO_ADDRESS, SOLANA_SYSTEM_PROGRAM_ID}, +}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct FiatMapping { + pub asset: Asset, + pub asset_symbol: FiatAssetSymbol, + pub unsupported_countries: HashMap>, + pub buy_limits: Vec, + pub sell_limits: Vec, +} + +#[derive(Debug, Clone)] +pub struct FiatProviderAsset { + pub id: String, + pub provider: FiatProviderName, + pub chain: Option, + pub symbol: String, + pub token_id: Option, + pub network: Option, + pub enabled: bool, + pub is_buy_enabled: bool, + pub is_sell_enabled: bool, + pub unsupported_countries: Option>>, + pub buy_limits: Vec, + pub sell_limits: Vec, +} + +impl FiatProviderAsset { + pub fn asset_id(&self) -> Option { + match self.clone().chain { + Some(chain) => match &self.token_id { + Some(token_id) => format_token_id(chain, token_id.to_string()).map(|formatted_token_id| AssetId::from(chain, Some(formatted_token_id))), + None => Some(chain.as_asset_id()), + }, + None => None, + } + } +} + +impl FiatMapping { + pub fn get_network(network: Option) -> Result { + network.ok_or_else(|| crate::error::FiatQuoteError::InvalidRequest("Missing network".to_string())) + } +} + +pub type FiatMappingMap = HashMap; + +// used to filter out fiat tokens that have specific token ids for native coins +pub fn filter_token_id(chain: Option, token_id: Option) -> Option { + let token_id = token_id.filter(|contract_address| { + ![ + "0x0000000000000000000000000000000000001010", // matic + EVM_ZERO_ADDRESS, + "0x471ece3750da237f93b8e339c536989b8978a438", // celo + WORLD_WETH_TOKEN_ID, // worldchain + CosmosDenom::Uosmo.as_ref(), // osmosis + CosmosDenom::Usei.as_ref(), // sei + CosmosDenom::Inj.as_ref(), // osmosis + CosmosDenom::Uusdc.as_ref(), // noble + CosmosDenom::Uatom.as_ref(), // atom + CosmosDenom::Rune.as_ref(), // rune + CosmosDenom::Utia.as_ref(), // celestia + SOLANA_SYSTEM_PROGRAM_ID, + ] + .contains(&contract_address.as_str()) + }); + if let Some(chain) = chain + && let Some(token_id) = token_id + { + return format_token_id(chain, token_id); + } + token_id +} diff --git a/core/crates/fiat/src/provider.rs b/core/crates/fiat/src/provider.rs new file mode 100644 index 0000000000..25fd2647d7 --- /dev/null +++ b/core/crates/fiat/src/provider.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use crate::model::{FiatMapping, FiatProviderAsset}; +use async_trait::async_trait; +use primitives::{FiatProviderCountry, FiatProviderName, FiatQuoteRequest, FiatQuoteResponse, FiatQuoteUrl, FiatQuoteUrlData, FiatTransactionUpdate, PaymentType}; +use streamer::FiatWebhook; + +pub(crate) fn generate_quote_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +#[async_trait] +pub trait FiatProvider: Send + Sync { + fn name(&self) -> FiatProviderName; + + async fn get_assets(&self) -> Result, Box>; + async fn get_countries(&self) -> Result, Box>; + async fn get_order_status(&self, _order_id: &str) -> Result> { + Err(format!("provider {} does not support order status fetch", self.name().id()).into()) + } + + async fn process_webhook(&self, data: serde_json::Value) -> Result>; + + async fn get_quote_buy(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result>; + async fn get_quote_sell(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result>; + async fn get_quote_url(&self, data: FiatQuoteUrlData) -> Result>; + + async fn payment_methods(&self) -> Vec { + vec![PaymentType::Card, PaymentType::ApplePay, PaymentType::GooglePay] + } +} + +#[async_trait] +impl FiatProvider for Arc +where + T: FiatProvider + ?Sized, +{ + fn name(&self) -> FiatProviderName { + (**self).name() + } + + async fn get_assets(&self) -> Result, Box> { + (**self).get_assets().await + } + async fn get_countries(&self) -> Result, Box> { + (**self).get_countries().await + } + + async fn get_order_status(&self, order_id: &str) -> Result> { + (**self).get_order_status(order_id).await + } + + async fn process_webhook(&self, data: serde_json::Value) -> Result> { + (**self).process_webhook(data).await + } + + async fn get_quote_buy(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + (**self).get_quote_buy(request, request_map).await + } + + async fn get_quote_sell(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + (**self).get_quote_sell(request, request_map).await + } + + async fn get_quote_url(&self, data: FiatQuoteUrlData) -> Result> { + (**self).get_quote_url(data).await + } + + async fn payment_methods(&self) -> Vec { + (**self).payment_methods().await + } +} + +#[cfg(test)] +mod tests { + use super::generate_quote_id; + use uuid::Uuid; + + #[test] + fn generate_quote_id_returns_uuid() { + let quote_id = generate_quote_id(); + assert!(Uuid::parse_str("e_id).is_ok()); + } +} diff --git a/core/crates/fiat/src/providers/banxa/client.rs b/core/crates/fiat/src/providers/banxa/client.rs new file mode 100644 index 0000000000..b198e47daa --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/client.rs @@ -0,0 +1,86 @@ +use std::error::Error; + +use primitives::FiatProviderName; +use reqwest::{ + Client, + header::{HeaderMap, HeaderValue}, +}; + +use super::models::{Asset, CheckoutOrder, Country, CreateOrderRequest, FiatCurrency, Order, PAYMENT_METHOD_CARD, Quote}; + +const API_URL: &str = "https://api.banxa.com"; +const BUY_ORDER_TYPE: &str = "buy"; + +pub struct BanxaClient { + pub client: Client, + pub url: String, + pub merchant_key: String, + pub secret_key: String, +} + +impl BanxaClient { + pub const NAME: FiatProviderName = FiatProviderName::Banxa; + + pub fn new(client: Client, url: String, merchant_key: String, secret_key: String) -> Self { + Self { + client, + url, + merchant_key, + secret_key, + } + } + + fn headers(&self) -> Result> { + let mut headers = HeaderMap::new(); + headers.insert("x-api-key", HeaderValue::from_str(self.secret_key.as_str())?); + Ok(headers) + } + + pub async fn get_assets_buy(&self) -> Result, Box> { + let url = format!("{API_URL}/{}/v2/crypto/{BUY_ORDER_TYPE}", self.merchant_key); + Ok(self.client.get(&url).headers(self.headers()?).send().await?.json().await?) + } + + pub async fn get_order(&self, order_id: &str) -> Result> { + let url = format!("{API_URL}/{}/v2/orders/{order_id}", self.merchant_key); + Ok(self.client.get(&url).headers(self.headers()?).send().await?.json().await?) + } + + pub async fn get_quote_buy(&self, symbol: &str, chain: &str, fiat_currency: &str, fiat_amount: f64) -> Result> { + let fiat_amount = fiat_amount.to_string(); + let query = vec![ + ("paymentMethodId", PAYMENT_METHOD_CARD), + ("crypto", symbol), + ("blockchain", chain), + ("fiat", fiat_currency), + ("fiatAmount", fiat_amount.as_str()), + ]; + let url = format!("{API_URL}/{}/v2/quotes/buy", self.merchant_key); + Ok(self.client.get(&url).query(&query).headers(self.headers()?).send().await?.json().await?) + } + + pub async fn get_countries(&self) -> Result, Box> { + let url = format!("{API_URL}/{}/v2/countries", self.merchant_key); + Ok(self.client.get(&url).headers(self.headers()?).send().await?.json().await?) + } + + pub async fn get_fiat_currencies_buy(&self) -> Result, Box> { + let url = format!("{API_URL}/{}/v2/fiats/{BUY_ORDER_TYPE}", self.merchant_key); + Ok(self.client.get(&url).headers(self.headers()?).send().await?.json().await?) + } + + pub async fn create_buy_order( + &self, + quote_id: String, + fiat_amount: f64, + fiat_currency: String, + symbol: String, + network: String, + wallet_address: String, + ) -> Result> { + let request = CreateOrderRequest::new(quote_id, symbol, fiat_currency, fiat_amount, network, wallet_address, self.url.clone()); + let url = format!("{API_URL}/{}/v2/buy", self.merchant_key); + let response = self.client.post(&url).headers(self.headers()?).json(&request).send().await?.error_for_status()?; + response.json().await.map_err(|e| e.into()) + } +} diff --git a/core/crates/fiat/src/providers/banxa/mapper.rs b/core/crates/fiat/src/providers/banxa/mapper.rs new file mode 100644 index 0000000000..6cc7275579 --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/mapper.rs @@ -0,0 +1,215 @@ +use crate::model::{FiatProviderAsset, filter_token_id}; +use primitives::currency::Currency; +use primitives::fiat_assets::FiatAssetLimits; +use primitives::{Chain, FiatProviderName, FiatTransactionStatus, FiatTransactionUpdate, PaymentType}; + +use super::models::{Asset, FiatCurrency, Order}; + +pub fn map_asset_chain(chain: &str) -> Option { + match chain { + "BTC" => Some(Chain::Bitcoin), + "LTC" => Some(Chain::Litecoin), + "ETH" => Some(Chain::Ethereum), + "TRON" => Some(Chain::Tron), + "BSC" | "BNB" => Some(Chain::SmartChain), + "SOL" => Some(Chain::Solana), + "MATIC" => Some(Chain::Polygon), + "ATOM" => Some(Chain::Cosmos), + "AVAX-C" => Some(Chain::AvalancheC), + "XRP" => Some(Chain::Xrp), + "FTM" => Some(Chain::Fantom), + "DOGE" => Some(Chain::Doge), + "APT" => Some(Chain::Aptos), + "TON" => Some(Chain::Ton), + "SUI" => Some(Chain::Sui), + "NEAR" => Some(Chain::Near), + "CELO" => Some(Chain::Celo), + "THORCHAIN" => Some(Chain::Thorchain), + "XLM" => Some(Chain::Stellar), + "ADA" => Some(Chain::Cardano), + "DOT" => Some(Chain::Polkadot), + "ALGO" => Some(Chain::Algorand), + "ZKSYNC" => Some(Chain::ZkSync), + "BCH" => Some(Chain::BitcoinCash), + "WLD" => Some(Chain::World), + "OPTIMISM" => Some(Chain::Optimism), + "LINEA" => Some(Chain::Linea), + "UNICHAIN" => Some(Chain::Unichain), + "ARB" => Some(Chain::Arbitrum), + "BASE" => Some(Chain::Base), + "S" => Some(Chain::Sonic), + "INJ" => Some(Chain::Injective), + "MNT" => Some(Chain::Mantle), + _ => None, + } +} + +pub fn map_order(order: Order) -> Result> { + match order.order_type.as_str() { + "BUY" | "SELL" => {} + _ => return Err(format!("Unsupported Banxa order type: {}", order.order_type).into()), + } + + let provider_order_id = order.id.clone(); + let transaction_id = order.external_order_id.clone().unwrap_or_else(|| provider_order_id.clone()); + let provider_transaction_id = (transaction_id != provider_order_id).then_some(provider_order_id); + + Ok(FiatTransactionUpdate { + transaction_id, + provider_transaction_id, + status: map_status(&order.status), + transaction_hash: order.transaction_hash, + fiat_amount: Some(order.fiat_amount), + fiat_currency: Some(order.fiat.to_ascii_uppercase()), + }) +} + +fn map_status(status: &str) -> FiatTransactionStatus { + match status { + "pendingPayment" | "waitingPayment" | "paymentReceived" | "inProgress" | "coinTransferred" | "cryptoTransferred" | "extraVerification" => FiatTransactionStatus::Pending, + "cancelled" | "declined" | "expired" | "refunded" => FiatTransactionStatus::Failed, + "complete" | "completed" | "succeeded" => FiatTransactionStatus::Complete, + _ => FiatTransactionStatus::Unknown, + } +} + +fn map_asset_base(asset: Asset, buy_limits: Vec, sell_limits: Vec) -> Vec { + let symbol = asset.id.clone(); + let is_buy_enabled = !buy_limits.is_empty(); + let is_sell_enabled = !sell_limits.is_empty(); + + asset + .blockchains + .into_iter() + .map(|blockchain| { + let chain = map_asset_chain(blockchain.id.as_str()); + let token_id = filter_token_id(chain, blockchain.address); + + FiatProviderAsset { + id: format!("{symbol}-{}", blockchain.id), + provider: FiatProviderName::Banxa, + chain, + symbol: symbol.clone(), + token_id, + network: Some(blockchain.id), + enabled: true, + is_buy_enabled, + is_sell_enabled, + unsupported_countries: Some(blockchain.unsupported_countries.list_map()), + buy_limits: buy_limits.clone(), + sell_limits: sell_limits.clone(), + } + }) + .collect() +} + +pub fn map_asset_with_limits(asset: Asset, buy_fiat_currencies: &[FiatCurrency], sell_fiat_currencies: &[FiatCurrency]) -> Vec { + map_asset_base(asset, map_limits(buy_fiat_currencies), map_limits(sell_fiat_currencies)) +} + +fn map_limits(fiat_currencies: &[FiatCurrency]) -> Vec { + fiat_currencies + .iter() + .filter_map(|fiat_currency| fiat_currency.id.parse::().ok().map(|currency| (currency, fiat_currency))) + .flat_map(|(currency, fiat_currency)| { + fiat_currency + .supported_payment_methods + .iter() + .filter_map(|payment_method| { + let payment_type = map_payment_type(payment_method.id.as_str())?; + + Some(FiatAssetLimits { + currency: currency.clone(), + payment_type, + min_amount: Some(payment_method.minimum), + max_amount: Some(payment_method.maximum), + }) + }) + .collect::>() + }) + .collect() +} + +fn map_payment_type(payment_id: &str) -> Option { + match payment_id { + "debit-credit-card" => Some(PaymentType::Card), + "google-pay" => Some(PaymentType::GooglePay), + "apple-pay" => Some(PaymentType::ApplePay), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::providers::banxa::models::{FiatCurrency, Order}; + use primitives::currency::Currency; + use primitives::{FiatTransactionStatus, FiatTransactionUpdate, PaymentType}; + + use super::{map_limits, map_order}; + + #[test] + fn map_order_maps_sell_failure() { + let response: Order = serde_json::from_str(include_str!("../../../testdata/banxa/transaction_sell_failed.json")).unwrap(); + let result = map_order(response).unwrap(); + + assert_eq!( + result, + FiatTransactionUpdate { + transaction_id: "123".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Failed, + transaction_hash: None, + fiat_amount: Some(595.3), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn map_order_prefers_external_order_id_for_reconciliation() { + let result = map_order(Order { + id: "banxa_order_123".to_string(), + external_order_id: Some("quote_123".to_string()), + status: "completed".to_string(), + fiat: "usd".to_string(), + fiat_amount: 100.0, + transaction_hash: Some("tx_hash".to_string()), + order_type: "BUY".to_string(), + }) + .unwrap(); + + assert_eq!( + result, + FiatTransactionUpdate { + transaction_id: "quote_123".to_string(), + provider_transaction_id: Some("banxa_order_123".to_string()), + status: FiatTransactionStatus::Complete, + transaction_hash: Some("tx_hash".to_string()), + fiat_amount: Some(100.0), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn map_limits_maps_supported_payment_methods() { + let fiat_currencies: Vec = serde_json::from_str(include_str!("../../../testdata/banxa/fiat_currencies.json")).unwrap(); + let buy_limits = map_limits(&fiat_currencies); + + assert_eq!(buy_limits.len(), 4); + + let eur_card_limit = buy_limits + .iter() + .find(|limit| limit.currency == Currency::EUR && limit.payment_type == PaymentType::Card) + .unwrap(); + assert_eq!(eur_card_limit.min_amount, Some(20.0)); + assert_eq!(eur_card_limit.max_amount, Some(15000.0)); + + let usd_google_pay_limit = buy_limits + .iter() + .find(|limit| limit.currency == Currency::USD && limit.payment_type == PaymentType::GooglePay) + .unwrap(); + assert_eq!(usd_google_pay_limit.min_amount, Some(20.0)); + assert_eq!(usd_google_pay_limit.max_amount, Some(15000.0)); + } +} diff --git a/core/crates/fiat/src/providers/banxa/mod.rs b/core/crates/fiat/src/providers/banxa/mod.rs new file mode 100644 index 0000000000..ae5586f14f --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod mapper; +pub mod models; +pub mod provider; diff --git a/core/crates/fiat/src/providers/banxa/models/asset.rs b/core/crates/fiat/src/providers/banxa/models/asset.rs new file mode 100644 index 0000000000..15a1f70cb9 --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/models/asset.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Deserialize, Clone)] +pub struct Asset { + pub id: String, + pub blockchains: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Blockchain { + pub id: String, + pub address: Option, + pub unsupported_countries: UnsupportedCountries, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub enum UnsupportedCountries { + Map(HashMap>), + Empty(Vec<()>), +} + +impl UnsupportedCountries { + pub fn list_map(self) -> HashMap> { + match self { + UnsupportedCountries::Map(map) => map, + UnsupportedCountries::Empty(_) => HashMap::new(), + } + } +} diff --git a/core/crates/fiat/src/providers/banxa/models/country.rs b/core/crates/fiat/src/providers/banxa/models/country.rs new file mode 100644 index 0000000000..f9b905eaa4 --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/models/country.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Country { + pub id: String, +} diff --git a/core/crates/fiat/src/providers/banxa/models/create_order.rs b/core/crates/fiat/src/providers/banxa/models/create_order.rs new file mode 100644 index 0000000000..10ac4d266f --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/models/create_order.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +pub const PAYMENT_METHOD_CARD: &str = "debit-credit-card"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateOrderRequest { + pub payment_method_id: String, + pub crypto: String, + pub blockchain: String, + pub fiat: String, + pub fiat_amount: String, + pub wallet_address: String, + pub redirect_url: String, + pub external_customer_id: String, + pub external_order_id: String, +} + +impl CreateOrderRequest { + pub fn new(external_order_id: String, crypto: String, fiat: String, fiat_amount: f64, blockchain: String, wallet_address: String, redirect_url: String) -> Self { + Self { + payment_method_id: PAYMENT_METHOD_CARD.to_string(), + crypto, + blockchain, + fiat, + fiat_amount: fiat_amount.to_string(), + external_customer_id: wallet_address.clone(), + wallet_address, + redirect_url, + external_order_id, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckoutOrder { + pub id: String, + pub checkout_url: String, +} diff --git a/core/crates/fiat/src/providers/banxa/models/fiat_currencies.rs b/core/crates/fiat/src/providers/banxa/models/fiat_currencies.rs new file mode 100644 index 0000000000..10cbf0c12b --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/models/fiat_currencies.rs @@ -0,0 +1,19 @@ +use serde::Deserialize; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FiatCurrency { + pub id: String, + pub supported_payment_methods: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FiatPaymentMethod { + pub id: String, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub minimum: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub maximum: f64, +} diff --git a/core/crates/fiat/src/providers/banxa/models/mod.rs b/core/crates/fiat/src/providers/banxa/models/mod.rs new file mode 100644 index 0000000000..1dec95eec4 --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/models/mod.rs @@ -0,0 +1,15 @@ +pub mod asset; +pub mod country; +pub mod create_order; +pub mod fiat_currencies; +pub mod order; +pub mod quote; +pub mod webhook; + +pub use asset::*; +pub use country::*; +pub use create_order::*; +pub use fiat_currencies::*; +pub use order::Order; +pub use quote::*; +pub use webhook::*; diff --git a/core/crates/fiat/src/providers/banxa/models/order.rs b/core/crates/fiat/src/providers/banxa/models/order.rs new file mode 100644 index 0000000000..46723ae17c --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/models/order.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Order { + pub id: String, + pub external_order_id: Option, + pub status: String, + pub fiat: String, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub fiat_amount: f64, + pub transaction_hash: Option, + pub order_type: String, +} diff --git a/core/crates/fiat/src/providers/banxa/models/quote.rs b/core/crates/fiat/src/providers/banxa/models/quote.rs new file mode 100644 index 0000000000..2fa95448dd --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/models/quote.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Quote { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub crypto_amount: f64, +} diff --git a/core/crates/fiat/src/providers/banxa/models/webhook.rs b/core/crates/fiat/src/providers/banxa/models/webhook.rs new file mode 100644 index 0000000000..ae20d5d384 --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/models/webhook.rs @@ -0,0 +1,6 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct Webhook { + pub order_id: String, +} diff --git a/core/crates/fiat/src/providers/banxa/provider.rs b/core/crates/fiat/src/providers/banxa/provider.rs new file mode 100644 index 0000000000..0ff0ddf8c9 --- /dev/null +++ b/core/crates/fiat/src/providers/banxa/provider.rs @@ -0,0 +1,136 @@ +use async_trait::async_trait; +use primitives::{FiatProviderCountry, FiatProviderName, FiatQuoteRequest, FiatQuoteResponse, FiatQuoteType, FiatQuoteUrl, FiatQuoteUrlData, FiatTransactionUpdate}; +use streamer::FiatWebhook; + +use crate::{ + FiatProvider, + model::{FiatMapping, FiatProviderAsset}, + provider::generate_quote_id, + providers::banxa::mapper::map_asset_with_limits, +}; + +use super::{client::BanxaClient, mapper::map_order, models::Webhook}; + +#[async_trait] +impl FiatProvider for BanxaClient { + fn name(&self) -> FiatProviderName { + Self::NAME + } + + async fn get_assets(&self) -> Result, Box> { + let (assets, buy_fiat_currencies) = tokio::try_join!(self.get_assets_buy(), self.get_fiat_currencies_buy())?; + Ok(assets.into_iter().flat_map(|asset| map_asset_with_limits(asset, &buy_fiat_currencies, &[])).collect()) + } + + async fn get_countries(&self) -> Result, Box> { + Ok(self + .get_countries() + .await? + .into_iter() + .map(|country| FiatProviderCountry { + provider: Self::NAME, + alpha2: country.id, + is_allowed: true, + }) + .collect()) + } + + async fn get_order_status(&self, order_id: &str) -> Result> { + let order = self.get_order(order_id).await?; + map_order(order) + } + + async fn process_webhook(&self, data: serde_json::Value) -> Result> { + let order_id = serde_json::from_value::(data)?.order_id; + Ok(FiatWebhook::OrderId(order_id)) + } + + async fn get_quote_buy(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let network = FiatMapping::get_network(request_map.asset_symbol.network)?; + let quote = self.get_quote_buy(&request_map.asset_symbol.symbol, &network, &request.currency, request.amount).await?; + + Ok(FiatQuoteResponse::new(generate_quote_id(), request.amount, quote.crypto_amount)) + } + + async fn get_quote_sell(&self, _request: FiatQuoteRequest, _request_map: FiatMapping) -> Result> { + Err("not supported".into()) + } + + async fn get_quote_url(&self, data: FiatQuoteUrlData) -> Result> { + match data.quote.quote_type { + FiatQuoteType::Buy => { + let network = FiatMapping::get_network(data.asset_symbol.network)?; + let order = self + .create_buy_order( + data.quote.id, + data.quote.fiat_amount, + data.quote.fiat_currency, + data.asset_symbol.symbol, + network, + data.wallet_address, + ) + .await?; + + Ok(FiatQuoteUrl { + redirect_url: order.checkout_url, + provider_transaction_id: Some(order.id), + }) + } + FiatQuoteType::Sell => Err("not supported".into()), + } + } +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +mod fiat_integration_tests { + use crate::testkit::create_banxa_test_client; + use crate::{FiatProvider, model::FiatMapping}; + use primitives::{FiatProviderName, FiatQuoteRequest}; + + #[tokio::test] + async fn test_banxa_get_buy_quote() -> Result<(), Box> { + let client = create_banxa_test_client(); + let request = FiatQuoteRequest::mock(); + let mut mapping = FiatMapping::mock(); + mapping.asset_symbol.network = Some("BTC".to_string()); + + let quote = FiatProvider::get_quote_buy(&client, request.clone(), mapping).await?; + + assert!(!quote.quote_id.is_empty()); + assert!(quote.crypto_amount > 0.0); + assert_eq!(quote.fiat_amount, request.amount); + + Ok(()) + } + + #[tokio::test] + async fn test_banxa_get_assets() -> Result<(), Box> { + let client = create_banxa_test_client(); + let assets = FiatProvider::get_assets(&client).await?; + + assert!(!assets.is_empty()); + + let assets_with_buy_limits = assets.iter().filter(|asset| !asset.buy_limits.is_empty()).count(); + assert!(assets_with_buy_limits > 0); + + let asset_with_limits = assets.iter().find(|asset| !asset.buy_limits.is_empty()).unwrap(); + let first_limit = asset_with_limits.buy_limits.first().unwrap(); + assert!(first_limit.min_amount.is_some() || first_limit.max_amount.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_banxa_get_countries() -> Result<(), Box> { + let client = create_banxa_test_client(); + let countries = FiatProvider::get_countries(&client).await?; + + assert!(!countries.is_empty()); + + let country = countries.first().unwrap(); + assert_eq!(country.provider, FiatProviderName::Banxa); + assert!(!country.alpha2.is_empty()); + + Ok(()) + } +} diff --git a/core/crates/fiat/src/providers/flashnet/client.rs b/core/crates/fiat/src/providers/flashnet/client.rs new file mode 100644 index 0000000000..6db1d5f26d --- /dev/null +++ b/core/crates/fiat/src/providers/flashnet/client.rs @@ -0,0 +1,62 @@ +use std::error::Error; + +use gem_client::json_response; +use primitives::FiatProviderName; +use reqwest::Client; + +use super::model::{FlashnetEstimateResponse, FlashnetOnrampRequest, FlashnetOnrampResponse, FlashnetRoutesResponse}; + +pub struct FlashnetClient { + client: Client, + base_url: String, + api_key: String, + pub(crate) affiliate_id: String, +} + +impl FlashnetClient { + pub const NAME: FiatProviderName = FiatProviderName::Flashnet; + + pub fn new(client: Client, base_url: String, api_key: String, affiliate_id: String) -> Self { + Self { + client, + base_url, + api_key, + affiliate_id, + } + } + + pub async fn get_routes(&self) -> Result> { + let response = self.client.get(format!("{}/v1/orchestration/routes", self.base_url)).send().await?; + Ok(json_response(response).await?) + } + + pub async fn create_onramp(&self, request: FlashnetOnrampRequest, idempotency_key: &str) -> Result> { + let response = self + .client + .post(format!("{}/v1/orchestration/onramp", self.base_url)) + .bearer_auth(&self.api_key) + .header("X-Idempotency-Key", idempotency_key) + .json(&request) + .send() + .await?; + Ok(json_response(response).await?) + } + + pub async fn get_estimate(&self, destination_chain: &str, destination_asset: &str, amount: &str) -> Result> { + let response = self + .client + .get(format!("{}/v1/orchestration/estimate", self.base_url)) + .bearer_auth(&self.api_key) + .query(&[ + ("sourceChain", "spark"), + ("sourceAsset", "USDB"), + ("destinationChain", destination_chain), + ("destinationAsset", destination_asset), + ("amount", amount), + ("affiliateId", self.affiliate_id.as_str()), + ]) + .send() + .await?; + Ok(json_response(response).await?) + } +} diff --git a/core/crates/fiat/src/providers/flashnet/mapper.rs b/core/crates/fiat/src/providers/flashnet/mapper.rs new file mode 100644 index 0000000000..7ad6e99dd8 --- /dev/null +++ b/core/crates/fiat/src/providers/flashnet/mapper.rs @@ -0,0 +1,399 @@ +use std::io; + +use std::collections::{BTreeMap, HashMap}; + +use number_formatter::{BigNumberFormatter, NumberFormatterError}; +use primitives::currency::Currency; +use primitives::fiat_assets::FiatAssetLimits; +use primitives::{Asset, Chain, FiatTransactionStatus, FiatTransactionUpdate, PaymentType}; +use streamer::FiatWebhook; + +use crate::model::{FiatProviderAsset, filter_token_id}; + +use super::{ + client::FlashnetClient, + model::{FlashnetOnrampResponse, FlashnetOrder, FlashnetRoute, FlashnetWebhookPayload}, +}; + +fn map_chain(chain: &str) -> Option { + match chain { + "bitcoin" => Some(Chain::Bitcoin), + "solana" => Some(Chain::Solana), + "base" => Some(Chain::Base), + "ethereum" => Some(Chain::Ethereum), + "arbitrum" => Some(Chain::Arbitrum), + "optimism" => Some(Chain::Optimism), + "polygon" => Some(Chain::Polygon), + "tron" => Some(Chain::Tron), + "plasma" => Some(Chain::Plasma), + _ => None, + } +} + +pub fn map_assets(routes: Vec) -> Vec { + routes + .into_iter() + .filter_map(map_asset) + .fold(BTreeMap::new(), |mut assets, asset| { + assets.entry(asset.id.clone()).or_insert(asset); + assets + }) + .into_values() + .collect() +} + +fn map_asset(route: FlashnetRoute) -> Option { + let destination = route.destination; + let chain = map_chain(&destination.chain)?; + let token_id = filter_token_id(Some(chain), destination.contract_address); + let symbol = destination.asset; + if token_id.is_none() && symbol != Asset::from_chain(chain).symbol { + return None; + } + let network = destination.chain; + + Some(FiatProviderAsset { + id: format!("{}_{}", symbol.to_ascii_lowercase(), network), + provider: FlashnetClient::NAME, + chain: Some(chain), + symbol, + token_id, + network: Some(network), + enabled: true, + is_buy_enabled: true, + is_sell_enabled: false, + unsupported_countries: Some(HashMap::new()), + buy_limits: vec![FiatAssetLimits { + currency: Currency::USD, + payment_type: PaymentType::CashApp, + min_amount: Some(1.0), + max_amount: Some(500.0), + }], + sell_limits: vec![], + }) +} + +const USDB_DECIMALS: u32 = 6; + +pub fn map_amount(amount: f64, decimals: u32) -> String { + let value = amount * 10f64.powi(decimals as i32); + (value.round() as u64).to_string() +} + +pub fn map_source_amount(fiat_amount: f64) -> String { + map_amount(fiat_amount, USDB_DECIMALS) +} + +pub fn map_crypto_amount(estimated_out: &str, decimals: u32) -> Result { + BigNumberFormatter::value_as_f64(estimated_out, decimals) +} + +pub fn map_redirect_url(response: &FlashnetOnrampResponse) -> String { + response.payment_links.cash_app.clone() +} + +pub fn map_webhook(payload: FlashnetWebhookPayload) -> Result { + match payload.event.as_str() { + "order.processing" + | "order.confirming" + | "order.bridging" + | "order.swapping" + | "order.awaiting_approval" + | "order.refunding" + | "order.delivering" + | "order.completed" + | "order.failed" + | "order.refunded" => Ok(FiatWebhook::Transaction(map_order( + payload.data.into_order().ok_or_else(|| io::Error::other("Missing Flashnet order fields in webhook"))?, + ))), + _ => Ok(FiatWebhook::None), + } +} + +pub fn map_order(order: FlashnetOrder) -> FiatTransactionUpdate { + let transaction_id = order.id.clone(); + + FiatTransactionUpdate { + transaction_id, + provider_transaction_id: None, + status: map_status(&order.status), + transaction_hash: order.destination_tx_hash().map(str::to_string), + fiat_amount: None, + fiat_currency: Some(Currency::USD.to_string()), + } +} + +fn map_status(status: &str) -> FiatTransactionStatus { + match status { + "processing" | "confirming" | "bridging" | "swapping" | "awaiting_approval" | "refunding" | "delivering" => FiatTransactionStatus::Pending, + "completed" => FiatTransactionStatus::Complete, + "failed" | "refunded" => FiatTransactionStatus::Failed, + _ => FiatTransactionStatus::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::flashnet::model::{FlashnetOrder, FlashnetRoute, FlashnetRouteAsset}; + use primitives::FiatTransactionStatus; + + #[test] + fn map_status_maps_all_documented_flashnet_statuses() { + for status in ["processing", "confirming", "bridging", "swapping", "awaiting_approval", "refunding", "delivering"] { + assert_eq!(map_status(status), FiatTransactionStatus::Pending); + } + + assert_eq!(map_status("completed"), FiatTransactionStatus::Complete); + assert_eq!(map_status("failed"), FiatTransactionStatus::Failed); + assert_eq!(map_status("refunded"), FiatTransactionStatus::Failed); + + assert_eq!(map_status("unexpected"), FiatTransactionStatus::Unknown); + } + + #[test] + fn map_amount_converts_to_smallest_units() { + assert_eq!(map_amount(100.0, 6), "100000000"); + assert_eq!(map_amount(1.0, 6), "1000000"); + assert_eq!(map_amount(0.5, 6), "500000"); + assert_eq!(map_amount(50.0, 8), "5000000000"); + assert_eq!(map_amount(500.0, 6), "500000000"); + } + + #[test] + fn map_source_amount_uses_usdb_decimals() { + assert_eq!(map_source_amount(100.0), "100000000"); + assert_eq!(map_source_amount(1.0), "1000000"); + } + + #[test] + fn map_crypto_amount_parses_valid_values_and_rejects_invalid_ones() { + assert_eq!(map_crypto_amount("1000000", 6).unwrap(), 1.0); + assert_eq!(map_crypto_amount("500000", 6).unwrap(), 0.5); + assert!(map_crypto_amount("invalid", 6).is_err()); + } + + #[test] + fn map_assets_deduplicates_duplicate_destination_routes() { + let routes = vec![ + FlashnetRoute { + source_chain: "lightning".to_string(), + source_asset: "BTC".to_string(), + destination: FlashnetRouteAsset { + chain: "solana".to_string(), + asset: "USDC".to_string(), + contract_address: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), + }, + }, + FlashnetRoute { + source_chain: "lightning".to_string(), + source_asset: "BTC".to_string(), + destination: FlashnetRouteAsset { + chain: "solana".to_string(), + asset: "USDC".to_string(), + contract_address: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), + }, + }, + FlashnetRoute { + source_chain: "lightning".to_string(), + source_asset: "BTC".to_string(), + destination: FlashnetRouteAsset { + chain: "base".to_string(), + asset: "USDC".to_string(), + contract_address: Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913".to_string()), + }, + }, + ]; + + let assets = map_assets(routes); + + assert_eq!(assets.len(), 2); + assert_eq!(assets[0].id, "usdc_base"); + assert_eq!(assets[1].id, "usdc_solana"); + } + + #[test] + fn map_assets_ignores_non_native_null_contract_assets() { + let routes = vec![ + FlashnetRoute { + source_chain: "lightning".to_string(), + source_asset: "BTC".to_string(), + destination: FlashnetRouteAsset { + chain: "solana".to_string(), + asset: "SOL".to_string(), + contract_address: None, + }, + }, + FlashnetRoute { + source_chain: "lightning".to_string(), + source_asset: "BTC".to_string(), + destination: FlashnetRouteAsset { + chain: "solana".to_string(), + asset: "HSUSD".to_string(), + contract_address: None, + }, + }, + FlashnetRoute { + source_chain: "lightning".to_string(), + source_asset: "BTC".to_string(), + destination: FlashnetRouteAsset { + chain: "solana".to_string(), + asset: "USDC".to_string(), + contract_address: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), + }, + }, + ]; + + let assets = map_assets(routes); + let ids = assets.iter().map(|asset| asset.id.as_str()).collect::>(); + let solana = assets.iter().find(|asset| asset.id == "sol_solana").unwrap(); + let usdc = assets.iter().find(|asset| asset.id == "usdc_solana").unwrap(); + + assert_eq!(ids, vec!["sol_solana", "usdc_solana"]); + assert_eq!(solana.asset_id(), Some(primitives::AssetId::from_chain(Chain::Solana))); + assert_eq!( + usdc.asset_id(), + Some(primitives::AssetId::from_token(Chain::Solana, "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")) + ); + } + + #[test] + fn map_assets_ignores_unsupported_usdc_chains() { + let routes = vec![ + FlashnetRoute { + source_chain: "lightning".to_string(), + source_asset: "BTC".to_string(), + destination: FlashnetRouteAsset { + chain: "base".to_string(), + asset: "USDC".to_string(), + contract_address: Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913".to_string()), + }, + }, + FlashnetRoute { + source_chain: "lightning".to_string(), + source_asset: "BTC".to_string(), + destination: FlashnetRouteAsset { + chain: "tempo".to_string(), + asset: "USDC".to_string(), + contract_address: Some("0x20c000000000000000000000b9537d11c60e8b50".to_string()), + }, + }, + ]; + + let assets = map_assets(routes); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0].id, "usdc_base"); + } + + #[test] + fn map_webhook_ignores_non_order_events() { + let data: serde_json::Value = serde_json::from_str(r#"{"event":"quote.updated","timestamp":"2026-03-13T00:00:00Z","data":{"id":"ord_123"}}"#).unwrap(); + let payload: FlashnetWebhookPayload = serde_json::from_value(data).unwrap(); + + match map_webhook(payload).unwrap() { + FiatWebhook::None => {} + payload => panic!("Expected ignored webhook, got {:?}", payload), + } + } + + #[test] + fn map_webhook_uses_embedded_order_fields_when_present() { + let payload: FlashnetWebhookPayload = serde_json::from_str(include_str!("../../../testdata/flashnet/webhook_completed.json")).unwrap(); + + match map_webhook(payload).unwrap() { + FiatWebhook::Transaction(transaction) => { + assert_eq!( + transaction, + FiatTransactionUpdate { + transaction_id: "ord_test_completed".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Complete, + transaction_hash: Some("solana_test_signature_completed".to_string()), + fiat_amount: None, + fiat_currency: Some("USD".to_string()), + } + ); + } + payload => panic!("Expected transaction webhook, got {:?}", payload), + } + } + + #[test] + fn map_webhook_returns_error_when_order_fields_are_missing() { + let data: serde_json::Value = serde_json::from_str(r#"{"event":"order.completed","timestamp":"2026-03-13T00:00:00Z","data":{"id":"ord_123"}}"#).unwrap(); + let payload: FlashnetWebhookPayload = serde_json::from_value(data).unwrap(); + + assert!(map_webhook(payload).is_err()); + } + + #[test] + fn map_order_supports_legacy_top_level_destination_fields() { + let order: FlashnetOrder = serde_json::from_str(include_str!("../../../testdata/flashnet/order_completed.json")).unwrap(); + + assert_eq!( + map_order(order), + FiatTransactionUpdate { + transaction_id: "ord_123".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Complete, + transaction_hash: Some("solana_sig_123".to_string()), + fiat_amount: None, + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn map_order_uses_nested_destination_fields() { + let payload: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/flashnet/webhook_completed.json")).unwrap(); + let order: FlashnetOrder = serde_json::from_value(payload["data"].clone()).unwrap(); + + assert_eq!( + map_order(order), + FiatTransactionUpdate { + transaction_id: "ord_test_completed".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Complete, + transaction_hash: Some("solana_test_signature_completed".to_string()), + fiat_amount: None, + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn map_order_ignores_payment_intent_target_amount_for_fiat_amount() { + let payload: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/flashnet/webhook_pending.json")).unwrap(); + let order: FlashnetOrder = serde_json::from_value(payload["data"].clone()).unwrap(); + + assert_eq!( + map_order(order), + FiatTransactionUpdate { + transaction_id: "ord_test_pending".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Pending, + transaction_hash: None, + fiat_amount: None, + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn map_order_does_not_use_crypto_amount_as_fiat_amount() { + let order: FlashnetOrder = serde_json::from_str(include_str!("../../../testdata/flashnet/order_completed_eth.json")).unwrap(); + + assert_eq!( + map_order(order), + FiatTransactionUpdate { + transaction_id: "ord_019de25e-59d8-7ca6-b5e1-39651db9717f".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Complete, + transaction_hash: Some("0xf3fa9ca081e1f97022352c80345b46ae5934b0fae68c76ab5ccc70773ef1443e".to_string()), + fiat_amount: None, + fiat_currency: Some("USD".to_string()), + } + ); + } +} diff --git a/core/crates/fiat/src/providers/flashnet/mod.rs b/core/crates/fiat/src/providers/flashnet/mod.rs new file mode 100644 index 0000000000..1c7669b8a6 --- /dev/null +++ b/core/crates/fiat/src/providers/flashnet/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod mapper; +pub mod model; +pub mod provider; diff --git a/core/crates/fiat/src/providers/flashnet/model.rs b/core/crates/fiat/src/providers/flashnet/model.rs new file mode 100644 index 0000000000..95c399f991 --- /dev/null +++ b/core/crates/fiat/src/providers/flashnet/model.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetOnrampRequest { + pub destination_chain: String, + pub destination_asset: String, + pub recipient_address: String, + pub amount: String, + pub amount_mode: String, + pub affiliate_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetOnrampResponse { + pub order_id: String, + pub payment_links: FlashnetPaymentLinks, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetPaymentLinks { + pub cash_app: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetRoutesResponse { + pub routes: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetEstimateResponse { + pub estimated_out: String, + pub fee_bps: u32, + pub app_fees: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetEstimateAppFee { + pub affiliate_id: String, + pub fee_bps: u32, + pub amount: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetRoute { + pub source_chain: String, + pub source_asset: String, + pub destination: FlashnetRouteAsset, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetRouteAsset { + pub chain: String, + pub asset: String, + pub contract_address: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetWebhookPayload { + pub event: String, + pub data: FlashnetWebhookData, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetWebhookData { + pub id: String, + pub status: Option, + pub destination: Option, +} + +impl FlashnetWebhookData { + pub fn into_order(self) -> Option { + let status = self.status?; + + Some(FlashnetOrder { + id: self.id, + status, + destination: self.destination, + }) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetOrder { + pub id: String, + pub status: String, + pub destination: Option, +} + +impl FlashnetOrder { + pub fn destination_tx_hash(&self) -> Option<&str> { + self.destination.as_ref().and_then(|destination| destination.tx_hash.as_deref()) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlashnetDestination { + pub tx_hash: Option, +} diff --git a/core/crates/fiat/src/providers/flashnet/provider.rs b/core/crates/fiat/src/providers/flashnet/provider.rs new file mode 100644 index 0000000000..84f5ee145f --- /dev/null +++ b/core/crates/fiat/src/providers/flashnet/provider.rs @@ -0,0 +1,125 @@ +use std::error::Error; + +use async_trait::async_trait; +use primitives::{FiatProviderCountry, FiatProviderName, FiatQuoteRequest, FiatQuoteResponse, FiatQuoteUrl, FiatQuoteUrlData, PaymentType}; +use streamer::FiatWebhook; + +use crate::FiatProvider; +use crate::model::{FiatMapping, FiatProviderAsset}; +use crate::provider::generate_quote_id; + +use super::{ + client::FlashnetClient, + mapper::{map_amount, map_assets, map_crypto_amount, map_redirect_url, map_source_amount, map_webhook}, + model::{FlashnetOnrampRequest, FlashnetWebhookPayload}, +}; + +#[async_trait] +impl FiatProvider for FlashnetClient { + fn name(&self) -> FiatProviderName { + Self::NAME + } + + async fn payment_methods(&self) -> Vec { + vec![PaymentType::CashApp] + } + + async fn get_assets(&self) -> Result, Box> { + let routes = self.get_routes().await?; + Ok(map_assets( + routes + .routes + .into_iter() + .filter(|route| route.source_chain == "lightning" && route.source_asset == "BTC") + .collect(), + )) + } + + async fn get_countries(&self) -> Result, Box> { + Ok(vec![FiatProviderCountry { + provider: Self::NAME, + alpha2: "US".to_string(), + is_allowed: true, + }]) + } + + async fn process_webhook(&self, data: serde_json::Value) -> Result> { + let payload = serde_json::from_value::(data)?; + Ok(map_webhook(payload)?) + } + + async fn get_quote_buy(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let chain = FiatMapping::get_network(request_map.asset_symbol.network)?; + let symbol = request_map.asset_symbol.symbol; + let amount = map_source_amount(request.amount); + let estimate = self.get_estimate(&chain, &symbol, &amount).await?; + let crypto_amount = map_crypto_amount(&estimate.estimated_out, request_map.asset.decimals as u32)?; + + Ok(FiatQuoteResponse::new(generate_quote_id(), request.amount, crypto_amount)) + } + + async fn get_quote_sell(&self, _request: FiatQuoteRequest, _request_map: FiatMapping) -> Result> { + Err("not implemented".into()) + } + + async fn get_quote_url(&self, data: FiatQuoteUrlData) -> Result> { + let network = FiatMapping::get_network(data.asset_symbol.network.clone())?; + let amount = map_amount(data.quote.crypto_amount, data.quote.asset.decimals as u32); + + let request = FlashnetOnrampRequest { + destination_chain: network, + destination_asset: data.asset_symbol.symbol.clone(), + recipient_address: data.wallet_address.clone(), + amount, + amount_mode: "exact_out".to_string(), + affiliate_id: self.affiliate_id.clone(), + }; + let response = self.create_onramp(request, &data.quote.id).await?; + + Ok(FiatQuoteUrl { + redirect_url: map_redirect_url(&response), + provider_transaction_id: Some(response.order_id), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::flashnet::model::{FlashnetEstimateResponse, FlashnetRoutesResponse}; + + #[test] + fn map_redirect_url_returns_cash_app_link() { + let response = serde_json::from_str(include_str!("../../../testdata/flashnet/onramp_response.json")).unwrap(); + let result = map_redirect_url(&response); + + assert_eq!(result, "https://orchestration.flashnet.xyz/pay/zimH6K-d"); + } + + #[test] + fn map_estimate_includes_affiliate_fees() { + let response: FlashnetEstimateResponse = serde_json::from_str(include_str!("../../../testdata/flashnet/estimate.json")).unwrap(); + + assert_eq!(response.estimated_out, "98951"); + assert_eq!(response.app_fees.len(), 1); + assert_eq!(response.app_fees[0].affiliate_id, "gemwallet"); + assert_eq!(response.app_fees[0].fee_bps, 100); + } + + #[test] + fn map_assets_maps_supported_routes() { + let response: FlashnetRoutesResponse = serde_json::from_str(include_str!("../../../testdata/flashnet/routes.json")).unwrap(); + let assets = map_assets( + response + .routes + .into_iter() + .filter(|route| route.source_chain == "lightning" && route.source_asset == "BTC") + .collect(), + ); + + assert_eq!(assets.len(), 3); + assert!(assets.iter().any(|asset| asset.id == "btc_bitcoin")); + assert!(assets.iter().any(|asset| asset.id == "eth_base")); + assert!(assets.iter().any(|asset| asset.id == "usdc_solana")); + } +} diff --git a/core/crates/fiat/src/providers/mercuryo/client.rs b/core/crates/fiat/src/providers/mercuryo/client.rs new file mode 100644 index 0000000000..fd8c107b27 --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/client.rs @@ -0,0 +1,77 @@ +use primitives::FiatProviderName; +use reqwest::Client; +use std::collections::HashMap; + +use super::mapper::map_sell_quote; +use super::models::{Currencies, CurrencyLimits, MercuryoResponse, Quote, QuoteQuery, QuoteSellQuery, Response}; + +const MERCURYO_API_BASE_URL: &str = "https://api.mercuryo.io"; +pub struct MercuryoClient { + pub client: Client, + // widget + pub widget_id: String, + pub secret_key: String, +} + +impl MercuryoClient { + pub const NAME: FiatProviderName = FiatProviderName::Mercuryo; + + pub fn new(client: Client, widget_id: String, secret_key: String) -> Self { + MercuryoClient { client, widget_id, secret_key } + } + + pub async fn get_quote_buy(&self, fiat_currency: String, symbol: String, fiat_amount: f64, network: String) -> Result> { + let query = QuoteQuery { + from: fiat_currency.clone(), + to: symbol.clone(), + amount: fiat_amount, + network: network.clone(), + widget_id: self.widget_id.clone(), + }; + let url = format!("{MERCURYO_API_BASE_URL}/v1.6/widget/buy/rate"); + self.client.get(url.as_str()).query(&query).send().await?.json::>().await?.into() + } + + pub async fn get_quote_sell(&self, fiat_currency: String, symbol: String, fiat_amount: f64, network: String) -> Result> { + let buy_quote = self.get_quote_buy(fiat_currency.clone(), symbol.clone(), fiat_amount, network.clone()).await?; + let sell_quote = self.get_sell_rate(symbol, fiat_currency, buy_quote.amount, network).await?; + + Ok(map_sell_quote(buy_quote, sell_quote, fiat_amount)) + } + + async fn get_sell_rate(&self, symbol: String, fiat_currency: String, crypto_amount: f64, network: String) -> Result> { + let query = QuoteSellQuery { + from: symbol, + to: fiat_currency, + quote_type: "sell".to_string(), + amount: crypto_amount, + network, + widget_id: self.widget_id.clone(), + }; + let url = format!("{MERCURYO_API_BASE_URL}/v1.6/public/convert"); + self.client.get(url.as_str()).query(&query).send().await?.json::>().await?.into() + } + + pub async fn get_currencies(&self) -> Result { + let url = format!("{MERCURYO_API_BASE_URL}/v1.6/lib/currencies"); + let response = self.client.get(&url).send().await?.json::>().await?; + Ok(response.data) + } + + pub async fn get_countries(&self) -> Result>, reqwest::Error> { + let query = [("type", "alpha2")]; + self.client + .get(format!("{MERCURYO_API_BASE_URL}/v1.6/public/card-countries")) + .query(&query) + .send() + .await? + .json() + .await + } + + pub async fn get_currency_limits(&self, from: String, to: String) -> Result>, reqwest::Error> { + let query = [("from", from), ("to", to), ("widget_id", self.widget_id.clone())]; + let url = format!("{MERCURYO_API_BASE_URL}/v1.6/public/currency-limits"); + self.client.get(&url).query(&query).send().await?.json().await + } +} diff --git a/core/crates/fiat/src/providers/mercuryo/mapper.rs b/core/crates/fiat/src/providers/mercuryo/mapper.rs new file mode 100644 index 0000000000..bc0ceb4cd8 --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/mapper.rs @@ -0,0 +1,295 @@ +use std::collections::HashMap; + +use super::models::{Asset, CurrencyLimits, WebhookData}; +use crate::{model::FiatProviderAsset, providers::mercuryo::models::FiatPaymentMethod}; +use primitives::{Chain, FiatProviderName, FiatTransactionStatus, FiatTransactionUpdate}; +use primitives::{PaymentType, currency::Currency, fiat_assets::FiatAssetLimits}; + +use super::models::Quote; + +pub fn map_sell_quote(buy_quote: Quote, sell_quote: Quote, requested_fiat_amount: f64) -> Quote { + let fee_ratio = sell_quote.fiat_amount / requested_fiat_amount; + let adjusted_crypto_amount = buy_quote.amount / fee_ratio; + + Quote { + amount: adjusted_crypto_amount, + currency: sell_quote.currency, + fiat_amount: requested_fiat_amount, + } +} + +pub fn map_asset_chain(chain: String) -> Option { + match chain.as_str() { + "BITCOIN" => Some(Chain::Bitcoin), + "ETHEREUM" => Some(Chain::Ethereum), + "OPTIMISM" => Some(Chain::Optimism), + "ARBITRUM" => Some(Chain::Arbitrum), + "BASE" => Some(Chain::Base), + "TRON" => Some(Chain::Tron), + "BINANCESMARTCHAIN" => Some(Chain::SmartChain), + "SOLANA" => Some(Chain::Solana), + "POLYGON" => Some(Chain::Polygon), + "COSMOS" => Some(Chain::Cosmos), + "AVALANCHE" => Some(Chain::AvalancheC), + "RIPPLE" => Some(Chain::Xrp), + "LITECOIN" => Some(Chain::Litecoin), + "FANTOM" => Some(Chain::Fantom), + "DOGECOIN" => Some(Chain::Doge), + "CELESTIA" => Some(Chain::Celestia), + "NEWTON" => Some(Chain::Ton), + "NEAR_PROTOCOL" => Some(Chain::Near), + "LINEA" => Some(Chain::Linea), + "ZKSYNC" => Some(Chain::ZkSync), + "INJECTIVE" => Some(Chain::Injective), + "STELLAR" => Some(Chain::Stellar), + "ALGORAND" => Some(Chain::Algorand), + "POLKADOT" => Some(Chain::Polkadot), + "CARDANO" => Some(Chain::Cardano), + "BITCOINCASH" => Some(Chain::BitcoinCash), + "SUI" => Some(Chain::Sui), + "SONIC" => Some(Chain::Sonic), + "MONAD" => Some(Chain::Monad), + _ => None, + } +} + +fn map_limits(fiat_payment_methods: &HashMap) -> Vec { + fiat_payment_methods + .iter() + .filter_map(|(currency_code, fiat_method)| { + let currency = currency_code.parse::().ok()?; + Some((currency, fiat_method)) + }) + .flat_map(|(currency, fiat_method)| { + fiat_method + .payment_methods + .iter() + .filter_map(|payment_method| { + let payment_type = map_payment_type(&payment_method.code, &payment_method.name)?; + Some(FiatAssetLimits { + currency: currency.clone(), + payment_type, + min_amount: Some(fiat_method.limits.min), + max_amount: Some(fiat_method.limits.max), + }) + }) + .collect::>() + }) + .collect() +} + +fn map_payment_type(payment_code: &str, payment_name: &str) -> Option { + match payment_code { + "card" if payment_name == "Visa" => Some(PaymentType::Card), + "google" => Some(PaymentType::GooglePay), + "apple" => Some(PaymentType::ApplePay), + _ => None, + } +} + +pub fn map_order_from_webhook(webhook: WebhookData) -> FiatTransactionUpdate { + let WebhookData { + merchant_transaction_id, + status, + fiat_amount, + fiat_currency, + tx, + .. + } = webhook; + + FiatTransactionUpdate { + transaction_id: merchant_transaction_id, + provider_transaction_id: None, + status: map_status(&status), + transaction_hash: tx.and_then(|tx| tx.id), + fiat_amount: Some(fiat_amount), + fiat_currency: Some(fiat_currency.to_ascii_uppercase()), + } +} + +fn map_status(status: &str) -> FiatTransactionStatus { + match status { + "new" | "pending" | "order_scheduled" => FiatTransactionStatus::Pending, + "cancelled" | "order_failed" | "descriptor_failed" => FiatTransactionStatus::Failed, + "paid" | "completed" | "succeeded" => FiatTransactionStatus::Complete, + _ => FiatTransactionStatus::Unknown, + } +} + +fn map_asset_base(asset: Asset, buy_limits: Vec, sell_limits: Vec) -> Option { + let chain = map_asset_chain(asset.network.clone()); + let token_id = if asset.contract.is_empty() { None } else { Some(asset.contract.clone()) }; + let is_buy_enabled = !buy_limits.is_empty(); + let is_sell_enabled = !sell_limits.is_empty(); + Some(FiatProviderAsset { + id: asset.clone().currency + "_" + asset.network.as_str(), + provider: FiatProviderName::Mercuryo, + chain, + token_id, + symbol: asset.clone().currency, + network: Some(asset.network), + enabled: true, + is_buy_enabled, + is_sell_enabled, + unsupported_countries: None, + buy_limits, + sell_limits, + }) +} + +pub fn map_asset(asset: Asset) -> Option { + map_asset_base(asset, vec![], vec![]) +} + +pub fn map_asset_with_limits(asset: Asset, buy_limits: Vec, sell_limits: Vec) -> Option { + map_asset_base(asset, buy_limits, sell_limits) +} + +pub fn map_asset_limits(currency_limits: Option<&CurrencyLimits>, currency: Currency, fiat_payment_methods: &HashMap) -> Vec { + match currency_limits { + Some(limits) => vec![FiatAssetLimits { + currency, + payment_type: PaymentType::Card, + min_amount: Some(limits.min), + max_amount: Some(limits.max), + }], + None => map_limits(fiat_payment_methods), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::mercuryo::models::{Currencies, Response, Webhook}; + use primitives::FiatTransactionStatus; + + #[test] + fn test_map_order_from_webhook_payloads() -> Result<(), Box> { + let buy: Webhook = serde_json::from_str(include_str!("../../../testdata/mercuryo/webhook_buy_complete.json"))?; + let mobile_pay: Webhook = serde_json::from_str(include_str!("../../../testdata/mercuryo/webhook_mobile_pay_complete.json"))?; + let sell: Webhook = serde_json::from_str(include_str!("../../../testdata/mercuryo/webhook_sell_complete.json"))?; + let withdraw: Webhook = serde_json::from_str(include_str!("../../../testdata/mercuryo/webhook_withdraw_same_order_complete.json"))?; + + assert_eq!( + map_order_from_webhook(buy.data), + FiatTransactionUpdate { + transaction_id: "11111111-2222-4333-8444-555555555555".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Failed, + transaction_hash: None, + fiat_amount: Some(270.0), + fiat_currency: Some("USD".to_string()), + } + ); + + assert_eq!( + map_order_from_webhook(sell.data), + FiatTransactionUpdate { + transaction_id: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Complete, + transaction_hash: Some("SELL_DEPOSIT_TX_HASH_123".to_string()), + fiat_amount: Some(250.5), + fiat_currency: Some("GBP".to_string()), + } + ); + + assert_eq!( + map_order_from_webhook(mobile_pay.data), + FiatTransactionUpdate { + transaction_id: "0d274c2f-cd7f-4137-a5b4-e63c4c2c020b".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Complete, + transaction_hash: None, + fiat_amount: Some(1500.0), + fiat_currency: Some("USD".to_string()), + } + ); + + assert_eq!( + map_order_from_webhook(withdraw.data), + FiatTransactionUpdate { + transaction_id: "0d274c2f-cd7f-4137-a5b4-e63c4c2c020b".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Complete, + transaction_hash: Some("WITHDRAW_TX_HASH_123".to_string()), + fiat_amount: Some(1192.13), + fiat_currency: Some("EUR".to_string()), + } + ); + + Ok(()) + } + + #[test] + fn test_map_trump_asset() -> Result<(), Box> { + let currencies = serde_json::from_str::>(include_str!("../../../testdata/mercuryo/assets.json"))?.data; + + let trump_asset = currencies + .config + .crypto_currencies + .iter() + .find(|asset| asset.currency == "TRUMP" && asset.network == "SOLANA") + .unwrap(); + + let result = map_asset_with_limits(trump_asset.clone(), vec![], vec![]).unwrap(); + + assert_eq!(result.symbol, "TRUMP"); + assert_eq!(result.chain, Some(Chain::Solana)); + assert_eq!(result.token_id, Some("6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN".to_string())); + assert_eq!(result.network, Some("SOLANA".to_string())); + assert_eq!(result.id, "TRUMP_SOLANA"); + + Ok(()) + } + + #[test] + fn test_contract_assets_mapping() -> Result<(), Box> { + let currencies = serde_json::from_str::>(include_str!("../../../testdata/mercuryo/assets.json"))?.data; + + let all_assets: Vec<_> = currencies + .config + .crypto_currencies + .into_iter() + .flat_map(|asset| map_asset_with_limits(asset, vec![], vec![])) + .collect(); + + let contract_assets: Vec<_> = all_assets.iter().filter(|asset| asset.token_id.is_some()).collect(); + + let trump_assets: Vec<_> = all_assets.iter().filter(|asset| asset.symbol == "TRUMP").collect(); + + println!("Total assets: {}", all_assets.len()); + println!("Contract-based assets: {}", contract_assets.len()); + println!("TRUMP assets: {}", trump_assets.len()); + + for trump in &trump_assets { + println!("TRUMP asset: {} on {:?} with contract: {:?}", trump.id, trump.chain, trump.token_id); + println!("TRUMP asset_id(): {:?}", trump.asset_id()); + } + + assert!(!contract_assets.is_empty()); + assert!(!trump_assets.is_empty()); + + Ok(()) + } + + #[test] + fn test_map_sell_quote() { + let buy_quote = Quote { + amount: 0.031198, + currency: "ETH".to_string(), + fiat_amount: 100.0, + }; + let sell_quote = Quote { + amount: 0.031198, + currency: "ETH".to_string(), + fiat_amount: 90.26, + }; + + let result = map_sell_quote(buy_quote, sell_quote, 100.0); + + assert!((result.amount - 0.03456).abs() < 0.0001); + assert_eq!(result.fiat_amount, 100.0); + assert_eq!(result.currency, "ETH"); + } +} diff --git a/core/crates/fiat/src/providers/mercuryo/mod.rs b/core/crates/fiat/src/providers/mercuryo/mod.rs new file mode 100644 index 0000000000..31cccb8025 --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod mapper; +pub mod models; +pub mod provider; +pub mod widget; diff --git a/core/crates/fiat/src/providers/mercuryo/models/asset.rs b/core/crates/fiat/src/providers/mercuryo/models/asset.rs new file mode 100644 index 0000000000..a2c11febdc --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/models/asset.rs @@ -0,0 +1,41 @@ +use serde::Deserialize; +use serde_serializers::deserialize_f64_from_str; +use std::collections::HashMap; + +#[derive(Debug, Deserialize, Clone)] +pub struct Currencies { + pub config: Config, + pub fiat_payment_methods: HashMap, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Config { + pub crypto_currencies: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Asset { + pub currency: String, + pub network: String, + pub contract: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct FiatPaymentMethod { + pub payment_methods: Vec, + pub limits: Limits, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct PaymentMethod { + pub code: String, + pub name: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Limits { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub min: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub max: f64, +} diff --git a/core/crates/fiat/src/providers/mercuryo/models/limits.rs b/core/crates/fiat/src/providers/mercuryo/models/limits.rs new file mode 100644 index 0000000000..deedadd2fa --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/models/limits.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrencyLimits { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub max: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub min: f64, +} diff --git a/core/crates/fiat/src/providers/mercuryo/models/mod.rs b/core/crates/fiat/src/providers/mercuryo/models/mod.rs new file mode 100644 index 0000000000..1207a8925c --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/models/mod.rs @@ -0,0 +1,11 @@ +pub mod asset; +pub mod limits; +pub mod quote; +pub mod response; +pub mod webhook; + +pub use asset::*; +pub use limits::*; +pub use quote::*; +pub use response::*; +pub use webhook::*; diff --git a/core/crates/fiat/src/providers/mercuryo/models/quote.rs b/core/crates/fiat/src/providers/mercuryo/models/quote.rs new file mode 100644 index 0000000000..11b3154c91 --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/models/quote.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Deserialize, Clone)] +pub struct Quote { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub amount: f64, + pub currency: String, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub fiat_amount: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct QuoteQuery { + pub from: String, + pub to: String, + pub amount: f64, + pub network: String, + pub widget_id: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct QuoteSellQuery { + pub from: String, + pub to: String, + #[serde(rename = "type")] + pub quote_type: String, + pub amount: f64, + pub network: String, + pub widget_id: String, +} diff --git a/core/crates/fiat/src/providers/mercuryo/models/response.rs b/core/crates/fiat/src/providers/mercuryo/models/response.rs new file mode 100644 index 0000000000..287daa861f --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/models/response.rs @@ -0,0 +1,27 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Response { + pub data: T, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum MercuryoResponse { + Success(Response), + Error(MercuryoError), +} + +#[derive(Debug, Deserialize)] +pub struct MercuryoError { + pub message: String, +} + +impl From> for Result> { + fn from(resp: MercuryoResponse) -> Self { + match resp { + MercuryoResponse::Success(data) => Ok(data.data), + MercuryoResponse::Error(error) => Err(error.message.into()), + } + } +} diff --git a/core/crates/fiat/src/providers/mercuryo/models/webhook.rs b/core/crates/fiat/src/providers/mercuryo/models/webhook.rs new file mode 100644 index 0000000000..85b1052df0 --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/models/webhook.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Deserialize, Clone)] +pub struct Webhook { + pub data: WebhookData, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct WebhookData { + pub id: String, + pub merchant_transaction_id: String, + pub status: String, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub fiat_amount: f64, + pub fiat_currency: String, + pub tx: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct WebhookTransaction { + pub id: Option, +} diff --git a/core/crates/fiat/src/providers/mercuryo/provider.rs b/core/crates/fiat/src/providers/mercuryo/provider.rs new file mode 100644 index 0000000000..41dd4faba8 --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/provider.rs @@ -0,0 +1,168 @@ +use crate::{ + FiatProvider, + model::{FiatMapping, FiatProviderAsset}, + provider::generate_quote_id, + providers::mercuryo::mapper::{map_asset_limits, map_asset_with_limits}, +}; +use async_trait::async_trait; +use futures::future; +use primitives::currency::Currency; +use primitives::{FiatProviderCountry, FiatProviderName, FiatQuoteRequest, FiatQuoteResponse, FiatQuoteType, FiatQuoteUrl, FiatQuoteUrlData}; +use std::error::Error; +use streamer::FiatWebhook; + +use super::{client::MercuryoClient, mapper::map_order_from_webhook, models::Webhook, widget::MercuryoWidget}; + +#[async_trait] +impl FiatProvider for MercuryoClient { + fn name(&self) -> FiatProviderName { + Self::NAME + } + + async fn get_assets(&self) -> Result, Box> { + let currencies = self.get_currencies().await?; + let currency = Currency::USD; + + let assets_with_limits = future::join_all(currencies.config.crypto_currencies.into_iter().map(|asset| { + let fiat_payment_methods = currencies.fiat_payment_methods.clone(); + let currency = currency.clone(); + async move { + match self.get_currency_limits(asset.currency.clone(), currency.as_ref().to_string()).await { + Ok(response) => (asset, map_asset_limits(response.data.get(currency.as_ref()), currency.clone(), &fiat_payment_methods)), + Err(_) => (asset, map_asset_limits(None, currency, &fiat_payment_methods)), + } + } + })) + .await; + + Ok(assets_with_limits + .into_iter() + .filter_map(|(asset, limits)| map_asset_with_limits(asset, limits.clone(), limits)) + .collect()) + } + + async fn get_countries(&self) -> Result, Box> { + Ok(self + .get_countries() + .await? + .data + .into_iter() + .map(|x| FiatProviderCountry { + provider: Self::NAME, + alpha2: x.to_uppercase(), + is_allowed: true, + }) + .collect()) + } + + // full transaction: https://github.com/mercuryoio/api-migration-docs/blob/master/Widget_API_Mercuryo_v1.6.md#22-callbacks-response-body + async fn process_webhook(&self, data: serde_json::Value) -> Result> { + let webhook_data = serde_json::from_value::(data)?.data; + Ok(FiatWebhook::Transaction(map_order_from_webhook(webhook_data))) + } + + async fn get_quote_buy(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let network = request_map.asset_symbol.network.clone().unwrap_or_default(); + let merchant_transaction_id = generate_quote_id(); + let quote = self + .get_quote_buy(request.currency.clone(), request_map.asset_symbol.symbol, request.amount, network) + .await?; + + Ok(FiatQuoteResponse::new(merchant_transaction_id, request.amount, quote.amount)) + } + + async fn get_quote_sell(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let network = request_map.asset_symbol.network.clone().unwrap_or_default(); + let merchant_transaction_id = generate_quote_id(); + let quote = self + .get_quote_sell(request.currency.clone(), request_map.asset_symbol.symbol, request.amount, network) + .await?; + + Ok(FiatQuoteResponse::new(merchant_transaction_id, request.amount, quote.amount)) + } + + async fn get_quote_url(&self, data: FiatQuoteUrlData) -> Result> { + let network = data.asset_symbol.network.unwrap_or_default(); + let amount = match data.quote.quote_type { + FiatQuoteType::Buy => data.quote.fiat_amount, + FiatQuoteType::Sell => data.quote.crypto_amount, + }; + + let widget = MercuryoWidget::new_from_data( + self.widget_id.clone(), + self.secret_key.clone(), + data.quote.id.clone(), + data.wallet_address, + data.ip_address, + data.asset_symbol.symbol, + data.quote.fiat_currency, + amount, + data.quote.quote_type, + network, + ); + + Ok(FiatQuoteUrl { + redirect_url: widget.to_url(), + provider_transaction_id: None, + }) + } +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +mod fiat_integration_tests { + use crate::testkit::*; + use crate::{FiatProvider, model::FiatMapping}; + use primitives::{FiatProviderName, FiatQuoteRequest}; + + #[tokio::test] + async fn test_mercuryo_get_buy_quote() -> Result<(), Box> { + let client = create_mercuryo_test_client(); + + let request = FiatQuoteRequest::mock(); + let mapping = FiatMapping::mock(); + + let quote = FiatProvider::get_quote_buy(&client, request.clone(), mapping).await?; + + println!("Mercuryo buy quote: {:?}", quote); + assert!(!quote.quote_id.is_empty()); + assert!(quote.crypto_amount > 0.0); + assert_eq!(quote.fiat_amount, request.amount); + + Ok(()) + } + + #[tokio::test] + async fn test_mercuryo_get_assets() -> Result<(), Box> { + let client = create_mercuryo_test_client(); + let assets = FiatProvider::get_assets(&client).await?; + + assert!(!assets.is_empty()); + + let assets_with_limits = assets.iter().filter(|a| !a.buy_limits.is_empty()).count(); + assert!(assets_with_limits > 0); + + if let Some(asset) = assets.iter().find(|a| !a.buy_limits.is_empty()) { + assert_eq!(asset.buy_limits.len(), asset.sell_limits.len()); + assert!(asset.buy_limits[0].min_amount.is_some() || asset.buy_limits[0].max_amount.is_some()); + } + + Ok(()) + } + + #[tokio::test] + async fn test_mercuryo_get_countries() -> Result<(), Box> { + let client = create_mercuryo_test_client(); + let countries = FiatProvider::get_countries(&client).await?; + + assert!(!countries.is_empty()); + println!("Found {} Mercuryo countries", countries.len()); + + if let Some(country) = countries.first() { + assert_eq!(country.provider, FiatProviderName::Mercuryo); + assert!(!country.alpha2.is_empty()); + println!("Sample Mercuryo country: {:?}", country); + } + + Ok(()) + } +} diff --git a/core/crates/fiat/src/providers/mercuryo/widget.rs b/core/crates/fiat/src/providers/mercuryo/widget.rs new file mode 100644 index 0000000000..780fed8e38 --- /dev/null +++ b/core/crates/fiat/src/providers/mercuryo/widget.rs @@ -0,0 +1,183 @@ +use crate::provider::generate_quote_id; +use hex; +use primitives::FiatQuoteType; +use sha2::{Digest, Sha512}; +use url::Url; + +use super::models::Quote; + +const MERCURYO_REDIRECT_URL: &str = "https://exchange.mercuryo.io"; + +pub struct MercuryoWidget { + widget_id: String, + secret_key: String, + merchant_transaction_id: String, + address: String, + ip_address: String, + currency: String, + network: String, + quote_type: FiatQuoteType, + amount: f64, +} + +impl MercuryoWidget { + pub fn new(widget_id: String, secret_key: String, address: String, ip_address: String, quote: Quote, quote_type: FiatQuoteType, network: String) -> Self { + let amount = match quote_type { + FiatQuoteType::Buy => quote.fiat_amount, + FiatQuoteType::Sell => quote.amount, + }; + + Self { + widget_id, + secret_key, + merchant_transaction_id: generate_quote_id(), + address, + ip_address, + currency: quote.currency, + network, + quote_type, + amount, + } + } + + pub fn new_from_data( + widget_id: String, + secret_key: String, + merchant_transaction_id: String, + address: String, + ip_address: String, + currency: String, + _fiat_currency: String, + amount: f64, + quote_type: FiatQuoteType, + network: String, + ) -> Self { + Self { + widget_id, + secret_key, + merchant_transaction_id, + address, + ip_address, + currency, + network, + quote_type, + amount, + } + } + + fn signature(&self) -> String { + let content = format!("{}{}{}{}", self.address, self.secret_key, self.ip_address, self.merchant_transaction_id); + let hash = hex::encode(Sha512::digest(content)); + format!("v2:{}", hash) + } + + pub fn merchant_transaction_id(&self) -> &str { + &self.merchant_transaction_id + } + + pub fn to_url(&self) -> String { + let mut url = Url::parse(MERCURYO_REDIRECT_URL).unwrap(); + + url.query_pairs_mut() + .append_pair("widget_id", &self.widget_id) + .append_pair("merchant_transaction_id", &self.merchant_transaction_id) + .append_pair("currency", &self.currency) + .append_pair("address", &self.address) + .append_pair("network", &self.network) + .append_pair("signature", &self.signature()); + + match self.quote_type { + FiatQuoteType::Buy => { + url.query_pairs_mut().append_pair("type", "buy").append_pair("fiat_amount", &self.amount.to_string()); + } + FiatQuoteType::Sell => { + url.query_pairs_mut().append_pair("type", "sell").append_pair("amount", &self.amount.to_string()); + } + }; + + url.as_str().to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signature_v2_format() { + let quote = Quote { + amount: 0.5, + currency: "BTC".to_string(), + fiat_amount: 1000.0, + }; + let mut widget = MercuryoWidget::new( + "widget123".to_string(), + "secret".to_string(), + "0x123".to_string(), + "127.0.0.1".to_string(), + quote, + FiatQuoteType::Buy, + "BITCOIN".to_string(), + ); + widget.merchant_transaction_id = "tx123".to_string(); + + let signature = widget.signature(); + let expected_content = "0x123secret127.0.0.1tx123"; + let expected_hash = hex::encode(Sha512::digest(expected_content)); + + assert_eq!(signature, format!("v2:{}", expected_hash)); + } + + #[test] + fn build_url_buy() { + let quote = Quote { + amount: 0.5, + currency: "BTC".to_string(), + fiat_amount: 1000.0, + }; + + let widget = MercuryoWidget::new( + "widget123".to_string(), + "secret".to_string(), + "0x123".to_string(), + "127.0.0.1".to_string(), + quote, + FiatQuoteType::Buy, + "BITCOIN".to_string(), + ); + let url = widget.to_url(); + + assert!(url.starts_with("https://exchange.mercuryo.io")); + assert!(url.contains("widget_id=widget123")); + assert!(url.contains("currency=BTC")); + assert!(url.contains("address=0x123")); + assert!(url.contains("network=BITCOIN")); + assert!(url.contains("type=buy")); + assert!(url.contains("fiat_amount=1000")); + assert!(url.contains("signature=v2%3A")); + } + + #[test] + fn build_url_sell() { + let quote = Quote { + amount: 1.5, + currency: "ETH".to_string(), + fiat_amount: 3000.0, + }; + + let widget = MercuryoWidget::new( + "widget123".to_string(), + "secret".to_string(), + "0xdef".to_string(), + "127.0.0.1".to_string(), + quote, + FiatQuoteType::Sell, + "ETHEREUM".to_string(), + ); + let url = widget.to_url(); + + assert!(url.contains("type=sell")); + assert!(url.contains("amount=1.5")); + assert!(!url.contains("fiat_amount=")); + } +} diff --git a/core/crates/fiat/src/providers/mod.rs b/core/crates/fiat/src/providers/mod.rs new file mode 100644 index 0000000000..aa01127ad3 --- /dev/null +++ b/core/crates/fiat/src/providers/mod.rs @@ -0,0 +1,17 @@ +pub mod moonpay; +pub use self::moonpay::client::MoonPayClient; + +pub mod mercuryo; +pub use self::mercuryo::client::MercuryoClient; + +pub mod transak; +pub use self::transak::client::TransakClient; + +pub mod banxa; +pub use self::banxa::client::BanxaClient; + +pub mod paybis; +pub use self::paybis::client::PaybisClient; + +pub mod flashnet; +pub use self::flashnet::client::FlashnetClient; diff --git a/core/crates/fiat/src/providers/moonpay/client.rs b/core/crates/fiat/src/providers/moonpay/client.rs new file mode 100644 index 0000000000..b39aa09425 --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/client.rs @@ -0,0 +1,192 @@ +use crate::hmac_signature::generate_hmac_signature; +use crate::model::{FiatProviderAsset, filter_token_id}; + +use super::mapper::map_asset_chain; +use super::models::{Asset, Country, MoonPayBuyQuote, MoonPayIpAddress, MoonPayResponse, MoonPaySellQuote}; +use primitives::currency::Currency; +use primitives::fiat_assets::FiatAssetLimits; +use primitives::{FiatProviderName, FiatQuoteType, PaymentType}; +use reqwest::Client; +use url::Url; + +#[derive(Clone)] +pub struct MoonPayClient { + client: Client, + api_key: String, + secret_key: String, +} + +const MOONPAY_API_BASE_URL: &str = "https://api.moonpay.com"; +const MOONPAY_BUY_REDIRECT_URL: &str = "https://buy.moonpay.com"; +const MOONPAY_SELL_REDIRECT_URL: &str = "https://sell.moonpay.com"; +impl MoonPayClient { + pub const NAME: FiatProviderName = FiatProviderName::MoonPay; + + pub fn new(client: Client, api_key: String, secret_key: String) -> Self { + Self { client, api_key, secret_key } + } + + pub async fn get_ip_address(&self, ip_address: &str) -> Result { + self.client + .get(format!("{MOONPAY_API_BASE_URL}/v4/ip_address/")) + .query(&[("ipAddress", ip_address), ("apiKey", &self.api_key)]) + .send() + .await? + .json() + .await + } + + pub async fn get_buy_quote(&self, symbol: String, fiat_currency: String, fiat_amount: f64) -> Result> { + self.client + .get(format!("{MOONPAY_API_BASE_URL}/v3/currencies/{symbol}/buy_quote/")) + .query(&[ + ("baseCurrencyCode", fiat_currency), + ("baseCurrencyAmount", fiat_amount.to_string()), + ("areFeesIncluded", "true".to_string()), + ("apiKey", self.api_key.clone()), + ]) + .send() + .await? + .json::>() + .await? + .into() + } + + pub async fn get_sell_quote(&self, symbol: String, fiat_currency: String, fiat_amount: f64) -> Result> { + self.client + .get(format!("{MOONPAY_API_BASE_URL}/v3/currencies/{symbol}/sell_quote/")) + .query(&[ + ("quoteCurrencyCode", fiat_currency), + ("quoteCurrencyAmount", fiat_amount.to_string()), + ("areFeesIncluded", "true".to_string()), + ("apiKey", self.api_key.clone()), + ]) + .send() + .await? + .json::>() + .await? + .into() + } + + pub async fn get_assets(&self) -> Result, reqwest::Error> { + self.client.get(format!("{MOONPAY_API_BASE_URL}/v3/currencies")).send().await?.json().await + } + + pub async fn get_countries(&self) -> Result, reqwest::Error> { + self.client.get(format!("{MOONPAY_API_BASE_URL}/v3/countries")).send().await?.json().await + } + + pub fn map_asset(asset: Asset) -> Option { + let chain = map_asset_chain(asset.clone())?; + let contract_address = match asset.metadata.as_ref().map(|m| m.network_code.as_str()) { + Some("ripple") => asset + .metadata + .as_ref() + .and_then(|m| m.contract_address.as_deref().and_then(|s| s.split('.').next_back().map(String::from))), + _ => asset.clone().metadata?.contract_address, + }; + + let token_id = filter_token_id(Some(chain), contract_address); + + // Skip tokens without contract address (only base assets can have no token_id) + if token_id.is_none() && !asset.is_base_asset.unwrap_or(false) { + return None; + } + let enabled = !asset.is_suspended.unwrap_or(true); + + let payment_types = [PaymentType::Card, PaymentType::GooglePay, PaymentType::ApplePay]; + + let buy_limits = if asset.min_buy_amount.is_some() || asset.max_buy_amount.is_some() { + payment_types + .iter() + .map(|x| FiatAssetLimits { + currency: Currency::USD, + payment_type: x.clone(), + min_amount: asset.min_buy_amount, + max_amount: asset.max_buy_amount, + }) + .collect() + } else { + vec![] + }; + + let sell_limits = if asset.min_sell_amount.is_some() || asset.max_sell_amount.is_some() { + payment_types + .iter() + .map(|x| FiatAssetLimits { + currency: Currency::USD, + payment_type: x.clone(), + min_amount: asset.min_sell_amount, + max_amount: asset.max_sell_amount, + }) + .collect() + } else { + vec![] + }; + + let is_buy_enabled = asset.min_buy_amount.is_some() || asset.max_buy_amount.is_some(); + let is_sell_enabled = asset.is_sell_supported.unwrap_or(false); + + Some(FiatProviderAsset { + id: asset.clone().code, + provider: FiatProviderName::MoonPay, + chain: Some(chain), + token_id, + symbol: asset.clone().code, + network: asset.clone().metadata.map(|x| x.network_code), + enabled, + is_buy_enabled, + is_sell_enabled, + unsupported_countries: Some(asset.unsupported_countries()), + buy_limits, + sell_limits, + }) + } + + fn generate_quote_url(&self, quote_type: FiatQuoteType, amount: f64, symbol: &str, wallet_address: &str, external_transaction_id: &str) -> String { + let url = match quote_type { + FiatQuoteType::Buy => MOONPAY_BUY_REDIRECT_URL, + FiatQuoteType::Sell => MOONPAY_SELL_REDIRECT_URL, + }; + let mut components = Url::parse(url).unwrap(); + components + .query_pairs_mut() + .append_pair("apiKey", &self.api_key) + .append_pair("externalTransactionId", external_transaction_id); + + match quote_type { + FiatQuoteType::Buy => { + // For buy: amount is fiat, symbol is crypto + components + .query_pairs_mut() + .append_pair("baseCurrencyAmount", &amount.to_string()) + .append_pair("currencyCode", symbol) + .append_pair("walletAddress", wallet_address); + } + FiatQuoteType::Sell => { + // For sell: amount is crypto, symbol is crypto + components + .query_pairs_mut() + .append_pair("baseCurrencyCode", symbol) + .append_pair("baseCurrencyAmount", &amount.to_string()) + .append_pair("refundWalletAddress", wallet_address); + } + }; + self.sign(components) + } + + fn sign(&self, mut components: Url) -> String { + let query = components.query().unwrap(); + let signature = self.generate_signature(format!("?{}", &query).as_str()); + components.query_pairs_mut().append_pair("signature", &signature); + components.as_str().to_string() + } + + fn generate_signature(&self, query: &str) -> String { + generate_hmac_signature(&self.secret_key, query) + } + + pub fn quote_redirect_url(&self, quote_type: FiatQuoteType, amount: f64, symbol: &str, wallet_address: &str, external_transaction_id: &str) -> String { + self.generate_quote_url(quote_type, amount, symbol, wallet_address, external_transaction_id) + } +} diff --git a/core/crates/fiat/src/providers/moonpay/mapper.rs b/core/crates/fiat/src/providers/moonpay/mapper.rs new file mode 100644 index 0000000000..225d8ef046 --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/mapper.rs @@ -0,0 +1,254 @@ +use crate::providers::moonpay::models::{Asset, FiatCurrencyType, Transaction}; +use primitives::{Chain, FiatQuoteType, FiatTransactionStatus, FiatTransactionUpdate}; + +#[cfg(test)] +use primitives::PaymentType; +pub fn map_asset_chain(asset: Asset) -> Option { + match asset.metadata?.network_code.as_str() { + "ethereum" => Some(Chain::Ethereum), + "binance_smart_chain" | "bnb_chain" => Some(Chain::SmartChain), + "solana" => Some(Chain::Solana), + "arbitrum" => Some(Chain::Arbitrum), + "base" => Some(Chain::Base), + "avalanche_c_chain" => Some(Chain::AvalancheC), + "optimism" => Some(Chain::Optimism), + "polygon" => Some(Chain::Polygon), + "tron" => Some(Chain::Tron), + "aptos" => Some(Chain::Aptos), + "bitcoin" => Some(Chain::Bitcoin), + "bitcoin_cash" => Some(Chain::BitcoinCash), + "dogecoin" => Some(Chain::Doge), + "litecoin" => Some(Chain::Litecoin), + "ripple" => Some(Chain::Xrp), + "sui" => Some(Chain::Sui), + "ton" => Some(Chain::Ton), + "cosmos" => Some(Chain::Cosmos), + "near" => Some(Chain::Near), + "linea" => Some(Chain::Linea), + "zksync" => Some(Chain::ZkSync), + "celo" => Some(Chain::Celo), + "stellar" => Some(Chain::Stellar), + "algorand" => Some(Chain::Algorand), + "polkadot" => Some(Chain::Polkadot), + "berachain" => Some(Chain::Berachain), + "sonic" => Some(Chain::Sonic), + "celestia" => Some(Chain::Celestia), + "noble" => Some(Chain::Noble), + "worldchain" => Some(Chain::World), + "injective" => Some(Chain::Injective), + "cardano" => Some(Chain::Cardano), + "monad" => Some(Chain::Monad), + _ => None, + } +} + +pub fn map_order(payload: Transaction) -> FiatTransactionUpdate { + let transaction_id = payload.external_transaction_id.clone().unwrap_or_else(|| payload.id.clone()); + let provider_transaction_id = (transaction_id != payload.id).then_some(payload.id.clone()); + let transaction_type = if payload.base_currency.currency_type == FiatCurrencyType::Fiat { + FiatQuoteType::Buy + } else { + FiatQuoteType::Sell + }; + let currency_amount = match transaction_type { + FiatQuoteType::Buy => payload.base_currency_amount.unwrap_or_default(), + FiatQuoteType::Sell => payload.quote_currency_amount.unwrap_or_default(), + }; + let status = map_status(&payload.status); + let fee_provider = payload.fee_amount.unwrap_or_default(); + let fee_network = payload.network_fee_amount.unwrap_or_default(); + let fee_partner = payload.extra_fee_amount.unwrap_or_default(); + + let fiat_amount = match transaction_type { + FiatQuoteType::Buy => currency_amount + fee_provider + fee_network + fee_partner, + FiatQuoteType::Sell => currency_amount, + }; + let fiat_currency = match transaction_type { + FiatQuoteType::Buy => Some(payload.base_currency.code.as_str()), + FiatQuoteType::Sell => payload.quote_currency.as_ref().map(|currency| currency.code.as_str()), + } + .map(str::to_ascii_uppercase); + + FiatTransactionUpdate { + transaction_id, + provider_transaction_id, + status, + transaction_hash: payload.crypto_transaction_id, + fiat_amount: Some(fiat_amount), + fiat_currency, + } +} + +fn map_status(status: &str) -> FiatTransactionStatus { + match status { + "pending" | "waitingForDeposit" => FiatTransactionStatus::Pending, + "failed" => FiatTransactionStatus::Failed, + "completed" => FiatTransactionStatus::Complete, + _ if status.starts_with("waiting") => FiatTransactionStatus::Pending, + _ => FiatTransactionStatus::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::moonpay::client::MoonPayClient; + use crate::providers::moonpay::models::{Data, Transaction}; + use primitives::{FiatTransactionStatus, FiatTransactionUpdate}; + + #[test] + fn test_map_order_buy_failed() { + let webhook_data: Data = serde_json::from_str(include_str!("../../../testdata/moonpay/webhook_buy_complete.json")).unwrap(); + let payload = webhook_data.data; + + let result = map_order(payload); + + assert_eq!( + result, + FiatTransactionUpdate { + transaction_id: "1b6cdb1e-9299-45b1-9670-54db1ea5a21f".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Failed, + transaction_hash: None, + fiat_amount: Some(20.0), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_map_order_sell_pending() { + let webhook_data: Data = serde_json::from_str(include_str!("../../../testdata/moonpay/webhook_sell_complete_.json")).unwrap(); + let payload = webhook_data.data; + + let result = map_order(payload); + + assert_eq!( + result, + FiatTransactionUpdate { + transaction_id: "557d8fc1-0657-4505-8702-6bd9e1cd6241".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Pending, + transaction_hash: None, + fiat_amount: Some(3123.07), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_map_order_v3_sell_complete() { + let webhook_data: Transaction = serde_json::from_str(include_str!("../../../testdata/moonpay/sell_transaction_complete.json")).unwrap(); + + let result = map_order(webhook_data); + + assert_eq!( + result, + FiatTransactionUpdate { + transaction_id: "bcd0315e-4264-48bb-8c10-1a5207297341".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Complete, + transaction_hash: Some("0xabc123456789".to_string()), + fiat_amount: Some(3123.07), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_map_order_sell_failed() { + let webhook_data: Transaction = serde_json::from_str(include_str!("../../../testdata/moonpay/transaction_sell_failed.json")).unwrap(); + + let result = map_order(webhook_data); + + assert_eq!( + result, + FiatTransactionUpdate { + transaction_id: "bcd0315e-4264-48bb-8c10-1a5207297341".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Failed, + transaction_hash: None, + fiat_amount: Some(8419.77), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_map_order_buy_waiting_payment() { + let payload: Transaction = serde_json::from_value(serde_json::json!({ + "id": "9a1a7efe-c6f1-4c69-ad9f-6abd2a7c6385", + "externalTransactionId": null, + "status": "waitingPayment", + "baseCurrencyAmount": 66.53, + "quoteCurrencyAmount": null, + "baseCurrency": { + "code": "eur", + "metadata": null, + "type": "fiat", + "isSuspended": false, + "isBaseAsset": false, + "isSellSupported": true, + "notAllowedCountries": [], + "minBuyAmount": 20.0, + "maxBuyAmount": 30000.0, + "minSellAmount": null, + "maxSellAmount": null + }, + "quoteCurrency": null, + "cryptoTransactionId": null, + "networkFeeAmount": 0.81, + "extraFeeAmount": 0.67, + "feeAmount": 3.99 + })) + .unwrap(); + + let result = map_order(payload); + + assert_eq!( + result, + FiatTransactionUpdate { + transaction_id: "9a1a7efe-c6f1-4c69-ad9f-6abd2a7c6385".to_string(), + provider_transaction_id: None, + status: FiatTransactionStatus::Pending, + transaction_hash: None, + fiat_amount: Some(72.0), + fiat_currency: Some("EUR".to_string()), + } + ); + } + + #[test] + fn test_map_asset_with_limits() { + let assets: Vec = serde_json::from_str(include_str!("../../../testdata/moonpay/assets.json")).unwrap(); + let cardano = assets.iter().find(|a| a.code == "ada").unwrap(); + + let result = MoonPayClient::map_asset(cardano.clone()).unwrap(); + + assert_eq!(result.symbol, "ada"); + assert_eq!(result.chain, Some(Chain::Cardano)); + assert!(result.enabled); + + assert_eq!(result.buy_limits.len(), 3); + let card_limit = result.buy_limits.iter().find(|limit| limit.payment_type == PaymentType::Card).unwrap(); + assert_eq!(card_limit.min_amount, Some(6.1)); + assert_eq!(card_limit.max_amount, None); + + assert!(result.buy_limits.iter().any(|limit| limit.payment_type == PaymentType::ApplePay)); + assert!(result.buy_limits.iter().any(|limit| limit.payment_type == PaymentType::GooglePay)); + + assert_eq!(result.sell_limits.len(), 3); + let sell_card_limit = result.sell_limits.iter().find(|limit| limit.payment_type == PaymentType::Card).unwrap(); + assert_eq!(sell_card_limit.min_amount, Some(24.3607)); + assert_eq!(sell_card_limit.max_amount, Some(12000.0)); + + assert!(result.sell_limits.iter().any(|limit| limit.payment_type == PaymentType::ApplePay)); + assert!(result.sell_limits.iter().any(|limit| limit.payment_type == PaymentType::GooglePay)); + } + + #[test] + fn test_skip_token_without_contract() { + assert!(MoonPayClient::map_asset(Asset::mock("sweat_near", "near", None, false)).is_none()); + assert!(MoonPayClient::map_asset(Asset::mock("near", "near", None, true)).is_some()); + } +} diff --git a/core/crates/fiat/src/providers/moonpay/mod.rs b/core/crates/fiat/src/providers/moonpay/mod.rs new file mode 100644 index 0000000000..229d2dad3b --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/mod.rs @@ -0,0 +1,7 @@ +pub mod client; +pub mod mapper; +pub mod models; +pub mod provider; + +#[cfg(test)] +mod testkit; diff --git a/core/crates/fiat/src/providers/moonpay/models/assets.rs b/core/crates/fiat/src/providers/moonpay/models/assets.rs new file mode 100644 index 0000000000..88382a70af --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/models/assets.rs @@ -0,0 +1,54 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Currency { + pub decimals: u32, + pub not_allowed_countries: Vec, + #[serde(rename = "notAllowedUSStates")] + pub not_allowed_us_states: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Asset { + pub code: String, + pub metadata: Option, + pub is_suspended: Option, + pub is_base_asset: Option, + pub is_sell_supported: Option, + #[serde(rename = "notAllowedCountries")] + pub not_allowed_countries: Option>, + #[serde(rename = "type")] + pub currency_type: FiatCurrencyType, + pub min_buy_amount: Option, + pub max_buy_amount: Option, + pub min_sell_amount: Option, + pub max_sell_amount: Option, +} + +impl Asset { + pub fn unsupported_countries(&self) -> HashMap> { + let mut map = HashMap::new(); + + for country in &self.not_allowed_countries.clone().unwrap_or_default() { + map.insert(country.clone(), vec![]); + } + map + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrencyMetadata { + pub contract_address: Option, + pub network_code: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FiatCurrencyType { + Fiat, + Crypto, +} diff --git a/core/crates/fiat/src/providers/moonpay/models/common.rs b/core/crates/fiat/src/providers/moonpay/models/common.rs new file mode 100644 index 0000000000..536598bb1e --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/models/common.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Data { + pub data: T, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum MoonPayResponse { + Success(T), + Error(MoonPayError), +} + +#[derive(Debug, Deserialize)] +pub struct MoonPayError { + pub message: String, +} + +impl From> for Result> { + fn from(resp: MoonPayResponse) -> Self { + match resp { + MoonPayResponse::Success(data) => Ok(data), + MoonPayResponse::Error(error) => Err(error.message.into()), + } + } +} diff --git a/core/crates/fiat/src/providers/moonpay/models/countries.rs b/core/crates/fiat/src/providers/moonpay/models/countries.rs new file mode 100644 index 0000000000..7a8c3daa82 --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/models/countries.rs @@ -0,0 +1,18 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Country { + pub alpha2: String, + pub is_allowed: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoonPayIpAddress { + pub alpha2: String, + pub state: String, + pub is_buy_allowed: bool, + pub is_sell_allowed: bool, + pub is_allowed: bool, +} diff --git a/core/crates/fiat/src/providers/moonpay/models/mod.rs b/core/crates/fiat/src/providers/moonpay/models/mod.rs new file mode 100644 index 0000000000..cd40cad5cc --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/models/mod.rs @@ -0,0 +1,11 @@ +pub mod assets; +pub mod common; +pub mod countries; +pub mod quotes; +pub mod transactions; + +pub use assets::*; +pub use common::*; +pub use countries::*; +pub use quotes::*; +pub use transactions::*; diff --git a/core/crates/fiat/src/providers/moonpay/models/quotes.rs b/core/crates/fiat/src/providers/moonpay/models/quotes.rs new file mode 100644 index 0000000000..0e1f57a73b --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/models/quotes.rs @@ -0,0 +1,21 @@ +use serde::Deserialize; + +use super::assets::Currency; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoonPayBuyQuote { + pub quote_currency_amount: f64, + pub quote_currency_code: String, + pub quote_currency: Currency, + pub total_amount: f64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoonPaySellQuote { + pub base_currency_amount: f64, + pub base_currency_code: String, + pub quote_currency_amount: f64, + pub base_currency: Currency, +} diff --git a/core/crates/fiat/src/providers/moonpay/models/transactions.rs b/core/crates/fiat/src/providers/moonpay/models/transactions.rs new file mode 100644 index 0000000000..1078fb74f0 --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/models/transactions.rs @@ -0,0 +1,19 @@ +use serde::Deserialize; + +use super::assets::Asset; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + pub id: String, + pub external_transaction_id: Option, + pub status: String, + pub base_currency_amount: Option, + pub quote_currency_amount: Option, + pub base_currency: Asset, + pub quote_currency: Option, + pub crypto_transaction_id: Option, + pub network_fee_amount: Option, + pub extra_fee_amount: Option, + pub fee_amount: Option, +} diff --git a/core/crates/fiat/src/providers/moonpay/provider.rs b/core/crates/fiat/src/providers/moonpay/provider.rs new file mode 100644 index 0000000000..8a22049546 --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/provider.rs @@ -0,0 +1,131 @@ +use crate::{ + FiatProvider, + model::{FiatMapping, FiatProviderAsset}, + provider::generate_quote_id, + providers::moonpay::models::{Data, Transaction}, +}; +use async_trait::async_trait; +use std::error::Error; +use streamer::FiatWebhook; + +use super::{client::MoonPayClient, mapper::map_order}; +use primitives::{FiatProviderCountry, FiatProviderName, FiatQuoteRequest, FiatQuoteResponse, FiatQuoteType, FiatQuoteUrl, FiatQuoteUrlData}; + +#[async_trait] +impl FiatProvider for MoonPayClient { + fn name(&self) -> FiatProviderName { + Self::NAME + } + + async fn get_assets(&self) -> Result, Box> { + let assets = self.get_assets().await?.into_iter().flat_map(Self::map_asset).collect::>(); + Ok(assets) + } + + async fn get_countries(&self) -> Result, Box> { + Ok(self + .get_countries() + .await? + .into_iter() + .map(|x| FiatProviderCountry { + provider: Self::NAME, + alpha2: x.alpha2, + is_allowed: x.is_allowed, + }) + .collect()) + } + + async fn process_webhook(&self, data: serde_json::Value) -> Result> { + let payload = serde_json::from_value::>(data)?.data; + Ok(FiatWebhook::Transaction(map_order(payload))) + } + + async fn get_quote_buy(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let quote = self + .get_buy_quote(request_map.asset_symbol.symbol.to_lowercase(), request.currency.to_lowercase(), request.amount) + .await?; + + Ok(FiatQuoteResponse::new(generate_quote_id(), request.amount, quote.quote_currency_amount)) + } + + async fn get_quote_sell(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let quote = self + .get_sell_quote(request_map.asset_symbol.symbol.to_lowercase(), request.currency.to_lowercase(), request.amount) + .await?; + + Ok(FiatQuoteResponse::new(generate_quote_id(), quote.quote_currency_amount, quote.base_currency_amount)) + } + + async fn get_quote_url(&self, data: FiatQuoteUrlData) -> Result> { + let amount = match data.quote.quote_type { + FiatQuoteType::Buy => data.quote.fiat_amount, + FiatQuoteType::Sell => data.quote.crypto_amount, + }; + + let redirect_url = self.quote_redirect_url(data.quote.quote_type, amount, &data.asset_symbol.symbol, &data.wallet_address, &data.quote.id); + + Ok(FiatQuoteUrl { + redirect_url, + provider_transaction_id: None, + }) + } +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +mod fiat_integration_tests { + use crate::testkit::*; + use crate::{FiatProvider, model::FiatMapping}; + use primitives::{FiatProviderName, FiatQuoteRequest}; + + #[tokio::test] + async fn test_moonpay_get_buy_quote() -> Result<(), Box> { + let client = create_moonpay_test_client(); + + let request = FiatQuoteRequest::mock(); + let mut mapping = FiatMapping::mock(); + mapping.asset_symbol.network = Some("bitcoin".to_string()); + + let quote = FiatProvider::get_quote_buy(&client, request.clone(), mapping).await?; + + println!("MoonPay buy quote: {:?}", quote); + assert!(!quote.quote_id.is_empty()); + assert!(quote.crypto_amount > 0.0); + assert_eq!(quote.fiat_amount, request.amount); + + Ok(()) + } + + #[tokio::test] + async fn test_moonpay_get_assets() -> Result<(), Box> { + let client = create_moonpay_test_client(); + let assets = FiatProvider::get_assets(&client).await?; + + assert!(!assets.is_empty()); + println!("Found {} MoonPay assets", assets.len()); + + if let Some(asset) = assets.first() { + assert!(!asset.id.is_empty()); + assert!(!asset.symbol.is_empty()); + println!("Sample MoonPay asset: {:?}", asset); + } + + Ok(()) + } + + #[tokio::test] + async fn test_moonpay_get_countries() -> Result<(), Box> { + let client = create_moonpay_test_client(); + let countries = FiatProvider::get_countries(&client).await?; + + assert!(!countries.is_empty()); + println!("Found {} MoonPay countries", countries.len()); + + if let Some(country) = countries.first() { + assert_eq!(country.provider, FiatProviderName::MoonPay); + assert!(!country.alpha2.is_empty()); + println!("Sample MoonPay country: {:?}", country); + } + + Ok(()) + } +} diff --git a/core/crates/fiat/src/providers/moonpay/testkit.rs b/core/crates/fiat/src/providers/moonpay/testkit.rs new file mode 100644 index 0000000000..c7bf0dc81d --- /dev/null +++ b/core/crates/fiat/src/providers/moonpay/testkit.rs @@ -0,0 +1,22 @@ +use super::models::{Asset, CurrencyMetadata, FiatCurrencyType}; + +impl Asset { + pub fn mock(code: &str, network_code: &str, contract_address: Option<&str>, is_base_asset: bool) -> Self { + Self { + code: code.to_string(), + metadata: Some(CurrencyMetadata { + contract_address: contract_address.map(|s| s.to_string()), + network_code: network_code.to_string(), + }), + is_suspended: Some(false), + is_base_asset: Some(is_base_asset), + is_sell_supported: Some(true), + not_allowed_countries: None, + currency_type: FiatCurrencyType::Crypto, + min_buy_amount: None, + max_buy_amount: None, + min_sell_amount: None, + max_sell_amount: None, + } + } +} diff --git a/core/crates/fiat/src/providers/paybis/client.rs b/core/crates/fiat/src/providers/paybis/client.rs new file mode 100644 index 0000000000..e4469fe803 --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/client.rs @@ -0,0 +1,143 @@ +use super::models::{Assets, PaybisQuote, PaybisResponse, QuoteRequest, Request, RequestResponse, SellAssets}; +use crate::rsa_signature::generate_rsa_pss_signature; +use primitives::FiatProviderName; +use reqwest::Client; +use url::Url; + +const PAYBIS_API_BASE_URL: &str = "https://widget-api.paybis.com"; +const PAYBIS_WIDGET_URL: &str = "https://widget.paybis.com"; + +pub struct PaybisClient { + client: Client, + api_key: String, + private_key: String, +} + +impl PaybisClient { + pub const NAME: FiatProviderName = FiatProviderName::Paybis; + + pub fn new(client: Client, api_key: String, private_key: String) -> Self { + Self { client, api_key, private_key } + } + + fn sign_request(&self, body: &str) -> Result> { + generate_rsa_pss_signature(&self.private_key, body) + } + + async fn signed_post(&self, url: String, body: String) -> Result> { + let signature = self.sign_request(&body)?; + self.client + .post(url) + .header("Authorization", &self.api_key) + .header("X-Request-Signature", signature) + .header("Content-Type", "application/json") + .body(body) + .send() + .await? + .json::>() + .await? + .into() + } + + pub async fn get_buy_quote(&self, crypto_currency: String, fiat_currency: String, fiat_amount: f64) -> Result> { + let request_body = QuoteRequest { + amount: fiat_amount.to_string(), + direction_change: "from".to_string(), + is_received_amount: false, + currency_code_from: fiat_currency, + currency_code_to: crypto_currency, + }; + + let body = serde_json::to_string(&request_body)?; + let url = format!("{PAYBIS_API_BASE_URL}/v2/quote"); + self.signed_post(url, body).await + } + + pub async fn get_sell_quote(&self, crypto_currency: String, fiat_currency: String, fiat_amount: f64) -> Result> { + let request_body = QuoteRequest { + amount: fiat_amount.to_string(), + direction_change: "to".to_string(), + is_received_amount: true, + currency_code_from: crypto_currency, + currency_code_to: fiat_currency, + }; + + let body = serde_json::to_string(&request_body)?; + let url = format!("{PAYBIS_API_BASE_URL}/v2/quote"); + self.signed_post(url, body).await + } + + async fn get_assets(&self, flow: &str) -> Result> { + let url = format!("{PAYBIS_API_BASE_URL}/v2/currency/pairs/{flow}"); + self.client + .get(url) + .header("Authorization", &self.api_key) + .send() + .await? + .json::>() + .await? + .into() + } + + pub async fn get_buy_assets(&self) -> Result> { + self.get_assets("buy-crypto").await + } + + pub async fn get_sell_assets(&self) -> Result> { + let url = format!("{PAYBIS_API_BASE_URL}/v2/currency/pairs/sell-crypto"); + self.client + .get(url) + .header("Authorization", &self.api_key) + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } + + pub async fn create_request(&self, request_body: Request) -> Result> { + let body = serde_json::to_string(&request_body)?; + let url = format!("{PAYBIS_API_BASE_URL}/v3/request"); + self.signed_post(url, body).await + } + + pub async fn get_redirect_url( + &self, + wallet_address: &str, + from_currency: &str, + to_currency: &str, + quote_id: &str, + is_buy: bool, + user_ip: &str, + locale: &str, + ) -> Result> { + let request_body = if is_buy { + Request::new_buy( + wallet_address.to_owned(), + wallet_address.to_owned(), + to_currency.to_string(), + from_currency.to_string(), + quote_id.to_string(), + user_ip.to_string(), + locale.to_string(), + ) + } else { + Request::new_sell( + wallet_address.to_owned(), + wallet_address.to_owned(), + to_currency.to_string(), + from_currency.to_string(), + quote_id.to_string(), + user_ip.to_string(), + locale.to_string(), + ) + }; + + let response = self.create_request(request_body).await?; + + let mut url = Url::parse(PAYBIS_WIDGET_URL)?; + url.query_pairs_mut().append_pair("requestId", &response.request_id); + + Ok(url.to_string()) + } +} diff --git a/core/crates/fiat/src/providers/paybis/mapper.rs b/core/crates/fiat/src/providers/paybis/mapper.rs new file mode 100644 index 0000000000..1c64d1c672 --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/mapper.rs @@ -0,0 +1,500 @@ +use std::collections::HashSet; + +use crate::model::FiatProviderAsset; +use primitives::asset_constants::{ + ARBITRUM_ARB_ASSET_ID, BASE_USDC_ASSET_ID, ETHEREUM_AAVE_ASSET_ID, ETHEREUM_DAI_ASSET_ID, ETHEREUM_LINK_ASSET_ID, ETHEREUM_UNI_ASSET_ID, ETHEREUM_USDC_ASSET_ID, + ETHEREUM_USDT_ASSET_ID, OPTIMISM_OP_ASSET_ID, POLYGON_USDC_ASSET_ID, POLYGON_USDT_ASSET_ID, SOLANA_USDC_ASSET_ID, SOLANA_USDT_ASSET_ID, STELLAR_USDC_ASSET_ID, + TRON_USDT_ASSET_ID, +}; +use primitives::currency::Currency; +use primitives::fiat_assets::FiatAssetLimits; +use primitives::{AssetId, Chain, FiatProviderName, FiatTransactionStatus, FiatTransactionUpdate, PaymentType}; +use streamer::FiatWebhook; + +use super::models::{Currency as PaybisCurrency, PaybisAmount, PaybisWebhook, PaybisWebhookData}; + +pub fn supported_payment_methods() -> Vec { + vec![PaymentType::Card, PaymentType::ApplePay, PaymentType::GooglePay] +} + +fn map_symbol_to_asset_id(symbol: &str) -> Option { + match symbol.to_ascii_uppercase().as_str() { + "BTC" => Some(AssetId::from_chain(Chain::Bitcoin)), + "BCH" => Some(AssetId::from_chain(Chain::BitcoinCash)), + "ETH" => Some(AssetId::from_chain(Chain::Ethereum)), + "XRP" => Some(AssetId::from_chain(Chain::Xrp)), + "SOL" => Some(AssetId::from_chain(Chain::Solana)), + "XLM" => Some(AssetId::from_chain(Chain::Stellar)), + "TRX" => Some(AssetId::from_chain(Chain::Tron)), + "ADA" => Some(AssetId::from_chain(Chain::Cardano)), + "LTC" => Some(AssetId::from_chain(Chain::Litecoin)), + "DOT" => Some(AssetId::from_chain(Chain::Polkadot)), + "CELO" => Some(AssetId::from_chain(Chain::Celo)), + "TON" => Some(AssetId::from_chain(Chain::Ton)), + "DOGE" => Some(AssetId::from_chain(Chain::Doge)), + "AVAX" | "AVAXC" => Some(AssetId::from_chain(Chain::AvalancheC)), + "ETH-BASE" => Some(AssetId::from_chain(Chain::Base)), + "USDC-BASE" => Some(BASE_USDC_ASSET_ID.clone()), + "POL" => Some(AssetId::from_chain(Chain::Polygon)), + "USDC-POLYGON" => Some(POLYGON_USDC_ASSET_ID.clone()), + "USDT-POLYGON" => Some(POLYGON_USDT_ASSET_ID.clone()), + "USDC-SOL" => Some(SOLANA_USDC_ASSET_ID.clone()), + "USDT-SOL" => Some(SOLANA_USDT_ASSET_ID.clone()), + "BONK-SOL" => Some(AssetId::token(Chain::Solana, "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263")), + "USDT-TRC20" => Some(TRON_USDT_ASSET_ID.clone()), + "BNB" | "BNBSC" => Some(AssetId::from_chain(Chain::SmartChain)), + "CAKE" => Some(AssetId::token(Chain::SmartChain, "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82")), + "ONT" => Some(AssetId::token(Chain::SmartChain, "0xFd7B3A77848f1C2D67E05E54d78d174a0C850335")), + "TWT" => Some(AssetId::token(Chain::SmartChain, "0x4B0F1812e5Df2A09796481Ff14017e6005508003")), + "XEC" => Some(AssetId::token(Chain::SmartChain, "0x0Ef2e7602adD1733Bfdb17aC3094d0421B502cA3")), + "ZIL" => Some(AssetId::token(Chain::SmartChain, "0xb86AbCb37C3A4B64f74f59301AFF131a1BEcC787")), + "USDC" => Some(ETHEREUM_USDC_ASSET_ID.clone()), + "USDT" => Some(ETHEREUM_USDT_ASSET_ID.clone()), + "DAI" => Some(ETHEREUM_DAI_ASSET_ID.clone()), + "LINK" => Some(ETHEREUM_LINK_ASSET_ID.clone()), + "AAVE" => Some(ETHEREUM_AAVE_ASSET_ID.clone()), + "UNI" => Some(ETHEREUM_UNI_ASSET_ID.clone()), + "MKR" => Some(AssetId::token(Chain::Ethereum, "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2")), + "COMP" => Some(AssetId::token(Chain::Ethereum, "0xc00e94Cb662C3520282E6f5717214004A7f26888")), + "CRV" => Some(AssetId::token(Chain::Ethereum, "0xD533a949740bb3306d119CC777fa900bA034cd52")), + "LDO" => Some(AssetId::token(Chain::Ethereum, "0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32")), + "ENS" => Some(AssetId::token(Chain::Ethereum, "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72")), + "SUSHI" => Some(AssetId::token(Chain::Ethereum, "0x6B3595068778DD592e39A122f4f5a5cF09C90fE2")), + "SHIB" => Some(AssetId::token(Chain::Ethereum, "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE")), + "PEPE" => Some(AssetId::token(Chain::Ethereum, "0x6982508145454Ce325dDbE47a25d4ec3d2311933")), + "APE" => Some(AssetId::token(Chain::Ethereum, "0x4d224452801ACEd8B2F0aebE155379bb5D594381")), + "SAND" => Some(AssetId::token(Chain::Ethereum, "0x3845badAde8e6dFF049820680d1F14bD3903a5d0")), + "BAT" => Some(AssetId::token(Chain::Ethereum, "0x0D8775F648430679A709E98d2b0Cb6250d2887EF")), + "FET" => Some(AssetId::token(Chain::Ethereum, "0xaea46A60368A7bD060eec7DF8CBa43b7EF41Ad85")), + "IMX" => Some(AssetId::token(Chain::Ethereum, "0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF")), + "CHZ" => Some(AssetId::token(Chain::Ethereum, "0x3506424F91fD33084466F402d5D97f05F8e3b4AF")), + "AXS" => Some(AssetId::token(Chain::Ethereum, "0xBB0E17EF65F82Ab018d8EDd776e8DD940327B28b")), + "DYDX" => Some(AssetId::token(Chain::Ethereum, "0x92D6C1e31e14520e676a687F0a93788B716BEff5")), + "ONEINCH" => Some(AssetId::token(Chain::Ethereum, "0x111111111117dC0aa78b770fA6A738034120C302")), + "GNO" => Some(AssetId::token(Chain::Ethereum, "0x6810e776880C02933D47DB1b9fc05908e5386b96")), + "QNT" => Some(AssetId::token(Chain::Ethereum, "0x4a220E6096B25EADb88358cb44068A3248254675")), + "NEXO" => Some(AssetId::token(Chain::Ethereum, "0xB62132e35a6c13ee1EE0f84dC5d40bad8d815206")), + "HOT" => Some(AssetId::token(Chain::Ethereum, "0x6c6EE5e31d828De241282B9606C8e98Ea48526E2")), + "ACH" => Some(AssetId::token(Chain::Ethereum, "0xEd04915c23f00A313a544955524EB7DBD823143d")), + "AMP" => Some(AssetId::token(Chain::Ethereum, "0xfF20817765cB7f73d4bde2e66e067E58D11095C2")), + "ANKR" => Some(AssetId::token(Chain::Ethereum, "0x8290333ceF9e6D528dD5618Fb97a76f268f3EDD4")), + "AUDIO" => Some(AssetId::token(Chain::Ethereum, "0x18aAA7115705e8be94bfFEBDE57Af9BFc265B998")), + "BICO" => Some(AssetId::token(Chain::Ethereum, "0xF17e65822b568B3903685a7c9F496CF7656Cc6C2")), + "CELR" => Some(AssetId::token(Chain::Ethereum, "0x4F9254C83EB525f9FCf346490bbb3ed28a81C667")), + "CVX" => Some(AssetId::token(Chain::Ethereum, "0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B")), + "FLUX" => Some(AssetId::token(Chain::Ethereum, "0x469eDA64aEd3A3Ad6f868c44564291aA415cB1d9")), + "FXS" => Some(AssetId::token(Chain::Ethereum, "0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0")), + "GLM" => Some(AssetId::token(Chain::Ethereum, "0x7DD9c5Cba05E151C895FDe1CF355C9A1D5DA6429")), + "GTC" => Some(AssetId::token(Chain::Ethereum, "0xDe30da39c46104798bB5aA3fe8B9e0e1F348163F")), + "ILV" => Some(AssetId::token(Chain::Ethereum, "0x767FE9EDC9E0dF98E07454847909b5E959D7ca0E")), + "JASMY" => Some(AssetId::token(Chain::Ethereum, "0x7420B4b9a0110cdC71fB720908340C03F9Bc03EC")), + "KNC" => Some(AssetId::token(Chain::Ethereum, "0xdd974D5C2e2928deA5F71b9825b8b646686BD200")), + "LPT" => Some(AssetId::token(Chain::Ethereum, "0x58b6A8A3302369DAEc383334672404Ee733aB239")), + "MASK" => Some(AssetId::token(Chain::Ethereum, "0x69af81e73A73B40adF4f3d4223Cd9b1ECE623074")), + "NMR" => Some(AssetId::token(Chain::Ethereum, "0x1776e1F26f98b1A5dF9cD347953a26dd3Cb46671")), + "PERP" => Some(AssetId::token(Chain::Ethereum, "0xbC396689893D065F41bc2C6EcbeE5e0085233447")), + "PUNDIX" => Some(AssetId::token(Chain::Ethereum, "0x0FD10b9899882a6f2fcb5c371E17e70FdEe00C38")), + "RPL" => Some(AssetId::token(Chain::Ethereum, "0xD33526068D116cE69F19A9ee46F0bd304F21A51f")), + "SKL" => Some(AssetId::token(Chain::Ethereum, "0x00c83aeCC790e8a4453e5dD3B0B4b3680501a7A7")), + "SSV" => Some(AssetId::token(Chain::Ethereum, "0x9D65fF81a3c488d585bBfb0Bfe3c7707c7917f54")), + "STG" => Some(AssetId::token(Chain::Ethereum, "0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6")), + "STORJ" => Some(AssetId::token(Chain::Ethereum, "0xB64ef51C888972c908CFacf59B47C1AfBC0Ab8aC")), + "SYN" => Some(AssetId::token(Chain::Ethereum, "0x0f2D719407FdBeFF09D87557AbB7232601FD9F29")), + "T" => Some(AssetId::token(Chain::Ethereum, "0xCdF7028ceAB81fA0C6971208e83fa7872994bEE5")), + "WOO" => Some(AssetId::token(Chain::Ethereum, "0x4691937a7508860F876c9c0a2a617E7d9E945D4B")), + "YFI" => Some(AssetId::token(Chain::Ethereum, "0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e")), + "ARB" => Some(ARBITRUM_ARB_ASSET_ID.clone()), + "OP" => Some(OPTIMISM_OP_ASSET_ID.clone()), + "USDC-STELLAR" => Some(STELLAR_USDC_ASSET_ID.clone()), + _ => None, + } +} + +pub fn map_asset_id(currency: PaybisCurrency) -> Option { + if !currency.is_crypto() { + return None; + } + map_symbol_to_asset_id(¤cy.code) +} + +pub fn map_status(status: &str) -> FiatTransactionStatus { + match status { + "started" | "pending" | "confirming" | "payment-authorized" | "paid" => FiatTransactionStatus::Pending, + "completed" | "success" => FiatTransactionStatus::Complete, + "failed" | "cancelled" | "canceled" | "rejected" => FiatTransactionStatus::Failed, + _ => FiatTransactionStatus::Unknown, + } +} + +pub fn map_process_webhook(data: serde_json::Value) -> Result { + let webhook = serde_json::from_value::>(data)?; + if webhook.event != "TRANSACTION_STATUS_CHANGED" { + return Ok(FiatWebhook::None); + } + + let data = serde_json::from_value::(webhook.data)?; + Ok(map_webhook_data(data)) +} + +pub fn map_webhook_data(webhook_data: PaybisWebhookData) -> FiatWebhook { + let transaction_id = webhook_data.partner_transaction_id.clone().unwrap_or_else(|| webhook_data.quote.quote_id.clone()); + let (fiat_amount, fiat_currency) = fiat_side(&webhook_data) + .map(|amount| (amount.amount.parse().ok(), Some(amount.currency.to_ascii_uppercase()))) + .unwrap_or((None, None)); + + FiatWebhook::Transaction(FiatTransactionUpdate { + transaction_id, + provider_transaction_id: Some(webhook_data.transaction.invoice.clone()), + status: map_status(&webhook_data.transaction.status), + transaction_hash: webhook_data.payout.as_ref().and_then(|p| p.transaction_hash.clone()), + fiat_amount, + fiat_currency, + }) +} + +fn fiat_side(webhook_data: &PaybisWebhookData) -> Option<&PaybisAmount> { + match webhook_data.transaction.flow.as_str() { + "buyCrypto" => Some(&webhook_data.amount_from), + "sellCrypto" => Some(&webhook_data.amount_to), + _ => None, + } +} + +fn default_limits() -> Vec { + supported_payment_methods() + .into_iter() + .map(|payment_type| FiatAssetLimits { + currency: Currency::USD, + payment_type, + min_amount: None, + max_amount: None, + }) + .collect() +} + +pub fn map_assets(buy_currencies: Vec, sell_codes: HashSet) -> Vec { + buy_currencies + .into_iter() + .filter_map(|currency| { + if !currency.is_crypto() { + return None; + } + let asset = map_asset_id(currency.clone()); + let is_sell = sell_codes.contains(¤cy.code); + let buy_limits = default_limits(); + let sell_limits = if is_sell { default_limits() } else { vec![] }; + + Some(FiatProviderAsset { + id: currency.code.clone(), + provider: FiatProviderName::Paybis, + chain: asset.as_ref().map(|x| x.chain), + token_id: asset.as_ref().and_then(|x| x.token_id.clone()), + symbol: currency.code.clone(), + network: currency.blockchain_name.clone(), + enabled: true, + is_buy_enabled: true, + is_sell_enabled: is_sell, + unsupported_countries: Some(currency.unsupported_countries()), + buy_limits, + sell_limits, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::paybis::models::{PaybisAmount, PaybisTransaction, PaybisWebhookData, PaybisWebhookQuote}; + use primitives::asset_constants::{ + ARBITRUM_ARB_ASSET_ID, BASE_USDC_ASSET_ID, ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDT_ASSET_ID, OPTIMISM_OP_ASSET_ID, POLYGON_USDC_ASSET_ID, POLYGON_USDT_ASSET_ID, + SOLANA_USDC_ASSET_ID, SOLANA_USDT_ASSET_ID, TRON_USDT_ASSET_ID, + }; + use primitives::{Chain, FiatTransactionStatus, FiatTransactionUpdate}; + + #[test] + fn test_map_asset_id() { + assert_eq!( + map_asset_id(PaybisCurrency { + code: "ETH".to_string(), + blockchain_name: Some("ethereum".to_string()), + }), + Some(AssetId::from_chain(Chain::Ethereum)) + ); + + assert_eq!( + map_asset_id(PaybisCurrency { + code: "BTC".to_string(), + blockchain_name: Some("bitcoin".to_string()), + }), + Some(AssetId::from_chain(Chain::Bitcoin)) + ); + + assert_eq!( + map_asset_id(PaybisCurrency { + code: "UNKNOWN".to_string(), + blockchain_name: Some("unknown-chain".to_string()), + }), + None + ); + + assert_eq!( + map_asset_id(PaybisCurrency { + code: "USD".to_string(), + blockchain_name: None, + }), + None + ); + } + + #[test] + fn test_map_symbol_to_asset_id_coins() { + assert_eq!(map_symbol_to_asset_id("BTC"), Some(AssetId::from_chain(Chain::Bitcoin))); + assert_eq!(map_symbol_to_asset_id("ETH"), Some(AssetId::from_chain(Chain::Ethereum))); + assert_eq!(map_symbol_to_asset_id("TRX"), Some(AssetId::from_chain(Chain::Tron))); + assert_eq!(map_symbol_to_asset_id("XRP"), Some(AssetId::from_chain(Chain::Xrp))); + assert_eq!(map_symbol_to_asset_id("SOL"), Some(AssetId::from_chain(Chain::Solana))); + assert_eq!(map_symbol_to_asset_id("ADA"), Some(AssetId::from_chain(Chain::Cardano))); + assert_eq!(map_symbol_to_asset_id("DOT"), Some(AssetId::from_chain(Chain::Polkadot))); + assert_eq!(map_symbol_to_asset_id("TON"), Some(AssetId::from_chain(Chain::Ton))); + assert_eq!(map_symbol_to_asset_id("DOGE"), Some(AssetId::from_chain(Chain::Doge))); + + assert_eq!(map_symbol_to_asset_id("ARB"), Some(ARBITRUM_ARB_ASSET_ID.clone())); + assert_eq!(map_symbol_to_asset_id("AVAXC"), Some(AssetId::from_chain(Chain::AvalancheC))); + assert_eq!(map_symbol_to_asset_id("POL"), Some(AssetId::from_chain(Chain::Polygon))); + assert_eq!(map_symbol_to_asset_id("BNBSC"), Some(AssetId::from_chain(Chain::SmartChain))); + + assert_eq!(map_symbol_to_asset_id("ETH-BASE"), Some(AssetId::from_chain(Chain::Base))); + + assert_eq!(map_symbol_to_asset_id("UNKNOWN"), None); + } + + #[test] + fn test_map_symbol_to_asset_id_tokens() { + let token_tests = vec![ + ("USDC", ETHEREUM_USDC_ASSET_ID.clone()), + ("USDC-BASE", BASE_USDC_ASSET_ID.clone()), + ("USDC-POLYGON", POLYGON_USDC_ASSET_ID.clone()), + ("USDC-SOL", SOLANA_USDC_ASSET_ID.clone()), + ("USDT", ETHEREUM_USDT_ASSET_ID.clone()), + ("USDT-POLYGON", POLYGON_USDT_ASSET_ID.clone()), + ("USDT-SOL", SOLANA_USDT_ASSET_ID.clone()), + ("USDT-TRC20", TRON_USDT_ASSET_ID.clone()), + ("LINK", AssetId::token(Chain::Ethereum, "0x514910771AF9Ca656af840dff83E8264EcF986CA")), + ("PEPE", AssetId::token(Chain::Ethereum, "0x6982508145454Ce325dDbE47a25d4ec3d2311933")), + ("MKR", AssetId::token(Chain::Ethereum, "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2")), + ("CRV", AssetId::token(Chain::Ethereum, "0xD533a949740bb3306d119CC777fa900bA034cd52")), + ("COMP", AssetId::token(Chain::Ethereum, "0xc00e94Cb662C3520282E6f5717214004A7f26888")), + ("CAKE", AssetId::token(Chain::SmartChain, "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82")), + ("BONK-SOL", AssetId::token(Chain::Solana, "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263")), + ("OP", OPTIMISM_OP_ASSET_ID.clone()), + ]; + + for (symbol, expected) in token_tests { + let result = map_symbol_to_asset_id(symbol); + assert_eq!(result, Some(expected), "Failed for symbol: {}", symbol); + } + } + + #[test] + fn test_map_process_webhook() { + let webhook_json: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/paybis/webhook_transaction_started.json")).unwrap(); + + let result = map_process_webhook(webhook_json).unwrap(); + let FiatWebhook::Transaction(transaction) = result else { + panic!("Expected FiatWebhook::Transaction variant"); + }; + + assert_eq!( + transaction, + FiatTransactionUpdate { + transaction_id: "a4a211ad-3bcf-47d9-b4ae-073e841e3e7a".to_string(), + provider_transaction_id: Some("PB21095868675TX1".to_string()), + status: FiatTransactionStatus::Pending, + transaction_hash: None, + fiat_amount: Some(50.0), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_map_process_webhook_with_payment() { + let webhook_json: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/paybis/webhook_transaction_started.json")).unwrap(); + + let result = map_process_webhook(webhook_json).unwrap(); + let FiatWebhook::Transaction(transaction) = result else { + panic!("Expected FiatWebhook::Transaction variant"); + }; + + assert_eq!( + transaction, + FiatTransactionUpdate { + transaction_id: "a4a211ad-3bcf-47d9-b4ae-073e841e3e7a".to_string(), + provider_transaction_id: Some("PB21095868675TX1".to_string()), + status: FiatTransactionStatus::Pending, + transaction_hash: None, + fiat_amount: Some(50.0), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_map_process_webhook_no_payment() { + let webhook_json: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/paybis/webhook_transaction_started_no_payment.json")).unwrap(); + + let result = map_process_webhook(webhook_json).unwrap(); + let FiatWebhook::Transaction(transaction) = result else { + panic!("Expected FiatWebhook::Transaction variant"); + }; + + assert_eq!( + transaction, + FiatTransactionUpdate { + transaction_id: "59b799d4-dc8c-458d-b9c7-292726ab6255".to_string(), + provider_transaction_id: Some("PB25095868675TX8".to_string()), + status: FiatTransactionStatus::Pending, + transaction_hash: None, + fiat_amount: Some(50.0), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_map_webhook_data_sell_uses_amount_to_currency() { + let result = map_webhook_data(PaybisWebhookData { + partner_transaction_id: Some("partner_tx_123".to_string()), + quote: PaybisWebhookQuote { + quote_id: "quote_456".to_string(), + }, + transaction: PaybisTransaction { + invoice: "invoice_123".to_string(), + status: "completed".to_string(), + flow: "sellCrypto".to_string(), + }, + amount_from: PaybisAmount { + amount: "0.5".to_string(), + currency: "BTC".to_string(), + }, + amount_to: PaybisAmount { + amount: "1234.56".to_string(), + currency: "EUR".to_string(), + }, + payout: None, + }); + + let FiatWebhook::Transaction(transaction) = result else { + panic!("Expected FiatWebhook::Transaction variant"); + }; + + assert_eq!( + transaction, + FiatTransactionUpdate { + transaction_id: "partner_tx_123".to_string(), + provider_transaction_id: Some("invoice_123".to_string()), + status: FiatTransactionStatus::Complete, + transaction_hash: None, + fiat_amount: Some(1234.56), + fiat_currency: Some("EUR".to_string()), + } + ); + } + + #[test] + fn test_map_process_webhook_completed_with_transaction_hash() { + let data: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/paybis/webhook_transaction_completed.json")).unwrap(); + + let result = map_process_webhook(data).unwrap(); + let FiatWebhook::Transaction(transaction) = result else { + panic!("Expected FiatWebhook::Transaction variant"); + }; + + assert_eq!( + transaction, + FiatTransactionUpdate { + transaction_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".to_string(), + provider_transaction_id: Some("PBXXXXXXXXXXTXX".to_string()), + status: FiatTransactionStatus::Complete, + transaction_hash: Some("paybis_test_tx_hash".to_string()), + fiat_amount: Some(50.0), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_verification_webhook_maps_to_none() { + let data: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/paybis/webhook_transaction_no_changes.json")).unwrap(); + + let result = map_process_webhook(data).unwrap(); + assert!(matches!(result, FiatWebhook::None), "Verification webhooks should map to FiatWebhook::None"); + } + + #[test] + fn test_malformed_transaction_webhook_returns_error() { + let data = serde_json::json!({ + "event": "TRANSACTION_STATUS_CHANGED", + "data": { + "quote": { "quoteId": "quote_123" } + } + }); + + assert!(map_process_webhook(data).is_err()); + } + + #[test] + fn test_default_limits() { + let limits = default_limits(); + + assert_eq!(limits.len(), 3); + assert!(limits.iter().all(|limit| limit.currency == Currency::USD)); + assert!(limits.iter().all(|limit| limit.min_amount.is_none())); + assert!(limits.iter().all(|limit| limit.max_amount.is_none())); + assert!(limits.iter().any(|limit| limit.payment_type == PaymentType::Card)); + assert!(limits.iter().any(|limit| limit.payment_type == PaymentType::ApplePay)); + assert!(limits.iter().any(|limit| limit.payment_type == PaymentType::GooglePay)); + } + + #[test] + fn test_map_assets_buy_and_sell() { + let buy_currencies = vec![ + PaybisCurrency { + code: "ETH".to_string(), + blockchain_name: Some("ethereum".to_string()), + }, + PaybisCurrency { + code: "BTC".to_string(), + blockchain_name: Some("bitcoin".to_string()), + }, + PaybisCurrency { + code: "SOL".to_string(), + blockchain_name: Some("solana".to_string()), + }, + ]; + let sell_codes: HashSet = ["ETH".to_string(), "SOL".to_string()].into_iter().collect(); + + let assets = map_assets(buy_currencies, sell_codes); + + let eth = assets.iter().find(|a| a.symbol == "ETH").unwrap(); + assert!(eth.is_buy_enabled); + assert!(eth.is_sell_enabled); + assert_eq!(eth.buy_limits.len(), 3); + assert!(eth.buy_limits.iter().any(|limit| limit.payment_type == PaymentType::ApplePay)); + assert!(eth.buy_limits.iter().any(|limit| limit.payment_type == PaymentType::GooglePay)); + assert_eq!(eth.sell_limits.len(), 3); + + let btc = assets.iter().find(|a| a.symbol == "BTC").unwrap(); + assert!(btc.is_buy_enabled); + assert!(!btc.is_sell_enabled); + assert_eq!(btc.buy_limits.len(), 3); + assert!(btc.sell_limits.is_empty()); + + let sol = assets.iter().find(|a| a.symbol == "SOL").unwrap(); + assert!(sol.is_buy_enabled); + assert!(sol.is_sell_enabled); + assert_eq!(sol.buy_limits.len(), 3); + assert_eq!(sol.sell_limits.len(), 3); + } +} diff --git a/core/crates/fiat/src/providers/paybis/mod.rs b/core/crates/fiat/src/providers/paybis/mod.rs new file mode 100644 index 0000000000..ae5586f14f --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod mapper; +pub mod models; +pub mod provider; diff --git a/core/crates/fiat/src/providers/paybis/models/asset.rs b/core/crates/fiat/src/providers/paybis/models/asset.rs new file mode 100644 index 0000000000..794402a0c1 --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/models/asset.rs @@ -0,0 +1,51 @@ +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone, Deserialize)] +pub struct Assets { + pub meta: MetaData, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MetaData { + pub currencies: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Currency { + pub code: String, + pub blockchain_name: Option, +} + +impl Currency { + pub fn is_crypto(&self) -> bool { + self.blockchain_name.is_some() + } + + pub fn unsupported_countries(&self) -> HashMap> { + HashMap::new() + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SellAssets { + pub data: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PayoutMethod { + pub pairs: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayoutPair { + pub from_asset_id: String, +} + +impl SellAssets { + pub fn get_crypto_codes(&self) -> HashSet { + self.data.iter().flat_map(|method| method.pairs.iter().map(|pair| pair.from_asset_id.clone())).collect() + } +} diff --git a/core/crates/fiat/src/providers/paybis/models/country.rs b/core/crates/fiat/src/providers/paybis/models/country.rs new file mode 100644 index 0000000000..78a63cc06c --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/models/country.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; + +pub fn country_status() -> HashMap<&'static str, bool> { + let mut map = HashMap::new(); + + let supported = [ + "AD", "AE", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", + "BQ", "BR", "BS", "BT", "BV", "BW", "BZ", "CA", "CC", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", + "EE", "EG", "ER", "ES", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", + "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", "KW", "KY", + "KZ", "LA", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", + "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PT", "PW", "PY", + "QA", "RE", "RO", "RS", "RW", "SA", "SB", "SC", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SR", "ST", "SV", "SX", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", + "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VG", "VI", "VN", "VU", "WF", "WS", "YT", "ZA", "ZM", "ZW", + ]; + + let restricted = [ + "AF", "BY", "CD", "CF", "CU", "EH", "ET", "IQ", "IR", "KP", "LB", "LY", "ML", "MM", "NI", "PS", "RU", "SD", "SO", "SS", "SY", "YE", + ]; + + for country in supported { + map.insert(country, true); + } + + for country in restricted { + map.insert(country, false); + } + + map +} diff --git a/core/crates/fiat/src/providers/paybis/models/limits.rs b/core/crates/fiat/src/providers/paybis/models/limits.rs new file mode 100644 index 0000000000..8bdc67f263 --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/models/limits.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaybisData { + pub data: T, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum PaybisResponse { + Success(T), + Error(PaybisError), +} + +impl From> for Result> { + fn from(resp: PaybisResponse) -> Self { + match resp { + PaybisResponse::Success(data) => Ok(data), + PaybisResponse::Error(error) => Err(error.into_error()), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PaybisError { + pub message: String, + pub code: String, +} + +impl PaybisError { + pub fn into_error(self) -> Box { + format!("Paybis API error [{}]: {}", self.code, self.message).into() + } +} diff --git a/core/crates/fiat/src/providers/paybis/models/mod.rs b/core/crates/fiat/src/providers/paybis/models/mod.rs new file mode 100644 index 0000000000..84fcafb041 --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/models/mod.rs @@ -0,0 +1,13 @@ +pub mod asset; +pub mod country; +pub mod limits; +pub mod quote; +pub mod request; +pub mod webhook; + +pub use asset::*; +pub use country::*; +pub use limits::*; +pub use quote::*; +pub use request::*; +pub use webhook::*; diff --git a/core/crates/fiat/src/providers/paybis/models/quote.rs b/core/crates/fiat/src/providers/paybis/models/quote.rs new file mode 100644 index 0000000000..cd43f2b764 --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/models/quote.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct QuoteRequest { + pub amount: String, + pub direction_change: String, + pub is_received_amount: bool, + pub currency_code_from: String, + pub currency_code_to: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaybisQuote { + pub id: String, + pub currency_code_to: String, + #[serde(default)] + pub payment_methods: Vec, + #[serde(default)] + pub payout_methods: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentMethod { + pub amount_from: AmountInfo, + pub amount_to: AmountInfo, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AmountInfo { + pub amount: String, +} diff --git a/core/crates/fiat/src/providers/paybis/models/request.rs b/core/crates/fiat/src/providers/paybis/models/request.rs new file mode 100644 index 0000000000..9b6b8d9b7d --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/models/request.rs @@ -0,0 +1,134 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CryptoWalletAddress { + pub address: String, + pub currency_code: String, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub partner_user_id: String, + pub partner_transaction_id: Option, + pub crypto_wallet_address: CryptoWalletAddress, + pub currency_code_from: String, + pub currency_code_to: String, + pub quote_id: String, + pub user_ip: String, + pub locale: String, + pub flow: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestResponse { + pub request_id: String, +} + +impl Request { + fn new( + partner_user_id: String, + wallet_address: String, + wallet_currency_code: String, + currency_code_from: String, + currency_code_to: String, + quote_id: String, + user_ip: String, + locale: String, + flow: String, + ) -> Self { + Self { + partner_user_id, + partner_transaction_id: Some(quote_id.clone()), + crypto_wallet_address: CryptoWalletAddress { + address: wallet_address, + currency_code: wallet_currency_code, + }, + currency_code_from, + currency_code_to, + quote_id, + user_ip, + locale, + flow, + } + } + + pub fn new_sell(partner_user_id: String, wallet_address: String, crypto_currency: String, fiat_currency: String, quote_id: String, user_ip: String, locale: String) -> Self { + Self::new( + partner_user_id, + wallet_address, + crypto_currency.clone(), + crypto_currency, + fiat_currency, + quote_id, + user_ip, + locale, + "sellCrypto".to_string(), + ) + } + + pub fn new_buy(partner_user_id: String, wallet_address: String, crypto_currency: String, fiat_currency: String, quote_id: String, user_ip: String, locale: String) -> Self { + Self::new( + partner_user_id, + wallet_address, + crypto_currency.clone(), + fiat_currency, + crypto_currency, + quote_id, + user_ip, + locale, + "buyCrypto".to_string(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_buy_serializes_flow_and_currency_direction() { + let request = Request::new_buy( + "test-user-id".to_string(), + "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H".to_string(), + "SOL".to_string(), + "USD".to_string(), + "test-quote-id".to_string(), + "1.2.3.4".to_string(), + "en".to_string(), + ); + + let parsed = serde_json::to_value(&request).unwrap(); + + assert_eq!(parsed["cryptoWalletAddress"]["currencyCode"], "SOL"); + assert_eq!(parsed["currencyCodeFrom"], "USD"); + assert_eq!(parsed["currencyCodeTo"], "SOL"); + assert_eq!(parsed["partnerTransactionId"], "test-quote-id"); + assert_eq!(parsed["quoteId"], "test-quote-id"); + assert_eq!(parsed["flow"], "buyCrypto"); + } + + #[test] + fn request_sell_serializes_flow_and_currency_direction() { + let request = Request::new_sell( + "test-user-id".to_string(), + "0x1234567890abcdef".to_string(), + "ETH".to_string(), + "USD".to_string(), + "test-quote-id".to_string(), + "1.2.3.4".to_string(), + "en".to_string(), + ); + + let parsed = serde_json::to_value(&request).unwrap(); + + assert_eq!(parsed["cryptoWalletAddress"]["currencyCode"], "ETH"); + assert_eq!(parsed["currencyCodeFrom"], "ETH"); + assert_eq!(parsed["currencyCodeTo"], "USD"); + assert_eq!(parsed["partnerTransactionId"], "test-quote-id"); + assert_eq!(parsed["quoteId"], "test-quote-id"); + assert_eq!(parsed["flow"], "sellCrypto"); + } +} diff --git a/core/crates/fiat/src/providers/paybis/models/webhook.rs b/core/crates/fiat/src/providers/paybis/models/webhook.rs new file mode 100644 index 0000000000..857ab59ad3 --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/models/webhook.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct PaybisWebhook { + pub event: String, + pub data: T, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaybisWebhookData { + pub partner_transaction_id: Option, + pub quote: PaybisWebhookQuote, + pub transaction: PaybisTransaction, + pub amount_from: PaybisAmount, + pub amount_to: PaybisAmount, + pub payout: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PaybisPayout { + pub transaction_hash: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PaybisTransaction { + pub invoice: String, + pub status: String, + pub flow: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaybisWebhookQuote { + pub quote_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PaybisAmount { + pub amount: String, + pub currency: String, +} diff --git a/core/crates/fiat/src/providers/paybis/provider.rs b/core/crates/fiat/src/providers/paybis/provider.rs new file mode 100644 index 0000000000..e0fb1c82b6 --- /dev/null +++ b/core/crates/fiat/src/providers/paybis/provider.rs @@ -0,0 +1,252 @@ +use crate::{ + FiatProvider, + error::FiatQuoteError, + model::{FiatMapping, FiatProviderAsset}, +}; +use async_trait::async_trait; +use std::error::Error; + +use super::models::country::country_status; +use super::{ + client::PaybisClient, + mapper::{map_assets, map_process_webhook, supported_payment_methods}, +}; +use primitives::{FiatProviderCountry, FiatProviderName, FiatQuoteRequest, FiatQuoteResponse, FiatQuoteUrl, FiatQuoteUrlData, PaymentType}; +use streamer::FiatWebhook; + +#[async_trait] +impl FiatProvider for PaybisClient { + fn name(&self) -> FiatProviderName { + Self::NAME + } + + async fn payment_methods(&self) -> Vec { + supported_payment_methods() + } + + async fn get_assets(&self) -> Result, Box> { + let buy_assets = PaybisClient::get_buy_assets(self).await?; + let sell_assets = PaybisClient::get_sell_assets(self).await?; + let buy_currencies = buy_assets.meta.currencies; + let sell_currencies = sell_assets.get_crypto_codes(); + Ok(map_assets(buy_currencies, sell_currencies)) + } + + async fn get_countries(&self) -> Result, Box> { + let countries = country_status() + .iter() + .map(|(alpha2, is_allowed)| FiatProviderCountry { + provider: Self::NAME, + alpha2: alpha2.to_string(), + is_allowed: *is_allowed, + }) + .collect(); + + Ok(countries) + } + + async fn process_webhook(&self, data: serde_json::Value) -> Result> { + Ok(map_process_webhook(data)?) + } + + async fn get_quote_buy(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let quote = self.get_buy_quote(request_map.asset_symbol.symbol, request.currency.to_uppercase(), request.amount).await?; + + let payment_method = quote + .payment_methods + .first() + .ok_or_else(|| FiatQuoteError::UnsupportedState("No payment methods available".to_string()))?; + let crypto_amount: f64 = payment_method.amount_to.amount.parse()?; + + Ok(FiatQuoteResponse::new(quote.id, request.amount, crypto_amount)) + } + + async fn get_quote_sell(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let quote = self + .get_sell_quote(request_map.asset_symbol.symbol, request.currency.to_uppercase(), request.amount) + .await?; + + let payout_method = quote + .payout_methods + .first() + .ok_or_else(|| FiatQuoteError::UnsupportedState("No payout methods available".to_string()))?; + let crypto_amount: f64 = payout_method.amount_from.amount.parse()?; + + Ok(FiatQuoteResponse::new(quote.id, request.amount, crypto_amount)) + } + + async fn get_quote_url(&self, data: FiatQuoteUrlData) -> Result> { + let is_buy = match data.quote.quote_type { + primitives::FiatQuoteType::Buy => true, + primitives::FiatQuoteType::Sell => false, + }; + let redirect_url = self + .get_redirect_url( + &data.wallet_address, + &data.quote.fiat_currency, + &data.asset_symbol.symbol, + &data.quote.id, + is_buy, + &data.ip_address, + &data.locale, + ) + .await?; + + Ok(FiatQuoteUrl { + redirect_url, + provider_transaction_id: None, + }) + } +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +mod fiat_integration_tests { + use crate::testkit::*; + use crate::{FiatProvider, model::FiatMapping}; + use primitives::asset_constants::{ + BASE_USDC_TOKEN_ID, ETHEREUM_USDC_TOKEN_ID, ETHEREUM_USDT_TOKEN_ID, POLYGON_USDC_TOKEN_ID, POLYGON_USDT_TOKEN_ID, SOLANA_USDC_TOKEN_ID, SOLANA_USDT_TOKEN_ID, + TRON_USDT_TOKEN_ID, + }; + use primitives::currency::Currency; + use primitives::{Chain, FiatProviderName, FiatQuoteRequest, FiatTransactionStatus, FiatTransactionUpdate}; + use streamer::FiatWebhook; + + #[tokio::test] + async fn test_paybis_get_buy_quote() -> Result<(), Box> { + let client = create_paybis_test_client(); + + let request = FiatQuoteRequest::mock(); + let mut mapping = FiatMapping::mock(); + mapping.asset_symbol.network = Some("bitcoin".to_string()); + + let quote = FiatProvider::get_quote_buy(&client, request.clone(), mapping).await?; + + println!("Paybis buy quote: {:?}", quote); + assert!(!quote.quote_id.is_empty()); + assert!(quote.crypto_amount > 0.0); + assert_eq!(quote.fiat_amount, request.amount); + + Ok(()) + } + + #[tokio::test] + #[ignore = "Paybis does not currently support sell for most assets"] + async fn test_paybis_get_sell_quote() -> Result<(), Box> { + let client = create_paybis_test_client(); + + let request = FiatQuoteRequest::mock_sell(); + let mut mapping = FiatMapping::mock(); + mapping.asset_symbol.symbol = "ETH".to_string(); + mapping.asset_symbol.network = Some("ethereum".to_string()); + + let quote = FiatProvider::get_quote_sell(&client, request.clone(), mapping).await?; + + println!("Paybis sell quote: {:?}", quote); + assert!(!quote.quote_id.is_empty()); + assert_eq!(quote.fiat_amount, request.amount); + assert!(quote.crypto_amount > 0.0); + + Ok(()) + } + + #[tokio::test] + async fn test_paybis_get_assets() -> Result<(), Box> { + let client = create_paybis_test_client(); + let result = FiatProvider::get_assets(&client).await?; + + assert!(!result.is_empty()); + + let expected_assets = vec![ + ("USDT-TRC20", Chain::Tron, Some(TRON_USDT_TOKEN_ID.to_string())), + ("USDT-SOL", Chain::Solana, Some(SOLANA_USDT_TOKEN_ID.to_string())), + ("USDT-POLYGON", Chain::Polygon, Some(POLYGON_USDT_TOKEN_ID.to_string())), + ("USDT", Chain::Ethereum, Some(ETHEREUM_USDT_TOKEN_ID.to_string())), + ("USDC-SOL", Chain::Solana, Some(SOLANA_USDC_TOKEN_ID.to_string())), + ("USDC-POLYGON", Chain::Polygon, Some(POLYGON_USDC_TOKEN_ID.to_string())), + ("USDC-BASE", Chain::Base, Some(BASE_USDC_TOKEN_ID.to_string())), + ("USDC", Chain::Ethereum, Some(ETHEREUM_USDC_TOKEN_ID.to_string())), + ("TRX", Chain::Tron, None), + ("XRP", Chain::Xrp, None), + ]; + + for (symbol, expected_chain, expected_token_id) in expected_assets { + let asset = result.iter().find(|asset| asset.symbol == symbol); + assert!(asset.is_some(), "{} asset should exist", symbol); + + if let Some(asset) = asset { + assert_eq!(asset.chain, Some(expected_chain)); + assert_eq!(asset.token_id, expected_token_id); + + println!("{} asset: {:?}", symbol, asset); + } + } + + let usdt_trc20_asset = result.iter().find(|asset| asset.symbol == "USDT-TRC20"); + if let Some(asset) = usdt_trc20_asset { + assert!(!asset.buy_limits.is_empty(), "USDT-TRC20 should have buy limits"); + let usd_buy_limit = asset.buy_limits.iter().find(|limit| limit.currency == Currency::USD); + assert!(usd_buy_limit.is_some(), "Should have USD limit with Card payment type"); + } + + println!("Found {} assets", result.len()); + + Ok(()) + } + + #[tokio::test] + async fn test_paybis_get_countries() -> Result<(), Box> { + let client = create_paybis_test_client(); + let countries = FiatProvider::get_countries(&client).await?; + + assert!(!countries.is_empty()); + + let us_country = countries.iter().find(|c| c.alpha2 == "US").unwrap(); + assert!(us_country.is_allowed); + assert_eq!(us_country.provider, FiatProviderName::Paybis); + + let ly_country = countries.iter().find(|c| c.alpha2 == "LY").unwrap(); + assert!(!ly_country.is_allowed); + assert_eq!(ly_country.provider, FiatProviderName::Paybis); + + Ok(()) + } + + #[tokio::test] + async fn test_process_webhook_verification_maps_to_none() -> Result<(), Box> { + let client = create_paybis_test_client(); + let verification_webhook: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/paybis/webhook_transaction_no_changes.json"))?; + + let result = client.process_webhook(verification_webhook).await?; + match result { + FiatWebhook::None => {} + _ => panic!("Verification webhooks should map to FiatWebhook::None"), + } + + Ok(()) + } + + #[tokio::test] + async fn test_process_webhook_transaction() -> Result<(), Box> { + let client = create_paybis_test_client(); + let transaction_webhook: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/paybis/webhook_transaction_started.json"))?; + + let result = client.process_webhook(transaction_webhook).await?; + let FiatWebhook::Transaction(transaction) = result else { + panic!("Expected FiatWebhook::Transaction variant"); + }; + + assert_eq!( + transaction, + FiatTransactionUpdate { + transaction_id: "a4a211ad-3bcf-47d9-b4ae-073e841e3e7a".to_string(), + provider_transaction_id: Some("PB21095868675TX1".to_string()), + status: FiatTransactionStatus::Pending, + transaction_hash: None, + fiat_amount: Some(50.0), + fiat_currency: Some("USD".to_string()), + } + ); + + Ok(()) + } +} diff --git a/core/crates/fiat/src/providers/transak/client.rs b/core/crates/fiat/src/providers/transak/client.rs new file mode 100644 index 0000000000..17956d5e47 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/client.rs @@ -0,0 +1,198 @@ +use super::models::{Asset, CachedToken, Country, CreateWidgetUrlRequest, CreateWidgetUrlResponse, Data, FiatCurrency, Response, TokenResponse, TransakQuote, TransakResponse}; +use gem_encoding::decode_base64_url; +use primitives::{FiatProviderName, FiatQuoteType}; +use reqwest::Client; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; + +const TRANSAK_API_URL: &str = "https://api.transak.com"; +const TRANSAK_API_GATEWAY_URL: &str = "https://api-gateway.transak.com"; +const TOKEN_TTL_SECONDS: u64 = 3600; + +#[derive(Debug, Clone)] +pub struct TransakClient { + pub client: Client, + pub api_key: String, + pub api_secret: String, + pub referrer_domain: String, + cached_token: Arc>>, +} + +impl TransakClient { + pub const NAME: FiatProviderName = FiatProviderName::Transak; + + pub fn new(client: Client, api_key: String, api_secret: String, referrer_domain: String) -> Self { + TransakClient { + client, + api_key, + api_secret, + referrer_domain, + cached_token: Arc::new(Mutex::new(None)), + } + } + + pub async fn get_buy_quote( + &self, + symbol: String, + fiat_currency: String, + fiat_amount: f64, + network: String, + ip_address: String, + ) -> Result> { + self.get_quote("buy", symbol, fiat_currency, Some(fiat_amount), None, network, ip_address).await + } + + pub async fn get_sell_quote( + &self, + symbol: String, + fiat_currency: String, + fiat_amount: f64, + network: String, + ip_address: String, + ) -> Result> { + let buy_quote = self + .get_buy_quote(symbol.clone(), fiat_currency.clone(), fiat_amount, network.clone(), ip_address.clone()) + .await?; + + let sell_quote = self + .get_quote( + "sell", + symbol.clone(), + fiat_currency.clone(), + None, + Some(&buy_quote.crypto_amount.to_string()), + network.clone(), + ip_address.clone(), + ) + .await?; + + let crypto_amount = sell_quote.sell_crypto_amount(fiat_amount); + self.get_quote("sell", symbol, fiat_currency, None, Some(&crypto_amount.to_string()), network, ip_address) + .await + } + + pub async fn get_quote( + &self, + quote_type: &str, + symbol: String, + fiat_currency: String, + fiat_amount: Option, + crypto_amount: Option<&str>, + network: String, + country_code: String, + ) -> Result> { + let url = format!("{TRANSAK_API_URL}/api/v1/pricing/public/quotes"); + let mut query = vec![ + ("isBuyOrSell", quote_type.to_string()), + ("quoteCountryCode", country_code.to_string()), + ("fiatCurrency", fiat_currency.to_string()), + ("cryptoCurrency", symbol.to_string()), + ("network", network.to_string()), + ("partnerApiKey", self.api_key.to_string()), + ]; + if let Some(amount) = fiat_amount { + query.push(("fiatAmount", amount.to_string())); + } + if let Some(amount) = crypto_amount { + query.push(("cryptoAmount", amount.to_string())); + } + + self.client.get(url).query(&query).send().await?.json::>().await?.into() + } + + pub async fn create_widget_url(&self, params: HashMap) -> Result { + let access_token = self.get_access_token().await?; + let url = format!("{TRANSAK_API_GATEWAY_URL}/api/v2/auth/session"); + + let request_body = CreateWidgetUrlRequest { params }; + + let response: Data = self + .client + .post(&url) + .header("access-token", &access_token) + .json(&request_body) + .send() + .await? + .json() + .await?; + + Ok(response.data.widget_url) + } + + pub async fn redirect_url(&self, quote: TransakQuote, address: String, quote_type: FiatQuoteType, fiat_amount: f64) -> Result { + let mut params: HashMap = HashMap::new(); + params.insert("apiKey".to_string(), json!(self.api_key)); + params.insert("referrerDomain".to_string(), json!(self.referrer_domain)); + params.insert("fiatCurrency".to_string(), json!(quote.fiat_currency)); + params.insert("cryptoCurrencyCode".to_string(), json!(quote.crypto_currency)); + params.insert("network".to_string(), json!(quote.network)); + params.insert("disableWalletAddressForm".to_string(), json!(true)); + params.insert("walletAddress".to_string(), json!(address)); + + match quote_type { + FiatQuoteType::Buy => { + params.insert("productsAvailed".to_string(), json!("BUY")); + params.insert("fiatAmount".to_string(), json!(fiat_amount)); + } + FiatQuoteType::Sell => { + params.insert("productsAvailed".to_string(), json!("SELL")); + params.insert("cryptoAmount".to_string(), json!(quote.sell_crypto_amount(fiat_amount))); + } + } + + self.create_widget_url(params).await + } + + pub async fn get_supported_assets(&self) -> Result>, reqwest::Error> { + let url = format!("{TRANSAK_API_URL}/cryptocoverage/api/v1/public/crypto-currencies"); + self.client.get(&url).send().await?.json().await + } + + pub async fn get_countries(&self) -> Result>, reqwest::Error> { + let url = format!("{TRANSAK_API_URL}/api/v2/countries"); + self.client.get(&url).send().await?.json().await + } + + pub async fn get_fiat_currencies(&self) -> Result>, reqwest::Error> { + let url = format!("{TRANSAK_API_URL}/fiat/public/v1/currencies/fiat-currencies"); + self.client.get(&url).send().await?.json().await + } + + async fn get_access_token(&self) -> Result { + let mut token_guard = self.cached_token.lock().await; + + if let Some(cached) = token_guard.as_ref() + && cached.is_valid() + { + return Ok(cached.access_token.clone()); + } + + let access_token = self.refresh_token_internal().await?; + let cached = CachedToken::new(access_token.clone(), TOKEN_TTL_SECONDS); + *token_guard = Some(cached); + + Ok(access_token) + } + + async fn refresh_token_internal(&self) -> Result { + let url = format!("{TRANSAK_API_URL}/partners/api/v2/refresh-token?apiKey={}", self.api_key); + let body = serde_json::json!({ + "apiKey": self.api_key + }); + + let response: Data = self.client.post(&url).header("api-secret", &self.api_secret).json(&body).send().await?.json().await?; + + Ok(response.data.access_token) + } + pub fn decode_jwt_content(&self, jwt: &str) -> Result> { + let parts: Vec<&str> = jwt.split('.').collect(); + if parts.len() != 3 { + return Err("Invalid JWT format".to_string().into()); + } + let payload = parts[1]; + let payload = decode_base64_url(payload)?; + Ok(String::from_utf8(payload)?) + } +} diff --git a/core/crates/fiat/src/providers/transak/mapper.rs b/core/crates/fiat/src/providers/transak/mapper.rs new file mode 100644 index 0000000000..0439e582f6 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/mapper.rs @@ -0,0 +1,210 @@ +use super::models::{Asset, FiatCurrency, TransakOrderResponse}; +use crate::model::{FiatProviderAsset, filter_token_id}; +use primitives::PaymentType; +use primitives::currency::Currency; +use primitives::fiat_assets::FiatAssetLimits; +use primitives::{Chain, FiatProviderName, FiatQuoteType, FiatTransactionStatus, FiatTransactionUpdate}; + +pub fn map_asset_chain(network: &str, coin_id: Option<&str>) -> Option { + match network { + "ethereum" => Some(Chain::Ethereum), + "polygon" => Some(Chain::Polygon), + "aptos" => Some(Chain::Aptos), + "sui" => Some(Chain::Sui), + "arbitrum" => Some(Chain::Arbitrum), + "optimism" => Some(Chain::Optimism), + "base" => Some(Chain::Base), + "bsc" => Some(Chain::SmartChain), + "tron" => Some(Chain::Tron), + "solana" => Some(Chain::Solana), + "avaxcchain" => Some(Chain::AvalancheC), + "ton" => Some(Chain::Ton), + "osmosis" => Some(Chain::Osmosis), + "fantom" => Some(Chain::Fantom), + "injective" => Some(Chain::Injective), + "sei" => Some(Chain::Sei), + "linea" => Some(Chain::Linea), + "zksync" => Some(Chain::ZkSync), + "celo" => Some(Chain::Celo), + "mantle" => Some(Chain::Mantle), + "opbnb" => Some(Chain::OpBNB), + "unichain" => Some(Chain::Unichain), + "stellar" => Some(Chain::Stellar), + "algorand" => Some(Chain::Algorand), + "berachain" => Some(Chain::Berachain), + "hyperevm" => Some(Chain::Hyperliquid), + "hyperliquid" => Some(Chain::HyperCore), + "monad" => Some(Chain::Monad), + "plasma" => Some(Chain::Plasma), + "mainnet" => match coin_id? { + "bitcoin" => Some(Chain::Bitcoin), + "litecoin" => Some(Chain::Litecoin), + "ripple" => Some(Chain::Xrp), + "dogecoin" => Some(Chain::Doge), + "tron" => Some(Chain::Tron), + "cosmos" => Some(Chain::Cosmos), + "near" => Some(Chain::Near), + "stellar" => Some(Chain::Stellar), + "algorand" => Some(Chain::Algorand), + "polkadot" => Some(Chain::Polkadot), + "cardano" => Some(Chain::Cardano), + _ => None, + }, + _ => None, + } +} + +fn map_status(status: &str) -> FiatTransactionStatus { + match status { + "ORDER_PAYMENT_VERIFYING" | "PAYMENT_DONE_MARKED_BY_USER" | "PENDING_DELIVERY_FROM_TRANSAK" | "AWAITING_PAYMENT_FROM_USER" | "PROCESSING" => FiatTransactionStatus::Pending, + "EXPIRED" | "FAILED" | "CANCELLED" | "REFUNDED" => FiatTransactionStatus::Failed, + "COMPLETED" => FiatTransactionStatus::Complete, + _ => FiatTransactionStatus::Unknown, + } +} + +pub fn map_order_from_response(payload: TransakOrderResponse) -> FiatTransactionUpdate { + let transaction_id = payload.quote_id.clone().unwrap_or_else(|| payload.id.clone()); + let provider_transaction_id = (transaction_id != payload.id).then_some(payload.id.clone()); + + FiatTransactionUpdate { + transaction_id, + provider_transaction_id, + status: map_status(&payload.status), + transaction_hash: payload.transaction_hash, + fiat_amount: Some(payload.fiat_amount), + fiat_currency: Some(payload.fiat_currency.to_ascii_uppercase()), + } +} +fn map_limits(fiat_currencies: &[FiatCurrency], quote_type: FiatQuoteType) -> Vec { + fiat_currencies + .iter() + .filter_map(|fiat_currency| fiat_currency.symbol.parse::().ok().map(|currency| (currency, fiat_currency))) + .flat_map(|(currency, fiat_currency)| { + fiat_currency + .payment_options + .iter() + .filter_map(|payment_option| { + if !payment_option.is_active { + return None; + } + let payment_type = map_payment_type(&payment_option.id)?; + let (min_amount, max_amount) = match quote_type { + FiatQuoteType::Buy => (payment_option.min_amount, payment_option.max_amount), + FiatQuoteType::Sell => (payment_option.min_amount_for_pay_out, payment_option.max_amount_for_pay_out), + }; + Some(FiatAssetLimits { + currency: currency.clone(), + payment_type, + min_amount, + max_amount, + }) + }) + .collect::>() + }) + .collect() +} + +pub fn map_asset(asset: Asset) -> Option { + let chain = map_asset_chain(&asset.network.name, Some(&asset.coin_id)); + let token_id = filter_token_id(chain, asset.clone().address); + let enabled = asset.is_allowed && !asset.is_suspended.unwrap_or(false); + let is_sell_enabled = asset.is_pay_in_allowed.unwrap_or(false); + + Some(FiatProviderAsset { + id: asset.clone().unique_id, + provider: FiatProviderName::Transak, + chain, + token_id, + symbol: asset.clone().symbol, + network: Some(asset.clone().network.name), + enabled, + is_buy_enabled: true, + is_sell_enabled, + unsupported_countries: Some(asset.unsupported_countries()), + buy_limits: vec![], + sell_limits: vec![], + }) +} + +pub fn map_asset_with_limits(asset: Asset, fiat_currencies: &[FiatCurrency]) -> Option { + let provider_asset = map_asset(asset)?; + let buy_limits = map_limits(fiat_currencies, FiatQuoteType::Buy); + let sell_limits = map_limits(fiat_currencies, FiatQuoteType::Sell); + let is_buy_enabled = !buy_limits.is_empty(); + let is_sell_enabled = provider_asset.is_sell_enabled && !sell_limits.is_empty(); + Some(FiatProviderAsset { + buy_limits, + sell_limits, + is_buy_enabled, + is_sell_enabled, + ..provider_asset + }) +} + +fn map_payment_type(payment_id: &str) -> Option { + match payment_id { + "credit_debit_card" => Some(PaymentType::Card), + "apple_pay" => Some(PaymentType::ApplePay), + "google_pay" => Some(PaymentType::GooglePay), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::transak::models::{Data, FiatCurrency, Response, TransakOrderResponse}; + use primitives::{FiatTransactionStatus, FiatTransactionUpdate, PaymentType}; + + #[test] + fn test_map_order_buy_failed() { + let response: Data = serde_json::from_str(include_str!("../../../testdata/transak/transaction_buy_error.json")).unwrap(); + + let result = map_order_from_response(response.data); + + assert_eq!( + result, + FiatTransactionUpdate { + transaction_id: "e75764cd-1275-476e-b6fa-9af787b40974".to_string(), + provider_transaction_id: Some("df7997b7-a19f-447e-b9fe-2f0eb7cb7b3a".to_string()), + status: FiatTransactionStatus::Failed, + transaction_hash: None, + fiat_amount: Some(108.0), + fiat_currency: Some("USD".to_string()), + } + ); + } + + #[test] + fn test_map_asset_with_limits() { + let fiat_response: Response> = serde_json::from_str(include_str!("../../../testdata/transak/fiat_currencies.json")).unwrap(); + + use crate::providers::transak::models::{Asset, AssetNetwork}; + let asset = Asset { + coin_id: "ethereum".to_string(), + unique_id: "eth".to_string(), + symbol: "ETH".to_string(), + network: AssetNetwork { name: "ethereum".to_string() }, + address: None, + is_allowed: true, + is_suspended: Some(false), + is_pay_in_allowed: Some(true), + kyc_countries_not_supported: vec![], + }; + + let result = map_asset_with_limits(asset, &fiat_response.response).unwrap(); + + assert_eq!(result.symbol, "ETH"); + assert!(result.enabled); + assert!(!result.buy_limits.is_empty()); + + let card_limit = result.buy_limits.iter().find(|limit| limit.payment_type == PaymentType::Card).unwrap(); + assert_eq!(card_limit.min_amount, Some(5.0)); + assert_eq!(card_limit.max_amount, Some(3000.0)); + + let googlepay_limit = result.buy_limits.iter().find(|limit| limit.payment_type == PaymentType::GooglePay).unwrap(); + assert_eq!(googlepay_limit.min_amount, Some(30.0)); + assert_eq!(googlepay_limit.max_amount, Some(1500.0)); + } +} diff --git a/core/crates/fiat/src/providers/transak/mod.rs b/core/crates/fiat/src/providers/transak/mod.rs new file mode 100644 index 0000000000..ae5586f14f --- /dev/null +++ b/core/crates/fiat/src/providers/transak/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod mapper; +pub mod models; +pub mod provider; diff --git a/core/crates/fiat/src/providers/transak/models/assets.rs b/core/crates/fiat/src/providers/transak/models/assets.rs new file mode 100644 index 0000000000..4bb6cec910 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/models/assets.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Asset { + pub coin_id: String, + pub unique_id: String, + pub symbol: String, + pub network: AssetNetwork, + pub address: Option, + pub is_allowed: bool, + pub is_suspended: Option, + pub is_pay_in_allowed: Option, + pub kyc_countries_not_supported: Vec, +} + +impl Asset { + pub fn unsupported_countries(&self) -> HashMap> { + self.kyc_countries_not_supported.clone().into_iter().map(|country| (country, vec![])).collect() + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetNetwork { + pub name: String, +} diff --git a/core/crates/fiat/src/providers/transak/models/auth.rs b/core/crates/fiat/src/providers/transak/models/auth.rs new file mode 100644 index 0000000000..9f9d214491 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/models/auth.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenResponse { + pub access_token: String, +} + +#[derive(Debug, Clone)] +pub struct CachedToken { + pub access_token: String, + pub expires_at: SystemTime, +} + +impl CachedToken { + pub fn new(access_token: String, ttl_seconds: u64) -> Self { + Self { + access_token, + expires_at: SystemTime::now() + Duration::from_secs(ttl_seconds), + } + } + + pub fn is_valid(&self) -> bool { + SystemTime::now() < self.expires_at + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateWidgetUrlRequest { + #[serde(rename = "widgetParams")] + pub params: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateWidgetUrlResponse { + pub widget_url: String, +} diff --git a/core/crates/fiat/src/providers/transak/models/common.rs b/core/crates/fiat/src/providers/transak/models/common.rs new file mode 100644 index 0000000000..00f1f43c0d --- /dev/null +++ b/core/crates/fiat/src/providers/transak/models/common.rs @@ -0,0 +1,37 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Response { + pub response: T, +} + +#[derive(Debug, Deserialize)] +pub struct Data { + pub data: T, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum TransakResponse { + Success(Response), + Error(TransakError), +} + +#[derive(Debug, Deserialize)] +pub struct TransakError { + pub error: TransakErrorDetail, +} + +#[derive(Debug, Deserialize)] +pub struct TransakErrorDetail { + pub message: String, +} + +impl From> for Result> { + fn from(resp: TransakResponse) -> Self { + match resp { + TransakResponse::Success(data) => Ok(data.response), + TransakResponse::Error(error) => Err(error.error.message.into()), + } + } +} diff --git a/core/crates/fiat/src/providers/transak/models/countries.rs b/core/crates/fiat/src/providers/transak/models/countries.rs new file mode 100644 index 0000000000..a534e1b010 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/models/countries.rs @@ -0,0 +1,8 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Country { + pub alpha2: String, + pub is_allowed: bool, +} diff --git a/core/crates/fiat/src/providers/transak/models/fiat_currencies.rs b/core/crates/fiat/src/providers/transak/models/fiat_currencies.rs new file mode 100644 index 0000000000..8dd3c8d3b4 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/models/fiat_currencies.rs @@ -0,0 +1,30 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FiatCurrency { + pub symbol: String, + pub name: String, + pub payment_options: Vec, + pub supporting_countries: Vec, + pub is_popular: bool, + pub is_allowed: bool, + pub round_off: u32, + pub is_pay_out_allowed: bool, + pub icon: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOption { + pub id: String, + pub name: String, + pub is_active: bool, + pub max_amount: Option, + pub min_amount: Option, + pub max_amount_for_pay_out: Option, + pub min_amount_for_pay_out: Option, + pub is_nft_allowed: Option, + pub processing_time: Option, + pub limit_currency: Option, +} diff --git a/core/crates/fiat/src/providers/transak/models/mod.rs b/core/crates/fiat/src/providers/transak/models/mod.rs new file mode 100644 index 0000000000..ce45eb9a54 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/models/mod.rs @@ -0,0 +1,15 @@ +pub mod assets; +pub mod auth; +pub mod common; +pub mod countries; +pub mod fiat_currencies; +pub mod quotes; +pub mod transactions; + +pub use assets::*; +pub use auth::*; +pub use common::*; +pub use countries::*; +pub use fiat_currencies::*; +pub use quotes::*; +pub use transactions::*; diff --git a/core/crates/fiat/src/providers/transak/models/quotes.rs b/core/crates/fiat/src/providers/transak/models/quotes.rs new file mode 100644 index 0000000000..44b8209f32 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/models/quotes.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransakQuote { + pub quote_id: String, + pub fiat_amount: f64, + pub fiat_currency: String, + pub crypto_currency: String, + pub crypto_amount: f64, + pub network: String, + pub conversion_price: f64, + pub total_fee: f64, +} + +impl TransakQuote { + pub fn sell_crypto_amount(&self, fiat_amount: f64) -> f64 { + (fiat_amount + self.total_fee) * self.conversion_price + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sell_crypto_amount() { + let quote = TransakQuote { + quote_id: "test".to_string(), + fiat_amount: 100.0, + fiat_currency: "USD".to_string(), + crypto_currency: "ETH".to_string(), + crypto_amount: 0.03, + network: "ethereum".to_string(), + conversion_price: 0.0005, + total_fee: 5.0, + }; + + // (100 + 5) * 0.0005 = 0.0525 + assert_eq!(quote.sell_crypto_amount(100.0), 0.0525); + } +} diff --git a/core/crates/fiat/src/providers/transak/models/transactions.rs b/core/crates/fiat/src/providers/transak/models/transactions.rs new file mode 100644 index 0000000000..397f6f1a97 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/models/transactions.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransakOrderResponse { + pub id: String, + pub quote_id: Option, + pub status: String, + pub fiat_currency: String, + pub fiat_amount: f64, + pub transaction_hash: Option, +} diff --git a/core/crates/fiat/src/providers/transak/provider.rs b/core/crates/fiat/src/providers/transak/provider.rs new file mode 100644 index 0000000000..06b34d73b2 --- /dev/null +++ b/core/crates/fiat/src/providers/transak/provider.rs @@ -0,0 +1,201 @@ +use super::{ + client::TransakClient, + mapper::map_order_from_response, + models::{Data, TransakOrderResponse, TransakQuote}, +}; +use crate::{ + FiatProvider, + model::{FiatMapping, FiatProviderAsset}, + providers::transak::mapper::map_asset_with_limits, +}; +use async_trait::async_trait; +use primitives::{FiatProviderCountry, FiatProviderName, FiatQuoteRequest, FiatQuoteResponse, FiatQuoteType, FiatQuoteUrl, FiatQuoteUrlData}; +use std::error::Error; +use streamer::FiatWebhook; + +#[async_trait] +impl FiatProvider for TransakClient { + fn name(&self) -> FiatProviderName { + Self::NAME + } + + async fn get_assets(&self) -> Result, Box> { + let (assets, fiat_currencies) = tokio::try_join!(self.get_supported_assets(), self.get_fiat_currencies())?; + Ok(assets + .response + .into_iter() + .flat_map(|asset| map_asset_with_limits(asset, &fiat_currencies.response)) + .collect::>()) + } + + async fn get_countries(&self) -> Result, Box> { + Ok(self + .get_countries() + .await? + .response + .into_iter() + .map(|x| FiatProviderCountry { + provider: Self::NAME, + alpha2: x.alpha2, + is_allowed: x.is_allowed, + }) + .collect()) + } + + async fn process_webhook(&self, data: serde_json::Value) -> Result> { + let encrypted_data = serde_json::from_value::>(data)?; + let decoded_payload = self.decode_jwt_content(&encrypted_data.data).map_err(|e| format!("Failed to decode Transak JWT: {}", e))?; + let order = match serde_json::from_str::>(&decoded_payload) { + Ok(payload) => payload.data, + Err(_) => serde_json::from_str::(&decoded_payload)?, + }; + + Ok(FiatWebhook::Transaction(map_order_from_response(order))) + } + + async fn get_quote_buy(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let network = request_map.asset_symbol.network.unwrap_or_default(); + let quote = self + .get_buy_quote(request_map.asset_symbol.symbol, request.currency.clone(), request.amount, network, request.ip_address) + .await?; + + Ok(FiatQuoteResponse::new(quote.quote_id, request.amount, quote.crypto_amount)) + } + + async fn get_quote_sell(&self, request: FiatQuoteRequest, request_map: FiatMapping) -> Result> { + let network = request_map.asset_symbol.network.unwrap_or_default(); + let quote = self + .get_sell_quote(request_map.asset_symbol.symbol, request.currency.clone(), request.amount, network, request.ip_address) + .await?; + + Ok(FiatQuoteResponse::new(quote.quote_id, request.amount, quote.crypto_amount)) + } + + async fn get_quote_url(&self, data: FiatQuoteUrlData) -> Result> { + let network = data.asset_symbol.network.clone().unwrap_or_default(); + + let transak_quote = match data.quote.quote_type { + FiatQuoteType::Buy => TransakQuote { + quote_id: data.quote.id.clone(), + fiat_amount: data.quote.fiat_amount, + fiat_currency: data.quote.fiat_currency.clone(), + crypto_currency: data.asset_symbol.symbol.clone(), + crypto_amount: data.quote.crypto_amount, + network, + conversion_price: 0.0, + total_fee: 0.0, + }, + FiatQuoteType::Sell => { + self.get_sell_quote( + data.asset_symbol.symbol.clone(), + data.quote.fiat_currency.clone(), + data.quote.fiat_amount, + network, + data.ip_address.clone(), + ) + .await? + } + }; + + let redirect_url = self.redirect_url(transak_quote, data.wallet_address, data.quote.quote_type, data.quote.fiat_amount).await?; + + Ok(FiatQuoteUrl { + redirect_url, + provider_transaction_id: None, + }) + } +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +mod fiat_integration_tests { + use crate::testkit::*; + use crate::{FiatProvider, model::FiatMapping}; + use primitives::{FiatProviderName, FiatQuoteRequest}; + + #[tokio::test] + async fn test_transak_get_buy_quote() -> Result<(), Box> { + let client = create_transak_test_client(); + + let request = FiatQuoteRequest::mock(); + let mut mapping = FiatMapping::mock(); + mapping.asset_symbol.network = Some("mainnet".to_string()); + + let quote = FiatProvider::get_quote_buy(&client, request.clone(), mapping).await?; + + println!("Transak buy quote: {:?}", quote); + assert!(!quote.quote_id.is_empty()); + assert!(quote.crypto_amount > 0.0); + assert_eq!(quote.fiat_amount, request.amount); + + Ok(()) + } + + #[tokio::test] + async fn test_transak_get_assets() -> Result<(), Box> { + let client = create_transak_test_client(); + let assets = FiatProvider::get_assets(&client).await?; + + assert!(!assets.is_empty()); + println!("Found {} Transak assets", assets.len()); + + if let Some(asset) = assets.first() { + assert!(!asset.id.is_empty()); + assert!(!asset.symbol.is_empty()); + println!("Sample Transak asset: {:?}", asset); + } + + let assets_with_buy_limits = assets.iter().filter(|a| !a.buy_limits.is_empty()).count(); + let assets_with_sell_limits = assets.iter().filter(|a| !a.sell_limits.is_empty()).count(); + + println!("Assets with buy limits: {}", assets_with_buy_limits); + println!("Assets with sell limits: {}", assets_with_sell_limits); + + assert!(assets_with_buy_limits > 0, "Expected at least some assets to have buy limits"); + + if let Some(asset_with_limits) = assets.iter().find(|a| !a.buy_limits.is_empty()) { + println!("Asset with limits: {} has {} buy limits", asset_with_limits.symbol, asset_with_limits.buy_limits.len()); + + let first_limit = &asset_with_limits.buy_limits[0]; + assert!(first_limit.min_amount.is_some() || first_limit.max_amount.is_some()); + println!("Sample limit: {:?}", first_limit); + } + + Ok(()) + } + + #[tokio::test] + async fn test_transak_get_countries() -> Result<(), Box> { + let client = create_transak_test_client(); + let countries = FiatProvider::get_countries(&client).await?; + + assert!(!countries.is_empty()); + println!("Found {} Transak countries", countries.len()); + + if let Some(country) = countries.first() { + assert_eq!(country.provider, FiatProviderName::Transak); + assert!(!country.alpha2.is_empty()); + println!("Sample Transak country: {:?}", country); + } + + Ok(()) + } + + #[tokio::test] + async fn test_transak_get_sell_quote() -> Result<(), Box> { + let client = create_transak_test_client(); + + let request = FiatQuoteRequest::mock_sell(); + let mut mapping = FiatMapping::mock(); + mapping.asset_symbol.symbol = "BTC".to_string(); + mapping.asset_symbol.network = Some("mainnet".to_string()); + + let quote = FiatProvider::get_quote_sell(&client, request.clone(), mapping).await?; + + println!("Transak sell quote: {:?}", quote); + assert!(!quote.quote_id.is_empty()); + assert_eq!(quote.fiat_amount, request.amount); + assert!(quote.crypto_amount > 0.0); + + Ok(()) + } +} diff --git a/core/crates/fiat/src/rsa_signature.rs b/core/crates/fiat/src/rsa_signature.rs new file mode 100644 index 0000000000..16a0a5bd25 --- /dev/null +++ b/core/crates/fiat/src/rsa_signature.rs @@ -0,0 +1,73 @@ +use gem_encoding::{decode_base64, encode_base64}; +use pem_rfc7468::decode_vec; +use ring::rand::SystemRandom; +use ring::signature::{RSA_PSS_SHA512, RsaKeyPair}; + +pub fn generate_rsa_pss_signature(private_key_base_64: &str, message: &str) -> Result> { + let decoded = decode_base64(private_key_base_64)?; + let key_der = match std::str::from_utf8(&decoded) { + Ok(pem_str) if pem_str.contains("BEGIN") => { + let (_, der) = decode_vec(pem_str.as_bytes())?; + der + } + _ => decoded, + }; + + let key_pair = RsaKeyPair::from_pkcs8(&key_der)?; + let mut signature = vec![0u8; key_pair.public().modulus_len()]; + let rng = SystemRandom::new(); + key_pair.sign(&RSA_PSS_SHA512, &rng, message.as_bytes(), &mut signature)?; + + Ok(encode_base64(&signature)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_rsa_pss_signature_invalid_key() { + let message = r#"{"test":"data"}"#; + let result = generate_rsa_pss_signature("invalid_base64", message); + + assert!(result.is_err()); + } + + #[test] + fn test_generate_rsa_pss_signature_paybis_example() { + let base64_private_key = "MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDYOqOAJiTF3+MTccz/bvgyljKWWmT0z0uBrSp+FhjzekE6y8jUVBSzXWIH4TcrJ8pDxovf+GCdna2o0kHSZ2kntY4yXcBmBPjwcfkuI0EdYjsnLyfHl2j6IkSat4JMa7wUMwmBMj+XGptXC6hd3BJ30q4o7egnKXxZYzSQ/nB+7ZrY0U5O6y/Xg/YUwzOGpY0DGxWzr2O9DjX4kkG9eMz+DiCMKIOFNNsxSBaxzj+E7IZhn6kkTikAMbcSP9lviCuO2pI04o3x2JjVREEeRgf8S36gRR7/0rRxBCf2hcikRfOXCKexOm2aH1/EtTW5JT3hiupEJ+YFGPf+uRHhWdsP+OiJWs11KTA7iBSTyPOhudsLhtxO0xeC4UrHAjc5KIsxiedBvfA6hnTjCbvnyxd9+47fz8NxEkyMxZcVxyFRDHsceHHgCZKQGy7MRByv3RuadeDugzAsOL2tQ5z1/jfTg7yahqqjKuSHma1O7741KTHM2naBsR+OxNL985Mz1Cur3yyPtoZ82Lp4gGUrtEzlmD0fJosVKF3XAWsTcdJG0b8GC8Ucr+srX53J8u6ksaF4THpdDz7fZBnCWwYU+7Js3FHD4td9tGc+BAlIW72i2kRzlxyoZJT+oXdyObJhRalc0RQxi8fqrG+869uDYhFVLHKiHVy8eeNipLMPWYlVFQIDAQABAoICABj1o9vuCz6gGmkrMLunhpToS4yZgJ/VseSVJZuKV3T7fr4XueXwkrclp2Q7dg/QNwPdzlWbKSPoiJw9MQXlk/jWd0SPF99u4YF31oih3ylSJnvecJwUeTSucfbeCfdiVEKMpaM5NqftlVLV8Khs9+DG+/2TgMHMgyMaVX4LMNcl/ELc3koz0cDx5Zz971uyjnV2Uen86+lt04MO9vG1GQyWeuFS5+Ofd1HX/W6m3SQt3VE1ieO79fWkx3oezq2WLVj/F/Ns12+8TeAIUe/5q4BPAp3jfLGRE+0byrUlOkTkIjsj75+AnBg3WOmu9TWa++qmC2a0qFOcTzwjBtJZefTGn8V4rxcj6vtBhc9c7xNoRknhXC8tNNXtZpLRUfa2MP897bsuTUQV67XdeS5AkIfBOZs65mH4UrkzVrWvRs9UPZ3zv8aLvPgfpq5vQm8NpCbrdo6bfmmmQo01zIPN+H3FlLp/WJliX4qf9kQ8MAHA2m1DppbmL9QvdcHM2RY+f8tDnIt81sJq3EhsOhmZUnC+GewBFSJm+bxMVMnwmgm49yA5L4VX0bVx/nFV2HRVuRvk0zH9T9hzSHtlxMfuilB9cK3MHzvPt55/hBCfmZS9qkO80x1x0H32F/7uTtab+/mAeR6zLpFhdRkssc/opDjiV2Lc9QhpOs+qaXw35i8dAoIBAQD5u2b6BSEkpmjlvCIaODpckKXlTMHpSaqyExR64rri9xjZFNvhj+JqV9t21q4rHQ9yoXceqcygw3uBUQPj/TK9fVfkmXkwGP9/JS39gWTCN1PQ0QEo+Rbx2yUBpnh1p0IvJoPo08UXbTkqaz95z0wdEeKCF560id4kVIvDYch5+rGWSRW74ltU7b+XgFflxVDryydfaPpP+VIWYRI99cHzmKBqj5a5neWKY82OvX9DWiVx+pcXxTdh9PPKg+oAKcPFPGvbqZC+oVcC8nMoz+jrC2VYBHJ7I02tiVZQjRI31YewwkxEYrcDxzjnUnCO2MmDZwhfoG4f6Qz9JfnQci0nAoIBAQDdp/hvUJL+aMFTKFPPeXAd/HVG71VeWUt4MZCf3xnc9HVQPka6FMzRXhx7Egnind5RHOetJ2tu1q3GW6AMv2PQdxP0MnlXtvSV/J7rt3i4CbfI+/WKO8gzSOHsBLdzgZXUJXdLiXwCLSEJvdc0AS+bza4bdqhZc3cOIz0BS2fHfk9pnk3WsdPUuK3tpcoL6gTvw7AjaQ0Rk+LSbzYMKvMEXEw9OGpnoWPkl/uAdJ3/M1ZOs+1W4DvIlYn2U7/eL6j/OG2OmzKJb70BEfSDKA4WGttQbPiHRrjRWNb337yIV7DVpdAnCQCgjeVh7zdbY4nZBXf1CKMCqbsqxxk2CIljAoIBAEfbBTlBSpUKELqxlDppHVnPAPzmRhFC8guE8+qb3Fw77vlfSBkx1lr05p/eC4U6OlyoWucGwmsrdBj0X6M1Eml1bFnJUxZkyvchkocTuRMs6j/2M1g/u7tha9d6t8RamO+KLIBMlrQz6DPtYflBjUv7/mmiNDcMSE+5x/Ey7IU0fe6ZHtjNu6vHMM59zky9ppgB/1Uzlnp2aYko6x/K28CklNu0bxD/frGAIABHRBv0Dzwpd1oOk+3qlk8Z/7WGTt8skHhG5PAE6k1dx4bhs8oVoFZgCTSnJs2c66oHvUs1dHKGpX0zzicXJqdgkCR5+hmGBuHE/orN+r/IMoYopBcCggEBAJMtgSyIl9INxLBuypeszuFaTJT5PfoT2KTKZHmDLi0ktPC/KT9NqGIs10RwydeLc57wTnUPA6rpKSHYnQFZ4/D74Gf5S9EOToF46B0kCihJa5sskfFjmJ9U+Y4544XyuYXQCtJBS/I1/QX24/pH/1C41a6ur0IWBSuCAnPlmddA64H59z1jfoB00ChIOUyH6xc5HK+mhWLyi12nMoAJ1KtEjerolt6Qrz+OGxVEWdSmRdykZCeXZJrfkGfbXD8v7krpMPXL31aatykKvwyHgDL1SkKw2KUaNIXtM3ALQ6hUcbqrCvegZqY1EeZhbKRmB5Xup6QwQ+z0vq683OSf7nkCggEAJs8r137gxt3BDMu5QfvpfL1xu7jtVC3JH8tgFNGqZViudBfja26CwzRWRNquIAomVdWmTQ9Er71a300bv8j5/55pVv3GComPTZJs2Ag/1gwCV0zCgpqcswwfwG1TYeDRsnwHQCC1uQzl69KHOZBAPNcQYwtblqa6qHCOX0VkKiJHuJ33QlG2oEGjWKVfiosjuoSjyZARQszspAYm5yVPw1bv0HjrNvok9QDRmDihGINb5WEMmn8mqKF81Zo2ZQ71RY4suDqqwjY4RhEWgGyYG+omSTObARkeva76tdkxg6x5EuXtCIlNxDaLY6qEMyJkSTVZHGYobeYzxV05PHylyg=="; + + let request_body = r#"{ + "partnerUserId": "1bc166bd-1808-4009-8454-aceb47ba8753", + "cryptoWalletAddress": null, + "email": "doe.john@paybis.com", + "applicantSumsubToken": "token_hash", + "locale": "en" +}"#; + + let signature = generate_rsa_pss_signature(base64_private_key, request_body).expect("Failed to generate signature"); + + assert!(!signature.is_empty()); + } + + #[test] + fn test_generate_rsa_pss_signature_pem_wrapped_private_key() { + let base64_private_key = "MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDYOqOAJiTF3+MTccz/bvgyljKWWmT0z0uBrSp+FhjzekE6y8jUVBSzXWIH4TcrJ8pDxovf+GCdna2o0kHSZ2kntY4yXcBmBPjwcfkuI0EdYjsnLyfHl2j6IkSat4JMa7wUMwmBMj+XGptXC6hd3BJ30q4o7egnKXxZYzSQ/nB+7ZrY0U5O6y/Xg/YUwzOGpY0DGxWzr2O9DjX4kkG9eMz+DiCMKIOFNNsxSBaxzj+E7IZhn6kkTikAMbcSP9lviCuO2pI04o3x2JjVREEeRgf8S36gRR7/0rRxBCf2hcikRfOXCKexOm2aH1/EtTW5JT3hiupEJ+YFGPf+uRHhWdsP+OiJWs11KTA7iBSTyPOhudsLhtxO0xeC4UrHAjc5KIsxiedBvfA6hnTjCbvnyxd9+47fz8NxEkyMxZcVxyFRDHsceHHgCZKQGy7MRByv3RuadeDugzAsOL2tQ5z1/jfTg7yahqqjKuSHma1O7741KTHM2naBsR+OxNL985Mz1Cur3yyPtoZ82Lp4gGUrtEzlmD0fJosVKF3XAWsTcdJG0b8GC8Ucr+srX53J8u6ksaF4THpdDz7fZBnCWwYU+7Js3FHD4td9tGc+BAlIW72i2kRzlxyoZJT+oXdyObJhRalc0RQxi8fqrG+869uDYhFVLHKiHVy8eeNipLMPWYlVFQIDAQABAoICABj1o9vuCz6gGmkrMLunhpToS4yZgJ/VseSVJZuKV3T7fr4XueXwkrclp2Q7dg/QNwPdzlWbKSPoiJw9MQXlk/jWd0SPF99u4YF31oih3ylSJnvecJwUeTSucfbeCfdiVEKMpaM5NqftlVLV8Khs9+DG+/2TgMHMgyMaVX4LMNcl/ELc3koz0cDx5Zz971uyjnV2Uen86+lt04MO9vG1GQyWeuFS5+Ofd1HX/W6m3SQt3VE1ieO79fWkx3oezq2WLVj/F/Ns12+8TeAIUe/5q4BPAp3jfLGRE+0byrUlOkTkIjsj75+AnBg3WOmu9TWa++qmC2a0qFOcTzwjBtJZefTGn8V4rxcj6vtBhc9c7xNoRknhXC8tNNXtZpLRUfa2MP897bsuTUQV67XdeS5AkIfBOZs65mH4UrkzVrWvRs9UPZ3zv8aLvPgfpq5vQm8NpCbrdo6bfmmmQo01zIPN+H3FlLp/WJliX4qf9kQ8MAHA2m1DppbmL9QvdcHM2RY+f8tDnIt81sJq3EhsOhmZUnC+GewBFSJm+bxMVMnwmgm49yA5L4VX0bVx/nFV2HRVuRvk0zH9T9hzSHtlxMfuilB9cK3MHzvPt55/hBCfmZS9qkO80x1x0H32F/7uTtab+/mAeR6zLpFhdRkssc/opDjiV2Lc9QhpOs+qaXw35i8dAoIBAQD5u2b6BSEkpmjlvCIaODpckKXlTMHpSaqyExR64rri9xjZFNvhj+JqV9t21q4rHQ9yoXceqcygw3uBUQPj/TK9fVfkmXkwGP9/JS39gWTCN1PQ0QEo+Rbx2yUBpnh1p0IvJoPo08UXbTkqaz95z0wdEeKCF560id4kVIvDYch5+rGWSRW74ltU7b+XgFflxVDryydfaPpP+VIWYRI99cHzmKBqj5a5neWKY82OvX9DWiVx+pcXxTdh9PPKg+oAKcPFPGvbqZC+oVcC8nMoz+jrC2VYBHJ7I02tiVZQjRI31YewwkxEYrcDxzjnUnCO2MmDZwhfoG4f6Qz9JfnQci0nAoIBAQDdp/hvUJL+aMFTKFPPeXAd/HVG71VeWUt4MZCf3xnc9HVQPka6FMzRXhx7Egnind5RHOetJ2tu1q3GW6AMv2PQdxP0MnlXtvSV/J7rt3i4CbfI+/WKO8gzSOHsBLdzgZXUJXdLiXwCLSEJvdc0AS+bza4bdqhZc3cOIz0BS2fHfk9pnk3WsdPUuK3tpcoL6gTvw7AjaQ0Rk+LSbzYMKvMEXEw9OGpnoWPkl/uAdJ3/M1ZOs+1W4DvIlYn2U7/eL6j/OG2OmzKJb70BEfSDKA4WGttQbPiHRrjRWNb337yIV7DVpdAnCQCgjeVh7zdbY4nZBXf1CKMCqbsqxxk2CIljAoIBAEfbBTlBSpUKELqxlDppHVnPAPzmRhFC8guE8+qb3Fw77vlfSBkx1lr05p/eC4U6OlyoWucGwmsrdBj0X6M1Eml1bFnJUxZkyvchkocTuRMs6j/2M1g/u7tha9d6t8RamO+KLIBMlrQz6DPtYflBjUv7/mmiNDcMSE+5x/Ey7IU0fe6ZHtjNu6vHMM59zky9ppgB/1Uzlnp2aYko6x/K28CklNu0bxD/frGAIABHRBv0Dzwpd1oOk+3qlk8Z/7WGTt8skHhG5PAE6k1dx4bhs8oVoFZgCTSnJs2c66oHvUs1dHKGpX0zzicXJqdgkCR5+hmGBuHE/orN+r/IMoYopBcCggEBAJMtgSyIl9INxLBuypeszuFaTJT5PfoT2KTKZHmDLi0ktPC/KT9NqGIs10RwydeLc57wTnUPA6rpKSHYnQFZ4/D74Gf5S9EOToF46B0kCihJa5sskfFjmJ9U+Y4544XyuYXQCtJBS/I1/QX24/pH/1C41a6ur0IWBSuCAnPlmddA64H59z1jfoB00ChIOUyH6xc5HK+mhWLyi12nMoAJ1KtEjerolt6Qrz+OGxVEWdSmRdykZCeXZJrfkGfbXD8v7krpMPXL31aatykKvwyHgDL1SkKw2KUaNIXtM3ALQ6hUcbqrCvegZqY1EeZhbKRmB5Xup6QwQ+z0vq683OSf7nkCggEAJs8r137gxt3BDMu5QfvpfL1xu7jtVC3JH8tgFNGqZViudBfja26CwzRWRNquIAomVdWmTQ9Er71a300bv8j5/55pVv3GComPTZJs2Ag/1gwCV0zCgpqcswwfwG1TYeDRsnwHQCC1uQzl69KHOZBAPNcQYwtblqa6qHCOX0VkKiJHuJ33QlG2oEGjWKVfiosjuoSjyZARQszspAYm5yVPw1bv0HjrNvok9QDRmDihGINb5WEMmn8mqKF81Zo2ZQ71RY4suDqqwjY4RhEWgGyYG+omSTObARkeva76tdkxg6x5EuXtCIlNxDaLY6qEMyJkSTVZHGYobeYzxV05PHylyg=="; + + let der = decode_base64(base64_private_key).unwrap(); + let pem_body = encode_base64(&der); + let pem_body_wrapped = pem_body + .as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk).expect("valid base64 chunk")) + .collect::>() + .join("\n"); + let pem = format!("-----BEGIN PRIVATE KEY-----\n{pem_body_wrapped}\n-----END PRIVATE KEY-----\n"); + let base64_pem = encode_base64(pem.as_bytes()); + + let request_body = r#"{"test":"data"}"#; + let signature = generate_rsa_pss_signature(&base64_pem, request_body).expect("Failed to generate signature from PEM"); + + assert!(!signature.is_empty()); + } +} diff --git a/core/crates/fiat/src/testkit.rs b/core/crates/fiat/src/testkit.rs new file mode 100644 index 0000000000..8d73b0f16a --- /dev/null +++ b/core/crates/fiat/src/testkit.rs @@ -0,0 +1,67 @@ +#[cfg(all(test, feature = "fiat_integration_tests"))] +use crate::client::FiatClient; +#[cfg(all(test, feature = "fiat_integration_tests"))] +use crate::model::FiatMapping; +#[cfg(all(test, feature = "fiat_integration_tests"))] +use crate::providers::{ + banxa::client::BanxaClient, mercuryo::client::MercuryoClient, moonpay::client::MoonPayClient, paybis::client::PaybisClient, transak::client::TransakClient, +}; +#[cfg(all(test, feature = "fiat_integration_tests"))] +use settings::Settings; + +#[cfg(all(test, feature = "fiat_integration_tests"))] +fn get_test_settings() -> Settings { + let settings_path = std::env::current_dir().expect("Failed to get current directory").join("../../Settings.yaml"); + Settings::new_setting_path(settings_path).expect("Failed to load settings for tests") +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +pub fn create_transak_test_client() -> TransakClient { + let settings = get_test_settings(); + let client = FiatClient::request_client(settings.fiat.timeout); + TransakClient::new(client, settings.transak.key.public, settings.transak.key.secret, settings.transak.referrer_domain) +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +pub fn create_moonpay_test_client() -> MoonPayClient { + let settings = get_test_settings(); + let client = FiatClient::request_client(settings.fiat.timeout); + MoonPayClient::new(client, settings.moonpay.key.public, settings.moonpay.key.secret) +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +pub fn create_paybis_test_client() -> PaybisClient { + let settings = get_test_settings(); + let client = FiatClient::request_client(settings.fiat.timeout); + PaybisClient::new(client, settings.paybis.key.public, settings.paybis.key.secret) +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +pub fn create_banxa_test_client() -> BanxaClient { + let settings = get_test_settings(); + let client = FiatClient::request_client(settings.fiat.timeout); + BanxaClient::new(client, settings.banxa.url, settings.banxa.key.public, settings.banxa.key.secret) +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +pub fn create_mercuryo_test_client() -> MercuryoClient { + let settings = get_test_settings(); + let client = FiatClient::request_client(settings.fiat.timeout); + MercuryoClient::new(client, settings.mercuryo.key.public, settings.mercuryo.key.secret) +} + +#[cfg(all(test, feature = "fiat_integration_tests"))] +impl FiatMapping { + pub fn mock() -> Self { + FiatMapping { + asset: primitives::Asset::from_chain(primitives::Chain::Bitcoin), + asset_symbol: primitives::FiatAssetSymbol { + symbol: "BTC".to_string(), + network: Some("BITCOIN".to_string()), + }, + unsupported_countries: std::collections::HashMap::new(), + buy_limits: vec![], + sell_limits: vec![], + } + } +} diff --git a/core/crates/fiat/src/transaction_info_mapper.rs b/core/crates/fiat/src/transaction_info_mapper.rs new file mode 100644 index 0000000000..815ea8211c --- /dev/null +++ b/core/crates/fiat/src/transaction_info_mapper.rs @@ -0,0 +1,118 @@ +use primitives::date_ext::NaiveDateTimeExt; +use primitives::{FiatProviderName, FiatQuoteType, FiatTransaction, FiatTransactionData, FiatTransactionStatus}; + +pub fn fiat_transaction_info(mut transaction: FiatTransaction) -> FiatTransactionData { + if transaction.status == FiatTransactionStatus::Pending && transaction.created_at.naive_utc().is_older_than_days(1) { + transaction.status = FiatTransactionStatus::Unknown; + } + + let details_url = match transaction.status { + FiatTransactionStatus::Unknown => None, + _ => details_url(&transaction.provider, &transaction.transaction_type, transaction.provider_transaction_id.as_deref()), + }; + + FiatTransactionData { transaction, details_url } +} + +fn details_url(provider: &FiatProviderName, transaction_type: &FiatQuoteType, provider_transaction_id: Option<&str>) -> Option { + let provider_transaction_id = provider_transaction_id?; + + match provider { + FiatProviderName::MoonPay => match transaction_type { + FiatQuoteType::Buy => Some(format!("https://buy.moonpay.com/v2/transaction-tracker?transactionId={provider_transaction_id}")), + FiatQuoteType::Sell => Some(format!("https://sell.moonpay.com/v2/transaction-tracker?transactionId={provider_transaction_id}")), + }, + FiatProviderName::Mercuryo => None, + FiatProviderName::Transak => None, + FiatProviderName::Banxa => Some(format!("https://gemwallet.banxa.com/status/{provider_transaction_id}")), + FiatProviderName::Paybis => None, + FiatProviderName::Flashnet => Some(format!("https://orchestra.flashnet.xyz/explorer/{provider_transaction_id}")), + } +} + +#[cfg(test)] +mod tests { + use super::{details_url, fiat_transaction_info}; + use primitives::chrono::{Duration, Utc}; + use primitives::{FiatProviderName, FiatQuoteType, FiatTransactionStatus}; + + #[test] + fn details_url_returns_expected_values() { + let cases = [ + ( + FiatProviderName::MoonPay, + FiatQuoteType::Buy, + Some("tx_123"), + Some("https://buy.moonpay.com/v2/transaction-tracker?transactionId=tx_123"), + ), + ( + FiatProviderName::MoonPay, + FiatQuoteType::Sell, + Some("tx_123"), + Some("https://sell.moonpay.com/v2/transaction-tracker?transactionId=tx_123"), + ), + (FiatProviderName::MoonPay, FiatQuoteType::Buy, None, None), + ( + FiatProviderName::Flashnet, + FiatQuoteType::Buy, + Some("ord_123"), + Some("https://orchestra.flashnet.xyz/explorer/ord_123"), + ), + (FiatProviderName::Mercuryo, FiatQuoteType::Buy, Some("tx_123"), None), + (FiatProviderName::Transak, FiatQuoteType::Buy, Some("tx_123"), None), + ( + FiatProviderName::Banxa, + FiatQuoteType::Buy, + Some("tx_123"), + Some("https://gemwallet.banxa.com/status/tx_123"), + ), + (FiatProviderName::Paybis, FiatQuoteType::Sell, Some("PB123"), None), + ]; + + for (provider, transaction_type, transaction_id, expected) in cases { + let result = details_url(&provider, &transaction_type, transaction_id); + assert_eq!(result.as_deref(), expected); + } + } + + #[test] + fn from_primitive_sets_details_url_on_render() { + let transaction = primitives::FiatTransaction { + transaction_type: FiatQuoteType::Buy, + status: FiatTransactionStatus::Complete, + ..primitives::FiatTransaction::mock() + }; + + let rendered = fiat_transaction_info(transaction); + + assert_eq!( + rendered.details_url, + Some("https://buy.moonpay.com/v2/transaction-tracker?transactionId=tx_123".to_string()) + ); + } + + #[test] + fn pending_older_than_one_day_becomes_unknown() { + let transaction = primitives::FiatTransaction { + status: FiatTransactionStatus::Pending, + created_at: Utc::now() - Duration::days(2), + ..primitives::FiatTransaction::mock() + }; + + let result = fiat_transaction_info(transaction); + assert_eq!(result.transaction.status, FiatTransactionStatus::Unknown); + assert_eq!(result.details_url, None); + } + + #[test] + fn recent_pending_stays_pending() { + let transaction = primitives::FiatTransaction { + status: FiatTransactionStatus::Pending, + created_at: Utc::now(), + ..primitives::FiatTransaction::mock() + }; + + let result = fiat_transaction_info(transaction); + assert_eq!(result.transaction.status, FiatTransactionStatus::Pending); + } +} diff --git a/core/crates/fiat/testdata/banxa/fiat_currencies.json b/core/crates/fiat/testdata/banxa/fiat_currencies.json new file mode 100644 index 0000000000..fff17f16d9 --- /dev/null +++ b/core/crates/fiat/testdata/banxa/fiat_currencies.json @@ -0,0 +1,46 @@ +[ + { + "id": "EUR", + "description": "Euro", + "symbol": "\u20ac", + "supportedPaymentMethods": [ + { + "id": "debit-credit-card", + "name": "Debit credit card", + "minimum": "20", + "maximum": "15000" + }, + { + "id": "google-pay", + "name": "Google pay", + "minimum": "17", + "maximum": "10000" + }, + { + "id": "sepa-bank-transfer", + "name": "Sepa bank transfer", + "minimum": "46", + "maximum": "30000" + } + ] + }, + { + "id": "USD", + "description": "United States Dollar", + "symbol": "$", + "supportedPaymentMethods": [ + { + "id": "debit-credit-card", + "name": "Debit credit card", + "minimum": "20", + "maximum": "15000" + }, + { + "id": "google-pay", + "name": "Google pay", + "minimum": "20", + "maximum": "15000" + } + ] + } +] diff --git a/core/crates/fiat/testdata/banxa/transaction_sell_failed.json b/core/crates/fiat/testdata/banxa/transaction_sell_failed.json new file mode 100644 index 0000000000..01aedfdc27 --- /dev/null +++ b/core/crates/fiat/testdata/banxa/transaction_sell_failed.json @@ -0,0 +1,9 @@ +{ + "id": "123", + "externalOrderId": null, + "status": "expired", + "fiat": "USD", + "fiatAmount": "595.3", + "transactionHash": null, + "orderType": "SELL" +} diff --git a/core/crates/fiat/testdata/flashnet/estimate.json b/core/crates/fiat/testdata/flashnet/estimate.json new file mode 100644 index 0000000000..0838685a87 --- /dev/null +++ b/core/crates/fiat/testdata/flashnet/estimate.json @@ -0,0 +1,23 @@ +{ + "estimatedOut": "98951", + "feeAmount": "50", + "feeBps": 5, + "totalFeeAmount": "1049", + "appFeeAmount": "999", + "appFeePlatformCutAmount": "199", + "appFees": [ + { + "affiliateId": "gemwallet", + "recipient": "0x", + "feeBps": 100, + "amount": "999", + "platformCutAmount": "199", + "recipientAmount": "800" + } + ], + "feeAsset": "USDC", + "route": [ + "USDB", + "USDC" + ] +} diff --git a/core/crates/fiat/testdata/flashnet/onramp_response.json b/core/crates/fiat/testdata/flashnet/onramp_response.json new file mode 100644 index 0000000000..044a452bad --- /dev/null +++ b/core/crates/fiat/testdata/flashnet/onramp_response.json @@ -0,0 +1,6 @@ +{ + "orderId": "ord_test_onramp", + "paymentLinks": { + "cashApp": "https://orchestration.flashnet.xyz/pay/zimH6K-d" + } +} diff --git a/core/crates/fiat/testdata/flashnet/order_completed.json b/core/crates/fiat/testdata/flashnet/order_completed.json new file mode 100644 index 0000000000..fcbc8b0552 --- /dev/null +++ b/core/crates/fiat/testdata/flashnet/order_completed.json @@ -0,0 +1,8 @@ +{ + "id": "ord_123", + "status": "completed", + "amountOut": "1234500", + "destination": { + "txHash": "solana_sig_123" + } +} diff --git a/core/crates/fiat/testdata/flashnet/order_completed_eth.json b/core/crates/fiat/testdata/flashnet/order_completed_eth.json new file mode 100644 index 0000000000..ee3785791a --- /dev/null +++ b/core/crates/fiat/testdata/flashnet/order_completed_eth.json @@ -0,0 +1,9 @@ +{ + "id": "ord_019de25e-59d8-7ca6-b5e1-39651db9717f", + "status": "completed", + "amountIn": "65415", + "amountOut": "21857183468046165", + "destination": { + "txHash": "0xf3fa9ca081e1f97022352c80345b46ae5934b0fae68c76ab5ccc70773ef1443e" + } +} diff --git a/core/crates/fiat/testdata/flashnet/routes.json b/core/crates/fiat/testdata/flashnet/routes.json new file mode 100644 index 0000000000..96ccaaaf6b --- /dev/null +++ b/core/crates/fiat/testdata/flashnet/routes.json @@ -0,0 +1,49 @@ +{ + "routes": [ + { + "sourceChain": "lightning", + "sourceAsset": "BTC", + "destination": { + "chain": "bitcoin", + "asset": "BTC", + "contractAddress": null + } + }, + { + "sourceChain": "lightning", + "sourceAsset": "BTC", + "destination": { + "chain": "base", + "asset": "ETH", + "contractAddress": null + } + }, + { + "sourceChain": "lightning", + "sourceAsset": "BTC", + "destination": { + "chain": "solana", + "asset": "USDC", + "contractAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + } + }, + { + "sourceChain": "spark", + "sourceAsset": "BTC", + "destination": { + "chain": "ethereum", + "asset": "USDC", + "contractAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + }, + { + "sourceChain": "lightning", + "sourceAsset": "BTC", + "destination": { + "chain": "tempo", + "asset": "USDC", + "contractAddress": "0x20c000000000000000000000b9537d11c60e8b50" + } + } + ] +} diff --git a/core/crates/fiat/testdata/flashnet/webhook_completed.json b/core/crates/fiat/testdata/flashnet/webhook_completed.json new file mode 100644 index 0000000000..85bd167964 --- /dev/null +++ b/core/crates/fiat/testdata/flashnet/webhook_completed.json @@ -0,0 +1,11 @@ +{ + "event": "order.completed", + "data": { + "id": "ord_test_completed", + "status": "completed", + "amountOut": "24737625", + "destination": { + "txHash": "solana_test_signature_completed" + } + } +} diff --git a/core/crates/fiat/testdata/flashnet/webhook_pending.json b/core/crates/fiat/testdata/flashnet/webhook_pending.json new file mode 100644 index 0000000000..4b9308d3c4 --- /dev/null +++ b/core/crates/fiat/testdata/flashnet/webhook_pending.json @@ -0,0 +1,10 @@ +{ + "event": "order.processing", + "data": { + "id": "ord_test_pending", + "status": "processing", + "paymentIntent": { + "targetAmountOut": "49475250" + } + } +} diff --git a/core/crates/fiat/testdata/mercuryo/assets.json b/core/crates/fiat/testdata/mercuryo/assets.json new file mode 100644 index 0000000000..7888a74e6f --- /dev/null +++ b/core/crates/fiat/testdata/mercuryo/assets.json @@ -0,0 +1,3579 @@ +{ + "status": 200, + "data": { + "fiat": [ + "AED", + "AMD", + "AUD", + "BGN", + "BRL", + "CAD", + "CHF", + "COP", + "CZK", + "DKK", + "DOP", + "EUR", + "GBP", + "GHS", + "HKD", + "HUF", + "IDR", + "ILS", + "INR", + "ISK", + "JOD", + "JPY", + "KRW", + "KZT", + "LKR", + "MXN", + "NOK", + "NZD", + "PEN", + "PHP", + "PLN", + "QAR", + "RON", + "SEK", + "SGD", + "THB", + "TRY", + "TWD", + "USD", + "UYU", + "VND", + "ZAR" + ], + "crypto": [ + "BTC", + "ETH", + "USDT", + "USDC", + "SOL", + "BNB", + "LTC", + "BCH", + "ADA", + "ALGO", + "ARB", + "AVAX", + "BAT", + "CRV", + "DAI", + "DOT", + "DOGE", + "DYDX", + "FTM", + "INJ", + "KSM", + "LINK", + "TON", + "TRX", + "MANA", + "POL", + "NEAR", + "NOT", + "1INCH", + "OKB", + "SAND", + "SHIB", + "SWEAT", + "TIA", + "UNI", + "WEMIX", + "XLM", + "XTZ", + "ATOM", + "XRP", + "PEPE", + "TWT", + "JUP", + "DOGS", + "HMSTR", + "MAJOR", + "CATI", + "X", + "TRUMP", + "USDE", + "SUI", + "KAS", + "S" + ], + "config": { + "base": { + "BTC": "BTC", + "ETH": "ETH", + "USDT": "ETH", + "USDC": "ETH", + "SOL": "SOL", + "BNB": "BNB", + "LTC": "LTC", + "BCH": "BCH", + "ADA": "ADA", + "ALGO": "ALGO", + "ARB": "ARB", + "AVAX": "AVAX", + "BAT": "ETH", + "CRV": "ETH", + "DAI": "ETH", + "DOT": "DOT", + "DOGE": "DOGE", + "DYDX": "ETH", + "FTM": "FTM", + "INJ": "INJ", + "KSM": "KSM", + "LINK": "ETH", + "TON": "TON", + "TRX": "TRX", + "MANA": "ETH", + "POL": "POL", + "NEAR": "NEAR", + "NOT": "NOT", + "1INCH": "BNB", + "OKB": "ETH", + "SAND": "ETH", + "SHIB": "ETH", + "SWEAT": "NEAR", + "TIA": "TIA", + "UNI": "ETH", + "WEMIX": "WEMIX", + "XLM": "XLM", + "XTZ": "XTZ", + "ATOM": "ATOM", + "XRP": "XRP", + "PEPE": "PEPE", + "TWT": "TWT", + "JUP": "JUP", + "DOGS": "DOGS", + "HMSTR": "HMSTR", + "MAJOR": "MAJOR", + "CATI": "CATI", + "X": "X", + "TRUMP": "TRUMP", + "USDE": "USDE", + "SUI": "SUI", + "KAS": "KAS", + "S": "S" + }, + "has_withdrawal_fee": { + "BTC": true, + "ETH": true, + "USDT": true, + "USDC": true, + "SOL": true, + "BNB": true, + "LTC": true, + "BCH": true, + "ADA": true, + "ALGO": true, + "ARB": true, + "AVAX": true, + "BAT": true, + "CRV": true, + "DAI": true, + "DOT": true, + "DOGE": true, + "DYDX": true, + "FTM": true, + "INJ": true, + "KSM": true, + "LINK": true, + "TON": false, + "TRX": true, + "MANA": true, + "POL": true, + "NEAR": true, + "NOT": true, + "1INCH": true, + "OKB": true, + "SAND": true, + "SHIB": true, + "SWEAT": true, + "TIA": true, + "UNI": true, + "WEMIX": true, + "XLM": true, + "XTZ": true, + "ATOM": true, + "XRP": true, + "PEPE": true, + "TWT": true, + "JUP": true, + "DOGS": true, + "HMSTR": true, + "MAJOR": true, + "CATI": true, + "X": true, + "TRUMP": true, + "USDE": true, + "SUI": true, + "KAS": true, + "S": true + }, + "display_options": { + "AED": { + "fullname": "United Arab Emirates Dirham", + "total_digits": 2, + "display_digits": 2 + }, + "AMD": { + "fullname": "Armenian Dram", + "total_digits": 2, + "display_digits": 2 + }, + "ARS": { + "fullname": "Argentine peso", + "total_digits": 2, + "display_digits": 2 + }, + "AUD": { + "fullname": "Australian dollar", + "total_digits": 2, + "display_digits": 2 + }, + "BGN": { + "fullname": "Bulgarian lev", + "total_digits": 2, + "display_digits": 2 + }, + "BRL": { + "fullname": "Brazilian real", + "total_digits": 2, + "display_digits": 2 + }, + "CAD": { + "fullname": "Canadian dollar", + "total_digits": 2, + "display_digits": 2 + }, + "CHF": { + "fullname": "Swiss frank", + "total_digits": 2, + "display_digits": 2 + }, + "COP": { + "fullname": "Colombian Peso", + "total_digits": 2, + "display_digits": 2 + }, + "CZK": { + "fullname": "Czech koruna", + "total_digits": 2, + "display_digits": 2 + }, + "DKK": { + "fullname": "Danish krone", + "total_digits": 2, + "display_digits": 2 + }, + "DOP": { + "fullname": "Dominican Peso", + "total_digits": 2, + "display_digits": 2 + }, + "EUR": { + "fullname": "Euro", + "total_digits": 2, + "display_digits": 2 + }, + "GBP": { + "fullname": "Pound sterling", + "total_digits": 2, + "display_digits": 2 + }, + "GEL": { + "fullname": "Georgian Lari", + "total_digits": 2, + "display_digits": 2 + }, + "GHS": { + "fullname": "Ghanaian cedi", + "total_digits": 2, + "display_digits": 2 + }, + "HKD": { + "fullname": "Hong Kong dollar", + "total_digits": 2, + "display_digits": 2 + }, + "HUF": { + "fullname": "Hungarian Forint", + "total_digits": 2, + "display_digits": 2 + }, + "IDR": { + "fullname": "Indonesian rupiah", + "total_digits": 0, + "display_digits": 0 + }, + "ILS": { + "fullname": "Israeli shekel", + "total_digits": 2, + "display_digits": 2 + }, + "INR": { + "fullname": "Indian rupee", + "total_digits": 2, + "display_digits": 2 + }, + "ISK": { + "fullname": "Icelandic Krona", + "total_digits": 0, + "display_digits": 0 + }, + "JOD": { + "fullname": "Jordanian Dinar", + "total_digits": 2, + "display_digits": 2 + }, + "JPY": { + "fullname": "Japanese yen", + "total_digits": 0, + "display_digits": 0 + }, + "KES": { + "fullname": "Kenyan shilling", + "total_digits": 2, + "display_digits": 2 + }, + "KRW": { + "fullname": "South Korean won", + "total_digits": 0, + "display_digits": 0 + }, + "KZT": { + "fullname": "Kazakhstani Tenge", + "total_digits": 2, + "display_digits": 2 + }, + "LKR": { + "fullname": "Sri Lankan Rupee", + "total_digits": 2, + "display_digits": 2 + }, + "MXN": { + "fullname": "Mexican peso", + "total_digits": 2, + "display_digits": 2 + }, + "NGN": { + "fullname": "Nigerian naira", + "total_digits": 2, + "display_digits": 2 + }, + "NOK": { + "fullname": "Norwegian krone", + "total_digits": 2, + "display_digits": 2 + }, + "NZD": { + "fullname": "New Zealand Dollar", + "total_digits": 2, + "display_digits": 2 + }, + "OMR": { + "fullname": "Omani rial", + "total_digits": 3, + "display_digits": 3 + }, + "PEN": { + "fullname": "Peruvian Nuevo Sol", + "total_digits": 2, + "display_digits": 2 + }, + "PHP": { + "fullname": "Philippine peso", + "total_digits": 2, + "display_digits": 2 + }, + "PLN": { + "fullname": "Polish zloty", + "total_digits": 2, + "display_digits": 2 + }, + "QAR": { + "fullname": "Qatari Riyal", + "total_digits": 2, + "display_digits": 2 + }, + "RON": { + "fullname": "New Romanian Lei", + "total_digits": 2, + "display_digits": 2 + }, + "RUB": { + "fullname": "Russian ruble", + "total_digits": 2, + "display_digits": 2 + }, + "SEK": { + "fullname": "Swedish krona", + "total_digits": 2, + "display_digits": 2 + }, + "SGD": { + "fullname": "Singapore Dollar", + "total_digits": 2, + "display_digits": 2 + }, + "THB": { + "fullname": "Thai Baht", + "total_digits": 2, + "display_digits": 2 + }, + "TRY": { + "fullname": "Turkish lira", + "total_digits": 2, + "display_digits": 2 + }, + "TWD": { + "fullname": "New Taiwan dollar", + "total_digits": 2, + "display_digits": 2 + }, + "TZS": { + "fullname": "Tanzanian shilling", + "total_digits": 2, + "display_digits": 2 + }, + "UAH": { + "fullname": "Ukrainian hryvnia", + "total_digits": 2, + "display_digits": 2 + }, + "UGX": { + "fullname": "Ugandan shilling", + "total_digits": 2, + "display_digits": 2 + }, + "USD": { + "fullname": "US dollar", + "total_digits": 2, + "display_digits": 2 + }, + "UYU": { + "fullname": "Uruguayan Peso", + "total_digits": 2, + "display_digits": 2 + }, + "VND": { + "fullname": "Vietnamese Dong", + "total_digits": 2, + "display_digits": 2 + }, + "ZAR": { + "fullname": "South African Rand", + "total_digits": 2, + "display_digits": 2 + }, + "BTC": { + "fullname": "Bitcoin", + "total_digits": 8, + "display_digits": 5 + }, + "ETH": { + "fullname": "ETH", + "total_digits": 18, + "display_digits": 5 + }, + "USDT": { + "fullname": "Tether", + "total_digits": 18, + "display_digits": 2 + }, + "USDC": { + "fullname": "USDC", + "total_digits": 6, + "display_digits": 6 + }, + "SOL": { + "fullname": "Solana", + "total_digits": 9, + "display_digits": 9 + }, + "BNB": { + "fullname": "Binance Coin", + "total_digits": 18, + "display_digits": 6 + }, + "LTC": { + "fullname": "Litecoin", + "total_digits": 8, + "display_digits": 8 + }, + "BCH": { + "fullname": "Bitcoin cash", + "total_digits": 8, + "display_digits": 5 + }, + "ADA": { + "fullname": "Cardano", + "total_digits": 6, + "display_digits": 6 + }, + "ALGO": { + "fullname": "Algorand", + "total_digits": 6, + "display_digits": 6 + }, + "ARB": { + "fullname": "ARB", + "total_digits": 18, + "display_digits": 6 + }, + "AVAX": { + "fullname": "Avalanche (C-Chain)", + "total_digits": 18, + "display_digits": 6 + }, + "BAT": { + "fullname": "Basic attention token", + "total_digits": 18, + "display_digits": 5 + }, + "CRV": { + "fullname": "Curve DAO Token", + "total_digits": 18, + "display_digits": 6 + }, + "DAI": { + "fullname": "Dai Stablecoin", + "total_digits": 18, + "display_digits": 5 + }, + "DOT": { + "fullname": "Polkadot", + "total_digits": 10, + "display_digits": 6 + }, + "DOGE": { + "fullname": "Dogecoin", + "total_digits": 8, + "display_digits": 8 + }, + "DYDX": { + "fullname": "dYdX", + "total_digits": 18, + "display_digits": 6 + }, + "FTM": { + "fullname": "Fantom", + "total_digits": 18, + "display_digits": 6 + }, + "INJ": { + "fullname": "Injective", + "total_digits": 18, + "display_digits": 6 + }, + "KSM": { + "fullname": "Kusama", + "total_digits": 12, + "display_digits": 6 + }, + "LINK": { + "fullname": "Chainlink", + "total_digits": 18, + "display_digits": 6 + }, + "TON": { + "fullname": "The Open Network", + "total_digits": 9, + "display_digits": 4 + }, + "TRX": { + "fullname": "Tron", + "total_digits": 6, + "display_digits": 2 + }, + "MANA": { + "fullname": "Decentraland", + "total_digits": 18, + "display_digits": 6 + }, + "POL": { + "fullname": "Polygon", + "total_digits": 18, + "display_digits": 6 + }, + "NEAR": { + "fullname": "NEAR Protocol", + "total_digits": 24, + "display_digits": 6 + }, + "NOT": { + "fullname": "NOTCOIN", + "total_digits": 9, + "display_digits": 3 + }, + "1INCH": { + "fullname": "1inch Network", + "total_digits": 18, + "display_digits": 6 + }, + "OKB": { + "fullname": "OKB", + "total_digits": 18, + "display_digits": 4 + }, + "SAND": { + "fullname": "The Sandbox", + "total_digits": 18, + "display_digits": 6 + }, + "SHIB": { + "fullname": "Shiba Inu", + "total_digits": 18, + "display_digits": 6 + }, + "SWEAT": { + "fullname": "Sweat", + "total_digits": 18, + "display_digits": 4 + }, + "TIA": { + "fullname": "TIA", + "total_digits": 6, + "display_digits": 6 + }, + "UNI": { + "fullname": "Uniswap", + "total_digits": 18, + "display_digits": 6 + }, + "WEMIX": { + "fullname": "WEMIX", + "total_digits": 10, + "display_digits": 6 + }, + "XLM": { + "fullname": "Stellar", + "total_digits": 7, + "display_digits": 6 + }, + "XTZ": { + "fullname": "Tezos", + "total_digits": 6, + "display_digits": 6 + }, + "ATOM": { + "fullname": "Cosmos", + "total_digits": 6, + "display_digits": 6 + }, + "XRP": { + "fullname": "XRP", + "total_digits": 6, + "display_digits": 6 + }, + "PEPE": { + "fullname": "PEPE", + "total_digits": 18, + "display_digits": 6 + }, + "TWT": { + "fullname": "TWT", + "total_digits": 18, + "display_digits": 6 + }, + "JUP": { + "fullname": "JUPITER", + "total_digits": 9, + "display_digits": 6 + }, + "DOGS": { + "fullname": "DOGS", + "total_digits": 8, + "display_digits": 6 + }, + "HMSTR": { + "fullname": "HMSTR", + "total_digits": 8, + "display_digits": 6 + }, + "MAJOR": { + "fullname": "MAJOR", + "total_digits": 8, + "display_digits": 6 + }, + "CATI": { + "fullname": "CATIZEN", + "total_digits": 8, + "display_digits": 6 + }, + "X": { + "fullname": "X Empire", + "total_digits": 8, + "display_digits": 6 + }, + "TRUMP": { + "fullname": "TRUMP", + "total_digits": 6, + "display_digits": 6 + }, + "USDE": { + "fullname": "USDE", + "total_digits": 9, + "display_digits": 2 + }, + "SUI": { + "fullname": "SUI", + "total_digits": 9, + "display_digits": 6 + }, + "KAS": { + "fullname": "KAS", + "total_digits": 9, + "display_digits": 6 + }, + "S": { + "fullname": "Sonic", + "total_digits": 18, + "display_digits": 10 + } + }, + "icons": { + "AED": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/aed.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/aed.svg", + "png": "v1.6/img/icons/currencies/aed.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/aed.png" + }, + "AMD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/amd.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/amd.svg", + "png": "v1.6/img/icons/currencies/amd.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/amd.png" + }, + "ARS": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ars.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ars.svg", + "png": "v1.6/img/icons/currencies/ars.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ars.png" + }, + "AUD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/aud.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/aud.svg", + "png": "v1.6/img/icons/currencies/aud.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/aud.png" + }, + "BGN": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/bgn.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/bgn.svg", + "png": "v1.6/img/icons/currencies/bgn.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/bgn.png" + }, + "BRL": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/brl.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/brl.svg", + "png": "v1.6/img/icons/currencies/brl.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/brl.png" + }, + "CAD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/cad.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/cad.svg", + "png": "v1.6/img/icons/currencies/cad.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/cad.png" + }, + "CHF": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/chf.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/chf.svg", + "png": "v1.6/img/icons/currencies/chf.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/chf.png" + }, + "COP": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/cop.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/cop.svg", + "png": "v1.6/img/icons/currencies/cop.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/cop.png" + }, + "CZK": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/czk.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/czk.svg", + "png": "v1.6/img/icons/currencies/czk.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/czk.png" + }, + "DKK": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/dkk.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/dkk.svg", + "png": "v1.6/img/icons/currencies/dkk.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/dkk.png" + }, + "DOP": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/dop.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/dop.svg", + "png": "v1.6/img/icons/currencies/dop.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/dop.png" + }, + "EUR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/eur.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/eur.svg", + "png": "v1.6/img/icons/currencies/eur.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/eur.png" + }, + "GBP": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/gbp.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/gbp.svg", + "png": "v1.6/img/icons/currencies/gbp.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/gbp.png" + }, + "GEL": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/default.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/default.svg", + "png": "v1.6/img/icons/currencies/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/default.png" + }, + "GHS": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ghs.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ghs.svg", + "png": "v1.6/img/icons/currencies/ghs.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ghs.png" + }, + "HKD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/hkd.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/hkd.svg", + "png": "v1.6/img/icons/currencies/hkd.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/hkd.png" + }, + "HUF": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/huf.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/huf.svg", + "png": "v1.6/img/icons/currencies/huf.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/huf.png" + }, + "IDR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/idr.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/idr.svg", + "png": "v1.6/img/icons/currencies/idr.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/idr.png" + }, + "ILS": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ils.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ils.svg", + "png": "v1.6/img/icons/currencies/ils.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ils.png" + }, + "INR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/inr.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/inr.svg", + "png": "v1.6/img/icons/currencies/inr.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/inr.png" + }, + "ISK": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/isk.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/isk.svg", + "png": "v1.6/img/icons/currencies/isk.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/isk.png" + }, + "JOD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/jod.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/jod.svg", + "png": "v1.6/img/icons/currencies/jod.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/jod.png" + }, + "JPY": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/jpy.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/jpy.svg", + "png": "v1.6/img/icons/currencies/jpy.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/jpy.png" + }, + "KES": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/kes.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/kes.svg", + "png": "v1.6/img/icons/currencies/kes.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/kes.png" + }, + "KRW": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/krw.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/krw.svg", + "png": "v1.6/img/icons/currencies/krw.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/krw.png" + }, + "KZT": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/kzt.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/kzt.svg", + "png": "v1.6/img/icons/currencies/kzt.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/kzt.png" + }, + "LKR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/lkr.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/lkr.svg", + "png": "v1.6/img/icons/currencies/lkr.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/lkr.png" + }, + "MXN": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/mxn.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/mxn.svg", + "png": "v1.6/img/icons/currencies/mxn.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/mxn.png" + }, + "NGN": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ngn.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ngn.svg", + "png": "v1.6/img/icons/currencies/ngn.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ngn.png" + }, + "NOK": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/nok.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/nok.svg", + "png": "v1.6/img/icons/currencies/nok.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/nok.png" + }, + "NZD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/nzd.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/nzd.svg", + "png": "v1.6/img/icons/currencies/nzd.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/nzd.png" + }, + "OMR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/default.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/default.svg", + "png": "v1.6/img/icons/currencies/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/default.png" + }, + "PEN": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/pen.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/pen.svg", + "png": "v1.6/img/icons/currencies/pen.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/pen.png" + }, + "PHP": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/php.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/php.svg", + "png": "v1.6/img/icons/currencies/php.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/php.png" + }, + "PLN": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/pln.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/pln.svg", + "png": "v1.6/img/icons/currencies/pln.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/pln.png" + }, + "QAR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/qar.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/qar.svg", + "png": "v1.6/img/icons/currencies/qar.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/qar.png" + }, + "RON": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ron.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ron.svg", + "png": "v1.6/img/icons/currencies/ron.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ron.png" + }, + "RUB": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/rub.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/rub.svg", + "png": "v1.6/img/icons/currencies/rub.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/rub.png" + }, + "SEK": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/sek.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/sek.svg", + "png": "v1.6/img/icons/currencies/sek.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/sek.png" + }, + "SGD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/sgd.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/sgd.svg", + "png": "v1.6/img/icons/currencies/sgd.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/sgd.png" + }, + "THB": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/thb.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/thb.svg", + "png": "v1.6/img/icons/currencies/thb.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/thb.png" + }, + "TRY": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/try.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/try.svg", + "png": "v1.6/img/icons/currencies/try.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/try.png" + }, + "TWD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/twd.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/twd.svg", + "png": "v1.6/img/icons/currencies/twd.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/twd.png" + }, + "TZS": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/tzs.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/tzs.svg", + "png": "v1.6/img/icons/currencies/tzs.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/tzs.png" + }, + "UAH": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/uah.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/uah.svg", + "png": "v1.6/img/icons/currencies/uah.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/uah.png" + }, + "UGX": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ugx.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ugx.svg", + "png": "v1.6/img/icons/currencies/ugx.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ugx.png" + }, + "USD": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/usd.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/usd.svg", + "png": "v1.6/img/icons/currencies/usd.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/usd.png" + }, + "UYU": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/uyu.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/uyu.svg", + "png": "v1.6/img/icons/currencies/uyu.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/uyu.png" + }, + "VND": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/vnd.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/vnd.svg", + "png": "v1.6/img/icons/currencies/vnd.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/vnd.png" + }, + "ZAR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/zar.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/zar.svg", + "png": "v1.6/img/icons/currencies/zar.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/zar.png" + }, + "BTC": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/btc.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/btc.svg", + "png": "v1.6/img/icons/currencies/btc.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/btc.png" + }, + "ETH": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/eth.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/eth.svg", + "png": "v1.6/img/icons/currencies/eth.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/eth.png" + }, + "USDT": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/usdt.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/usdt.svg", + "png": "v1.6/img/icons/currencies/usdt.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/usdt.png" + }, + "USDC": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/usdc.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/usdc.svg", + "png": "v1.6/img/icons/currencies/usdc.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/usdc.png" + }, + "SOL": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/sol.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/sol.svg", + "png": "v1.6/img/icons/currencies/sol.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/sol.png" + }, + "BNB": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/bnb.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/bnb.svg", + "png": "v1.6/img/icons/currencies/bnb.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/bnb.png" + }, + "LTC": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ltc.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ltc.svg", + "png": "v1.6/img/icons/currencies/ltc.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ltc.png" + }, + "BCH": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/bch.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/bch.svg", + "png": "v1.6/img/icons/currencies/bch.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/bch.png" + }, + "ADA": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ada.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ada.svg", + "png": "v1.6/img/icons/currencies/ada.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ada.png" + }, + "ALGO": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/algo.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/algo.svg", + "png": "v1.6/img/icons/currencies/algo.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/algo.png" + }, + "ARB": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/arb.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/arb.svg", + "png": "v1.6/img/icons/currencies/arb.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/arb.png" + }, + "AVAX": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/avax.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/avax.svg", + "png": "v1.6/img/icons/currencies/avax.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/avax.png" + }, + "BAT": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/bat.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/bat.svg", + "png": "v1.6/img/icons/currencies/bat.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/bat.png" + }, + "CRV": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/crv.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/crv.svg", + "png": "v1.6/img/icons/currencies/crv.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/crv.png" + }, + "DAI": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/dai.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/dai.svg", + "png": "v1.6/img/icons/currencies/dai.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/dai.png" + }, + "DOT": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/dot.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/dot.svg", + "png": "v1.6/img/icons/currencies/dot.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/dot.png" + }, + "DOGE": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/doge.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/doge.svg", + "png": "v1.6/img/icons/currencies/doge.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/doge.png" + }, + "DYDX": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/dydx.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/dydx.svg", + "png": "v1.6/img/icons/currencies/dydx.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/dydx.png" + }, + "FTM": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ftm.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ftm.svg", + "png": "v1.6/img/icons/currencies/ftm.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ftm.png" + }, + "INJ": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/inj.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/inj.svg", + "png": "v1.6/img/icons/currencies/inj.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/inj.png" + }, + "KSM": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ksm.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ksm.svg", + "png": "v1.6/img/icons/currencies/ksm.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ksm.png" + }, + "LINK": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/link.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/link.svg", + "png": "v1.6/img/icons/currencies/link.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/link.png" + }, + "TON": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/ton.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/ton.svg", + "png": "v1.6/img/icons/currencies/ton.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/ton.png" + }, + "TRX": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/trx.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/trx.svg", + "png": "v1.6/img/icons/currencies/trx.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/trx.png" + }, + "MANA": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/mana.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/mana.svg", + "png": "v1.6/img/icons/currencies/mana.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/mana.png" + }, + "POL": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/pol.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/pol.svg", + "png": "v1.6/img/icons/currencies/pol.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/pol.png" + }, + "NEAR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/near.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/near.svg", + "png": "v1.6/img/icons/currencies/near.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/near.png" + }, + "NOT": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/not.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/not.svg", + "png": "v1.6/img/icons/currencies/not.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/not.png" + }, + "1INCH": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/1inch.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/1inch.svg", + "png": "v1.6/img/icons/currencies/1inch.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/1inch.png" + }, + "OKB": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/okb.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/okb.svg", + "png": "v1.6/img/icons/currencies/okb.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/okb.png" + }, + "SAND": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/sand.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/sand.svg", + "png": "v1.6/img/icons/currencies/sand.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/sand.png" + }, + "SHIB": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/shib.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/shib.svg", + "png": "v1.6/img/icons/currencies/shib.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/shib.png" + }, + "SWEAT": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/sweat.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/sweat.svg", + "png": "v1.6/img/icons/currencies/sweat.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/sweat.png" + }, + "TIA": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/tia.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/tia.svg", + "png": "v1.6/img/icons/currencies/tia.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/tia.png" + }, + "UNI": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/uni.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/uni.svg", + "png": "v1.6/img/icons/currencies/uni.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/uni.png" + }, + "WEMIX": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/wemix.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/wemix.svg", + "png": "v1.6/img/icons/currencies/wemix.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/wemix.png" + }, + "XLM": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/xlm.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/xlm.svg", + "png": "v1.6/img/icons/currencies/xlm.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/xlm.png" + }, + "XTZ": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/xtz.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/xtz.svg", + "png": "v1.6/img/icons/currencies/xtz.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/xtz.png" + }, + "ATOM": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/atom.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/atom.svg", + "png": "v1.6/img/icons/currencies/atom.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/atom.png" + }, + "XRP": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/xrp.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/xrp.svg", + "png": "v1.6/img/icons/currencies/xrp.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/xrp.png" + }, + "PEPE": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/pepe.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/pepe.svg", + "png": "v1.6/img/icons/currencies/pepe.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/pepe.png" + }, + "TWT": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/twt.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/twt.svg", + "png": "v1.6/img/icons/currencies/twt.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/twt.png" + }, + "JUP": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/jup.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/jup.svg", + "png": "v1.6/img/icons/currencies/jup.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/jup.png" + }, + "DOGS": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/dogs.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/dogs.svg", + "png": "v1.6/img/icons/currencies/dogs.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/dogs.png" + }, + "HMSTR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/hmstr.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/hmstr.svg", + "png": "v1.6/img/icons/currencies/hmstr.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/hmstr.png" + }, + "MAJOR": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/major.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/major.svg", + "png": "v1.6/img/icons/currencies/major.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/major.png" + }, + "CATI": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/cati.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/cati.svg", + "png": "v1.6/img/icons/currencies/cati.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/cati.png" + }, + "X": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/x.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/x.svg", + "png": "v1.6/img/icons/currencies/x.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/x.png" + }, + "TRUMP": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/trump.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/trump.svg", + "png": "v1.6/img/icons/currencies/trump.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/trump.png" + }, + "USDE": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/usde.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/usde.svg", + "png": "v1.6/img/icons/currencies/usde.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/usde.png" + }, + "SUI": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/sui.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/sui.svg", + "png": "v1.6/img/icons/currencies/sui.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/sui.png" + }, + "KAS": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/kas.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/kas.svg", + "png": "v1.6/img/icons/currencies/kas.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/kas.png" + }, + "S": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/currencies/s.svg", + "relative": { + "svg": "v1.6/img/icons/currencies/s.svg", + "png": "v1.6/img/icons/currencies/s.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/currencies/s.png" + } + }, + "networks": { + "ALGORAND": { + "name": "ALGORAND", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "ARBITRUM": { + "name": "ARBITRUM", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/arbitrum.svg", + "relative": { + "svg": "v1.6/img/icons/networks/arbitrum.svg", + "png": "v1.6/img/icons/networks/arbitrum.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/arbitrum.png" + } + }, + "AVALANCHE": { + "name": "AVALANCHE", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/avalanche.svg", + "relative": { + "svg": "v1.6/img/icons/networks/avalanche.svg", + "png": "v1.6/img/icons/networks/avalanche.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/avalanche.png" + } + }, + "BASE": { + "name": "BASE", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/base.svg", + "relative": { + "svg": "v1.6/img/icons/networks/base.svg", + "png": "v1.6/img/icons/networks/base.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/base.png" + } + }, + "BINANCESMARTCHAIN": { + "name": "BINANCESMARTCHAIN", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/binancesmartchain.svg", + "relative": { + "svg": "v1.6/img/icons/networks/binancesmartchain.svg", + "png": "v1.6/img/icons/networks/binancesmartchain.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/binancesmartchain.png" + } + }, + "BITCOIN": { + "name": "BITCOIN", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "BITCOINCASH": { + "name": "BITCOINCASH", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "CARDANO": { + "name": "CARDANO", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "CELESTIA": { + "name": "CELESTIA", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/celestia.svg", + "relative": { + "svg": "v1.6/img/icons/networks/celestia.svg", + "png": "v1.6/img/icons/networks/celestia.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/celestia.png" + } + }, + "COSMOS": { + "name": "COSMOS", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "CRONOS": { + "name": "CRONOS", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "DASH": { + "name": "DASH", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "DOGECOIN": { + "name": "DOGECOIN", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "ETHEREUM": { + "name": "ETHEREUM", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/ethereum.svg", + "relative": { + "svg": "v1.6/img/icons/networks/ethereum.svg", + "png": "v1.6/img/icons/networks/ethereum.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/ethereum.png" + } + }, + "FANTOM": { + "name": "FANTOM", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "FLOW": { + "name": "FLOW", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "INJECTIVE": { + "name": "INJECTIVE", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/injective.svg", + "relative": { + "svg": "v1.6/img/icons/networks/injective.svg", + "png": "v1.6/img/icons/networks/injective.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/injective.png" + } + }, + "KASPA": { + "name": "KASPA", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/kaspa.svg", + "relative": { + "svg": "v1.6/img/icons/networks/kaspa.svg", + "png": "v1.6/img/icons/networks/kaspa.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/kaspa.png" + } + }, + "KAVA": { + "name": "KAVA", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "KUSAMA": { + "name": "KUSAMA", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "LINEA": { + "name": "LINEA", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/linea.svg", + "relative": { + "svg": "v1.6/img/icons/networks/linea.svg", + "png": "v1.6/img/icons/networks/linea.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/linea.png" + } + }, + "LITECOIN": { + "name": "LITECOIN", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "NEAR_PROTOCOL": { + "name": "NEAR_PROTOCOL", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/near_protocol.svg", + "relative": { + "svg": "v1.6/img/icons/networks/near_protocol.svg", + "png": "v1.6/img/icons/networks/near_protocol.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/near_protocol.png" + } + }, + "NEWTON": { + "name": "NEWTON", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/newton.svg", + "relative": { + "svg": "v1.6/img/icons/networks/newton.svg", + "png": "v1.6/img/icons/networks/newton.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/newton.png" + } + }, + "OPTIMISM": { + "name": "OPTIMISM", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/optimism.svg", + "relative": { + "svg": "v1.6/img/icons/networks/optimism.svg", + "png": "v1.6/img/icons/networks/optimism.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/optimism.png" + } + }, + "POLKADOT": { + "name": "POLKADOT", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "POLYGON": { + "name": "POLYGON", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/polygon.svg", + "relative": { + "svg": "v1.6/img/icons/networks/polygon.svg", + "png": "v1.6/img/icons/networks/polygon.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/polygon.png" + } + }, + "RIPPLE": { + "name": "RIPPLE", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "SOLANA": { + "name": "SOLANA", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/solana.svg", + "relative": { + "svg": "v1.6/img/icons/networks/solana.svg", + "png": "v1.6/img/icons/networks/solana.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/solana.png" + } + }, + "SONIC": { + "name": "SONIC", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/sonic.svg", + "relative": { + "svg": "v1.6/img/icons/networks/sonic.svg", + "png": "v1.6/img/icons/networks/sonic.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/sonic.png" + } + }, + "STELLAR": { + "name": "STELLAR", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/stellar.svg", + "relative": { + "svg": "v1.6/img/icons/networks/stellar.svg", + "png": "v1.6/img/icons/networks/stellar.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/stellar.png" + } + }, + "SUI": { + "name": "SUI", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/sui.svg", + "relative": { + "svg": "v1.6/img/icons/networks/sui.svg", + "png": "v1.6/img/icons/networks/sui.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/sui.png" + } + }, + "TERRA": { + "name": "TERRA", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/terra.svg", + "relative": { + "svg": "v1.6/img/icons/networks/terra.svg", + "png": "v1.6/img/icons/networks/terra.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/terra.png" + } + }, + "TEZOS": { + "name": "TEZOS", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/default.svg", + "relative": { + "svg": "v1.6/img/icons/networks/default.svg", + "png": "v1.6/img/icons/networks/default.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/default.png" + } + }, + "TRON": { + "name": "TRON", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/tron.svg", + "relative": { + "svg": "v1.6/img/icons/networks/tron.svg", + "png": "v1.6/img/icons/networks/tron.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/tron.png" + } + }, + "WEMIX": { + "name": "WEMIX", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/wemix.svg", + "relative": { + "svg": "v1.6/img/icons/networks/wemix.svg", + "png": "v1.6/img/icons/networks/wemix.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/wemix.png" + } + }, + "ZKSYNC": { + "name": "ZKSYNC", + "icons": { + "svg": "https://api.mercuryo.io/v1.6/img/icons/networks/zksync.svg", + "relative": { + "svg": "v1.6/img/icons/networks/zksync.svg", + "png": "v1.6/img/icons/networks/zksync.png" + }, + "png": "https://api.mercuryo.io/v1.6/img/icons/networks/zksync.png" + } + } + }, + "crypto_currencies": [ + { + "currency": "BTC", + "network": "BITCOIN", + "show_network_icon": false, + "network_label": "BITCOIN", + "contract": "" + }, + { + "currency": "ETH", + "network": "ARBITRUM", + "show_network_icon": true, + "network_label": "ARBITRUM", + "contract": "" + }, + { + "currency": "ETH", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ETHEREUM", + "contract": "" + }, + { + "currency": "ETH", + "network": "ZKSYNC", + "show_network_icon": true, + "network_label": "ZKSYNC", + "contract": "" + }, + { + "currency": "ETH", + "network": "LINEA", + "show_network_icon": true, + "network_label": "LINEA", + "contract": "" + }, + { + "currency": "ETH", + "network": "BASE", + "show_network_icon": true, + "network_label": "BASE", + "contract": "" + }, + { + "currency": "ETH", + "network": "OPTIMISM", + "show_network_icon": true, + "network_label": "OPTIMISM", + "contract": "" + }, + { + "currency": "USDT", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "network_ud": "ERC20" + }, + { + "currency": "USDT", + "network": "SOLANA", + "show_network_icon": true, + "network_label": "SOLANA", + "contract": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" + }, + { + "currency": "USDT", + "network": "BINANCESMARTCHAIN", + "show_network_icon": true, + "network_label": "BNB Chain", + "contract": "0x55d398326f99059fF775485246999027B3197955" + }, + { + "currency": "USDT", + "network": "POLYGON", + "show_network_icon": true, + "network_label": "POLYGON", + "contract": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F" + }, + { + "currency": "USDT", + "network": "NEWTON", + "show_network_icon": true, + "network_label": "TON", + "contract": "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" + }, + { + "currency": "USDT", + "network": "TRON", + "show_network_icon": true, + "network_label": "TRC-20", + "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "network_ud": "TRON" + }, + { + "currency": "USDT", + "network": "AVALANCHE", + "show_network_icon": true, + "network_label": "AVALANCHE", + "contract": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7" + }, + { + "currency": "USDT", + "network": "ARBITRUM", + "show_network_icon": true, + "network_label": "ARBITRUM", + "contract": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9" + }, + { + "currency": "USDC", + "network": "POLYGON", + "show_network_icon": true, + "network_label": "POLYGON", + "contract": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" + }, + { + "currency": "USDC", + "network": "NEAR_PROTOCOL", + "show_network_icon": true, + "network_label": "NEAR", + "contract": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1" + }, + { + "currency": "USDC", + "network": "SOLANA", + "show_network_icon": true, + "network_label": "SOLANA", + "contract": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + }, + { + "currency": "USDC", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ETHEREUM", + "contract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + { + "currency": "USDC", + "network": "BINANCESMARTCHAIN", + "show_network_icon": true, + "network_label": "BNB Chain", + "contract": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d-8R8-XHwh3gsNKhy-UrdrPcUo" + }, + { + "currency": "USDC", + "network": "STELLAR", + "show_network_icon": true, + "network_label": "STELLAR", + "contract": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + }, + { + "currency": "USDC", + "network": "BASE", + "show_network_icon": true, + "network_label": "BASE", + "contract": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + }, + { + "currency": "USDC", + "network": "AVALANCHE", + "show_network_icon": true, + "network_label": "AVALANCHE", + "contract": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E" + }, + { + "currency": "USDC", + "network": "ARBITRUM", + "show_network_icon": true, + "network_label": "ARBITRUM", + "contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + }, + { + "currency": "SOL", + "network": "SOLANA", + "show_network_icon": false, + "network_label": "SOLANA", + "contract": "" + }, + { + "currency": "BNB", + "network": "BINANCESMARTCHAIN", + "show_network_icon": false, + "network_label": "BEP-20", + "contract": "" + }, + { + "currency": "LTC", + "network": "LITECOIN", + "show_network_icon": false, + "network_label": "LITECOIN", + "contract": "" + }, + { + "currency": "BCH", + "network": "BITCOINCASH", + "show_network_icon": false, + "network_label": "BITCOINCASH", + "contract": "" + }, + { + "currency": "ADA", + "network": "CARDANO", + "show_network_icon": false, + "network_label": "CARDANO", + "contract": "" + }, + { + "currency": "ALGO", + "network": "ALGORAND", + "show_network_icon": false, + "network_label": "ALGORAND", + "contract": "" + }, + { + "currency": "ARB", + "network": "ARBITRUM", + "show_network_icon": true, + "network_label": "ARBITRUM", + "contract": "0x912CE59144191C1204E64559FE8253a0e49E6548" + }, + { + "currency": "AVAX", + "network": "AVALANCHE", + "show_network_icon": false, + "network_label": "AVALANCHE", + "contract": "" + }, + { + "currency": "BAT", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x0d8775f648430679a709e98d2b0cb6250d2887ef" + }, + { + "currency": "CRV", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0xd533a949740bb3306d119cc777fa900ba034cd52", + "network_ud": "ERC20" + }, + { + "currency": "DAI", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x6b175474e89094c44da98b954eedeac495271d0f" + }, + { + "currency": "DOT", + "network": "POLKADOT", + "show_network_icon": false, + "network_label": "POLKADOT", + "contract": "" + }, + { + "currency": "DOGE", + "network": "DOGECOIN", + "show_network_icon": false, + "network_label": "DOGECOIN", + "contract": "" + }, + { + "currency": "DYDX", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x92d6c1e31e14520e676a687f0a93788b716beff5" + }, + { + "currency": "FTM", + "network": "FANTOM", + "show_network_icon": false, + "network_label": "FANTOM", + "contract": "" + }, + { + "currency": "INJ", + "network": "INJECTIVE", + "show_network_icon": false, + "network_label": "INJECTIVE", + "contract": "" + }, + { + "currency": "KSM", + "network": "KUSAMA", + "show_network_icon": false, + "network_label": "KUSAMA", + "contract": "" + }, + { + "currency": "LINK", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x514910771AF9Ca656af840dff83E8264EcF986CA" + }, + { + "currency": "TON", + "network": "NEWTON", + "show_network_icon": false, + "network_label": "NEWTON", + "contract": "" + }, + { + "currency": "TRX", + "network": "TRON", + "show_network_icon": false, + "network_label": "TRC-20", + "contract": "" + }, + { + "currency": "MANA", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x0f5d2fb29fb7d3cfee444a200298f468908cc942", + "network_ud": "ERC20" + }, + { + "currency": "POL", + "network": "POLYGON", + "show_network_icon": false, + "network_label": "POLYGON", + "contract": "", + "network_ud": "POL" + }, + { + "currency": "NEAR", + "network": "NEAR_PROTOCOL", + "show_network_icon": false, + "network_label": "NEAR_PROTOCOL", + "contract": "" + }, + { + "currency": "NOT", + "network": "NEWTON", + "show_network_icon": true, + "network_label": "TON", + "contract": "EQAvlWFDxGF2lXm67y4yzC17wYKD9A0guwPkMs1gOsM__NOT" + }, + { + "currency": "1INCH", + "network": "BINANCESMARTCHAIN", + "show_network_icon": true, + "network_label": "BEP-20", + "contract": "0x111111111117dc0aa78b770fa6a738034120c302" + }, + { + "currency": "OKB", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x75231f58b43240c9718dd58b4967c5114342a86c" + }, + { + "currency": "SAND", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x3845badade8e6dff049820680d1f14bd3903a5d0", + "network_ud": "ERC20" + }, + { + "currency": "SHIB", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE", + "network_ud": "ERC20" + }, + { + "currency": "SWEAT", + "network": "NEAR_PROTOCOL", + "show_network_icon": true, + "network_label": "NEAR", + "contract": "token.sweat" + }, + { + "currency": "TIA", + "network": "CELESTIA", + "show_network_icon": false, + "network_label": "CELESTIA", + "contract": "" + }, + { + "currency": "UNI", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ERC-20", + "contract": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "network_ud": "ERC20" + }, + { + "currency": "WEMIX", + "network": "WEMIX", + "show_network_icon": false, + "network_label": "WEMIX", + "contract": "" + }, + { + "currency": "XLM", + "network": "STELLAR", + "show_network_icon": false, + "network_label": "STELLAR", + "contract": "" + }, + { + "currency": "XTZ", + "network": "TEZOS", + "show_network_icon": false, + "network_label": "TEZOS", + "contract": "" + }, + { + "currency": "ATOM", + "network": "COSMOS", + "show_network_icon": false, + "network_label": "COSMOS", + "contract": "" + }, + { + "currency": "XRP", + "network": "RIPPLE", + "show_network_icon": false, + "network_label": "RIPPLE", + "contract": "" + }, + { + "currency": "PEPE", + "network": "ETHEREUM", + "show_network_icon": true, + "network_label": "ETHEREUM", + "contract": "0x6982508145454ce325ddbe47a25d4ec3d2311933" + }, + { + "currency": "TWT", + "network": "BINANCESMARTCHAIN", + "show_network_icon": true, + "network_label": "BEP-20", + "contract": "0x4B0F1812e5Df2A09796481Ff14017e6005508003" + }, + { + "currency": "JUP", + "network": "SOLANA", + "show_network_icon": true, + "network_label": "SOLANA", + "contract": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN" + }, + { + "currency": "DOGS", + "network": "NEWTON", + "show_network_icon": true, + "network_label": "TON", + "contract": "EQCvxJy4eG8hyHBFsZ7eePxrRsUQSFE_jpptRAYBmcG_DOGS" + }, + { + "currency": "HMSTR", + "network": "NEWTON", + "show_network_icon": true, + "network_label": "TON", + "contract": "EQAJ8uWd7EBqsmpSWaRdf_I-8R8-XHwh3gsNKhy-UrdrPcUo" + }, + { + "currency": "MAJOR", + "network": "NEWTON", + "show_network_icon": true, + "network_label": "TON", + "contract": "EQCuPm01HldiduQ55xaBF_1kaW_WAUy5DHey8suqzU_MAJOR" + }, + { + "currency": "CATI", + "network": "NEWTON", + "show_network_icon": true, + "network_label": "TON", + "contract": "EQD-cvR0Nz6XAyRBvbhz-abTrRC6sI5tvHvvpeQraV9UAAD7" + }, + { + "currency": "X", + "network": "NEWTON", + "show_network_icon": true, + "network_label": "TON", + "contract": "EQB4zZusHsbU2vVTPqjhlokIOoiZhEdCMT703CWEzhTOo__X" + }, + { + "currency": "TRUMP", + "network": "SOLANA", + "show_network_icon": true, + "network_label": "SOLANA", + "contract": "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN" + }, + { + "currency": "USDE", + "network": "NEWTON", + "show_network_icon": true, + "network_label": "TON", + "contract": "EQAIb6KmdfdDR7CN1GBqVJuP25iCnLKCvBlJ07Evuu2dzP5f" + }, + { + "currency": "SUI", + "network": "SUI", + "show_network_icon": true, + "network_label": "SUI", + "contract": "" + }, + { + "currency": "KAS", + "network": "KASPA", + "show_network_icon": true, + "network_label": "KASPA", + "contract": "" + }, + { + "currency": "S", + "network": "SONIC", + "show_network_icon": true, + "network_label": "SONIC", + "contract": "" + } + ], + "default_networks": { + "BTC": "BITCOIN", + "ETH": "ETHEREUM", + "USDT": "ETHEREUM", + "USDC": "ETHEREUM", + "SOL": "SOLANA", + "BNB": "BINANCESMARTCHAIN", + "LTC": "LITECOIN", + "BCH": "BITCOINCASH", + "ADA": "CARDANO", + "ALGO": "ALGORAND", + "ARB": "ARBITRUM", + "AVAX": "AVALANCHE", + "BAT": "ETHEREUM", + "CRV": "ETHEREUM", + "DAI": "ETHEREUM", + "DOT": "POLKADOT", + "DOGE": "DOGECOIN", + "DYDX": "ETHEREUM", + "FTM": "FANTOM", + "INJ": "INJECTIVE", + "KSM": "KUSAMA", + "LINK": "ETHEREUM", + "TON": "NEWTON", + "TRX": "TRON", + "MANA": "ETHEREUM", + "POL": "POLYGON", + "NEAR": "NEAR_PROTOCOL", + "NOT": "NEWTON", + "1INCH": "BINANCESMARTCHAIN", + "OKB": "ETHEREUM", + "SAND": "ETHEREUM", + "SHIB": "ETHEREUM", + "SWEAT": "NEAR_PROTOCOL", + "TIA": "CELESTIA", + "UNI": "ETHEREUM", + "WEMIX": "WEMIX", + "XLM": "STELLAR", + "XTZ": "TEZOS", + "ATOM": "COSMOS", + "XRP": "RIPPLE", + "PEPE": "ETHEREUM", + "TWT": "BINANCESMARTCHAIN", + "JUP": "SOLANA", + "DOGS": "NEWTON", + "HMSTR": "NEWTON", + "MAJOR": "NEWTON", + "CATI": "NEWTON", + "X": "NEWTON", + "TRUMP": "SOLANA", + "USDE": "NEWTON", + "SUI": "SUI", + "KAS": "KASPA", + "S": "SONIC" + } + }, + "fiat_payment_methods": { + "AED": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "107.70", + "max": "21539.95" + } + }, + "AMD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + } + ], + "limits": { + "min": "11181.44", + "max": "2236287.54" + } + }, + "AUD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "44.65", + "max": "8928.80" + } + }, + "BGN": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "48.90", + "max": "9779.15" + } + }, + "BRL": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "unlimint_pix_brl", + "name": "PIX instant payment" + } + ], + "limits": { + "min": "158.56", + "max": "31711.64" + } + }, + "CAD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + }, + { + "code": "interac_gk", + "name": "Interac e-Transfer" + } + ], + "limits": { + "min": "40.51", + "max": "8100.89" + } + }, + "CHF": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "23.47", + "max": "4693.11" + } + }, + "COP": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "116882.84", + "max": "23376567.39" + } + }, + "CZK": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "610.02", + "max": "122002.76" + } + }, + "DKK": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "186.63", + "max": "37325.44" + } + }, + "DOP": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + } + ], + "limits": { + "min": "1856.12", + "max": "371223.80" + } + }, + "EUR": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + }, + { + "code": "volt_banktransfer_eur", + "name": "Bank payment SEPA" + } + ], + "limits": { + "min": "25", + "max": "5000" + } + }, + "GBP": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "21.70", + "max": "4339.37" + } + }, + "GHS": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + } + ], + "limits": { + "min": "354.00", + "max": "70798.91" + } + }, + "HKD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "229.04", + "max": "45806.47" + } + }, + "HUF": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "9807.54", + "max": "1961506.20" + } + }, + "IDR": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "EightBWorld_bni", + "name": "BNI: Virtual account" + }, + { + "code": "EightBWorld_bri", + "name": "BRI: Virtual account" + }, + { + "code": "EightBWorld_qris", + "name": "QRIS" + }, + { + "code": "EightBWorld_dana", + "name": "DANA" + }, + { + "code": "EightBWorld_ovo", + "name": "OVO" + }, + { + "code": "EightBWorld_mandiri", + "name": "Mandiri: Virtual Account" + }, + { + "code": "EightBWorld_permata", + "name": "Permata: Virtual Account" + } + ], + "limits": { + "min": "480406", + "max": "96081065" + } + }, + "ILS": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "97.56", + "max": "19510.34" + } + }, + "INR": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + } + ], + "limits": { + "min": "2585.39", + "max": "517076.12" + } + }, + "ISK": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "3581", + "max": "716036" + } + }, + "JOD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + } + ], + "limits": { + "min": "20.80", + "max": "4158.43" + } + }, + "JPY": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "4320", + "max": "864087" + } + }, + "KRW": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "40630", + "max": "8125881" + } + }, + "KZT": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + } + ], + "limits": { + "min": "15745.67", + "max": "3149132.09" + } + }, + "LKR": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + } + ], + "limits": { + "min": "8858.02", + "max": "1771602.18" + } + }, + "MXN": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "546.60", + "max": "109319.97" + } + }, + "NOK": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "294.03", + "max": "58804.82" + } + }, + "NZD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "49.72", + "max": "9943.60" + } + }, + "PEN": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + } + ], + "limits": { + "min": "103.36", + "max": "20670.80" + } + }, + "PHP": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + }, + { + "code": "EightBWorld_instapay", + "name": "Instapay QRPh" + } + ], + "limits": { + "min": "1661.90", + "max": "332378.93" + } + }, + "PLN": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + }, + { + "code": "blik_direct", + "name": "BLIK" + } + ], + "limits": { + "min": "106.21", + "max": "21241.12" + } + }, + "QAR": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "106.75", + "max": "21349.33" + } + }, + "RON": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "126.96", + "max": "25391.07" + } + }, + "SEK": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "274.89", + "max": "54976.61" + } + }, + "SGD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "37.66", + "max": "7530.42" + } + }, + "THB": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "941.42", + "max": "188282.78" + } + }, + "TRY": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "1209.31", + "max": "241860.22" + } + }, + "TWD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + } + ], + "limits": { + "min": "893.65", + "max": "178729.98" + } + }, + "USD": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "google", + "name": "Google Pay" + }, + { + "code": "apple", + "name": "Apple Pay" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "29.33", + "max": "5865.20" + } + }, + "UYU": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + } + ], + "limits": { + "min": "1175.47", + "max": "235093.83" + } + }, + "VND": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + } + ], + "limits": { + "min": "775528", + "max": "155105469" + } + }, + "ZAR": { + "payment_methods": [ + { + "code": "card", + "name": "Visa" + }, + { + "code": "card", + "name": "Mastercard" + }, + { + "code": "revolut_pay", + "name": "Revolut Pay" + } + ], + "limits": { + "min": "515.75", + "max": "103149.46" + } + } + } + } +} \ No newline at end of file diff --git a/core/crates/fiat/testdata/mercuryo/webhook_buy_complete.json b/core/crates/fiat/testdata/mercuryo/webhook_buy_complete.json new file mode 100644 index 0000000000..62f2d999ce --- /dev/null +++ b/core/crates/fiat/testdata/mercuryo/webhook_buy_complete.json @@ -0,0 +1,11 @@ +{ + "data": { + "id": "buy_provider_tx_123456789", + "merchant_transaction_id": "11111111-2222-4333-8444-555555555555", + "type": "buy", + "payment_method": "card", + "status": "cancelled", + "fiat_amount": "270.00", + "fiat_currency": "USD" + } +} diff --git a/core/crates/fiat/testdata/mercuryo/webhook_mobile_pay_complete.json b/core/crates/fiat/testdata/mercuryo/webhook_mobile_pay_complete.json new file mode 100644 index 0000000000..cac66e685f --- /dev/null +++ b/core/crates/fiat/testdata/mercuryo/webhook_mobile_pay_complete.json @@ -0,0 +1,11 @@ +{ + "data": { + "id": "0f93bf39e3e918172", + "merchant_transaction_id": "0d274c2f-cd7f-4137-a5b4-e63c4c2c020b", + "type": "buy", + "payment_method": "mobile_pay", + "status": "paid", + "fiat_amount": "1500.00", + "fiat_currency": "USD" + } +} diff --git a/core/crates/fiat/testdata/mercuryo/webhook_sell_complete.json b/core/crates/fiat/testdata/mercuryo/webhook_sell_complete.json new file mode 100644 index 0000000000..00f9756180 --- /dev/null +++ b/core/crates/fiat/testdata/mercuryo/webhook_sell_complete.json @@ -0,0 +1,14 @@ +{ + "data": { + "id": "sell_provider_tx_123456789", + "merchant_transaction_id": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", + "type": "sell", + "payment_method": "bank_card", + "status": "succeeded", + "fiat_amount": "250.50", + "fiat_currency": "GBP", + "tx": { + "id": "SELL_DEPOSIT_TX_HASH_123" + } + } +} diff --git a/core/crates/fiat/testdata/mercuryo/webhook_withdraw_complete.json b/core/crates/fiat/testdata/mercuryo/webhook_withdraw_complete.json new file mode 100644 index 0000000000..ac4aaa3841 --- /dev/null +++ b/core/crates/fiat/testdata/mercuryo/webhook_withdraw_complete.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "withdraw_provider_tx_123456789", + "tx": { + "id": "CELESTIA_TX_HASH_123" + }, + "merchant_transaction_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "status": "completed", + "fiat_amount": "39.23", + "fiat_currency": "EUR" + } +} diff --git a/core/crates/fiat/testdata/mercuryo/webhook_withdraw_same_order_complete.json b/core/crates/fiat/testdata/mercuryo/webhook_withdraw_same_order_complete.json new file mode 100644 index 0000000000..46496feaf1 --- /dev/null +++ b/core/crates/fiat/testdata/mercuryo/webhook_withdraw_same_order_complete.json @@ -0,0 +1,13 @@ +{ + "data": { + "id": "withdraw_provider_tx_987654321", + "merchant_transaction_id": "0d274c2f-cd7f-4137-a5b4-e63c4c2c020b", + "type": "withdraw", + "status": "completed", + "fiat_amount": "1192.13", + "fiat_currency": "EUR", + "tx": { + "id": "WITHDRAW_TX_HASH_123" + } + } +} diff --git a/core/crates/fiat/testdata/moonpay/assets.json b/core/crates/fiat/testdata/moonpay/assets.json new file mode 100644 index 0000000000..87073af49a --- /dev/null +++ b/core/crates/fiat/testdata/moonpay/assets.json @@ -0,0 +1,129 @@ +[ + { + "id": "352111d3-4745-4ddc-a1d3-1cfeee0f4450", + "createdAt": "2024-12-10T10:46:31.656Z", + "updatedAt": "2025-06-04T14:37:09.471Z", + "type": "crypto", + "name": "1inch (ERC-20)", + "code": "1inch_eth", + "precision": 1, + "decimals": 18, + "icon": "https://static.moonpay.com/widget/currencies/1inch_eth.svg", + "maxAmount": null, + "minAmount": null, + "minBuyAmount": 1.1, + "maxBuyAmount": null, + "isSellSupported": false, + "isUtxoCompatible": false, + "notAllowedUSStates": [], + "notAllowedCountries": [ + "CA", + "US" + ], + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "testnetAddressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "supportsAddressTag": false, + "addressTagRegex": "", + "supportsTestMode": true, + "supportsLiveMode": true, + "isSuspended": false, + "isStableCoin": false, + "minSellAmount": null, + "maxSellAmount": null, + "isSwapBaseSupported": false, + "isSwapQuoteSupported": false, + "isBaseAsset": false, + "isSupportedInUS": false, + "metadata": { + "contractAddress": "0x111111111117dc0aa78b770fa6a738034120c302", + "coinType": "", + "chainId": "1", + "networkCode": "ethereum" + } + }, + { + "id": "3c28b499-2066-4890-8b58-79c52994362f", + "createdAt": "2020-10-20T17:19:55.110Z", + "updatedAt": "2025-06-04T14:37:25.685Z", + "type": "crypto", + "name": "Aave", + "code": "aave", + "precision": 2, + "decimals": 18, + "icon": "https://static.moonpay.com/widget/currencies/aave.svg", + "maxAmount": null, + "minAmount": null, + "minBuyAmount": 0.011, + "maxBuyAmount": null, + "isSellSupported": false, + "isUtxoCompatible": false, + "notAllowedUSStates": [ + "VI" + ], + "notAllowedCountries": [ + "CA", + "US" + ], + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "testnetAddressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "supportsAddressTag": false, + "addressTagRegex": null, + "supportsTestMode": true, + "supportsLiveMode": true, + "isSuspended": false, + "isStableCoin": false, + "minSellAmount": 0.40361, + "maxSellAmount": 180, + "isSwapBaseSupported": true, + "isSwapQuoteSupported": true, + "isBaseAsset": false, + "isSupportedInUS": false, + "metadata": { + "contractAddress": "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9", + "coinType": null, + "chainId": "1", + "networkCode": "ethereum" + } + }, + { + "id": "1440a41c-d03d-4047-a3b3-a6ddac63598e", + "createdAt": "2019-05-17T18:24:45.206Z", + "updatedAt": "2025-09-05T19:00:05.165Z", + "type": "crypto", + "name": "Cardano", + "code": "ada", + "precision": 1, + "decimals": 6, + "icon": "https://static.moonpay.com/widget/currencies/ada.svg", + "maxAmount": 2000, + "minAmount": 20, + "minBuyAmount": 6.1, + "maxBuyAmount": null, + "isSellSupported": true, + "isUtxoCompatible": true, + "notAllowedUSStates": [ + "VI" + ], + "notAllowedCountries": [], + "addressRegex": "^(([0-9A-Za-z]{57,59})|([0-9A-Za-z]{100,104}))$", + "testnetAddressRegex": "^(([0-9A-Za-z]{57,59})|([0-9A-Za-z]{100,104}))$", + "supportsAddressTag": false, + "addressTagRegex": null, + "supportsTestMode": false, + "supportsLiveMode": true, + "isSuspended": false, + "isStableCoin": false, + "minSellAmount": 24.3607, + "maxSellAmount": 12000, + "isSwapBaseSupported": false, + "isSwapQuoteSupported": true, + "isBaseAsset": true, + "isSupportedInUS": true, + "metadata": { + "contractAddress": null, + "coinType": "1815", + "chainId": null, + "networkCode": "cardano" + } + } +] \ No newline at end of file diff --git a/core/crates/fiat/testdata/moonpay/sell_transaction_complete.json b/core/crates/fiat/testdata/moonpay/sell_transaction_complete.json new file mode 100644 index 0000000000..b5abfd2ff8 --- /dev/null +++ b/core/crates/fiat/testdata/moonpay/sell_transaction_complete.json @@ -0,0 +1,34 @@ +{ + "id": "bcd0315e-4264-48bb-8c10-1a5207297341", + "externalTransactionId": null, + "status": "completed", + "baseCurrencyAmount": 2.0, + "quoteCurrencyAmount": 3123.07, + "baseCurrency": { + "code": "eth", + "metadata": null, + "isSuspended": false, + "isBaseAsset": true, + "isSellSupported": true, + "notAllowedCountries": [], + "type": "crypto", + "minBuyAmount": 0.0031, + "maxBuyAmount": 30000.0, + "minSellAmount": 0.01189, + "maxSellAmount": 8.5 + }, + "quoteCurrency": { + "code": "usd", + "metadata": null, + "isSuspended": false, + "isBaseAsset": false, + "isSellSupported": true, + "notAllowedCountries": [], + "type": "fiat", + "minBuyAmount": 20.0, + "maxBuyAmount": 30000.0, + "minSellAmount": null, + "maxSellAmount": null + }, + "cryptoTransactionId": "0xabc123456789" +} diff --git a/core/crates/fiat/testdata/moonpay/transaction_sell_failed.json b/core/crates/fiat/testdata/moonpay/transaction_sell_failed.json new file mode 100644 index 0000000000..8b09186112 --- /dev/null +++ b/core/crates/fiat/testdata/moonpay/transaction_sell_failed.json @@ -0,0 +1,36 @@ +{ + "id": "bcd0315e-4264-48bb-8c10-1a5207297341", + "externalTransactionId": null, + "status": "failed", + "baseCurrencyAmount": 16.468, + "quoteCurrencyAmount": 8419.77, + "baseCurrency": { + "code": "bch", + "metadata": null, + "isSuspended": false, + "isBaseAsset": true, + "isSellSupported": true, + "notAllowedCountries": [], + "type": "crypto", + "minBuyAmount": 0.01, + "maxBuyAmount": null, + "minSellAmount": 0.03719, + "maxSellAmount": 100.0 + }, + "quoteCurrency": { + "code": "usd", + "metadata": null, + "isSuspended": false, + "isBaseAsset": false, + "isSellSupported": true, + "notAllowedCountries": [], + "type": "fiat", + "minBuyAmount": 20.0, + "maxBuyAmount": 30000.0, + "minSellAmount": null, + "maxSellAmount": null + }, + "cryptoTransactionId": null, + "feeAmount": 400.94, + "extraFeeAmount": 89.1 +} diff --git a/core/crates/fiat/testdata/moonpay/webhook_buy_complete.json b/core/crates/fiat/testdata/moonpay/webhook_buy_complete.json new file mode 100644 index 0000000000..54ec30e0b6 --- /dev/null +++ b/core/crates/fiat/testdata/moonpay/webhook_buy_complete.json @@ -0,0 +1,27 @@ +{ + "data": { + "id": "1b6cdb1e-9299-45b1-9670-54db1ea5a21f", + "externalTransactionId": null, + "status": "failed", + "baseCurrencyAmount": 15.39, + "quoteCurrencyAmount": null, + "baseCurrency": { + "code": "usd", + "metadata": null, + "isSuspended": false, + "isBaseAsset": false, + "isSellSupported": true, + "notAllowedCountries": [], + "type": "fiat", + "minBuyAmount": 20.0, + "maxBuyAmount": 30000.0, + "minSellAmount": null, + "maxSellAmount": null + }, + "quoteCurrency": null, + "cryptoTransactionId": null, + "networkFeeAmount": 0.47, + "extraFeeAmount": 0.15, + "feeAmount": 3.99 + } +} diff --git a/core/crates/fiat/testdata/moonpay/webhook_sell_complete_.json b/core/crates/fiat/testdata/moonpay/webhook_sell_complete_.json new file mode 100644 index 0000000000..f29646b8c6 --- /dev/null +++ b/core/crates/fiat/testdata/moonpay/webhook_sell_complete_.json @@ -0,0 +1,36 @@ +{ + "data": { + "id": "557d8fc1-0657-4505-8702-6bd9e1cd6241", + "externalTransactionId": null, + "status": "waitingForDeposit", + "baseCurrencyAmount": 2.0, + "quoteCurrencyAmount": 3123.07, + "baseCurrency": { + "code": "eth", + "metadata": null, + "isSuspended": false, + "isBaseAsset": true, + "isSellSupported": true, + "notAllowedCountries": [], + "type": "crypto", + "minBuyAmount": 0.0031, + "maxBuyAmount": 30000.0, + "minSellAmount": 0.01189, + "maxSellAmount": 8.5 + }, + "quoteCurrency": { + "code": "usd", + "metadata": null, + "isSuspended": false, + "isBaseAsset": false, + "isSellSupported": true, + "notAllowedCountries": [], + "type": "fiat", + "minBuyAmount": 20.0, + "maxBuyAmount": 30000.0, + "minSellAmount": null, + "maxSellAmount": null + }, + "cryptoTransactionId": null + } +} diff --git a/core/crates/fiat/testdata/paybis/assets.json b/core/crates/fiat/testdata/paybis/assets.json new file mode 100644 index 0000000000..0e3b799cdf --- /dev/null +++ b/core/crates/fiat/testdata/paybis/assets.json @@ -0,0 +1,71588 @@ +{ + "data": [ + { + "name": "gem-wallet-apple-pay-credit-card", + "displayName": "Apple Pay", + "pairs": [ + { + "from": "AED", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "AUD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "AZN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "BDT", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "BRL", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CAD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CHF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CLP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "COP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CRC", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CZK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DKK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DOP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DZD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "EGP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "EUR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "GBP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "GEL", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HKD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HUF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ILS", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "INR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "JPY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "KRW", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "KWD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MAD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MXN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MYR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NGN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NOK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PEN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PLN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "RON", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SEK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SGD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "THB", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "TND", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "TRY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "UAH", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "XAF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ZAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet-credit-card", + "displayName": "Credit\/Debit Card", + "pairs": [ + { + "from": "AED", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "AUD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "AZN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "BDT", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "BRL", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CAD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CHF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CLP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "COP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CRC", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CZK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DKK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DOP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DZD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "EGP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "EUR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "GBP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "GEL", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HKD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HUF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ILS", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "INR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "JPY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "KRW", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "KWD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MAD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MXN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MYR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NGN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NOK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PEN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PLN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "RON", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SEK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SGD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "THB", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "TND", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "TRY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "UAH", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "XAF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ZAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet-google-pay-credit-card", + "displayName": "Google Pay", + "pairs": [ + { + "from": "AED", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "AUD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "AZN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "BDT", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "BRL", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CAD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CHF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CLP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "COP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CRC", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CZK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DKK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DOP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DZD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "EGP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "EUR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "GBP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "GEL", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HKD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HUF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ILS", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "INR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "JPY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "KRW", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "KWD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MAD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MXN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MYR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NGN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NOK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PEN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PLN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "RON", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SEK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SGD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "THB", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "TND", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "TRY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "UAH", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "XAF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ZAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet-manual-bank-transfer", + "displayName": "SEPA Bank Transfer", + "pairs": [ + { + "from": "EUR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet-trustly", + "displayName": "Online Banking", + "pairs": [ + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_revolutpay", + "displayName": "Revolut Pay", + "pairs": [ + { + "from": "AED", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "AUD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "BGN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CAD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CHF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CZK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DKK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "EUR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "GBP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HKD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HUF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ILS", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "JPY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "MXN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NOK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NZD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PLN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "RON", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SEK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SGD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "THB", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "TRY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ZAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_directa24_pix", + "displayName": "PIX", + "pairs": [ + { + "from": "BRL", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_flutterwave_kenya", + "displayName": "M-Pesa", + "pairs": [ + { + "from": "KES", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_directa24_spei", + "displayName": "SPEI", + "pairs": [ + { + "from": "MXN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_apm_bridgerpay_skrill", + "displayName": "Skrill", + "pairs": [ + { + "from": "EUR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_grabpay", + "displayName": "GrabPay", + "pairs": [ + { + "from": "MYR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_neteller", + "displayName": "Neteller", + "pairs": [ + { + "from": "EUR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_boost", + "displayName": "Boost", + "pairs": [ + { + "from": "MYR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_qrph_gcash", + "displayName": "GCash", + "pairs": [ + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_shopee", + "displayName": "Shopee", + "pairs": [ + { + "from": "MYR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_touch_and_go", + "displayName": "Touch\u0027n Go", + "pairs": [ + { + "from": "MYR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_qrph_maya", + "displayName": "Maya", + "pairs": [ + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_qrph", + "displayName": "QRPh", + "pairs": [ + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_qrph_pesonet", + "displayName": "PESONet", + "pairs": [ + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_qrph_grabpay", + "displayName": "GrabPay", + "pairs": [ + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet_bridgerpay_ecommpay_qrph_instapay", + "displayName": "InstaPay", + "pairs": [ + { + "from": "PHP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + }, + { + "name": "gem-wallet-swift-bank-transfer", + "displayName": "SWIFT Bank Transfer", + "pairs": [ + { + "from": "CAD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CHF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "CZK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "DKK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "GBP", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HKD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "HUF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "JPY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NOK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "NZD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "PLN", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SEK", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "SGD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "TRY", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + }, + { + "from": "ZAR", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC" + }, + { + "currency": "ETH", + "currencyCode": "ETH" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20" + }, + { + "currency": "USDT", + "currencyCode": "USDT" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC" + }, + { + "currency": "USDC", + "currencyCode": "USDC" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL" + }, + { + "currency": "XRP", + "currencyCode": "XRP" + }, + { + "currency": "TON", + "currencyCode": "TON" + }, + { + "currency": "ADA", + "currencyCode": "ADA" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE" + }, + { + "currency": "POL", + "currencyCode": "POL" + }, + { + "currency": "SOL", + "currencyCode": "SOL" + }, + { + "currency": "DOT", + "currencyCode": "DOT" + }, + { + "currency": "LTC", + "currencyCode": "LTC" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC" + }, + { + "currency": "TRX", + "currencyCode": "TRX" + }, + { + "currency": "DAI", + "currencyCode": "DAI" + }, + { + "currency": "LINK", + "currencyCode": "LINK" + }, + { + "currency": "UNI", + "currencyCode": "UNI" + }, + { + "currency": "ETC", + "currencyCode": "ETC" + }, + { + "currency": "XLM", + "currencyCode": "XLM" + }, + { + "currency": "BCH", + "currencyCode": "BCH" + }, + { + "currency": "LDO", + "currencyCode": "LDO" + }, + { + "currency": "APE", + "currencyCode": "APE" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE" + }, + { + "currency": "ARB", + "currencyCode": "ARB" + }, + { + "currency": "QNT", + "currencyCode": "QNT" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ" + }, + { + "currency": "IMX", + "currencyCode": "IMX" + }, + { + "currency": "OP", + "currencyCode": "OP" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL" + }, + { + "currency": "AXS", + "currencyCode": "AXS" + }, + { + "currency": "SAND", + "currencyCode": "SAND" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ" + }, + { + "currency": "RPL", + "currencyCode": "RPL" + }, + { + "currency": "CRV", + "currencyCode": "CRV" + }, + { + "currency": "MKR", + "currencyCode": "MKR" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE" + }, + { + "currency": "FXS", + "currencyCode": "FXS" + }, + { + "currency": "XEC", + "currencyCode": "XEC" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN" + }, + { + "currency": "TWT", + "currencyCode": "TWT" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL" + }, + { + "currency": "WOO", + "currencyCode": "WOO" + }, + { + "currency": "CVX", + "currencyCode": "CVX" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO" + }, + { + "currency": "BAT", + "currencyCode": "BAT" + }, + { + "currency": "MASK", + "currencyCode": "MASK" + }, + { + "currency": "ENS", + "currencyCode": "ENS" + }, + { + "currency": "HOT", + "currencyCode": "HOT" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO" + }, + { + "currency": "COMP", + "currencyCode": "COMP" + }, + { + "currency": "YFI", + "currencyCode": "YFI" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY" + }, + { + "currency": "GNO", + "currencyCode": "GNO" + }, + { + "currency": "T", + "currencyCode": "T" + }, + { + "currency": "FET", + "currencyCode": "FET" + }, + { + "currency": "SSV", + "currencyCode": "SSV" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI" + }, + { + "currency": "GLM", + "currencyCode": "GLM" + }, + { + "currency": "ONT", + "currencyCode": "ONT" + }, + { + "currency": "ACH", + "currencyCode": "ACH" + }, + { + "currency": "BICO", + "currencyCode": "BICO" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX" + }, + { + "currency": "SKL", + "currencyCode": "SKL" + }, + { + "currency": "CELR", + "currencyCode": "CELR" + }, + { + "currency": "LPT", + "currencyCode": "LPT" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR" + }, + { + "currency": "AMP", + "currencyCode": "AMP" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ" + }, + { + "currency": "ILV", + "currencyCode": "ILV" + }, + { + "currency": "STG", + "currencyCode": "STG" + }, + { + "currency": "KNC", + "currencyCode": "KNC" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX" + }, + { + "currency": "SYN", + "currencyCode": "SYN" + }, + { + "currency": "NMR", + "currencyCode": "NMR" + }, + { + "currency": "CELO", + "currencyCode": "CELO" + }, + { + "currency": "GTC", + "currencyCode": "GTC" + }, + { + "currency": "PERP", + "currencyCode": "PERP" + } + ] + } + ] + } + ], + "meta": { + "currencies": [ + { + "name": "Aave", + "code": "AAVE", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/aave.svg", + "blockchainName": "ethereum" + }, + { + "name": "Alchemy Pay", + "code": "ACH", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ach.svg", + "blockchainName": "ethereum" + }, + { + "name": "Cardano", + "code": "ADA", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ada.svg", + "blockchainName": "cardano" + }, + { + "name": "United Arab Emirates dirham", + "code": "AED", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Amp", + "code": "AMP", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/amp.svg", + "blockchainName": "ethereum" + }, + { + "name": "Ankr", + "code": "ANKR", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ankr.svg", + "blockchainName": "ethereum" + }, + { + "name": "ApeCoin", + "code": "APE", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ape.svg", + "blockchainName": "ethereum" + }, + { + "name": "Arbitrum", + "code": "ARB", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/arb.svg", + "blockchainName": "arbitrum" + }, + { + "name": "Astar", + "code": "ASTR-PARACHAIN", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/astr.svg", + "blockchainName": "astar-parachain" + }, + { + "name": "Australian dollar", + "code": "AUD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Audius", + "code": "AUDIO", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/audio.svg", + "blockchainName": "ethereum" + }, + { + "name": "Avalanche", + "code": "AVAXC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/avax.svg", + "blockchainName": "avalanche-c-chain" + }, + { + "name": "Axie Infinity", + "code": "AXS", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/axs.svg", + "blockchainName": "ethereum" + }, + { + "name": "Azerbaijan Manat", + "code": "AZN", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Basic Attention Token", + "code": "BAT", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/bat.svg", + "blockchainName": "ethereum" + }, + { + "name": "Bitcoin Cash", + "code": "BCH", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/bch.svg", + "blockchainName": "bitcoin-cash" + }, + { + "name": "Bangladeshi Taka", + "code": "BDT", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Bulgarian Lev", + "code": "BGN", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Biconomy", + "code": "BICO", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/bico.svg", + "blockchainName": "ethereum" + }, + { + "name": "Binance Coin (BEP20)", + "code": "BNBSC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/bnb.svg", + "blockchainName": "binance-smart-chain" + }, + { + "name": "Bonk (SOL)", + "code": "BONK-SOL", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/bonk.svg", + "blockchainName": "solana" + }, + { + "name": "Brazilian real", + "code": "BRL", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Bitcoin", + "code": "BTC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/btc.svg", + "blockchainName": "bitcoin" + }, + { + "name": "Canadian dollar", + "code": "CAD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "PancakeSwap", + "code": "CAKE", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/cake.svg", + "blockchainName": "binance-smart-chain" + }, + { + "name": "Celo", + "code": "CELO", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/celo.svg", + "blockchainName": "celo" + }, + { + "name": "Celer Network", + "code": "CELR", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/celr.svg", + "blockchainName": "ethereum" + }, + { + "name": "Swiss Franc", + "code": "CHF", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Chiliz", + "code": "CHZ", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/chz.svg", + "blockchainName": "ethereum" + }, + { + "name": "Chilean peso", + "code": "CLP", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Compound", + "code": "COMP", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/comp.svg", + "blockchainName": "ethereum" + }, + { + "name": "Colombian peso", + "code": "COP", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Costa Rican Colon", + "code": "CRC", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Curve DAO Token", + "code": "CRV", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/crv.svg", + "blockchainName": "ethereum" + }, + { + "name": "Convex Finance", + "code": "CVX", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/cvx.svg", + "blockchainName": "ethereum" + }, + { + "name": "Czech Koruna", + "code": "CZK", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Dai", + "code": "DAI", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/dai.svg", + "blockchainName": "ethereum" + }, + { + "name": "Danish krone", + "code": "DKK", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Dogecoin", + "code": "DOGE", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/doge.svg", + "blockchainName": "dogecoin" + }, + { + "name": "Dominican Republic Peso", + "code": "DOP", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Polkadot", + "code": "DOT", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/dot.svg", + "blockchainName": "polkadot" + }, + { + "name": "dYdX", + "code": "DYDX", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/dydx.svg", + "blockchainName": "ethereum" + }, + { + "name": "Algerian Dinar", + "code": "DZD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Egyptian Pound", + "code": "EGP", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Ethereum Name Service", + "code": "ENS", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ens.svg", + "blockchainName": "ethereum" + }, + { + "name": "Ethereum Classic", + "code": "ETC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/etc.svg", + "blockchainName": "ethereum-classic" + }, + { + "name": "Ethereum", + "code": "ETH", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/eth.svg", + "blockchainName": "ethereum" + }, + { + "name": "Ethereum (Base)", + "code": "ETH-BASE", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/eth.svg", + "blockchainName": "base" + }, + { + "name": "Euro", + "code": "EUR", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Fetch.ai", + "code": "FET", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/fet.svg", + "blockchainName": "ethereum" + }, + { + "name": "Flux", + "code": "FLUX", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/flux.svg", + "blockchainName": "ethereum" + }, + { + "name": "Frax Share", + "code": "FXS", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/fxs.svg", + "blockchainName": "ethereum" + }, + { + "name": "Pound sterling", + "code": "GBP", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Georgian Lari", + "code": "GEL", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Golem", + "code": "GLM", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/glm.svg", + "blockchainName": "ethereum" + }, + { + "name": "Moonbeam", + "code": "GLMR", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/glmr.svg", + "blockchainName": "moonbeam" + }, + { + "name": "Gnosis", + "code": "GNO", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/gno.svg", + "blockchainName": "ethereum" + }, + { + "name": "Gitcoin", + "code": "GTC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/gtc.svg", + "blockchainName": "ethereum" + }, + { + "name": "Hong Kong dollar", + "code": "HKD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Holo", + "code": "HOT", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/hot.svg", + "blockchainName": "ethereum" + }, + { + "name": "Hungarian Forint", + "code": "HUF", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Israeli Shekel", + "code": "ILS", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Illuvium", + "code": "ILV", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ilv.svg", + "blockchainName": "ethereum" + }, + { + "name": "ImmutableX", + "code": "IMX", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/imx.svg", + "blockchainName": "ethereum" + }, + { + "name": "Indian Rupee", + "code": "INR", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "JasmyCoin", + "code": "JASMY", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/jasmy.svg", + "blockchainName": "ethereum" + }, + { + "name": "Japanese yen", + "code": "JPY", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Kenyan Shilling", + "code": "KES", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Kyber Network Crystal", + "code": "KNC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/knc.svg", + "blockchainName": "ethereum" + }, + { + "name": "Korean Won", + "code": "KRW", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Kuwaiti Dinar", + "code": "KWD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Lido DAO", + "code": "LDO", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ldo.svg", + "blockchainName": "ethereum" + }, + { + "name": "Chainlink", + "code": "LINK", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/link.svg", + "blockchainName": "ethereum" + }, + { + "name": "Livepeer", + "code": "LPT", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/lpt.svg", + "blockchainName": "ethereum" + }, + { + "name": "Litecoin", + "code": "LTC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ltc.svg", + "blockchainName": "litecoin" + }, + { + "name": "Morocco Dirham", + "code": "MAD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Mask Network", + "code": "MASK", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/mask.svg", + "blockchainName": "ethereum" + }, + { + "name": "Maker", + "code": "MKR", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/mkr.svg", + "blockchainName": "ethereum" + }, + { + "name": "Mexican peso", + "code": "MXN", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Malaysian Ringgit", + "code": "MYR", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Nexo", + "code": "NEXO", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/nexo.svg", + "blockchainName": "ethereum" + }, + { + "name": "Nigerian Naira", + "code": "NGN", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Numeraire", + "code": "NMR", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/nmr.svg", + "blockchainName": "ethereum" + }, + { + "name": "Norwegian Krone", + "code": "NOK", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "New Zealand Dollar", + "code": "NZD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "1inch Network", + "code": "ONEINCH", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/1inch.svg", + "blockchainName": "ethereum" + }, + { + "name": "Ontology", + "code": "ONT", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ont.svg", + "blockchainName": "binance-smart-chain" + }, + { + "name": "Optimism", + "code": "OP", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/op.svg", + "blockchainName": "optimism" + }, + { + "name": "Peruvian Sol", + "code": "PEN", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Pepe", + "code": "PEPE", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/pepe.svg", + "blockchainName": "ethereum" + }, + { + "name": "Perpetual Protocol", + "code": "PERP", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/perp.svg", + "blockchainName": "ethereum" + }, + { + "name": "Philippine Peso", + "code": "PHP", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Polish Zloty", + "code": "PLN", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Polygon", + "code": "POL", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/pol.svg", + "blockchainName": "polygon" + }, + { + "name": "Pundi X (New)", + "code": "PUNDIX", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/pundix.svg", + "blockchainName": "ethereum" + }, + { + "name": "Quant", + "code": "QNT", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/qnt.svg", + "blockchainName": "ethereum" + }, + { + "name": "Romanian Leu", + "code": "RON", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Rocket Pool", + "code": "RPL", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/rpl.svg", + "blockchainName": "ethereum" + }, + { + "name": "The Sandbox", + "code": "SAND", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/sand.svg", + "blockchainName": "ethereum" + }, + { + "name": "Saudi Riyal", + "code": "SAR", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Swedish Krona", + "code": "SEK", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Singapore Dollar", + "code": "SGD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Shiba Inu", + "code": "SHIB", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/shib.svg", + "blockchainName": "ethereum" + }, + { + "name": "SKALE", + "code": "SKL", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/skl.svg", + "blockchainName": "ethereum" + }, + { + "name": "Solana", + "code": "SOL", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/sol.svg", + "blockchainName": "solana" + }, + { + "name": "ssv.network", + "code": "SSV", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ssv.svg", + "blockchainName": "ethereum" + }, + { + "name": "Stargate Finance", + "code": "STG", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/stg.svg", + "blockchainName": "ethereum" + }, + { + "name": "Storj", + "code": "STORJ", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/storj.svg", + "blockchainName": "ethereum" + }, + { + "name": "SushiSwap", + "code": "SUSHI", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/sushi.svg", + "blockchainName": "ethereum" + }, + { + "name": "Synapse", + "code": "SYN", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/syn.svg", + "blockchainName": "ethereum" + }, + { + "name": "Threshold", + "code": "T", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/t.svg", + "blockchainName": "ethereum" + }, + { + "name": "Thai Baht", + "code": "THB", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Tunisian Dinar", + "code": "TND", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Toncoin", + "code": "TON", + "hasDestinationTag": true, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/ton.svg", + "blockchainName": "ton" + }, + { + "name": "Tron", + "code": "TRX", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/trx.svg", + "blockchainName": "tron" + }, + { + "name": "Turkish lira", + "code": "TRY", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Trust Wallet Token", + "code": "TWT", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/twt.svg", + "blockchainName": "binance-smart-chain" + }, + { + "name": "Ukrainian Hryvnia", + "code": "UAH", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Uniswap", + "code": "UNI", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/uni.svg", + "blockchainName": "ethereum" + }, + { + "name": "United States dollar", + "code": "USD", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "USD Coin", + "code": "USDC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdc.svg", + "blockchainName": "ethereum" + }, + { + "name": "USD Coin (Base)", + "code": "USDC-BASE", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdc.svg", + "blockchainName": "base" + }, + { + "name": "USD Coin (Polygon)", + "code": "USDC-POLYGON", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdc.svg", + "blockchainName": "polygon" + }, + { + "name": "USD Coin (SOL)", + "code": "USDC-SOL", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdc.svg", + "blockchainName": "solana" + }, + { + "name": "USD Coin (Stellar)", + "code": "USDC-STELLAR", + "hasDestinationTag": true, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdc.svg", + "blockchainName": "stellar" + }, + { + "name": "Tether (ERC20)", + "code": "USDT", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdt.svg", + "blockchainName": "ethereum" + }, + { + "name": "Tether (Polygon)", + "code": "USDT-POLYGON", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdt.svg", + "blockchainName": "polygon" + }, + { + "name": "Tether (SOL)", + "code": "USDT-SOL", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdt.svg", + "blockchainName": "solana" + }, + { + "name": "Tether (TRC20)", + "code": "USDT-TRC20", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/usdt.svg", + "blockchainName": "tron" + }, + { + "name": "WOO Network", + "code": "WOO", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/woo.svg", + "blockchainName": "ethereum" + }, + { + "name": "Central African CFA Franc", + "code": "XAF", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "eCash", + "code": "XEC", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/xec.svg", + "blockchainName": "binance-smart-chain" + }, + { + "name": "Stellar", + "code": "XLM", + "hasDestinationTag": true, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/xlm.svg", + "blockchainName": "stellar" + }, + { + "name": "Ripple", + "code": "XRP", + "hasDestinationTag": true, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/xrp.svg", + "blockchainName": "ripple" + }, + { + "name": "Tezos", + "code": "XTZ", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/xtz.svg", + "blockchainName": "tezos" + }, + { + "name": "Yearn Finance", + "code": "YFI", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/yfi.svg", + "blockchainName": "ethereum" + }, + { + "name": "South African Rand", + "code": "ZAR", + "hasDestinationTag": false, + "image": "" + }, + { + "name": "Zilliqa", + "code": "ZIL", + "hasDestinationTag": false, + "image": "https:\/\/cdn.paybis.com\/shared\/icons\/default\/money-services\/zil.svg", + "blockchainName": "binance-smart-chain" + } + ], + "defaultCurrency": "USD" + } +} \ No newline at end of file diff --git a/core/crates/fiat/testdata/paybis/assets_with_limits.json b/core/crates/fiat/testdata/paybis/assets_with_limits.json new file mode 100644 index 0000000000..69a8d4f8ce --- /dev/null +++ b/core/crates/fiat/testdata/paybis/assets_with_limits.json @@ -0,0 +1,1113 @@ +{ + "data": [ + { + "name": "gem-wallet-credit-card", + "displayName": "Credit/Debit Card", + "isRecurring": false, + "pairs": [ + { + "from": "USD", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "ETH", + "currencyCode": "ETH", + "minAmount": "10", + "maxAmount": "20000" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE", + "minAmount": "10", + "maxAmount": "20000" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "USDT", + "currencyCode": "USDT", + "minAmount": "10", + "maxAmount": "20000" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "USDC", + "currencyCode": "USDC", + "minAmount": "10", + "maxAmount": "20000" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE", + "minAmount": "10", + "maxAmount": "20000" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "XRP", + "currencyCode": "XRP", + "minAmount": "30", + "maxAmount": "20000" + }, + { + "currency": "TON", + "currencyCode": "TON", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "ADA", + "currencyCode": "ADA", + "minAmount": "50", + "maxAmount": "20000" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE", + "minAmount": "5", + "maxAmount": "15000" + }, + { + "currency": "POL", + "currencyCode": "POL", + "minAmount": "5", + "maxAmount": "10000" + }, + { + "currency": "SOL", + "currencyCode": "SOL", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "DOT", + "currencyCode": "DOT", + "minAmount": "50", + "maxAmount": "10000" + }, + { + "currency": "LTC", + "currencyCode": "LTC", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB", + "minAmount": "10", + "maxAmount": "15000" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "TRX", + "currencyCode": "TRX", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "DAI", + "currencyCode": "DAI", + "minAmount": "50", + "maxAmount": "20000" + }, + { + "currency": "LINK", + "currencyCode": "LINK", + "minAmount": "50", + "maxAmount": "10000" + }, + { + "currency": "ETC", + "currencyCode": "ETC", + "minAmount": "10", + "maxAmount": "20000" + }, + { + "currency": "UNI", + "currencyCode": "UNI", + "minAmount": "50", + "maxAmount": "10000" + }, + { + "currency": "XLM", + "currencyCode": "XLM", + "minAmount": "5", + "maxAmount": "10000" + }, + { + "currency": "BCH", + "currencyCode": "BCH", + "minAmount": "5", + "maxAmount": "20000" + }, + { + "currency": "LDO", + "currencyCode": "LDO", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "APE", + "currencyCode": "APE", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "ARB", + "currencyCode": "ARB", + "minAmount": "10", + "maxAmount": "10000" + }, + { + "currency": "QNT", + "currencyCode": "QNT", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE", + "minAmount": "50", + "maxAmount": "15000" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "IMX", + "currencyCode": "IMX", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "OP", + "currencyCode": "OP", + "minAmount": "10", + "maxAmount": "20000" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL", + "minAmount": "5", + "maxAmount": "5000" + }, + { + "currency": "AXS", + "currencyCode": "AXS", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "SAND", + "currencyCode": "SAND", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "RPL", + "currencyCode": "RPL", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "CRV", + "currencyCode": "CRV", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "MKR", + "currencyCode": "MKR", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "FXS", + "currencyCode": "FXS", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "XEC", + "currencyCode": "XEC", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN", + "minAmount": "10", + "maxAmount": "5000" + }, + { + "currency": "TWT", + "currencyCode": "TWT", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "WOO", + "currencyCode": "WOO", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "CVX", + "currencyCode": "CVX", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "BAT", + "currencyCode": "BAT", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "MASK", + "currencyCode": "MASK", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "ENS", + "currencyCode": "ENS", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "HOT", + "currencyCode": "HOT", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "COMP", + "currencyCode": "COMP", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "YFI", + "currencyCode": "YFI", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "GNO", + "currencyCode": "GNO", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "T", + "currencyCode": "T", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "FET", + "currencyCode": "FET", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "SSV", + "currencyCode": "SSV", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI", + "minAmount": "50", + "maxAmount": "5000" + }, + { + "currency": "GLM", + "currencyCode": "GLM", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "ONT", + "currencyCode": "ONT", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "ACH", + "currencyCode": "ACH", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "BICO", + "currencyCode": "BICO", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "SKL", + "currencyCode": "SKL", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "CELR", + "currencyCode": "CELR", + "minAmount": "50", + "maxAmount": "10000" + }, + { + "currency": "LPT", + "currencyCode": "LPT", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR", + "minAmount": "10", + "maxAmount": "5000" + }, + { + "currency": "AMP", + "currencyCode": "AMP", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "ILV", + "currencyCode": "ILV", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "STG", + "currencyCode": "STG", + "minAmount": "50", + "maxAmount": "2500" + }, + { + "currency": "KNC", + "currencyCode": "KNC", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "SYN", + "currencyCode": "SYN", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "NMR", + "currencyCode": "NMR", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "CELO", + "currencyCode": "CELO", + "minAmount": "15", + "maxAmount": "5000" + }, + { + "currency": "GTC", + "currencyCode": "GTC", + "minAmount": "50", + "maxAmount": "1000" + }, + { + "currency": "PERP", + "currencyCode": "PERP", + "minAmount": "50", + "maxAmount": "2500" + } + ] + }, + { + "from": "XAF", + "to": [ + { + "currency": "BTC", + "currencyCode": "BTC", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "ETH", + "currencyCode": "ETH", + "minAmount": "6000", + "maxAmount": "10000000" + }, + { + "currency": "ETH", + "currencyCode": "ETH-BASE", + "minAmount": "6000", + "maxAmount": "10000000" + }, + { + "currency": "USDT", + "currencyCode": "USDT-TRC20", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "USDT", + "currencyCode": "USDT", + "minAmount": "6000", + "maxAmount": "10000000" + }, + { + "currency": "USDT", + "currencyCode": "USDT-POLYGON", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "USDT", + "currencyCode": "USDT-SOL", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "BNB", + "currencyCode": "BNBSC", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "USDC", + "currencyCode": "USDC", + "minAmount": "6000", + "maxAmount": "10000000" + }, + { + "currency": "USDC", + "currencyCode": "USDC-POLYGON", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "USDC", + "currencyCode": "USDC-BASE", + "minAmount": "6000", + "maxAmount": "10000000" + }, + { + "currency": "USDC", + "currencyCode": "USDC-STELLAR", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "USDC", + "currencyCode": "USDC-SOL", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "XRP", + "currencyCode": "XRP", + "minAmount": "20000", + "maxAmount": "10000000" + }, + { + "currency": "TON", + "currencyCode": "TON", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "ADA", + "currencyCode": "ADA", + "minAmount": "30000", + "maxAmount": "10000000" + }, + { + "currency": "DOGE", + "currencyCode": "DOGE", + "minAmount": "3000", + "maxAmount": "8000000" + }, + { + "currency": "POL", + "currencyCode": "POL", + "minAmount": "3000", + "maxAmount": "5500000" + }, + { + "currency": "SOL", + "currencyCode": "SOL", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "DOT", + "currencyCode": "DOT", + "minAmount": "30000", + "maxAmount": "5500000" + }, + { + "currency": "LTC", + "currencyCode": "LTC", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "SHIB", + "currencyCode": "SHIB", + "minAmount": "6000", + "maxAmount": "8000000" + }, + { + "currency": "AVAX", + "currencyCode": "AVAXC", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "TRX", + "currencyCode": "TRX", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "DAI", + "currencyCode": "DAI", + "minAmount": "30000", + "maxAmount": "10000000" + }, + { + "currency": "LINK", + "currencyCode": "LINK", + "minAmount": "30000", + "maxAmount": "5500000" + }, + { + "currency": "ETC", + "currencyCode": "ETC", + "minAmount": "6000", + "maxAmount": "10000000" + }, + { + "currency": "UNI", + "currencyCode": "UNI", + "minAmount": "30000", + "maxAmount": "5500000" + }, + { + "currency": "XLM", + "currencyCode": "XLM", + "minAmount": "3000", + "maxAmount": "5500000" + }, + { + "currency": "BCH", + "currencyCode": "BCH", + "minAmount": "3000", + "maxAmount": "10000000" + }, + { + "currency": "LDO", + "currencyCode": "LDO", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "APE", + "currencyCode": "APE", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "PEPE", + "currencyCode": "PEPE", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "ARB", + "currencyCode": "ARB", + "minAmount": "6000", + "maxAmount": "5500000" + }, + { + "currency": "QNT", + "currencyCode": "QNT", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "AAVE", + "currencyCode": "AAVE", + "minAmount": "30000", + "maxAmount": "8000000" + }, + { + "currency": "XTZ", + "currencyCode": "XTZ", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "IMX", + "currencyCode": "IMX", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "OP", + "currencyCode": "OP", + "minAmount": "6000", + "maxAmount": "10000000" + }, + { + "currency": "BONK", + "currencyCode": "BONK-SOL", + "minAmount": "3000", + "maxAmount": "2500000" + }, + { + "currency": "AXS", + "currencyCode": "AXS", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "SAND", + "currencyCode": "SAND", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "CHZ", + "currencyCode": "CHZ", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "RPL", + "currencyCode": "RPL", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "CRV", + "currencyCode": "CRV", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "MKR", + "currencyCode": "MKR", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "CAKE", + "currencyCode": "CAKE", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "FXS", + "currencyCode": "FXS", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "XEC", + "currencyCode": "XEC", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "ASTR", + "currencyCode": "ASTR-PARACHAIN", + "minAmount": "6000", + "maxAmount": "2500000" + }, + { + "currency": "TWT", + "currencyCode": "TWT", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "ZIL", + "currencyCode": "ZIL", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "WOO", + "currencyCode": "WOO", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "CVX", + "currencyCode": "CVX", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "1INCH", + "currencyCode": "ONEINCH", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "DYDX", + "currencyCode": "DYDX", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "NEXO", + "currencyCode": "NEXO", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "BAT", + "currencyCode": "BAT", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "MASK", + "currencyCode": "MASK", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "ENS", + "currencyCode": "ENS", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "HOT", + "currencyCode": "HOT", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "ANKR", + "currencyCode": "ANKR", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "AUDIO", + "currencyCode": "AUDIO", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "COMP", + "currencyCode": "COMP", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "YFI", + "currencyCode": "YFI", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "JASMY", + "currencyCode": "JASMY", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "GNO", + "currencyCode": "GNO", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "T", + "currencyCode": "T", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "FET", + "currencyCode": "FET", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "SSV", + "currencyCode": "SSV", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "SUSHI", + "currencyCode": "SUSHI", + "minAmount": "30000", + "maxAmount": "2500000" + }, + { + "currency": "GLM", + "currencyCode": "GLM", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "ONT", + "currencyCode": "ONT", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "ACH", + "currencyCode": "ACH", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "BICO", + "currencyCode": "BICO", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "FLUX", + "currencyCode": "FLUX", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "SKL", + "currencyCode": "SKL", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "CELR", + "currencyCode": "CELR", + "minAmount": "30000", + "maxAmount": "5500000" + }, + { + "currency": "LPT", + "currencyCode": "LPT", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "GLMR", + "currencyCode": "GLMR", + "minAmount": "6000", + "maxAmount": "2500000" + }, + { + "currency": "AMP", + "currencyCode": "AMP", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "STORJ", + "currencyCode": "STORJ", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "ILV", + "currencyCode": "ILV", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "STG", + "currencyCode": "STG", + "minAmount": "30000", + "maxAmount": "1000000" + }, + { + "currency": "KNC", + "currencyCode": "KNC", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "PUNDIX", + "currencyCode": "PUNDIX", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "SYN", + "currencyCode": "SYN", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "NMR", + "currencyCode": "NMR", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "CELO", + "currencyCode": "CELO", + "minAmount": "8500", + "maxAmount": "2500000" + }, + { + "currency": "GTC", + "currencyCode": "GTC", + "minAmount": "30000", + "maxAmount": "550000" + }, + { + "currency": "PERP", + "currencyCode": "PERP", + "minAmount": "30000", + "maxAmount": "1000000" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/core/crates/fiat/testdata/paybis/quote_bitcoin.json b/core/crates/fiat/testdata/paybis/quote_bitcoin.json new file mode 100644 index 0000000000..34cd586c8c --- /dev/null +++ b/core/crates/fiat/testdata/paybis/quote_bitcoin.json @@ -0,0 +1,298 @@ +{ + "id": "268ddd18-86a5-40c9-bedb-26e38bc2d7b7", + "currencyCodeTo": "BTC-TESTNET", + "currencyCodeFrom": "USD", + "requestedAmount": { + "amount": "100.00", + "currencyCode": "USD" + }, + "requestedAmountType": "from", + "paymentMethods": [ + { + "id": "gem-wallet-credit-card", + "name": "Credit\/Debit Card", + "amountTo": { + "amount": "0.00078738", + "currencyCode": "BTC" + }, + "amountFrom": { + "amount": "100.00", + "currencyCode": "USD" + }, + "amountToEquivalent": { + "amount": "92.01", + "currencyCode": "USD" + }, + "fees": { + "networkFee": { + "amount": "3.00", + "currencyCode": "USD" + }, + "serviceFee": { + "amount": "4.99", + "currencyCode": "USD" + }, + "totalFee": { + "amount": "7.99", + "currencyCode": "USD" + } + }, + "feesInCrypto": { + "networkFee": { + "amount": "0.00002567", + "currencyCode": "BTC" + }, + "serviceFee": { + "amount": "0.0000427", + "currencyCode": "BTC" + }, + "totalFee": { + "amount": "0.00006837", + "currencyCode": "BTC" + } + }, + "expiration": "2025-07-15T21:23:49+00:00", + "expiresAt": "2025-07-15T21:23:49+00:00" + }, + { + "id": "gem-wallet-trustly", + "name": "Online Banking", + "amountTo": { + "amount": "0.00079585", + "currencyCode": "BTC" + }, + "amountFrom": { + "amount": "100.00", + "currencyCode": "USD" + }, + "amountToEquivalent": { + "amount": "93.00", + "currencyCode": "USD" + }, + "fees": { + "networkFee": { + "amount": "3.00", + "currencyCode": "USD" + }, + "serviceFee": { + "amount": "4.00", + "currencyCode": "USD" + }, + "totalFee": { + "amount": "7.00", + "currencyCode": "USD" + } + }, + "feesInCrypto": { + "networkFee": { + "amount": "0.00002567", + "currencyCode": "BTC" + }, + "serviceFee": { + "amount": "0.00003423", + "currencyCode": "BTC" + }, + "totalFee": { + "amount": "0.0000599", + "currencyCode": "BTC" + } + }, + "expiration": "2025-07-15T21:23:49+00:00", + "expiresAt": "2025-07-15T21:23:49+00:00" + }, + { + "id": "gem-wallet_bridgerpay_revolutpay", + "name": "Revolut Pay", + "amountTo": { + "amount": "0.00080022", + "currencyCode": "BTC" + }, + "amountFrom": { + "amount": "100.00", + "currencyCode": "USD" + }, + "amountToEquivalent": { + "amount": "93.51", + "currencyCode": "USD" + }, + "fees": { + "networkFee": { + "amount": "3.00", + "currencyCode": "USD" + }, + "serviceFee": { + "amount": "3.49", + "currencyCode": "USD" + }, + "totalFee": { + "amount": "6.49", + "currencyCode": "USD" + } + }, + "feesInCrypto": { + "networkFee": { + "amount": "0.00002567", + "currencyCode": "BTC" + }, + "serviceFee": { + "amount": "0.00002986", + "currencyCode": "BTC" + }, + "totalFee": { + "amount": "0.00005553", + "currencyCode": "BTC" + } + }, + "expiration": "2025-07-15T21:23:49+00:00", + "expiresAt": "2025-07-15T21:23:49+00:00" + }, + { + "id": "gem-wallet_bridgerpay_astropay", + "name": "AstroPay", + "amountTo": { + "amount": "0.00077454", + "currencyCode": "BTC" + }, + "amountFrom": { + "amount": "100.00", + "currencyCode": "USD" + }, + "amountToEquivalent": { + "amount": "90.51", + "currencyCode": "USD" + }, + "fees": { + "networkFee": { + "amount": "3.00", + "currencyCode": "USD" + }, + "serviceFee": { + "amount": "6.49", + "currencyCode": "USD" + }, + "totalFee": { + "amount": "9.49", + "currencyCode": "USD" + } + }, + "feesInCrypto": { + "networkFee": { + "amount": "0.00002567", + "currencyCode": "BTC" + }, + "serviceFee": { + "amount": "0.00005553", + "currencyCode": "BTC" + }, + "totalFee": { + "amount": "0.00008121", + "currencyCode": "BTC" + } + }, + "expiration": "2025-07-15T21:23:49+00:00", + "expiresAt": "2025-07-15T21:23:49+00:00" + }, + { + "id": "gem-wallet_apm_bridgerpay_skrill", + "name": "Skrill", + "amountTo": { + "amount": "0.00076256", + "currencyCode": "BTC" + }, + "amountFrom": { + "amount": "100.00", + "currencyCode": "USD" + }, + "amountToEquivalent": { + "amount": "89.11", + "currencyCode": "USD" + }, + "fees": { + "networkFee": { + "amount": "3.00", + "currencyCode": "USD" + }, + "serviceFee": { + "amount": "7.89", + "currencyCode": "USD" + }, + "totalFee": { + "amount": "10.89", + "currencyCode": "USD" + } + }, + "feesInCrypto": { + "networkFee": { + "amount": "0.00002567", + "currencyCode": "BTC" + }, + "serviceFee": { + "amount": "0.00006751", + "currencyCode": "BTC" + }, + "totalFee": { + "amount": "0.00009319", + "currencyCode": "BTC" + } + }, + "expiration": "2025-07-15T21:23:49+00:00", + "expiresAt": "2025-07-15T21:23:49+00:00" + }, + { + "id": "gem-wallet_bridgerpay_neteller", + "name": "Neteller", + "amountTo": { + "amount": "0.00076188", + "currencyCode": "BTC" + }, + "amountFrom": { + "amount": "100.00", + "currencyCode": "USD" + }, + "amountToEquivalent": { + "amount": "89.03", + "currencyCode": "USD" + }, + "fees": { + "networkFee": { + "amount": "3.00", + "currencyCode": "USD" + }, + "serviceFee": { + "amount": "7.97", + "currencyCode": "USD" + }, + "totalFee": { + "amount": "10.97", + "currencyCode": "USD" + } + }, + "feesInCrypto": { + "networkFee": { + "amount": "0.00002567", + "currencyCode": "BTC" + }, + "serviceFee": { + "amount": "0.0000682", + "currencyCode": "BTC" + }, + "totalFee": { + "amount": "0.00009387", + "currencyCode": "BTC" + } + }, + "expiration": "2025-07-15T21:23:49+00:00", + "expiresAt": "2025-07-15T21:23:49+00:00" + } + ], + "exchangeRate": { + "from": "USD", + "to": "BTC-TESTNET", + "rate": "0.00000855755" + }, + "exchangeRateCryptoToFiat": { + "from": "BTC", + "to": "USD", + "rate": "116855.87287" + } + } \ No newline at end of file diff --git a/core/crates/fiat/testdata/paybis/webhook_transaction_completed.json b/core/crates/fiat/testdata/paybis/webhook_transaction_completed.json new file mode 100644 index 0000000000..b7f0f48219 --- /dev/null +++ b/core/crates/fiat/testdata/paybis/webhook_transaction_completed.json @@ -0,0 +1,25 @@ +{ + "event": "TRANSACTION_STATUS_CHANGED", + "data": { + "partnerTransactionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "quote": { + "quoteId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + }, + "transaction": { + "invoice": "PBXXXXXXXXXXTXX", + "status": "completed", + "flow": "buyCrypto" + }, + "amountFrom": { + "amount": "50.00", + "currency": "USD" + }, + "amountTo": { + "amount": "0.1", + "currency": "BTC" + }, + "payout": { + "transaction_hash": "paybis_test_tx_hash" + } + } +} diff --git a/core/crates/fiat/testdata/paybis/webhook_transaction_no_changes.json b/core/crates/fiat/testdata/paybis/webhook_transaction_no_changes.json new file mode 100644 index 0000000000..c4c28d8f57 --- /dev/null +++ b/core/crates/fiat/testdata/paybis/webhook_transaction_no_changes.json @@ -0,0 +1,4 @@ +{ + "event": "VERIFICATION_STATUS_UPDATED", + "data": {} +} diff --git a/core/crates/fiat/testdata/paybis/webhook_transaction_started.json b/core/crates/fiat/testdata/paybis/webhook_transaction_started.json new file mode 100644 index 0000000000..3a292cbae3 --- /dev/null +++ b/core/crates/fiat/testdata/paybis/webhook_transaction_started.json @@ -0,0 +1,22 @@ +{ + "event": "TRANSACTION_STATUS_CHANGED", + "data": { + "partnerTransactionId": "a4a211ad-3bcf-47d9-b4ae-073e841e3e7a", + "quote": { + "quoteId": "a4a211ad-3bcf-47d9-b4ae-073e841e3e7a" + }, + "transaction": { + "invoice": "PB21095868675TX1", + "status": "started", + "flow": "buyCrypto" + }, + "amountFrom": { + "amount": "50.00", + "currency": "USD" + }, + "amountTo": { + "amount": "0.23230161", + "currency": "SOL" + } + } +} diff --git a/core/crates/fiat/testdata/paybis/webhook_transaction_started_no_payment.json b/core/crates/fiat/testdata/paybis/webhook_transaction_started_no_payment.json new file mode 100644 index 0000000000..08d60a2fa8 --- /dev/null +++ b/core/crates/fiat/testdata/paybis/webhook_transaction_started_no_payment.json @@ -0,0 +1,22 @@ +{ + "event": "TRANSACTION_STATUS_CHANGED", + "data": { + "partnerTransactionId": "59b799d4-dc8c-458d-b9c7-292726ab6255", + "quote": { + "quoteId": "59b799d4-dc8c-458d-b9c7-292726ab6255" + }, + "transaction": { + "invoice": "PB25095868675TX8", + "status": "started", + "flow": "buyCrypto" + }, + "amountFrom": { + "amount": "50.00", + "currency": "USD" + }, + "amountTo": { + "amount": "0.22726373", + "currency": "SOL" + } + } +} diff --git a/core/crates/fiat/testdata/transak/fiat_currencies.json b/core/crates/fiat/testdata/transak/fiat_currencies.json new file mode 100644 index 0000000000..2150299f1b --- /dev/null +++ b/core/crates/fiat/testdata/transak/fiat_currencies.json @@ -0,0 +1,111 @@ +{ + "response": [ + { + "symbol": "USD", + "supportingCountries": [ + "US", + "PR", + "VI", + "GU", + "TC", + "MP" + ], + "logoSymbol": "US", + "name": "US Dollar", + "paymentOptions": [ + { + "name": "Card Payment", + "id": "credit_debit_card", + "isNftAllowed": true, + "isNonCustodial": true, + "processingTime": "1-3 minutes", + "displayText": true, + "icon": "https://assets.transak.com/images/fiat-currency/visa_master_h.png", + "limitCurrency": "USD", + "isActive": true, + "provider": "checkout", + "maxAmount": 3000, + "minAmount": 5, + "defaultAmount": 300, + "isConverted": true, + "visaPayoutCountries": [ + "US" + ], + "mastercardPayoutCountries": [ + "US" + ], + "isPayOutAllowed": true, + "minAmountForPayOut": 10, + "maxAmountForPayOut": 25000, + "defaultAmountForPayOut": 500 + }, + { + "name": "Apple Pay", + "id": "apple_pay", + "isNftAllowed": true, + "isNonCustodial": true, + "processingTime": "1-4 minutes", + "displayText": true, + "icon": "https://assets.transak.com/images/fiat-currency/apple_logo.svg", + "limitCurrency": "USD", + "isActive": true, + "provider": "checkout", + "maxAmount": 3000, + "minAmount": 5, + "defaultAmount": 300, + "isConverted": true, + "isPayOutAllowed": false, + "minAmountForPayOut": 10, + "maxAmountForPayOut": 25000, + "defaultAmountForPayOut": 500 + }, + { + "name": "Google Pay", + "id": "google_pay", + "isNftAllowed": true, + "isNonCustodial": true, + "processingTime": "1-4 minutes", + "displayText": true, + "icon": "https://assets.transak.com/images/fiat-currency/google_pay.svg", + "limitCurrency": "USD", + "isActive": true, + "provider": "checkout", + "maxAmount": 1500, + "minAmount": 30, + "defaultAmount": 300, + "isConverted": true, + "isPayOutAllowed": false, + "minAmountForPayOut": 10, + "maxAmountForPayOut": 25000, + "defaultAmountForPayOut": 500 + }, + { + "name": "", + "id": "pm_wire", + "displayText": true, + "processingTime": "1 to 2 days", + "icon": "https://assets.transak.com/images/fiat-currency/wire_payment_icon.svg", + "limitCurrency": "USD", + "maxAmount": 10000, + "minAmount": 1000, + "isActive": true, + "supportedCountryCode": [ + "US" + ], + "defaultAmount": 3000, + "isConverted": true, + "isPayOutAllowed": false, + "minAmountForPayOut": 10, + "maxAmountForPayOut": 25000, + "defaultAmountForPayOut": 500 + } + ], + "isPopular": true, + "isAllowed": true, + "roundOff": 2, + "isPayOutAllowed": true, + "defaultCountryForNFT": "US", + "icon": "\n \n US\n Created with sketchtool.\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n" + } + ] +} \ No newline at end of file diff --git a/core/crates/fiat/testdata/transak/transaction_buy_error.json b/core/crates/fiat/testdata/transak/transaction_buy_error.json new file mode 100644 index 0000000000..dd5eb3bc17 --- /dev/null +++ b/core/crates/fiat/testdata/transak/transaction_buy_error.json @@ -0,0 +1,10 @@ +{ + "data": { + "id": "df7997b7-a19f-447e-b9fe-2f0eb7cb7b3a", + "quoteId": "e75764cd-1275-476e-b6fa-9af787b40974", + "status": "FAILED", + "fiatCurrency": "USD", + "fiatAmount": 108.0, + "transactionHash": null + } +} diff --git a/core/crates/gem_algorand/Cargo.toml b/core/crates/gem_algorand/Cargo.toml new file mode 100644 index 0000000000..2425523f9f --- /dev/null +++ b/core/crates/gem_algorand/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "gem_algorand" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +rpc = ["dep:chrono", "dep:chain_traits", "dep:gem_client"] +signer = ["dep:hex", "dep:num-traits"] +reqwest = ["gem_client/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +gem_encoding = { path = "../gem_encoding" } +num-bigint = { workspace = true } +num-traits = { workspace = true, optional = true } + +chrono = { workspace = true, optional = true } + +primitives = { path = "../primitives" } +chain_traits = { path = "../chain_traits", optional = true } +gem_client = { path = "../gem_client", optional = true } +gem_hash = { path = "../gem_hash" } +hex = { workspace = true, optional = true } +signer = { path = "../signer" } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } +primitives = { path = "../primitives", features = ["testkit"] } +hex = { workspace = true } diff --git a/core/crates/gem_algorand/src/address.rs b/core/crates/gem_algorand/src/address.rs new file mode 100644 index 0000000000..31845313ac --- /dev/null +++ b/core/crates/gem_algorand/src/address.rs @@ -0,0 +1,75 @@ +use gem_encoding::{decode_base32, encode_base32}; +use gem_hash::sha2::sha512_256; +use primitives::Address; +use signer::Base32Address; +use std::fmt; + +const ADDRESS_DATA_LENGTH: usize = 32; +const ADDRESS_CHECKSUM_LENGTH: usize = 4; + +#[derive(Clone)] +pub struct AlgorandAddress { + pub(crate) base32: Base32Address, +} + +impl Address for AlgorandAddress { + fn try_parse(address: &str) -> Option { + let decoded = decode_base32(address.as_bytes()).ok()?; + if decoded.len() != ADDRESS_DATA_LENGTH + ADDRESS_CHECKSUM_LENGTH { + return None; + } + let base32 = Base32Address::from_slice(&decoded[..ADDRESS_DATA_LENGTH]).ok()?; + (decoded[ADDRESS_DATA_LENGTH..] == Self::checksum(base32.payload())).then_some(Self { base32 }) + } + + fn as_bytes(&self) -> &[u8] { + self.base32.payload() + } + + fn encode(&self) -> String { + let mut raw = Vec::with_capacity(ADDRESS_DATA_LENGTH + ADDRESS_CHECKSUM_LENGTH); + raw.extend_from_slice(self.base32.payload()); + raw.extend_from_slice(&Self::checksum(self.base32.payload())); + encode_base32(&raw) + } +} + +pub fn validate_address(address: &str) -> bool { + AlgorandAddress::is_valid(address) +} + +impl AlgorandAddress { + fn checksum(bytes: &[u8; 32]) -> [u8; ADDRESS_CHECKSUM_LENGTH] { + let digest = sha512_256(bytes); + let mut checksum = [0u8; ADDRESS_CHECKSUM_LENGTH]; + checksum.copy_from_slice(&digest[digest.len() - ADDRESS_CHECKSUM_LENGTH..]); + checksum + } +} + +impl fmt::Display for AlgorandAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_ADDRESS: &str = "QKDS2YGDHDFZFAAGA4HAF3AJIKW5ZN46P66QDR3ELCXKKJUJTPJSXVHNQU"; + + #[test] + fn test_algorand_address() { + let parsed = AlgorandAddress::from_str(VALID_ADDRESS).unwrap(); + + assert!(validate_address(VALID_ADDRESS)); + assert_eq!(parsed.to_string(), VALID_ADDRESS); + assert_eq!(parsed.as_bytes().len(), 32); + + assert!(!validate_address("")); + assert!(!validate_address("invalid")); + // wrong checksum (last char flipped) + assert!(!validate_address("QKDS2YGDHDFZFAAGA4HAF3AJIKW5ZN46P66QDR3ELCXKKJUJTPJSXVHNQX")); + } +} diff --git a/core/crates/gem_algorand/src/constants.rs b/core/crates/gem_algorand/src/constants.rs new file mode 100644 index 0000000000..271354833a --- /dev/null +++ b/core/crates/gem_algorand/src/constants.rs @@ -0,0 +1 @@ +pub const TRANSACTION_TYPE_PAY: &str = "pay"; diff --git a/core/crates/gem_algorand/src/lib.rs b/core/crates/gem_algorand/src/lib.rs new file mode 100644 index 0000000000..4cd2d70ea2 --- /dev/null +++ b/core/crates/gem_algorand/src/lib.rs @@ -0,0 +1,17 @@ +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +pub mod address; +pub mod constants; +pub mod models; +#[cfg(feature = "signer")] +pub mod signer; + +pub use address::{AlgorandAddress, validate_address}; +#[cfg(feature = "rpc")] +pub use rpc::client::AlgorandClient; +#[cfg(feature = "signer")] +pub use signer::*; diff --git a/core/crates/gem_algorand/src/models/account.rs b/core/crates/gem_algorand/src/models/account.rs new file mode 100644 index 0000000000..7d4c0db0e7 --- /dev/null +++ b/core/crates/gem_algorand/src/models/account.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + pub amount: u64, + pub assets: Vec, + #[serde(rename = "min-balance")] + pub min_balance: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountAsset { + pub amount: u64, + #[serde(rename = "asset-id")] + pub asset_id: i32, +} diff --git a/core/crates/gem_algorand/src/models/asset.rs b/core/crates/gem_algorand/src/models/asset.rs new file mode 100644 index 0000000000..e89a0697df --- /dev/null +++ b/core/crates/gem_algorand/src/models/asset.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Asset { + #[serde(rename = "asset-id")] + pub asset_id: i64, + pub amount: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetDetails { + pub index: i64, + pub params: AssetParams, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetParams { + pub decimals: i64, + pub name: String, + #[serde(rename = "unit-name")] + pub unit_name: String, +} diff --git a/core/crates/gem_algorand/src/models/block.rs b/core/crates/gem_algorand/src/models/block.rs new file mode 100644 index 0000000000..aadcc3fe27 --- /dev/null +++ b/core/crates/gem_algorand/src/models/block.rs @@ -0,0 +1,20 @@ +use super::transaction::Transaction; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockHeaders { + #[serde(rename = "current-round")] + pub current_round: u64, + pub blocks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockHeader { + #[serde(rename = "genesis-id")] + pub genesis_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + pub transactions: Vec, +} diff --git a/core/crates/gem_algorand/src/models/indexer.rs b/core/crates/gem_algorand/src/models/indexer.rs new file mode 100644 index 0000000000..bf93731865 --- /dev/null +++ b/core/crates/gem_algorand/src/models/indexer.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +use crate::models::AssetDetails; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetResponse { + pub asset: AssetDetails, +} diff --git a/core/crates/gem_algorand/src/models/mod.rs b/core/crates/gem_algorand/src/models/mod.rs new file mode 100644 index 0000000000..b37fc59074 --- /dev/null +++ b/core/crates/gem_algorand/src/models/mod.rs @@ -0,0 +1,13 @@ +pub mod account; +pub mod asset; +pub mod block; +pub mod indexer; +#[cfg(feature = "signer")] +pub mod signing; +pub mod transaction; + +pub use account::*; +pub use asset::*; +pub use block::*; +pub use indexer::*; +pub use transaction::*; diff --git a/core/crates/gem_algorand/src/models/signing/mod.rs b/core/crates/gem_algorand/src/models/signing/mod.rs new file mode 100644 index 0000000000..5721429979 --- /dev/null +++ b/core/crates/gem_algorand/src/models/signing/mod.rs @@ -0,0 +1,5 @@ +mod operation; +mod transaction; + +pub use operation::Operation; +pub use transaction::AlgorandTransaction; diff --git a/core/crates/gem_algorand/src/models/signing/operation.rs b/core/crates/gem_algorand/src/models/signing/operation.rs new file mode 100644 index 0000000000..47502623d6 --- /dev/null +++ b/core/crates/gem_algorand/src/models/signing/operation.rs @@ -0,0 +1,41 @@ +use crate::address::AlgorandAddress; + +const TX_TYPE_PAYMENT: &str = "pay"; +const TX_TYPE_ASSET_TRANSFER: &str = "axfer"; + +// Canonical msgpack field counts per operation type. +const PAYMENT_FIELDS: u8 = 9; +const PAYMENT_ZERO_AMOUNT_FIELDS: u8 = 8; +const ASSET_TRANSFER_FIELDS: u8 = 10; +const ASSET_OPT_IN_FIELDS: u8 = 9; + +pub enum Operation { + Payment { destination: AlgorandAddress, amount: u64 }, + AssetTransfer { destination: AlgorandAddress, amount: u64, asset_id: u64 }, + AssetOptIn { asset_id: u64 }, +} + +impl Operation { + pub fn tx_type(&self) -> &'static str { + match self { + Self::Payment { .. } => TX_TYPE_PAYMENT, + Self::AssetTransfer { .. } | Self::AssetOptIn { .. } => TX_TYPE_ASSET_TRANSFER, + } + } + + pub fn size(&self) -> u8 { + match self { + Self::Payment { amount: 0, .. } => PAYMENT_ZERO_AMOUNT_FIELDS, + Self::Payment { .. } => PAYMENT_FIELDS, + Self::AssetTransfer { .. } => ASSET_TRANSFER_FIELDS, + Self::AssetOptIn { .. } => ASSET_OPT_IN_FIELDS, + } + } + + pub fn payment_amount(&self) -> Option { + match self { + Self::Payment { amount, .. } if *amount > 0 => Some(*amount), + _ => None, + } + } +} diff --git a/core/crates/gem_algorand/src/models/signing/transaction.rs b/core/crates/gem_algorand/src/models/signing/transaction.rs new file mode 100644 index 0000000000..0b8a47ae2c --- /dev/null +++ b/core/crates/gem_algorand/src/models/signing/transaction.rs @@ -0,0 +1,66 @@ +use super::Operation; +use crate::address::AlgorandAddress; +use gem_encoding::decode_base64; +use num_traits::ToPrimitive; +use primitives::{Address, SignerError, SignerInput}; +use signer::InvalidInput; + +const TRANSACTION_VALIDITY_ROUNDS: u64 = 1000; + +pub struct AlgorandTransaction { + pub sender: AlgorandAddress, + pub fee: u64, + pub first_round: u64, + pub last_round: u64, + pub genesis_id: String, + pub genesis_hash: Vec, + pub note: Vec, + pub operation: Operation, +} + +impl AlgorandTransaction { + pub fn transfer(input: &SignerInput) -> Result { + Self::from_input( + input, + Operation::Payment { + destination: AlgorandAddress::from_str(&input.destination_address).invalid_input("invalid Algorand address")?, + amount: input.value_as_u64()?, + }, + ) + } + + pub fn token_transfer(input: &SignerInput) -> Result { + Self::from_input( + input, + Operation::AssetTransfer { + destination: AlgorandAddress::from_str(&input.destination_address).invalid_input("invalid Algorand address")?, + amount: input.value_as_u64()?, + asset_id: get_asset_id(input)?, + }, + ) + } + + pub fn account_action(input: &SignerInput) -> Result { + Self::from_input(input, Operation::AssetOptIn { asset_id: get_asset_id(input)? }) + } + + fn from_input(input: &SignerInput, operation: Operation) -> Result { + let fee = input.fee.fee.to_u64().invalid_input("invalid transaction fee")?; + let first_round = input.metadata.get_sequence()?; + + Ok(Self { + sender: AlgorandAddress::from_str(&input.sender_address).invalid_input("invalid Algorand address")?, + fee, + first_round, + last_round: first_round + TRANSACTION_VALIDITY_ROUNDS, + genesis_id: input.metadata.get_chain_id()?, + genesis_hash: decode_base64(&input.metadata.get_block_hash()?).invalid_input("invalid Algorand genesis hash")?, + note: input.memo.clone().unwrap_or_default().into_bytes(), + operation, + }) + } +} + +fn get_asset_id(input: &SignerInput) -> Result { + input.input_type.get_asset().id.get_token_id()?.parse::().invalid_input("invalid Algorand asset id") +} diff --git a/core/crates/gem_algorand/src/models/transaction.rs b/core/crates/gem_algorand/src/models/transaction.rs new file mode 100644 index 0000000000..f5362fd140 --- /dev/null +++ b/core/crates/gem_algorand/src/models/transaction.rs @@ -0,0 +1,88 @@ +use core::str; + +use gem_encoding::decode_base64; +use primitives::TransactionState; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionsParams { + #[serde(rename = "last-round")] + pub last_round: u64, + #[serde(rename = "genesis-hash")] + pub genesis_hash: String, + #[serde(rename = "genesis-id")] + pub genesis_id: String, + #[serde(rename = "min-fee")] + pub min_fee: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transaction { + pub id: String, + #[serde(rename = "round-time")] + pub round_time: i64, + pub fee: Option, + pub sender: Option, + pub note: Option, + #[serde(rename = "payment-transaction")] + pub payment_transaction: Option, + #[serde(rename = "tx-type")] + pub transaction_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentTransaction { + pub amount: Option, + pub receiver: Option, +} + +impl Transaction { + pub fn get_memo(&self) -> Option { + self.note + .clone() + .and_then(|note| decode_base64(¬e).ok()) + .and_then(|decoded| str::from_utf8(&decoded).ok().map(|s| s.to_string())) + .map(|s| s.to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transactions { + pub transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionLookup { + pub transaction: Transaction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionBroadcast { + #[serde(rename = "txId")] + pub tx_id: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionStatus { + #[serde(rename = "confirmed-round")] + pub confirmed_round: Option, + #[serde(rename = "pool-error")] + pub pool_error: Option, +} + +impl TransactionStatus { + pub fn state(&self) -> TransactionState { + if self.confirmed_round.unwrap_or(0) > 0 { + TransactionState::Confirmed + } else if self.has_pool_error() { + TransactionState::Failed + } else { + TransactionState::Pending + } + } + + fn has_pool_error(&self) -> bool { + self.pool_error.as_ref().is_some_and(|error| !error.trim().is_empty()) + } +} diff --git a/core/crates/gem_algorand/src/provider/balances.rs b/core/crates/gem_algorand/src/provider/balances.rs new file mode 100644 index 0000000000..ecc4d7bfa7 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/balances.rs @@ -0,0 +1,90 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use num_bigint::BigUint; +use std::error::Error; + +use gem_client::Client; +use primitives::{AssetBalance, AssetId}; + +use super::balances_mapper::{map_balance_coin, map_balance_tokens}; +use crate::rpc::client::AlgorandClient; + +#[async_trait] +impl ChainBalances for AlgorandClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let account = self.get_account(&address).await?; + Ok(map_balance_coin(&account, self.get_chain())) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let account = self.get_account(&address).await?; + Ok(map_balance_tokens(&account, token_ids, self.get_chain())) + } + + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Ok(None) + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + let account = self.get_account(&address).await?; + let asset_balances: Vec = account + .assets + .into_iter() + .map(|asset| AssetBalance::new(AssetId::from(self.get_chain(), Some(asset.asset_id.to_string())), BigUint::from(asset.amount))) + .collect(); + + Ok(asset_balances) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainBalances; + use primitives::Chain; + + #[tokio::test] + async fn test_algorand_get_balance_coin() -> Result<(), Box> { + let client = create_algorand_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + assert_eq!(balance.asset_id.chain, Chain::Algorand); + println!("Balance: {:?}", balance); + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + Ok(()) + } + + #[tokio::test] + async fn test_algorand_get_balance_tokens() -> Result<(), Box> { + let client = create_algorand_test_client(); + let token_ids = vec![ + "31566704".to_string(), // USDC + ]; + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids).await?; + + assert_eq!(balances.len(), 1); + for balance in &balances { + assert_eq!(balance.asset_id.chain, Chain::Algorand); + + println!("Token balance: {:?}", balance); + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + } + Ok(()) + } + + #[tokio::test] + async fn test_algorand_get_balance_assets() -> Result<(), Box> { + let client = create_algorand_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + assert!(assets.is_empty()); + + for asset in &assets { + assert_eq!(asset.asset_id.chain, primitives::Chain::Algorand); + assert!(asset.balance.available > num_bigint::BigUint::from(0u32)); + assert!(asset.asset_id.token_id.is_some()); + } + + Ok(()) + } +} diff --git a/core/crates/gem_algorand/src/provider/balances_mapper.rs b/core/crates/gem_algorand/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..64876f95c3 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/balances_mapper.rs @@ -0,0 +1,58 @@ +use crate::models::{Account, Asset}; +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Balance, Chain}; + +pub fn map_balance_coin(account: &Account, chain: Chain) -> AssetBalance { + let (available, reserved): (u64, u64) = { + let amount = account.amount; + if amount > 0 { + let reserved = account.min_balance; + (std::cmp::max(amount - reserved, 0), reserved) + } else { + (0, 0) + } + }; + + AssetBalance::new_with_active(chain.as_asset_id(), Balance::with_reserved(BigUint::from(available), BigUint::from(reserved)), true) +} + +pub fn map_balance_tokens(account: &Account, token_ids: Vec, chain: Chain) -> Vec { + token_ids + .into_iter() + .map(|token_id| { + let (balance, is_active): (u64, bool) = { + if let Some(asset) = account.assets.iter().find(|asset| asset.asset_id.to_string() == token_id) { + (asset.amount, true) + } else { + (0, false) + } + }; + + AssetBalance::new_with_active(AssetId { chain, token_id: Some(token_id) }, Balance::coin_balance(BigUint::from(balance)), is_active) + }) + .collect() +} + +pub fn map_assets_balance(assets: Vec) -> Vec { + assets + .into_iter() + .map(|asset| AssetBalance::new(AssetId::from_token(Chain::Algorand, &asset.asset_id.to_string()), BigUint::from(asset.amount.max(0) as u64))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::account::Account; + use primitives::Chain; + + #[test] + fn test_map_balance_coin() { + let account: Account = serde_json::from_str(include_str!("../../testdata/account.json")).unwrap(); + let balance = map_balance_coin(&account, Chain::Algorand); + + assert_eq!(balance.balance.available, BigUint::from(71414422_u64)); + assert_eq!(balance.balance.reserved, BigUint::from(200000_u64)); + assert!(balance.is_active); + } +} diff --git a/core/crates/gem_algorand/src/provider/mod.rs b/core/crates/gem_algorand/src/provider/mod.rs new file mode 100644 index 0000000000..92094ec293 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/mod.rs @@ -0,0 +1,19 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod request_classifier; +pub mod state; +pub mod state_mapper; +pub mod token; +pub mod token_mapper; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; + +#[cfg(test)] +mod testkit; diff --git a/core/crates/gem_algorand/src/provider/preload.rs b/core/crates/gem_algorand/src/provider/preload.rs new file mode 100644 index 0000000000..072130761c --- /dev/null +++ b/core/crates/gem_algorand/src/provider/preload.rs @@ -0,0 +1,36 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use num_bigint::BigInt; +use std::error::Error; + +use gem_client::Client; +use primitives::{ + FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput, +}; + +use crate::rpc::client::AlgorandClient; + +#[async_trait] +impl ChainTransactionLoad for AlgorandClient { + async fn get_transaction_preload(&self, _input: TransactionPreloadInput) -> Result> { + Ok(TransactionLoadMetadata::None) + } + + async fn get_transaction_load(&self, _input: TransactionLoadInput) -> Result> { + let params = self.get_transactions_params().await?; + let metadata = TransactionLoadMetadata::Algorand { + sequence: params.last_round, + block_hash: params.genesis_hash, + chain_id: params.genesis_id, + }; + + Ok(TransactionLoadData { + fee: TransactionFee::new_from_fee(BigInt::from(params.min_fee)), + metadata, + }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1)))]) + } +} diff --git a/core/crates/gem_algorand/src/provider/request_classifier.rs b/core/crates/gem_algorand/src/provider/request_classifier.rs new file mode 100644 index 0000000000..2a2b60935b --- /dev/null +++ b/core/crates/gem_algorand/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/v2/transactions") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_algorand/src/provider/state.rs b/core/crates/gem_algorand/src/provider/state.rs new file mode 100644 index 0000000000..928de4155b --- /dev/null +++ b/core/crates/gem_algorand/src/provider/state.rs @@ -0,0 +1,42 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; + +use crate::rpc::client::AlgorandClient; + +#[async_trait] +impl ChainState for AlgorandClient { + async fn get_chain_id(&self) -> Result> { + Ok(self.get_transactions_params().await?.genesis_id) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_transactions_params().await?.last_round) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_algorand_get_chain_id() -> Result<(), Box> { + let client = create_algorand_test_client(); + let chain_id = client.get_chain_id().await?; + println!("Algorand chain ID: {}", chain_id); + assert!(chain_id == "mainnet-v1.0"); + Ok(()) + } + + #[tokio::test] + async fn test_algorand_get_block_latest_number() -> Result<(), Box> { + let client = create_algorand_test_client(); + let latest_block = client.get_block_latest_number().await?; + println!("Latest block: {}", latest_block); + assert!(latest_block > 0); + Ok(()) + } +} diff --git a/core/crates/gem_algorand/src/provider/state_mapper.rs b/core/crates/gem_algorand/src/provider/state_mapper.rs new file mode 100644 index 0000000000..29bfbf5726 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/state_mapper.rs @@ -0,0 +1,30 @@ +use crate::models::TransactionsParams; +use num_bigint::BigInt; +use primitives::{FeePriority, FeeRate, GasPriceType}; + +pub fn map_transaction_params_to_fee(params: &TransactionsParams) -> FeeRate { + FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(params.min_fee))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_transaction_params_to_fee() { + let params = TransactionsParams { + min_fee: 1000, + genesis_id: "mainnet-v1.0".to_string(), + genesis_hash: "hash".to_string(), + last_round: 12345, + }; + + let result = map_transaction_params_to_fee(¶ms); + assert_eq!(result.priority, FeePriority::Normal); + + match result.gas_price_type { + GasPriceType::Regular { ref gas_price } => assert_eq!(*gas_price, BigInt::from(1000)), + _ => panic!("Expected Regular gas price type"), + } + } +} diff --git a/core/crates/gem_algorand/src/provider/testkit.rs b/core/crates/gem_algorand/src/provider/testkit.rs new file mode 100644 index 0000000000..fe0f16d199 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/testkit.rs @@ -0,0 +1,25 @@ +#[cfg(feature = "chain_integration_tests")] +use crate::rpc::client::AlgorandClient; +#[cfg(feature = "chain_integration_tests")] +use gem_client::ReqwestClient; +#[cfg(feature = "chain_integration_tests")] +use settings::testkit::get_test_settings; + +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "LAEWXAG6FYFIEDAY76YQFKO46EIKEOIT4GTONUQFD6TL23XG45KQ"; + +#[cfg(feature = "chain_integration_tests")] +pub const TEST_ADDRESS: &str = "RXIOUIR5IGFZMIZ7CR7FJXDYY4JI7NZG5UCWCZZNWXUPFJRLG6K6X5ITXM"; + +#[cfg(feature = "chain_integration_tests")] +pub fn create_algorand_test_client() -> AlgorandClient { + use crate::rpc::{AlgorandClientIndexer, client_indexer::ALGORAND_INDEXER_URL}; + + let settings = get_test_settings(); + let client = reqwest::Client::new(); + let reqwest_client = ReqwestClient::new(settings.chains.algorand.url, client.clone()); + AlgorandClient::new( + reqwest_client.clone(), + AlgorandClientIndexer::new(ReqwestClient::new(ALGORAND_INDEXER_URL.to_string(), client.clone())), + ) +} diff --git a/core/crates/gem_algorand/src/provider/token.rs b/core/crates/gem_algorand/src/provider/token.rs new file mode 100644 index 0000000000..ceab4e8b2c --- /dev/null +++ b/core/crates/gem_algorand/src/provider/token.rs @@ -0,0 +1,39 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::Asset; + +use crate::{ + provider::token_mapper::{is_valid_token_id, map_asset}, + rpc::client::AlgorandClient, +}; + +#[async_trait] +impl ChainToken for AlgorandClient { + async fn get_token_data(&self, token_id: String) -> Result> { + let asset = self.get_asset(&token_id).await?; + Ok(map_asset(asset)) + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + is_valid_token_id(token_id) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainToken; + + #[tokio::test] + async fn test_algorand_get_token_data() -> Result<(), Box> { + let client = create_algorand_test_client(); + let token_data = client.get_token_data("31566704".to_string()).await?; + assert!(!token_data.name.is_empty()); + assert!(token_data.decimals > 0); + println!("Token data: {:?}", token_data); + Ok(()) + } +} diff --git a/core/crates/gem_algorand/src/provider/token_mapper.rs b/core/crates/gem_algorand/src/provider/token_mapper.rs new file mode 100644 index 0000000000..0bf62fa504 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/token_mapper.rs @@ -0,0 +1,31 @@ +use crate::models::AssetDetails; +use primitives::{Asset, AssetId, AssetType, Chain}; + +pub fn is_valid_token_id(token_id: &str) -> bool { + if token_id.len() > 4 && token_id.parse::().is_ok() { + return true; + } + false +} + +pub fn map_asset(asset: AssetDetails) -> Asset { + Asset::new( + AssetId::from_token(Chain::Algorand, &asset.index.to_string()), + asset.params.name, + asset.params.unit_name, + asset.params.decimals as i32, + AssetType::TOKEN, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_valid_token_id() { + assert!(is_valid_token_id("31566704")); + assert!(!is_valid_token_id("abc")); + assert!(!is_valid_token_id("12")); + } +} diff --git a/core/crates/gem_algorand/src/provider/transaction_broadcast.rs b/core/crates/gem_algorand/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..2e0471b167 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/transaction_broadcast.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{BroadcastProvider, transaction_broadcast_mapper::map_transaction_broadcast_response_from_str, transactions_mapper::map_transaction_broadcast}, + rpc::client::AlgorandClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for AlgorandClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(&data).await?; + map_transaction_broadcast(&response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_algorand/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_algorand/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..1dfa4be211 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,9 @@ +use std::error::Error; + +use crate::models::TransactionBroadcast; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::(response)?; + map_transaction_broadcast(&response) +} diff --git a/core/crates/gem_algorand/src/provider/transaction_state.rs b/core/crates/gem_algorand/src/provider/transaction_state.rs new file mode 100644 index 0000000000..e0606190bf --- /dev/null +++ b/core/crates/gem_algorand/src/provider/transaction_state.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::AlgorandClient}; + +#[async_trait] +impl ChainTransactionState for AlgorandClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let transaction = self.get_transaction_status(&request.id).await?; + Ok(map_transaction_status(&transaction)) + } +} diff --git a/core/crates/gem_algorand/src/provider/transaction_state_mapper.rs b/core/crates/gem_algorand/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..5289adc72f --- /dev/null +++ b/core/crates/gem_algorand/src/provider/transaction_state_mapper.rs @@ -0,0 +1,55 @@ +use crate::models::TransactionStatus; +use primitives::{TransactionChange, TransactionUpdate}; + +pub fn map_transaction_status(transaction: &TransactionStatus) -> TransactionUpdate { + let state = transaction.state(); + let mut changes = Vec::new(); + if let Some(round) = transaction.confirmed_round.filter(|r| *r > 0) { + changes.push(TransactionChange::BlockNumber(round.to_string())); + } + + TransactionUpdate { state, changes } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::TransactionStatus; + use primitives::{TransactionChange, TransactionState}; + + #[test] + fn test_map_transaction_status_confirmed() { + let result = map_transaction_status(&TransactionStatus { + confirmed_round: Some(52961610), + pool_error: None, + }); + assert_eq!(result.state, TransactionState::Confirmed); + assert_eq!(result.changes, vec![TransactionChange::BlockNumber("52961610".to_string())]); + } + + #[test] + fn test_map_transaction_status_success_data() { + let status: TransactionStatus = serde_json::from_str(include_str!("../../testdata/transaction_transfer_success.json")).unwrap(); + let result = map_transaction_status(&status); + assert_eq!(result.state, TransactionState::Confirmed); + assert_eq!(result.changes, vec![TransactionChange::BlockNumber("52961610".to_string())]); + } + + #[test] + fn test_map_transaction_status_pending_data() { + let status: TransactionStatus = serde_json::from_str(include_str!("../../testdata/transaction_transfer_pending.json")).unwrap(); + let result = map_transaction_status(&status); + assert_eq!(result.state, TransactionState::Pending); + assert_eq!(result.changes.len(), 0); + } + + #[test] + fn test_map_transaction_status_failed_with_pool_error() { + let result = map_transaction_status(&TransactionStatus { + confirmed_round: None, + pool_error: Some("overspend".to_string()), + }); + assert_eq!(result.state, TransactionState::Failed); + assert_eq!(result.changes.len(), 0); + } +} diff --git a/core/crates/gem_algorand/src/provider/transactions.rs b/core/crates/gem_algorand/src/provider/transactions.rs new file mode 100644 index 0000000000..06db00d591 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/transactions.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::{ + provider::transactions_mapper::{map_transaction_by_hash, map_transactions}, + rpc::client::AlgorandClient, +}; + +#[async_trait] +impl ChainTransactions for AlgorandClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let block = self.indexer.get_block(block).await?; + Ok(map_transactions(block.transactions)) + } + + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, .. } = request; + let transactions = self.indexer.get_account_transactions(&address).await?; + Ok(map_transactions(transactions.transactions)) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + let transaction = self.indexer.get_transaction(&hash).await?; + Ok(map_transaction_by_hash(transaction)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::{ChainState, ChainTransactions, TransactionsRequest}; + + #[tokio::test] + async fn test_algorand_get_transactions_by_block() -> Result<(), Box> { + let client = create_algorand_test_client(); + let latest_block = client.get_block_latest_number().await?; + let transactions = client.get_transactions_by_block(latest_block - 1).await?; + println!("Transactions in block {}: {}", latest_block - 1, transactions.len()); + + Ok(()) + } + + #[tokio::test] + async fn test_algorand_get_transactions_by_address() -> Result<(), Box> { + let client = create_algorand_test_client(); + let transactions = client.get_transactions_by_address(TransactionsRequest::new(TEST_ADDRESS.to_string())).await?; + println!("Address: {}, transactions count: {}", TEST_ADDRESS, transactions.len()); + + assert!(!transactions.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_algorand_get_transaction_by_hash() -> Result<(), Box> { + let client = create_algorand_test_client(); + let transaction = client.get_transaction_by_hash(TEST_TRANSACTION_ID.to_string()).await?.unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + Ok(()) + } +} diff --git a/core/crates/gem_algorand/src/provider/transactions_mapper.rs b/core/crates/gem_algorand/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..ff722845e0 --- /dev/null +++ b/core/crates/gem_algorand/src/provider/transactions_mapper.rs @@ -0,0 +1,87 @@ +use std::error::Error; + +use crate::constants::TRANSACTION_TYPE_PAY; +use crate::models::{Transaction as AlgoTransaction, TransactionBroadcast, TransactionLookup}; +use chrono::DateTime; +use primitives::{Transaction, TransactionState, TransactionType, chain::Chain}; + +pub fn map_transaction_broadcast(result: &TransactionBroadcast) -> Result> { + if let Some(message) = &result.message { + Err(message.clone().into()) + } else if let Some(hash) = &result.tx_id { + Ok(hash.clone()) + } else { + Err("Broadcast failed without specific error".into()) + } +} + +pub fn map_transactions(transactions: Vec) -> Vec { + transactions.into_iter().flat_map(map_transaction).collect::>() +} + +pub fn map_transaction_by_hash(transaction: TransactionLookup) -> Option { + map_transaction(transaction.transaction) +} + +pub fn map_transaction(transaction: AlgoTransaction) -> Option { + let chain = Chain::Algorand; + match transaction.transaction_type.as_str() { + TRANSACTION_TYPE_PAY => Some(Transaction::new( + transaction.id.clone(), + chain.as_asset_id(), + transaction.sender.clone().unwrap_or_default(), + transaction.payment_transaction.clone()?.receiver.clone().unwrap_or_default(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + transaction.fee.unwrap_or_default().to_string(), + chain.as_asset_id(), + transaction.payment_transaction.clone()?.amount.unwrap_or_default().to_string(), + transaction.clone().get_memo(), + None, + DateTime::from_timestamp(transaction.round_time, 0)?, + )), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{models::TransactionBroadcast, provider::testkit::TEST_TRANSACTION_ID}; + + #[test] + fn test_map_transaction_broadcast_success() { + let broadcast = TransactionBroadcast { + tx_id: Some("G4MBO3DS7ACGA3XF5XD5Y52ZVJL6ZYROTCVB2I3BQHBYHTPQ7VOA".to_string()), + message: None, + }; + assert_eq!(map_transaction_broadcast(&broadcast).unwrap(), "G4MBO3DS7ACGA3XF5XD5Y52ZVJL6ZYROTCVB2I3BQHBYHTPQ7VOA"); + } + + #[test] + fn test_map_transaction_broadcast_success_data() { + let broadcast: TransactionBroadcast = serde_json::from_str(include_str!("../../testdata/transaction_broadcast_success.json")).unwrap(); + assert_eq!(map_transaction_broadcast(&broadcast).unwrap(), "LAEWXAG6FYFIEDAY76YQFKO46EIKEOIT4GTONUQFD6TL23XG45KQ"); + } + + #[test] + fn test_map_transaction_broadcast_error_data() { + let broadcast: TransactionBroadcast = serde_json::from_str(include_str!("../../testdata/transaction_broadcast_error.json")).unwrap(); + assert_eq!( + map_transaction_broadcast(&broadcast).unwrap_err().to_string(), + "txgroup had 0 in fees, which is less than the minimum 1 * 1000" + ); + } + + #[test] + fn test_map_transaction_by_hash() { + let lookup: TransactionLookup = serde_json::from_str(include_str!("../../testdata/transaction_by_hash.json")).unwrap(); + let transaction = map_transaction_by_hash(lookup).unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + assert_eq!(transaction.from, "RXIOUIR5IGFZMIZ7CR7FJXDYY4JI7NZG5UCWCZZNWXUPFJRLG6K6X5ITXM"); + assert_eq!(transaction.to, "NXSHXB3CLKPZ4JJ3LIXOKOEAB575EDDHCUTDYAKYRXZWVJ6CCQUP55ZEPE"); + assert_eq!(transaction.value, "100000"); + } +} diff --git a/core/crates/gem_algorand/src/rpc/client.rs b/core/crates/gem_algorand/src/rpc/client.rs new file mode 100644 index 0000000000..3c8b407e90 --- /dev/null +++ b/core/crates/gem_algorand/src/rpc/client.rs @@ -0,0 +1,84 @@ +use std::collections::HashMap; +use std::error::Error; + +use crate::{ + models::{Account, AssetDetails, Block, TransactionBroadcast, TransactionStatus, TransactionsParams}, + rpc::AlgorandClientIndexer, +}; +use gem_client::{CONTENT_TYPE, ContentType}; + +#[cfg(feature = "rpc")] +use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual, ChainProvider, ChainStaking, ChainTraits}; +#[cfg(feature = "rpc")] +use gem_client::{Client, ClientExt}; +#[cfg(feature = "rpc")] +use primitives::Chain; + +#[derive(Debug)] +pub struct AlgorandClient { + client: C, + pub chain: Chain, + pub indexer: AlgorandClientIndexer, +} + +impl AlgorandClient { + pub fn new(client: C, indexer: AlgorandClientIndexer) -> Self { + Self { + client, + chain: Chain::Algorand, + indexer, + } + } + + pub fn get_chain(&self) -> Chain { + self.chain + } + + pub async fn get_account(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/v2/accounts/{}", address)).await?) + } + + pub async fn get_asset(&self, asset_id: &str) -> Result> { + Ok(self.client.get(&format!("/v2/assets/{}", asset_id)).await?) + } + + pub async fn get_transactions_params(&self) -> Result> { + Ok(self.client.get("/v2/transactions/params").await?) + } + + pub async fn get_block(&self, block_number: u64) -> Result> { + Ok(self.client.get(&format!("/v2/blocks/{}", block_number)).await?) + } + + pub async fn broadcast_transaction(&self, data: &str) -> Result> { + let headers = HashMap::from([(CONTENT_TYPE.to_string(), ContentType::ApplicationXBinary.as_str().to_string())]); + + Ok(self.client.post_with_headers("/v2/transactions", &data, headers).await?) + } + + pub async fn get_transaction_status(&self, transaction_id: &str) -> Result> { + Ok(self.client.get(&format!("/v2/transactions/pending/{}", transaction_id)).await?) + } +} + +#[cfg(feature = "rpc")] +impl ChainProvider for AlgorandClient { + fn get_chain(&self) -> Chain { + self.chain + } +} + +#[cfg(feature = "rpc")] +impl ChainStaking for AlgorandClient {} + +#[cfg(feature = "rpc")] +impl ChainAccount for AlgorandClient {} + +#[cfg(feature = "rpc")] +impl ChainPerpetual for AlgorandClient {} + +#[cfg(feature = "rpc")] +impl ChainAddressStatus for AlgorandClient {} + +#[cfg(feature = "rpc")] +impl ChainTraits for AlgorandClient {} diff --git a/core/crates/gem_algorand/src/rpc/client_indexer.rs b/core/crates/gem_algorand/src/rpc/client_indexer.rs new file mode 100644 index 0000000000..9b7e7917b1 --- /dev/null +++ b/core/crates/gem_algorand/src/rpc/client_indexer.rs @@ -0,0 +1,31 @@ +use std::error::Error; + +use crate::models::{Block, TransactionLookup, Transactions}; + +#[cfg(feature = "rpc")] +use gem_client::{Client, ClientExt}; + +#[derive(Clone, Debug)] +pub struct AlgorandClientIndexer { + pub client: C, +} + +pub const ALGORAND_INDEXER_URL: &str = "https://mainnet-idx.algonode.cloud"; + +impl AlgorandClientIndexer { + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_account_transactions(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/v2/accounts/{}/transactions", address)).await?) + } + + pub async fn get_block(&self, block_number: u64) -> Result> { + Ok(self.client.get(&format!("/v2/blocks/{}", block_number)).await?) + } + + pub async fn get_transaction(&self, txid: &str) -> Result> { + Ok(self.client.get(&format!("/v2/transactions/{}", txid)).await?) + } +} diff --git a/core/crates/gem_algorand/src/rpc/mod.rs b/core/crates/gem_algorand/src/rpc/mod.rs new file mode 100644 index 0000000000..73479f09d3 --- /dev/null +++ b/core/crates/gem_algorand/src/rpc/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub use client::AlgorandClient; +pub mod client_indexer; +pub use client_indexer::AlgorandClientIndexer; diff --git a/core/crates/gem_algorand/src/signer/chain_signer.rs b/core/crates/gem_algorand/src/signer/chain_signer.rs new file mode 100644 index 0000000000..6e194600f7 --- /dev/null +++ b/core/crates/gem_algorand/src/signer/chain_signer.rs @@ -0,0 +1,100 @@ +use crate::models::signing::AlgorandTransaction; +use crate::signer::signing::sign_transaction; +use primitives::{ChainSigner, SignerError, SignerInput}; + +#[derive(Default)] +pub struct AlgorandChainSigner; + +impl ChainSigner for AlgorandChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&AlgorandTransaction::transfer(input)?, private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&AlgorandTransaction::token_transfer(input)?, private_key) + } + + fn sign_account_action(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&AlgorandTransaction::account_action(input)?, private_key) + } +} + +#[cfg(test)] +mod tests { + // Tests taken from https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Algorand/TWAnySignerTests.cpp + use super::*; + use primitives::{Asset, AssetId, AssetType, Chain, TransactionFee, TransactionLoadInput, TransactionLoadMetadata}; + + const PRIVATE_KEY: &str = "5a6a3cfe5ff4cc44c19381d15a0d16de2a76ee5c9b9d83b232e38cb5a2c84b04"; + const SENDER: &str = "QKDS2YGDHDFZFAAGA4HAF3AJIKW5ZN46P66QDR3ELCXKKJUJTPJSXVHNQU"; + const DESTINATION: &str = "GJIWJSX2EU5RC32LKTDDXWLA2YICBHKE35RV2ZPASXZYKWUWXFLKNFSS4U"; + + #[test] + fn test_sign_algorand_transactions() { + let key = hex::decode(PRIVATE_KEY).unwrap(); + let token = Asset::new(AssetId::token(Chain::Algorand, "13379146"), "AlgoToken".into(), "ALGO".into(), 6, AssetType::TOKEN); + let metadata = |sequence: u64| TransactionLoadMetadata::Algorand { + sequence, + block_hash: "SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=".into(), + chain_id: "testnet-v1.0".into(), + }; + + // Native transfer + let input = SignerInput::new( + TransactionLoadInput::mock_transfer(Asset::from_chain(Chain::Algorand), SENDER, DESTINATION, "1000000", 2340, None, metadata(15775683)), + TransactionFee::new_from_fee(2340.into()), + ); + let signed = AlgorandChainSigner.sign_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "82a3736967c440e87330ca542b7ee4f09ff31f8752e51c8a13fdf2b9d0c07a67a40ed3c4c981e4e23b1ea5f17cb5f34e5e66a937110f4ae5800baf09a12ea18dda25193c399d06a374786e89a3616d74ce000f4240a3666565cd0924a26676ce00f0b7c3a367656eac746573746e65742d76312e30a26768c4204863b518a4b3c84ec810f22d4f1081cb0f71f059a7ac20dec62f7f70e5093a22a26c76ce00f0bbaba3726376c420325164cafa253b116f4b54c63bd960d610209d44df635d65e095f3855a96b956a3736e64c42082872d60c338cb928006070e02ec0942addcb79e7fbd01c76458aea526899bd3a474797065a3706179" + ); + + // Token transfer + let input = SignerInput::new( + TransactionLoadInput::mock_transfer(token.clone(), SENDER, DESTINATION, "1000000", 2340, None, metadata(15775683)), + TransactionFee::new_from_fee(2340.into()), + ); + let signed = AlgorandChainSigner.sign_token_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "82a3736967c440412720eff99a17280a437bdb8eeba7404b855d6433fffd5dde7f7966c1f9ae531a1af39e18b8a58b4a6c6acb709cca92f8a18c36d8328be9520c915311027005a374786e8aa461616d74ce000f4240a461726376c420325164cafa253b116f4b54c63bd960d610209d44df635d65e095f3855a96b956a3666565cd0924a26676ce00f0b7c3a367656eac746573746e65742d76312e30a26768c4204863b518a4b3c84ec810f22d4f1081cb0f71f059a7ac20dec62f7f70e5093a22a26c76ce00f0bbaba3736e64c42082872d60c338cb928006070e02ec0942addcb79e7fbd01c76458aea526899bd3a474797065a56178666572a478616964ce00cc264a" + ); + + // Account action (asset opt-in) + let input = SignerInput::new( + TransactionLoadInput::mock_transfer(token, SENDER, "", "0", 2340, None, metadata(15775553)), + TransactionFee::new_from_fee(2340.into()), + ); + let signed = AlgorandChainSigner.sign_account_action(&input, &key).unwrap(); + assert_eq!( + signed, + "82a3736967c440f3a29d9a40271c00b542b38ab2ccb4967015ae6609368d4b8eb2f5e2b5348577cf9e0f62b0777ccb2d8d9b943b15c24c0cf1db312cb01a3c198d9d9c6c5bb00ba374786e89a461726376c42082872d60c338cb928006070e02ec0942addcb79e7fbd01c76458aea526899bd3a3666565cd0924a26676ce00f0b741a367656eac746573746e65742d76312e30a26768c4204863b518a4b3c84ec810f22d4f1081cb0f71f059a7ac20dec62f7f70e5093a22a26c76ce00f0bb29a3736e64c42082872d60c338cb928006070e02ec0942addcb79e7fbd01c76458aea526899bd3a474797065a56178666572a478616964ce00cc264a" + ); + } + + #[test] + fn test_sign_native_transfer_with_note() { + let key = hex::decode("d5b43d706ef0cb641081d45a2ec213b5d8281f439f2425d1af54e2afdaabf55b").unwrap(); + let load = TransactionLoadInput::mock_transfer( + Asset::from_chain(Chain::Algorand), + "MG7QMDX4ALRIQ7P77SHNQUTIZDAJDQAT53PTCW6FA6KNAKUHSGW4FGK32Q", + "CRLADAHJZEW2GFY2UPEHENLOGCUOU74WYSTUXQLVLJUJFHEUZOHYZNWYR4", + "1000000000000", + 263000, + Some("hello"), + TransactionLoadMetadata::Algorand { + sequence: 1937767, + block_hash: "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=".into(), + chain_id: "mainnet-v1.0".into(), + }, + ); + let input = SignerInput::new(load, TransactionFee::new_from_fee(263000.into())); + + let signed = AlgorandChainSigner.sign_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "82a3736967c440baa00062adcdcb5875e4435cdc6885d26bfe5308ab17983c0fda790b7103051fcb111554e5badfc0ac7edf7e1223a434342a9eeed5cdb047690827325051560ba374786e8aa3616d74cf000000e8d4a51000a3666565ce00040358a26676ce001d9167a367656eac6d61696e6e65742d76312e30a26768c420c061c4d8fc1dbdded2d7604be4568e3f6d041987ac37bde4b620b5ab39248adfa26c76ce001d954fa46e6f7465c40568656c6c6fa3726376c42014560180e9c92da3171aa3c872356e30a8ea7f96c4a74bc1755a68929c94cb8fa3736e64c42061bf060efc02e2887dfffc8ed85268c8c091c013eedf315bc50794d02a8791ada474797065a3706179" + ); + } +} diff --git a/core/crates/gem_algorand/src/signer/mod.rs b/core/crates/gem_algorand/src/signer/mod.rs new file mode 100644 index 0000000000..7353a15fb2 --- /dev/null +++ b/core/crates/gem_algorand/src/signer/mod.rs @@ -0,0 +1,5 @@ +mod chain_signer; +mod serialization; +mod signing; + +pub use chain_signer::AlgorandChainSigner; diff --git a/core/crates/gem_algorand/src/signer/serialization.rs b/core/crates/gem_algorand/src/signer/serialization.rs new file mode 100644 index 0000000000..0df6555104 --- /dev/null +++ b/core/crates/gem_algorand/src/signer/serialization.rs @@ -0,0 +1,151 @@ +use crate::address::AlgorandAddress; +use crate::models::signing::{AlgorandTransaction, Operation}; +use primitives::{Address, SignerError}; + +const SIGNED_TX_FIELDS: u8 = 2; + +const FIXMAP_PREFIX: u8 = 0x80; +const FIXSTR_PREFIX: u8 = 0xa0; +const FIXSTR_MAX_LEN: usize = 0x20; +const STR8_PREFIX: u8 = 0xd9; +const UINT8_PREFIX: u8 = 0xcc; +const UINT16_PREFIX: u8 = 0xcd; +const UINT32_PREFIX: u8 = 0xce; +const UINT64_PREFIX: u8 = 0xcf; +const BIN8_PREFIX: u8 = 0xc4; +const BIN16_PREFIX: u8 = 0xc5; + +/// Encode an unsigned Algorand transaction as canonical MessagePack (keys in lexicographic order). +pub(crate) fn encode_transaction(tx: &AlgorandTransaction) -> Result, SignerError> { + let mut data = Vec::new(); + + let mut size = tx.operation.size(); + if !tx.note.is_empty() { + size += 1; + } + + data.push(FIXMAP_PREFIX | size); + + if let Some(amount) = tx.operation.payment_amount() { + encode_string("amt", &mut data); + encode_uint(amount, &mut data); + } + + match &tx.operation { + Operation::Payment { destination, .. } => encode_payment(tx, destination, &mut data), + Operation::AssetTransfer { destination, amount, asset_id } => encode_asset_transfer(tx, destination, *amount, *asset_id, &mut data), + Operation::AssetOptIn { asset_id } => encode_asset_opt_in(tx, *asset_id, &mut data), + } + + Ok(data) +} + +fn encode_payment(tx: &AlgorandTransaction, destination: &AlgorandAddress, data: &mut Vec) { + encode_common_fields(tx, data); + encode_string("rcv", data); + encode_address(destination, data); + encode_sender_and_type(tx, data); +} + +fn encode_asset_transfer(tx: &AlgorandTransaction, destination: &AlgorandAddress, amount: u64, asset_id: u64, data: &mut Vec) { + encode_string("aamt", data); + encode_uint(amount, data); + encode_string("arcv", data); + encode_address(destination, data); + encode_common_fields(tx, data); + encode_sender_and_type(tx, data); + encode_string("xaid", data); + encode_uint(asset_id, data); +} + +fn encode_asset_opt_in(tx: &AlgorandTransaction, asset_id: u64, data: &mut Vec) { + encode_string("arcv", data); + encode_address(&tx.sender, data); + encode_common_fields(tx, data); + encode_sender_and_type(tx, data); + encode_string("xaid", data); + encode_uint(asset_id, data); +} + +fn encode_sender_and_type(tx: &AlgorandTransaction, data: &mut Vec) { + encode_string("snd", data); + encode_address(&tx.sender, data); + encode_string("type", data); + encode_string(tx.operation.tx_type(), data); +} + +pub(crate) fn encode_signed_transaction(encoded_tx: &[u8], signature: &[u8]) -> Vec { + let mut data = Vec::new(); + data.push(FIXMAP_PREFIX | SIGNED_TX_FIELDS); + encode_string("sig", &mut data); + encode_bytes(signature, &mut data); + encode_string("txn", &mut data); + data.extend_from_slice(encoded_tx); + data +} + +fn encode_common_fields(tx: &AlgorandTransaction, data: &mut Vec) { + encode_string("fee", data); + encode_uint(tx.fee, data); + encode_string("fv", data); + encode_uint(tx.first_round, data); + encode_string("gen", data); + encode_string(&tx.genesis_id, data); + encode_string("gh", data); + encode_bytes(&tx.genesis_hash, data); + encode_string("lv", data); + encode_uint(tx.last_round, data); + if !tx.note.is_empty() { + encode_string("note", data); + encode_bytes(&tx.note, data); + } +} + +fn encode_address(address: &AlgorandAddress, data: &mut Vec) { + encode_bytes(address.as_bytes(), data); +} + +fn encode_string(value: &str, data: &mut Vec) { + let len = value.len(); + if len < FIXSTR_MAX_LEN { + data.push(FIXSTR_PREFIX | len as u8); + } else { + data.push(STR8_PREFIX); + data.push(len as u8); + } + data.extend_from_slice(value.as_bytes()); +} + +fn encode_uint(value: u64, data: &mut Vec) { + match value { + 0..0x80 => data.push(value as u8), + 0x80..0x100 => { + data.push(UINT8_PREFIX); + data.push(value as u8); + } + 0x100..0x1_0000 => { + data.push(UINT16_PREFIX); + data.extend_from_slice(&(value as u16).to_be_bytes()); + } + 0x1_0000..0x1_0000_0000 => { + data.push(UINT32_PREFIX); + data.extend_from_slice(&(value as u32).to_be_bytes()); + } + _ => { + data.push(UINT64_PREFIX); + data.extend_from_slice(&value.to_be_bytes()); + } + } +} + +fn encode_bytes(bytes: &[u8], data: &mut Vec) { + let len = bytes.len(); + if len < 0x100 { + data.push(BIN8_PREFIX); + data.push(len as u8); + } else { + data.push(BIN16_PREFIX); + data.extend_from_slice(&(len as u16).to_be_bytes()); + } + data.extend_from_slice(bytes); +} diff --git a/core/crates/gem_algorand/src/signer/signing.rs b/core/crates/gem_algorand/src/signer/signing.rs new file mode 100644 index 0000000000..b623ccf85a --- /dev/null +++ b/core/crates/gem_algorand/src/signer/signing.rs @@ -0,0 +1,18 @@ +use crate::models::signing::AlgorandTransaction; +use crate::signer::serialization::{encode_signed_transaction, encode_transaction}; +use primitives::SignerError; +use signer::{SignatureScheme, Signer}; + +const TX_TAG: &[u8; 2] = b"TX"; + +pub(crate) fn sign_transaction(transaction: &AlgorandTransaction, private_key: &[u8]) -> Result { + let encoded = encode_transaction(transaction)?; + + let mut preimage = Vec::with_capacity(TX_TAG.len() + encoded.len()); + preimage.extend_from_slice(TX_TAG); + preimage.extend_from_slice(&encoded); + + let signature = Signer::sign_digest(SignatureScheme::Ed25519, &preimage, private_key)?; + let signed = encode_signed_transaction(&encoded, &signature); + Ok(hex::encode(signed)) +} diff --git a/core/crates/gem_algorand/testdata/account.json b/core/crates/gem_algorand/testdata/account.json new file mode 100644 index 0000000000..e35c9769ad --- /dev/null +++ b/core/crates/gem_algorand/testdata/account.json @@ -0,0 +1,29 @@ +{ + "address": "RXIOUIR5IGFZMIZ7CR7FJXDYY4JI7NZG5UCWCZZNWXUPFJRLG6K6X5ITXM", + "amount": 71614422, + "amount-without-pending-rewards": 71614422, + "assets": [ + { + "amount": 26823378, + "asset-id": 31566704, + "deleted": false, + "is-frozen": false, + "opted-in-at-round": 45465345 + } + ], + "created-at-round": 45408355, + "deleted": false, + "min-balance": 200000, + "pending-rewards": 0, + "reward-base": 218288, + "rewards": 0, + "round": 53105781, + "sig-type": "sig", + "status": "Offline", + "total-apps-opted-in": 0, + "total-assets-opted-in": 1, + "total-box-bytes": 0, + "total-boxes": 0, + "total-created-apps": 0, + "total-created-assets": 0 +} \ No newline at end of file diff --git a/core/crates/gem_algorand/testdata/transaction_broadcast_error.json b/core/crates/gem_algorand/testdata/transaction_broadcast_error.json new file mode 100644 index 0000000000..b53a075dbb --- /dev/null +++ b/core/crates/gem_algorand/testdata/transaction_broadcast_error.json @@ -0,0 +1,3 @@ +{ + "message": "txgroup had 0 in fees, which is less than the minimum 1 * 1000" +} \ No newline at end of file diff --git a/core/crates/gem_algorand/testdata/transaction_broadcast_success.json b/core/crates/gem_algorand/testdata/transaction_broadcast_success.json new file mode 100644 index 0000000000..55a8cefc67 --- /dev/null +++ b/core/crates/gem_algorand/testdata/transaction_broadcast_success.json @@ -0,0 +1,3 @@ +{ + "txId": "LAEWXAG6FYFIEDAY76YQFKO46EIKEOIT4GTONUQFD6TL23XG45KQ" +} \ No newline at end of file diff --git a/core/crates/gem_algorand/testdata/transaction_by_hash.json b/core/crates/gem_algorand/testdata/transaction_by_hash.json new file mode 100644 index 0000000000..9321b69e19 --- /dev/null +++ b/core/crates/gem_algorand/testdata/transaction_by_hash.json @@ -0,0 +1,14 @@ +{ + "transaction": { + "id": "LAEWXAG6FYFIEDAY76YQFKO46EIKEOIT4GTONUQFD6TL23XG45KQ", + "round-time": 1755820269, + "fee": 1000, + "sender": "RXIOUIR5IGFZMIZ7CR7FJXDYY4JI7NZG5UCWCZZNWXUPFJRLG6K6X5ITXM", + "note": null, + "payment-transaction": { + "amount": 100000, + "receiver": "NXSHXB3CLKPZ4JJ3LIXOKOEAB575EDDHCUTDYAKYRXZWVJ6CCQUP55ZEPE" + }, + "tx-type": "pay" + } +} diff --git a/core/crates/gem_algorand/testdata/transaction_transfer_pending.json b/core/crates/gem_algorand/testdata/transaction_transfer_pending.json new file mode 100644 index 0000000000..79b5bb2f82 --- /dev/null +++ b/core/crates/gem_algorand/testdata/transaction_transfer_pending.json @@ -0,0 +1,17 @@ +{ + "pool-error": "", + "txn": { + "sig": "KQpswiZRlAW7LKUpmHZ5FkqISDFlbNw4K2gIEkYDc2lClezsvRhjw1LyA0cAp/f8rbvtsxuvnIRzZuHg5zVUDg==", + "txn": { + "amt": 100000, + "fee": 1000, + "fv": 52961607, + "gen": "mainnet-v1.0", + "gh": "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=", + "lv": 52962607, + "rcv": "NXSHXB3CLKPZ4JJ3LIXOKOEAB575EDDHCUTDYAKYRXZWVJ6CCQUP55ZEPE", + "snd": "RXIOUIR5IGFZMIZ7CR7FJXDYY4JI7NZG5UCWCZZNWXUPFJRLG6K6X5ITXM", + "type": "pay" + } + } + } \ No newline at end of file diff --git a/core/crates/gem_algorand/testdata/transaction_transfer_success.json b/core/crates/gem_algorand/testdata/transaction_transfer_success.json new file mode 100644 index 0000000000..4e3511590d --- /dev/null +++ b/core/crates/gem_algorand/testdata/transaction_transfer_success.json @@ -0,0 +1,18 @@ +{ + "confirmed-round": 52961610, + "pool-error": "", + "txn": { + "sig": "KQpswiZRlAW7LKUpmHZ5FkqISDFlbNw4K2gIEkYDc2lClezsvRhjw1LyA0cAp/f8rbvtsxuvnIRzZuHg5zVUDg==", + "txn": { + "amt": 100000, + "fee": 1000, + "fv": 52961607, + "gen": "mainnet-v1.0", + "gh": "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=", + "lv": 52962607, + "rcv": "NXSHXB3CLKPZ4JJ3LIXOKOEAB575EDDHCUTDYAKYRXZWVJ6CCQUP55ZEPE", + "snd": "RXIOUIR5IGFZMIZ7CR7FJXDYY4JI7NZG5UCWCZZNWXUPFJRLG6K6X5ITXM", + "type": "pay" + } + } + } \ No newline at end of file diff --git a/core/crates/gem_aptos/Cargo.toml b/core/crates/gem_aptos/Cargo.toml new file mode 100644 index 0000000000..39525ca3fe --- /dev/null +++ b/core/crates/gem_aptos/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "gem_aptos" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +rpc = [ + "dep:async-trait", + "dep:chrono", + "dep:chain_traits", + "dep:chain_primitives", + "dep:gem_client", + "dep:futures", +] +reqwest = ["gem_client/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] + +[dependencies] +primitives = { path = "../primitives" } +serde = { workspace = true } +serde_json = { workspace = true } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } +num-bigint = { workspace = true } +bcs = { workspace = true } +hex = { workspace = true } +num-traits = { workspace = true } +signer = { path = "../signer" } +gem_hash = { path = "../gem_hash" } + +# Dependencies for RPC client & mapper +async-trait = { workspace = true, optional = true } +chrono = { workspace = true, optional = true } +chain_traits = { path = "../chain_traits", optional = true } +chain_primitives = { path = "../chain_primitives", optional = true } +gem_client = { path = "../gem_client", optional = true } +futures = { workspace = true, optional = true } + +[dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } +tokio = { workspace = true, features = ["macros", "rt"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } +ed25519-dalek = { version = "2.2.0", default-features = false, features = [ + "std", +] } diff --git a/core/crates/gem_aptos/src/address.rs b/core/crates/gem_aptos/src/address.rs new file mode 100644 index 0000000000..db43f6e924 --- /dev/null +++ b/core/crates/gem_aptos/src/address.rs @@ -0,0 +1,85 @@ +use primitives::{Address as AddressTrait, SignerError, decode_hex}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +const ADDRESS_LENGTH: usize = 32; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountAddress([u8; ADDRESS_LENGTH]); + +impl AccountAddress { + pub fn from_hex(value: &str) -> Result { + ::from_str(value) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > ADDRESS_LENGTH { + return Err(SignerError::InvalidInput("Aptos address too long".to_string())); + } + let mut address = [0u8; ADDRESS_LENGTH]; + let offset = ADDRESS_LENGTH - bytes.len(); + address[offset..].copy_from_slice(bytes); + Ok(Self(address)) + } + + pub fn one() -> Self { + let mut bytes = [0u8; ADDRESS_LENGTH]; + bytes[ADDRESS_LENGTH - 1] = 1; + Self(bytes) + } +} + +impl FromStr for AccountAddress { + type Err = SignerError; + + fn from_str(value: &str) -> Result { + let bytes = decode_hex(value)?; + Self::from_bytes(&bytes) + } +} + +impl AddressTrait for AccountAddress { + fn try_parse(address: &str) -> Option { + Self::from_hex(address).ok() + } + + fn as_bytes(&self) -> &[u8] { + &self.0 + } + + fn encode(&self) -> String { + self.to_string() + } +} + +pub fn validate_address(address: &str) -> bool { + AccountAddress::is_valid(address) +} + +impl fmt::Display for AccountAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x{}", ::hex::encode(self.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_ADDRESS: &str = "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc"; + + #[test] + fn test_aptos_address() { + let parsed = AccountAddress::from_hex(VALID_ADDRESS).unwrap(); + + assert!(validate_address(VALID_ADDRESS)); + assert_eq!(parsed.to_string(), VALID_ADDRESS); + assert_eq!(parsed.as_bytes().len(), 32); + assert!(!validate_address("invalid")); + + // short hex is left-padded to 32 bytes (Aptos framework address convention) + let short = AccountAddress::from_hex("0x1").unwrap(); + assert_eq!(short.to_string(), format!("0x{}", "00".repeat(31) + "01")); + } +} diff --git a/core/crates/gem_aptos/src/constants.rs b/core/crates/gem_aptos/src/constants.rs new file mode 100644 index 0000000000..5ae6efc153 --- /dev/null +++ b/core/crates/gem_aptos/src/constants.rs @@ -0,0 +1,25 @@ +pub const APTOS_NATIVE_COIN: &str = "0x1::aptos_coin::AptosCoin"; +pub const APTOS_TRANSFER_FUNCTION: &str = "0x1::aptos_account::transfer"; +pub const ENTRY_FUNCTION_PAYLOAD_TYPE: &str = "entry_function_payload"; +pub const NO_ACCOUNT_SIGNATURE_TYPE: &str = "no_account_signature"; +pub const DEFAULT_MAX_GAS_AMOUNT: u64 = 1500; +pub const DEFAULT_SWAP_MAX_GAS_AMOUNT: u64 = 20000; + +/// The module address for the coin info resource +pub const COIN_INFO: &str = "0x1::coin::CoinInfo"; +pub const COIN_STORE: &str = "0x1::coin::CoinStore"; + +pub const STAKE_WITHDRAW_EVENT: &str = "0x1::coin::WithdrawEvent"; +pub const STAKE_DEPOSIT_EVENT: &str = "0x1::coin::DepositEvent"; + +pub const FUNGIBLE_ASSET_WITHDRAW_EVENT: &str = "0x1::fungible_asset::Withdraw"; +pub const FUNGIBLE_ASSET_DEPOSIT_EVENT: &str = "0x1::fungible_asset::Deposit"; + +pub const DELEGATION_POOL_ADD_STAKE_FUNCTION: &str = "0x1::delegation_pool::add_stake"; +pub const DELEGATION_POOL_UNLOCK_FUNCTION: &str = "0x1::delegation_pool::unlock"; +pub const DELEGATION_POOL_WITHDRAW_FUNCTION: &str = "0x1::delegation_pool::withdraw"; + +pub const DELEGATION_POOL_ADD_STAKE_EVENT: &str = "0x1::delegation_pool::AddStake"; +pub const DELEGATION_POOL_UNLOCK_STAKE_EVENT: &str = "0x1::delegation_pool::UnlockStake"; + +pub const KNOWN_VALIDATOR_POOL: &str = "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e"; // Everstake diff --git a/core/crates/gem_aptos/src/lib.rs b/core/crates/gem_aptos/src/lib.rs new file mode 100644 index 0000000000..b1e904280a --- /dev/null +++ b/core/crates/gem_aptos/src/lib.rs @@ -0,0 +1,20 @@ +pub mod constants; +pub use constants::*; +pub mod address; +pub use address::{AccountAddress, validate_address}; +pub mod models; +pub use models::*; +pub mod r#move; +pub mod signer; +mod token_id; + +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +#[cfg(feature = "rpc")] +pub use rpc::client::AptosClient; + +pub use signer::AptosChainSigner; diff --git a/core/crates/gem_aptos/src/models/account.rs b/core/crates/gem_aptos/src/models/account.rs new file mode 100644 index 0000000000..8007fd2bfd --- /dev/null +++ b/core/crates/gem_aptos/src/models/account.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +use super::coin::CoinData; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub sequence_number: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Resource { + #[serde(rename = "type")] + pub type_field: String, + pub data: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceData { + pub coin: Option, +} diff --git a/core/crates/gem_aptos/src/models/coin.rs b/core/crates/gem_aptos/src/models/coin.rs new file mode 100644 index 0000000000..bf2210d4ee --- /dev/null +++ b/core/crates/gem_aptos/src/models/coin.rs @@ -0,0 +1,27 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_biguint_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoinData { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub value: BigUint, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Coin { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub value: BigUint, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoinStore { + pub coin: Coin, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoinInfo { + pub decimals: u8, + pub name: String, + pub symbol: String, +} diff --git a/core/crates/gem_aptos/src/models/fee.rs b/core/crates/gem_aptos/src/models/fee.rs new file mode 100644 index 0000000000..bed96bae1f --- /dev/null +++ b/core/crates/gem_aptos/src/models/fee.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GasFee { + pub deprioritized_gas_estimate: u64, + pub gas_estimate: u64, + pub prioritized_gas_estimate: u64, +} diff --git a/core/crates/gem_aptos/src/models/ledger.rs b/core/crates/gem_aptos/src/models/ledger.rs new file mode 100644 index 0000000000..a116c7a99c --- /dev/null +++ b/core/crates/gem_aptos/src/models/ledger.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +use super::transaction::Transaction; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ledger { + pub chain_id: i32, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub block_height: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub epoch: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub ledger_timestamp: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + pub block_height: String, + pub transactions: Vec, +} diff --git a/core/crates/gem_aptos/src/models/mod.rs b/core/crates/gem_aptos/src/models/mod.rs new file mode 100644 index 0000000000..b0037135b4 --- /dev/null +++ b/core/crates/gem_aptos/src/models/mod.rs @@ -0,0 +1,15 @@ +pub mod account; +pub mod coin; +pub mod fee; +pub mod ledger; +pub mod signer_transaction; +pub mod staking; +pub mod transaction; + +pub use account::*; +pub use coin::*; +pub use fee::*; +pub use ledger::*; +pub use signer_transaction::*; +pub use staking::*; +pub use transaction::*; diff --git a/core/crates/gem_aptos/src/models/signer_transaction.rs b/core/crates/gem_aptos/src/models/signer_transaction.rs new file mode 100644 index 0000000000..11ed2cef71 --- /dev/null +++ b/core/crates/gem_aptos/src/models/signer_transaction.rs @@ -0,0 +1,50 @@ +use crate::AccountAddress; +use crate::r#move::{EntryFunction, TypeTag}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Script { + pub code: Vec, + pub ty_args: Vec, + pub args: Vec>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeprecatedPayload { + pub modules: Vec>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransactionPayloadBCS { + Script(Script), + ModuleBundle(DeprecatedPayload), + EntryFunction(EntryFunction), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RawTransaction { + pub sender: AccountAddress, + pub sequence_number: u64, + pub payload: TransactionPayloadBCS, + pub max_gas_amount: u64, + pub gas_unit_price: u64, + pub expiration_timestamp_secs: u64, + pub chain_id: u8, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Ed25519Authenticator { + pub public_key: Vec, + pub signature: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransactionAuthenticator { + Ed25519(Ed25519Authenticator), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedTransaction { + pub raw_tx: RawTransaction, + pub authenticator: TransactionAuthenticator, +} diff --git a/core/crates/gem_aptos/src/models/staking.rs b/core/crates/gem_aptos/src/models/staking.rs new file mode 100644 index 0000000000..a3add99cf9 --- /dev/null +++ b/core/crates/gem_aptos/src/models/staking.rs @@ -0,0 +1,43 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_biguint_from_str, deserialize_u64_from_str}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorSet { + pub active_validators: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorInfo { + pub addr: String, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub voting_power: BigUint, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationPoolStake { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub active: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub inactive: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub pending_inactive: BigUint, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StakingConfig { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub rewards_rate: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub rewards_rate_denominator: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub recurring_lockup_duration_secs: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReconfigurationState { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub epoch: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub last_reconfiguration_time: u64, +} diff --git a/core/crates/gem_aptos/src/models/transaction.rs b/core/crates/gem_aptos/src/models/transaction.rs new file mode 100644 index 0000000000..7671103689 --- /dev/null +++ b/core/crates/gem_aptos/src/models/transaction.rs @@ -0,0 +1,128 @@ +use crate::{FUNGIBLE_ASSET_DEPOSIT_EVENT, FUNGIBLE_ASSET_WITHDRAW_EVENT, NO_ACCOUNT_SIGNATURE_TYPE, STAKE_DEPOSIT_EVENT, STAKE_WITHDRAW_EVENT}; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_option_u64_from_str, deserialize_u64_from_str}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transaction { + pub hash: Option, + pub sender: Option, + pub success: bool, + #[serde(default, deserialize_with = "deserialize_option_u64_from_str")] + pub gas_used: Option, + #[serde(default, deserialize_with = "deserialize_option_u64_from_str")] + pub gas_unit_price: Option, + pub events: Option>, + pub payload: Option, + #[serde(rename = "type", default)] + pub transaction_type: Option, + pub sequence_number: Option, + #[serde(default, deserialize_with = "deserialize_u64_from_str")] + pub timestamp: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + pub guid: Guid, + pub data: Option, + #[serde(rename = "type")] + pub event_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AmountData { + pub amount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationPoolAddStakeData { + pub pool_address: String, + pub amount_added: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationPoolUnlockStakeData { + pub pool_address: String, + pub amount_unlocked: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Guid { + pub account_address: String, +} + +impl Event { + pub fn get_amount(&self) -> Option { + let data = self.data.clone()?; + match self.event_type.as_str() { + STAKE_WITHDRAW_EVENT | STAKE_DEPOSIT_EVENT | FUNGIBLE_ASSET_WITHDRAW_EVENT | FUNGIBLE_ASSET_DEPOSIT_EVENT => serde_json::from_value::(data).ok()?.amount, + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionPayload { + pub function: Option, + #[serde(default)] + pub type_arguments: Vec, + #[serde(default)] + pub arguments: Vec, + #[serde(rename = "type")] + pub payload_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionSignature { + #[serde(rename = "type")] + pub signature_type: String, + pub public_key: Option, + pub signature: Option, +} + +impl TransactionSignature { + pub fn no_account() -> Self { + TransactionSignature { + signature_type: NO_ACCOUNT_SIGNATURE_TYPE.to_string(), + public_key: None, + signature: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateTransactionQuery { + pub estimate_max_gas_amount: bool, + pub estimate_gas_unit_price: bool, + pub estimate_prioritized_gas_unit_price: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionSimulation { + pub expiration_timestamp_secs: String, + pub gas_unit_price: String, + pub max_gas_amount: String, + pub payload: TransactionPayload, + pub sender: String, + pub sequence_number: String, + pub signature: TransactionSignature, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionResponse { + pub hash: Option, + pub message: Option, + pub error_code: Option, + pub vm_error_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmitTransactionBcsRequest { + pub bcs: String, + #[serde(rename = "bcsEncoding")] + pub bcs_encoding: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionBroadcast { + pub hash: String, +} diff --git a/core/crates/gem_aptos/src/move/mod.rs b/core/crates/gem_aptos/src/move/mod.rs new file mode 100644 index 0000000000..1e3337b088 --- /dev/null +++ b/core/crates/gem_aptos/src/move/mod.rs @@ -0,0 +1,6 @@ +mod parser; +mod types; +mod values; + +pub(crate) use parser::{encode_argument, infer_type_tags, parse_function_id, parse_type_tag}; +pub use types::{EntryFunction, ModuleId, StructTag, TypeTag}; diff --git a/core/crates/gem_aptos/src/move/parser.rs b/core/crates/gem_aptos/src/move/parser.rs new file mode 100644 index 0000000000..43058ce5fc --- /dev/null +++ b/core/crates/gem_aptos/src/move/parser.rs @@ -0,0 +1,360 @@ +use crate::AccountAddress; +use num_bigint::BigUint; +use num_traits::ToPrimitive; +use primitives::{SignerError, decode_hex}; +use serde::Deserialize; +use serde_json::Value; + +use super::types::{ModuleId, StructTag, TypeTag}; +use super::values::MoveValue; + +const OPTION_MODULE: &str = "option"; +const OPTION_STRUCT: &str = "Option"; + +#[derive(Deserialize)] +#[serde(untagged)] +enum NumericInput { + Number(serde_json::Number), + String(String), +} + +impl NumericInput { + fn as_string(&self) -> String { + match self { + NumericInput::Number(number) => number.to_string(), + NumericInput::String(value) => value.clone(), + } + } +} + +pub(crate) fn parse_function_id(function_id: &str) -> Result<(ModuleId, String), SignerError> { + let parts: Vec<&str> = function_id.split("::").collect(); + if parts.len() != 3 { + return Err(SignerError::InvalidInput("Invalid Aptos function id".to_string())); + } + let address = AccountAddress::from_hex(parts[0])?; + let module = parts[1].to_string(); + let function = parts[2].to_string(); + + Ok((ModuleId { address, name: module }, function)) +} + +pub(crate) fn parse_type_tag(value: &str) -> Result { + let trimmed = value.trim(); + match trimmed { + "bool" => Ok(TypeTag::Bool), + "u8" => Ok(TypeTag::U8), + "u16" => Ok(TypeTag::U16), + "u32" => Ok(TypeTag::U32), + "u64" => Ok(TypeTag::U64), + "u128" => Ok(TypeTag::U128), + "u256" => Ok(TypeTag::U256), + "address" => Ok(TypeTag::Address), + "signer" | "&signer" => Ok(TypeTag::Signer), + _ if trimmed.starts_with("vector<") && trimmed.ends_with('>') => { + let inner = &trimmed["vector<".len()..trimmed.len() - 1]; + Ok(TypeTag::Vector(Box::new(parse_type_tag(inner)?))) + } + _ => parse_struct_tag(trimmed).map(|tag| TypeTag::Struct(Box::new(tag))), + } +} + +pub(crate) fn infer_type_tags(arguments: &[Value]) -> Result, SignerError> { + arguments.iter().map(infer_type_tag).collect() +} + +pub(crate) fn encode_argument(value: &Value, arg_type: &TypeTag) -> Result, SignerError> { + let move_value = parse_move_value(value, arg_type)?; + bcs::to_bytes(&move_value).map_err(|err| SignerError::InvalidInput(format!("Failed to encode Aptos argument: {err}"))) +} + +fn parse_struct_tag(value: &str) -> Result { + let (base, args) = if let Some(index) = find_top_level_char(value, '<') { + if !value.ends_with('>') { + return Err(SignerError::InvalidInput("Invalid Aptos struct tag".to_string())); + } + (&value[..index], Some(&value[index + 1..value.len() - 1])) + } else { + (value, None) + }; + + let parts: Vec<&str> = base.split("::").collect(); + if parts.len() != 3 { + return Err(SignerError::InvalidInput("Invalid Aptos struct tag".to_string())); + } + + let address = AccountAddress::from_hex(parts[0])?; + let module = parts[1].to_string(); + let name = parts[2].to_string(); + let type_args = if let Some(args) = args { + split_type_args(args)?.into_iter().map(|arg| parse_type_tag(&arg)).collect::, _>>()? + } else { + Vec::new() + }; + + Ok(StructTag { address, module, name, type_args }) +} + +fn split_type_args(input: &str) -> Result, SignerError> { + let mut args = Vec::new(); + let mut depth = 0u32; + let mut start = 0usize; + + for (index, ch) in input.char_indices() { + match ch { + '<' => depth += 1, + '>' => { + if depth == 0 { + return Err(SignerError::InvalidInput("Invalid Aptos type arguments".to_string())); + } + depth -= 1; + } + ',' if depth == 0 => { + let arg = input[start..index].trim(); + if !arg.is_empty() { + args.push(arg.to_string()); + } + start = index + 1; + } + _ => {} + } + } + + let last = input[start..].trim(); + if !last.is_empty() { + args.push(last.to_string()); + } + + Ok(args) +} + +fn find_top_level_char(input: &str, target: char) -> Option { + let mut depth = 0u32; + for (index, ch) in input.char_indices() { + if depth == 0 && ch == target { + return Some(index); + } + match ch { + '<' => depth += 1, + '>' => depth = depth.saturating_sub(1), + _ => {} + } + } + None +} + +fn infer_type_tag(value: &Value) -> Result { + match value { + Value::Bool(_) => Ok(TypeTag::Bool), + Value::Number(_) => Ok(TypeTag::U64), + Value::String(text) => { + if text.trim().starts_with("0x") { + Ok(TypeTag::Address) + } else { + Ok(TypeTag::U64) + } + } + Value::Array(values) => infer_vector_type(values), + Value::Null => Err(SignerError::InvalidInput("Cannot infer Aptos type from null".to_string())), + Value::Object(_) => Err(SignerError::InvalidInput("Unsupported Aptos object argument".to_string())), + } +} + +fn infer_vector_type(values: &[Value]) -> Result { + if values.is_empty() { + return Ok(TypeTag::Vector(Box::new(TypeTag::U8))); + } + + if values.iter().all(is_u8_value) { + return Ok(TypeTag::Vector(Box::new(TypeTag::U8))); + } + + if values.iter().all(is_address_value) { + return Ok(TypeTag::Vector(Box::new(TypeTag::Address))); + } + + Ok(TypeTag::Vector(Box::new(TypeTag::U64))) +} + +fn is_u8_value(value: &Value) -> bool { + match value { + Value::Number(number) => number.as_u64().map(|num| num <= u8::MAX as u64).unwrap_or(false), + Value::String(text) => parse_u8_from_str(text).is_ok(), + _ => false, + } +} + +fn is_address_value(value: &Value) -> bool { + match value { + Value::String(text) => text.trim().starts_with("0x"), + _ => false, + } +} + +fn parse_move_value(value: &Value, arg_type: &TypeTag) -> Result { + match arg_type { + TypeTag::Bool => Ok(MoveValue::Bool(parse_bool(value)?)), + TypeTag::U8 => Ok(MoveValue::U8(parse_u8(value)?)), + TypeTag::U16 => Ok(MoveValue::U16(parse_u16(value)?)), + TypeTag::U32 => Ok(MoveValue::U32(parse_u32(value)?)), + TypeTag::U64 => Ok(MoveValue::U64(parse_u64(value)?)), + TypeTag::U128 => Ok(MoveValue::U128(parse_u128(value)?)), + TypeTag::U256 => Ok(MoveValue::U256(parse_u256(value)?)), + TypeTag::Address => Ok(MoveValue::Address(parse_address(value)?)), + TypeTag::Signer => Ok(MoveValue::Signer(parse_address(value)?)), + TypeTag::Vector(inner) => parse_vector(value, inner), + TypeTag::Struct(tag) => parse_struct(value, tag), + } +} + +fn parse_struct(value: &Value, tag: &StructTag) -> Result { + if is_option_struct(tag) { + let inner = tag + .type_args + .first() + .ok_or_else(|| SignerError::InvalidInput("Option type missing inner type".to_string()))?; + if value.is_null() { + return Ok(MoveValue::Vector(Vec::new())); + } + let inner_value = parse_move_value(value, inner)?; + return Ok(MoveValue::Vector(vec![inner_value])); + } + + Err(SignerError::InvalidInput("Unsupported Aptos struct argument".to_string())) +} + +fn is_option_struct(tag: &StructTag) -> bool { + tag.address == AccountAddress::one() && tag.module == OPTION_MODULE && tag.name == OPTION_STRUCT +} + +fn parse_vector(value: &Value, inner: &TypeTag) -> Result { + match value { + Value::Array(values) => { + let parsed = values.iter().map(|entry| parse_move_value(entry, inner)).collect::, _>>()?; + Ok(MoveValue::Vector(parsed)) + } + Value::String(text) if matches!(inner, TypeTag::U8) => { + let bytes = parse_hex_bytes(text)?; + let parsed = bytes.into_iter().map(MoveValue::U8).collect(); + Ok(MoveValue::Vector(parsed)) + } + _ => Err(SignerError::InvalidInput("Invalid Aptos vector argument".to_string())), + } +} + +fn parse_hex_bytes(value: &str) -> Result, SignerError> { + Ok(decode_hex(value)?) +} + +fn parse_address(value: &Value) -> Result { + match value { + Value::String(text) => AccountAddress::from_hex(text), + Value::Number(number) => AccountAddress::from_hex(&number.to_string()), + _ => Err(SignerError::InvalidInput("Invalid Aptos address argument".to_string())), + } +} + +fn parse_bool(value: &Value) -> Result { + match value { + Value::Bool(value) => Ok(*value), + Value::Number(number) => Ok(number.as_u64().unwrap_or(0) != 0), + Value::String(text) => match text.trim().to_lowercase().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(SignerError::InvalidInput("Invalid Aptos bool argument".to_string())), + }, + _ => Err(SignerError::InvalidInput("Invalid Aptos bool argument".to_string())), + } +} + +fn parse_numeric_string(value: &Value, label: &str) -> Result { + let input: NumericInput = serde_json::from_value(value.clone()).map_err(|_| SignerError::InvalidInput(format!("Invalid Aptos {label} argument")))?; + Ok(input.as_string()) +} + +fn parse_u8(value: &Value) -> Result { + let text = parse_numeric_string(value, "u8")?; + parse_u8_from_str(&text) +} + +fn parse_u8_from_str(text: &str) -> Result { + if text.trim().starts_with("0x") { + let bytes = decode_hex(text)?; + if bytes.len() != 1 { + return Err(SignerError::InvalidInput("Invalid Aptos u8 argument".to_string())); + } + Ok(bytes[0]) + } else { + text.trim().parse::().map_err(|_| SignerError::InvalidInput("Invalid Aptos u8 argument".to_string())) + } +} + +fn parse_u16(value: &Value) -> Result { + let text = parse_numeric_string(value, "u16")?; + parse_unsigned_from_str::(&text, "u16") +} + +fn parse_u32(value: &Value) -> Result { + let text = parse_numeric_string(value, "u32")?; + parse_unsigned_from_str::(&text, "u32") +} + +fn parse_u64(value: &Value) -> Result { + let text = parse_numeric_string(value, "u64")?; + parse_unsigned_from_str::(&text, "u64") +} + +fn parse_u128(value: &Value) -> Result { + let text = parse_numeric_string(value, "u128")?; + parse_unsigned_from_str::(&text, "u128") +} + +fn parse_u256(value: &Value) -> Result<[u8; 32], SignerError> { + let text = parse_numeric_string(value, "u256")?; + let value = parse_big_uint_from_str(&text, "u256")?; + let mut bytes = value.to_bytes_le(); + if bytes.len() > 32 { + return Err(SignerError::InvalidInput("Aptos u256 argument too large".to_string())); + } + bytes.resize(32, 0u8); + let mut output = [0u8; 32]; + output.copy_from_slice(&bytes); + Ok(output) +} + +fn parse_unsigned_from_str(text: &str, label: &str) -> Result +where + T: TryFrom, +{ + let value = parse_big_uint_from_str(text, label)? + .to_u128() + .ok_or_else(|| SignerError::InvalidInput(format!("Invalid Aptos {label} argument")))?; + T::try_from(value).map_err(|_| SignerError::InvalidInput(format!("Invalid Aptos {label} argument"))) +} + +fn parse_big_uint_from_str(text: &str, label: &str) -> Result { + let trimmed = text.trim(); + if trimmed.starts_with("0x") { + let bytes = decode_hex(trimmed)?; + Ok(BigUint::from_bytes_be(&bytes)) + } else { + BigUint::parse_bytes(trimmed.as_bytes(), 10).ok_or_else(|| SignerError::InvalidInput(format!("Invalid Aptos {label} argument"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_vector_u8_hex() { + let value = Value::String("0x0a0b".to_string()); + let parsed = parse_vector(&value, &TypeTag::U8).unwrap(); + match parsed { + MoveValue::Vector(entries) => { + assert_eq!(entries.len(), 2); + } + _ => panic!("Expected vector"), + } + } +} diff --git a/core/crates/gem_aptos/src/move/types.rs b/core/crates/gem_aptos/src/move/types.rs new file mode 100644 index 0000000000..9fb3aca9e5 --- /dev/null +++ b/core/crates/gem_aptos/src/move/types.rs @@ -0,0 +1,39 @@ +use crate::AccountAddress; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModuleId { + pub address: AccountAddress, + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StructTag { + pub address: AccountAddress, + pub module: String, + pub name: String, + pub type_args: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TypeTag { + Bool, + U8, + U64, + U128, + Address, + Signer, + Vector(Box), + Struct(Box), + U16, + U32, + U256, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFunction { + pub module: ModuleId, + pub function: String, + pub ty_args: Vec, + pub args: Vec>, +} diff --git a/core/crates/gem_aptos/src/move/values.rs b/core/crates/gem_aptos/src/move/values.rs new file mode 100644 index 0000000000..3a04fab850 --- /dev/null +++ b/core/crates/gem_aptos/src/move/values.rs @@ -0,0 +1,43 @@ +use crate::AccountAddress; +use serde::Serialize; +use serde::ser::{SerializeSeq, Serializer}; + +#[derive(Clone, Debug)] +pub(crate) enum MoveValue { + Bool(bool), + U8(u8), + U16(u16), + U32(u32), + U64(u64), + U128(u128), + U256([u8; 32]), + Address(AccountAddress), + Signer(AccountAddress), + Vector(Vec), +} + +impl Serialize for MoveValue { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + MoveValue::Bool(value) => serializer.serialize_bool(*value), + MoveValue::U8(value) => serializer.serialize_u8(*value), + MoveValue::U16(value) => serializer.serialize_u16(*value), + MoveValue::U32(value) => serializer.serialize_u32(*value), + MoveValue::U64(value) => serializer.serialize_u64(*value), + MoveValue::U128(value) => serializer.serialize_u128(*value), + MoveValue::U256(value) => value.serialize(serializer), + MoveValue::Address(value) => value.serialize(serializer), + MoveValue::Signer(value) => value.serialize(serializer), + MoveValue::Vector(values) => { + let mut seq = serializer.serialize_seq(Some(values.len()))?; + for value in values { + seq.serialize_element(value)?; + } + seq.end() + } + } + } +} diff --git a/core/crates/gem_aptos/src/provider/balances.rs b/core/crates/gem_aptos/src/provider/balances.rs new file mode 100644 index 0000000000..a63b1745cf --- /dev/null +++ b/core/crates/gem_aptos/src/provider/balances.rs @@ -0,0 +1,111 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use futures::future::try_join_all; +use std::{error::Error, sync::Arc}; + +use gem_client::Client; +use primitives::AssetBalance; + +use super::balances_mapper::{map_balance_staking, map_balance_tokens, map_native_balance}; +use crate::{APTOS_NATIVE_COIN, KNOWN_VALIDATOR_POOL, rpc::client::AptosClient}; + +#[async_trait] +impl ChainBalances for AptosClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let balance = self.get_account_balance(&address, APTOS_NATIVE_COIN).await?; + Ok(map_native_balance(&num_bigint::BigUint::from(balance), self.get_chain())) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let address_arc = Arc::new(address); + let futures = token_ids.into_iter().map(|token_id| { + let address = address_arc.clone(); + let client = self; + async move { + let balance = client.get_account_balance(&address, &token_id).await?; + Ok::<(String, u64), Box>((token_id, balance)) + } + }); + + let results = try_join_all(futures).await?; + + Ok(map_balance_tokens(results, self.get_chain())) + } + + async fn get_balance_staking(&self, address: String) -> Result, Box> { + let stake = self.get_delegation_pool_stake(KNOWN_VALIDATOR_POOL, &address).await?; + Ok(Some(map_balance_staking(stake, self.get_chain()))) + } + + async fn get_balance_assets(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{TEST_ADDRESS, TEST_ADDRESS_STAKING, create_aptos_test_client}; + use chain_traits::ChainBalances; + use num_bigint::BigUint; + use primitives::Chain; + + #[tokio::test] + async fn test_aptos_get_balance_coin() -> Result<(), Box> { + let client = create_aptos_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + assert_eq!(balance.asset_id.chain, Chain::Aptos); + println!("Balance: {:?}", balance); + Ok(()) + } + + #[tokio::test] + async fn test_aptos_get_balance_tokens() -> Result<(), Box> { + let client = create_aptos_test_client(); + let token_ids = vec![ + "0x159df6b7689437016108a019fd5bef736bac692b6d4a1f10c941f6fbb9a74ca6::oft::CakeOFT".to_string(), // CakeOFT + ]; + + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids.clone()).await?; + + assert_eq!(balances.len(), token_ids.len()); + for (i, balance) in balances.iter().enumerate() { + assert_eq!(balance.asset_id.chain, Chain::Aptos); + assert_eq!(balance.asset_id.token_id, Some(token_ids[i].clone())); + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + println!("Token balance: {:?}", balance); + } + + Ok(()) + } + + #[tokio::test] + async fn test_aptos_get_balance_assets() -> Result<(), Box> { + let client = create_aptos_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + assert_eq!(assets.len(), 0); + Ok(()) + } + + #[tokio::test] + async fn test_aptos_get_balance_staking() -> Result<(), Box> { + let client = create_aptos_test_client(); + let balance = client.get_balance_staking(TEST_ADDRESS_STAKING.to_string()).await?; + + assert!(balance.is_some()); + + if let Some(balance) = balance { + assert_eq!(balance.asset_id.chain, Chain::Aptos); + assert_eq!(balance.asset_id.token_id, None); + println!( + "Staking balance: staked={}, pending={}, rewards={}", + balance.balance.staked, balance.balance.pending, balance.balance.rewards + ); + + assert!(balance.balance.staked > BigUint::from(0u32)); + } + + Ok(()) + } +} diff --git a/core/crates/gem_aptos/src/provider/balances_mapper.rs b/core/crates/gem_aptos/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..3f578361ee --- /dev/null +++ b/core/crates/gem_aptos/src/provider/balances_mapper.rs @@ -0,0 +1,105 @@ +use crate::models::{DelegationPoolStake, Resource, ResourceData}; +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Balance, Chain}; + +pub fn map_native_balance(balance: &BigUint, chain: Chain) -> AssetBalance { + let asset_id = AssetId::from_chain(chain); + AssetBalance::new(asset_id, balance.clone()) +} + +pub fn map_balance_tokens(balances: Vec<(String, u64)>, chain: Chain) -> Vec { + balances + .into_iter() + .map(|(token_id, balance)| { + let asset_id = AssetId::from_token(chain, &token_id); + AssetBalance::new(asset_id, BigUint::from(balance)) + }) + .collect() +} + +pub fn map_token_balances(resources: &[Resource], token_ids: Vec, chain: Chain) -> Vec { + token_ids + .into_iter() + .map(|token_id| { + let coin_store_type = format!("0x1::coin::CoinStore<{}>", token_id); + let balance = resources + .iter() + .find(|r| r.type_field == coin_store_type) + .and_then(|resource| resource.data.coin.as_ref()) + .map(|coin_data| coin_data.value.clone()) + .unwrap_or_else(|| BigUint::from(0u32)); + + AssetBalance::new_with_active(AssetId::from_token(chain, &token_id), Balance::coin_balance(balance), true) + }) + .collect() +} + +pub fn map_balance_staking(stake: DelegationPoolStake, chain: Chain) -> AssetBalance { + let staked = stake.active; + let pending = &stake.pending_inactive + &stake.inactive; + let balance = Balance::stake_balance(staked, pending, None); + + AssetBalance::new_balance(AssetId::from_chain(chain), balance) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + APTOS_NATIVE_COIN, + models::{CoinData, Resource, ResourceData}, + }; + + #[test] + fn test_map_native_balance() { + let balance = BigUint::from(1000000_u64); + let result = map_native_balance(&balance, Chain::Aptos); + + assert_eq!(result.balance.available, BigUint::from(1000000_u64)); + assert_eq!(result.asset_id.chain, Chain::Aptos); + assert_eq!(result.asset_id.token_id, None); + } + + #[test] + fn test_map_balance_tokens() { + let balances = vec![ + ("0x159df6b7689437016108a019fd5bef736bac692b6d4a1f10c941f6fbb9a74ca6::oft::CakeOFT".to_string(), 25379808), + (APTOS_NATIVE_COIN.to_string(), 1000000), + ]; + + let result = map_balance_tokens(balances, Chain::Aptos); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].balance.available, BigUint::from(25379808_u64)); + assert_eq!(result[0].asset_id.chain, Chain::Aptos); + assert_eq!( + result[0].asset_id.token_id, + Some("0x159df6b7689437016108a019fd5bef736bac692b6d4a1f10c941f6fbb9a74ca6::oft::CakeOFT".to_string()) + ); + + assert_eq!(result[1].balance.available, BigUint::from(1000000_u64)); + assert_eq!(result[1].asset_id.chain, Chain::Aptos); + assert_eq!(result[1].asset_id.token_id, Some(APTOS_NATIVE_COIN.to_string())); + } + + #[test] + fn test_map_token_balances() { + let coin_data = CoinData { + value: BigUint::from(1000000_u64), + }; + + let resource = Resource { + type_field: "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>".to_string(), + data: ResourceData { coin: Some(coin_data) }, + }; + + let resources = vec![resource]; + let token_ids = vec![APTOS_NATIVE_COIN.to_string()]; + + let result = map_token_balances(&resources, token_ids, Chain::Aptos); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].balance.available, BigUint::from(1000000_u64)); + assert!(result[0].is_active); + } +} diff --git a/core/crates/gem_aptos/src/provider/mod.rs b/core/crates/gem_aptos/src/provider/mod.rs new file mode 100644 index 0000000000..59ad1bdaa0 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/mod.rs @@ -0,0 +1,36 @@ +use chain_traits::{ChainProvider, ChainTraits}; +use gem_client::Client; +use primitives::Chain; + +use crate::rpc::client::AptosClient; + +pub mod balances; +pub mod balances_mapper; +pub mod payload_builder; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod staking; +pub mod staking_mapper; +pub mod state; +pub mod state_mapper; +#[cfg(test)] +pub mod testkit; +pub mod token; +pub mod token_mapper; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; + +impl ChainTraits for AptosClient {} + +impl ChainProvider for AptosClient { + fn get_chain(&self) -> Chain { + self.chain + } +} diff --git a/core/crates/gem_aptos/src/provider/payload_builder.rs b/core/crates/gem_aptos/src/provider/payload_builder.rs new file mode 100644 index 0000000000..bf1102a8a2 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/payload_builder.rs @@ -0,0 +1,129 @@ +use serde_json::json; + +use crate::models::TransactionPayload; +use crate::token_id::is_fungible_asset_token_id; +use crate::{APTOS_TRANSFER_FUNCTION, DELEGATION_POOL_ADD_STAKE_FUNCTION, DELEGATION_POOL_UNLOCK_FUNCTION, DELEGATION_POOL_WITHDRAW_FUNCTION, ENTRY_FUNCTION_PAYLOAD_TYPE}; + +fn build_payload(function: &str, first_argument: &str, amount: &str) -> TransactionPayload { + TransactionPayload { + function: Some(function.to_string()), + type_arguments: vec![], + arguments: vec![json!(first_argument), json!(amount)], + payload_type: ENTRY_FUNCTION_PAYLOAD_TYPE.to_string(), + } +} + +pub fn build_stake_transaction_payload(pool_address: &str, amount: &str) -> TransactionPayload { + build_payload(DELEGATION_POOL_ADD_STAKE_FUNCTION, pool_address, amount) +} + +pub fn build_unstake_transaction_payload(pool_address: &str, amount: &str) -> TransactionPayload { + build_payload(DELEGATION_POOL_UNLOCK_FUNCTION, pool_address, amount) +} + +pub fn build_withdraw_transaction_payload(pool_address: &str, amount: &str) -> TransactionPayload { + build_payload(DELEGATION_POOL_WITHDRAW_FUNCTION, pool_address, amount) +} + +pub fn build_transfer_transaction_payload(recipient: &str, amount: &str) -> TransactionPayload { + build_payload(APTOS_TRANSFER_FUNCTION, recipient, amount) +} + +pub fn build_fungible_transfer_transaction_payload(token_id: &str, recipient: &str, amount: &str) -> TransactionPayload { + TransactionPayload { + function: Some("0x1::primary_fungible_store::transfer".to_string()), + type_arguments: vec!["0x1::object::ObjectCore".to_string()], + arguments: vec![json!(token_id), json!(recipient), json!(amount)], + payload_type: ENTRY_FUNCTION_PAYLOAD_TYPE.to_string(), + } +} + +pub fn build_token_transfer_transaction_payload(token_id: &str, recipient: &str, amount: &str) -> Result { + if !is_fungible_asset_token_id(token_id) { + return Err("Invalid Aptos token ID format"); + } + + Ok(build_fungible_transfer_transaction_payload(token_id, recipient, amount)) +} + +pub fn build_stake_payload_data(pool_address: &str, amount: &str) -> String { + serde_json::to_string(&build_stake_transaction_payload(pool_address, amount)).unwrap() +} + +pub fn build_unstake_payload_data(pool_address: &str, amount: &str) -> String { + serde_json::to_string(&build_unstake_transaction_payload(pool_address, amount)).unwrap() +} + +pub fn build_withdraw_payload_data(pool_address: &str, amount: &str) -> String { + serde_json::to_string(&build_withdraw_transaction_payload(pool_address, amount)).unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + const TEST_POOL_ADDRESS: &str = "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e"; + + #[test] + fn test_build_stake_transaction_payload() { + let payload = build_stake_transaction_payload(TEST_POOL_ADDRESS, "100000000"); + let result: Value = serde_json::to_value(&payload).unwrap(); + let expected = serde_json::json!({ + "function": "0x1::delegation_pool::add_stake", + "type_arguments": [], + "arguments": [TEST_POOL_ADDRESS, "100000000"], + "type": "entry_function_payload" + }); + + assert_eq!(result, expected); + } + + #[test] + fn test_build_unstake_transaction_payload() { + let payload = build_unstake_transaction_payload(TEST_POOL_ADDRESS, "50000000"); + let result: Value = serde_json::to_value(&payload).unwrap(); + let expected = serde_json::json!({ + "function": "0x1::delegation_pool::unlock", + "type_arguments": [], + "arguments": [TEST_POOL_ADDRESS, "50000000"], + "type": "entry_function_payload" + }); + + assert_eq!(result, expected); + } + + #[test] + fn test_build_withdraw_transaction_payload() { + let payload = build_withdraw_transaction_payload(TEST_POOL_ADDRESS, "1102185008"); + let result: Value = serde_json::to_value(&payload).unwrap(); + let expected = serde_json::json!({ + "function": "0x1::delegation_pool::withdraw", + "type_arguments": [], + "arguments": [TEST_POOL_ADDRESS, "1102185008"], + "type": "entry_function_payload" + }); + + assert_eq!(result, expected); + } + + #[test] + fn test_build_token_transfer_transaction_payload_fungible_asset() { + let payload = build_token_transfer_transaction_payload("0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", TEST_POOL_ADDRESS, "1").unwrap(); + let result: Value = serde_json::to_value(&payload).unwrap(); + let expected = serde_json::json!({ + "function": "0x1::primary_fungible_store::transfer", + "type_arguments": ["0x1::object::ObjectCore"], + "arguments": ["0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", TEST_POOL_ADDRESS, "1"], + "type": "entry_function_payload" + }); + + assert_eq!(result, expected); + } + + #[test] + fn test_build_token_transfer_transaction_payload_invalid() { + let err = build_token_transfer_transaction_payload("invalid", TEST_POOL_ADDRESS, "1").unwrap_err(); + assert_eq!(err, "Invalid Aptos token ID format"); + } +} diff --git a/core/crates/gem_aptos/src/provider/preload.rs b/core/crates/gem_aptos/src/provider/preload.rs new file mode 100644 index 0000000000..913b9c3d00 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/preload.rs @@ -0,0 +1,102 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use std::error::Error; + +use gem_client::Client; +use primitives::{ + FeePriority, FeeRate, GasPriceType, StakeType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, + TransactionPreloadInput, +}; + +use super::preload_mapper::map_transaction_preload; +use crate::provider::payload_builder::{build_stake_payload_data, build_unstake_payload_data, build_withdraw_payload_data}; +use crate::rpc::client::AptosClient; + +#[async_trait] +impl ChainTransactionLoad for AptosClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let account = self.get_account(&input.sender_address).await?; + map_transaction_preload(&account) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let gas_limit = self.calculate_gas_limit(&input).await?; + let fee = TransactionFee::calculate(gas_limit, &input.gas_price); + + let data = match &input.input_type { + TransactionInputType::Stake(_, stake_type) => match stake_type { + StakeType::Stake(validator) => Some(build_stake_payload_data(&validator.id, &input.value)), + StakeType::Unstake(delegation) => Some(build_unstake_payload_data(&delegation.validator.id, &input.value)), + StakeType::Withdraw(delegation) => Some(build_withdraw_payload_data(&delegation.validator.id, &input.value)), + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => None, + }, + _ => None, + }; + + let sequence = input.metadata.get_sequence()?; + let metadata = TransactionLoadMetadata::Aptos { sequence, data }; + + Ok(TransactionLoadData { fee, metadata }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + let gas_fee = self.get_gas_price().await?; + + Ok(vec![ + FeeRate::new(FeePriority::Slow, GasPriceType::regular(gas_fee.deprioritized_gas_estimate)), + FeeRate::new(FeePriority::Normal, GasPriceType::regular(gas_fee.gas_estimate)), + FeeRate::new(FeePriority::Fast, GasPriceType::regular(gas_fee.prioritized_gas_estimate)), + ]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::KNOWN_VALIDATOR_POOL; + use crate::provider::testkit::{TEST_ADDRESS_STAKING, create_aptos_test_client}; + use num_bigint::BigInt; + use primitives::{Asset, Chain, DelegationValidator}; + use serde_json::Value; + + #[tokio::test] + async fn test_aptos_get_transaction_load_stake() -> Result<(), Box> { + let client = create_aptos_test_client(); + let metadata = client + .get_transaction_preload(TransactionPreloadInput { + input_type: TransactionInputType::Stake( + Asset::from_chain(Chain::Aptos), + StakeType::Stake(DelegationValidator::stake(Chain::Aptos, KNOWN_VALIDATOR_POOL.to_string(), String::new(), true, 0.0, 0.0)), + ), + sender_address: TEST_ADDRESS_STAKING.to_string(), + destination_address: KNOWN_VALIDATOR_POOL.to_string(), + }) + .await?; + + let load = client + .get_transaction_load(TransactionLoadInput { + input_type: TransactionInputType::Stake( + Asset::from_chain(Chain::Aptos), + StakeType::Stake(DelegationValidator::stake(Chain::Aptos, KNOWN_VALIDATOR_POOL.to_string(), String::new(), true, 0.0, 0.0)), + ), + sender_address: TEST_ADDRESS_STAKING.to_string(), + destination_address: KNOWN_VALIDATOR_POOL.to_string(), + value: "1100000000".to_string(), + gas_price: GasPriceType::regular(BigInt::from(100u64)), + memo: None, + is_max_value: false, + metadata, + }) + .await?; + + let TransactionLoadMetadata::Aptos { data: Some(data), .. } = load.metadata else { + panic!("Expected Aptos transaction load metadata with payload"); + }; + + let payload: Value = serde_json::from_str(&data).unwrap(); + assert_eq!(payload["function"], "0x1::delegation_pool::add_stake"); + assert!(load.fee.gas_limit > BigInt::from(0u32)); + + Ok(()) + } +} diff --git a/core/crates/gem_aptos/src/provider/preload_mapper.rs b/core/crates/gem_aptos/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..b2337067de --- /dev/null +++ b/core/crates/gem_aptos/src/provider/preload_mapper.rs @@ -0,0 +1,27 @@ +use crate::models::Account; +use primitives::TransactionLoadMetadata; +use std::error::Error; + +pub fn map_transaction_preload(account: &Account) -> Result> { + Ok(TransactionLoadMetadata::Aptos { + sequence: account.sequence_number, + data: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Account; + + #[test] + fn test_transaction_preload() { + let account = Account { sequence_number: 42 }; + + let result = map_transaction_preload(&account).unwrap(); + match result { + TransactionLoadMetadata::Aptos { sequence, .. } => assert_eq!(sequence, 42), + _ => panic!("Expected Aptos metadata"), + } + } +} diff --git a/core/crates/gem_aptos/src/provider/request_classifier.rs b/core/crates/gem_aptos/src/provider/request_classifier.rs new file mode 100644 index 0000000000..22c474caba --- /dev/null +++ b/core/crates/gem_aptos/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/v1/transactions") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_aptos/src/provider/staking.rs b/core/crates/gem_aptos/src/provider/staking.rs new file mode 100644 index 0000000000..94af154f80 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/staking.rs @@ -0,0 +1,90 @@ +use async_trait::async_trait; +use chain_traits::ChainStaking; +use futures::try_join; +use std::error::Error; + +use gem_client::Client; +use primitives::{DelegationBase, DelegationValidator}; + +use super::staking_mapper; +use crate::{ + KNOWN_VALIDATOR_POOL, + provider::staking_mapper::{calculate_apy, map_validators}, + rpc::client::AptosClient, +}; + +#[async_trait] +impl ChainStaking for AptosClient { + async fn get_staking_apy(&self) -> Result, Box> { + let staking_config = self.get_staking_config().await?; + Ok(Some(calculate_apy(&staking_config))) + } + + async fn get_staking_validators(&self, apy: Option) -> Result, Box> { + let (validator_set, commission) = try_join!(self.get_validator_set(), self.get_operator_commission_percentage(KNOWN_VALIDATOR_POOL))?; + + Ok(map_validators(validator_set, apy.unwrap_or(0.0), KNOWN_VALIDATOR_POOL, commission)) + } + + async fn get_staking_delegations(&self, address: String) -> Result, Box> { + let (delegation, lockup_secs) = try_join!( + self.get_delegation_for_pool(&address, KNOWN_VALIDATOR_POOL), + self.get_stake_lockup_secs(KNOWN_VALIDATOR_POOL) + )?; + Ok(staking_mapper::map_delegations(vec![delegation], lockup_secs)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, create_aptos_test_client}; + + #[tokio::test] + async fn test_aptos_get_staking_apy() -> Result<(), Box> { + let client = create_aptos_test_client(); + let apy = client.get_staking_apy().await?; + assert!(apy.is_some()); + + println!("Aptos APY: {:?}", apy); + + Ok(()) + } + + #[tokio::test] + async fn test_aptos_get_staking_validators() -> Result<(), Box> { + let client = create_aptos_test_client(); + let validators = client.get_staking_validators(Some(5.0)).await?; + + println!("{:?}", validators); + + assert!(!validators.is_empty()); + + if let Some(first) = validators.first() { + assert!(first.commission > 0.0); + } + + println!("Found {} validators", validators.len()); + + Ok(()) + } + + #[tokio::test] + async fn test_aptos_get_staking_delegations() -> Result<(), Box> { + let client = create_aptos_test_client(); + let delegations = client.get_staking_delegations(TEST_ADDRESS.to_string()).await?; + + println!("Delegations: {:?}", delegations); + + assert!(!delegations.is_empty(), "Expected at least one delegation"); + + for delegation in &delegations { + println!("State: {:?}, Balance: {}, Validator: {}", delegation.state, delegation.balance, delegation.validator_id); + if let Some(date) = delegation.completion_date { + println!("Completion date: {}", date); + } + } + + Ok(()) + } +} diff --git a/core/crates/gem_aptos/src/provider/staking_mapper.rs b/core/crates/gem_aptos/src/provider/staking_mapper.rs new file mode 100644 index 0000000000..54f71f3acb --- /dev/null +++ b/core/crates/gem_aptos/src/provider/staking_mapper.rs @@ -0,0 +1,150 @@ +use chrono::{DateTime, Utc}; +use num_bigint::BigUint; +use primitives::{Chain, DelegationBase, DelegationState, DelegationValidator}; + +use crate::models::{DelegationPoolStake, StakingConfig, ValidatorInfo, ValidatorSet}; + +pub fn map_validators(validator_set: ValidatorSet, apy: f64, pool_address: &str, commission: f64) -> Vec { + validator_set + .active_validators + .iter() + .filter(|v| v.addr == pool_address) + .map(|v| map_validator(v, apy, commission, true)) + .collect() +} + +pub fn map_validator(validator: &ValidatorInfo, apy: f64, commission: f64, is_active: bool) -> DelegationValidator { + DelegationValidator::stake(Chain::Aptos, validator.addr.clone(), "".to_string(), is_active, commission, apy) +} + +fn map_delegation(asset_id: &primitives::AssetId, state: DelegationState, balance: BigUint, validator_id: &str, completion_date: Option>) -> DelegationBase { + DelegationBase { + asset_id: asset_id.clone(), + state, + balance, + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date, + delegation_id: format!("{}_{}", state.as_ref().to_lowercase(), validator_id), + validator_id: validator_id.to_string(), + } +} + +pub fn map_delegations(stakes: Vec<(String, DelegationPoolStake)>, lockup_secs: u64) -> Vec { + let asset_id = Chain::Aptos.as_asset_id(); + let withdrawal_completion = DateTime::from_timestamp(lockup_secs as i64, 0); + + stakes + .into_iter() + .flat_map(|(pool_address, stake)| { + let mut delegations = Vec::new(); + + if stake.active > BigUint::from(0u32) { + delegations.push(map_delegation(&asset_id, DelegationState::Active, stake.active, &pool_address, None)); + } + + if stake.pending_inactive > BigUint::from(0u32) { + delegations.push(map_delegation( + &asset_id, + DelegationState::Deactivating, + stake.pending_inactive, + &pool_address, + withdrawal_completion, + )); + } + + if stake.inactive > BigUint::from(0u32) { + delegations.push(map_delegation(&asset_id, DelegationState::AwaitingWithdrawal, stake.inactive, &pool_address, None)); + } + + delegations + }) + .collect() +} + +pub fn calculate_apy(staking_config: &StakingConfig) -> f64 { + if staking_config.rewards_rate_denominator == 0 { + return 0.0; + } + + let epoch_rewards_rate = staking_config.rewards_rate as f64 / staking_config.rewards_rate_denominator as f64; + let epochs_per_year = 365.25 * 24.0 * 60.0 * 60.0 / 7200.0; + + epoch_rewards_rate * epochs_per_year * 100.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_stake(active: u32, inactive: u32, pending_inactive: u32) -> DelegationPoolStake { + DelegationPoolStake { + active: BigUint::from(active), + inactive: BigUint::from(inactive), + pending_inactive: BigUint::from(pending_inactive), + } + } + + fn mock_lockup_secs() -> u64 { + 1700000000 + } + + #[test] + fn test_calculate_apy() { + let config = StakingConfig { + rewards_rate: 1600000000000, + rewards_rate_denominator: 100000000000000000, + recurring_lockup_duration_secs: 1209600, + }; + + assert!((calculate_apy(&config) - 7.0128).abs() < 0.01); + } + + #[test] + fn test_calculate_apy_zero_denominator() { + let config = StakingConfig { + rewards_rate: 1600000000000000, + rewards_rate_denominator: 0, + recurring_lockup_duration_secs: 1209600, + }; + + assert_eq!(calculate_apy(&config), 0.0); + } + + #[test] + fn test_map_delegations_active() { + let delegations = map_delegations(vec![("pool".to_string(), mock_stake(1000, 0, 0))], mock_lockup_secs()); + + assert_eq!(delegations.len(), 1); + assert_eq!(delegations[0].state, DelegationState::Active); + assert_eq!(delegations[0].balance, BigUint::from(1000u32)); + assert!(delegations[0].completion_date.is_none()); + } + + #[test] + fn test_map_delegations_pending_inactive() { + let delegations = map_delegations(vec![("pool".to_string(), mock_stake(0, 0, 300))], mock_lockup_secs()); + + assert_eq!(delegations.len(), 1); + assert_eq!(delegations[0].state, DelegationState::Deactivating); + assert_eq!(delegations[0].balance, BigUint::from(300u32)); + assert!(delegations[0].completion_date.is_some()); + } + + #[test] + fn test_map_delegations_inactive() { + let delegations = map_delegations(vec![("pool".to_string(), mock_stake(0, 200, 0))], mock_lockup_secs()); + + assert_eq!(delegations.len(), 1); + assert_eq!(delegations[0].state, DelegationState::AwaitingWithdrawal); + assert_eq!(delegations[0].balance, BigUint::from(200u32)); + assert!(delegations[0].completion_date.is_none()); + } + + #[test] + fn test_map_delegations_multiple_states() { + let delegations = map_delegations(vec![("pool".to_string(), mock_stake(1000, 200, 300))], mock_lockup_secs()); + + assert_eq!(delegations.len(), 3); + } +} diff --git a/core/crates/gem_aptos/src/provider/state.rs b/core/crates/gem_aptos/src/provider/state.rs new file mode 100644 index 0000000000..90ced7f26c --- /dev/null +++ b/core/crates/gem_aptos/src/provider/state.rs @@ -0,0 +1,61 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; +use primitives::NodeSyncStatus; + +use crate::provider::state_mapper; +use crate::rpc::client::AptosClient; + +#[async_trait] +impl ChainState for AptosClient { + async fn get_chain_id(&self) -> Result> { + Ok(self.get_ledger().await?.chain_id.to_string()) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_ledger().await?.block_height) + } + + async fn get_node_status(&self) -> Result> { + let ledger = self.get_ledger().await?; + state_mapper::map_node_status(&ledger) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::create_aptos_test_client; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_aptos_get_chain_id() -> Result<(), Box> { + let client = create_aptos_test_client(); + let chain_id = client.get_chain_id().await?; + assert!(!chain_id.is_empty()); + println!("Aptos chain ID: {}", chain_id); + Ok(()) + } + + #[tokio::test] + async fn test_aptos_get_block_latest_number() -> Result<(), Box> { + let client = create_aptos_test_client(); + let latest_block = client.get_block_latest_number().await?; + assert!(latest_block > 0); + println!("Latest block: {}", latest_block); + Ok(()) + } + + #[tokio::test] + async fn test_get_node_status() -> Result<(), Box> { + let client = create_aptos_test_client(); + let node_status = client.get_node_status().await?; + + assert!(node_status.in_sync); + assert!(node_status.latest_block_number.is_some()); + assert!(node_status.latest_block_number.unwrap_or(0) > 0); + + Ok(()) + } +} diff --git a/core/crates/gem_aptos/src/provider/state_mapper.rs b/core/crates/gem_aptos/src/provider/state_mapper.rs new file mode 100644 index 0000000000..c6c21c778e --- /dev/null +++ b/core/crates/gem_aptos/src/provider/state_mapper.rs @@ -0,0 +1,27 @@ +use crate::models::Ledger; +use primitives::NodeSyncStatus; +use std::error::Error; + +pub fn map_node_status(ledger: &Ledger) -> Result> { + Ok(NodeSyncStatus::synced(ledger.block_height)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_node_status() { + let ledger = Ledger { + chain_id: 1, + block_height: 987654321, + epoch: 13156, + ledger_timestamp: 1759855099031447, + }; + let mapped = map_node_status(&ledger).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(987654321)); + assert_eq!(mapped.current_block_number, Some(987654321)); + } +} diff --git a/core/crates/gem_aptos/src/provider/testkit.rs b/core/crates/gem_aptos/src/provider/testkit.rs new file mode 100644 index 0000000000..65704106f7 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/testkit.rs @@ -0,0 +1,21 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::AptosClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "0x6a43e0034486583a30cff449c03c4d882c641b351e392096272496168240de8e"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS_STAKING: &str = "0xc95615aa095c100b18eb6eaa0f0a0f30b9cd96685118a7cbc1a2328a91ca2eda"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_aptos_test_client() -> AptosClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.aptos.url, reqwest::Client::new()); + AptosClient::new(reqwest_client) +} diff --git a/core/crates/gem_aptos/src/provider/token.rs b/core/crates/gem_aptos/src/provider/token.rs new file mode 100644 index 0000000000..bf38edd470 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/token.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::Asset; + +use super::token_mapper::map_token_data; +use crate::models::CoinInfo; +use crate::rpc::client::AptosClient; +use crate::token_id::is_fungible_asset_token_id; + +const FUNGIBLE_ASSET_METADATA_TYPE: &str = "0x1::fungible_asset::Metadata"; + +#[async_trait] +impl ChainToken for AptosClient { + async fn get_token_data(&self, token_id: String) -> Result> { + let resource = self.get_account_resource::(token_id.clone(), FUNGIBLE_ASSET_METADATA_TYPE).await?; + map_token_data(&resource, &token_id) + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + is_fungible_asset_token_id(token_id) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::create_aptos_test_client; + use chain_traits::ChainToken; + use primitives::asset_constants::APTOS_USDT_TOKEN_ID; + + #[tokio::test] + async fn test_aptos_get_token_data() -> Result<(), Box> { + let client = create_aptos_test_client(); + let token_data = client.get_token_data(APTOS_USDT_TOKEN_ID.to_string()).await?; + assert!(!token_data.name.is_empty()); + assert!(token_data.decimals > 0); + assert_eq!(token_data.symbol, "USDt"); + assert_eq!(token_data.decimals, 6); + Ok(()) + } +} diff --git a/core/crates/gem_aptos/src/provider/token_mapper.rs b/core/crates/gem_aptos/src/provider/token_mapper.rs new file mode 100644 index 0000000000..78ab054bec --- /dev/null +++ b/core/crates/gem_aptos/src/provider/token_mapper.rs @@ -0,0 +1,48 @@ +use crate::models::{CoinInfo, Resource}; +use primitives::{Asset, AssetId, AssetType, Chain}; +use std::error::Error; + +pub fn map_token_data(resource: &Resource, token_id: &str) -> Result> { + let coin_info = &resource.data; + + Ok(Asset { + id: AssetId::from_token(Chain::Aptos, token_id), + chain: Chain::Aptos, + token_id: Some(token_id.to_string()), + name: coin_info.name.clone(), + symbol: coin_info.symbol.clone(), + decimals: coin_info.decimals as i32, + asset_type: AssetType::TOKEN, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + APTOS_NATIVE_COIN, + models::{CoinInfo, Resource}, + }; + + #[test] + fn test_map_token_data() { + let coin_info = CoinInfo { + name: "Aptos Coin".to_string(), + symbol: "APT".to_string(), + decimals: 8, + }; + + let resource = Resource { + type_field: "0x1::coin::CoinInfo<0x1::aptos_coin::AptosCoin>".to_string(), + data: coin_info, + }; + + let result = map_token_data(&resource, APTOS_NATIVE_COIN).unwrap(); + + assert_eq!(result.name, "Aptos Coin"); + assert_eq!(result.symbol, "APT"); + assert_eq!(result.decimals, 8); + assert_eq!(result.asset_type, AssetType::TOKEN); + assert_eq!(result.id.chain, Chain::Aptos); + } +} diff --git a/core/crates/gem_aptos/src/provider/transaction_broadcast.rs b/core/crates/gem_aptos/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..87ab7f820a --- /dev/null +++ b/core/crates/gem_aptos/src/provider/transaction_broadcast.rs @@ -0,0 +1,30 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{ + BroadcastProvider, + transaction_broadcast_mapper::{map_transaction_broadcast_request, map_transaction_broadcast_response_from_str}, + transactions_mapper::map_transaction_broadcast, + }, + rpc::client::AptosClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for AptosClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let data = map_transaction_broadcast_request(&data)?; + let response = self.submit_transaction(data).await?; + map_transaction_broadcast(&response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_aptos/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_aptos/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..2cb0718a69 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,16 @@ +use std::error::Error; + +use primitives::decode_hex; + +use crate::models::{SubmitTransactionBcsRequest, TransactionResponse}; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub fn map_transaction_broadcast_request(data: &str) -> Result, Box> { + let request = serde_json::from_str::(data)?; + Ok(decode_hex(&request.bcs)?) +} + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::(response)?; + map_transaction_broadcast(&response) +} diff --git a/core/crates/gem_aptos/src/provider/transaction_state.rs b/core/crates/gem_aptos/src/provider/transaction_state.rs new file mode 100644 index 0000000000..bca69bc00b --- /dev/null +++ b/core/crates/gem_aptos/src/provider/transaction_state.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::AptosClient}; + +#[async_trait] +impl ChainTransactionState for AptosClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + Ok(map_transaction_status(&self.get_transaction_by_hash(&request.id).await?)) + } +} diff --git a/core/crates/gem_aptos/src/provider/transaction_state_mapper.rs b/core/crates/gem_aptos/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..51c2351c4d --- /dev/null +++ b/core/crates/gem_aptos/src/provider/transaction_state_mapper.rs @@ -0,0 +1,61 @@ +use crate::models::Transaction; +use num_bigint::BigInt; +use primitives::{TransactionChange, TransactionState, TransactionUpdate}; + +pub fn map_transaction_status(transaction: &Transaction) -> TransactionUpdate { + let state = if transaction.success { TransactionState::Confirmed } else { TransactionState::Failed }; + + let mut update = TransactionUpdate::new_state(state); + + if let (Some(gas_used), Some(gas_unit_price)) = (transaction.gas_used, transaction.gas_unit_price) { + let fee = gas_used * gas_unit_price; + update.changes.push(TransactionChange::NetworkFee(BigInt::from(fee))); + } + + update +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_transaction_status_confirmed() { + let transaction = Transaction { + hash: Some("0xabc123".to_string()), + sender: Some("0x123".to_string()), + success: true, + gas_used: Some(100), + gas_unit_price: Some(1), + events: None, + payload: None, + transaction_type: Some("user_transaction".to_string()), + sequence_number: Some("1".to_string()), + timestamp: 1234567890, + }; + + let result = map_transaction_status(&transaction); + assert_eq!(result.state, TransactionState::Confirmed); + assert_eq!(result.changes, vec![TransactionChange::NetworkFee(BigInt::from(100u64))]); + } + + #[test] + fn test_map_transaction_status_failed() { + let transaction = Transaction { + hash: Some("0xdef456".to_string()), + sender: Some("0x456".to_string()), + success: false, + gas_used: Some(50), + gas_unit_price: Some(1), + events: None, + payload: None, + transaction_type: Some("user_transaction".to_string()), + sequence_number: Some("2".to_string()), + timestamp: 1234567891, + }; + + let result = map_transaction_status(&transaction); + assert_eq!(result.state, TransactionState::Failed); + assert_eq!(result.changes, vec![TransactionChange::NetworkFee(BigInt::from(50u64))]); + } +} diff --git a/core/crates/gem_aptos/src/provider/transactions.rs b/core/crates/gem_aptos/src/provider/transactions.rs new file mode 100644 index 0000000000..245ddeb271 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/transactions.rs @@ -0,0 +1,59 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::{ + provider::transactions_mapper::{map_transaction, map_transactions}, + rpc::client::AptosClient, +}; + +#[async_trait] +impl ChainTransactions for AptosClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + Ok(map_transactions(self.get_block_transactions(block).await?.transactions)) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + Ok(map_transaction(self.get_transaction_by_hash(&hash).await?)) + } + + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, .. } = request; + Ok(map_transactions(self.get_transactions_by_address(address).await?)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{TEST_ADDRESS, TEST_TRANSACTION_ID, create_aptos_test_client}; + use chain_traits::{ChainState, ChainTransactions}; + + #[tokio::test] + async fn test_aptos_get_transactions_by_block() -> Result<(), Box> { + let client = create_aptos_test_client(); + let _latest_block = client.get_block_latest_number().await?; + let transactions = client.get_transactions_by_block(100000).await?; + println!("Transactions in block 100000: {}", transactions.len()); + Ok(()) + } + + #[tokio::test] + async fn test_aptos_get_transactions_by_address() -> Result<(), Box> { + let client = create_aptos_test_client(); + let transactions = client.get_transactions_by_address(TEST_ADDRESS.to_string()).await?; + println!("Address: {}, transactions count: {}", TEST_ADDRESS, transactions.len()); + Ok(()) + } + + #[tokio::test] + async fn test_aptos_get_transaction_by_hash() -> Result<(), Box> { + let client = create_aptos_test_client(); + let transaction = ChainTransactions::get_transaction_by_hash(&client, TEST_TRANSACTION_ID.to_string()).await?.unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + Ok(()) + } +} diff --git a/core/crates/gem_aptos/src/provider/transactions_mapper.rs b/core/crates/gem_aptos/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..35340e1580 --- /dev/null +++ b/core/crates/gem_aptos/src/provider/transactions_mapper.rs @@ -0,0 +1,369 @@ +use crate::models::{DelegationPoolAddStakeData, DelegationPoolUnlockStakeData, Event, Transaction, TransactionResponse}; +use crate::{ + APTOS_NATIVE_COIN, DELEGATION_POOL_ADD_STAKE_EVENT, DELEGATION_POOL_UNLOCK_STAKE_EVENT, FUNGIBLE_ASSET_DEPOSIT_EVENT, FUNGIBLE_ASSET_WITHDRAW_EVENT, STAKE_DEPOSIT_EVENT, +}; +use chain_primitives::{BalanceDiff, SwapMapper}; +use chrono::DateTime; +use num_bigint::{BigInt, BigUint}; +use primitives::{AssetId, Chain, SwapProvider, Transaction as PrimitivesTransaction, TransactionState, TransactionType}; +use std::error::Error; + +const PANORA_SWAP_EVENT: &str = "panora_swap"; +const PANORA_SWAP_EVENT_ADDRESS: &str = "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c"; +const PANORA_SWAP_SUMMARY_EVENT: &str = "PanoraSwapSummaryEvent"; +const APTOS_NATIVE_METADATA_ADDRESS: &str = "0xa"; + +#[derive(serde::Deserialize)] +struct PanoraSwapSummaryEventData { + input_token_address: String, + input_token_amount: String, + output_token_address: String, + output_token_amount: String, +} + +fn map_token_address_to_asset_id(chain: Chain, token_address: &str) -> AssetId { + if token_address == APTOS_NATIVE_METADATA_ADDRESS || token_address == APTOS_NATIVE_COIN { + chain.as_asset_id() + } else { + AssetId::from_token(chain, token_address) + } +} + +pub fn map_transaction_broadcast(response: &TransactionResponse) -> Result> { + if let Some(message) = &response.message { + return Err(message.clone().into()); + } + + response.hash.clone().ok_or_else(|| "Transaction response missing hash".into()) +} + +pub fn map_transactions(transactions: Vec) -> Vec { + let mut transactions = transactions.into_iter().flat_map(map_transaction).collect::>(); + + transactions.sort_by_key(|b| std::cmp::Reverse(b.created_at)); + transactions +} + +struct TransactionMeta { + hash: String, + sender: String, + state: TransactionState, + fee: String, + created_at: DateTime, +} + +fn extract_meta(transaction: &Transaction) -> Option { + let hash = transaction.hash.clone().unwrap_or_default(); + let sender = transaction.sender.clone().unwrap_or_default(); + let state = if transaction.success { TransactionState::Confirmed } else { TransactionState::Failed }; + let gas_used = BigUint::from(transaction.gas_used.unwrap_or_default()); + let gas_unit_price = BigUint::from(transaction.gas_unit_price.unwrap_or_default()); + let fee = (gas_used * gas_unit_price).to_string(); + let created_at = DateTime::from_timestamp_micros(transaction.timestamp as i64)?; + + Some(TransactionMeta { + hash, + sender, + state, + fee, + created_at, + }) +} + +fn map_swap_transaction(transaction: Transaction, events: Vec, chain: Chain) -> Option { + let meta = extract_meta(&transaction)?; + + if let Some(summary) = events + .iter() + .find(|e| e.event_type.contains(PANORA_SWAP_EVENT_ADDRESS) && e.event_type.contains(PANORA_SWAP_SUMMARY_EVENT)) + .and_then(|e| e.data.clone()) + .and_then(|data| serde_json::from_value::(data).ok()) + { + let from_asset = map_token_address_to_asset_id(chain, &summary.input_token_address); + let to_asset = map_token_address_to_asset_id(chain, &summary.output_token_address); + + let balance_diffs = vec![ + BalanceDiff { + asset_id: from_asset, + from_value: None, + to_value: None, + diff: -BigInt::parse_bytes(summary.input_token_amount.as_bytes(), 10)?, + }, + BalanceDiff { + asset_id: to_asset, + from_value: None, + to_value: None, + diff: BigInt::parse_bytes(summary.output_token_amount.as_bytes(), 10)?, + }, + ]; + + let swap = SwapMapper::map_swap(&balance_diffs, &BigUint::from(0u8), &chain.as_asset_id(), Some(SwapProvider::Panora.id().to_owned()))?; + let asset_id = swap.from_asset.clone(); + let metadata = serde_json::to_value(&swap).ok(); + let to = meta.sender.clone(); + + return Some(build_transaction(meta, asset_id, chain.as_asset_id(), to, swap.from_value, TransactionType::Swap, metadata)); + } + + let withdraw_event = events.iter().find(|e| e.event_type == FUNGIBLE_ASSET_WITHDRAW_EVENT)?; + let deposit_event = events.iter().find(|e| e.event_type == FUNGIBLE_ASSET_DEPOSIT_EVENT)?; + let withdraw_amount = withdraw_event.get_amount()?; + let deposit_amount = deposit_event.get_amount()?; + + let type_args = transaction.payload.as_ref()?.type_arguments.clone(); + if type_args.len() != 2 { + return None; + } + + let map_asset = |coin_type: &str| { + if coin_type == APTOS_NATIVE_COIN { + chain.as_asset_id() + } else { + AssetId::from_token(chain, coin_type) + } + }; + + let from_asset = map_asset(&type_args[0]); + let to_asset = map_asset(&type_args[1]); + + let balance_diffs = vec![ + BalanceDiff { + asset_id: from_asset, + from_value: None, + to_value: None, + diff: -BigInt::parse_bytes(withdraw_amount.as_bytes(), 10)?, + }, + BalanceDiff { + asset_id: to_asset, + from_value: None, + to_value: None, + diff: BigInt::parse_bytes(deposit_amount.as_bytes(), 10)?, + }, + ]; + + let provider = events.iter().find(|e| e.event_type.contains(PANORA_SWAP_EVENT)).and_then(|e| { + if e.event_type.contains(PANORA_SWAP_EVENT_ADDRESS) { + Some(SwapProvider::Panora.id().to_owned()) + } else { + None + } + }); + + let swap = SwapMapper::map_swap(&balance_diffs, &BigUint::from(0u8), &chain.as_asset_id(), provider)?; + let asset_id = swap.from_asset.clone(); + let metadata = serde_json::to_value(&swap).ok(); + let to = meta.sender.clone(); + + Some(build_transaction(meta, asset_id, chain.as_asset_id(), to, swap.from_value, TransactionType::Swap, metadata)) +} + +fn build_transaction( + meta: TransactionMeta, + asset_id: AssetId, + fee_asset_id: AssetId, + to: String, + value: String, + transaction_type: TransactionType, + metadata: Option, +) -> PrimitivesTransaction { + PrimitivesTransaction::new( + meta.hash, + asset_id, + meta.sender, + to, + None, + transaction_type, + meta.state, + meta.fee, + fee_asset_id, + value, + None, + metadata, + meta.created_at, + ) +} + +pub fn map_transaction(transaction: Transaction) -> Option { + let chain = Chain::Aptos; + let events = transaction.clone().events.unwrap_or_default(); + let meta = extract_meta(&transaction)?; + let asset_id = chain.as_asset_id(); + + if events.iter().any(|e| e.event_type.contains("Swap")) { + return map_swap_transaction(transaction, events, chain); + } + + for event in &events { + match event.event_type.as_str() { + DELEGATION_POOL_ADD_STAKE_EVENT => { + let data: DelegationPoolAddStakeData = serde_json::from_value(event.data.clone()?).ok()?; + return Some(build_transaction( + meta, + asset_id.clone(), + asset_id, + data.pool_address, + data.amount_added, + TransactionType::StakeDelegate, + None, + )); + } + DELEGATION_POOL_UNLOCK_STAKE_EVENT => { + let data: DelegationPoolUnlockStakeData = serde_json::from_value(event.data.clone()?).ok()?; + return Some(build_transaction( + meta, + asset_id.clone(), + asset_id, + data.pool_address, + data.amount_unlocked, + TransactionType::StakeUndelegate, + None, + )); + } + _ => continue, + } + } + + if transaction.transaction_type.as_deref() == Some("user_transaction") && events.len() <= 4 { + let deposit_event = events + .iter() + .find(|x| x.event_type == STAKE_DEPOSIT_EVENT || x.event_type == FUNGIBLE_ASSET_DEPOSIT_EVENT)?; + + let to = if deposit_event.event_type == FUNGIBLE_ASSET_DEPOSIT_EVENT { + transaction.payload.as_ref()?.arguments.first()?.as_str()?.to_string() + } else { + deposit_event.guid.account_address.clone() + }; + + let value = deposit_event.get_amount()?; + + return Some(build_transaction(meta, asset_id.clone(), asset_id, to, value, TransactionType::Transfer, None)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::TransactionResponse; + use crate::provider::testkit::TEST_TRANSACTION_ID; + use primitives::asset_constants::APTOS_USDT_TOKEN_ID; + + #[test] + fn test_map_transaction_broadcast() { + let response = TransactionResponse { + hash: Some("0xabc123".to_string()), + message: None, + error_code: None, + vm_error_code: None, + }; + + let result = map_transaction_broadcast(&response).unwrap(); + assert_eq!(result, "0xabc123"); + } + + #[test] + fn test_map_transaction_broadcast_error() { + let response = TransactionResponse { + hash: None, + message: Some("Invalid transaction: Type: Validation Code: MAX_GAS_UNITS_BELOW_MIN_TRANSACTION_GAS_UNITS".to_string()), + error_code: Some("vm_error".to_string()), + vm_error_code: Some(14), + }; + + let result = map_transaction_broadcast(&response); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Invalid transaction: Type: Validation Code: MAX_GAS_UNITS_BELOW_MIN_TRANSACTION_GAS_UNITS" + ); + } + + #[test] + fn test_map_transaction_broadcast_from_testdata() { + let response: TransactionResponse = serde_json::from_str(include_str!("../../testdata/invalid_transaction_response.json")).unwrap(); + + let result = map_transaction_broadcast(&response); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Invalid transaction: Type: Validation Code: MAX_GAS_UNITS_BELOW_MIN_TRANSACTION_GAS_UNITS" + ); + } + + #[test] + fn test_map_transaction_by_hash() { + let transaction: Transaction = serde_json::from_str(include_str!("../../testdata/transaction_near_intent_transfer.json")).unwrap(); + + let result = map_transaction(transaction); + + assert!(result.is_some()); + let tx = result.unwrap(); + assert_eq!(tx.hash, TEST_TRANSACTION_ID); + assert_eq!(tx.id.to_string(), format!("aptos_{TEST_TRANSACTION_ID}")); + assert_eq!(tx.from, "0xd1a1c1804e91ba85a569c7f018bb7502d2f13d4742d2611953c9c14681af6446"); + assert_eq!(tx.to, "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc"); + assert_eq!(tx.value, "2431838058"); + assert_eq!(tx.state, TransactionState::Confirmed); + assert_eq!(tx.transaction_type, TransactionType::Transfer); + } + + #[test] + fn test_map_transaction_swap_panora() { + let transaction: Transaction = serde_json::from_str(include_str!("../../testdata/transaction_swap_panora.json")).unwrap(); + + let result = map_transaction(transaction); + + assert!(result.is_some()); + let tx = result.unwrap(); + assert_eq!(tx.id.to_string(), "aptos_0xf1c24162c08b6b8b452c00adad1836d72949902a8701611479d4e49fec0a9e3c"); + assert_eq!(tx.from, "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef"); + assert_eq!(tx.to, "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef"); + assert_eq!(tx.state, TransactionState::Confirmed); + assert_eq!(tx.transaction_type, TransactionType::Swap); + assert_eq!(tx.asset_id, AssetId::from_token(Chain::Aptos, APTOS_USDT_TOKEN_ID)); + assert_eq!(tx.fee_asset_id, Chain::Aptos.as_asset_id()); + assert_eq!(tx.fee, "142600"); + assert!(tx.metadata.is_some()); + + let metadata: primitives::TransactionSwapMetadata = serde_json::from_value(tx.metadata.unwrap()).unwrap(); + assert_eq!(metadata.from_asset, AssetId::from_token(Chain::Aptos, APTOS_USDT_TOKEN_ID)); + assert_eq!(metadata.from_value, "2346314"); + assert_eq!(metadata.to_asset, Chain::Aptos.as_asset_id()); + assert_eq!(metadata.to_value, "120590251"); + assert_eq!(metadata.provider.unwrap(), "panora"); + } + + #[test] + fn test_map_transaction_stake_delegate() { + let transaction: Transaction = serde_json::from_str(include_str!("../../testdata/transaction_stake_delegate.json")).unwrap(); + + let result = map_transaction(transaction); + + assert!(result.is_some()); + let tx = result.unwrap(); + assert_eq!(tx.id.to_string(), "aptos_0x130cc74c1a768780ca062a97bc833a01dec85b2d315484869559b7cdee4d0e75"); + assert_eq!(tx.from, "0xc95615aa095c100b18eb6eaa0f0a0f30b9cd96685118a7cbc1a2328a91ca2eda"); + assert_eq!(tx.to, "0xe5452230b8d5f4a664e33b8ad95354e50da64caaf003f11c0158391e96a4db2c"); + assert_eq!(tx.value, "1100000000"); + assert_eq!(tx.state, TransactionState::Confirmed); + assert_eq!(tx.transaction_type, TransactionType::StakeDelegate); + assert_eq!(tx.fee, "142400"); + } + + #[test] + fn test_map_transaction_stake_undelegate() { + let transaction: Transaction = serde_json::from_str(include_str!("../../testdata/transaction_stake_undelegate.json")).unwrap(); + + let result = map_transaction(transaction); + + assert!(result.is_some()); + let tx = result.unwrap(); + assert_eq!(tx.id.to_string(), "aptos_0xef6430bef0e8de7090b2c4bce210adb75d648be4614dcc37232b0d67f819b137"); + assert_eq!(tx.from, "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc"); + assert_eq!(tx.to, "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e"); + assert_eq!(tx.value, "1109984251"); + assert_eq!(tx.state, TransactionState::Confirmed); + assert_eq!(tx.transaction_type, TransactionType::StakeUndelegate); + assert_eq!(tx.fee, "88400"); + } +} diff --git a/core/crates/gem_aptos/src/rpc/client.rs b/core/crates/gem_aptos/src/rpc/client.rs new file mode 100644 index 0000000000..d30d361fd3 --- /dev/null +++ b/core/crates/gem_aptos/src/rpc/client.rs @@ -0,0 +1,241 @@ +use std::collections::HashMap; +use std::error::Error; +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use gem_client::{CONTENT_TYPE, Client, ClientExt, ContentType, build_path_with_query}; +use num_bigint::BigUint; +use primitives::chain::Chain; +use primitives::{StakeType, TransactionInputType, TransactionLoadInput}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::models::{ + Account, Block, DelegationPoolStake, GasFee, Ledger, ReconfigurationState, Resource, ResourceData, SimulateTransactionQuery, StakingConfig, Transaction, TransactionPayload, + TransactionResponse, TransactionSignature, TransactionSimulation, ValidatorSet, +}; +use crate::provider::payload_builder::{ + build_stake_transaction_payload, build_token_transfer_transaction_payload, build_transfer_transaction_payload, build_unstake_transaction_payload, + build_withdraw_transaction_payload, +}; +use crate::{DEFAULT_MAX_GAS_AMOUNT, DEFAULT_SWAP_MAX_GAS_AMOUNT}; + +#[derive(Debug)] +pub struct AptosClient { + client: C, + pub chain: Chain, +} + +impl AptosClient { + pub fn new(client: C) -> Self { + Self { client, chain: Chain::Aptos } + } + + pub fn get_chain(&self) -> Chain { + self.chain + } + + pub async fn get_ledger(&self) -> Result> { + Ok(self.client.get("/v1/").await?) + } + + pub async fn get_block_transactions(&self, block_number: u64) -> Result> { + let url = format!("/v1/blocks/by_height/{}?with_transactions=true", block_number); + Ok(self.client.get(&url).await?) + } + + pub async fn get_transactions_by_address(&self, address: String) -> Result, Box> { + let url = format!("/v1/accounts/{}/transactions", address); + Ok(self.client.get(&url).await?) + } + + pub async fn get_account_resource(&self, address: String, resource: &str) -> Result, Box> { + Ok(self.client.get(&format!("/v1/accounts/{}/resource/{}", address, resource)).await?) + } + + pub async fn get_account_balance(&self, address: &str, asset_type: &str) -> Result> { + Ok(self.client.get(&format!("/v1/accounts/{}/balance/{}", address, asset_type)).await?) + } + + pub async fn get_account_resources(&self, address: &str) -> Result>, Box> { + Ok(self.client.get(&format!("/v1/accounts/{}/resources", address)).await?) + } + + pub async fn get_account(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/v1/accounts/{}", address)).await?) + } + + pub async fn submit_transaction(&self, bcs_bytes: Vec) -> Result> { + let headers = HashMap::from([(CONTENT_TYPE.to_string(), ContentType::ApplicationAptosBcs.as_str().to_string())]); + let response = self + .client + .post_with_headers::, TransactionResponse>("/v1/transactions", &bcs_bytes, headers) + .await?; + + if let Some(message) = &response.message { + return Err(Box::new(std::io::Error::other(message.clone()))); + } + + Ok(response) + } + + pub async fn get_transaction_by_hash(&self, hash: &str) -> Result> { + Ok(self.client.get(&format!("/v1/transactions/by_hash/{}", hash)).await?) + } + + pub async fn get_gas_price(&self) -> Result> { + Ok(self.client.get("/v1/estimate_gas_price").await?) + } + + pub async fn calculate_gas_limit(&self, input: &TransactionLoadInput) -> Result> { + let sequence = input.metadata.get_sequence()?; + + match &input.input_type { + TransactionInputType::Transfer(asset) + | TransactionInputType::Deposit(asset) + | TransactionInputType::TransferNft(asset, _) + | TransactionInputType::Account(asset, _) => { + let payload = match &asset.id.token_id { + None => build_transfer_transaction_payload(&input.destination_address, &input.value), + Some(token_id) => build_token_transfer_transaction_payload(token_id, &input.destination_address, &input.value)?, + }; + + self.simulate_transaction(&input.sender_address, sequence, payload, &input.gas_price.gas_price().to_string()) + .await + } + TransactionInputType::Swap(_, _, swap_data) => match &swap_data.data.gas_limit { + Some(gas_limit) => gas_limit.parse::().map_err(|_| "Invalid Aptos gas limit".into()), + None => { + let payload: TransactionPayload = serde_json::from_str(&swap_data.data.data)?; + Ok(self + .simulate_transaction(&input.sender_address, sequence, payload, &input.gas_price.gas_price().to_string()) + .await + .unwrap_or(DEFAULT_SWAP_MAX_GAS_AMOUNT)) + } + }, + TransactionInputType::Stake(_, stake_type) => { + let payload = match stake_type { + StakeType::Stake(validator) => Some(build_stake_transaction_payload(&validator.id, &input.value)), + StakeType::Unstake(delegation) => Some(build_unstake_transaction_payload(&delegation.validator.id, &input.value)), + StakeType::Withdraw(delegation) => Some(build_withdraw_transaction_payload(&delegation.validator.id, &input.value)), + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => None, + }; + + let payload = payload.ok_or("Unsupported Aptos stake type")?; + self.simulate_transaction(&input.sender_address, sequence, payload, &input.gas_price.gas_price().to_string()) + .await + } + TransactionInputType::Generic(_, _, _) => Ok(DEFAULT_MAX_GAS_AMOUNT), + TransactionInputType::TokenApprove(_, _) | TransactionInputType::Perpetual(_, _) | TransactionInputType::Earn(_, _, _) => { + Err("Unsupported Aptos transaction type".into()) + } + } + } + + pub async fn simulate_transaction(&self, sender: &str, sequence: u64, payload: TransactionPayload, gas_price: &str) -> Result> { + let expiration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 1_000_000; + + let query = SimulateTransactionQuery { + estimate_max_gas_amount: true, + estimate_gas_unit_price: false, + estimate_prioritized_gas_unit_price: false, + }; + let path = build_path_with_query("/v1/transactions/simulate", &query)?; + + let simulation = TransactionSimulation { + expiration_timestamp_secs: expiration.to_string(), + gas_unit_price: gas_price.to_string(), + max_gas_amount: DEFAULT_MAX_GAS_AMOUNT.to_string(), + payload, + sender: sender.to_string(), + sequence_number: sequence.to_string(), + signature: TransactionSignature::no_account(), + }; + + let response: Vec = self.client.post(&path, &simulation).await?; + let transaction = response.into_iter().next().ok_or("No simulation result")?; + + transaction.gas_used.ok_or_else(|| "No gas used in simulation".into()) + } + + pub async fn get_validator_set(&self) -> Result> { + Ok(self.get_account_resource::("0x1".to_string(), "0x1::stake::ValidatorSet").await?.data) + } + + pub async fn get_staking_config(&self) -> Result> { + Ok(self + .get_account_resource::("0x1".to_string(), "0x1::staking_config::StakingConfig") + .await? + .data) + } + + pub async fn get_delegation_pool_stake(&self, pool_address: &str, delegator_address: &str) -> Result> { + let view_request = serde_json::json!({ + "function": "0x1::delegation_pool::get_stake", + "type_arguments": [], + "arguments": [pool_address, delegator_address] + }); + + let (active, inactive, pending_inactive): (String, String, String) = self.client.post("/v1/view", &view_request).await?; + + Ok(DelegationPoolStake { + active: BigUint::from_str(&active).unwrap_or_else(|_| BigUint::from(0u32)), + inactive: BigUint::from_str(&inactive).unwrap_or_else(|_| BigUint::from(0u32)), + pending_inactive: BigUint::from_str(&pending_inactive).unwrap_or_else(|_| BigUint::from(0u32)), + }) + } + + pub async fn get_delegation_for_pool(&self, delegator_address: &str, pool_address: &str) -> Result<(String, DelegationPoolStake), Box> { + let stake = self.get_delegation_pool_stake(pool_address, delegator_address).await?; + Ok((pool_address.to_string(), stake)) + } + + pub async fn get_operator_commission_percentage(&self, pool_address: &str) -> Result> { + let view_request = serde_json::json!({ + "function": "0x1::delegation_pool::operator_commission_percentage", + "type_arguments": [], + "arguments": [pool_address] + }); + + let result: Vec = self.client.post("/v1/view", &view_request).await?; + let commission_bps = result.first().and_then(|s| s.parse::().ok()).unwrap_or(0); + + Ok(commission_bps as f64 / 100.0) + } + + pub async fn get_reconfiguration_state(&self) -> Result> { + Ok(self + .get_account_resource::("0x1".to_string(), "0x1::reconfiguration::Configuration") + .await? + .data) + } + + pub async fn get_stake_lockup_secs(&self, pool_address: &str) -> Result> { + let view_request = serde_json::json!({ + "function": "0x1::stake::get_lockup_secs", + "type_arguments": [], + "arguments": [pool_address] + }); + + let result: Vec = self.client.post("/v1/view", &view_request).await?; + let lockup_secs = result.first().and_then(|s| s.parse::().ok()).ok_or("Failed to parse lockup_secs")?; + + Ok(lockup_secs) + } +} + +#[cfg(feature = "rpc")] +mod chain_trait_impls { + use super::*; + use async_trait::async_trait; + use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual}; + + #[async_trait] + impl ChainAccount for AptosClient {} + + #[async_trait] + impl ChainPerpetual for AptosClient {} + + #[async_trait] + impl ChainAddressStatus for AptosClient {} +} diff --git a/core/crates/gem_aptos/src/rpc/mod.rs b/core/crates/gem_aptos/src/rpc/mod.rs new file mode 100644 index 0000000000..ff8cd527da --- /dev/null +++ b/core/crates/gem_aptos/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::AptosClient; diff --git a/core/crates/gem_aptos/src/signer/abi.rs b/core/crates/gem_aptos/src/signer/abi.rs new file mode 100644 index 0000000000..c279b0b43b --- /dev/null +++ b/core/crates/gem_aptos/src/signer/abi.rs @@ -0,0 +1,24 @@ +pub(crate) const PANORA_ROUTER_MODULE: &str = "panora_swap"; +pub(crate) const PANORA_ROUTER_FUNCTION: &str = "router_entry"; +pub(crate) const PANORA_ROUTER_ENTRY_PARAMS: [&str; 20] = [ + "0x1::option::Option", + "address", + "u64", + "u8", + "vector", + "vector>>", + "vector>>", + "vector>>", + "vector>", + "vector>>", + "vector>", + "vector>", + "0x1::option::Option>>>>>", + "vector>>", + "0x1::option::Option>>>", + "address", + "vector", + "u64", + "u64", + "address", +]; diff --git a/core/crates/gem_aptos/src/signer/chain_signer.rs b/core/crates/gem_aptos/src/signer/chain_signer.rs new file mode 100644 index 0000000000..d8cf842d9c --- /dev/null +++ b/core/crates/gem_aptos/src/signer/chain_signer.rs @@ -0,0 +1,234 @@ +use hex::encode; +use primitives::{ChainSigner, SignerError, SignerInput, TransactionInputType, TransactionLoadMetadata}; +use serde_json::{Value, from_str}; +use std::str::from_utf8; + +use super::abi::{PANORA_ROUTER_ENTRY_PARAMS, PANORA_ROUTER_FUNCTION, PANORA_ROUTER_MODULE}; +use super::{ + EntryFunction, EntryFunctionPayload, build_raw_transaction, build_submit_transaction_bcs, expiration_timestamp_secs, sign_message as sign_aptos_message, sign_raw_transaction, +}; +use crate::AccountAddress; +use crate::token_id::is_fungible_asset_token_id; +use crate::{APTOS_TRANSFER_FUNCTION, DELEGATION_POOL_ADD_STAKE_FUNCTION, DELEGATION_POOL_UNLOCK_FUNCTION, DELEGATION_POOL_WITHDRAW_FUNCTION, ENTRY_FUNCTION_PAYLOAD_TYPE}; + +const APTOS_CHAIN_ID: u8 = 1; +const FUNGIBLE_TRANSFER_FUNCTION: &str = "0x1::primary_fungible_store::transfer"; +const OBJECT_CORE_TYPE: &str = "0x1::object::ObjectCore"; + +const STAKE_ENTRY_PARAMS: [&str; 2] = ["address", "u64"]; +const FUNGIBLE_TRANSFER_ENTRY_PARAMS: [&str; 3] = ["address", "address", "u64"]; + +#[derive(Default)] +pub struct AptosChainSigner; + +impl ChainSigner for AptosChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let payload = EntryFunctionPayload { + payload_type: ENTRY_FUNCTION_PAYLOAD_TYPE.to_string(), + function: APTOS_TRANSFER_FUNCTION.to_string(), + type_arguments: Vec::new(), + arguments: vec![Value::String(input.destination_address.clone()), Value::String(input.value.clone())], + }; + + let gas_limit = input.fee.gas_limit()?; + self.sign_payload(payload, Some(&STAKE_ENTRY_PARAMS), input, private_key, gas_limit) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let gas_limit = input.fee.gas_limit()?; + let (payload, abi) = token_transfer_payload(input)?; + self.sign_payload(payload, Some(abi), input, private_key, gas_limit) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let swap_data = match &input.input_type { + TransactionInputType::Swap(_, _, data) => data, + _ => return Err(SignerError::InvalidInput("Expected Aptos swap input".to_string())), + }; + + let payload: EntryFunctionPayload = from_str(swap_data.data.data.as_str())?; + let (payload, abi) = prepare_payload(payload)?; + let entry_function = payload.to_entry_function(abi)?; + + let signed = self.sign_entry_function(entry_function, input, private_key, input.fee.gas_limit()?)?; + Ok(vec![signed]) + } + + fn sign_stake(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let data = match &input.metadata { + TransactionLoadMetadata::Aptos { data: Some(data), .. } => data, + _ => return Err(SignerError::InvalidInput("Missing Aptos stake payload".to_string())), + }; + + let payload: EntryFunctionPayload = from_str(data)?; + let (payload, abi) = prepare_payload(payload)?; + let entry_function = payload.to_entry_function(abi)?; + + let signed = self.sign_entry_function(entry_function, input, private_key, input.fee.gas_limit()?)?; + Ok(vec![signed]) + } + + fn sign_message(&self, message: &[u8], private_key: &[u8]) -> Result { + let (signature, _) = sign_aptos_message(message, private_key)?; + Ok(format!("0x{}", encode(signature))) + } + + fn sign_data(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let (payload, gas_limit) = get_generic_payload(input)?; + let (payload, abi) = prepare_payload(payload)?; + let entry_function = payload.to_entry_function(abi)?; + + self.sign_entry_function(entry_function, input, private_key, gas_limit) + } +} + +impl AptosChainSigner { + fn sign_payload(&self, payload: EntryFunctionPayload, abi: Option<&[&str]>, input: &SignerInput, private_key: &[u8], gas_limit: u64) -> Result { + let entry_function = payload.to_entry_function(abi)?; + self.sign_entry_function(entry_function, input, private_key, gas_limit) + } + + fn sign_entry_function(&self, entry_function: EntryFunction, input: &SignerInput, private_key: &[u8], gas_limit: u64) -> Result { + let sender = AccountAddress::from_hex(&input.sender_address)?; + let sequence = sequence_from_metadata(&input.metadata)?; + let gas_unit_price = input.fee.gas_price_u64()?; + let expiration = expiration_timestamp_secs()?; + + let raw_tx = build_raw_transaction(sender, sequence, entry_function, gas_limit, gas_unit_price, expiration, APTOS_CHAIN_ID); + let (signature, public_key) = sign_raw_transaction(&raw_tx, private_key)?; + + build_submit_transaction_bcs(raw_tx, signature, public_key) + } +} + +fn get_payload_abi(payload: &EntryFunctionPayload) -> Option<&'static [&'static str]> { + match payload.function.as_str() { + DELEGATION_POOL_ADD_STAKE_FUNCTION | DELEGATION_POOL_UNLOCK_FUNCTION | DELEGATION_POOL_WITHDRAW_FUNCTION => Some(&STAKE_ENTRY_PARAMS), + _ => None, + } +} + +fn prepare_payload(payload: EntryFunctionPayload) -> Result<(EntryFunctionPayload, Option<&'static [&'static str]>), SignerError> { + if is_panora_router(&payload.function) { + return prepare_panora_payload(payload); + } + + let abi = get_payload_abi(&payload); + Ok((payload, abi)) +} + +fn prepare_panora_payload(mut payload: EntryFunctionPayload) -> Result<(EntryFunctionPayload, Option<&'static [&'static str]>), SignerError> { + let expected = PANORA_ROUTER_ENTRY_PARAMS.len(); + let before_len = payload.arguments.len(); + if before_len + 1 == expected { + payload.arguments.insert(0, Value::Null); + } + if payload.arguments.len() != expected { + return Err(SignerError::InvalidInput("Aptos ABI length does not match arguments".to_string())); + } + + Ok((payload, Some(&PANORA_ROUTER_ENTRY_PARAMS))) +} + +fn get_generic_payload(input: &SignerInput) -> Result<(EntryFunctionPayload, u64), SignerError> { + let data = match &input.input_type { + TransactionInputType::Generic(_, _, extra) => extra.data.as_ref(), + _ => return Err(SignerError::InvalidInput("Expected Aptos generic input".to_string())), + }; + + let json = if let Some(bytes) = data { + if bytes.is_empty() { + return Err(SignerError::InvalidInput("Missing Aptos payload data".to_string())); + } + from_utf8(bytes) + .map_err(|_| SignerError::InvalidInput("Aptos payload must be valid UTF-8".to_string()))? + .to_string() + } else if let TransactionLoadMetadata::Aptos { data: Some(json), .. } = &input.metadata { + json.clone() + } else { + return Err(SignerError::InvalidInput("Missing Aptos payload data".to_string())); + }; + + let payload: EntryFunctionPayload = from_str(&json)?; + Ok((payload, input.fee.gas_limit()?)) +} + +fn is_panora_router(function_id: &str) -> bool { + let mut parts = function_id.split("::"); + let _address = parts.next(); + let module = parts.next(); + let function = parts.next(); + + module == Some(PANORA_ROUTER_MODULE) && function == Some(PANORA_ROUTER_FUNCTION) +} + +fn sequence_from_metadata(metadata: &TransactionLoadMetadata) -> Result { + match metadata { + TransactionLoadMetadata::Aptos { sequence, .. } => Ok(*sequence), + _ => Err(SignerError::InvalidInput("Missing Aptos sequence".to_string())), + } +} + +fn token_transfer_payload(input: &SignerInput) -> Result<(EntryFunctionPayload, &'static [&'static str]), SignerError> { + let asset = input.input_type.get_asset(); + let token_id = asset.token_id.as_ref().ok_or_else(|| SignerError::invalid_input("Missing Aptos token id"))?; + if !is_fungible_asset_token_id(token_id) { + return Err(SignerError::invalid_input("Invalid Aptos token ID format")); + } + + Ok(( + EntryFunctionPayload { + payload_type: ENTRY_FUNCTION_PAYLOAD_TYPE.to_string(), + function: FUNGIBLE_TRANSFER_FUNCTION.to_string(), + type_arguments: vec![OBJECT_CORE_TYPE.to_string()], + arguments: vec![ + Value::String(token_id.to_string()), + Value::String(input.destination_address.clone()), + Value::String(input.value.clone()), + ], + }, + &FUNGIBLE_TRANSFER_ENTRY_PARAMS, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{GasPriceType, SignerInput, TransactionFee, TransactionLoadInput}; + + #[test] + fn gas_limit_uses_fee_model() { + let input = TransactionLoadInput::mock_aptos_token_transfer("0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"); + let input = SignerInput::new( + input, + TransactionFee::new_gas_price_type(GasPriceType::regular(1u64), 42u64.into(), 42u64.into(), Default::default()), + ); + + assert_eq!(input.fee.gas_limit().unwrap(), 42); + } + + #[test] + fn token_transfer_payload_uses_fungible_asset_transfer() { + let input = TransactionLoadInput::mock_aptos_token_transfer("0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"); + let fee = input.default_fee(); + let input = SignerInput::new(input, fee); + let (payload, abi) = token_transfer_payload(&input).unwrap(); + + assert_eq!(payload.function, FUNGIBLE_TRANSFER_FUNCTION); + assert_eq!(payload.type_arguments, vec![OBJECT_CORE_TYPE.to_string()]); + assert_eq!(abi, &FUNGIBLE_TRANSFER_ENTRY_PARAMS); + } + + #[test] + fn token_transfer_payload_rejects_invalid_token_id() { + let input = TransactionLoadInput::mock_aptos_token_transfer("invalid"); + let fee = input.default_fee(); + let input = SignerInput::new(input, fee); + let err = token_transfer_payload(&input).unwrap_err(); + + match err { + SignerError::InvalidInput(message) => assert_eq!(message, "Invalid Aptos token ID format"), + SignerError::SigningError(message) => panic!("unexpected signing error: {message}"), + } + } +} diff --git a/core/crates/gem_aptos/src/signer/mod.rs b/core/crates/gem_aptos/src/signer/mod.rs new file mode 100644 index 0000000000..ddaee779a3 --- /dev/null +++ b/core/crates/gem_aptos/src/signer/mod.rs @@ -0,0 +1,10 @@ +mod abi; +mod chain_signer; +mod payload; +mod transaction; + +pub use crate::models::{DeprecatedPayload, RawTransaction, Script, TransactionPayloadBCS}; +pub use crate::r#move::{EntryFunction, ModuleId, StructTag, TypeTag}; +pub use chain_signer::AptosChainSigner; +pub use payload::EntryFunctionPayload; +pub use transaction::{build_raw_transaction, build_submit_transaction_bcs, expiration_timestamp_secs, sign_message, sign_raw_transaction}; diff --git a/core/crates/gem_aptos/src/signer/payload.rs b/core/crates/gem_aptos/src/signer/payload.rs new file mode 100644 index 0000000000..e568fce298 --- /dev/null +++ b/core/crates/gem_aptos/src/signer/payload.rs @@ -0,0 +1,57 @@ +use crate::ENTRY_FUNCTION_PAYLOAD_TYPE; +use crate::models::TransactionPayload; +use primitives::SignerError; +use serde::Deserialize; +use serde_json::Value; + +use crate::r#move::{EntryFunction, encode_argument, infer_type_tags, parse_function_id, parse_type_tag}; + +#[derive(Clone, Debug, Deserialize)] +pub struct EntryFunctionPayload { + #[serde(rename = "type")] + pub payload_type: String, + pub function: String, + #[serde(default)] + pub type_arguments: Vec, + #[serde(default)] + pub arguments: Vec, +} + +impl EntryFunctionPayload { + pub fn to_transaction_payload(&self) -> TransactionPayload { + TransactionPayload { + function: Some(self.function.clone()), + type_arguments: self.type_arguments.clone(), + arguments: self.arguments.clone(), + payload_type: self.payload_type.clone(), + } + } + + pub fn to_entry_function(&self, abi: Option<&[&str]>) -> Result { + if self.payload_type != ENTRY_FUNCTION_PAYLOAD_TYPE { + return Err(SignerError::InvalidInput(format!("Unsupported Aptos payload type: {}", self.payload_type))); + } + + let (module, function) = parse_function_id(&self.function)?; + let ty_args = self.type_arguments.iter().map(|arg| parse_type_tag(arg)).collect::, _>>()?; + + let arg_types = match abi { + Some(abi_types) => { + if abi_types.len() != self.arguments.len() { + return Err(SignerError::InvalidInput("Aptos ABI length does not match arguments".to_string())); + } + abi_types.iter().map(|arg| parse_type_tag(arg)).collect::, _>>()? + } + None => infer_type_tags(&self.arguments)?, + }; + + let args = self + .arguments + .iter() + .zip(arg_types.iter()) + .map(|(value, arg_type)| encode_argument(value, arg_type)) + .collect::, _>>()?; + + Ok(EntryFunction { module, function, ty_args, args }) + } +} diff --git a/core/crates/gem_aptos/src/signer/transaction.rs b/core/crates/gem_aptos/src/signer/transaction.rs new file mode 100644 index 0000000000..6108de3b64 --- /dev/null +++ b/core/crates/gem_aptos/src/signer/transaction.rs @@ -0,0 +1,157 @@ +use crate::models::{Ed25519Authenticator, RawTransaction, SignedTransaction, SubmitTransactionBcsRequest, TransactionAuthenticator, TransactionPayloadBCS}; +use gem_hash::sha3::sha3_256; +use hex::encode; +use primitives::SignerError; +use signer::Ed25519KeyPair; +use std::time::SystemTime; + +use super::EntryFunction; +use crate::AccountAddress; + +const RAW_TRANSACTION_SALT: &[u8] = b"APTOS::RawTransaction"; +const MESSAGE_SALT: &[u8] = b"APTOS::Message"; + +pub fn build_raw_transaction( + sender: AccountAddress, + sequence_number: u64, + payload: EntryFunction, + max_gas_amount: u64, + gas_unit_price: u64, + expiration_timestamp_secs: u64, + chain_id: u8, +) -> RawTransaction { + RawTransaction { + sender, + sequence_number, + payload: TransactionPayloadBCS::EntryFunction(payload), + max_gas_amount, + gas_unit_price, + expiration_timestamp_secs, + chain_id, + } +} + +pub fn sign_raw_transaction(raw_tx: &RawTransaction, private_key: &[u8]) -> Result<(Vec, Vec), SignerError> { + let raw_tx_bytes = bcs::to_bytes(raw_tx).map_err(|err| SignerError::InvalidInput(format!("Failed to encode Aptos transaction: {err}")))?; + let seed = sha3_256(RAW_TRANSACTION_SALT); + let mut preimage = Vec::with_capacity(seed.len() + raw_tx_bytes.len()); + preimage.extend_from_slice(&seed); + preimage.extend_from_slice(&raw_tx_bytes); + + sign_ed25519(&preimage, private_key) +} + +pub fn sign_message(message: &[u8], private_key: &[u8]) -> Result<(Vec, Vec), SignerError> { + let seed = sha3_256(MESSAGE_SALT); + let mut preimage = Vec::with_capacity(seed.len() + message.len()); + preimage.extend_from_slice(&seed); + preimage.extend_from_slice(message); + + sign_ed25519(&preimage, private_key) +} + +fn sign_ed25519(preimage: &[u8], private_key: &[u8]) -> Result<(Vec, Vec), SignerError> { + let key_pair = Ed25519KeyPair::from_private_key(private_key)?; + Ok((key_pair.sign(preimage).to_vec(), key_pair.public_key_bytes.to_vec())) +} + +pub fn build_submit_transaction_bcs(raw_tx: RawTransaction, signature: Vec, public_key: Vec) -> Result { + let signed = SignedTransaction { + raw_tx, + authenticator: TransactionAuthenticator::Ed25519(Ed25519Authenticator { + public_key: ensure_length(public_key, 32, "public key")?, + signature: ensure_length(signature, 64, "signature")?, + }), + }; + let bcs_bytes = bcs::to_bytes(&signed).map_err(|err| SignerError::InvalidInput(format!("Failed to encode Aptos signed transaction: {err}")))?; + let request = SubmitTransactionBcsRequest { + bcs: encode(bcs_bytes), + bcs_encoding: "hex".to_string(), + }; + + serde_json::to_string(&request).map_err(|err| SignerError::InvalidInput(err.to_string())) +} + +pub fn expiration_timestamp_secs() -> Result { + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| SignerError::InvalidInput("Invalid system time".to_string()))?; + Ok(now.as_secs() + 3_600) +} + +fn ensure_length(input: Vec, expected: usize, label: &str) -> Result, SignerError> { + if input.len() != expected { + return Err(SignerError::InvalidInput(format!("Invalid Aptos {label} length: expected {expected}, got {}", input.len()))); + } + Ok(input) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signer::EntryFunctionPayload; + use ed25519_dalek::{Signature, SigningKey, Verifier}; + use serde_json::Value; + + fn sample_raw_tx() -> RawTransaction { + let payload = EntryFunctionPayload { + payload_type: "entry_function_payload".to_string(), + function: "0x1::aptos_account::transfer".to_string(), + type_arguments: Vec::new(), + arguments: vec![ + Value::String("0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef".to_string()), + Value::String("100".to_string()), + ], + }; + let entry_function = payload.to_entry_function(Some(&["address", "u64"])).expect("entry function"); + build_raw_transaction( + AccountAddress::from_hex("0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef").unwrap(), + 1, + entry_function, + 1500, + 100, + 1700000000, + 1, + ) + } + + #[test] + fn sign_raw_transaction_verifies_against_signing_message() { + let raw_tx = sample_raw_tx(); + let private_key = hex::decode("1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34").unwrap(); + let (signature, public_key) = sign_raw_transaction(&raw_tx, &private_key).expect("signature"); + + let raw_tx_bytes = bcs::to_bytes(&raw_tx).expect("bcs"); + let seed = sha3_256(RAW_TRANSACTION_SALT); + let mut preimage = Vec::with_capacity(seed.len() + raw_tx_bytes.len()); + preimage.extend_from_slice(&seed); + preimage.extend_from_slice(&raw_tx_bytes); + + let signing_key = SigningKey::from_bytes(&private_key.try_into().unwrap()); + assert_eq!(public_key, signing_key.verifying_key().to_bytes().to_vec()); + let signature = Signature::from_bytes(&signature.try_into().unwrap()); + signing_key.verifying_key().verify(&preimage, &signature).expect("signature should verify"); + } + + #[test] + fn build_submit_transaction_bcs_roundtrip() { + let raw_tx = sample_raw_tx(); + let private_key = hex::decode("1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34").unwrap(); + let (signature, public_key) = sign_raw_transaction(&raw_tx, &private_key).expect("signature"); + + let json = build_submit_transaction_bcs(raw_tx.clone(), signature.clone(), public_key.clone()).expect("bcs"); + let request: SubmitTransactionBcsRequest = serde_json::from_str(&json).expect("json"); + assert_eq!(request.bcs_encoding, "hex"); + + let bytes = hex::decode(request.bcs).expect("hex"); + let signed: SignedTransaction = bcs::from_bytes(&bytes).expect("bcs decode"); + assert_eq!(signed.raw_tx, raw_tx); + + match signed.authenticator { + TransactionAuthenticator::Ed25519(authenticator) => { + assert_eq!(authenticator.public_key, public_key); + assert_eq!(authenticator.signature, signature); + } + } + } +} diff --git a/core/crates/gem_aptos/src/token_id.rs b/core/crates/gem_aptos/src/token_id.rs new file mode 100644 index 0000000000..6c714c9792 --- /dev/null +++ b/core/crates/gem_aptos/src/token_id.rs @@ -0,0 +1,24 @@ +use primitives::decode_hex; + +const FUNGIBLE_ASSET_TOKEN_ID_LENGTH: usize = 66; + +pub(crate) fn is_fungible_asset_token_id(token_id: &str) -> bool { + token_id.starts_with("0x") && token_id.len() >= FUNGIBLE_ASSET_TOKEN_ID_LENGTH && decode_hex(token_id).map(|bytes| bytes.len() == 32).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::asset_constants::APTOS_USDT_TOKEN_ID; + + #[test] + fn test_is_fungible_asset_token_id() { + assert!(is_fungible_asset_token_id(APTOS_USDT_TOKEN_ID)); + assert!(!is_fungible_asset_token_id( + "0xf22bede237a07e121b56d91a491eb7bcdfd1f5907926a9e58338f964a01b17fa::asset::USDC" + )); + assert!(!is_fungible_asset_token_id("0xa")); + assert!(!is_fungible_asset_token_id("0xzz7b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b")); + assert!(!is_fungible_asset_token_id("invalid")); + } +} diff --git a/core/crates/gem_aptos/testdata/invalid_transaction_response.json b/core/crates/gem_aptos/testdata/invalid_transaction_response.json new file mode 100644 index 0000000000..fa405f60cc --- /dev/null +++ b/core/crates/gem_aptos/testdata/invalid_transaction_response.json @@ -0,0 +1,5 @@ +{ + "message": "Invalid transaction: Type: Validation Code: MAX_GAS_UNITS_BELOW_MIN_TRANSACTION_GAS_UNITS", + "error_code": "vm_error", + "vm_error_code": 14 +} \ No newline at end of file diff --git a/core/crates/gem_aptos/testdata/transaction_near_intent_transfer.json b/core/crates/gem_aptos/testdata/transaction_near_intent_transfer.json new file mode 100644 index 0000000000..18f3bde91d --- /dev/null +++ b/core/crates/gem_aptos/testdata/transaction_near_intent_transfer.json @@ -0,0 +1,365 @@ +{ + "version": "3521662834", + "hash": "0x6a43e0034486583a30cff449c03c4d882c641b351e392096272496168240de8e", + "state_change_hash": "0x086ef22806f323ae9f33ea63a86273c568b6dc8f071f86b77c2c9bee726baae8", + "event_root_hash": "0x3ce8464ee9c6fd30100011ea5cbdd971ab5c401162be3fe7feaf0a1df939a221", + "state_checkpoint_hash": null, + "gas_used": "18", + "success": true, + "vm_status": "Executed successfully", + "accumulator_root_hash": "0x1b130337c71d982ae89d2be00dd21d9366dfdb1556f368ce724a9f6e545b7e7a", + "changes": [ + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::coin::PairedCoinType", + "data": { + "type": { + "account_address": "0x1", + "module_name": "0x6170746f735f636f696e", + "struct_name": "0x4170746f73436f696e" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::coin::PairedFungibleAssetRefs", + "data": { + "burn_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + }, + "mint_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + }, + "transfer_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "27042047889714407" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 8, + "icon_uri": "", + "name": "Aptos Coin", + "project_uri": "", + "symbol": "APT" + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x1", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xa", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x7800ef2582c7360a59aaae4ec83c99ae6375dbe33f3c772e9382f02fea47bedc", + "state_key_hash": "0x4dccbed6e8f4e9bf3c238925cea240e093d67cace1550775ed007588a1b0b6bc", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "3017335400", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x7800ef2582c7360a59aaae4ec83c99ae6375dbe33f3c772e9382f02fea47bedc", + "state_key_hash": "0x4dccbed6e8f4e9bf3c238925cea240e093d67cace1550775ed007588a1b0b6bc", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x107b277f8ac97230f1e53cf3661b3f05a40c5a02d1d2b74fe77826b62b4d1c43", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x7800ef2582c7360a59aaae4ec83c99ae6375dbe33f3c772e9382f02fea47bedc", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa1576e342e664a6cea2931c088f0cd3d4f605ddd993c78895d0c49fc2e8bdd4f", + "state_key_hash": "0x9ce63f7194e64503f131cecdf9322a87c3b5cca1ea42521165568cf284e04625", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "345128553001", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa1576e342e664a6cea2931c088f0cd3d4f605ddd993c78895d0c49fc2e8bdd4f", + "state_key_hash": "0x9ce63f7194e64503f131cecdf9322a87c3b5cca1ea42521165568cf284e04625", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xd1a1c1804e91ba85a569c7f018bb7502d2f13d4742d2611953c9c14681af6446", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xa1576e342e664a6cea2931c088f0cd3d4f605ddd993c78895d0c49fc2e8bdd4f", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xba019ce4c1cd7fa53a5ebd6b4252008731e92ad0e80fdbaf7e170fac35ac95f9", + "state_key_hash": "0xb7de0aac04fd1332c3a983d095a5d62849ef36baa1fde72d6b5c474a98fc14f1", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "2924168925", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xba019ce4c1cd7fa53a5ebd6b4252008731e92ad0e80fdbaf7e170fac35ac95f9", + "state_key_hash": "0xb7de0aac04fd1332c3a983d095a5d62849ef36baa1fde72d6b5c474a98fc14f1", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xba019ce4c1cd7fa53a5ebd6b4252008731e92ad0e80fdbaf7e170fac35ac95f9", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xd1a1c1804e91ba85a569c7f018bb7502d2f13d4742d2611953c9c14681af6446", + "state_key_hash": "0x9d7a9bb595f91a373c4a6e93729762939e2dc70c9a26b6eca6d3bf7a08850129", + "data": { + "type": "0x1::account::Account", + "data": { + "authentication_key": "0xd1a1c1804e91ba85a569c7f018bb7502d2f13d4742d2611953c9c14681af6446", + "coin_register_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xd1a1c1804e91ba85a569c7f018bb7502d2f13d4742d2611953c9c14681af6446", + "creation_num": "0" + } + } + }, + "guid_creation_num": "4", + "key_rotation_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xd1a1c1804e91ba85a569c7f018bb7502d2f13d4742d2611953c9c14681af6446", + "creation_num": "1" + } + } + }, + "rotation_capability_offer": { + "for": { + "vec": [] + } + }, + "sequence_number": "103", + "signer_capability_offer": { + "for": { + "vec": [] + } + } + } + }, + "type": "write_resource" + }, + { + "state_key_hash": "0x6e4b28d40f98a106a65163530924c0dcb40c1349d3aa915d108b4d6cfc1ddb19", + "handle": "0x1b854694ae746cdbd8d44186ca4929b2b337df21d1c74633be19b2710552fdca", + "key": "0x0619dc29a0aac8fa146714058e8dd6d2d0f3bdf5f6331907bf91f3acd81e6935", + "value": "0x8db5a78553a142010000000000000000", + "data": null, + "type": "write_table_item" + } + ], + "sender": "0xd1a1c1804e91ba85a569c7f018bb7502d2f13d4742d2611953c9c14681af6446", + "sequence_number": "102", + "max_gas_amount": "200000", + "gas_unit_price": "100", + "expiration_timestamp_secs": "1759854861", + "payload": { + "function": "0x1::aptos_account::transfer_coins", + "type_arguments": [ + "0x1::aptos_coin::AptosCoin" + ], + "arguments": [ + "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "2431838058" + ], + "type": "entry_function_payload" + }, + "signature": { + "sender": { + "public_key": "0xf54716330cc13d7150840a82ea2af96615dd91cba628e68f65b63b1002858955", + "signature": "0xd5f44b2ee21aa8a934c7f8e92a9c4db1466beb71004d5e8f8981f9275fd22904350110be7c8280f5561c65bf0480973982e2911e18e11fa025afc5939c777509", + "type": "ed25519_signature" + }, + "secondary_signer_addresses": [], + "secondary_signers": [], + "fee_payer_address": "0x107b277f8ac97230f1e53cf3661b3f05a40c5a02d1d2b74fe77826b62b4d1c43", + "fee_payer_signer": { + "public_key": "0x9fd189c40dc0d8696bffbd08020915e86c5490689952c039cc53c7a88efe9db0", + "signature": "0x0b64fea5ad24c38948d7ad03ec624a8a24809675d053acdcfd2a82ca322a40a3b52a1ac39a61bae86768a05b549d0ae15f0b8886deff7719a042e735eb08f501", + "type": "ed25519_signature" + }, + "type": "fee_payer_signature" + }, + "replay_protection_nonce": null, + "events": [ + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "2431838058", + "store": "0xa1576e342e664a6cea2931c088f0cd3d4f605ddd993c78895d0c49fc2e8bdd4f" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "2431838058", + "store": "0xba019ce4c1cd7fa53a5ebd6b4252008731e92ad0e80fdbaf7e170fac35ac95f9" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::transaction_fee::FeeStatement", + "data": { + "execution_gas_units": "7", + "io_gas_units": "11", + "storage_fee_octas": "0", + "storage_fee_refund_octas": "0", + "total_charge_gas_units": "18" + } + } + ], + "timestamp": "1759854842251097", + "type": "user_transaction" +} \ No newline at end of file diff --git a/core/crates/gem_aptos/testdata/transaction_stake_delegate.json b/core/crates/gem_aptos/testdata/transaction_stake_delegate.json new file mode 100644 index 0000000000..526623f596 --- /dev/null +++ b/core/crates/gem_aptos/testdata/transaction_stake_delegate.json @@ -0,0 +1,142 @@ +{ + "version": "3517584511", + "hash": "0x130cc74c1a768780ca062a97bc833a01dec85b2d315484869559b7cdee4d0e75", + "state_change_hash": "0xee4f23a789b44cb889197953b3b0ecf4c1484a15c4b3cb3109c3b7374b2a24a5", + "event_root_hash": "0x50b74a32cba7c1945532fbc056a14711125e89114547441f451a9dc3acd86bbd", + "state_checkpoint_hash": null, + "gas_used": "1424", + "success": true, + "vm_status": "Executed successfully", + "accumulator_root_hash": "0x3287ad224cc1364cd141333f4fdce3bb58c1dfe9a6e6ad2525f2090aeb345486", + "changes": [], + "sender": "0xc95615aa095c100b18eb6eaa0f0a0f30b9cd96685118a7cbc1a2328a91ca2eda", + "sequence_number": "8", + "max_gas_amount": "2848", + "gas_unit_price": "100", + "expiration_timestamp_secs": "1759780189", + "payload": { + "function": "0x1::delegation_pool::add_stake", + "type_arguments": [], + "arguments": [ + "0xe5452230b8d5f4a664e33b8ad95354e50da64caaf003f11c0158391e96a4db2c", + "1100000000" + ], + "type": "entry_function_payload" + }, + "signature": { + "public_key": "0x91905d75d9b4be7e859353fa8321ce1967fb522d2bd8a4780cfe3676c92bd696", + "signature": "0xd95c207655511c79d26bf51bfcfbeaf09214a1099c2b14e7300268fff6246e2da558159ee3c58a9f375db327dab08487bffaadab376f5c966c13c512ac45e20d", + "type": "ed25519_signature" + }, + "replay_protection_nonce": null, + "events": [ + { + "guid": { + "creation_number": "20", + "account_address": "0xe5452230b8d5f4a664e33b8ad95354e50da64caaf003f11c0158391e96a4db2c" + }, + "sequence_number": "1778", + "type": "0x1::delegation_pool::DistributeCommissionEvent", + "data": { + "commission_active": "0", + "commission_pending_inactive": "0", + "operator": "0xa5f8b2eec1acee4f5a635b8046e30e1cc388a086717e0730ec903b7b4aca140d", + "pool_address": "0xe5452230b8d5f4a664e33b8ad95354e50da64caaf003f11c0158391e96a4db2c" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::delegation_pool::DistributeCommission", + "data": { + "beneficiary": "0xa5f8b2eec1acee4f5a635b8046e30e1cc388a086717e0730ec903b7b4aca140d", + "commission_active": "0", + "commission_pending_inactive": "0", + "operator": "0xa5f8b2eec1acee4f5a635b8046e30e1cc388a086717e0730ec903b7b4aca140d", + "pool_address": "0xe5452230b8d5f4a664e33b8ad95354e50da64caaf003f11c0158391e96a4db2c" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "1100000000", + "store": "0xa282afbc76d731ce5e78995a26d35e08a823aa918e6f96f95dd4a1d60291032a" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "1100000000", + "store": "0xb21de110fb278fdb319ee21291b34909cb8ab2617bdf98cfa64f1fb3adc1426d" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "1100000000", + "store": "0xb21de110fb278fdb319ee21291b34909cb8ab2617bdf98cfa64f1fb3adc1426d" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::stake::AddStake", + "data": { + "amount_added": "1100000000", + "pool_address": "0xe5452230b8d5f4a664e33b8ad95354e50da64caaf003f11c0158391e96a4db2c" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::delegation_pool::AddStake", + "data": { + "add_stake_fee": "15606", + "amount_added": "1100000000", + "delegator_address": "0xc95615aa095c100b18eb6eaa0f0a0f30b9cd96685118a7cbc1a2328a91ca2eda", + "pool_address": "0xe5452230b8d5f4a664e33b8ad95354e50da64caaf003f11c0158391e96a4db2c" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::transaction_fee::FeeStatement", + "data": { + "execution_gas_units": "24", + "io_gas_units": "30", + "storage_fee_octas": "137000", + "storage_fee_refund_octas": "0", + "total_charge_gas_units": "1424" + } + } + ], + "timestamp": "1759780099581734", + "type": "user_transaction" +} \ No newline at end of file diff --git a/core/crates/gem_aptos/testdata/transaction_stake_undelegate.json b/core/crates/gem_aptos/testdata/transaction_stake_undelegate.json new file mode 100644 index 0000000000..180c13522e --- /dev/null +++ b/core/crates/gem_aptos/testdata/transaction_stake_undelegate.json @@ -0,0 +1,552 @@ +{ + "version": "3521671381", + "hash": "0xef6430bef0e8de7090b2c4bce210adb75d648be4614dcc37232b0d67f819b137", + "state_change_hash": "0x291d04f66c6dbb60e624587ed58ad3e843f59366cf0eace325213e94a682147c", + "event_root_hash": "0x8497e4cbc326773fcf6e973ee04e8fca908d6ec18cbef716d97e1b2ddebb09ed", + "state_checkpoint_hash": null, + "gas_used": "884", + "success": true, + "vm_status": "Executed successfully", + "accumulator_root_hash": "0xca06a5017a56341b706479d6869ca5057ec15ac519268b6348c41f6f39ad2b12", + "changes": [ + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::coin::PairedCoinType", + "data": { + "type": { + "account_address": "0x1", + "module_name": "0x6170746f735f636f696e", + "struct_name": "0x4170746f73436f696e" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::coin::PairedFungibleAssetRefs", + "data": { + "burn_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + }, + "mint_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + }, + "transfer_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "27042055558621096" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 8, + "icon_uri": "", + "name": "Aptos Coin", + "project_uri": "", + "symbol": "APT" + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x1", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xa", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "state_key_hash": "0x80aac70129b028dddba5fc1784cbf85b925289cf54f375b8fc781c488b2ccbbe", + "data": { + "type": "0x1::account::Account", + "data": { + "authentication_key": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "coin_register_events": { + "counter": "8", + "guid": { + "id": { + "addr": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "creation_num": "0" + } + } + }, + "guid_creation_num": "18", + "key_rotation_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "creation_num": "1" + } + } + }, + "rotation_capability_offer": { + "for": { + "vec": [] + } + }, + "sequence_number": "71", + "signer_capability_offer": { + "for": { + "vec": [] + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xba019ce4c1cd7fa53a5ebd6b4252008731e92ad0e80fdbaf7e170fac35ac95f9", + "state_key_hash": "0xb7de0aac04fd1332c3a983d095a5d62849ef36baa1fde72d6b5c474a98fc14f1", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "1814027825", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xba019ce4c1cd7fa53a5ebd6b4252008731e92ad0e80fdbaf7e170fac35ac95f9", + "state_key_hash": "0xb7de0aac04fd1332c3a983d095a5d62849ef36baa1fde72d6b5c474a98fc14f1", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xba019ce4c1cd7fa53a5ebd6b4252008731e92ad0e80fdbaf7e170fac35ac95f9", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "state_key_hash": "0x783dc9516f2e2afec4ab696b453785a063eb077a04487cde5f1ed32409e6525f", + "data": { + "type": "0x1::stake::StakePool", + "data": { + "active": { + "value": "1429369626097825" + }, + "add_stake_events": { + "counter": "15441", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "6" + } + } + }, + "delegated_voter": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "distribute_rewards_events": { + "counter": "6131", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "12" + } + } + }, + "inactive": { + "value": "19550964978938" + }, + "increase_lockup_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "10" + } + } + }, + "initialize_validator_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "4" + } + } + }, + "join_validator_set_events": { + "counter": "1", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "11" + } + } + }, + "leave_validator_set_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "15" + } + } + }, + "locked_until_secs": "1759895056", + "operator_address": "0x95d02c012a39f2aceb93f597b6520f9827b3d1f430c9c796e12c117c38196a90", + "pending_active": { + "value": "1110000000" + }, + "pending_inactive": { + "value": "13664466648979" + }, + "reactivate_stake_events": { + "counter": "413", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "7" + } + } + }, + "rotate_consensus_key_events": { + "counter": "1", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "8" + } + } + }, + "set_operator_events": { + "counter": "3", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "5" + } + } + }, + "unlock_stake_events": { + "counter": "6881", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "13" + } + } + }, + "update_network_and_fullnode_addresses_events": { + "counter": "2", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "9" + } + } + }, + "withdraw_stake_events": { + "counter": "4887", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "14" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "state_key_hash": "0x3009ca34d0c56ce639e1e4df3f89eb282bbd94dabc018d84909bb590cdc098a1", + "data": { + "type": "0x1::delegation_pool::DelegationPool", + "data": { + "active_shares": { + "scaling_factor": "10000000000000000", + "shares": { + "inner": { + "handle": "0x2511b54b61427dbbd87841b27fcbded076ffeac4df9d09288138af5e1ace3bb2" + }, + "length": "4504" + }, + "total_coins": "1429370736097825", + "total_shares": "12743019957048015552426476015377" + }, + "add_stake_events": { + "counter": "15441", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "16" + } + } + }, + "distribute_commission_events": { + "counter": "94938", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "20" + } + } + }, + "inactive_shares": { + "handle": "0xf95912bc170199c7d08d176d89ea3d4e412945149810d92a3d3d3e75af299a63" + }, + "observed_lockup_cycle": { + "index": "34" + }, + "operator_commission_percentage": "500", + "pending_withdrawals": { + "handle": "0xd01c3d7ff876340c2a0518f820ef73dd6f9088641da26dce815de5246eca652" + }, + "reactivate_stake_events": { + "counter": "413", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "17" + } + } + }, + "stake_pool_signer_cap": { + "account": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e" + }, + "total_coins_inactive": "19550964978938", + "unlock_stake_events": { + "counter": "6975", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "18" + } + } + }, + "withdraw_stake_events": { + "counter": "4888", + "guid": { + "id": { + "addr": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "creation_num": "19" + } + } + } + } + }, + "type": "write_resource" + }, + { + "state_key_hash": "0xc49787d0ee0ea09c3c86f6fd5703fa316d131a3e64f6c89c4aa1dff68e21b413", + "handle": "0x0afd910603ed12ef327c1ef68d305c631a406fff024be0289d4de020916b6dc9", + "key": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "value": "0x60ca32dc467337ca0d29090000000000", + "data": null, + "type": "write_table_item" + }, + { + "state_key_hash": "0xa83c1e9249705ff66c392f66cf751acf19f1179f99cc752ceccf14412c6515c2", + "handle": "0x0d01c3d7ff876340c2a0518f820ef73dd6f9088641da26dce815de5246eca652", + "key": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "value": "0x2200000000000000", + "data": null, + "type": "write_table_item" + }, + { + "state_key_hash": "0xa7fa91fc23f771a472e9db6a9ecc64017e9a8a74afb5825c3f4cb4c93bdd8cd7", + "handle": "0x2511b54b61427dbbd87841b27fcbded076ffeac4df9d09288138af5e1ace3bb2", + "key": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "data": null, + "type": "delete_table_item" + }, + { + "state_key_hash": "0x045edcf40537b06a4a7864c1add014d71b6181151232ebcbef4a40190efd63c6", + "handle": "0xaadd838c653df0fc4b75902d431df4c75e88848632104ebf1681d9e91c383847", + "key": "0x5801000000000000", + "value": "0x0f58d16c5c756f1500b9f76ff3dda0e14597b82943287580dd5865d5b912f8a6a1e211fc423f59aa2b000000000000000000000000000000001fb83194aea855b48d9e10000000000000000000000000000000000000000000c842666600000000588989d469423c62804c741471b0f25743829c79cf6e5d1ef9ca93d394eccd6d99e7b0367c0e3ce400000000000000000000000000000000ea46d6b196b18c00c493090000000000000000000000000000000000000000000c9729670000000058195bb82fe1b5ed9c95d44cee36c20eba19603b9eb262228ef99ba0c2cf35d6fd697203c5345bf300000000000000000000000000000000b214769f2057050139a00c0000000000000000000000000000000000000000004f3cd06700000000582d5e97117f99e51bbcb9de7aebf14310e86f3f5e803ece833304f64c35b6e5f2678be12b89a3fbbd3565a1de8e6ef164dc08000000000000000000000000000000000000000000bd3565a1de8e6ef164dc080000000000c842666600000000587da03dbc3fbb08dc63a83c3d1516cc9b9fbb920cde605e315fa5a6254fd8541c8683f24f299bdd00000000000000000000000000000000e1511958289539ec08d6110000000000000000000000000000000000000000004b46f5670000000058953138c1da496ae174249ffa04e3d81b47b9b91ce944ee45c7254af571b4fa08c576eaae2e9e240000000000000000000000000000000002d158923cf8b094e9eee100000000000000000000000000000000000000000053193c6700000000589d51e3d94cb35e460fbf57f734e256e1b53bcb2657ffd672ebc3feb6dd4fceefc0f798da9c36ffc00ea9ebc2a2de005fc4080000000000000000000000000000000000000000000000000000000000000000000000000024a0046700000000586df43f7132066b723b4be7ebb8d2833646bbd9ff910094a7f8254af2c91faecd4b95476e5e2a53000000000000000000000000000000005e531301aeaf2fab8c7685050000000000000000000000000000000000000000d8aa73670000000058a118137556a7e64269f50bd25ffa003aa1f71d40ed62b206300f9e73d68c73bfcaee29d83839ebbb381618b6290831d3edbe010000000000000000000000000000000000000000bb381618b6290831d3edbe01000000000cfedc66000000005885b1c249f6d38843056d198a6f2a7f5acdca6de5586c7783c5b331c1cb040767cf4064d284e4f100000000000000000000000000000000c65c5d315a407a99710a0a000000000000000000000000000000000000000000731e61670000000058a97f7886b2f67ecb9a84abe46d53c34ef4d6193195a0222814d89cfcf87e13b140983d1fec760f000000000000000000000000000000008a5228c83db192309c54090000000000000000000000000000000000000000004b46f5670000000058b9eb8e48202e3b2b321b7048a9ada80e60f2dab3c7d1d13a20e4036f13e1de3f02cddc37ec79a775c6c3f8680ce3aa747b0800000000000000000000000000000000000000000075c6c3f8680ce3aa747b080000000000ed30ab67000000005839d8cb08133ab453b81356963667d6a342c9412766d6ac80c6a7ffdb0ba66672eb9a3037a26c410e7ae26a48ba9b879166100000000000000000000000000000000000000000000e7ae26a48ba9b8791661000000000004b46f56700000000586d271e56912a9326fdeefc83a1146fd3144d26d692e3c2ed90a690bcc69bb81de027fa272be2550000000000000000000000000000000015b6d0df6b03c4e95dce10010000000000000000000000000000000000000000b0d1766800000000581db493fc70103b6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc0000000000000000000000000000000060ca32dc467337ca0d290900000000000000000000000000000000000000000010dee56800000000", + "data": null, + "type": "write_table_item" + }, + { + "state_key_hash": "0x89b4d5a11548340405a2bbacf59e36900e801bad865b664bf7c21ad17fca8b52", + "handle": "0xf95912bc170199c7d08d176d89ea3d4e412945149810d92a3d3d3e75af299a63", + "key": "0x2200000000000000", + "value": "0x933fae816d0c0000ebd54134e07225f74dbd80b8010000000afd910603ed12ef327c1ef68d305c631a406fff024be0289d4de020916b6dc98c000000000000000000c16ff2862300", + "data": null, + "type": "write_table_item" + } + ], + "sender": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "sequence_number": "70", + "max_gas_amount": "1500", + "gas_unit_price": "100", + "expiration_timestamp_secs": "1759858585", + "payload": { + "function": "0x1::delegation_pool::unlock", + "type_arguments": [], + "arguments": [ + "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e", + "1109984251" + ], + "type": "entry_function_payload" + }, + "signature": { + "public_key": "0xd7cdf4d8e54ff77d0e5ea47c852dc3e7f59330d69319efeeefb34210109a6916", + "signature": "0x9f71de522f53df0c7afedb0e17eedfd42d8e16362ad77b807b91ce695ec139a9bc87a7bf487dae6bdae38996b3a63dc4e170b7fc9f5cf4bb6963a473344c7f01", + "type": "ed25519_signature" + }, + "replay_protection_nonce": null, + "events": [ + { + "guid": { + "creation_number": "20", + "account_address": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e" + }, + "sequence_number": "94937", + "type": "0x1::delegation_pool::DistributeCommissionEvent", + "data": { + "commission_active": "0", + "commission_pending_inactive": "0", + "operator": "0x95d02c012a39f2aceb93f597b6520f9827b3d1f430c9c796e12c117c38196a90", + "pool_address": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::delegation_pool::DistributeCommission", + "data": { + "beneficiary": "0x95d02c012a39f2aceb93f597b6520f9827b3d1f430c9c796e12c117c38196a90", + "commission_active": "0", + "commission_pending_inactive": "0", + "operator": "0x95d02c012a39f2aceb93f597b6520f9827b3d1f430c9c796e12c117c38196a90", + "pool_address": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::stake::UnlockStake", + "data": { + "amount_unlocked": "1109984251", + "pool_address": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::delegation_pool::UnlockStake", + "data": { + "amount_unlocked": "1109984251", + "delegator_address": "0x6467997d9c3a5bc9f714e17a168984595ce9bec7350645713a1fe7983a7f5fcc", + "pool_address": "0xdb5247f859ce63dbe8940cf8773be722a60dcc594a8be9aca4b76abceb251b8e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::transaction_fee::FeeStatement", + "data": { + "execution_gas_units": "15", + "io_gas_units": "9", + "storage_fee_octas": "86080", + "storage_fee_refund_octas": "43200", + "total_charge_gas_units": "884" + } + } + ], + "timestamp": "1759854986197626", + "type": "user_transaction" +} \ No newline at end of file diff --git a/core/crates/gem_aptos/testdata/transaction_swap_panora.json b/core/crates/gem_aptos/testdata/transaction_swap_panora.json new file mode 100644 index 0000000000..343a8c188e --- /dev/null +++ b/core/crates/gem_aptos/testdata/transaction_swap_panora.json @@ -0,0 +1,26083 @@ +{ + "version": "4120398629", + "hash": "0xf1c24162c08b6b8b452c00adad1836d72949902a8701611479d4e49fec0a9e3c", + "state_change_hash": "0xe0742bbb054d66ed9be74da762d53e582ec31cd806bf2c5f55e08e5b8716f3d8", + "event_root_hash": "0xacfc79cf780eda49e137f38fc00cf02f152fb8d14cf35fd06fa461546f2571b1", + "state_checkpoint_hash": null, + "gas_used": "1426", + "success": true, + "vm_status": "Executed successfully", + "accumulator_root_hash": "0x76a33e0d1b1ba2c806cde45171517a30dc6ff35bd472094c5f69704e0e0a8e38", + "changes": [ + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::coin::PairedCoinType", + "data": { + "type": { + "account_address": "0x1", + "module_name": "0x6170746f735f636f696e", + "struct_name": "0x4170746f73436f696e" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::coin::PairedFungibleAssetRefs", + "data": { + "burn_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + }, + "mint_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + }, + "transfer_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xa" + } + } + ] + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "29387265419032832" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 8, + "icon_uri": "", + "name": "Aptos Coin", + "project_uri": "", + "symbol": "APT" + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x1", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xa", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xa", + "state_key_hash": "0x1db5441d8fa4229c5844f73fd66da4ad8176cb8793d8b3a7f6ca858722030043", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x111ae3e5bc816a5e63c2da97d0aa3886519e0cd5e4b046659fa35796bd11542a", + "state_key_hash": "0xc93dc20a98d381574acdbe983b0acd42f46bce23ff8a0b652439acc609370c8f", + "data": { + "type": "0x1::coin::CoinInfo<0x111ae3e5bc816a5e63c2da97d0aa3886519e0cd5e4b046659fa35796bd11542a::stapt_token::StakedApt>", + "data": { + "decimals": 8, + "name": "Staked Aptos Coin", + "supply": { + "vec": [ + { + "aggregator": { + "vec": [] + }, + "integer": { + "vec": [ + { + "limit": "340282366920938463463374607431768211455", + "value": "386230977616628" + } + ] + } + } + ] + }, + "symbol": "stAPT" + } + }, + "type": "write_resource" + }, + { + "address": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7", + "state_key_hash": "0x83c379ff42400332e48e9edc5494bb396d44c3389014eefb7b99188d452948e8", + "data": { + "type": "0x1::fungible_asset::FungibleAssetEvents", + "data": { + "deposit_events": { + "counter": "34981", + "guid": { + "id": { + "addr": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7", + "creation_num": "1125899906842625" + } + } + }, + "frozen_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7", + "creation_num": "1125899906842627" + } + } + }, + "withdraw_events": { + "counter": "12", + "guid": { + "id": { + "addr": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7", + "creation_num": "1125899906842626" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7", + "state_key_hash": "0x83c379ff42400332e48e9edc5494bb396d44c3389014eefb7b99188d452948e8", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "984", + "frozen": false, + "metadata": { + "inner": "0x50fdfa97914bd00b656e3041e143f157c84931eb1ca7224b8a8570e7d5be70f2" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7", + "state_key_hash": "0x83c379ff42400332e48e9edc5494bb396d44c3389014eefb7b99188d452948e8", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842628", + "owner": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x151243cd05dd0e9cbf78b41cda61e1813c291c40ef92ad1ce4f1489bc1c113c5", + "state_key_hash": "0x102bf5c32ec1a25b35697bcfe1735de180eae14688eb8090fe05fab86988178d", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "17580703756472", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x151243cd05dd0e9cbf78b41cda61e1813c291c40ef92ad1ce4f1489bc1c113c5", + "state_key_hash": "0x102bf5c32ec1a25b35697bcfe1735de180eae14688eb8090fe05fab86988178d", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x151243cd05dd0e9cbf78b41cda61e1813c291c40ef92ad1ce4f1489bc1c113c5", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c", + "state_key_hash": "0xcee2d5801f3445bbfab007161334de4758369fb58dd36ab8e98c0ac8c3a21e04", + "data": { + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap::EventStore1", + "data": { + "swap_step_events": { + "counter": "7416352", + "guid": { + "id": { + "addr": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c", + "creation_num": "52" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c", + "state_key_hash": "0x6e33e9a1d0813dd09a641047f37e44682e36b2fe2f03e4abdef1f394c0a1463c", + "data": { + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_fees_structure::EventStore1", + "data": { + "swap_fee_events1": { + "counter": "35561", + "guid": { + "id": { + "addr": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c", + "creation_num": "49" + } + } + }, + "swap_fee_events2": { + "counter": "379421", + "guid": { + "id": { + "addr": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c", + "creation_num": "50" + } + } + }, + "swap_fee_events3": { + "counter": "0", + "guid": { + "id": { + "addr": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c", + "creation_num": "51" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c", + "state_key_hash": "0xce272b3b9f93ecbc96e807bb76664b746f86610ca9d4edd986922ad038b6a898", + "data": { + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap_aggregator_fungible_asset::EventStore1", + "data": { + "swap_step_events": { + "counter": "32209713", + "guid": { + "id": { + "addr": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c", + "creation_num": "48" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e", + "state_key_hash": "0x48dbbf5c54e4e473efe3b13b34f8edcd5ab65cc2240dbd5be7ee700e68513c98", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "0", + "frozen": false, + "metadata": { + "inner": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e", + "state_key_hash": "0x48dbbf5c54e4e473efe3b13b34f8edcd5ab65cc2240dbd5be7ee700e68513c98", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xb7cb159db88215cd1670edfe4e30df58177571610983fc06681f4c9773810b3e", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e", + "state_key_hash": "0x48dbbf5c54e4e473efe3b13b34f8edcd5ab65cc2240dbd5be7ee700e68513c98", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "state_key_hash": "0x07c6495a92b6eae74407fe5b158a61bd35a975ee62c700eb529c3d0947636294", + "data": { + "type": "0x1::coin::PairedCoinType", + "data": { + "type": { + "account_address": "0xf22bede237a07e121b56d91a491eb7bcdfd1f5907926a9e58338f964a01b17fa", + "module_name": "0x6173736574", + "struct_name": "0x55534443" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "state_key_hash": "0x07c6495a92b6eae74407fe5b158a61bd35a975ee62c700eb529c3d0947636294", + "data": { + "type": "0x1::coin::PairedFungibleAssetRefs", + "data": { + "burn_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8" + } + } + ] + }, + "mint_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8" + } + } + ] + }, + "transfer_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8" + } + } + ] + } + } + }, + "type": "write_resource" + }, + { + "address": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "state_key_hash": "0x07c6495a92b6eae74407fe5b158a61bd35a975ee62c700eb529c3d0947636294", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "3341504233234" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "state_key_hash": "0x07c6495a92b6eae74407fe5b158a61bd35a975ee62c700eb529c3d0947636294", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 6, + "icon_uri": "", + "name": "USD Coin", + "project_uri": "", + "symbol": "USDC" + } + }, + "type": "write_resource" + }, + { + "address": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "state_key_hash": "0x07c6495a92b6eae74407fe5b158a61bd35a975ee62c700eb529c3d0947636294", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0xa", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "state_key_hash": "0x07c6495a92b6eae74407fe5b158a61bd35a975ee62c700eb529c3d0947636294", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x305e7e0b0344a60a3012412e1b8e46665c45d9059f7b1b5aa0eda2fb2bbfa479", + "state_key_hash": "0x77b5679f064651d57512e8996c488475ac4fe5100aba95f801572503b727d997", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "13846821915", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x305e7e0b0344a60a3012412e1b8e46665c45d9059f7b1b5aa0eda2fb2bbfa479", + "state_key_hash": "0x77b5679f064651d57512e8996c488475ac4fe5100aba95f801572503b727d997", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x75b4890de3e312d9425408c43d9a9752b64ab3562a30e89a55bdc568c645920", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x305e7e0b0344a60a3012412e1b8e46665c45d9059f7b1b5aa0eda2fb2bbfa479", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x305e7e0b0344a60a3012412e1b8e46665c45d9059f7b1b5aa0eda2fb2bbfa479", + "state_key_hash": "0x77b5679f064651d57512e8996c488475ac4fe5100aba95f801572503b727d997", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82", + "state_key_hash": "0xf363d5ee629022e8dac00804bffe8a76803f8b4c6db8115e24213fc11c848aa6", + "data": { + "type": "0x1::fungible_asset::FungibleAssetEvents", + "data": { + "deposit_events": { + "counter": "39230", + "guid": { + "id": { + "addr": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82", + "creation_num": "1125899906842625" + } + } + }, + "frozen_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82", + "creation_num": "1125899906842627" + } + } + }, + "withdraw_events": { + "counter": "35419", + "guid": { + "id": { + "addr": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82", + "creation_num": "1125899906842626" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82", + "state_key_hash": "0xf363d5ee629022e8dac00804bffe8a76803f8b4c6db8115e24213fc11c848aa6", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "121353494357", + "frozen": false, + "metadata": { + "inner": "0x416416665d52c0868d5a9f02801a4035bfcfb24f117618b2b6c1f921128caf3b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82", + "state_key_hash": "0xf363d5ee629022e8dac00804bffe8a76803f8b4c6db8115e24213fc11c848aa6", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842628", + "owner": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x3adab3cb7723d75180dae65c7e75802cd9adec7eb387f50bec250d33d3313d36", + "state_key_hash": "0x00bf557aae381b6f48f04e059678fd1b68a30abf79293f54fa0cc4bec23ca8da", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "1591522552", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x3adab3cb7723d75180dae65c7e75802cd9adec7eb387f50bec250d33d3313d36", + "state_key_hash": "0x00bf557aae381b6f48f04e059678fd1b68a30abf79293f54fa0cc4bec23ca8da", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xb7cb159db88215cd1670edfe4e30df58177571610983fc06681f4c9773810b3e", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x3adab3cb7723d75180dae65c7e75802cd9adec7eb387f50bec250d33d3313d36", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x4069c13a15c5611127c471cce83799a0e88555857f7ad21778204a137b46b36f", + "state_key_hash": "0x3435905839d6b66663f4605ae996a90414b27f5a425e2060b2a4c092296ae1ad", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "1727822036", + "frozen": false, + "metadata": { + "inner": "0xc2b4d1ebff237b44da4272faf2fa1b62c877da0d1949f76b3ee1ead7b5090cc0" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x4069c13a15c5611127c471cce83799a0e88555857f7ad21778204a137b46b36f", + "state_key_hash": "0x3435905839d6b66663f4605ae996a90414b27f5a425e2060b2a4c092296ae1ad", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x92b0e7194ae1b55cc2b55c127dac4c6a37a832a10bea4f68f02855f997ae3066", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x4069c13a15c5611127c471cce83799a0e88555857f7ad21778204a137b46b36f", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x416416665d52c0868d5a9f02801a4035bfcfb24f117618b2b6c1f921128caf3b", + "state_key_hash": "0x9f745b7566cdd42094ab7a5e63d512607254928a6f3ef9e8074f6202c142ebb7", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 8, + "icon_uri": "", + "name": "Staked Aptos Coin", + "project_uri": "", + "symbol": "stAPT" + } + }, + "type": "write_resource" + }, + { + "address": "0x416416665d52c0868d5a9f02801a4035bfcfb24f117618b2b6c1f921128caf3b", + "state_key_hash": "0x9f745b7566cdd42094ab7a5e63d512607254928a6f3ef9e8074f6202c142ebb7", + "data": { + "type": "0x1::fungible_asset::Supply", + "data": { + "current": "277045609347", + "maximum": { + "vec": [] + } + } + }, + "type": "write_resource" + }, + { + "address": "0x416416665d52c0868d5a9f02801a4035bfcfb24f117618b2b6c1f921128caf3b", + "state_key_hash": "0x9f745b7566cdd42094ab7a5e63d512607254928a6f3ef9e8074f6202c142ebb7", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x3b38735644d0be8ac37ebd84a1e42fa5c2487495ef8782f6c694b1a147f82426", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x416416665d52c0868d5a9f02801a4035bfcfb24f117618b2b6c1f921128caf3b", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x416416665d52c0868d5a9f02801a4035bfcfb24f117618b2b6c1f921128caf3b", + "state_key_hash": "0x9f745b7566cdd42094ab7a5e63d512607254928a6f3ef9e8074f6202c142ebb7", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0x416416665d52c0868d5a9f02801a4035bfcfb24f117618b2b6c1f921128caf3b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x45e5b44b6ac976f0a8fe93b503054ee64e6fb7e5bdb89831b5f1d551705ed6fa", + "state_key_hash": "0x5b0e0555255ba46c8390e2cee987f890e88ba8c7ce1d4c15092b4a69c7775db7", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "332370380477", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x45e5b44b6ac976f0a8fe93b503054ee64e6fb7e5bdb89831b5f1d551705ed6fa", + "state_key_hash": "0x5b0e0555255ba46c8390e2cee987f890e88ba8c7ce1d4c15092b4a69c7775db7", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x45e5b44b6ac976f0a8fe93b503054ee64e6fb7e5bdb89831b5f1d551705ed6fa", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x45e5b44b6ac976f0a8fe93b503054ee64e6fb7e5bdb89831b5f1d551705ed6fa", + "state_key_hash": "0x5b0e0555255ba46c8390e2cee987f890e88ba8c7ce1d4c15092b4a69c7775db7", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x49a9c1f633089baecc2386dfcc3383470c70825c2c25673601d2123af4ab42c9", + "state_key_hash": "0xf8a3b34ebd92ed0246ac672a2ea9e5c293e825c4b8cac7868cf976cfb8673a30", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "386154528", + "frozen": false, + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x49a9c1f633089baecc2386dfcc3383470c70825c2c25673601d2123af4ab42c9", + "state_key_hash": "0xf8a3b34ebd92ed0246ac672a2ea9e5c293e825c4b8cac7868cf976cfb8673a30", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xd0b17bea776bb87b70b2fb2ca631014f0ca94fc1acde4b8ff1a763f4172aa6c4", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x49a9c1f633089baecc2386dfcc3383470c70825c2c25673601d2123af4ab42c9", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x4e204b7064be63d9686c51572ade53816743593aa0d63f7f51e72ceb1592c2be", + "state_key_hash": "0xcc8a6ee898beabdc6014d8df90000733ab29e55b59bc285d9316b0feac06f0a9", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "146418424", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x4e204b7064be63d9686c51572ade53816743593aa0d63f7f51e72ceb1592c2be", + "state_key_hash": "0xcc8a6ee898beabdc6014d8df90000733ab29e55b59bc285d9316b0feac06f0a9", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x7730cd28ee1cdc9e999336cbc430f99e7c44397c0aa77516f6f23a78559bb5", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x4e204b7064be63d9686c51572ade53816743593aa0d63f7f51e72ceb1592c2be", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x4e204b7064be63d9686c51572ade53816743593aa0d63f7f51e72ceb1592c2be", + "state_key_hash": "0xcc8a6ee898beabdc6014d8df90000733ab29e55b59bc285d9316b0feac06f0a9", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "state_key_hash": "0x0bc248a7ef376c1232c096a8271ab649a2001ff8612e95169be06ca447e6fbf1", + "data": { + "type": "0x1::account::Account", + "data": { + "authentication_key": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "coin_register_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "creation_num": "0" + } + } + }, + "guid_creation_num": "2", + "key_rotation_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "creation_num": "1" + } + } + }, + "rotation_capability_offer": { + "for": { + "vec": [] + } + }, + "sequence_number": "5", + "signer_capability_offer": { + "for": { + "vec": [] + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "state_key_hash": "0x4f2373bdac7ce7265e3daa022021c3e50d008ead2e74fbfe9a1334f817dfd4c1", + "data": { + "type": "0x487e905f899ccb6d46fdaec56ba1e0c4cf119862a16c409904b8c78fab1f5e8a::hook_factory::PoolMeta", + "data": { + "assets": [ + "0xa", + "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" + ], + "hook_type": 3, + "is_paused": false, + "platform_fee_rate": "330000", + "pool_addr": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "reserves": [ + "8942790243600", + "171075851618" + ] + } + }, + "type": "write_resource" + }, + { + "address": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "state_key_hash": "0xbdda5441f3335d5ff156f4ca418b156c944b24f4cb907ae315d616a41e4e2ea1", + "data": { + "type": "0x5c2e5a4d1b355b939ab160c618ed5504a6e1addf109388aa3b83b73b207ab6c7::clmm::Pool", + "data": { + "asset_a": "0xa", + "asset_b": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", + "campaigns": [ + { + "campaign_idx": "6", + "end_time": "1755838014", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1755838014", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "10", + "min_leaf_index": "12", + "nodes": { + "__variant__": "V1", + "new_slot_index": "45", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xa50d1e51ce4d6a52238e8204cd18532adedd16aeec310ae8ae32da3da9d8cd3d" + }, + "length": "11" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "2742", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": "3438", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": "3973", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "30" + } + } + }, + { + "key": "5144", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "32" + } + } + }, + { + "key": "5250", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "33" + } + } + }, + { + "key": "5348", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "37" + } + } + }, + { + "key": "18264", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "39" + } + } + }, + { + "key": "18349", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "40" + } + } + }, + { + "key": "18508", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "43" + } + } + }, + { + "key": "19773", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "44" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "80968", + "reward_growth_global": "195393684784544030", + "start_time": "1755233214", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "20", + "nodes": { + "__variant__": "V1", + "new_slot_index": "21", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x8dc76ddb05375f5f75773c442f2a73284d5a2bf9c7385d747338f427485f2e33" + }, + "length": "11" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510706" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": { + "bits": "18446744073709511566" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": { + "bits": "18446744073709513496" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": { + "bits": "18446744073709514756" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": { + "bits": "18446744073709516206" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709517386" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709518496" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709519696" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709520636" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709521756" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709535516" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "48968885600", + "total_rewards": "48968885600", + "unclaimed_rewards": "0" + }, + { + "campaign_idx": "7", + "end_time": "1756702032", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1756702032", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "28", + "min_leaf_index": "31", + "nodes": { + "__variant__": "V1", + "new_slot_index": "87", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xbefae79faf86cfe70c20f82503fee29151067c295c2f2d3aa2b6962f88cb68ec" + }, + "length": "10" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "2823", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "31" + } + } + }, + { + "key": "3508", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "34" + } + } + }, + { + "key": "4145", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "36" + } + } + }, + { + "key": "5155", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "38" + } + } + }, + { + "key": "5261", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "39" + } + } + }, + { + "key": "18148", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "41" + } + } + }, + { + "key": "18360", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "43" + } + } + }, + { + "key": "18435", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "44" + } + } + }, + { + "key": "18524", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "86" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "28" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "80968", + "reward_growth_global": "297913483403119472", + "start_time": "1755838032", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "20", + "nodes": { + "__variant__": "V1", + "new_slot_index": "21", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xd6bd0f82378a049419d6db7c114a71d0bc3535974ed2b218c3084f8a135dcc60" + }, + "length": "11" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510666" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": { + "bits": "18446744073709511526" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": { + "bits": "18446744073709513266" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": { + "bits": "18446744073709514566" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": { + "bits": "18446744073709516076" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709517326" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709518456" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709519646" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709520566" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709521736" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709535516" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "69955550000", + "total_rewards": "69955550000", + "unclaimed_rewards": "0" + }, + { + "campaign_idx": "8", + "end_time": "1757306843", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1757306843", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "14", + "min_leaf_index": "19", + "nodes": { + "__variant__": "V1", + "new_slot_index": "50", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x772ae72f65d66881751d7088245e217fad749690537d2fbe9c6d430dcfd76714" + }, + "length": "10" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "3039", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": "3971", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "35" + } + } + }, + { + "key": "5144", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "37" + } + } + }, + { + "key": "5250", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "38" + } + } + }, + { + "key": "5348", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "42" + } + } + }, + { + "key": "18264", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "44" + } + } + }, + { + "key": "18349", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "45" + } + } + }, + { + "key": "18508", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "48" + } + } + }, + { + "key": "19773", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "49" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "89722", + "reward_growth_global": "241730075159680140", + "start_time": "1756702043", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "20", + "nodes": { + "__variant__": "V1", + "new_slot_index": "21", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x2c5820a0983c8e4f31689ec122d3f7c579449b50fd05997f2b1e3018f114fae9" + }, + "length": "11" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510666" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": { + "bits": "18446744073709511526" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": { + "bits": "18446744073709513216" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": { + "bits": "18446744073709514476" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": { + "bits": "18446744073709516036" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709517276" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709518396" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709519606" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709520526" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709521676" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709535516" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "54263500000", + "total_rewards": "54263500000", + "unclaimed_rewards": "0" + }, + { + "campaign_idx": "9", + "end_time": "1758515311", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1758515311", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "14", + "min_leaf_index": "16", + "nodes": { + "__variant__": "V1", + "new_slot_index": "380", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x5b32b842be18ac940a7b9c982e78dfcea9291a6c985de276064cef9c93830e5d" + }, + "length": "10" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "3319", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": "3840", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "26" + } + } + }, + { + "key": "5137", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "28" + } + } + }, + { + "key": "5243", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "29" + } + } + }, + { + "key": "5341", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "373" + } + } + }, + { + "key": "18257", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "375" + } + } + }, + { + "key": "18342", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "376" + } + } + }, + { + "key": "18485", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "378" + } + } + }, + { + "key": "18582", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "379" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "90047", + "reward_growth_global": "629021729389812464", + "start_time": "1757309611", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "20", + "nodes": { + "__variant__": "V1", + "new_slot_index": "21", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xe649b2e689f1fa171568406f1f7c032e6512ac641bcba89d435d6c8392fa5ac4" + }, + "length": "11" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510666" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": { + "bits": "18446744073709511526" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": { + "bits": "18446744073709513226" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": { + "bits": "18446744073709514486" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": { + "bits": "18446744073709516046" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709517296" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709518446" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709519636" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709520566" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709521756" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709535516" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "103864890484", + "total_rewards": "108569000000", + "unclaimed_rewards": "4704109516" + }, + { + "campaign_idx": "10", + "end_time": "1759293232", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1759293232", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "10", + "min_leaf_index": "12", + "nodes": { + "__variant__": "V1", + "new_slot_index": "26", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x4b627498ac24242fbc79c363cb366e385220e46015d96f6f5201db8d955300bc" + }, + "length": "8" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "4148", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": "5164", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": "5304", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": "18157", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": "18308", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": "18443", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "22" + } + } + }, + { + "key": "18547", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "25" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "90567", + "reward_growth_global": "599829054720675647", + "start_time": "1758522232", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "20", + "nodes": { + "__variant__": "V1", + "new_slot_index": "22", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x2e6f7ddbb735a497309d2ed9cdee6af95e4de7878ffb0a3ec80459288342e191" + }, + "length": "12" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510526" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": { + "bits": "18446744073709511386" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": { + "bits": "18446744073709512266" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": { + "bits": "18446744073709514086" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": { + "bits": "18446744073709515566" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709516676" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709517656" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709518806" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709520016" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709521396" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709522266" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "21" + } + } + }, + { + "key": { + "bits": "18446744073709535516" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "64381422237", + "total_rewards": "69826500000", + "unclaimed_rewards": "5445077763" + }, + { + "campaign_idx": "11", + "end_time": "1759381081", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1759381081", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "14", + "min_leaf_index": "15", + "nodes": { + "__variant__": "V1", + "new_slot_index": "66", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xacb0c44a19caac95783bf2363ea745429f6bb89153502924ea787b4d31c63eed" + }, + "length": "8" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "4146", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": "5156", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": "5274", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": "18149", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": "18361", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "22" + } + } + }, + { + "key": "18436", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "23" + } + } + }, + { + "key": "18525", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "65" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "658561", + "reward_growth_global": "2097742158875963", + "start_time": "1759293275", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "20", + "nodes": { + "__variant__": "V1", + "new_slot_index": "21", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xaff431878c6c49a82ebac278e47071c1e0c3a66ab3bca46ce923de937f47227c" + }, + "length": "11" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510626" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": { + "bits": "18446744073709511486" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": { + "bits": "18446744073709512496" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": { + "bits": "18446744073709514186" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": { + "bits": "18446744073709515686" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709516796" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709517766" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709518856" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709521106" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709522016" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709533286" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "267252572", + "total_rewards": "343768842", + "unclaimed_rewards": "76516270" + }, + { + "campaign_idx": "12", + "end_time": "1759726025", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1759726025", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "14", + "min_leaf_index": "15", + "nodes": { + "__variant__": "V1", + "new_slot_index": "66", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x72caee50ad68b2830ec04f8a29bbf70c8cfa827558da4c35ccc5be8a6d6d1ed9" + }, + "length": "8" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "4146", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": "5156", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": "5274", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": "18149", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": "18361", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "22" + } + } + }, + { + "key": "18436", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "23" + } + } + }, + { + "key": "18525", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "65" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "87806", + "reward_growth_global": "488711130903760684", + "start_time": "1759294025", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "20", + "nodes": { + "__variant__": "V1", + "new_slot_index": "21", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x9f21feced42207da2d7e3c13c3919842f17b1a22fecbda87ce5c02daf6f54d5f" + }, + "length": "11" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510626" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": { + "bits": "18446744073709511486" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": { + "bits": "18446744073709512496" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": { + "bits": "18446744073709514186" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": { + "bits": "18446744073709515686" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709516796" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709517766" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709518856" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709521106" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709522016" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709533286" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "36255042376", + "total_rewards": "37932000000", + "unclaimed_rewards": "1676957624" + }, + { + "campaign_idx": "13", + "end_time": "1761971299", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1761971299", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "10", + "min_leaf_index": "15", + "nodes": { + "__variant__": "V1", + "new_slot_index": "25", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x1a36806da668ae8d25cea4d338cf31dbe6b522786b73f9fa2b5ff1e0a5748ca8" + }, + "length": "8" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "5118", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": "5220", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": "5322", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": "18238", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": "18323", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "21" + } + } + }, + { + "key": "18466", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "23" + } + } + }, + { + "key": "18564", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "24" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "88158", + "reward_growth_global": "5464204504586883011", + "start_time": "1759733899", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "19", + "nodes": { + "__variant__": "V1", + "new_slot_index": "20", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x82ace1ec26c21c5142505dd6060f3f01d78d1880f9603b6e65625f5d416d300" + }, + "length": "10" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510806" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "19" + } + } + }, + { + "key": { + "bits": "18446744073709511666" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": { + "bits": "18446744073709513646" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": { + "bits": "18446744073709514916" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709516276" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709517396" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709518396" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709519926" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709521836" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709533286" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "183981672266", + "total_rewards": "197244000000", + "unclaimed_rewards": "13262327734" + }, + { + "campaign_idx": "14", + "end_time": "1762489837", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1762489837", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "12", + "min_leaf_index": "14", + "nodes": { + "__variant__": "V1", + "new_slot_index": "22", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x6515482449e57508fe9c522cc27c83e3105f1f73b819c838a23eb5b4aa7534e0" + }, + "length": "7" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "5226", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": "5327", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": "18243", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": "18328", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": "18471", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": "18569", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "21" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "75237", + "reward_growth_global": "1031179991728393970", + "start_time": "1761978637", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "16", + "nodes": { + "__variant__": "V1", + "new_slot_index": "17", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xef3647dc17ddaaba5d4c34e57cf04f8ae2168cb9ba1d25f044ae72b9816443e3" + }, + "length": "7" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510166" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": { + "bits": "18446744073709511026" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709511886" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709513856" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709515266" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709516546" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709526086" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "32454725208", + "total_rewards": "38461000000", + "unclaimed_rewards": "6006274792" + }, + { + "campaign_idx": "15", + "end_time": "1764562738", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1764562738", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "12", + "min_leaf_index": "14", + "nodes": { + "__variant__": "V1", + "new_slot_index": "22", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xc268fa91a91774f3fc037ece6fc4cc26840dd2b7114cf25894e1e93dd323af24" + }, + "length": "7" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "5229", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": "5330", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": "18246", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": "18331", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "18" + } + } + }, + { + "key": "18474", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": "18572", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "21" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "77306", + "reward_growth_global": "2676020108650646290", + "start_time": "1762493638", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "15", + "nodes": { + "__variant__": "V1", + "new_slot_index": "16", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x8d8630d96cd60162b55c750cb3355253c65df0095e3beb772e8b5750eb0827f2" + }, + "length": "6" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510796" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": { + "bits": "18446744073709511656" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": { + "bits": "18446744073709513446" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": { + "bits": "18446744073709514836" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709516616" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709525036" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "117362838451", + "total_rewards": "159952000000", + "unclaimed_rewards": "42589161549" + }, + { + "campaign_idx": "16", + "end_time": "1765167805", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1765167805", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "10", + "min_leaf_index": "11", + "nodes": { + "__variant__": "V1", + "new_slot_index": "18", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x4d685a3090c80d0325abe3f53220e5303bea8711383589dafafdb071184be352" + }, + "length": "6" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "5320", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": "18236", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": "18321", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "14" + } + } + }, + { + "key": "18458", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "16" + } + } + }, + { + "key": "18562", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "17" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "66573", + "reward_growth_global": "1282352241884650998", + "start_time": "1764564205", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "12", + "nodes": { + "__variant__": "V1", + "new_slot_index": "13", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xc2a84235a1123e37c1b1fd7ae209b0184a178142903cb15e5faf2d51501c9520" + }, + "length": "3" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510716" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709511576" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709525036" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "34352113222", + "total_rewards": "40183000000", + "unclaimed_rewards": "5830886778" + }, + { + "campaign_idx": "17", + "end_time": "1767241838", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1767241838", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "10", + "min_leaf_index": "11", + "nodes": { + "__variant__": "V1", + "new_slot_index": "58", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x1e21014da133f803e617355f7b150a6defab7b233a122749ef56e5f89d6b752a" + }, + "length": "6" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "7331", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": "18150", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": "18288", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": "18436", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "15" + } + } + }, + { + "key": "18525", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "57" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "57360", + "reward_growth_global": "3695666670527368133", + "start_time": "1765177238", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "12", + "nodes": { + "__variant__": "V1", + "new_slot_index": "13", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xd184f414b1fcc2109de8fd6492f8892810da4e760b7c3b4344d45c618d1d5c22" + }, + "length": "3" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510516" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "12" + } + } + }, + { + "key": { + "bits": "18446744073709511376" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709525036" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "90790842942", + "total_rewards": "118425000000", + "unclaimed_rewards": "27634157058" + }, + { + "campaign_idx": "18", + "end_time": "1767846875", + "is_active": false, + "last_active_liquidity": "1179550690946", + "last_update_time": "1767846875", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "1", + "min_leaf_index": "1", + "nodes": { + "__variant__": "V1", + "new_slot_index": "10", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "14050", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "4986403381109289", + "rewards_owed": "355" + } + } + }, + { + "key": "14095", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "7236567675684608", + "rewards_owed": "5164" + } + } + }, + { + "key": "18589", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "18590", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "33817164533477399", + "rewards_owed": "0" + } + } + }, + { + "key": "19222", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1251689842905220995", + "rewards_owed": "0" + } + } + }, + { + "key": "19773", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19775", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19776", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19777", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19778", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19779", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19780", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19781", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19782", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19783", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19784", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19785", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19786", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "19931", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20072", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "836400089293891017", + "rewards_owed": "0" + } + } + }, + { + "key": "20103", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20104", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20108", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1503074288130790544", + "rewards_owed": "0" + } + } + }, + { + "key": "20112", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20113", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1425990625371982827", + "rewards_owed": "0" + } + } + }, + { + "key": "20114", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "210394974797484800", + "rewards_owed": "0" + } + } + }, + { + "key": "20115", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20117", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "254809692013766770", + "rewards_owed": "0" + } + } + }, + { + "key": "20118", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20119", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "255703598009788167", + "rewards_owed": "0" + } + } + }, + { + "key": "20121", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20122", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20123", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "41019764203709082", + "rewards_owed": "0" + } + } + }, + { + "key": "20125", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1540129637446441305", + "rewards_owed": "0" + } + } + }, + { + "key": "20128", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20131", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20136", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "41019764203709082", + "rewards_owed": "0" + } + } + }, + { + "key": "20137", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "64056733610709292", + "rewards_owed": "0" + } + } + }, + { + "key": "20138", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "63976879368893059", + "rewards_owed": "0" + } + } + }, + { + "key": "20139", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20140", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20141", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "39507367127750549", + "rewards_owed": "0" + } + } + }, + { + "key": "20142", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "449624904665234655", + "rewards_owed": "0" + } + } + }, + { + "key": "20146", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20152", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20153", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1298210367483526496", + "rewards_owed": "0" + } + } + }, + { + "key": "20154", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1298210367483526496", + "rewards_owed": "0" + } + } + }, + { + "key": "20155", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1504180724227060759", + "rewards_owed": "0" + } + } + }, + { + "key": "20157", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20158", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20160", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20161", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "307817591419604843", + "rewards_owed": "0" + } + } + }, + { + "key": "20162", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1531186193786566154", + "rewards_owed": "0" + } + } + }, + { + "key": "20163", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20164", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20165", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "402830306637867409", + "rewards_owed": "0" + } + } + }, + { + "key": "20166", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "280318303718862671", + "rewards_owed": "0" + } + } + }, + { + "key": "20167", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "190815786478518681", + "rewards_owed": "0" + } + } + }, + { + "key": "20168", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1144644697416048901", + "rewards_owed": "0" + } + } + }, + { + "key": "20170", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20172", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1109224538005624083", + "rewards_owed": "0" + } + } + }, + { + "key": "20173", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20174", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "90934940823649723", + "rewards_owed": "0" + } + } + }, + { + "key": "20176", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "413626487208687711", + "rewards_owed": "0" + } + } + }, + { + "key": "20177", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1527110311510205452", + "rewards_owed": "0" + } + } + }, + { + "key": "20179", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "7992637918856940", + "rewards_owed": "0" + } + } + }, + { + "key": "20180", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20181", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1121508215166170146", + "rewards_owed": "0" + } + } + }, + { + "key": "20182", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "403566999242830763", + "rewards_owed": "0" + } + } + }, + { + "key": "20183", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1555624226463170654", + "rewards_owed": "0" + } + } + }, + { + "key": "20184", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1347893604927023124", + "rewards_owed": "0" + } + } + }, + { + "key": "20185", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1554471847635859611", + "rewards_owed": "0" + } + } + }, + { + "key": "20186", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "190953877665435751", + "rewards_owed": "0" + } + } + }, + { + "key": "20187", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20188", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20190", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "366202649553799554", + "rewards_owed": "0" + } + } + }, + { + "key": "20191", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1144644697416048901", + "rewards_owed": "0" + } + } + }, + { + "key": "20192", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "454156182520122644", + "rewards_owed": "0" + } + } + }, + { + "key": "20193", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "190953877665435751", + "rewards_owed": "0" + } + } + }, + { + "key": "20194", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1144644697416048901", + "rewards_owed": "0" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "1144644697416048901", + "rewards_owed": "0" + } + } + } + ] + }, + "is_leaf": true, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "66905", + "reward_growth_global": "1555624226463170654", + "start_time": "1767246275", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "10", + "min_leaf_index": "11", + "nodes": { + "__variant__": "V1", + "new_slot_index": "12", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x47b9ac68cd439a9ceb59920d56f7288694f6a19c029c865ada5fb9dda72dcbc6" + }, + "length": "2" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709511996" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "11" + } + } + }, + { + "key": { + "bits": "18446744073709525036" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "27021044793", + "total_rewards": "40183000000", + "unclaimed_rewards": "13161955207" + }, + { + "campaign_idx": "19", + "end_time": "1768967957", + "is_active": true, + "last_active_liquidity": "1179550690946", + "last_update_time": "1768436509", + "position_campaign_data": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 128, + "max_leaf_index": "1", + "min_leaf_index": "1", + "nodes": { + "__variant__": "V1", + "new_slot_index": "10", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "20108", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "70986469838523407", + "rewards_owed": "0" + } + } + }, + { + "key": "20112", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20113", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20114", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "136695987323228180", + "rewards_owed": "0" + } + } + }, + { + "key": "20115", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20117", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "218155592778050426", + "rewards_owed": "0" + } + } + }, + { + "key": "20118", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20119", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "268066540887898693", + "rewards_owed": "0" + } + } + }, + { + "key": "20121", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20122", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "5815100782451387", + "rewards_owed": "0" + } + } + }, + { + "key": "20123", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "355081387092929604", + "rewards_owed": "0" + } + } + }, + { + "key": "20125", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20128", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "3437097713276748", + "rewards_owed": "0" + } + } + }, + { + "key": "20131", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20136", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "518918635485697790", + "rewards_owed": "0" + } + } + }, + { + "key": "20137", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "233018877572346766", + "rewards_owed": "0" + } + } + }, + { + "key": "20138", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20139", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20140", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20141", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20142", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20146", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20152", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20153", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "510124381868939983", + "rewards_owed": "0" + } + } + }, + { + "key": "20154", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "514818780118671737", + "rewards_owed": "0" + } + } + }, + { + "key": "20155", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "503594657797638721", + "rewards_owed": "0" + } + } + }, + { + "key": "20157", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "178675805181659136", + "rewards_owed": "0" + } + } + }, + { + "key": "20158", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20160", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20161", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "37830542857119636", + "rewards_owed": "0" + } + } + }, + { + "key": "20162", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "577631044704779171", + "rewards_owed": "0" + } + } + }, + { + "key": "20163", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "6083081261568206", + "rewards_owed": "0" + } + } + }, + { + "key": "20164", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "6450275453272732", + "rewards_owed": "0" + } + } + }, + { + "key": "20165", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "594808164466937301", + "rewards_owed": "0" + } + } + }, + { + "key": "20166", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20167", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20168", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "9550897457917610", + "rewards_owed": "0" + } + } + }, + { + "key": "20170", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20172", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "30739697109013223", + "rewards_owed": "0" + } + } + }, + { + "key": "20173", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20174", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "32661499630193060", + "rewards_owed": "0" + } + } + }, + { + "key": "20176", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20177", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "650330840555187983", + "rewards_owed": "0" + } + } + }, + { + "key": "20179", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20180", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20181", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "97581113202983642", + "rewards_owed": "0" + } + } + }, + { + "key": "20182", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20183", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20184", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "271909115263695", + "rewards_owed": "54507" + } + } + }, + { + "key": "20185", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20186", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20187", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "0", + "rewards_owed": "0" + } + } + }, + { + "key": "20188", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "14035017274049606", + "rewards_owed": "0" + } + } + }, + { + "key": "20190", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "52446024423384032", + "rewards_owed": "0" + } + } + }, + { + "key": "20191", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "103287200162062072", + "rewards_owed": "0" + } + } + }, + { + "key": "20192", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "102887487388262147", + "rewards_owed": "0" + } + } + }, + { + "key": "20193", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "2916672958120640", + "rewards_owed": "0" + } + } + }, + { + "key": "20194", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "104203770250265019", + "rewards_owed": "0" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Leaf", + "value": { + "reward_growth_inside_last": "104467744455591789", + "rewards_owed": "0" + } + } + } + ] + }, + "is_leaf": true, + "next": "0", + "prev": "0" + } + }, + "rate_per_second": "51846", + "reward_growth_global": "716025320669911381", + "start_time": "1767868157", + "tick_reward_growth": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 170, + "max_leaf_index": "1", + "min_leaf_index": "1", + "nodes": { + "__variant__": "V1", + "new_slot_index": "10", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "443630" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709107986" + }, + "value": { + "__variant__": "Leaf", + "value": "181282222448637075" + } + }, + { + "key": { + "bits": "18446744073709506376" + }, + "value": { + "__variant__": "Leaf", + "value": "391542520811047679" + } + }, + { + "key": { + "bits": "18446744073709509616" + }, + "value": { + "__variant__": "Leaf", + "value": "411726887044441588" + } + }, + { + "key": { + "bits": "18446744073709510266" + }, + "value": { + "__variant__": "Leaf", + "value": "62844595154796190" + } + }, + { + "key": { + "bits": "18446744073709510446" + }, + "value": { + "__variant__": "Leaf", + "value": "205414011583619848" + } + }, + { + "key": { + "bits": "18446744073709510736" + }, + "value": { + "__variant__": "Leaf", + "value": "174102653656170756" + } + }, + { + "key": { + "bits": "18446744073709510806" + }, + "value": { + "__variant__": "Leaf", + "value": "504737510573939793" + } + }, + { + "key": { + "bits": "18446744073709510866" + }, + "value": { + "__variant__": "Leaf", + "value": "381982959581202857" + } + }, + { + "key": { + "bits": "18446744073709510926" + }, + "value": { + "__variant__": "Leaf", + "value": "445491654074905811" + } + }, + { + "key": { + "bits": "18446744073709510936" + }, + "value": { + "__variant__": "Leaf", + "value": "533318883803456655" + } + }, + { + "key": { + "bits": "18446744073709510946" + }, + "value": { + "__variant__": "Leaf", + "value": "506509925307455152" + } + }, + { + "key": { + "bits": "18446744073709510996" + }, + "value": { + "__variant__": "Leaf", + "value": "531536459843179158" + } + }, + { + "key": { + "bits": "18446744073709511006" + }, + "value": { + "__variant__": "Leaf", + "value": "532033945741135650" + } + }, + { + "key": { + "bits": "18446744073709511016" + }, + "value": { + "__variant__": "Leaf", + "value": "533932345784390980" + } + }, + { + "key": { + "bits": "18446744073709511036" + }, + "value": { + "__variant__": "Leaf", + "value": "528933794755625484" + } + }, + { + "key": { + "bits": "18446744073709511046" + }, + "value": { + "__variant__": "Leaf", + "value": "129233653801033225" + } + }, + { + "key": { + "bits": "18446744073709511056" + }, + "value": { + "__variant__": "Leaf", + "value": "122646870800364" + } + }, + { + "key": { + "bits": "18446744073709511086" + }, + "value": { + "__variant__": "Leaf", + "value": "1034824099424034" + } + }, + { + "key": { + "bits": "18446744073709511096" + }, + "value": { + "__variant__": "Leaf", + "value": "4910027951497917" + } + }, + { + "key": { + "bits": "18446744073709511116" + }, + "value": { + "__variant__": "Leaf", + "value": "4634826866698377" + } + }, + { + "key": { + "bits": "18446744073709511146" + }, + "value": { + "__variant__": "Leaf", + "value": "11614340549703265" + } + }, + { + "key": { + "bits": "18446744073709511156" + }, + "value": { + "__variant__": "Leaf", + "value": "13265717080290781" + } + }, + { + "key": { + "bits": "18446744073709511166" + }, + "value": { + "__variant__": "Leaf", + "value": "561857787632643208" + } + }, + { + "key": { + "bits": "18446744073709511176" + }, + "value": { + "__variant__": "Leaf", + "value": "231452063504319441" + } + }, + { + "key": { + "bits": "18446744073709511186" + }, + "value": { + "__variant__": "Leaf", + "value": "563771939388021912" + } + }, + { + "key": { + "bits": "18446744073709511196" + }, + "value": { + "__variant__": "Leaf", + "value": "28047876319717424" + } + }, + { + "key": { + "bits": "18446744073709511206" + }, + "value": { + "__variant__": "Leaf", + "value": "32428338976584346" + } + }, + { + "key": { + "bits": "18446744073709511216" + }, + "value": { + "__variant__": "Leaf", + "value": "516025698693419275" + } + }, + { + "key": { + "bits": "18446744073709511256" + }, + "value": { + "__variant__": "Leaf", + "value": "65070629130012886" + } + }, + { + "key": { + "bits": "18446744073709511266" + }, + "value": { + "__variant__": "Leaf", + "value": "572133155827412461" + } + }, + { + "key": { + "bits": "18446744073709511276" + }, + "value": { + "__variant__": "Leaf", + "value": "77825391714151997" + } + }, + { + "key": { + "bits": "18446744073709511286" + }, + "value": { + "__variant__": "Leaf", + "value": "84289686612697738" + } + }, + { + "key": { + "bits": "18446744073709511296" + }, + "value": { + "__variant__": "Leaf", + "value": "90928963814838652" + } + }, + { + "key": { + "bits": "18446744073709511306" + }, + "value": { + "__variant__": "Leaf", + "value": "96710435324624020" + } + }, + { + "key": { + "bits": "18446744073709511326" + }, + "value": { + "__variant__": "Leaf", + "value": "112578681851623004" + } + }, + { + "key": { + "bits": "18446744073709511336" + }, + "value": { + "__variant__": "Leaf", + "value": "120182891422252609" + } + }, + { + "key": { + "bits": "18446744073709511346" + }, + "value": { + "__variant__": "Leaf", + "value": "130490301728622251" + } + }, + { + "key": { + "bits": "18446744073709511356" + }, + "value": { + "__variant__": "Leaf", + "value": "141002446131165559" + } + }, + { + "key": { + "bits": "18446744073709511366" + }, + "value": { + "__variant__": "Leaf", + "value": "404128329860729324" + } + }, + { + "key": { + "bits": "18446744073709511386" + }, + "value": { + "__variant__": "Leaf", + "value": "131811823953116580" + } + }, + { + "key": { + "bits": "18446744073709511396" + }, + "value": { + "__variant__": "Leaf", + "value": "429432258637700472" + } + }, + { + "key": { + "bits": "18446744073709511406" + }, + "value": { + "__variant__": "Leaf", + "value": "565822936054259206" + } + }, + { + "key": { + "bits": "18446744073709511416" + }, + "value": { + "__variant__": "Leaf", + "value": "566805738931321081" + } + }, + { + "key": { + "bits": "18446744073709511436" + }, + "value": { + "__variant__": "Leaf", + "value": "232022511162542418" + } + }, + { + "key": { + "bits": "18446744073709511466" + }, + "value": { + "__variant__": "Leaf", + "value": "279541830992689079" + } + }, + { + "key": { + "bits": "18446744073709511476" + }, + "value": { + "__variant__": "Leaf", + "value": "584752083969344553" + } + }, + { + "key": { + "bits": "18446744073709511486" + }, + "value": { + "__variant__": "Leaf", + "value": "331636309062845612" + } + }, + { + "key": { + "bits": "18446744073709511496" + }, + "value": { + "__variant__": "Leaf", + "value": "581654582529110139" + } + }, + { + "key": { + "bits": "18446744073709511546" + }, + "value": { + "__variant__": "Leaf", + "value": "458382189124771322" + } + }, + { + "key": { + "bits": "18446744073709511556" + }, + "value": { + "__variant__": "Leaf", + "value": "480434453593040378" + } + }, + { + "key": { + "bits": "18446744073709511566" + }, + "value": { + "__variant__": "Leaf", + "value": "582557254209576575" + } + }, + { + "key": { + "bits": "18446744073709511576" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709511596" + }, + "value": { + "__variant__": "Leaf", + "value": "551821672770249949" + } + }, + { + "key": { + "bits": "18446744073709511606" + }, + "value": { + "__variant__": "Leaf", + "value": "583688909904736213" + } + }, + { + "key": { + "bits": "18446744073709511616" + }, + "value": { + "__variant__": "Leaf", + "value": "567618396154022117" + } + }, + { + "key": { + "bits": "18446744073709511636" + }, + "value": { + "__variant__": "Leaf", + "value": "587164960258473993" + } + }, + { + "key": { + "bits": "18446744073709511656" + }, + "value": { + "__variant__": "Leaf", + "value": "584254663618794767" + } + }, + { + "key": { + "bits": "18446744073709511666" + }, + "value": { + "__variant__": "Leaf", + "value": "584423826945900361" + } + }, + { + "key": { + "bits": "18446744073709511676" + }, + "value": { + "__variant__": "Leaf", + "value": "584627929893191790" + } + }, + { + "key": { + "bits": "18446744073709511686" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709511736" + }, + "value": { + "__variant__": "Leaf", + "value": "587744008141811296" + } + }, + { + "key": { + "bits": "18446744073709511746" + }, + "value": { + "__variant__": "Leaf", + "value": "619046356230151838" + } + }, + { + "key": { + "bits": "18446744073709511756" + }, + "value": { + "__variant__": "Leaf", + "value": "586145857899377163" + } + }, + { + "key": { + "bits": "18446744073709511766" + }, + "value": { + "__variant__": "Leaf", + "value": "586220108749862622" + } + }, + { + "key": { + "bits": "18446744073709511776" + }, + "value": { + "__variant__": "Leaf", + "value": "586343200764823573" + } + }, + { + "key": { + "bits": "18446744073709511796" + }, + "value": { + "__variant__": "Leaf", + "value": "586760762744324598" + } + }, + { + "key": { + "bits": "18446744073709511816" + }, + "value": { + "__variant__": "Leaf", + "value": "587491018800206992" + } + }, + { + "key": { + "bits": "18446744073709511826" + }, + "value": { + "__variant__": "Leaf", + "value": "587925193174407652" + } + }, + { + "key": { + "bits": "18446744073709511836" + }, + "value": { + "__variant__": "Leaf", + "value": "588455239087112573" + } + }, + { + "key": { + "bits": "18446744073709511846" + }, + "value": { + "__variant__": "Leaf", + "value": "615000013975868896" + } + }, + { + "key": { + "bits": "18446744073709511856" + }, + "value": { + "__variant__": "Leaf", + "value": "612383977857122480" + } + }, + { + "key": { + "bits": "18446744073709511866" + }, + "value": { + "__variant__": "Leaf", + "value": "623190406283035182" + } + }, + { + "key": { + "bits": "18446744073709511876" + }, + "value": { + "__variant__": "Leaf", + "value": "589454508666215944" + } + }, + { + "key": { + "bits": "18446744073709511886" + }, + "value": { + "__variant__": "Leaf", + "value": "647928927844286558" + } + }, + { + "key": { + "bits": "18446744073709511896" + }, + "value": { + "__variant__": "Leaf", + "value": "683175583667114239" + } + }, + { + "key": { + "bits": "18446744073709511906" + }, + "value": { + "__variant__": "Leaf", + "value": "590078825579046033" + } + }, + { + "key": { + "bits": "18446744073709511916" + }, + "value": { + "__variant__": "Leaf", + "value": "684331178046514124" + } + }, + { + "key": { + "bits": "18446744073709511936" + }, + "value": { + "__variant__": "Leaf", + "value": "591195013780634204" + } + }, + { + "key": { + "bits": "18446744073709511946" + }, + "value": { + "__variant__": "Leaf", + "value": "697374701153762095" + } + }, + { + "key": { + "bits": "18446744073709511956" + }, + "value": { + "__variant__": "Leaf", + "value": "593484211809012563" + } + }, + { + "key": { + "bits": "18446744073709511976" + }, + "value": { + "__variant__": "Leaf", + "value": "591980642267442158" + } + }, + { + "key": { + "bits": "18446744073709511986" + }, + "value": { + "__variant__": "Leaf", + "value": "696390910567048501" + } + }, + { + "key": { + "bits": "18446744073709511996" + }, + "value": { + "__variant__": "Leaf", + "value": "595542251144145753" + } + }, + { + "key": { + "bits": "18446744073709512006" + }, + "value": { + "__variant__": "Leaf", + "value": "691582541537921440" + } + }, + { + "key": { + "bits": "18446744073709512016" + }, + "value": { + "__variant__": "Leaf", + "value": "688781085375380187" + } + }, + { + "key": { + "bits": "18446744073709512026" + }, + "value": { + "__variant__": "Leaf", + "value": "599625734759703641" + } + }, + { + "key": { + "bits": "18446744073709512036" + }, + "value": { + "__variant__": "Leaf", + "value": "593395596150971751" + } + }, + { + "key": { + "bits": "18446744073709512046" + }, + "value": { + "__variant__": "Leaf", + "value": "609986351496599486" + } + }, + { + "key": { + "bits": "18446744073709512056" + }, + "value": { + "__variant__": "Leaf", + "value": "610210684190888615" + } + }, + { + "key": { + "bits": "18446744073709512076" + }, + "value": { + "__variant__": "Leaf", + "value": "694374754617531992" + } + }, + { + "key": { + "bits": "18446744073709512086" + }, + "value": { + "__variant__": "Leaf", + "value": "695393237135763757" + } + }, + { + "key": { + "bits": "18446744073709512136" + }, + "value": { + "__variant__": "Leaf", + "value": "27918249771527974" + } + }, + { + "key": { + "bits": "18446744073709512146" + }, + "value": { + "__variant__": "Leaf", + "value": "623907400844717652" + } + }, + { + "key": { + "bits": "18446744073709512156" + }, + "value": { + "__variant__": "Leaf", + "value": "638657910372211051" + } + }, + { + "key": { + "bits": "18446744073709512176" + }, + "value": { + "__variant__": "Leaf", + "value": "79751768440477457" + } + }, + { + "key": { + "bits": "18446744073709512196" + }, + "value": { + "__variant__": "Leaf", + "value": "65105008895369937" + } + }, + { + "key": { + "bits": "18446744073709512206" + }, + "value": { + "__variant__": "Leaf", + "value": "60030637802363979" + } + }, + { + "key": { + "bits": "18446744073709512226" + }, + "value": { + "__variant__": "Leaf", + "value": "5411932509634822" + } + }, + { + "key": { + "bits": "18446744073709512236" + }, + "value": { + "__variant__": "Leaf", + "value": "38294649274117850" + } + }, + { + "key": { + "bits": "18446744073709512246" + }, + "value": { + "__variant__": "Leaf", + "value": "35203125978368309" + } + }, + { + "key": { + "bits": "18446744073709512256" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512266" + }, + "value": { + "__variant__": "Leaf", + "value": "33276343066596315" + } + }, + { + "key": { + "bits": "18446744073709512276" + }, + "value": { + "__variant__": "Leaf", + "value": "28895589336518461" + } + }, + { + "key": { + "bits": "18446744073709512286" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512306" + }, + "value": { + "__variant__": "Leaf", + "value": "16810429792797563" + } + }, + { + "key": { + "bits": "18446744073709512316" + }, + "value": { + "__variant__": "Leaf", + "value": "517825312424230" + } + }, + { + "key": { + "bits": "18446744073709512336" + }, + "value": { + "__variant__": "Leaf", + "value": "9472715096306405" + } + }, + { + "key": { + "bits": "18446744073709512346" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512356" + }, + "value": { + "__variant__": "Leaf", + "value": "7178450087843975" + } + }, + { + "key": { + "bits": "18446744073709512386" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512396" + }, + "value": { + "__variant__": "Leaf", + "value": "3520744013295991" + } + }, + { + "key": { + "bits": "18446744073709512406" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512416" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512426" + }, + "value": { + "__variant__": "Leaf", + "value": "3051688053949901" + } + }, + { + "key": { + "bits": "18446744073709512436" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512446" + }, + "value": { + "__variant__": "Leaf", + "value": "1380362093187779" + } + }, + { + "key": { + "bits": "18446744073709512456" + }, + "value": { + "__variant__": "Leaf", + "value": "336657624188560" + } + }, + { + "key": { + "bits": "18446744073709512486" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512496" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512536" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512556" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512696" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512736" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512786" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512886" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709512976" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709515116" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709521656" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709521996" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709525026" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + }, + { + "key": { + "bits": "18446744073709525036" + }, + "value": { + "__variant__": "Leaf", + "value": "0" + } + } + ] + }, + "is_leaf": true, + "next": "0", + "prev": "0" + } + }, + "token": "0xa", + "total_distributed": "14008584564", + "total_rewards": "57020000000", + "unclaimed_rewards": "15458193228" + } + ], + "current_sqrt_price": "2566074555342320693", + "current_tick_index": { + "bits": "18446744073709512163" + }, + "fee_growth_global_a": "2762498456832236235", + "fee_growth_global_b": "93986549403464034", + "fee_rate": "500", + "last_campaign_update": "1749589123", + "liquidity": "2236721679428", + "next_campaign_idx": "20", + "pool_addr": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "position_index": "20196", + "positions": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 42, + "max_leaf_index": "10", + "min_leaf_index": "13", + "nodes": { + "__variant__": "V1", + "new_slot_index": "112", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0x8385017019484c0bfa6e544f8b49a9b4804b126c9d85f072b95d87f7eb649ad5" + }, + "length": "38" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": "133", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "13" + } + } + }, + { + "key": "296", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "20" + } + } + }, + { + "key": "1962", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "41" + } + } + }, + { + "key": "2205", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "43" + } + } + }, + { + "key": "2472", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "46" + } + } + }, + { + "key": "2493", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "47" + } + } + }, + { + "key": "2820", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "48" + } + } + }, + { + "key": "2942", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "50" + } + } + }, + { + "key": "3030", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "51" + } + } + }, + { + "key": "3148", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "57" + } + } + }, + { + "key": "3357", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "59" + } + } + }, + { + "key": "3795", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "65" + } + } + }, + { + "key": "3979", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "67" + } + } + }, + { + "key": "4746", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "72" + } + } + }, + { + "key": "5061", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "74" + } + } + }, + { + "key": "5113", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "76" + } + } + }, + { + "key": "5134", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "77" + } + } + }, + { + "key": "5155", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "78" + } + } + }, + { + "key": "5234", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "80" + } + } + }, + { + "key": "5258", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "81" + } + } + }, + { + "key": "5328", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "83" + } + } + }, + { + "key": "5349", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "84" + } + } + }, + { + "key": "7369", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "89" + } + } + }, + { + "key": "18151", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "92" + } + } + }, + { + "key": "18256", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "94" + } + } + }, + { + "key": "18284", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "95" + } + } + }, + { + "key": "18336", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "97" + } + } + }, + { + "key": "18357", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "98" + } + } + }, + { + "key": "18404", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "100" + } + } + }, + { + "key": "18425", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "101" + } + } + }, + { + "key": "18446", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "102" + } + } + }, + { + "key": "18473", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "104" + } + } + }, + { + "key": "18512", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "105" + } + } + }, + { + "key": "18562", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "107" + } + } + }, + { + "key": "18583", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "109" + } + } + }, + { + "key": "20104", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "110" + } + } + }, + { + "key": "20142", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "111" + } + } + }, + { + "key": "20195", + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "10" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + }, + "reserve_a": "9006994507161", + "reserve_b": "172532117305", + "tick_indexes": { + "data": [ + { + "key": "0", + "value": { + "bit_field": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "88", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "41", + "value": { + "bit_field": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + true, + true, + true, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "40", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + true, + true, + false, + false, + false, + true, + true, + false, + false, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + true, + true, + false, + false, + false, + false, + true, + false, + true, + true, + false, + true, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + true, + true, + false, + true, + false, + false, + true, + true, + true, + false, + false, + true, + true, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + false, + false, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false, + true, + true, + false, + false, + false, + false, + true, + true, + true, + false, + true, + false, + true, + true, + false, + true, + true, + true, + false, + true, + true, + true, + false, + true, + true, + false, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + false, + false, + false, + true, + false, + true, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + true, + true, + false, + true, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + true, + true, + true, + true, + false, + true, + false, + false, + false, + false, + false, + true, + true, + false, + true, + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "42", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "34", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "30", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "37", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "31", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "32", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "39", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "45", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "43", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "35", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + }, + { + "key": "46", + "value": { + "bit_field": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "length": "1000" + } + } + ] + }, + "tick_spacing": "10", + "ticks": { + "__variant__": "BPlusTreeMap", + "constant_kv_size": true, + "inner_max_degree": 512, + "leaf_max_degree": 42, + "max_leaf_index": "50", + "min_leaf_index": "116", + "nodes": { + "__variant__": "V1", + "new_slot_index": "147", + "reuse_head_index": "0", + "reuse_spare_count": 0, + "should_reuse": false, + "slots": { + "vec": [ + { + "inner": { + "handle": "0xc059bcd62b7820aaaa2bf3a3756aaaf7336984a157feda833aaea3984493057b" + }, + "length": "10" + } + ] + } + }, + "root": { + "__variant__": "V1", + "children": { + "__variant__": "SortedVectorMap", + "entries": [ + { + "key": { + "bits": "18446744073709510116" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "116" + } + } + }, + { + "key": { + "bits": "18446744073709510876" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "143" + } + } + }, + { + "key": { + "bits": "18446744073709511276" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "145" + } + } + }, + { + "key": { + "bits": "18446744073709511826" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "92" + } + } + }, + { + "key": { + "bits": "18446744073709512236" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "146" + } + } + }, + { + "key": { + "bits": "18446744073709513146" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "144" + } + } + }, + { + "key": { + "bits": "18446744073709514316" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "88" + } + } + }, + { + "key": { + "bits": "18446744073709517456" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "77" + } + } + }, + { + "key": { + "bits": "18446744073709520436" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "79" + } + } + }, + { + "key": { + "bits": "18446744073709533166" + }, + "value": { + "__variant__": "Inner", + "node_index": { + "slot_index": "50" + } + } + } + ] + }, + "is_leaf": false, + "next": "0", + "prev": "0" + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x50fdfa97914bd00b656e3041e143f157c84931eb1ca7224b8a8570e7d5be70f2", + "state_key_hash": "0xe1c41250a0cefcdefa4b063f10537a1788e2acff3e8d09bb1a3827c4c5827412", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 6, + "icon_uri": "", + "name": "USD Coin", + "project_uri": "", + "symbol": "USDC" + } + }, + "type": "write_resource" + }, + { + "address": "0x50fdfa97914bd00b656e3041e143f157c84931eb1ca7224b8a8570e7d5be70f2", + "state_key_hash": "0xe1c41250a0cefcdefa4b063f10537a1788e2acff3e8d09bb1a3827c4c5827412", + "data": { + "type": "0x1::fungible_asset::Supply", + "data": { + "current": "200255274584", + "maximum": { + "vec": [] + } + } + }, + "type": "write_resource" + }, + { + "address": "0x50fdfa97914bd00b656e3041e143f157c84931eb1ca7224b8a8570e7d5be70f2", + "state_key_hash": "0xe1c41250a0cefcdefa4b063f10537a1788e2acff3e8d09bb1a3827c4c5827412", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x3b38735644d0be8ac37ebd84a1e42fa5c2487495ef8782f6c694b1a147f82426", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x50fdfa97914bd00b656e3041e143f157c84931eb1ca7224b8a8570e7d5be70f2", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x50fdfa97914bd00b656e3041e143f157c84931eb1ca7224b8a8570e7d5be70f2", + "state_key_hash": "0xe1c41250a0cefcdefa4b063f10537a1788e2acff3e8d09bb1a3827c4c5827412", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0x50fdfa97914bd00b656e3041e143f157c84931eb1ca7224b8a8570e7d5be70f2" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x52eb213a62b79674b6764ab515fea2e399508539f5de4cab439603967e564aee", + "state_key_hash": "0x350531679160a623e08c2c1a3ad8bfdba59e4796c90f29262d0b84695a59e2ec", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "1490704174777", + "frozen": false, + "metadata": { + "inner": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x52eb213a62b79674b6764ab515fea2e399508539f5de4cab439603967e564aee", + "state_key_hash": "0x350531679160a623e08c2c1a3ad8bfdba59e4796c90f29262d0b84695a59e2ec", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x57edaae7ac6e3813b057a675c05f155c0296f6757050e213dda7d8941b79609d", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x52eb213a62b79674b6764ab515fea2e399508539f5de4cab439603967e564aee", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x52eb213a62b79674b6764ab515fea2e399508539f5de4cab439603967e564aee", + "state_key_hash": "0x350531679160a623e08c2c1a3ad8bfdba59e4796c90f29262d0b84695a59e2ec", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x5325e36b9c5184593fafbce0f291d73cbfc053bd4b24e57083cd8c58ffc49596", + "state_key_hash": "0x8cb633ca003fdb868b886fb3b889ad0a6bc39bffeac1a9965d19a9cdb8e92deb", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "0", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5325e36b9c5184593fafbce0f291d73cbfc053bd4b24e57083cd8c58ffc49596", + "state_key_hash": "0x8cb633ca003fdb868b886fb3b889ad0a6bc39bffeac1a9965d19a9cdb8e92deb", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x5325e36b9c5184593fafbce0f291d73cbfc053bd4b24e57083cd8c58ffc49596", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5325e36b9c5184593fafbce0f291d73cbfc053bd4b24e57083cd8c58ffc49596", + "state_key_hash": "0x8cb633ca003fdb868b886fb3b889ad0a6bc39bffeac1a9965d19a9cdb8e92deb", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x532a89b74cf4a98dc98bcd49adbb3823838344840ccff1d69c5354354cf75ae4", + "state_key_hash": "0x7ad47a07a38f5590c4a6a3679ab960ca958db4c9e9544f1ab461c8acbc19b8c6", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "509774577", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x532a89b74cf4a98dc98bcd49adbb3823838344840ccff1d69c5354354cf75ae4", + "state_key_hash": "0x7ad47a07a38f5590c4a6a3679ab960ca958db4c9e9544f1ab461c8acbc19b8c6", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x532a89b74cf4a98dc98bcd49adbb3823838344840ccff1d69c5354354cf75ae4", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "state_key_hash": "0xc98f8dff189029942d8aec089a3cff64f248d04c3bed336c54cd0d32aa32ddcc", + "data": { + "type": "0x1::fungible_asset::FungibleAssetEvents", + "data": { + "deposit_events": { + "counter": "1", + "guid": { + "id": { + "addr": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "creation_num": "1125899906842625" + } + } + }, + "frozen_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "creation_num": "1125899906842627" + } + } + }, + "withdraw_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "creation_num": "1125899906842626" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "state_key_hash": "0xc98f8dff189029942d8aec089a3cff64f248d04c3bed336c54cd0d32aa32ddcc", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "1000", + "frozen": false, + "metadata": { + "inner": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "state_key_hash": "0xc98f8dff189029942d8aec089a3cff64f248d04c3bed336c54cd0d32aa32ddcc", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 8, + "icon_uri": "", + "name": "LP-stAPT-USDC", + "project_uri": "", + "symbol": "LP" + } + }, + "type": "write_resource" + }, + { + "address": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "state_key_hash": "0xc98f8dff189029942d8aec089a3cff64f248d04c3bed336c54cd0d32aa32ddcc", + "data": { + "type": "0x1::fungible_asset::Supply", + "data": { + "current": "18353776176", + "maximum": { + "vec": [] + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "state_key_hash": "0xc98f8dff189029942d8aec089a3cff64f248d04c3bed336c54cd0d32aa32ddcc", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842632", + "owner": "0x4bf51972879e3b95c4781a5cdcb9e1ee24ef483e7d22f2d903626f126df62bd1", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "state_key_hash": "0xc98f8dff189029942d8aec089a3cff64f248d04c3bed336c54cd0d32aa32ddcc", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "state_key_hash": "0xc98f8dff189029942d8aec089a3cff64f248d04c3bed336c54cd0d32aa32ddcc", + "data": { + "type": "0x4bf51972879e3b95c4781a5cdcb9e1ee24ef483e7d22f2d903626f126df62bd1::liquidity_pool::FeesAccounting", + "data": { + "claimable_1": { + "buckets": { + "inner": { + "handle": "0x1a9a520946edf2ad3baa2910414ce7151dba3812cbae0e772f208ad2b7ccea6b" + }, + "length": "2" + }, + "level": 1, + "num_buckets": "2", + "size": "1", + "split_load_threshold": 75, + "target_bucket_size": "18" + }, + "claimable_2": { + "buckets": { + "inner": { + "handle": "0x967b0de9ee02a25bd91c87ea7cf51aa1b5be310d391529a78dc91373ae37a382" + }, + "length": "2" + }, + "level": 1, + "num_buckets": "2", + "size": "1", + "split_load_threshold": 75, + "target_bucket_size": "18" + }, + "total_fees_1": "91478246770", + "total_fees_2": "9160599107", + "total_fees_at_last_claim_1": { + "buckets": { + "inner": { + "handle": "0x55860b65daa3c4f1bfa12ecc386d4cd2d8c86aae12234129bde41ec455a854fb" + }, + "length": "41" + }, + "level": 5, + "num_buckets": "41", + "size": "548", + "split_load_threshold": 75, + "target_bucket_size": "18" + }, + "total_fees_at_last_claim_2": { + "buckets": { + "inner": { + "handle": "0xaf395a05b652db0520b1e395290aff051b57176b538a5d0a0aedda8faef111a3" + }, + "length": "41" + }, + "level": 5, + "num_buckets": "41", + "size": "548", + "split_load_threshold": 75, + "target_bucket_size": "18" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "state_key_hash": "0xc98f8dff189029942d8aec089a3cff64f248d04c3bed336c54cd0d32aa32ddcc", + "data": { + "type": "0x4bf51972879e3b95c4781a5cdcb9e1ee24ef483e7d22f2d903626f126df62bd1::liquidity_pool::LiquidityPool", + "data": { + "fees_store_1": { + "inner": "0x16001871dc91f225501c36f5d375e9403c613012609a65e056d959544623fe5a" + }, + "fees_store_2": { + "inner": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7" + }, + "is_stable": false, + "lp_token_refs": { + "burn_ref": { + "metadata": { + "inner": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de" + } + }, + "mint_ref": { + "metadata": { + "inner": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de" + } + }, + "transfer_ref": { + "metadata": { + "inner": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de" + } + } + }, + "swap_fee_bps": "10", + "token_store_1": { + "inner": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82" + }, + "token_store_2": { + "inner": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x5dd39b261199f6f9232b67c4662248333707328adbc750ba921a8df1599dc31f", + "state_key_hash": "0x654ab41d919ac75f05ef2fabfc56308237e568fe993d00556efc953deff07e03", + "data": { + "type": "0x5dd39b261199f6f9232b67c4662248333707328adbc750ba921a8df1599dc31f::context::Storage", + "data": { + "data_list": { + "buckets": { + "inner": { + "handle": "0x4e5ef015cd3cfab349a8c9a375272573cbd13e50b91ed9d87582feb77c173e1c" + }, + "length": "1" + }, + "level": 0, + "num_buckets": "1", + "size": "0", + "split_load_threshold": 75, + "target_bucket_size": "24" + }, + "in_use": false + } + }, + "type": "write_resource" + }, + { + "address": "0x68556944aebe002a5ef1f016e944ac6f6978dd0a0d958bc2a64239ac467399a6", + "state_key_hash": "0x2931b378b0597be7a980d1270bf0edcaf8d9e643a56495be2b2066b12c681b64", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "34719167", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x68556944aebe002a5ef1f016e944ac6f6978dd0a0d958bc2a64239ac467399a6", + "state_key_hash": "0x2931b378b0597be7a980d1270bf0edcaf8d9e643a56495be2b2066b12c681b64", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xfee091c1ff970a750e2c23d8e5c808393f4f67f2f53ccb628e8ce9376fe9e467", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x68556944aebe002a5ef1f016e944ac6f6978dd0a0d958bc2a64239ac467399a6", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x68556944aebe002a5ef1f016e944ac6f6978dd0a0d958bc2a64239ac467399a6", + "state_key_hash": "0x2931b378b0597be7a980d1270bf0edcaf8d9e643a56495be2b2066b12c681b64", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x6bcdbe98d03a20e8f5f837dda9e4af927e2b594af7566ee486b410b0fd20efca", + "state_key_hash": "0x1d8bdf1cff353c7144a0704055be6867531683783a3c14de52034fa293b7b852", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "0", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x6bcdbe98d03a20e8f5f837dda9e4af927e2b594af7566ee486b410b0fd20efca", + "state_key_hash": "0x1d8bdf1cff353c7144a0704055be6867531683783a3c14de52034fa293b7b852", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xc4a41d7c13a80c8a35cb93c13b780a458a12c58ad20b002715a608b45e556234", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x6bcdbe98d03a20e8f5f837dda9e4af927e2b594af7566ee486b410b0fd20efca", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x6bcdbe98d03a20e8f5f837dda9e4af927e2b594af7566ee486b410b0fd20efca", + "state_key_hash": "0x1d8bdf1cff353c7144a0704055be6867531683783a3c14de52034fa293b7b852", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x6eacf71f26b6fcfdcef02a3e0491eb10db822e95209a8843f1f7e30c38265937", + "state_key_hash": "0x640dee0a887955e6a5e707fca6357877c1d522c500f6d8b547610e96e27f813a", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "277045609347", + "frozen": false, + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x6eacf71f26b6fcfdcef02a3e0491eb10db822e95209a8843f1f7e30c38265937", + "state_key_hash": "0x640dee0a887955e6a5e707fca6357877c1d522c500f6d8b547610e96e27f813a", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x3b38735644d0be8ac37ebd84a1e42fa5c2487495ef8782f6c694b1a147f82426", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x6eacf71f26b6fcfdcef02a3e0491eb10db822e95209a8843f1f7e30c38265937", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x7973c81bd7b567c1ec1cab55a8712a03d127ffa666299bec42a8dc93b8e6b9cb", + "state_key_hash": "0x05312ecaee72c817debaa8a5d31ac473fb67679f77f64471297a421565584998", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "162368743649" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x7973c81bd7b567c1ec1cab55a8712a03d127ffa666299bec42a8dc93b8e6b9cb", + "state_key_hash": "0x05312ecaee72c817debaa8a5d31ac473fb67679f77f64471297a421565584998", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 6, + "icon_uri": "https://circle.com/usdc-icon", + "name": "tappUSDC", + "project_uri": "tapp::@0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8::@0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", + "symbol": "tappUSDC" + } + }, + "type": "write_resource" + }, + { + "address": "0x7973c81bd7b567c1ec1cab55a8712a03d127ffa666299bec42a8dc93b8e6b9cb", + "state_key_hash": "0x05312ecaee72c817debaa8a5d31ac473fb67679f77f64471297a421565584998", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x57edaae7ac6e3813b057a675c05f155c0296f6757050e213dda7d8941b79609d", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x7973c81bd7b567c1ec1cab55a8712a03d127ffa666299bec42a8dc93b8e6b9cb", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x7973c81bd7b567c1ec1cab55a8712a03d127ffa666299bec42a8dc93b8e6b9cb", + "state_key_hash": "0x05312ecaee72c817debaa8a5d31ac473fb67679f77f64471297a421565584998", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0x7973c81bd7b567c1ec1cab55a8712a03d127ffa666299bec42a8dc93b8e6b9cb" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x79c20b4d5e06ff91d50c7987e052e144ea8bf8d2ccd536923fd3739af38fd5e9", + "state_key_hash": "0x5298e0c80a9f999fd571e4fa61177b24d8661cb3caf007e09bc02c101f829b94", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "12554987", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x79c20b4d5e06ff91d50c7987e052e144ea8bf8d2ccd536923fd3739af38fd5e9", + "state_key_hash": "0x5298e0c80a9f999fd571e4fa61177b24d8661cb3caf007e09bc02c101f829b94", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xc4a41d7c13a80c8a35cb93c13b780a458a12c58ad20b002715a608b45e556234", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x79c20b4d5e06ff91d50c7987e052e144ea8bf8d2ccd536923fd3739af38fd5e9", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x79c20b4d5e06ff91d50c7987e052e144ea8bf8d2ccd536923fd3739af38fd5e9", + "state_key_hash": "0x5298e0c80a9f999fd571e4fa61177b24d8661cb3caf007e09bc02c101f829b94", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x8bd593274b0e326a60e0f8b14d9306b1391de73baec769f4531d883a12e0d89b", + "state_key_hash": "0xfb0940aa463ddfd2f94b3b43f7143c35e9513acaaf6c412e6d10eb3090a245bd", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "1409485878", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x8bd593274b0e326a60e0f8b14d9306b1391de73baec769f4531d883a12e0d89b", + "state_key_hash": "0xfb0940aa463ddfd2f94b3b43f7143c35e9513acaaf6c412e6d10eb3090a245bd", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xd0b17bea776bb87b70b2fb2ca631014f0ca94fc1acde4b8ff1a763f4172aa6c4", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x8bd593274b0e326a60e0f8b14d9306b1391de73baec769f4531d883a12e0d89b", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x8bd593274b0e326a60e0f8b14d9306b1391de73baec769f4531d883a12e0d89b", + "state_key_hash": "0xfb0940aa463ddfd2f94b3b43f7143c35e9513acaaf6c412e6d10eb3090a245bd", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0x8ca2f1959aaf78305239b2e30d6d50b5e0e961d0ca511dab14191d7bf074a548", + "state_key_hash": "0xd114035c36fca8f2d043b637d360d1c12f48c9f43e8ecd95420081295a5b182a", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "34596936566", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x8ca2f1959aaf78305239b2e30d6d50b5e0e961d0ca511dab14191d7bf074a548", + "state_key_hash": "0xd114035c36fca8f2d043b637d360d1c12f48c9f43e8ecd95420081295a5b182a", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0xd626084b4e5875bba13d12bcabc51a29de8e6ce390891a953e5bc39f9a9d417e", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x8ca2f1959aaf78305239b2e30d6d50b5e0e961d0ca511dab14191d7bf074a548", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x91b0a2257b086692fe1e9240e4a22ea126656bdd9fd0de4f2773d5ea1f29a90a", + "state_key_hash": "0xa3f5be29011d3d5edb442aa8981f5e1428a9946a4292e4cd3fb6742f9b09f101", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "0", + "frozen": false, + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x91b0a2257b086692fe1e9240e4a22ea126656bdd9fd0de4f2773d5ea1f29a90a", + "state_key_hash": "0xa3f5be29011d3d5edb442aa8981f5e1428a9946a4292e4cd3fb6742f9b09f101", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x91b0a2257b086692fe1e9240e4a22ea126656bdd9fd0de4f2773d5ea1f29a90a", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x92b0e7194ae1b55cc2b55c127dac4c6a37a832a10bea4f68f02855f997ae3066", + "state_key_hash": "0xcc8f2b4e6071987fe550fef20e5a24d218d9509425b2f52090e695fae9bf948a", + "data": { + "type": "0x487e905f899ccb6d46fdaec56ba1e0c4cf119862a16c409904b8c78fab1f5e8a::hook_factory::PoolMeta", + "data": { + "assets": [ + "0xa", + "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", + "0x821c94e69bc7ca058c913b7b5e6b0a5c9fd1523d58723a966fb8c1f5ea888105", + "0x42556039b88593e768c97ab1a3ab0c6a17230825769304482dff8fdebe4c002b", + "0xcd92c290c66daaec439eedcb032adcb346654346fb96f286bce284c23e9e9597", + "0xa259be733b6a759909f92815927fa213904df6540519568692caf0b068fe8e62", + "0x81214a80d82035a190fcb76b6ff3c0145161c3a9f33d137f2bbaee4cfec8a387", + "0x68844a0d7f2587e726ad0579f3d640865bb4162c08a4589eeda3f9689ec52a3d", + "0xf599112bc3a5b6092469890d6a2f353f485a6193c9d36626b480704467d3f4c8", + "0xf26bcc9b3e5122971555f6cae700e8e2535f31bb266a44726c7b0012f734b4d1", + "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "0x40af260f7394ef56d6222322fff4bfa70922bb8369f7ee92c1c1ae697260b7d6", + "0x435ad41e7b383cef98899c4e5a22c8dc88ab67b22f95e5663d6c6649298c3a9d", + "0xd0ab8c2f76cd640455db56ca758a9766a966c88f77920347aac1719edab1df5e", + "0x52ebd8bc84185c6694b872003d286dc825fafd5314b6ed92ffa11ce081322a71", + "0x5fabd1b12e39967a3c24e91b7b8f67719a6dacee74f3c8b9fb7d93e855437d2", + "0x828ad8ad7429d0ad5004bc1f60945b879cbd4c826aac682d5cdfba94a476d3a", + "0xb27b0c6b60772f0fc804ec1cd3339f552badf9bd1e125a7dd700d8eb11248ef1", + "0xbcff91abababee684b194219ff2113c26e63d57c8872e6fdaf25a41a45fb7197", + "0xc2411341418cc557703a536fb1fd0911d2bb563b6f699bbe0de8bd38d03eac28", + "0xc1612c24ae63b175a10b13ba27ca4ce03dcca9dc2401a64d4960b3a696718c0b", + "0x5915ae0eae3701833fa02e28bf530bc01ca96a5f010ac8deecb14c7a92661368", + "0x9da434d9b873b5159e8eeed70202ad22dc075867a7793234fbc981b63e119", + "0x92f0951cecb35dc7613bc0e09f27e2c7aaebdc320f93901c121a1ec06f3aa455", + "0x4c3efb98d8d3662352f331b3465c6df263d1a7e84f002844348519614a5fea30", + "0x7fa78d58cccc849363df4ed1acd373b1f09397d1c322450101e3b0a4a7a14d80", + "0x1ff8bf54987b665fd0aa8b317a22a60f5927675d35021473a85d720e254ed77e", + "0xd3985309869f7697b03af4167de38766524d50c9efe7df1a878c7b7a2d521071", + "0x73992b487d517a8fc710acf953248b2045e381e5eb6fd9a92828db64a269530", + "0x41944cf1d4dac152d692644944e2cc49ee81fafdfb37abd541d06388ec3f7eda", + "0xc692943f7b340f02191c5de8dac2f827e0b66b3ed2206206a3526bcb0cae6e40", + "0xcd70630fb90cab716ab01a7884821f86dceb1bbb09a89683b5c22c5462503f51", + "0xc40443d625f94ddec95a76bcf2534eda394bf67713b93f08eb202026e2aaa66a", + "0x4497d70897762eb540280f1d90a17661f30239ffb1bfc8a0bd4db7d44dfab77a", + "0xef0d49f03e48dbd055c3a369f74a304c366bda148005ddf6bb881ced79da0b09", + "0x41dfe1fb3d33d4d9d0b460f03ce1c0a6af6520dd8bdc0f204583c4987faf81de", + "0xe87ffe47d2e77253865f964c9478b333bedd1c54aa3e090ac8fc84db51aeff8b", + "0xb36527754eb54d7ff55daf13bcb54b42b88ec484bd6f0e3b2e0d1db169de6451", + "0x2370cc1d995f3aadd337c1c6c63834ad8d2bd0cdc70bc8dff81de463e18b159", + "0xa1470025a9b110ccf666a5d74a1a929eee6a79cdecb6b4999632c7f3c676b1ea", + "0xc1fb6d3141ac840e9a40150ab7c2efe80857b213a71521b0aa46934632040703", + "0x79e8a5ddb82aa53854c1348c2865fd00732a41937dc1c160a4a50205537bd740", + "0xeedba439a4ab8987a995cf5cfefebd713000b3365718a29dfbc36bc214445fb8", + "0xb2c7780f0a255a6137e5b39733f5a4c85fe093c549de5c359c1232deef57d1b7", + "0xfad230e7d9df2baf83a68b6f50217ed3c06da593e766970a885965b43b894a04", + "0xaca80bdba3a9f58af0c6348f15530e4d891d1c60abca4c2cfb4c1a73bff0f8dd", + "0x495b693f6ef6222129adfdf12dacb611de98ec96d947c15e15f24ac24babd2a", + "0x377adc4848552eb2ea17259be928001923efe12271fef1667e2b784f04a7cf3a", + "0xbec2a2f1fe06a539bfc54e5ea142a0eb5777968cd1c0864be2c5d1f885b1010a", + "0xa0fa5918da73235921c6120597db820df0be391d0056dc0a7ee7a80b83f29d64", + "0x2a8227993a4e38537a57caefe5e7e9a51327bf6cd732c1f56648f26f68304ebc", + "0x878370592f9129e14b76558689a4b570ad22678111df775befbfcbc9fb3d90ab", + "0x5c72816a0a188673f2d457c855226949db1befd062201c03db1ee5b21fdb3383", + "0x378d5ba871c3d1bdf477a617f997f23d9e0702de97a02f42925b44fa3abc9866", + "0x76dd1fafe2f28dd1af8e71a02a41fb96d1f63b88bebdc629bbfe76703f5e7a6a", + "0x2ebb2ccac5e027a87fa0e2e5f656a3a4238d6a48d93ec9b610d570fc0aa0df12", + "0xe0f14d8d51dc88f73f05afa11e6d769274d04dcfa145995ebff5946e2993522", + "0x4168ccea6e59aab79b6ec57261bea37acb5b3aa577c339fe622fc3138d81d12f", + "0x2c34f7d30cf32e0f21949eadf2c86661ed14626393c37bf2d4793dfe95e89afa", + "0x17a56e0c9092989424b12e5682eb1db094d4031f2f61cd47c7cec0e916cff73", + "0xad18575b0e51dd056e1e082223c0e014cbfe4b13bc55e92f450585884f4cf951", + "0x96d1ccca420ebc20fc8af6cacb864e44856ca879c6436d4e9be2b0a4b99bf852", + "0xb43ee276631be63ecb14659db29b2b864859abddeebaf9eb567c45706ec83451" + ], + "hook_type": 1, + "is_paused": false, + "platform_fee_rate": "0", + "pool_addr": "0x92b0e7194ae1b55cc2b55c127dac4c6a37a832a10bea4f68f02855f997ae3066", + "reserves": [ + "27170038912", + "1741238931", + "1727822036", + "9299822532", + "78488994", + "0", + "9448318", + "351", + "666", + "60360", + "73677575810", + "395356", + "42993432", + "1020", + "126122408747", + "954034718364", + "257933372", + "21605541864", + "0", + "0", + "23230233343", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "705975538747", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "1130997721081", + "5675015155826", + "787724270883", + "656625348987", + "0", + "0", + "49500000000" + ] + } + }, + "type": "write_resource" + }, + { + "address": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "state_key_hash": "0x955893e7bbb56720a47807bd6d1611a5d98818579355f3dd3257eb3a41afa1c2", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "373379392421149" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "state_key_hash": "0x955893e7bbb56720a47807bd6d1611a5d98818579355f3dd3257eb3a41afa1c2", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 8, + "icon_uri": "", + "name": "LP-APT-stAPT", + "project_uri": "", + "symbol": "LP" + } + }, + "type": "write_resource" + }, + { + "address": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "state_key_hash": "0x955893e7bbb56720a47807bd6d1611a5d98818579355f3dd3257eb3a41afa1c2", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842631", + "owner": "0x4ea3c7d6fd8ee6e752ca70420d4aac1fda379db4475520249faf8e04ad31c5a4", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "state_key_hash": "0x955893e7bbb56720a47807bd6d1611a5d98818579355f3dd3257eb3a41afa1c2", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "state_key_hash": "0x955893e7bbb56720a47807bd6d1611a5d98818579355f3dd3257eb3a41afa1c2", + "data": { + "type": "0x8b4a2c4bb53857c718a04c020b98f8c2e1f99a68b0f57389a8bf5434cd22e05c::pool_v3::LiquidityPoolV3", + "data": { + "fee_growth_global_a": "55498532293754305231", + "fee_growth_global_b": "49568766848760720225", + "fee_protocol": "200000", + "fee_rate": "100", + "last_update_timestamp": "1768436509", + "liquidity": "31413415807128", + "lp_token_refs": { + "burn_ref": { + "metadata": { + "inner": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3" + } + }, + "extend_ref": { + "self": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3" + }, + "mint_ref": { + "metadata": { + "inner": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3" + } + }, + "transfer_ref": { + "metadata": { + "inner": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3" + } + } + }, + "max_liquidity_per_tick": "383514844834609487117690504987493", + "observation_cardinality": "0", + "observation_cardinality_next": "0", + "observation_index": "0", + "position_blacklist": { + "addresses": { + "big_vec": { + "vec": [] + }, + "bucket_size": { + "vec": [] + }, + "inline_capacity": { + "vec": [] + }, + "inline_vec": [] + } + }, + "protocol_fees": { + "token_a": { + "inner": "0x2c1f23ffdac53e74a70f36c5d904e581588730825e8958e618b895835bb81ee" + }, + "token_b": { + "inner": "0x9da49e8ce09ebb646cc9fa0738883db4106af7042eafc5af49a0d193516dd4b4" + } + }, + "rewarder_manager": { + "last_updated_time": "1768436509", + "pause": false, + "rewarders": [] + }, + "seconds_per_liquidity_incentive": "120258881740075214", + "seconds_per_liquidity_oracle": "8127697782150", + "sqrt_price": "16912647502560983813", + "tick": { + "bits": 4294965559 + }, + "tick_info": { + "buckets": { + "inner": { + "handle": "0xd236f756a5aa2919db4222c33861dce3d9f57e821e0a26ceeff72eb59c3dcc91" + }, + "length": "18" + }, + "level": 4, + "num_buckets": "18", + "size": "101", + "split_load_threshold": 75, + "target_bucket_size": "8" + }, + "tick_map": { + "map": { + "handle": "0xf995de7d03940262cb76c580df63b75d93f2642cc0c8eeaae6de21f87289915e" + } + }, + "tick_spacing": 1, + "token_a_fee": { + "inner": "0x3234526761b1c80fe340942dbaafbc0964227ee40fdff90d4746c53037d3cee1" + }, + "token_a_liquidity": { + "inner": "0x151243cd05dd0e9cbf78b41cda61e1813c291c40ef92ad1ce4f1489bc1c113c5" + }, + "token_b_fee": { + "inner": "0xe20a3eb31bb0cc125af93aa212a557eb8b1f5aaf0ef328666ba74d1caf2fdacb" + }, + "token_b_liquidity": { + "inner": "0xdbfaf8b4b43607b8352f918021c1e6749c57529454c660fa3f54cc3580db9e57" + }, + "unlocked": true + } + }, + "type": "write_resource" + }, + { + "address": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "state_key_hash": "0x955893e7bbb56720a47807bd6d1611a5d98818579355f3dd3257eb3a41afa1c2", + "data": { + "type": "0x8b4a2c4bb53857c718a04c020b98f8c2e1f99a68b0f57389a8bf5434cd22e05c::position_blacklist_v2::PositionBlackListV2", + "data": { + "position_info": { + "buckets": { + "inner": { + "handle": "0xc0df6451dcb813babd3dea73338d22958cfc0455360fea023980873eda55d8d7" + }, + "length": "2" + }, + "level": 1, + "num_buckets": "2", + "size": "1", + "split_load_threshold": 75, + "target_bucket_size": "14" + }, + "user_info": { + "buckets": { + "inner": { + "handle": "0x7efdf0e2ea86f95a6c1a0f365a80877a389a9837e7fdea0fc7cc8b8233ccef9b" + }, + "length": "2" + }, + "level": 1, + "num_buckets": "2", + "size": "1", + "split_load_threshold": 75, + "target_bucket_size": "23" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x947f8404b90af90a508d2735af771a3e826cefe45dfe993dfa43e0f5650465f9", + "state_key_hash": "0x0dbad9e916b0ee0465eaef959cafb4bb0d247055ba601fc6980b01c108388edd", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "200255274584", + "frozen": false, + "metadata": { + "inner": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x947f8404b90af90a508d2735af771a3e826cefe45dfe993dfa43e0f5650465f9", + "state_key_hash": "0x0dbad9e916b0ee0465eaef959cafb4bb0d247055ba601fc6980b01c108388edd", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x3b38735644d0be8ac37ebd84a1e42fa5c2487495ef8782f6c694b1a147f82426", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x947f8404b90af90a508d2735af771a3e826cefe45dfe993dfa43e0f5650465f9", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x94ac8cc8dba4ffed3af058b44ae1fe20712ab967602559a74ca3ce86a6a9e80d", + "state_key_hash": "0xbd953a8a8bb20d02e57aaaacf02b58eed03f8c29e0f1e21cdcc5e298e451cdc6", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "6726589", + "frozen": false, + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x94ac8cc8dba4ffed3af058b44ae1fe20712ab967602559a74ca3ce86a6a9e80d", + "state_key_hash": "0xbd953a8a8bb20d02e57aaaacf02b58eed03f8c29e0f1e21cdcc5e298e451cdc6", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0xd577798e58fd2d66f48622733c513b93dd84fdf7c26cf639dad15296a411c4eb", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x94ac8cc8dba4ffed3af058b44ae1fe20712ab967602559a74ca3ce86a6a9e80d", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x9fdcd70978aa1d13f6e06d428062d0764aaada6b25fc80634433c1cfcd70fc02", + "state_key_hash": "0x724c5dc66da1a233d63af45671e54ce682bffbdd64d16d704d4747ca9cb42226", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "669080210", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0x9fdcd70978aa1d13f6e06d428062d0764aaada6b25fc80634433c1cfcd70fc02", + "state_key_hash": "0x724c5dc66da1a233d63af45671e54ce682bffbdd64d16d704d4747ca9cb42226", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xd626084b4e5875bba13d12bcabc51a29de8e6ce390891a953e5bc39f9a9d417e", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0x9fdcd70978aa1d13f6e06d428062d0764aaada6b25fc80634433c1cfcd70fc02", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0x9fdcd70978aa1d13f6e06d428062d0764aaada6b25fc80634433c1cfcd70fc02", + "state_key_hash": "0x724c5dc66da1a233d63af45671e54ce682bffbdd64d16d704d4747ca9cb42226", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0xaa6be15d03b651cae303a952122e03881d25aaf99795725f1f6403bfca3a9839", + "state_key_hash": "0x91a90f518773dd19c68521b2e2418c405bffdca5483197cbed6373276f0a6bc5", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "572253136", + "frozen": false, + "metadata": { + "inner": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xaa6be15d03b651cae303a952122e03881d25aaf99795725f1f6403bfca3a9839", + "state_key_hash": "0x91a90f518773dd19c68521b2e2418c405bffdca5483197cbed6373276f0a6bc5", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xaf5f759ce4a2594cec4d1982160c846eb6ec2c70e7e8e6e2723e2917f13ac88c", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xaa6be15d03b651cae303a952122e03881d25aaf99795725f1f6403bfca3a9839", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xab2bab159c1ebc63532804cb638c40aa43ce3c8cc275d8a2b86cb6db070d03ca", + "state_key_hash": "0x2e6019866e07394baee4f5131232735f4ad4ab66aebb141e04dde7f01ca8650a", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "3241629542", + "frozen": false, + "metadata": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xab2bab159c1ebc63532804cb638c40aa43ce3c8cc275d8a2b86cb6db070d03ca", + "state_key_hash": "0x2e6019866e07394baee4f5131232735f4ad4ab66aebb141e04dde7f01ca8650a", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xaf5f759ce4a2594cec4d1982160c846eb6ec2c70e7e8e6e2723e2917f13ac88c", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xab2bab159c1ebc63532804cb638c40aa43ce3c8cc275d8a2b86cb6db070d03ca", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xab2bab159c1ebc63532804cb638c40aa43ce3c8cc275d8a2b86cb6db070d03ca", + "state_key_hash": "0x2e6019866e07394baee4f5131232735f4ad4ab66aebb141e04dde7f01ca8650a", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "state_key_hash": "0x6880500a503258018962c3fe46a243810045f3e6f01043a09858b361d7a1584b", + "data": { + "type": "0x1::coin::PairedCoinType", + "data": { + "type": { + "account_address": "0x111ae3e5bc816a5e63c2da97d0aa3886519e0cd5e4b046659fa35796bd11542a", + "module_name": "0x73746170745f746f6b656e", + "struct_name": "0x5374616b6564417074" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "state_key_hash": "0x6880500a503258018962c3fe46a243810045f3e6f01043a09858b361d7a1584b", + "data": { + "type": "0x1::coin::PairedFungibleAssetRefs", + "data": { + "burn_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + ] + }, + "mint_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + ] + }, + "transfer_ref_opt": { + "vec": [ + { + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + ] + } + } + }, + "type": "write_resource" + }, + { + "address": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "state_key_hash": "0x6880500a503258018962c3fe46a243810045f3e6f01043a09858b361d7a1584b", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "1045696031272233" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "state_key_hash": "0x6880500a503258018962c3fe46a243810045f3e6f01043a09858b361d7a1584b", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 8, + "icon_uri": "", + "name": "Staked Aptos Coin", + "project_uri": "", + "symbol": "stAPT" + } + }, + "type": "write_resource" + }, + { + "address": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "state_key_hash": "0x6880500a503258018962c3fe46a243810045f3e6f01043a09858b361d7a1584b", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0xa", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "state_key_hash": "0x6880500a503258018962c3fe46a243810045f3e6f01043a09858b361d7a1584b", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xbc52b11f809e5c13236f3ec3296f56259b7bee9898d47efd621354083adb97d9", + "state_key_hash": "0x1b476b4f20c01e05383e85528d356843676749b6d0dea0e488db58463afcd7fb", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "589692507991", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xbc52b11f809e5c13236f3ec3296f56259b7bee9898d47efd621354083adb97d9", + "state_key_hash": "0x1b476b4f20c01e05383e85528d356843676749b6d0dea0e488db58463afcd7fb", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xd0b17bea776bb87b70b2fb2ca631014f0ca94fc1acde4b8ff1a763f4172aa6c4", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xbc52b11f809e5c13236f3ec3296f56259b7bee9898d47efd621354083adb97d9", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xbf521931fc9fac7cf02e4d56eaf8aaa56d96c1ac1f78e63779a5db5a7f8aae81", + "state_key_hash": "0x2eb7e5109dd05b551d91d67e7596ba73a826f32a8cefe9036412fc57ad81e4c8", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "8903053136379", + "frozen": false, + "metadata": { + "inner": "0xca8d4a618356c120053eccdb5d80cbe2bc063ed49159606768437c9c437759a3" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xbf521931fc9fac7cf02e4d56eaf8aaa56d96c1ac1f78e63779a5db5a7f8aae81", + "state_key_hash": "0x2eb7e5109dd05b551d91d67e7596ba73a826f32a8cefe9036412fc57ad81e4c8", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xbf521931fc9fac7cf02e4d56eaf8aaa56d96c1ac1f78e63779a5db5a7f8aae81", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12", + "state_key_hash": "0x4d7379e42f8389fe747815fb6cad9ff3f1f563be204871a0a63fdb2b03f2154d", + "data": { + "type": "0x1::fungible_asset::FungibleAssetEvents", + "data": { + "deposit_events": { + "counter": "35859", + "guid": { + "id": { + "addr": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12", + "creation_num": "1125899906842625" + } + } + }, + "frozen_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12", + "creation_num": "1125899906842627" + } + } + }, + "withdraw_events": { + "counter": "38790", + "guid": { + "id": { + "addr": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12", + "creation_num": "1125899906842626" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12", + "state_key_hash": "0x4d7379e42f8389fe747815fb6cad9ff3f1f563be204871a0a63fdb2b03f2154d", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "2775919452", + "frozen": false, + "metadata": { + "inner": "0x50fdfa97914bd00b656e3041e143f157c84931eb1ca7224b8a8570e7d5be70f2" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12", + "state_key_hash": "0x4d7379e42f8389fe747815fb6cad9ff3f1f563be204871a0a63fdb2b03f2154d", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842628", + "owner": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc2b4d1ebff237b44da4272faf2fa1b62c877da0d1949f76b3ee1ead7b5090cc0", + "state_key_hash": "0x0869282ae1c3e0ad47931e2a2d6d001e57d799363e0187fdb18b25057d941d5e", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "1727822036" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc2b4d1ebff237b44da4272faf2fa1b62c877da0d1949f76b3ee1ead7b5090cc0", + "state_key_hash": "0x0869282ae1c3e0ad47931e2a2d6d001e57d799363e0187fdb18b25057d941d5e", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 6, + "icon_uri": "https://circle.com/usdc-icon", + "name": "tappUSDC", + "project_uri": "tapp::@0x92b0e7194ae1b55cc2b55c127dac4c6a37a832a10bea4f68f02855f997ae3066::@0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", + "symbol": "tappUSDC" + } + }, + "type": "write_resource" + }, + { + "address": "0xc2b4d1ebff237b44da4272faf2fa1b62c877da0d1949f76b3ee1ead7b5090cc0", + "state_key_hash": "0x0869282ae1c3e0ad47931e2a2d6d001e57d799363e0187fdb18b25057d941d5e", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x57edaae7ac6e3813b057a675c05f155c0296f6757050e213dda7d8941b79609d", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xc2b4d1ebff237b44da4272faf2fa1b62c877da0d1949f76b3ee1ead7b5090cc0", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc2b4d1ebff237b44da4272faf2fa1b62c877da0d1949f76b3ee1ead7b5090cc0", + "state_key_hash": "0x0869282ae1c3e0ad47931e2a2d6d001e57d799363e0187fdb18b25057d941d5e", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0xc2b4d1ebff237b44da4272faf2fa1b62c877da0d1949f76b3ee1ead7b5090cc0" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc4a41d7c13a80c8a35cb93c13b780a458a12c58ad20b002715a608b45e556234", + "state_key_hash": "0x739fa954a0bebba52224346eede609b14095ff5c34bfe921940f22ee8c8dd692", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906926162", + "owner": "0xdcadefcf4c1cd5e33207979a76a77fed1c244d24fa4d1f7cae8204779729dd01", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xc4a41d7c13a80c8a35cb93c13b780a458a12c58ad20b002715a608b45e556234", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc4a41d7c13a80c8a35cb93c13b780a458a12c58ad20b002715a608b45e556234", + "state_key_hash": "0x739fa954a0bebba52224346eede609b14095ff5c34bfe921940f22ee8c8dd692", + "data": { + "type": "0x12169b6e1bf75ab1a2b2d987d20f8dd4c191e5dbc2066cb7e9af40b1fa7fb659::fees_manager::FeesConfig", + "data": { + "liquidity_provider_fee_bps": "900000000", + "protocol_fee_bps": "10000000", + "referral_fee_bps": "90000000" + } + }, + "type": "write_resource" + }, + { + "address": "0xc4a41d7c13a80c8a35cb93c13b780a458a12c58ad20b002715a608b45e556234", + "state_key_hash": "0x739fa954a0bebba52224346eede609b14095ff5c34bfe921940f22ee8c8dd692", + "data": { + "type": "0x12169b6e1bf75ab1a2b2d987d20f8dd4c191e5dbc2066cb7e9af40b1fa7fb659::fees_manager::FeesRewardedPool", + "data": { + "extend_ref": { + "self": "0xc4a41d7c13a80c8a35cb93c13b780a458a12c58ad20b002715a608b45e556234" + }, + "lp": { + "inner": "0xd626084b4e5875bba13d12bcabc51a29de8e6ce390891a953e5bc39f9a9d417e" + }, + "lp_store": { + "inner": "0x44bed53ec4c0126825c96695966a57846b3e73236bb13de656205dae1b72308c" + }, + "rewards": { + "buckets": { + "inner": { + "handle": "0x9f6b1ec7577e0a0bd7733726730202e73739bd4247b9db6d67d5e93a96bcb99e" + }, + "length": "2" + }, + "level": 1, + "num_buckets": "2", + "size": "2", + "split_load_threshold": 75, + "target_bucket_size": "9" + }, + "users": { + "buckets": { + "inner": { + "handle": "0xec70526f3e48e7a8292621cbe9dfc7e84525cc0e2f1c27ee380e463a71925ebc" + }, + "length": "11" + }, + "level": 3, + "num_buckets": "11", + "size": "32", + "split_load_threshold": 75, + "target_bucket_size": "4" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc9c40132974852dc6682a4ae36bbbd62fe20c2cd845ad8dd2558fec6d8238165", + "state_key_hash": "0xc8b1b5c5393b3b62736f9ef9c9792ca645e7a92f3a301fb985e404f1fe6ae026", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "3497288226", + "frozen": false, + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xc9c40132974852dc6682a4ae36bbbd62fe20c2cd845ad8dd2558fec6d8238165", + "state_key_hash": "0xc8b1b5c5393b3b62736f9ef9c9792ca645e7a92f3a301fb985e404f1fe6ae026", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x53e2555324ecbcf9cc400ed61367d7eec98adb2257e5dc076049a5f9446454d8", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xc9c40132974852dc6682a4ae36bbbd62fe20c2cd845ad8dd2558fec6d8238165", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xca8d4a618356c120053eccdb5d80cbe2bc063ed49159606768437c9c437759a3", + "state_key_hash": "0x60fb4dc75153a1433ca4f26c54416653fc52244ef1d20f57b6d96a3abf4d060b", + "data": { + "type": "0x1::fungible_asset::ConcurrentSupply", + "data": { + "current": { + "max_value": "340282366920938463463374607431768211455", + "value": "8903053136379" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xca8d4a618356c120053eccdb5d80cbe2bc063ed49159606768437c9c437759a3", + "state_key_hash": "0x60fb4dc75153a1433ca4f26c54416653fc52244ef1d20f57b6d96a3abf4d060b", + "data": { + "type": "0x1::fungible_asset::Metadata", + "data": { + "decimals": 8, + "icon_uri": "", + "name": "tappAptos Coin", + "project_uri": "tapp::@0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8::@0xa", + "symbol": "tappAPT" + } + }, + "type": "write_resource" + }, + { + "address": "0xca8d4a618356c120053eccdb5d80cbe2bc063ed49159606768437c9c437759a3", + "state_key_hash": "0x60fb4dc75153a1433ca4f26c54416653fc52244ef1d20f57b6d96a3abf4d060b", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x57edaae7ac6e3813b057a675c05f155c0296f6757050e213dda7d8941b79609d", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xca8d4a618356c120053eccdb5d80cbe2bc063ed49159606768437c9c437759a3", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xca8d4a618356c120053eccdb5d80cbe2bc063ed49159606768437c9c437759a3", + "state_key_hash": "0x60fb4dc75153a1433ca4f26c54416653fc52244ef1d20f57b6d96a3abf4d060b", + "data": { + "type": "0x1::primary_fungible_store::DeriveRefPod", + "data": { + "metadata_derive_ref": { + "self": "0xca8d4a618356c120053eccdb5d80cbe2bc063ed49159606768437c9c437759a3" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xcec5821fed3c0cbe90d21699dd628856c9e5f6b465cc1a44a5aeb7423ee8b231", + "state_key_hash": "0xd7d28c773dbc22c0c7d665b9511cbc5250b518f98f976c980e3436878dddeb52", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "35579596133471", + "frozen": false, + "metadata": { + "inner": "0xa" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xcec5821fed3c0cbe90d21699dd628856c9e5f6b465cc1a44a5aeb7423ee8b231", + "state_key_hash": "0xd7d28c773dbc22c0c7d665b9511cbc5250b518f98f976c980e3436878dddeb52", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x57edaae7ac6e3813b057a675c05f155c0296f6757050e213dda7d8941b79609d", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xcec5821fed3c0cbe90d21699dd628856c9e5f6b465cc1a44a5aeb7423ee8b231", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xd007d8c1a82369b23b02988836289a8b689bdaa8f73bc445bffbd1d9ccc7d914", + "state_key_hash": "0x24bb5118ed3a3523615b9d2dcf43092461df9bbe15d5fbab0a70fd00b90b8304", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "0", + "frozen": false, + "metadata": { + "inner": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xd007d8c1a82369b23b02988836289a8b689bdaa8f73bc445bffbd1d9ccc7d914", + "state_key_hash": "0x24bb5118ed3a3523615b9d2dcf43092461df9bbe15d5fbab0a70fd00b90b8304", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xb7cb159db88215cd1670edfe4e30df58177571610983fc06681f4c9773810b3e", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xd007d8c1a82369b23b02988836289a8b689bdaa8f73bc445bffbd1d9ccc7d914", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xdbfaf8b4b43607b8352f918021c1e6749c57529454c660fa3f54cc3580db9e57", + "state_key_hash": "0x8ac9717a3fed360bb9d55d10bf3dcb78da0eae70c86a810e6e69ae9a7ae4de6e", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "14748477715543", + "frozen": false, + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xdbfaf8b4b43607b8352f918021c1e6749c57529454c660fa3f54cc3580db9e57", + "state_key_hash": "0x8ac9717a3fed360bb9d55d10bf3dcb78da0eae70c86a810e6e69ae9a7ae4de6e", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xdbfaf8b4b43607b8352f918021c1e6749c57529454c660fa3f54cc3580db9e57", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xe3306edc8474330632f681d0e42e2cddf519ee928f0447b29b2466938e751b48", + "state_key_hash": "0x327cd76d6eb06c531d165bb186b92f40fe7252e96bc423025ac014d1137b0d82", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "162368743649", + "frozen": false, + "metadata": { + "inner": "0x7973c81bd7b567c1ec1cab55a8712a03d127ffa666299bec42a8dc93b8e6b9cb" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xe3306edc8474330632f681d0e42e2cddf519ee928f0447b29b2466938e751b48", + "state_key_hash": "0x327cd76d6eb06c531d165bb186b92f40fe7252e96bc423025ac014d1137b0d82", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xe3306edc8474330632f681d0e42e2cddf519ee928f0447b29b2466938e751b48", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xf22bede237a07e121b56d91a491eb7bcdfd1f5907926a9e58338f964a01b17fa", + "state_key_hash": "0xb2c6ac0fb290adcc000aa3dabb872c3d702d9db5ffcd79f0372c468d66b9ad75", + "data": { + "type": "0x1::coin::CoinInfo<0xf22bede237a07e121b56d91a491eb7bcdfd1f5907926a9e58338f964a01b17fa::asset::USDC>", + "data": { + "decimals": 6, + "name": "USD Coin", + "supply": { + "vec": [ + { + "aggregator": { + "vec": [] + }, + "integer": { + "vec": [ + { + "limit": "340282366920938463463374607431768211455", + "value": "3978072361336" + } + ] + } + } + ] + }, + "symbol": "USDC" + } + }, + "type": "write_resource" + }, + { + "address": "0xf6b010cfc9a9a2a8424d99fd35115d5b45ba4f81ac9f0de5c9df2fe85a8a0943", + "state_key_hash": "0xcb73443352fe603bf4796b1b9fb7b7b3a8f2cff991b753ec8c2bf6532ab17b24", + "data": { + "type": "0x1::fungible_asset::FungibleStore", + "data": { + "balance": "3132535313019", + "frozen": false, + "metadata": { + "inner": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" + } + } + }, + "type": "write_resource" + }, + { + "address": "0xf6b010cfc9a9a2a8424d99fd35115d5b45ba4f81ac9f0de5c9df2fe85a8a0943", + "state_key_hash": "0xcb73443352fe603bf4796b1b9fb7b7b3a8f2cff991b753ec8c2bf6532ab17b24", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": false, + "guid_creation_num": "1125899906842625", + "owner": "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xf6b010cfc9a9a2a8424d99fd35115d5b45ba4f81ac9f0de5c9df2fe85a8a0943", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xf6b010cfc9a9a2a8424d99fd35115d5b45ba4f81ac9f0de5c9df2fe85a8a0943", + "state_key_hash": "0xcb73443352fe603bf4796b1b9fb7b7b3a8f2cff991b753ec8c2bf6532ab17b24", + "data": { + "type": "0x1::object::Untransferable", + "data": { + "dummy_field": false + } + }, + "type": "write_resource" + }, + { + "address": "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f", + "state_key_hash": "0xe02ae35ff82e59c98c6fb92fcfcb0c4b53ab4b4dbc9125aace459c82f4cb07e4", + "data": { + "type": "0x1::object::ObjectCore", + "data": { + "allow_ungated_transfer": true, + "guid_creation_num": "1125899906842625", + "owner": "0x75b4890de3e312d9425408c43d9a9752b64ab3562a30e89a55bdc568c645920", + "transfer_events": { + "counter": "0", + "guid": { + "id": { + "addr": "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f", + "creation_num": "1125899906842624" + } + } + } + } + }, + "type": "write_resource" + }, + { + "address": "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f", + "state_key_hash": "0xe02ae35ff82e59c98c6fb92fcfcb0c4b53ab4b4dbc9125aace459c82f4cb07e4", + "data": { + "type": "0x75b4890de3e312d9425408c43d9a9752b64ab3562a30e89a55bdc568c645920::pool::Pool", + "data": { + "collection_obj": { + "inner": "0x39bed6497f77502b17a06b5736e9d2b3cb4942e3a80afad33b430af584509f01" + }, + "extend_ref": { + "self": "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f" + }, + "fee_growth_global_0": "36908206306811", + "fee_growth_global_1": "36985757034051", + "liquidity": "13524043868760916", + "locked": false, + "max_liquidity_per_tick": "92246818294066185", + "metadata_0": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + }, + "metadata_1": { + "inner": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" + }, + "mutator_ref": { + "self": "0x39bed6497f77502b17a06b5736e9d2b3cb4942e3a80afad33b430af584509f01" + }, + "oracle_obj": { + "inner": "0x51421df999dd4bf6e426178979988de0a771847ffd8828c5fa747ce7bed87e6b" + }, + "position_id": "2060", + "sqrt_price": "18448539567095063993", + "swap_fee_bps": "1", + "tick": { + "bits": "1" + }, + "tick_bitmap": { + "buckets": { + "inner": { + "handle": "0x447606b7b90a6b6d8b05a6e91ffabf45c5bf0e9567bd3c47600d4874a17a1ed2" + }, + "length": "2" + }, + "level": 1, + "num_buckets": "2", + "size": "13", + "split_load_threshold": 75, + "target_bucket_size": "21" + }, + "tick_spacing": "2", + "ticks": { + "buckets": { + "inner": { + "handle": "0x7a0af7fb9d1fda632dca5f71841e0ec1fadf8fab39ed5af9b1070459e6957684" + }, + "length": "9" + }, + "level": 3, + "num_buckets": "9", + "size": "53", + "split_load_threshold": 75, + "target_bucket_size": "9" + } + } + }, + "type": "write_resource" + }, + { + "state_key_hash": "0x9c4f6473c5f8a15e6a821d713234ef580a5bc5d52801d9bcca72892969d8e5a0", + "handle": "0x4e5ef015cd3cfab349a8c9a375272573cbd13e50b91ed9d87582feb77c173e1c", + "key": "0x0000000000000000", + "value": "0x00", + "data": null, + "type": "write_table_item" + }, + { + "state_key_hash": "0x688017f0973854b0a474500550e88a5289acb2f69e2d019605ebf4abb6cfe83e", + "handle": "0x9f6b1ec7577e0a0bd7733726730202e73739bd4247b9db6d67d5e93a96bcb99e", + "key": "0x0000000000000000", + "value": "0x02dc8fb122b8c08ca3000000000000000000000000000000000000000000000000000000000000000a18f4f4fd587c550d00000000000000000000000000000000000000000000000058d3056ac5699263f4c47eb7d75b72b234589ff425f98a215608c8581679814c440bfb6d9778eef7357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2bad69d56d8f49190000000000000000000000000000000000000000000000000079c20b4d5e06ff91d50c7987e052e144ea8bf8d2ccd536923fd3739af38fd5e9", + "data": null, + "type": "write_table_item" + } + ], + "sender": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "sequence_number": "4", + "max_gas_amount": "1500", + "gas_unit_price": "100", + "expiration_timestamp_secs": "1768440109", + "payload": { + "function": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap::router_entry", + "type_arguments": [ + "0x1::string::String", + "0xf22bede237a07e121b56d91a491eb7bcdfd1f5907926a9e58338f964a01b17fa::asset::USDC", + "0x111ae3e5bc816a5e63c2da97d0aa3886519e0cd5e4b046659fa35796bd11542a::stapt_token::StakedApt", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::string::String", + "0x1::aptos_coin::AptosCoin" + ], + "arguments": [ + { + "vec": [] + }, + "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "1", + 3, + "0x01020300", + [ + [ + "0x31" + ], + [ + "0x2d", + "0x30" + ], + [ + "0x28", + "0x17", + "0x2c" + ] + ], + [ + [ + [ + "0" + ] + ], + [ + [ + "1" + ], + [ + "1" + ] + ], + [ + [ + "1" + ], + [ + "0" + ], + [ + "0" + ] + ] + ], + [ + [ + [ + false + ] + ], + [ + [ + true + ], + [ + false + ] + ], + [ + [ + false + ], + [ + false + ], + [ + false + ] + ] + ], + [ + "0x01" + ], + [ + [ + [ + "0x0" + ] + ], + [ + [ + "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f" + ], + [ + "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8" + ] + ], + [ + [ + "0xaf5f759ce4a2594cec4d1982160c846eb6ec2c70e7e8e6e2723e2917f13ac88c" + ], + [ + "0x0" + ], + [ + "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3" + ] + ] + ], + [ + [ + "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + ], + [ + "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" + ], + [ + "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + ] + ], + [ + [ + "0xa" + ], + [ + "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", + "0xa" + ], + [ + "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "0xa" + ] + ], + { + "vec": [ + [ + [ + [ + [ + "0x" + ] + ], + [ + [ + "0x" + ], + [ + "0x" + ] + ], + [ + [ + "0x" + ], + [ + "0xa994040000000000" + ], + [ + "0x" + ] + ] + ] + ] + ] + }, + [ + [ + [ + "10000" + ] + ], + [ + [ + "10000" + ], + [ + "10000" + ] + ], + [ + [ + "10000" + ], + [ + "10000" + ], + [ + "10000" + ] + ] + ], + { + "vec": [ + [ + [ + "0x34460f0000000000", + "0x0231390700000000", + "0x20a1070000000000", + "0xc800000000000000" + ], + [ + "0x0c35646b6b766132706c676338" + ], + [ + "0x12302e357c3230307c31373638343336353036", + "0x5d41dbece2d16d6c7094a152e91d9717fe6de46aaf7d59c70a35601b72ea48a4", + "0x0c0b3e83760642cd1cad14d6e80964fe5f30a13099321e7c8f525cc647c027b57a8c29a7565fd25f8125a6879fa9ebff76779d66727e8278da06bf53742de104", + "0x801da14762480600" + ] + ] + ] + }, + "0xa", + [ + "93852", + "2205535", + "46927" + ], + "119391830", + "11000000000005000", + "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef" + ], + "type": "entry_function_payload" + }, + "signature": { + "public_key": "0xea0d1ea7dd33093b2484cff8bf20f8ff30b18f15d6e179e83c01e2608fcdbaa0", + "signature": "0x8611a7a5e0f5bbca733bba1cc0618d0b90b76900956f6b5f4a4efec1c225032c6706d669b2d7697b0231f275c2550d6d1099ecaf8f22e67cb7e21c6d7f216304", + "type": "ed25519_signature" + }, + "replay_protection_nonce": null, + "events": [ + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "2346314", + "store": "0x5325e36b9c5184593fafbce0f291d73cbfc053bd4b24e57083cd8c58ffc49596" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap::PanoraSwapMetadataEvent", + "data": { + "metadata": "0x0c35646b6b766132706c67633812302e357c3230307c313736383433363530365d41dbece2d16d6c7094a152e91d9717fe6de46aaf7d59c70a35601b72ea48a40c0b3e83760642cd1cad14d6e80964fe5f30a13099321e7c8f525cc647c027b57a8c29a7565fd25f8125a6879fa9ebff76779d66727e8278da06bf53742de104801da14762480600" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x12169b6e1bf75ab1a2b2d987d20f8dd4c191e5dbc2066cb7e9af40b1fa7fb659::fees_manager::SwapFeePaidEvent", + "data": { + "lp": { + "inner": "0xd626084b4e5875bba13d12bcabc51a29de8e6ce390891a953e5bc39f9a9d417e" + }, + "protocol_fee_amount": "2", + "referrer_address": "0xd0b17bea776bb87b70b2fb2ca631014f0ca94fc1acde4b8ff1a763f4172aa6c4", + "referrer_fee_amount": "8", + "reward_pool": { + "inner": "0xc4a41d7c13a80c8a35cb93c13b780a458a12c58ad20b002715a608b45e556234" + }, + "swap_fee_amount": "93", + "timestamp": "1768436509", + "token": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "83", + "store": "0x79c20b4d5e06ff91d50c7987e052e144ea8bf8d2ccd536923fd3739af38fd5e9" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "8", + "store": "0x8bd593274b0e326a60e0f8b14d9306b1391de73baec769f4531d883a12e0d89b" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "2", + "store": "0x68556944aebe002a5ef1f016e944ac6f6978dd0a0d958bc2a64239ac467399a6" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "93759", + "store": "0x9fdcd70978aa1d13f6e06d428062d0764aaada6b25fc80634433c1cfcd70fc02" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "4848789", + "store": "0x8ca2f1959aaf78305239b2e30d6d50b5e0e961d0ca511dab14191d7bf074a548" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x12169b6e1bf75ab1a2b2d987d20f8dd4c191e5dbc2066cb7e9af40b1fa7fb659::liquidity_pool::SwapEvent", + "data": { + "amount_in": "93759", + "amount_out": "4848789", + "fees_amount": "93", + "from_token": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + }, + "pool": "0xd626084b4e5875bba13d12bcabc51a29de8e6ce390891a953e5bc39f9a9d417e", + "timestamp": "1768436509", + "to_token": { + "inner": "0xa" + } + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x12169b6e1bf75ab1a2b2d987d20f8dd4c191e5dbc2066cb7e9af40b1fa7fb659::liquidity_pool::SyncEvent", + "data": { + "pool": "0xd626084b4e5875bba13d12bcabc51a29de8e6ce390891a953e5bc39f9a9d417e", + "reserves_1": "34596936566", + "reserves_2": "669080210", + "timestamp": "1768436509" + } + }, + { + "guid": { + "creation_number": "48", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "32209707", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap_aggregator_fungible_asset::PanoraSwapStepEvent", + "data": { + "dex_id": 49, + "input_token_address": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + "input_token_amount": "93852", + "output_token_address": "0xa", + "output_token_amount": "4848789", + "pool_id": "0", + "time_stamp": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "110", + "store": "0x305e7e0b0344a60a3012412e1b8e46665c45d9059f7b1b5aa0eda2fb2bbfa479" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "2205425", + "store": "0x45e5b44b6ac976f0a8fe93b503054ee64e6fb7e5bdb89831b5f1d551705ed6fa" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "2205743", + "store": "0xf6b010cfc9a9a2a8424d99fd35115d5b45ba4f81ac9f0de5c9df2fe85a8a0943" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xe5c5befe31ce06bc1f2fd31210988aac08af6d821b039935557a6f14c03471be::stablecoin::Withdraw", + "data": { + "amount": "2205743", + "store": "0xf6b010cfc9a9a2a8424d99fd35115d5b45ba4f81ac9f0de5c9df2fe85a8a0943", + "store_owner": "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x75b4890de3e312d9425408c43d9a9752b64ab3562a30e89a55bdc568c645920::pool::SwapEvent", + "data": { + "amount_in": "2205535", + "amount_out": "2205743", + "exact_in": true, + "fee_amount": "221", + "integrator": "Panora", + "metadata_0": { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + }, + "metadata_1": { + "inner": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" + }, + "pool_balance_0": "332370380477", + "pool_balance_1": "3132535313019", + "pool_obj": { + "inner": "0xfec90c113a5093bde30a7927f608fb41d6f56e00d2f944242de6c75c1732503f" + }, + "protocol_fee_amount": "110", + "refund_amount": "0", + "zero_for_one": true + } + }, + { + "guid": { + "creation_number": "48", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "32209708", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap_aggregator_fungible_asset::PanoraSwapStepEvent", + "data": { + "dex_id": 45, + "input_token_address": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + "input_token_amount": "2205535", + "output_token_address": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", + "output_token_amount": "2205743", + "pool_id": "1", + "time_stamp": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "2205743", + "store": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xe5c5befe31ce06bc1f2fd31210988aac08af6d821b039935557a6f14c03471be::stablecoin::Deposit", + "data": { + "amount": "2205743", + "store": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e", + "store_owner": "0xb7cb159db88215cd1670edfe4e30df58177571610983fc06681f4c9773810b3e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x5c2e5a4d1b355b939ab160c618ed5504a6e1addf109388aa3b83b73b207ab6c7::clmm::IncentiveSynced", + "data": { + "accumulated_rewards_per_share": "716025320669911381", + "campaign_idx": "19", + "last_accumulation_time": "1768436509", + "last_total_shares": "1179550690946", + "pool_addr": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "total_distributed": "14008584564", + "ts": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x5c2e5a4d1b355b939ab160c618ed5504a6e1addf109388aa3b83b73b207ab6c7::clmm::Swapped", + "data": { + "a_to_b": false, + "after_sqrt_price": "2566074555342320693", + "amount_in": "2205380", + "amount_out": "113930987", + "creator": "0xb7cb159db88215cd1670edfe4e30df58177571610983fc06681f4c9773810b3e", + "liquidity": "2236721679428", + "pool_addr": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "reserve_a": "9006994507161", + "reserve_b": "172532117305", + "ts": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "2205380", + "store": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xe5c5befe31ce06bc1f2fd31210988aac08af6d821b039935557a6f14c03471be::stablecoin::Withdraw", + "data": { + "amount": "2205380", + "store": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e", + "store_owner": "0xb7cb159db88215cd1670edfe4e30df58177571610983fc06681f4c9773810b3e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "2205380", + "store": "0x52eb213a62b79674b6764ab515fea2e399508539f5de4cab439603967e564aee" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xe5c5befe31ce06bc1f2fd31210988aac08af6d821b039935557a6f14c03471be::stablecoin::Deposit", + "data": { + "amount": "2205380", + "store": "0x52eb213a62b79674b6764ab515fea2e399508539f5de4cab439603967e564aee", + "store_owner": "0x57edaae7ac6e3813b057a675c05f155c0296f6757050e213dda7d8941b79609d" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "2205380", + "store": "0xe3306edc8474330632f681d0e42e2cddf519ee928f0447b29b2466938e751b48" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "113930987", + "store": "0xbf521931fc9fac7cf02e4d56eaf8aaa56d96c1ac1f78e63779a5db5a7f8aae81" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "113930987", + "store": "0xcec5821fed3c0cbe90d21699dd628856c9e5f6b465cc1a44a5aeb7423ee8b231" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "113930987", + "store": "0x3adab3cb7723d75180dae65c7e75802cd9adec7eb387f50bec250d33d3313d36" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x487e905f899ccb6d46fdaec56ba1e0c4cf119862a16c409904b8c78fab1f5e8a::router::Swapped", + "data": { + "amount_in": "2205380", + "amount_out": "113930987", + "asset_in_index": "1", + "asset_out_index": "0", + "assets": [ + "0xa", + "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" + ], + "creator": "0xb7cb159db88215cd1670edfe4e30df58177571610983fc06681f4c9773810b3e", + "pool_addr": "0x4ed8fda291b604491ead0cc9e5232bc1edc1f31d0e0cf343be043d8c792af1a8", + "ts": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "363", + "store": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xe5c5befe31ce06bc1f2fd31210988aac08af6d821b039935557a6f14c03471be::stablecoin::Withdraw", + "data": { + "amount": "363", + "store": "0x1cb0972b66289ac44d3628fb16add7ea14c35b53e3fef287a8a64191a315b10e", + "store_owner": "0xb7cb159db88215cd1670edfe4e30df58177571610983fc06681f4c9773810b3e" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "363", + "store": "0x52eb213a62b79674b6764ab515fea2e399508539f5de4cab439603967e564aee" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xe5c5befe31ce06bc1f2fd31210988aac08af6d821b039935557a6f14c03471be::stablecoin::Deposit", + "data": { + "amount": "363", + "store": "0x52eb213a62b79674b6764ab515fea2e399508539f5de4cab439603967e564aee", + "store_owner": "0x57edaae7ac6e3813b057a675c05f155c0296f6757050e213dda7d8941b79609d" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "363", + "store": "0x4069c13a15c5611127c471cce83799a0e88555857f7ad21778204a137b46b36f" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "113930987", + "store": "0x3adab3cb7723d75180dae65c7e75802cd9adec7eb387f50bec250d33d3313d36" + } + }, + { + "guid": { + "creation_number": "48", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "32209709", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap_aggregator_fungible_asset::PanoraSwapStepEvent", + "data": { + "dex_id": 48, + "input_token_address": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", + "input_token_amount": "2205743", + "output_token_address": "0xa", + "output_token_amount": "113930987", + "pool_id": "1", + "time_stamp": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xe5c5befe31ce06bc1f2fd31210988aac08af6d821b039935557a6f14c03471be::stablecoin::Deposit", + "data": { + "amount": "0", + "store": "0xe221eef6bda1c8c3162e147308acb3d6eb5b6e91851f4e865da50685775d581e", + "store_owner": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "2", + "store": "0x4e204b7064be63d9686c51572ade53816743593aa0d63f7f51e72ceb1592c2be" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "46925", + "store": "0xab2bab159c1ebc63532804cb638c40aa43ce3c8cc275d8a2b86cb6db070d03ca" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "46663", + "store": "0xaa6be15d03b651cae303a952122e03881d25aaf99795725f1f6403bfca3a9839" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x7730cd28ee1cdc9e999336cbc430f99e7c44397c0aa77516f6f23a78559bb5::pool::SwapEvent", + "data": { + "amount_in": "46927", + "amount_out": "46663", + "idx_in": "1", + "idx_out": "0", + "metadata": [ + { + "inner": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8" + }, + { + "inner": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + } + ], + "pool_balances": [ + "572253136", + "3241629542" + ], + "pool_obj": { + "inner": "0xaf5f759ce4a2594cec4d1982160c846eb6ec2c70e7e8e6e2723e2917f13ac88c" + }, + "protocol_fee_amount": "2", + "total_fee_amount": "4" + } + }, + { + "guid": { + "creation_number": "48", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "32209710", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap_aggregator_fungible_asset::PanoraSwapStepEvent", + "data": { + "dex_id": 40, + "input_token_address": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + "input_token_amount": "46927", + "output_token_address": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "output_token_amount": "46663", + "pool_id": "1", + "time_stamp": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "46663", + "store": "0xd007d8c1a82369b23b02988836289a8b689bdaa8f73bc445bffbd1d9ccc7d914" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "46663", + "store": "0xd007d8c1a82369b23b02988836289a8b689bdaa8f73bc445bffbd1d9ccc7d914" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "46663", + "store": "0x947f8404b90af90a508d2735af771a3e826cefe45dfe993dfa43e0f5650465f9" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "46616", + "store": "0xc115632444ee8ad55ea9836fc827511e9322f899760d1a718480af63592d4f12" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "47", + "store": "0x11b37da2f14aec2fec70a9a8023a95344739f4fa1aa611a3b1dff6965faf7cc7" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "2037922", + "store": "0x3816ff22d28624e490a67f49a6c270910fa749b17f925dccd2b5d6749ca86c82" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x4bf51972879e3b95c4781a5cdcb9e1ee24ef483e7d22f2d903626f126df62bd1::liquidity_pool::SwapEvent", + "data": { + "amount_in": "46663", + "amount_out": "2037922", + "from_token": "0xf22bede237a07e121b56d91a491eb7bcdfd1f5907926a9e58338f964a01b17fa::asset::USDC", + "pool": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "to_token": "0x111ae3e5bc816a5e63c2da97d0aa3886519e0cd5e4b046659fa35796bd11542a::stapt_token::StakedApt" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x4bf51972879e3b95c4781a5cdcb9e1ee24ef483e7d22f2d903626f126df62bd1::liquidity_pool::SyncEvent", + "data": { + "pool": "0x5669f388059383ab806e0dfce92196304205059874fd845944137d96bbdfc8de", + "reserves_1": "121353494357", + "reserves_2": "2775919452" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "2037922", + "store": "0x6eacf71f26b6fcfdcef02a3e0491eb10db822e95209a8843f1f7e30c38265937" + } + }, + { + "guid": { + "creation_number": "48", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "32209711", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap_aggregator_fungible_asset::PanoraSwapStepEvent", + "data": { + "dex_id": 23, + "input_token_address": "0x2b3be0a97a73c87ff62cbdd36837a9fb5bbd1d7f06a73b7ed62ec15c5326c1b8", + "input_token_amount": "46663", + "output_token_address": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "output_token_amount": "2037922", + "pool_id": "0", + "time_stamp": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x8b4a2c4bb53857c718a04c020b98f8c2e1f99a68b0f57389a8bf5434cd22e05c::pool_v3::PoolSnapshotV2", + "data": { + "fee_growth_global_a": "55498532293754305231", + "fee_growth_global_b": "49568766848563917478", + "fee_rate": "100", + "fee_rate_denominatore": "1000000", + "liquidity": "31413415807128", + "observation_cardinality": "0", + "observation_cardinality_next": "0", + "observation_index": "0", + "pool_id": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "sqrt_price": "16912646305961967929", + "tick": { + "bits": 4294965559 + }, + "tick_spacing": 1, + "token_a_reserve": "17580706180626", + "token_b_reserve": "14748475677661" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x8b4a2c4bb53857c718a04c020b98f8c2e1f99a68b0f57389a8bf5434cd22e05c::pool_v3::SwapBeforeEvent", + "data": { + "liquidity": "31413415807128", + "pool_id": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "sqrt_price": "16912646305961967929", + "tick": { + "bits": 4294965559 + } + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x661799897c0d2e94c1de976cb3f0e344672c71871e50188622d1b9192723b44c::commission::CommissionEvent", + "data": { + "amount": "20", + "commission_address": "0xd0b17bea776bb87b70b2fb2ca631014f0ca94fc1acde4b8ff1a763f4172aa6c4", + "identifier": "Panora", + "metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "20", + "store": "0x49a9c1f633089baecc2386dfcc3383470c70825c2c25673601d2123af4ab42c9" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "10", + "store": "0xc9c40132974852dc6682a4ae36bbbd62fe20c2cd845ad8dd2558fec6d8238165" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "10", + "store": "0x94ac8cc8dba4ffed3af058b44ae1fe20712ab967602559a74ca3ce86a6a9e80d" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xd6e31e55a750d442bcfb60bbf842d152b102ffa5ac3ae3f2c8b43748c36a3e6f::send_event::FeeToEpoch", + "data": { + "amount": "10", + "epoch": "1768204800", + "meta": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0xcd21066689eb2b346b7cc9f61dd8836693435ef7663725da41075f7a02bae3ae::fee_sharer::DistributeEvent", + "data": { + "amount": "20", + "share_asset_metadata": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "2037882", + "store": "0xdbfaf8b4b43607b8352f918021c1e6749c57529454c660fa3f54cc3580db9e57" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Withdraw", + "data": { + "amount": "2424154", + "store": "0x151243cd05dd0e9cbf78b41cda61e1813c291c40ef92ad1ce4f1489bc1c113c5" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x8b4a2c4bb53857c718a04c020b98f8c2e1f99a68b0f57389a8bf5434cd22e05c::pool_v3::SwapAfterEvent", + "data": { + "liquidity": "31413415807128", + "pool_id": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "sqrt_price": "16912647502560983813", + "tick": { + "bits": 4294965559 + } + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x8b4a2c4bb53857c718a04c020b98f8c2e1f99a68b0f57389a8bf5434cd22e05c::pool_v3::PoolSnapshotV2", + "data": { + "fee_growth_global_a": "55498532293754305231", + "fee_growth_global_b": "49568766848760720225", + "fee_rate": "100", + "fee_rate_denominatore": "1000000", + "liquidity": "31413415807128", + "observation_cardinality": "0", + "observation_cardinality_next": "0", + "observation_index": "0", + "pool_id": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "sqrt_price": "16912647502560983813", + "tick": { + "bits": 4294965559 + }, + "tick_spacing": 1, + "token_a_reserve": "17580703756472", + "token_b_reserve": "14748477715543" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x8b4a2c4bb53857c718a04c020b98f8c2e1f99a68b0f57389a8bf5434cd22e05c::pool_v3::SwapEventV3", + "data": { + "active_liquidity": "31413415807128", + "amount_in": "2037882", + "amount_out": "2424154", + "current_tick": { + "bits": 4294965559 + }, + "fee_amount": "204", + "from_token": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + }, + "pool_id": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "pool_reserve_a": "17580703756472", + "pool_reserve_b": "14748477715543", + "protocol_fee_amount": "40", + "sqrt_price": "16912647502560983813", + "to_token": { + "inner": "0xa" + } + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x8b4a2c4bb53857c718a04c020b98f8c2e1f99a68b0f57389a8bf5434cd22e05c::partnership::PartnerSwapEvent", + "data": { + "amount_in": "2037922", + "partner": "Panora", + "pool_id": "0x92d262e9b10b1dfad780db8d73b9f84cdd555aaf8853a74a2db1b61194ce2df3", + "token_in": { + "inner": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627" + } + } + }, + { + "guid": { + "creation_number": "48", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "32209712", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap_aggregator_fungible_asset::PanoraSwapStepEvent", + "data": { + "dex_id": 44, + "input_token_address": "0xb614bfdf9edc39b330bbf9c3c5bcd0473eee2f6d4e21748629cc367869ece627", + "input_token_amount": "2037922", + "output_token_address": "0xa", + "output_token_amount": "2424154", + "pool_id": "0", + "time_stamp": "1768436509405198" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "310670", + "store": "0xbc52b11f809e5c13236f3ec3296f56259b7bee9898d47efd621354083adb97d9" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "303009", + "store": "0x532a89b74cf4a98dc98bcd49adbb3823838344840ccff1d69c5354354cf75ae4" + } + }, + { + "guid": { + "creation_number": "49", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "35560", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_fees_structure::FeeEventIntegrator", + "data": { + "campaign_id": "0", + "integrator_address": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "time_stamp": "1768436509405198", + "token_address": "0xa", + "token_amount": "303009" + } + }, + { + "guid": { + "creation_number": "50", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "379420", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_fees_structure::FeeEventPanora", + "data": { + "campaign_id": "0", + "time_stamp": "1768436509405198", + "token_address": "0xa", + "token_amount": "310670" + } + }, + { + "guid": { + "creation_number": "52", + "account_address": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c" + }, + "sequence_number": "7416351", + "type": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap::PanoraSwapSummaryEvent", + "data": { + "function_id": "1", + "input_token_address": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + "input_token_amount": "2346314", + "integrator_address": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + "output_token_address": "0xa", + "output_token_amount": "120590251", + "platform_id": "1001012", + "time_stamp": "1768436509405198", + "user_address": "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::fungible_asset::Deposit", + "data": { + "amount": "120590251", + "store": "0x532a89b74cf4a98dc98bcd49adbb3823838344840ccff1d69c5354354cf75ae4" + } + }, + { + "guid": { + "creation_number": "0", + "account_address": "0x0" + }, + "sequence_number": "0", + "type": "0x1::transaction_fee::FeeStatement", + "data": { + "execution_gas_units": "155", + "io_gas_units": "185", + "storage_fee_octas": "108760", + "storage_fee_refund_octas": "0", + "total_charge_gas_units": "1426" + } + } + ], + "timestamp": "1768436509405198", + "type": "user_transaction" +} diff --git a/core/crates/gem_auth/Cargo.toml b/core/crates/gem_auth/Cargo.toml new file mode 100644 index 0000000000..1718ddfdbf --- /dev/null +++ b/core/crates/gem_auth/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "gem_auth" +edition = { workspace = true } +version = { workspace = true } + +[features] +default = [] +client = ["dep:cacher", "dep:chrono", "dep:uuid", "dep:jsonwebtoken"] + +[dependencies] +primitives = { path = "../primitives" } +serde = { workspace = true } +serde_json = { workspace = true } +alloy-primitives = { workspace = true } +gem_encoding = { path = "../gem_encoding" } +ed25519-dalek = { workspace = true } + +# Optional dependencies for client feature (server-side only) +cacher = { path = "../cacher", optional = true } +chrono = { workspace = true, optional = true } +uuid = { workspace = true, optional = true } +jsonwebtoken = { workspace = true, optional = true } + +[dev-dependencies] +alloy-signer = { workspace = true } +alloy-signer-local = { workspace = true } +primitives = { path = "../primitives", features = ["testkit"] } diff --git a/core/crates/gem_auth/src/client.rs b/core/crates/gem_auth/src/client.rs new file mode 100644 index 0000000000..a83ed1ba5a --- /dev/null +++ b/core/crates/gem_auth/src/client.rs @@ -0,0 +1,44 @@ +use std::error::Error; +use std::time::Duration; + +use cacher::{CacheKey, CacherClient}; +use chrono::Utc; +use primitives::{AuthNonce, DeviceToken}; +use uuid::Uuid; + +use crate::jwt; + +pub struct AuthClient { + cacher: CacherClient, +} + +impl AuthClient { + pub fn new(cacher: CacherClient) -> Self { + Self { cacher } + } + + pub fn create_device_token(&self, device_id: &str, secret: &str, expiry: Duration) -> Result> { + let (token, expires_at) = jwt::create_device_token(device_id, secret, expiry)?; + Ok(DeviceToken { token, expires_at }) + } + + pub async fn get_nonce(&self, device_id: &str) -> Result> { + let auth_nonce = AuthNonce { + nonce: Uuid::new_v4().to_string(), + timestamp: Utc::now().timestamp() as u32, + }; + let cache_key = CacheKey::AuthNonce(device_id, &auth_nonce.nonce); + let value = serde_json::to_string(&auth_nonce)?; + self.cacher.set_value_with_ttl(&cache_key.key(), value, cache_key.ttl()).await?; + Ok(auth_nonce) + } + + pub async fn get_auth_nonce(&self, device_id: &str, nonce: &str) -> Result> { + self.cacher.get_value::(&CacheKey::AuthNonce(device_id, nonce).key()).await + } + + pub async fn invalidate_nonce(&self, device_id: &str, nonce: &str) -> Result<(), Box> { + self.cacher.delete(&CacheKey::AuthNonce(device_id, nonce).key()).await?; + Ok(()) + } +} diff --git a/core/crates/gem_auth/src/device_signature.rs b/core/crates/gem_auth/src/device_signature.rs new file mode 100644 index 0000000000..8c1452d941 --- /dev/null +++ b/core/crates/gem_auth/src/device_signature.rs @@ -0,0 +1,187 @@ +use alloy_primitives::hex; +use ed25519_dalek::{Signature, VerifyingKey}; +use gem_encoding::decode_base64; + +pub const GEM_AUTH_SCHEME: &str = "Gem "; + +#[derive(Debug, PartialEq)] +pub enum AuthScheme { + Gem, + Legacy, +} + +pub struct DeviceAuthPayload { + pub scheme: AuthScheme, + pub device_id: String, + pub timestamp: String, + pub wallet_id: Option, + pub body_hash: String, + pub signature: Vec, +} + +pub fn parse_device_auth(header_value: &str) -> Option { + let encoded = header_value.strip_prefix(GEM_AUTH_SCHEME)?; + let decoded = decode_base64(encoded).ok()?; + let payload = String::from_utf8(decoded).ok()?; + let parts: Vec<&str> = payload.splitn(5, '.').collect(); + if parts.len() != 5 { + return None; + } + Some(DeviceAuthPayload { + scheme: AuthScheme::Gem, + device_id: parts[0].to_string(), + timestamp: parts[1].to_string(), + wallet_id: if parts[2].is_empty() { None } else { Some(parts[2].to_string()) }, + body_hash: parts[3].to_string(), + signature: hex::decode(parts[4]).ok()?, + }) +} + +// TODO: remove base64 fallback once all clients use hex signatures +pub fn decode_signature(value: &str) -> Option> { + hex::decode(value).ok().or_else(|| decode_base64(value).ok()) +} + +pub fn verify_device_signature(public_key_hex: &str, message: &str, signature: &[u8]) -> bool { + let Ok(pk_bytes) = hex::decode(public_key_hex) else { + return false; + }; + let Ok(pk_array): Result<[u8; 32], _> = pk_bytes.try_into() else { + return false; + }; + let Ok(verifying_key) = VerifyingKey::from_bytes(&pk_array) else { + return false; + }; + let Ok(sig_array): Result<[u8; 64], _> = signature.try_into() else { + return false; + }; + let signature = Signature::from_bytes(&sig_array); + verifying_key.verify_strict(message.as_bytes(), &signature).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::hex; + use ed25519_dalek::{Signer, SigningKey}; + use gem_encoding::encode_base64; + + #[test] + fn test_verify_valid_signature() { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let message = "v1.1706000000000.GET./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let signature = signing_key.sign(message.as_bytes()); + + assert!(verify_device_signature(&public_key_hex, message, &signature.to_bytes())); + } + + #[test] + fn test_reject_invalid_signature() { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let message = "v1.1706000000000.GET./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + assert!(!verify_device_signature(&public_key_hex, message, &[0u8; 64])); + } + + #[test] + fn test_reject_tampered_message() { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let message = "v1.1706000000000.GET./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let signature = signing_key.sign(message.as_bytes()); + + let tampered = "v1.1706000000000.POST./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + assert!(!verify_device_signature(&public_key_hex, tampered, &signature.to_bytes())); + } + + #[test] + fn test_reject_wrong_public_key() { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + let wrong_key = SigningKey::from_bytes(&[2u8; 32]); + let wrong_public_key_hex = hex::encode(wrong_key.verifying_key().as_bytes()); + let message = "v1.1706000000000.GET./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let signature = signing_key.sign(message.as_bytes()); + + assert!(!verify_device_signature(&wrong_public_key_hex, message, &signature.to_bytes())); + } + + #[test] + fn test_reject_invalid_signature_length() { + assert!(!verify_device_signature("aabb", "msg", &[0u8; 2])); + } + + #[test] + fn test_parse_device_auth() { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let timestamp = "1706000000000"; + let body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let wallet_id = "multicoin_0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"; + let signature = signing_key.sign(b"test"); + let signature_hex = hex::encode(signature.to_bytes()); + + let payload = format!("{}.{}.{}.{}.{}", public_key_hex, timestamp, wallet_id, body_hash, signature_hex); + let encoded = encode_base64(payload.as_bytes()); + let header = format!("Gem {}", encoded); + + let result = parse_device_auth(&header).unwrap(); + assert_eq!(result.device_id, public_key_hex); + assert_eq!(result.timestamp, timestamp); + assert_eq!(result.wallet_id.as_deref(), Some(wallet_id)); + assert_eq!(result.body_hash, body_hash); + assert_eq!(result.signature, signature.to_bytes()); + } + + #[test] + fn test_parse_device_auth_invalid() { + assert!(parse_device_auth("Bearer token").is_none()); + assert!(parse_device_auth("Gem !!!").is_none()); + let encoded = encode_base64(b"only.two.parts"); + assert!(parse_device_auth(&format!("Gem {}", encoded)).is_none()); + } + + #[test] + fn test_parse_device_auth_empty_wallet_id() { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let timestamp = "1706000000000"; + let body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let signature = signing_key.sign(b"test"); + let signature_hex = hex::encode(signature.to_bytes()); + + let payload = format!("{}.{}..{}.{}", public_key_hex, timestamp, body_hash, signature_hex); + let encoded = encode_base64(payload.as_bytes()); + let header = format!("Gem {}", encoded); + + let result = parse_device_auth(&header).unwrap(); + assert_eq!(result.device_id, public_key_hex); + assert_eq!(result.timestamp, timestamp); + assert_eq!(result.wallet_id, None); + assert_eq!(result.body_hash, body_hash); + } + + #[test] + fn test_verify_signature_with_wallet_id() { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let wallet_id = "multicoin_0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"; + let body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let message = format!("1706000000000.GET./v1/devices/abc.{}.{}", wallet_id, body_hash); + let signature = signing_key.sign(message.as_bytes()); + + assert!(verify_device_signature(&public_key_hex, &message, &signature.to_bytes())); + } + + #[test] + fn test_verify_signature_empty_wallet_id() { + let signing_key = SigningKey::from_bytes(&[1u8; 32]); + let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let message = format!("1706000000000.GET./v1/devices/abc..{}", body_hash); + let signature = signing_key.sign(message.as_bytes()); + + assert!(verify_device_signature(&public_key_hex, &message, &signature.to_bytes())); + } +} diff --git a/core/crates/gem_auth/src/jwt.rs b/core/crates/gem_auth/src/jwt.rs new file mode 100644 index 0000000000..44f7473400 --- /dev/null +++ b/core/crates/gem_auth/src/jwt.rs @@ -0,0 +1,68 @@ +use std::time::Duration; + +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct JwtClaims { + pub sub: String, + pub exp: u64, + pub iat: u64, +} + +pub fn create_device_token(device_id: &str, secret: &str, expiry: Duration) -> Result<(String, u64), jsonwebtoken::errors::Error> { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let expires_at = now + expiry.as_secs(); + let claims = JwtClaims { + sub: device_id.to_string(), + exp: expires_at, + iat: now, + }; + let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?; + Ok((token, expires_at)) +} + +pub fn verify_device_token(token: &str, secret: &str) -> Result { + let token_data = decode::(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::new(Algorithm::HS256))?; + Ok(token_data.claims) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_and_verify() { + let secret = "test_secret_key_12345"; + let device_id = "abc123"; + let (token, expires_at) = create_device_token(device_id, secret, Duration::from_secs(3600)).unwrap(); + let claims = verify_device_token(&token, secret).unwrap(); + + assert_eq!(claims.sub, device_id); + assert_eq!(claims.exp, expires_at); + assert_eq!(claims.exp - claims.iat, 3600); + } + + #[test] + fn test_wrong_secret() { + let (token, _) = create_device_token("device1", "secret1", Duration::from_secs(3600)).unwrap(); + assert!(verify_device_token(&token, "wrong_secret").is_err()); + } + + #[test] + fn test_expired_token() { + let secret = "test_secret"; + let claims = JwtClaims { + sub: "device1".to_string(), + exp: 1000, + iat: 900, + }; + let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes())).unwrap(); + assert!(verify_device_token(&token, secret).is_err()); + } + + #[test] + fn test_invalid_token() { + assert!(verify_device_token("not.a.valid.token", "secret").is_err()); + } +} diff --git a/core/crates/gem_auth/src/lib.rs b/core/crates/gem_auth/src/lib.rs new file mode 100644 index 0000000000..20b59fecb4 --- /dev/null +++ b/core/crates/gem_auth/src/lib.rs @@ -0,0 +1,13 @@ +#[cfg(feature = "client")] +mod client; +mod device_signature; +#[cfg(feature = "client")] +mod jwt; +mod signature; + +#[cfg(feature = "client")] +pub use client::AuthClient; +pub use device_signature::{AuthScheme, DeviceAuthPayload, GEM_AUTH_SCHEME, decode_signature, parse_device_auth, verify_device_signature}; +#[cfg(feature = "client")] +pub use jwt::{JwtClaims, create_device_token, verify_device_token}; +pub use signature::{AuthMessageData, create_auth_hash, verify_auth_signature}; diff --git a/core/crates/gem_auth/src/signature.rs b/core/crates/gem_auth/src/signature.rs new file mode 100644 index 0000000000..10e4022de9 --- /dev/null +++ b/core/crates/gem_auth/src/signature.rs @@ -0,0 +1,92 @@ +use alloy_primitives::{B256, Signature, hex}; +use primitives::{AuthMessage, ChainType}; + +pub struct AuthMessageData { + pub message: String, + pub hash: [u8; 32], +} + +pub fn create_auth_hash(auth_message: &AuthMessage) -> AuthMessageData { + let message = serde_json::to_string(auth_message).unwrap_or_default(); + let hash = alloy_primitives::keccak256(message.as_bytes()); + AuthMessageData { message, hash: hash.into() } +} + +pub fn verify_auth_signature(auth_message: &AuthMessage, signature: &str) -> bool { + match auth_message.chain.chain_type() { + ChainType::Ethereum => verify_ethereum_signature(auth_message, signature), + _ => false, // TODO: Add support for other chain types + } +} + +fn verify_ethereum_signature(auth_message: &AuthMessage, signature: &str) -> bool { + let data = create_auth_hash(auth_message); + verify_hash_signature(&data.hash, signature, &auth_message.address) +} + +fn verify_hash_signature(hash: &[u8; 32], signature: &str, expected_address: &str) -> bool { + let Some(recovered) = recover_address_from_hash(hash, signature) else { + return false; + }; + recovered == expected_address +} + +fn recover_address_from_hash(hash: &[u8; 32], signature: &str) -> Option { + let signature_bytes = hex::decode(signature.strip_prefix("0x").unwrap_or(signature)).ok()?; + + if signature_bytes.len() != 65 { + return None; + } + + let signature = Signature::try_from(signature_bytes.as_slice()).ok()?; + let hash = B256::from_slice(hash); + let address = signature.recover_address_from_prehash(&hash).ok()?; + + Some(address.to_checksum(None)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use primitives::{AuthNonce, Chain, testkit::signer_mock::TEST_PRIVATE_KEY}; + + fn sign_auth_message(auth_message: &AuthMessage, signer: &PrivateKeySigner) -> String { + let message = serde_json::to_string(auth_message).unwrap(); + let hash = alloy_primitives::keccak256(message.as_bytes()); + let signature = signer.sign_hash_sync(&hash).unwrap(); + format!("0x{}", hex::encode(signature.as_bytes())) + } + + #[test] + fn test_verify_auth_signature_success() { + let signer = PrivateKeySigner::from_slice(&TEST_PRIVATE_KEY).unwrap(); + let address = signer.address().to_checksum(None); + + let auth_message = AuthMessage { + chain: Chain::Ethereum, + address: address.clone(), + auth_nonce: AuthNonce { + nonce: "test-nonce-123".to_string(), + timestamp: 1734100000, + }, + }; + + let signature = sign_auth_message(&auth_message, &signer); + assert!(verify_auth_signature(&auth_message, &signature)); + } + + #[test] + fn test_verify_auth_signature_invalid() { + let auth_message = AuthMessage { + chain: Chain::Ethereum, + address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + auth_nonce: AuthNonce { + nonce: "test123".to_string(), + timestamp: 1234567890, + }, + }; + assert!(!verify_auth_signature(&auth_message, "0x")); + } +} diff --git a/core/crates/gem_bitcoin/Cargo.toml b/core/crates/gem_bitcoin/Cargo.toml new file mode 100644 index 0000000000..66c73a36dd --- /dev/null +++ b/core/crates/gem_bitcoin/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "gem_bitcoin" +version = { workspace = true } +edition = { workspace = true } +publish = false + +[features] +default = [] +rpc = ["dep:chain_traits", "dep:gem_client"] +signer = ["dep:signer", "dep:gem_hash", "dep:hex"] +reqwest = ["gem_client/reqwest"] +unit_tests = ["signer"] +chain_integration_tests = ["rpc", "reqwest", "primitives/testkit", "settings/testkit"] + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +primitives = { path = "../primitives" } +gem_client = { path = "../gem_client", optional = true } +chain_traits = { path = "../chain_traits", optional = true } +number_formatter = { path = "../number_formatter" } +async-trait = { workspace = true } +futures = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +num-bigint = { workspace = true } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } + +# Optional signer dependencies +signer = { path = "../signer", optional = true } +gem_hash = { path = "../gem_hash", optional = true } +hex = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_bitcoin/src/lib.rs b/core/crates/gem_bitcoin/src/lib.rs new file mode 100644 index 0000000000..07e4f514bd --- /dev/null +++ b/core/crates/gem_bitcoin/src/lib.rs @@ -0,0 +1,19 @@ +pub mod models; + +#[cfg(feature = "rpc")] +pub mod provider; + +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "signer")] +pub mod signer; + +#[cfg(test)] +pub mod testkit; + +#[cfg(feature = "rpc")] +pub use provider::map_transaction; + +#[cfg(feature = "rpc")] +pub use rpc::client::BitcoinClient; diff --git a/core/crates/gem_bitcoin/src/models/account.rs b/core/crates/gem_bitcoin/src/models/account.rs new file mode 100644 index 0000000000..cd447ff1eb --- /dev/null +++ b/core/crates/gem_bitcoin/src/models/account.rs @@ -0,0 +1,33 @@ +use num_bigint::{BigInt, BigUint}; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_bigint_from_str, deserialize_biguint_from_str}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitcoinAccount { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub balance: BigUint, + #[serde(default, deserialize_with = "deserialize_bigint_from_str")] + pub unconfirmed_balance: BigInt, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_positive_unconfirmed() { + let json = r#"{"balance": "16097910", "unconfirmedBalance": "10000000"}"#; + let account: BitcoinAccount = serde_json::from_str(json).unwrap(); + assert_eq!(account.balance, BigUint::from(16097910_u64)); + assert_eq!(account.unconfirmed_balance, BigInt::from(10000000_i64)); + } + + #[test] + fn test_deserialize_negative_unconfirmed() { + let json = r#"{"balance": "20000000", "unconfirmedBalance": "-10001045"}"#; + let account: BitcoinAccount = serde_json::from_str(json).unwrap(); + assert_eq!(account.balance, BigUint::from(20000000_u64)); + assert_eq!(account.unconfirmed_balance, BigInt::from(-10001045_i64)); + } +} diff --git a/core/crates/gem_bitcoin/src/models/address.rs b/core/crates/gem_bitcoin/src/models/address.rs new file mode 100644 index 0000000000..e76071da6f --- /dev/null +++ b/core/crates/gem_bitcoin/src/models/address.rs @@ -0,0 +1,59 @@ +use primitives::chain::Chain; + +const BITCOINCASH_PREFIX: &str = "bitcoincash:"; + +pub struct Address { + value: String, + chain: Chain, +} + +impl Address { + pub fn new(value: impl Into, chain: Chain) -> Self { + Self { value: value.into(), chain } + } + + pub fn short(&self) -> &str { + match self.chain { + Chain::BitcoinCash => self.value.strip_prefix(BITCOINCASH_PREFIX).unwrap_or(&self.value), + _ => &self.value, + } + } + + pub fn full(&self) -> String { + match self.chain { + Chain::BitcoinCash if !self.value.starts_with(BITCOINCASH_PREFIX) => { + format!("{}{}", BITCOINCASH_PREFIX, self.value) + } + _ => self.value.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_short() { + let addr = Address::new("bitcoincash:qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q", Chain::BitcoinCash); + assert_eq!(addr.short(), "qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q"); + + let addr = Address::new("qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q", Chain::BitcoinCash); + assert_eq!(addr.short(), "qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q"); + + let addr = Address::new("bc1qinput", Chain::Bitcoin); + assert_eq!(addr.short(), "bc1qinput"); + } + + #[test] + fn test_full() { + let addr = Address::new("qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q", Chain::BitcoinCash); + assert_eq!(addr.full(), "bitcoincash:qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q"); + + let addr = Address::new("bitcoincash:qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q", Chain::BitcoinCash); + assert_eq!(addr.full(), "bitcoincash:qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q"); + + let addr = Address::new("bc1qinput", Chain::Bitcoin); + assert_eq!(addr.full(), "bc1qinput"); + } +} diff --git a/core/crates/gem_bitcoin/src/models/block.rs b/core/crates/gem_bitcoin/src/models/block.rs new file mode 100644 index 0000000000..5d774c77e0 --- /dev/null +++ b/core/crates/gem_bitcoin/src/models/block.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +use crate::models::Transaction; + +type Int = u64; + +// Domain models +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitcoinBlock { + pub previous_block_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BitcoinNodeInfo { + pub blockbook: BitcoinBlockbook, + pub backend: BitcoinBackend, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitcoinBlockbook { + pub in_sync: bool, + pub last_block_time: String, + pub best_height: Int, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitcoinBackend { + pub blocks: Int, + pub chain: Option, + pub consensus: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Consensus { + pub chaintip: String, +} + +// RPC models +#[derive(Debug, Deserialize, Serialize)] +pub struct Status { + pub blockbook: Blockbook, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Blockbook { + #[serde(rename = "bestHeight")] + pub best_height: i64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Block { + pub page: u64, + pub total_pages: u64, + pub txs: Vec, +} diff --git a/core/crates/gem_bitcoin/src/models/fee.rs b/core/crates/gem_bitcoin/src/models/fee.rs new file mode 100644 index 0000000000..dc2e2d069c --- /dev/null +++ b/core/crates/gem_bitcoin/src/models/fee.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BitcoinFeeResult { + pub result: String, +} diff --git a/core/crates/gem_bitcoin/src/models/mod.rs b/core/crates/gem_bitcoin/src/models/mod.rs new file mode 100644 index 0000000000..3e76e87ca3 --- /dev/null +++ b/core/crates/gem_bitcoin/src/models/mod.rs @@ -0,0 +1,13 @@ +pub mod account; +pub mod address; +pub mod block; +pub mod fee; +pub mod transaction; + +pub type UInt64 = u64; + +pub use account::*; +pub use address::*; +pub use block::*; +pub use fee::*; +pub use transaction::*; diff --git a/core/crates/gem_bitcoin/src/models/transaction.rs b/core/crates/gem_bitcoin/src/models/transaction.rs new file mode 100644 index 0000000000..f16f5d72be --- /dev/null +++ b/core/crates/gem_bitcoin/src/models/transaction.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; + +use super::UInt64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitcoinTransaction { + pub block_height: UInt64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BitcoinTransactionBroacastResult { + pub error: Option, + pub result: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum BitcoinTransactionBroacastError { + Plain(String), + Detailed { message: String }, +} + +impl BitcoinTransactionBroacastError { + pub fn message(&self) -> &str { + match self { + Self::Plain(s) => s, + Self::Detailed { message } => message, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BitcoinUTXO { + pub txid: String, + pub vout: i32, + pub value: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AddressDetails { + pub transactions: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + pub txid: String, + pub value: String, + pub value_in: String, + pub fees: String, + pub confirmations: Option, + pub block_time: i64, + pub block_height: i64, + pub vin: Vec, + pub vout: Vec, +} + +impl Transaction { + pub fn is_confirmed(&self) -> bool { + self.confirmations.map_or(self.block_height > 0, |confirmations| confirmations > 0) + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Input { + pub is_address: bool, + pub addresses: Option>, // will be optional for Coinbase Input + pub value: String, + pub n: i64, + pub tx_id: Option, // will be optional for Coinbase Input + pub vout: Option, // will be optional for Coinbase Input +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Output { + pub is_address: bool, + pub addresses: Option>, + pub value: String, + pub n: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_broadcast_error_string() { + let json = r#"{"error": "-26: min relay fee not met, 432 < 576"}"#; + let result: BitcoinTransactionBroacastResult = serde_json::from_str(json).unwrap(); + + assert!(result.result.is_none()); + assert_eq!(result.error.unwrap().message(), "-26: min relay fee not met, 432 < 576"); + } + + #[test] + fn test_deserialize_broadcast_error_object() { + let json = r#"{"error": {"message": "transaction already in block chain"}}"#; + let result: BitcoinTransactionBroacastResult = serde_json::from_str(json).unwrap(); + + assert!(result.result.is_none()); + assert_eq!(result.error.unwrap().message(), "transaction already in block chain"); + } + + #[test] + fn test_deserialize_broadcast_success() { + let json = r#"{"result": "abc123def456"}"#; + let result: BitcoinTransactionBroacastResult = serde_json::from_str(json).unwrap(); + + assert!(result.error.is_none()); + assert_eq!(result.result.unwrap(), "abc123def456"); + } +} diff --git a/core/crates/gem_bitcoin/src/provider/balances.rs b/core/crates/gem_bitcoin/src/provider/balances.rs new file mode 100644 index 0000000000..08aef56f58 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/balances.rs @@ -0,0 +1,57 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use std::error::Error; + +use gem_client::Client; +use primitives::AssetBalance; + +use super::balances_mapper::map_balance_coin; +use crate::models::Address; +use crate::rpc::client::BitcoinClient; + +#[async_trait] +impl ChainBalances for BitcoinClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let address = &Address::new(&address, self.get_chain()).full(); + let account = self.get_balance(address).await?; + Ok(map_balance_coin(&account, self.chain)) + } + + async fn get_balance_tokens(&self, _address: String, _token_ids: Vec) -> Result, Box> { + Ok(vec![]) + } + + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Ok(None) + } + + async fn get_balance_assets(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainBalances; + + #[tokio::test] + async fn test_bitcoin_get_balance_coin() -> Result<(), Box> { + let client = create_bitcoin_test_client(); + let address = TEST_ADDRESS.to_string(); + let balance = client.get_balance_coin(address).await?; + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + println!("Balance: {:?} {}", balance.balance.available, balance.asset_id); + Ok(()) + } + + #[tokio::test] + async fn test_bitcoin_get_balance_assets() -> Result<(), Box> { + let client = create_bitcoin_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + assert_eq!(assets.len(), 0); + Ok(()) + } +} diff --git a/core/crates/gem_bitcoin/src/provider/balances_mapper.rs b/core/crates/gem_bitcoin/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..f3ec2be545 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/balances_mapper.rs @@ -0,0 +1,38 @@ +use crate::models::account::BitcoinAccount; +use primitives::{AssetBalance, Balance, BitcoinChain}; + +pub fn map_balance_coin(account: &BitcoinAccount, chain: BitcoinChain) -> AssetBalance { + let pending_unconfirmed = account.unconfirmed_balance.to_biguint().unwrap_or_default(); + let balance = Balance::with_pending_unconfirmed(account.balance.clone(), pending_unconfirmed); + AssetBalance::new_balance(chain.get_chain().as_asset_id(), balance) +} + +#[cfg(test)] +mod tests { + use super::*; + use num_bigint::{BigInt, BigUint}; + + #[test] + fn test_map_balance_coin_positive_unconfirmed() { + let account = BitcoinAccount { + balance: BigUint::from(20998955_u64), + unconfirmed_balance: BigInt::from(5100000_i64), + }; + let result = map_balance_coin(&account, BitcoinChain::Bitcoin); + + assert_eq!(result.balance.available, BigUint::from(20998955_u64)); + assert_eq!(result.balance.pending_unconfirmed, BigUint::from(5100000_u64)); + } + + #[test] + fn test_map_balance_coin_negative_unconfirmed() { + let account = BitcoinAccount { + balance: BigUint::from(20000000_u64), + unconfirmed_balance: BigInt::from(-10001045_i64), + }; + let result = map_balance_coin(&account, BitcoinChain::Bitcoin); + + assert_eq!(result.balance.available, BigUint::from(20000000_u64)); + assert_eq!(result.balance.pending_unconfirmed, BigUint::ZERO); + } +} diff --git a/core/crates/gem_bitcoin/src/provider/mod.rs b/core/crates/gem_bitcoin/src/provider/mod.rs new file mode 100644 index 0000000000..fb11ca31e4 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/mod.rs @@ -0,0 +1,26 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod state; +pub mod state_mapper; +pub mod testkit; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; + +pub use transactions_mapper::map_transaction; + +// Empty ChainAccount implementation +use crate::rpc::client::BitcoinClient; +use async_trait::async_trait; +use chain_traits::ChainAccount; +use gem_client::Client; + +#[async_trait] +impl ChainAccount for BitcoinClient {} diff --git a/core/crates/gem_bitcoin/src/provider/preload.rs b/core/crates/gem_bitcoin/src/provider/preload.rs new file mode 100644 index 0000000000..5cce4332c2 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/preload.rs @@ -0,0 +1,123 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use futures; +use num_bigint::BigInt; +use number_formatter::BigNumberFormatter; +use std::error::Error; + +use gem_client::Client; +use primitives::{ + BitcoinChain, FeePriority, FeeRate, GasPriceType, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput, UTXO, +}; + +use crate::models::Address; +use crate::provider::preload_mapper::{map_transaction_preload, map_transaction_preload_zcash, map_utxos}; +use crate::rpc::client::BitcoinClient; + +#[async_trait] +impl ChainTransactionLoad for BitcoinClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let address = Address::new(&input.sender_address, self.get_chain()).full(); + match self.chain { + BitcoinChain::Bitcoin | BitcoinChain::Litecoin | BitcoinChain::BitcoinCash | BitcoinChain::Doge => { + let utxos = self.get_utxos(&address).await?; + Ok(map_transaction_preload(utxos, input)) + } + BitcoinChain::Zcash => { + let utxos = self.get_utxos(&address).await?; + let node_info = self.get_node_info().await?; + Ok(map_transaction_preload_zcash(node_info, utxos, input)?) + } + } + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + Ok(TransactionLoadData { + fee: input.default_fee(), + metadata: input.metadata, + }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + match self.chain { + BitcoinChain::Bitcoin | BitcoinChain::Litecoin | BitcoinChain::BitcoinCash | BitcoinChain::Doge => { + let priority = self.chain.get_blocks_fee_priority(); + let (slow, normal, fast) = futures::try_join!(self.get_fee(priority.slow), self.get_fee(priority.normal), self.get_fee(priority.fast))?; + Ok(map_fee_rates(slow, normal, fast, self.chain)) + } + BitcoinChain::Zcash => { + return Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1_000).clone()))]); + } + } + } + + async fn get_utxos(&self, address: String) -> Result, Box> { + let utxos = BitcoinClient::get_utxos(self, &address).await?; + Ok(map_utxos(utxos, address)) + } +} + +impl BitcoinClient { + async fn get_fee(&self, blocks: i32) -> Result> { + let fee_sat_per_kb = self.get_fee_priority(blocks).await?; + calculate_fee_rate(&fee_sat_per_kb, self.chain.minimum_byte_fee() as u32) + } +} + +fn calculate_fee_rate(fee_sat_per_kb: &str, minimum_byte_fee: u32) -> Result> { + let rate = BigNumberFormatter::value_from_amount(fee_sat_per_kb, 8)?.parse::()? / 1000.0; + let minimum_byte_fee = minimum_byte_fee as f64; + + Ok(BigInt::from(rate.max(minimum_byte_fee) as i64)) +} + +fn map_fee_rates(slow: BigInt, normal: BigInt, fast: BigInt, chain: BitcoinChain) -> Vec { + let min_fee = BigInt::from(chain.minimum_byte_fee()); + let normal = normal.max(&slow + &min_fee); + let third: BigInt = &normal / 3; + let fast = fast.max(&slow + &min_fee * 2).max(&normal + third); + vec![ + FeeRate::new(FeePriority::Slow, GasPriceType::regular(slow)), + FeeRate::new(FeePriority::Normal, GasPriceType::regular(normal)), + FeeRate::new(FeePriority::Fast, GasPriceType::regular(fast)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_fee_rate() { + assert_eq!(calculate_fee_rate("0.00004131", 1).unwrap(), BigInt::from(4)); + assert_eq!(calculate_fee_rate("0.00001131", 1).unwrap(), BigInt::from(1)); + assert_eq!(calculate_fee_rate("0.000001", 5).unwrap(), BigInt::from(5)); + assert_eq!(calculate_fee_rate("0", 1).unwrap(), BigInt::from(1)); + assert!(calculate_fee_rate("invalid", 1).is_err()); + } + + #[test] + fn test_map_fee_rates_bitcoin() { + // all 1 sat → normal=2 (slow+1), fast=3 (slow+2) + let rates = map_fee_rates(BigInt::from(1), BigInt::from(1), BigInt::from(1), BitcoinChain::Bitcoin); + assert_eq!(FeeRate::find(&rates, FeePriority::Normal).unwrap().gas_price_type.gas_price(), BigInt::from(2)); + assert_eq!(FeeRate::find(&rates, FeePriority::Fast).unwrap().gas_price_type.gas_price(), BigInt::from(3)); + + // slow=1, normal=12, fast=12 → normal kept, fast=16 (12+12/3) + let rates = map_fee_rates(BigInt::from(1), BigInt::from(12), BigInt::from(12), BitcoinChain::Bitcoin); + assert_eq!(FeeRate::find(&rates, FeePriority::Normal).unwrap().gas_price_type.gas_price(), BigInt::from(12)); + assert_eq!(FeeRate::find(&rates, FeePriority::Fast).unwrap().gas_price_type.gas_price(), BigInt::from(16)); + + // fast already higher → kept + let rates = map_fee_rates(BigInt::from(1), BigInt::from(12), BigInt::from(25), BitcoinChain::Bitcoin); + assert_eq!(FeeRate::find(&rates, FeePriority::Fast).unwrap().gas_price_type.gas_price(), BigInt::from(25)); + } + + #[test] + fn test_map_fee_rates_doge() { + // all 1000 → normal=2000 (1000+1000), fast=3000 (slow+2000) + let rates = map_fee_rates(BigInt::from(1000), BigInt::from(1000), BigInt::from(1000), BitcoinChain::Doge); + assert_eq!(FeeRate::find(&rates, FeePriority::Normal).unwrap().gas_price_type.gas_price(), BigInt::from(2000)); + assert_eq!(FeeRate::find(&rates, FeePriority::Fast).unwrap().gas_price_type.gas_price(), BigInt::from(3000)); + } +} diff --git a/core/crates/gem_bitcoin/src/provider/preload_mapper.rs b/core/crates/gem_bitcoin/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..bd6860bf09 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/preload_mapper.rs @@ -0,0 +1,32 @@ +use std::error::Error; + +use primitives::{TransactionLoadMetadata, TransactionPreloadInput, UTXO}; + +use crate::models::{BitcoinNodeInfo, BitcoinUTXO}; + +pub fn map_transaction_preload(utxos: Vec, input: TransactionPreloadInput) -> TransactionLoadMetadata { + let utxos = map_utxos(utxos, input.sender_address.clone()); + TransactionLoadMetadata::Bitcoin { utxos } +} + +pub fn map_utxos(utxos: Vec, address: String) -> Vec { + utxos + .into_iter() + .map(|utxo| UTXO { + transaction_id: utxo.txid, + vout: utxo.vout, + value: utxo.value, + address: address.clone(), + }) + .collect() +} + +pub fn map_transaction_preload_zcash( + node_info: BitcoinNodeInfo, + utxos: Vec, + input: TransactionPreloadInput, +) -> Result> { + let utxos = map_utxos(utxos, input.sender_address.clone()); + let branch_id = node_info.backend.consensus.ok_or("Branch ID not found")?.chaintip; + Ok(TransactionLoadMetadata::Zcash { utxos, branch_id }) +} diff --git a/core/crates/gem_bitcoin/src/provider/request_classifier.rs b/core/crates/gem_bitcoin/src/provider/request_classifier.rs new file mode 100644 index 0000000000..e5b0d67946 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/api/v2/sendtx/") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_bitcoin/src/provider/state.rs b/core/crates/gem_bitcoin/src/provider/state.rs new file mode 100644 index 0000000000..8771bc16bb --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/state.rs @@ -0,0 +1,69 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use gem_client::Client; +use primitives::NodeSyncStatus; +use std::error::Error; + +use crate::{provider::state_mapper, rpc::client::BitcoinClient}; + +#[async_trait] +impl ChainState for BitcoinClient { + async fn get_chain_id(&self) -> Result> { + let block = self.get_block_info(1).await?; + block.previous_block_hash.ok_or_else(|| "Unable to get block hash".into()) + } + + async fn get_node_status(&self) -> Result> { + let node_info = self.get_node_info().await?; + Ok(state_mapper::map_node_status(&node_info)) + } + + async fn get_block_latest_number(&self) -> Result> { + let node_info = self.get_node_info().await?; + Ok(state_mapper::map_latest_block_number(&node_info)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_get_bitcoin_latest_block() -> Result<(), Box> { + let client = create_bitcoin_test_client(); + let block_number = client.get_block_latest_number().await?; + + assert!(block_number > 800_000, "Bitcoin block number should be above 800k, got: {}", block_number); + println!("Bitcoin latest block: {}", block_number); + + Ok(()) + } + + #[tokio::test] + async fn test_get_bitcoin_chain_id() -> Result<(), Box> { + let client = create_bitcoin_test_client(); + let chain_id = client.get_chain_id().await?; + + assert!(!chain_id.is_empty()); + assert!(chain_id.len() == 64); // Bitcoin block hashes are 64 characters + println!("Bitcoin chain ID: {}", chain_id); + + Ok(()) + } + + #[tokio::test] + async fn test_get_bitcoin_node_status() -> Result<(), Box> { + let client = create_bitcoin_test_client(); + let status = client.get_node_status().await?; + + println!("Bitcoin node status: {:#?}", status); + + assert!(status.in_sync); + assert!(status.latest_block_number.unwrap_or(0) > 0); + assert!(status.current_block_number.unwrap_or(0) > 0); + assert!(status.latest_block_number == status.current_block_number); + + Ok(()) + } +} diff --git a/core/crates/gem_bitcoin/src/provider/state_mapper.rs b/core/crates/gem_bitcoin/src/provider/state_mapper.rs new file mode 100644 index 0000000000..f48b0bd130 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/state_mapper.rs @@ -0,0 +1,59 @@ +use crate::models::block::BitcoinNodeInfo; +use primitives::NodeSyncStatus; + +pub fn map_node_status(node_info: &BitcoinNodeInfo) -> NodeSyncStatus { + let latest_block_number = node_info.backend.blocks; + let current_block_number = Some(node_info.blockbook.best_height); + + NodeSyncStatus::new(node_info.blockbook.in_sync, Some(latest_block_number), current_block_number) +} + +pub fn map_latest_block_number(node_info: &BitcoinNodeInfo) -> u64 { + node_info.blockbook.best_height +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::block::{BitcoinBackend, BitcoinBlockbook, BitcoinNodeInfo}; + + #[test] + fn test_map_node_status_returns_flag_and_block_numbers() { + let node_info = BitcoinNodeInfo { + blockbook: BitcoinBlockbook { + in_sync: false, + last_block_time: "2024-01-01T00:00:00Z".to_string(), + best_height: 123, + }, + backend: BitcoinBackend { + blocks: 456, + chain: Some("main".to_string()), + consensus: None, + }, + }; + + let status = map_node_status(&node_info); + + assert!(!status.in_sync); + assert_eq!(status.latest_block_number, Some(456)); + assert_eq!(status.current_block_number, Some(123)); + } + + #[test] + fn test_map_latest_block_number_returns_best_height() { + let node_info = BitcoinNodeInfo { + blockbook: BitcoinBlockbook { + in_sync: true, + last_block_time: "2024-01-01T00:00:00Z".to_string(), + best_height: 1_000, + }, + backend: BitcoinBackend { + blocks: 2_000, + chain: Some("main".to_string()), + consensus: None, + }, + }; + + assert_eq!(map_latest_block_number(&node_info), 1_000); + } +} diff --git a/core/crates/gem_bitcoin/src/provider/testkit.rs b/core/crates/gem_bitcoin/src/provider/testkit.rs new file mode 100644 index 0000000000..8e7328e5e5 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/testkit.rs @@ -0,0 +1,20 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::BitcoinClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use primitives::BitcoinChain; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "bc1qk9cu0nj5czvalnvmlsyc8tmqh8d6f0v9plrrdr"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "654c6a28f7ff1915d2b9abc2e18e32a37e0196203d64aced6221651f003f5e94"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_bitcoin_test_client() -> BitcoinClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.bitcoin.url, reqwest::Client::new()); + BitcoinClient::new(reqwest_client, BitcoinChain::Bitcoin) +} diff --git a/core/crates/gem_bitcoin/src/provider/transaction_broadcast.rs b/core/crates/gem_bitcoin/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..ebfb46dc5f --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/transaction_broadcast.rs @@ -0,0 +1,28 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{ + BroadcastProvider, + transaction_broadcast_mapper::{map_transaction_broadcast_response, map_transaction_broadcast_response_from_str}, + }, + rpc::client::BitcoinClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for BitcoinClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(data).await?; + map_transaction_broadcast_response(response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_bitcoin/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_bitcoin/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..9007e1185e --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,16 @@ +use std::error::Error; + +use crate::models::BitcoinTransactionBroacastResult; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub(crate) fn map_transaction_broadcast_response(response: BitcoinTransactionBroacastResult) -> Result> { + if let Some(error) = response.error { + return Err(error.message().into()); + } + + map_transaction_broadcast(response.result.ok_or("unknown hash")?) +} + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + map_transaction_broadcast_response(serde_json::from_str::(response)?) +} diff --git a/core/crates/gem_bitcoin/src/provider/transaction_state.rs b/core/crates/gem_bitcoin/src/provider/transaction_state.rs new file mode 100644 index 0000000000..52637711fb --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/transaction_state.rs @@ -0,0 +1,21 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use primitives::{TransactionState, TransactionStateRequest, TransactionUpdate}; +use std::error::Error; + +use gem_client::Client; + +use crate::rpc::client::BitcoinClient; + +#[async_trait] +impl ChainTransactionState for BitcoinClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let transaction = self.get_transaction(&request.id).await?; + let status = if transaction.is_confirmed() { + TransactionState::Confirmed + } else { + TransactionState::Pending + }; + Ok(TransactionUpdate::new_state(status)) + } +} diff --git a/core/crates/gem_bitcoin/src/provider/transactions.rs b/core/crates/gem_bitcoin/src/provider/transactions.rs new file mode 100644 index 0000000000..145c490044 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/transactions.rs @@ -0,0 +1,102 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use primitives::Transaction; +use std::error::Error; + +use gem_client::Client; + +use crate::{ + models::Address, + provider::transactions_mapper::{map_transaction, map_transactions}, + rpc::client::BitcoinClient, +}; + +#[async_trait] +impl ChainTransactions for BitcoinClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let mut transactions = Vec::new(); + let mut page = 1; + + loop { + let block = self.get_block(block, page).await?; + + transactions.extend(map_transactions(self.get_chain(), block.txs)); + + if block.total_pages == block.page { + break; + } + + page += 1; + } + + Ok(transactions) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + let transaction = self.get_transaction(&hash).await?; + if transaction.block_height <= 0 || transaction.block_time <= 0 { + return Ok(None); + } + + Ok(map_transaction(self.get_chain(), &transaction)) + } + + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, limit, .. } = request; + let address = Address::new(&address, self.get_chain()).full(); + let address_details = self.get_address_details(&address, limit.unwrap_or(25)).await?; + let transactions = address_details.transactions.unwrap_or_default(); + Ok(map_transactions(self.get_chain(), transactions)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::{ChainState, ChainTransactionState, ChainTransactions, TransactionsRequest}; + use primitives::{TransactionState, TransactionStateRequest}; + + #[tokio::test] + async fn test_bitcoin_get_transactions_status() { + let bitcoin_client = create_bitcoin_test_client(); + + let request = TransactionStateRequest::mock_with_id(TEST_TRANSACTION_ID); + let update = bitcoin_client.get_transaction_status(request).await.unwrap(); + + println!("State: {:?}", update.state); + assert!(update.state == TransactionState::Confirmed); + } + + #[tokio::test] + async fn test_bitcoin_get_transactions_by_block() { + let bitcoin_client = create_bitcoin_test_client(); + + let latest_block = bitcoin_client.get_block_latest_number().await.unwrap(); + let transactions = bitcoin_client.get_transactions_by_block(latest_block).await.unwrap(); + + println!("Latest block: {}, transactions count: {}", latest_block, transactions.len()); + assert!(latest_block > 0); + } + + #[tokio::test] + async fn test_bitcoin_get_transactions_by_address() { + let bitcoin_client = create_bitcoin_test_client(); + + let transactions = bitcoin_client + .get_transactions_by_address(TransactionsRequest::new(TEST_ADDRESS.to_string())) + .await + .unwrap(); + + println!("Address: {}, transactions count: {}", TEST_ADDRESS, transactions.len()); + + assert!(!transactions.is_empty()); + } + + #[tokio::test] + async fn test_bitcoin_get_transaction_by_hash() { + let bitcoin_client = create_bitcoin_test_client(); + let transaction = bitcoin_client.get_transaction_by_hash(TEST_TRANSACTION_ID.to_string()).await.unwrap().unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + } +} diff --git a/core/crates/gem_bitcoin/src/provider/transactions_mapper.rs b/core/crates/gem_bitcoin/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..11a28d8650 --- /dev/null +++ b/core/crates/gem_bitcoin/src/provider/transactions_mapper.rs @@ -0,0 +1,219 @@ +use crate::models::{Address, Transaction}; +use chrono::{TimeZone, Utc}; +use primitives::{TransactionState, TransactionType, chain::Chain, transaction_utxo::TransactionUtxoInput}; +use std::error::Error; + +const OP_RETURN_PREFIX: &str = "OP_RETURN "; + +pub fn map_transaction_broadcast(hash: String) -> Result> { + if hash.is_empty() { Err("Empty transaction hash".into()) } else { Ok(hash) } +} + +pub fn map_transactions(chain: Chain, transactions: Vec) -> Vec { + transactions.into_iter().flat_map(|x| map_transaction(chain, &x)).collect() +} + +fn op_return_memo(transaction: &Transaction) -> Option { + transaction + .vout + .iter() + .filter(|o| !o.is_address) + .flat_map(|o| o.addresses.as_deref().unwrap_or_default()) + .find_map(|addr| { + addr.strip_prefix(OP_RETURN_PREFIX) + .map(|s| s.strip_prefix('(').unwrap_or(s)) + .map(|s| s.strip_suffix(')').unwrap_or(s)) + .map(String::from) + }) +} + +pub fn map_transaction(chain: Chain, transaction: &Transaction) -> Option { + let inputs: Vec = transaction + .vin + .iter() + .filter(|i| i.is_address) + .map(|input| TransactionUtxoInput { + address: Address::new(input.addresses.clone().unwrap().first().unwrap(), chain).short().to_string(), + value: input.value.clone(), + }) + .collect(); + + let outputs: Vec = transaction + .vout + .iter() + .filter(|o| o.is_address) + .map(|output| TransactionUtxoInput { + address: Address::new(output.addresses.clone().unwrap_or_default().first().unwrap(), chain).short().to_string(), + value: output.value.clone(), + }) + .collect(); + + if inputs.is_empty() || outputs.is_empty() { + return None; + } + let created_at = Utc.timestamp_opt(transaction.block_time, 0).single()?; + let memo = op_return_memo(transaction); + + let state = if transaction.is_confirmed() { + TransactionState::Confirmed + } else { + TransactionState::Pending + }; + + let transaction = primitives::Transaction::new_with_utxo( + transaction.txid.clone(), + chain.as_asset_id(), + TransactionType::Transfer, + state, + transaction.fees.clone(), + chain.as_asset_id(), + transaction.value.clone(), + memo, + inputs.into(), + outputs.into(), + None, + created_at, + ); + + Some(transaction) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Transaction as BitcoinTransaction; + use crate::models::transaction::{Input, Output}; + use crate::provider::testkit::TEST_TRANSACTION_ID; + + #[test] + fn test_map_transaction() { + let transaction = Transaction { + vin: vec![Input::mock()], + vout: vec![Output::mock()], + ..Transaction::mock() + }; + + let result = map_transaction(Chain::Bitcoin, &transaction).unwrap(); + assert_eq!(result.id.to_string(), "bitcoin_abc123"); + assert_eq!(result.value, "100000"); + assert_eq!(result.fee, "5000"); + assert_eq!(result.transaction_type, TransactionType::Transfer); + assert_eq!(result.state, TransactionState::Confirmed); + assert!(result.memo.is_none()); + let utxo_inputs = result.utxo_inputs.as_ref().unwrap(); + assert_eq!(utxo_inputs.len(), 1); + assert_eq!(utxo_inputs[0].address, "bc1qinput"); + let utxo_outputs = result.utxo_outputs.as_ref().unwrap(); + assert_eq!(utxo_outputs.len(), 1); + assert_eq!(utxo_outputs[0].address, "bc1qoutput"); + } + + #[test] + fn test_map_transaction_with_address_prefix() { + let transaction = Transaction { + txid: "def456".to_string(), + vin: vec![Input { + addresses: Some(vec!["bitcoincash:qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q".to_string()]), + ..Input::mock() + }], + vout: vec![Output { + addresses: Some(vec!["bitcoincash:qpcns7lget89x9km0t8ry5fk52e8lhl53q0a64gd65".to_string()]), + ..Output::mock() + }], + ..Transaction::mock() + }; + + let result = map_transaction(Chain::BitcoinCash, &transaction).unwrap(); + assert_eq!(result.id.to_string(), "bitcoincash_def456"); + let utxo_inputs = result.utxo_inputs.as_ref().unwrap(); + assert_eq!(utxo_inputs.len(), 1); + assert_eq!(utxo_inputs[0].address, "qqm3kh5j8ptj2y4ryglk0j83t6jkcjk7x52kgzvh4q"); + let utxo_outputs = result.utxo_outputs.as_ref().unwrap(); + assert_eq!(utxo_outputs.len(), 1); + assert_eq!(utxo_outputs[0].address, "qpcns7lget89x9km0t8ry5fk52e8lhl53q0a64gd65"); + } + + #[test] + fn test_map_transaction_with_op_return_memo() { + let transaction = Transaction { + vin: vec![Input::mock()], + vout: vec![ + Output::mock(), + Output { + is_address: false, + addresses: Some(vec!["OP_RETURN (=:e:0xaF1879D693d49375fc9b74a66b937C2d73557bCb:0/1/0:g1:50)".to_string()]), + value: "0".to_string(), + n: 2, + }, + ], + ..Transaction::mock() + }; + + let result = map_transaction(Chain::Bitcoin, &transaction).unwrap(); + assert_eq!(result.memo.as_deref(), Some("=:e:0xaF1879D693d49375fc9b74a66b937C2d73557bCb:0/1/0:g1:50"),); + } + + #[test] + fn test_op_return_memo_none_without_op_return() { + let transaction = Transaction { + vin: vec![Input::mock()], + vout: vec![Output::mock()], + ..Transaction::mock() + }; + assert!(op_return_memo(&transaction).is_none()); + } + + #[test] + fn test_op_return_memo_ignores_regular_addresses() { + let transaction = Transaction { + vin: vec![Input::mock()], + vout: vec![Output { + is_address: true, + addresses: Some(vec!["OP_RETURN (fake)".to_string()]), + ..Output::mock() + }], + ..Transaction::mock() + }; + assert!(op_return_memo(&transaction).is_none()); + } + + #[test] + fn test_map_transaction_by_hash() { + let transaction: BitcoinTransaction = serde_json::from_str(include_str!("../../testdata/transaction_by_hash.json")).unwrap(); + let mapped = map_transaction(Chain::Bitcoin, &transaction).unwrap(); + + assert_eq!(mapped.hash, TEST_TRANSACTION_ID); + assert_eq!(mapped.fee, "1694"); + assert_eq!(mapped.value, "546"); + } + + #[test] + fn test_map_transaction_unconfirmed() { + let transaction = Transaction { + block_height: -1, + confirmations: Some(0), + vin: vec![Input::mock()], + vout: vec![Output::mock()], + ..Transaction::mock() + }; + + let result = map_transaction(Chain::Doge, &transaction).unwrap(); + + assert_eq!(result.state, TransactionState::Pending); + } + + #[test] + fn test_map_transaction_zero_confirmations_pending() { + let transaction = Transaction { + block_height: 0, + confirmations: Some(0), + vin: vec![Input::mock()], + vout: vec![Output::mock()], + ..Transaction::mock() + }; + + let result = map_transaction(Chain::Doge, &transaction).unwrap(); + + assert_eq!(result.state, TransactionState::Pending); + } +} diff --git a/core/crates/gem_bitcoin/src/rpc/client.rs b/core/crates/gem_bitcoin/src/rpc/client.rs new file mode 100644 index 0000000000..996d092314 --- /dev/null +++ b/core/crates/gem_bitcoin/src/rpc/client.rs @@ -0,0 +1,84 @@ +use std::error::Error; + +use crate::models::account::BitcoinAccount; +use crate::models::block::{BitcoinBlock, BitcoinNodeInfo, Block, Status}; +use crate::models::fee::BitcoinFeeResult; +use crate::models::transaction::{AddressDetails, BitcoinTransactionBroacastResult, BitcoinUTXO, Transaction}; +use chain_traits::{ChainAddressStatus, ChainPerpetual, ChainStaking, ChainToken, ChainTraits}; +use gem_client::{CONTENT_TYPE, Client, ClientExt, ContentType}; +use primitives::{BitcoinChain, chain::Chain}; +use std::collections::HashMap; + +#[derive(Debug)] +pub struct BitcoinClient { + client: C, + pub chain: BitcoinChain, +} + +impl BitcoinClient { + pub fn new(client: C, chain: BitcoinChain) -> Self { + Self { client, chain } + } + + pub fn get_chain(&self) -> Chain { + self.chain.get_chain() + } + + pub async fn get_status(&self) -> Result> { + Ok(self.client.get("/api/").await?) + } + + pub async fn get_block(&self, block_number: u64, page: usize) -> Result> { + Ok(self.client.get(&format!("/api/v2/block/{block_number}?page={page}")).await?) + } + + pub async fn get_address_details(&self, address: &str, limit: usize) -> Result> { + Ok(self.client.get(&format!("/api/v2/address/{address}?pageSize={limit}&details=txs")).await?) + } + + pub async fn get_transaction(&self, txid: &str) -> Result> { + Ok(self.client.get(&format!("/api/v2/tx/{txid}")).await?) + } + + pub async fn get_balance(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/api/v2/address/{address}")).await?) + } + + pub async fn get_block_info(&self, block_number: u64) -> Result> { + Ok(self.client.get(&format!("/api/v2/block/{block_number}")).await?) + } + + pub async fn get_node_info(&self) -> Result> { + Ok(self.client.get("/api/").await?) + } + + pub async fn broadcast_transaction(&self, data: String) -> Result> { + let headers = HashMap::from([(CONTENT_TYPE.to_string(), ContentType::TextPlain.as_str().to_string())]); + Ok(self.client.post_with_headers("/api/v2/sendtx/", &data, headers).await?) + } + + pub async fn get_utxos(&self, address: &str) -> Result, Box> { + Ok(self.client.get(&format!("/api/v2/utxo/{address}")).await?) + } + + pub async fn get_fee_priority(&self, blocks: i32) -> Result> { + let result: BitcoinFeeResult = self.client.get(&format!("/api/v2/estimatefee/{blocks}")).await?; + Ok(result.result) + } +} + +impl ChainStaking for BitcoinClient {} + +impl ChainPerpetual for BitcoinClient {} + +impl ChainAddressStatus for BitcoinClient {} + +impl ChainToken for BitcoinClient {} + +impl ChainTraits for BitcoinClient {} + +impl chain_traits::ChainProvider for BitcoinClient { + fn get_chain(&self) -> primitives::Chain { + self.chain.get_chain() + } +} diff --git a/core/crates/gem_bitcoin/src/rpc/mod.rs b/core/crates/gem_bitcoin/src/rpc/mod.rs new file mode 100644 index 0000000000..a60a42f272 --- /dev/null +++ b/core/crates/gem_bitcoin/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::BitcoinClient; diff --git a/core/crates/gem_bitcoin/src/signer/encoding.rs b/core/crates/gem_bitcoin/src/signer/encoding.rs new file mode 100644 index 0000000000..d60a813fc8 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/encoding.rs @@ -0,0 +1,36 @@ +pub fn encode_varint(n: usize) -> Vec { + if n < 0xfd { + vec![n as u8] + } else if n <= 0xffff { + let b = (n as u16).to_le_bytes(); + vec![0xfd, b[0], b[1]] + } else if n <= 0xffffffff { + let b = (n as u32).to_le_bytes(); + vec![0xfe, b[0], b[1], b[2], b[3]] + } else { + let b = (n as u64).to_le_bytes(); + vec![0xff, b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_varint_small() { + assert_eq!(encode_varint(0), vec![0]); + assert_eq!(encode_varint(252), vec![252]); + } + + #[test] + fn test_encode_varint_medium() { + assert_eq!(encode_varint(253), vec![0xfd, 253, 0]); + assert_eq!(encode_varint(0xffff), vec![0xfd, 0xff, 0xff]); + } + + #[test] + fn test_encode_varint_large() { + assert_eq!(encode_varint(0x10000), vec![0xfe, 0, 0, 1, 0]); + } +} diff --git a/core/crates/gem_bitcoin/src/signer/mod.rs b/core/crates/gem_bitcoin/src/signer/mod.rs new file mode 100644 index 0000000000..56171d6cf3 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/mod.rs @@ -0,0 +1,6 @@ +mod encoding; +mod signature; +mod types; + +pub use signature::sign_personal; +pub use types::{BitcoinSignDataResponse, BitcoinSignMessageData}; diff --git a/core/crates/gem_bitcoin/src/signer/signature.rs b/core/crates/gem_bitcoin/src/signer/signature.rs new file mode 100644 index 0000000000..3054d5a9bd --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/signature.rs @@ -0,0 +1,51 @@ +use primitives::SignerError; +use signer::{RECOVERY_ID_INDEX, SIGNATURE_LENGTH, SignatureScheme, Signer}; + +use super::types::{BitcoinSignDataResponse, BitcoinSignMessageData}; + +const BIP137_P2WPKH_BASE: u8 = 39; + +pub fn sign_personal(data: &[u8], private_key: &[u8]) -> Result { + let message = BitcoinSignMessageData::from_bytes(data)?; + let hash = message.hash(); + + let signed = Signer::sign_digest(SignatureScheme::Secp256k1, &hash, private_key).map_err(|e| SignerError::InvalidInput(e.to_string()))?; + + // BIP137: [header(1), r(32), s(32)] from [r(32), s(32), recovery_id(1)] + let recovery_id = signed[RECOVERY_ID_INDEX]; + let header = BIP137_P2WPKH_BASE + recovery_id; + + let mut signature = Vec::with_capacity(SIGNATURE_LENGTH); + signature.push(header); + signature.extend_from_slice(&signed[..RECOVERY_ID_INDEX]); + + Ok(BitcoinSignDataResponse::new(message.address, hex::encode(&signature))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sign_bitcoin_personal() { + let data = BitcoinSignMessageData::new("Hello Bitcoin".to_string(), "bc1qtest".to_string()).to_bytes(); + let private_key = hex::decode("1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34").unwrap(); + let result = sign_personal(&data, &private_key).unwrap(); + + let parsed: serde_json::Value = serde_json::from_str(&result.to_json().unwrap()).unwrap(); + assert_eq!(parsed["address"], "bc1qtest"); + + let sig_hex = parsed["signature"].as_str().unwrap(); + let sig_bytes = hex::decode(sig_hex).unwrap(); + assert_eq!(sig_bytes.len(), SIGNATURE_LENGTH); + + let header = sig_bytes[0]; + assert!(header == BIP137_P2WPKH_BASE || header == BIP137_P2WPKH_BASE + 1, "unexpected BIP-137 header: {header}"); + } + + #[test] + fn test_sign_bitcoin_personal_rejects_invalid_key() { + let data = BitcoinSignMessageData::new("Hello Bitcoin".to_string(), "bc1qtest".to_string()).to_bytes(); + assert!(sign_personal(&data, &[0u8; 16]).is_err()); + } +} diff --git a/core/crates/gem_bitcoin/src/signer/types.rs b/core/crates/gem_bitcoin/src/signer/types.rs new file mode 100644 index 0000000000..913e60e8c2 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/types.rs @@ -0,0 +1,84 @@ +use gem_hash::sha2::sha256; +use primitives::SignerError; +use serde::{Deserialize, Serialize}; + +use super::encoding::encode_varint; + +const BITCOIN_MESSAGE_PREFIX: &[u8] = b"\x18Bitcoin Signed Message:\n"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BitcoinSignMessageData { + pub message: String, + pub address: String, +} + +impl BitcoinSignMessageData { + pub fn new(message: String, address: String) -> Self { + Self { message, address } + } + + pub fn from_bytes(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(SignerError::from) + } + + pub fn to_bytes(&self) -> Vec { + serde_json::to_vec(self).unwrap_or_default() + } + + pub fn hash(&self) -> Vec { + let message = self.message.as_bytes(); + let varint = encode_varint(message.len()); + + let mut data = Vec::with_capacity(BITCOIN_MESSAGE_PREFIX.len() + varint.len() + message.len()); + data.extend_from_slice(BITCOIN_MESSAGE_PREFIX); + data.extend_from_slice(&varint); + data.extend_from_slice(message); + + sha256(&sha256(&data)).to_vec() + } +} + +#[derive(Serialize)] +pub struct BitcoinSignDataResponse { + address: String, + signature: String, +} + +impl BitcoinSignDataResponse { + pub fn new(address: String, signature: String) -> Self { + Self { address, signature } + } + + pub fn to_json(&self) -> Result { + serde_json::to_string(self).map_err(SignerError::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_and_to_bytes() { + let data = BitcoinSignMessageData::new("Hello Bitcoin".to_string(), "bc1qtest".to_string()); + + let bytes = data.to_bytes(); + let parsed = BitcoinSignMessageData::from_bytes(&bytes).unwrap(); + + assert_eq!(parsed.message, "Hello Bitcoin"); + assert_eq!(parsed.address, "bc1qtest"); + } + + #[test] + fn test_hash() { + let hash = BitcoinSignMessageData::new("Hello Bitcoin".to_string(), "bc1qtest".to_string()).hash(); + assert_eq!(hex::encode(&hash), "93a4e556613458adb2019c52d7dbaff7a7261da4bc4b8b3f8b9c5f098209de37"); + } + + #[test] + fn test_response_to_json() { + let parsed: serde_json::Value = serde_json::from_str(&BitcoinSignDataResponse::new("bc1qtest".to_string(), "27abcdef".to_string()).to_json().unwrap()).unwrap(); + assert_eq!(parsed["address"], "bc1qtest"); + assert_eq!(parsed["signature"], "27abcdef"); + } +} diff --git a/core/crates/gem_bitcoin/src/testkit/mod.rs b/core/crates/gem_bitcoin/src/testkit/mod.rs new file mode 100644 index 0000000000..c9fa0bff1d --- /dev/null +++ b/core/crates/gem_bitcoin/src/testkit/mod.rs @@ -0,0 +1 @@ +pub mod transaction_mock; diff --git a/core/crates/gem_bitcoin/src/testkit/transaction_mock.rs b/core/crates/gem_bitcoin/src/testkit/transaction_mock.rs new file mode 100644 index 0000000000..ef5dc4c32c --- /dev/null +++ b/core/crates/gem_bitcoin/src/testkit/transaction_mock.rs @@ -0,0 +1,41 @@ +use crate::models::transaction::{Input, Output, Transaction}; + +impl Transaction { + pub fn mock() -> Self { + Self { + txid: "abc123".to_string(), + value: "100000".to_string(), + value_in: "105000".to_string(), + fees: "5000".to_string(), + confirmations: Some(1), + block_time: 1640995200, + block_height: 700000, + vin: vec![], + vout: vec![], + } + } +} + +impl Input { + pub fn mock() -> Self { + Self { + is_address: true, + addresses: Some(vec!["bc1qinput".to_string()]), + value: "105000".to_string(), + n: 0, + tx_id: Some("prev_tx".to_string()), + vout: Some(0), + } + } +} + +impl Output { + pub fn mock() -> Self { + Self { + is_address: true, + addresses: Some(vec!["bc1qoutput".to_string()]), + value: "100000".to_string(), + n: 0, + } + } +} diff --git a/core/crates/gem_bitcoin/testdata/transaction_by_hash.json b/core/crates/gem_bitcoin/testdata/transaction_by_hash.json new file mode 100644 index 0000000000..b27c1d04f9 --- /dev/null +++ b/core/crates/gem_bitcoin/testdata/transaction_by_hash.json @@ -0,0 +1,41 @@ +{ + "txid": "654c6a28f7ff1915d2b9abc2e18e32a37e0196203d64aced6221651f003f5e94", + "version": 2, + "vin": [ + { + "txid": "f77c92a9b8b4ab6515b4f674e8ceb93281fcf42ece10a5405626aad9827e53fa", + "sequence": 4294967293, + "n": 0, + "addresses": [ + "bc1p8l0xktmerqgwgrq6l6cejzc3dhph5z8cv6ae083cuvcjhpnten7s2wjswr" + ], + "isAddress": true, + "value": "2240" + } + ], + "vout": [ + { + "value": "546", + "n": 0, + "spent": true, + "spentTxId": "a676626827915a894bd2d56d4511ba2114817bfebf87d555f98211839f1c21c0", + "spentIndex": 4, + "spentHeight": 873029, + "hex": "51201c28a2c875668b7fee34e45f496973b6b23b6a65854f3fc47d73d02a56c55bbf", + "addresses": [ + "bc1prs529jr4v69hlm35u305j6tnk6erk6n9s48nl3raw0gz54k9twls6pvnee" + ], + "isAddress": true + } + ], + "blockHash": "00000000000000000001c1117af6eda21827639576295c94d60612bce4a0dd8a", + "blockHeight": 873006, + "confirmations": 69076, + "blockTime": 1733199947, + "size": 334, + "vsize": 154, + "value": "546", + "valueIn": "2240", + "fees": "1694", + "hex": "02000000000101fa537e82d9aa265640a510ce2ef4fc8132b9cee874f6b41565abb4b8a9927cf70000000000fdffffff0122020000000000002251201c28a2c875668b7fee34e45f496973b6b23b6a65854f3fc47d73d02a56c55bbf0340086046eceffdcba6776a3ca6ee390ab4431c0334647f58b66178f11d89a6aa9288b3fe9d431289785f4dab7e4623f4b2ed2705ffeb29b2f4b3cca72f71af45d78920943946d20832ac45f9b60c7983101e7de68c71b9996e09328552a8e0598f8bdfac0063036f7264010118746578742f706c61696e3b636861727365743d7574662d3800437b2270223a226272632d3230222c226f70223a227472616e73666572222c227469636b223a22f09d9b91222c22616d74223a22333235363636363636362e383838227d6821c1943946d20832ac45f9b60c7983101e7de68c71b9996e09328552a8e0598f8bdf00000000" +} diff --git a/core/crates/gem_bsc/Cargo.toml b/core/crates/gem_bsc/Cargo.toml new file mode 100644 index 0000000000..befc8efdd4 --- /dev/null +++ b/core/crates/gem_bsc/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "gem_bsc" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] + +hex = { workspace = true } +alloy-primitives = { workspace = true } +alloy-sol-types = { workspace = true } diff --git a/core/crates/gem_bsc/src/lib.rs b/core/crates/gem_bsc/src/lib.rs new file mode 100644 index 0000000000..73e431f489 --- /dev/null +++ b/core/crates/gem_bsc/src/lib.rs @@ -0,0 +1,2 @@ +pub mod stake_hub; +pub use stake_hub::HUB_READER_ADDRESS; diff --git a/core/crates/gem_bsc/src/stake_hub.rs b/core/crates/gem_bsc/src/stake_hub.rs new file mode 100644 index 0000000000..39a843d64f --- /dev/null +++ b/core/crates/gem_bsc/src/stake_hub.rs @@ -0,0 +1,277 @@ +use alloy_primitives::{Address, U256}; +use alloy_sol_types::{SolCall, sol}; +use std::{error::Error, str::FromStr}; + +pub const HUB_READER_ADDRESS: &str = "0x830295c0abe7358f7e24bc38408095621474280b"; +pub const STAKE_HUB_ADDRESS: &str = "0x0000000000000000000000000000000000002002"; + +sol! { + #[derive(Debug, PartialEq)] + interface IHubReader { + struct Validator { + address operatorAddress; + bool jailed; + string moniker; + uint64 commission; + uint64 apy; + } + + struct Delegation { + address delegatorAddress; + address validatorAddress; + uint256 amount; + uint256 shares; + } + + struct Undelegation { + address delegatorAddress; + address validatorAddress; + uint256 amount; + uint256 shares; + uint256 unlockTime; + } + + function getValidators(uint16 offset, uint16 limit) external view returns (Validator[] memory); + function getDelegations(address delegator, uint16 offset, uint16 limit) external view returns (Delegation[] memory); + function getUndelegations(address delegator, uint16 offset, uint16 limit) external view returns (Undelegation[] memory); + } +} + +sol! { + #[derive(Debug, PartialEq)] + interface IStakeHub { + function delegate(address operatorAddress, bool delegateVotePower) external payable; + function undelegate(address operatorAddress, uint256 shares) external; + function redelegate(address srcValidator, address dstValidator, uint256 shares, bool delegateVotePower) external; + function claim(address operatorAddress, uint256 requestNumber) external; + function claimBatch(address[] calldata operatorAddresses,uint256[] calldata requestNumbers) external; + } +} + +pub struct BscValidator { + pub operator_address: String, + pub moniker: String, + pub commission: u64, + pub apy: u64, + pub jailed: bool, +} + +pub struct BscDelegation { + pub delegator_address: String, + pub validator_address: String, + pub amount: String, + pub shares: String, +} + +pub struct BscUndelegation { + pub delegator_address: String, + pub validator_address: String, + pub amount: String, + pub shares: String, + pub unlock_time: String, +} + +pub fn encode_validators_call(offset: u16, limit: u16) -> Vec { + let call = IHubReader::getValidatorsCall { offset, limit }; + call.abi_encode() +} + +pub fn decode_validators_return(result: &[u8]) -> Result, Box> { + let decoded = IHubReader::getValidatorsCall::abi_decode_returns(result)?; + let validators = decoded + .iter() + .map(|validator| BscValidator { + operator_address: validator.operatorAddress.to_string(), + moniker: validator.moniker.to_string(), + commission: validator.commission, + apy: validator.apy, + jailed: validator.jailed, + }) + .collect(); + Ok(validators) +} + +pub fn encode_delegations_call(delegator: &str, offset: u16, limit: u16) -> Result, alloy_primitives::hex::FromHexError> { + let delegator = Address::from_str(delegator)?; + let call = IHubReader::getDelegationsCall { delegator, offset, limit }; + Ok(call.abi_encode()) +} + +pub fn decode_delegations_return(result: &[u8]) -> Result, Box> { + let decoded = IHubReader::getDelegationsCall::abi_decode_returns(result)?; + let delegations = decoded + .iter() + .map(|delegation| BscDelegation { + delegator_address: delegation.delegatorAddress.to_string(), + validator_address: delegation.validatorAddress.to_string(), + amount: delegation.amount.to_string(), + shares: delegation.shares.to_string(), + }) + .collect(); + Ok(delegations) +} + +pub fn encode_undelegations_call(delegator: &str, offset: u16, limit: u16) -> Result, alloy_primitives::hex::FromHexError> { + let delegator = Address::from_str(delegator)?; + let call = IHubReader::getUndelegationsCall { delegator, offset, limit }; + Ok(call.abi_encode()) +} + +pub fn decode_undelegations_return(result: &[u8]) -> Result, Box> { + let decoded = IHubReader::getUndelegationsCall::abi_decode_returns(result)?; + let undelegations = decoded + .iter() + .map(|undelegation| BscUndelegation { + delegator_address: undelegation.delegatorAddress.to_string(), + validator_address: undelegation.validatorAddress.to_string(), + amount: undelegation.amount.to_string(), + shares: undelegation.shares.to_string(), + unlock_time: undelegation.unlockTime.to_string(), + }) + .collect(); + Ok(undelegations) +} + +pub fn encode_delegate_call(operator_address: &str, delegate_vote_power: bool) -> Result, alloy_primitives::hex::FromHexError> { + let operator_address = Address::from_str(operator_address)?; + let call = IStakeHub::delegateCall { + operatorAddress: operator_address, + delegateVotePower: delegate_vote_power, + }; + Ok(call.abi_encode()) +} + +pub fn encode_undelegate_call(operator_address: &str, shares: &str) -> Result, Box> { + let address = Address::from_str(operator_address)?; + let amount = U256::from_str(shares)?; + let call = IStakeHub::undelegateCall { + operatorAddress: address, + shares: amount, + }; + Ok(call.abi_encode()) +} + +pub fn encode_redelegate_call(src_validator: &str, dst_validator: &str, shares: &str, delegate_vote_power: bool) -> Result, Box> { + let src_validator = Address::from_str(src_validator)?; + let dst_validator = Address::from_str(dst_validator)?; + let amount = U256::from_str(shares)?; + let call = IStakeHub::redelegateCall { + srcValidator: src_validator, + dstValidator: dst_validator, + shares: amount, + delegateVotePower: delegate_vote_power, + }; + Ok(call.abi_encode()) +} + +pub fn encode_claim_call(operator_address: &str, request_number: u64) -> Result, alloy_primitives::hex::FromHexError> { + let operator_address = Address::from_str(operator_address)?; + let call = IStakeHub::claimCall { + operatorAddress: operator_address, + requestNumber: U256::from(request_number), + }; + Ok(call.abi_encode()) +} + +pub fn encode_claim_batch_call(operator_addresses: Vec, request_numbers: Vec) -> Result, Box> { + let operator_addresses = operator_addresses.iter().map(|x| Address::from_str(x)).collect::, _>>()?; + let request_numbers = request_numbers.iter().map(|x| U256::from(*x)).collect::>(); + let call = IStakeHub::claimBatchCall { + operatorAddresses: operator_addresses, + requestNumbers: request_numbers, + }; + Ok(call.abi_encode()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_validators_return() { + let result = hex::decode("0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000003e000000000000000000000000000000000000000000000000000000000000004c000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000000000000000000000000000000000000000680000000000000000000000000000000000000000000000000000000000000076000000000000000000000000000000000000000000000000000000000000008400000000000000000000000000000000000000000000000000000000000000920000000000000000000000000773760b0708a5cc369c346993a0c225d8e4043b1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000002bc000000000000000000000000000000000000000000000000000000000000017400000000000000000000000000000000000000000000000000000000000000064c6567656e640000000000000000000000000000000000000000000000000000000000000000000000000000343da7ff0446247ca47aa41e2a25c5bbb230ed0a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000002bc00000000000000000000000000000000000000000000000000000000000000c900000000000000000000000000000000000000000000000000000000000000084c6567656e644949000000000000000000000000000000000000000000000000000000000000000000000000f2b1d86dc7459887b1f7ce8d840db1d87613ce7f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000002bc00000000000000000000000000000000000000000000000000000000000001d300000000000000000000000000000000000000000000000000000000000000094c6567656e644949490000000000000000000000000000000000000000000000000000000000000000000000eace91702b20bc6ee62034ec7f5162d9a94bfbe4000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000004416e6b72000000000000000000000000000000000000000000000000000000000000000000000000000000005ce21461e6472914f5e4d5b296c72125f26ed462000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000008b00000000000000000000000000000000000000000000000000000000000000095472616e636865737300000000000000000000000000000000000000000000000000000000000000000000005c38ff8ca2b16099c086bf36546e99b13d152c4c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000003e80000000000000000000000000000000000000000000000000000000000000057000000000000000000000000000000000000000000000000000000000000000954575374616b696e6700000000000000000000000000000000000000000000000000000000000000000000001ae5f5c3cb452e042b0b7b9dc60596c9cd84baf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000007b000000000000000000000000000000000000000000000000000000000000000446756a6900000000000000000000000000000000000000000000000000000000000000000000000000000000b12e8137ef499a1d81552db11664a9e617fd350a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000009f00000000000000000000000000000000000000000000000000000000000000054d617468570000000000000000000000000000000000000000000000000000000000000000000000000000004dc1bf52da103452097df48505a6d01020ffb22b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000009a000000000000000000000000000000000000000000000000000000000000000744656669626974000000000000000000000000000000000000000000000000000000000000000000000000007d0f8a6d1c8fbf929dcf4847a31e30d14923fa31000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000009f00000000000000000000000000000000000000000000000000000000000000084e6f64655265616c000000000000000000000000000000000000000000000000").unwrap(); + let validators = decode_validators_return(&result).unwrap(); + assert_eq!(validators.len(), 10); + assert_eq!(validators[0].operator_address, "0x773760b0708a5Cc369c346993a0c225D8e4043B1"); + assert_eq!(validators[0].moniker, "Legend"); + assert_eq!(validators[0].commission, 700); + assert_eq!(validators[0].apy, 372); + } + + #[test] + fn test_decode_delegations_return() { + let result = hex::decode("00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000ee448667ffc3d15ca023a6deef2d0faf084c0716000000000000000000000000773760b0708a5cc369c346993a0c225d8e4043b10000000000000000000000000000000000000000000000000de0b6b3b015a6430000000000000000000000000000000000000000000000000dd62dce1850f388000000000000000000000000ee448667ffc3d15ca023a6deef2d0faf084c0716000000000000000000000000343da7ff0446247ca47aa41e2a25c5bbb230ed0a0000000000000000000000000000000000000000000000000e09ef1d9101a1740000000000000000000000000000000000000000000000000e028d70463b87f8").unwrap(); + let delegations = decode_delegations_return(&result).unwrap(); + assert_eq!(delegations.len(), 2); + assert_eq!(delegations[1].delegator_address, "0xee448667ffc3D15ca023A6deEf2D0fAf084C0716"); + assert_eq!(delegations[1].validator_address, "0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A"); + assert_eq!(delegations[1].amount, "1011602501587280244"); + assert_eq!(delegations[1].shares, "1009524779838572536"); + } + + #[test] + fn test_decode_undelegations_return() { + let result = hex::decode("00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000ee448667ffc3d15ca023a6deef2d0faf084c0716000000000000000000000000343da7ff0446247ca47aa41e2a25c5bbb230ed0a000000000000000000000000000000000000000000000000016345785d89ffff00000000000000000000000000000000000000000000000001628aab7a64b3dc00000000000000000000000000000000000000000000000000000000664e7431").unwrap(); + let undelegations = decode_undelegations_return(&result).unwrap(); + assert_eq!(undelegations.len(), 1); + assert_eq!(undelegations[0].delegator_address, "0xee448667ffc3D15ca023A6deEf2D0fAf084C0716"); + assert_eq!(undelegations[0].validator_address, "0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A"); + assert_eq!(undelegations[0].amount, "99999999999999999"); + assert_eq!(undelegations[0].shares, "99794610853032924"); + assert_eq!(undelegations[0].unlock_time, "1716417585"); + } + + #[test] + fn test_encode_delegatie_call() { + let data = encode_delegate_call("0x773760b0708a5Cc369c346993a0c225D8e4043B1", false).unwrap(); + + assert_eq!( + hex::encode(data), + "982ef0a7000000000000000000000000773760b0708a5cc369c346993a0c225d8e4043b10000000000000000000000000000000000000000000000000000000000000000" + ); + } + + #[test] + fn test_encode_undelegatie_call() { + let data = encode_undelegate_call("0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A", "99794610853032924").unwrap(); + + assert_eq!( + hex::encode(data), + "4d99dd16000000000000000000000000343da7ff0446247ca47aa41e2a25c5bbb230ed0a00000000000000000000000000000000000000000000000001628aab7a64b3dc" + ); + } + + #[test] + fn test_encode_redelegatie_call() { + let data = encode_redelegate_call( + "0x773760b0708a5Cc369c346993a0c225D8e4043B1", + "0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A", + "1196258548170776928", + false, + ) + .unwrap(); + + assert_eq!( + hex::encode(data), + "59491871000000000000000000000000773760b0708a5cc369c346993a0c225d8e4043b1000000000000000000000000343da7ff0446247ca47aa41e2a25c5bbb230ed0a0000000000000000000000000000000000000000000000001099f6cfbf3e61600000000000000000000000000000000000000000000000000000000000000000" + ); + } + + #[test] + fn test_encode_claim_call() { + let data = encode_claim_call("0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A", 0).unwrap(); + + // Check the function selector + assert_eq!(hex::encode(&data[0..4]), "aad3ec96"); + } + + #[test] + fn test_encode_claim_batch_call() { + let data = encode_claim_batch_call(vec!["0xE5572297718e1943A92BfEde2E67A060439e8EFd".to_string()], vec![0]).unwrap(); + + assert_eq!( + hex::encode(data), + "d7c2dfc8000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000001000000000000000000000000e5572297718e1943a92bfede2e67a060439e8efd00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000" + ); + } +} diff --git a/core/crates/gem_cardano/Cargo.toml b/core/crates/gem_cardano/Cargo.toml new file mode 100644 index 0000000000..4f72519a84 --- /dev/null +++ b/core/crates/gem_cardano/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "gem_cardano" +version = { workspace = true } +edition = { workspace = true } +publish = false + +[features] +default = [] +rpc = [ + "dep:async-trait", + "dep:bech32", + "dep:chain_traits", + "dep:chrono", + "dep:futures", + "dep:gem_client", + "dep:hex", + "dep:num-bigint", + "dep:num-traits", + "dep:serde_json", +] +signer = [ + "dep:bech32", + "dep:curve25519-dalek", + "dep:gem_hash", + "dep:hex", + "dep:num-traits", + "dep:sha2", + "dep:zeroize", +] +reqwest = ["dep:gem_client", "gem_client/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] + +[dependencies] +primitives = { path = "../primitives" } +num-bigint = { workspace = true, optional = true } +serde_serializers = { path = "../serde_serializers" } +bech32 = { workspace = true, optional = true } +gem_hash = { path = "../gem_hash", optional = true } +futures = { workspace = true, optional = true } +hex = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, optional = true } +chrono = { workspace = true, features = ["serde"], optional = true } +async-trait = { workspace = true, optional = true } +chain_traits = { path = "../chain_traits", optional = true } +gem_client = { path = "../gem_client", optional = true } +num-traits = { workspace = true, optional = true } +curve25519-dalek = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } + +[dev-dependencies] +gem_client = { path = "../gem_client", features = ["testkit"] } +primitives = { path = "../primitives", features = ["testkit"] } +tokio = { workspace = true, features = ["macros"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_cardano/src/address.rs b/core/crates/gem_cardano/src/address.rs new file mode 100644 index 0000000000..368af78d66 --- /dev/null +++ b/core/crates/gem_cardano/src/address.rs @@ -0,0 +1,86 @@ +use primitives::SignerError; + +const MAINNET_NETWORK_ID: u8 = 1; +const BASE_KEY_ADDRESS_TYPE: u8 = 0; +const ENTERPRISE_KEY_ADDRESS_TYPE: u8 = 6; +const ADDRESS_HEADER_LENGTH: usize = 1; +const KEY_HASH_LENGTH: usize = 28; +const ENTERPRISE_ADDRESS_LENGTH: usize = ADDRESS_HEADER_LENGTH + KEY_HASH_LENGTH; +const BASE_ADDRESS_LENGTH: usize = ADDRESS_HEADER_LENGTH + KEY_HASH_LENGTH + KEY_HASH_LENGTH; + +#[derive(Debug)] +pub(crate) struct ShelleyAddress { + bytes: Vec, +} + +impl ShelleyAddress { + pub(crate) fn parse(address: &str) -> Result { + let (hrp, bytes) = bech32::decode(address).map_err(|_| SignerError::invalid_input("invalid Cardano address"))?; + if hrp.as_str() != "addr" { + return SignerError::invalid_input_err("unsupported Cardano address network"); + } + + Self::from_bytes(bytes) + } + + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + pub(crate) fn payment_hash(&self) -> &[u8] { + &self.bytes[ADDRESS_HEADER_LENGTH..ADDRESS_HEADER_LENGTH + KEY_HASH_LENGTH] + } + + fn from_bytes(bytes: Vec) -> Result { + if bytes.is_empty() { + return SignerError::invalid_input_err("invalid Cardano address"); + } + let address = Self { bytes }; + if address.network_id() != MAINNET_NETWORK_ID { + return SignerError::invalid_input_err("unsupported Cardano address network"); + } + match address.address_type() { + BASE_KEY_ADDRESS_TYPE => { + if address.bytes.len() != BASE_ADDRESS_LENGTH { + return SignerError::invalid_input_err("invalid Cardano base address"); + } + } + ENTERPRISE_KEY_ADDRESS_TYPE => { + if address.bytes.len() != ENTERPRISE_ADDRESS_LENGTH { + return SignerError::invalid_input_err("invalid Cardano enterprise address"); + } + } + _ => return SignerError::invalid_input_err("unsupported Cardano address type"), + } + Ok(address) + } + + fn address_type(&self) -> u8 { + self.bytes[0] >> 4 + } + + fn network_id(&self) -> u8 { + self.bytes[0] & 0x0f + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_shelley_address_decode() { + let address = ShelleyAddress::parse("addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk23").unwrap(); + assert_eq!( + hex::encode(address.as_bytes()), + "01df58ee97ce7a46cd8bdeec4e5f3a03297eb197825ed5681191110804df22424b6880b39e4bac8c58de9fe6d23d79aaf44756389d827aa09b" + ); + assert_eq!(hex::encode(address.payment_hash()), "df58ee97ce7a46cd8bdeec4e5f3a03297eb197825ed5681191110804"); + } + + #[test] + fn test_shelley_address_rejects_unsupported() { + assert!(ShelleyAddress::parse("stake1uykptcz226y5r5at5rfqqm00p9n0z0yfajz3gk3j3wm8dxg2sn0r4").is_err()); + assert!(ShelleyAddress::parse("addr_test1qr4p6f6mm0q9kfyyd9u30umk9cc6gk0nxu25k5rsc4fp7ls7k0qqxslcwwj4gvn4yfmdyrfgwjt3ztuz4zpy4242u0m95r0n").is_err()); + } +} diff --git a/core/crates/gem_cardano/src/cbor.rs b/core/crates/gem_cardano/src/cbor.rs new file mode 100644 index 0000000000..71fd7a9e73 --- /dev/null +++ b/core/crates/gem_cardano/src/cbor.rs @@ -0,0 +1,65 @@ +pub(crate) struct CborEncoder { + bytes: Vec, +} + +impl CborEncoder { + pub(crate) fn new() -> Self { + Self { bytes: Vec::new() } + } + + pub(crate) fn into_bytes(self) -> Vec { + self.bytes + } + + pub(crate) fn raw(&mut self, raw: &[u8]) { + self.bytes.extend_from_slice(raw); + } + + pub(crate) fn unsigned(&mut self, value: u64) { + self.major_type(0, value); + } + + pub(crate) fn bytes(&mut self, value: &[u8]) { + self.major_type(2, value.len() as u64); + self.bytes.extend_from_slice(value); + } + + pub(crate) fn array(&mut self, len: usize) { + self.major_type(4, len as u64); + } + + pub(crate) fn map(&mut self, len: usize) { + self.major_type(5, len as u64); + } + + pub(crate) fn null(&mut self) { + self.bytes.push(0xf6); + } + + pub(crate) fn true_value(&mut self) { + self.bytes.push(0xf5); + } + + fn major_type(&mut self, major: u8, value: u64) { + let prefix = major << 5; + match value { + 0..=23 => self.bytes.push(prefix | value as u8), + 24..=0xff => { + self.bytes.push(prefix | 24); + self.bytes.push(value as u8); + } + 0x100..=0xffff => { + self.bytes.push(prefix | 25); + self.bytes.extend_from_slice(&(value as u16).to_be_bytes()); + } + 0x1_0000..=0xffff_ffff => { + self.bytes.push(prefix | 26); + self.bytes.extend_from_slice(&(value as u32).to_be_bytes()); + } + _ => { + self.bytes.push(prefix | 27); + self.bytes.extend_from_slice(&value.to_be_bytes()); + } + } + } +} diff --git a/core/crates/gem_cardano/src/lib.rs b/core/crates/gem_cardano/src/lib.rs new file mode 100644 index 0000000000..cbb3fe876d --- /dev/null +++ b/core/crates/gem_cardano/src/lib.rs @@ -0,0 +1,26 @@ +#[cfg(any(feature = "rpc", feature = "signer"))] +mod address; +#[cfg(any(feature = "rpc", feature = "signer"))] +mod cbor; +pub mod models; +#[cfg(any(feature = "rpc", feature = "signer"))] +mod planner; +#[cfg(feature = "rpc")] +pub mod provider; +#[cfg(feature = "rpc")] +pub mod rpc; +#[cfg(any(feature = "rpc", feature = "signer"))] +mod transaction; + +#[cfg(feature = "rpc")] +pub use provider::map_transaction; +#[cfg(feature = "rpc")] +pub use rpc::client::CardanoClient; + +#[cfg(feature = "signer")] +pub mod signer; + +#[cfg(any(feature = "rpc", feature = "signer"))] +pub fn validate_address(address: &str) -> bool { + address::ShelleyAddress::parse(address).is_ok() +} diff --git a/core/crates/gem_cardano/src/models/account.rs b/core/crates/gem_cardano/src/models/account.rs new file mode 100644 index 0000000000..338a2de4b9 --- /dev/null +++ b/core/crates/gem_cardano/src/models/account.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Balance { + pub address: String, + pub tx_hash: String, + pub index: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BalanceResponse { + pub utxos: BalanceAggregate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BalanceAggregate { + pub aggregate: BalanceSum, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BalanceSum { + pub sum: BalanceSumValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BalanceSumValue { + pub value: Option, +} diff --git a/core/crates/gem_cardano/src/models/block.rs b/core/crates/gem_cardano/src/models/block.rs new file mode 100644 index 0000000000..8aa6a6c856 --- /dev/null +++ b/core/crates/gem_cardano/src/models/block.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +use super::UInt64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Block { + pub number: UInt64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub slot_no: UInt64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockData { + pub cardano: BlockTip, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockTip { + pub tip: Block, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenesisData { + pub genesis: Genesis, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Genesis { + pub shelley: GenesisShelley, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenesisShelley { + pub network_magic: i32, +} diff --git a/core/crates/gem_cardano/src/models/mod.rs b/core/crates/gem_cardano/src/models/mod.rs new file mode 100644 index 0000000000..55dbddbf30 --- /dev/null +++ b/core/crates/gem_cardano/src/models/mod.rs @@ -0,0 +1,13 @@ +pub mod account; +pub mod block; +pub mod rpc; +pub mod transaction; +pub mod utxo; + +pub type UInt64 = u64; + +pub use account::{Balance, BalanceAggregate, BalanceResponse, BalanceSum, BalanceSumValue}; +pub use block::{Block, BlockData, BlockTip, Genesis, GenesisData, GenesisShelley}; +pub use rpc::{Block as RpcBlock, Blocks, Data, Input, Output, Transaction as RpcTransaction}; +pub use transaction::{SubmitTransactionHash, Transaction as ModelTransaction, TransactionBroadcast}; +pub use utxo::{UTXO, UTXOS}; diff --git a/core/crates/gem_cardano/src/models/rpc.rs b/core/crates/gem_cardano/src/models/rpc.rs new file mode 100644 index 0000000000..3b6f711c3b --- /dev/null +++ b/core/crates/gem_cardano/src/models/rpc.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Data { + pub data: T, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Blocks { + pub blocks: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Block { + pub number: i64, + pub hash: String, + #[serde(rename = "forgedAt")] + pub forged_at: String, + pub transactions: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Transaction { + pub hash: String, + pub inputs: Vec, + pub outputs: Vec, + pub fee: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Input { + pub address: String, + pub value: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Output { + pub address: String, + pub value: String, +} diff --git a/core/crates/gem_cardano/src/models/transaction.rs b/core/crates/gem_cardano/src/models/transaction.rs new file mode 100644 index 0000000000..a584caade8 --- /dev/null +++ b/core/crates/gem_cardano/src/models/transaction.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +use super::block::Block; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionBroadcast { + pub submit_transaction: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitTransactionHash { + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + pub fee: String, + pub block: Block, +} diff --git a/core/crates/gem_cardano/src/models/utxo.rs b/core/crates/gem_cardano/src/models/utxo.rs new file mode 100644 index 0000000000..ceed8f0f0b --- /dev/null +++ b/core/crates/gem_cardano/src/models/utxo.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UTXOS { + pub utxos: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UTXO { + pub address: String, + pub tx_hash: String, + pub index: i32, + pub value: String, +} + +impl From for primitives::UTXO { + fn from(utxo: UTXO) -> Self { + primitives::UTXO { + transaction_id: utxo.tx_hash, + vout: utxo.index, + value: utxo.value, + address: utxo.address, + } + } +} diff --git a/core/crates/gem_cardano/src/planner.rs b/core/crates/gem_cardano/src/planner.rs new file mode 100644 index 0000000000..3b03534480 --- /dev/null +++ b/core/crates/gem_cardano/src/planner.rs @@ -0,0 +1,334 @@ +use num_traits::ToPrimitive; +use primitives::{Chain, SignerError, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, UTXO, hex::decode_hex_array}; + +use crate::{ + address::ShelleyAddress, + transaction::{Transaction, TransactionInput, TransactionOutput}, +}; + +const CARDANO_EXPIRATION_BLOCK_OFFSET: u64 = 7_200; +const FEE_ESTIMATE_LOVELACE: u64 = 170_000; +const MIN_OUTPUT_LOVELACE: u64 = 1_000_000; +const FEE_CONSTANT_MILLI_LOVELACE: u64 = 155_881_000; +const FEE_COEFFICIENT_MILLI_LOVELACE: u64 = 44_046; +const MILLI_LOVELACE: u64 = 1_000; + +pub(crate) struct TransactionPlan { + utxos: Vec, + amount: u64, + pub(crate) fee: u64, + change: u64, +} + +pub(crate) fn plan_transfer(input: &TransactionLoadInput) -> Result { + match &input.input_type { + TransactionInputType::Transfer(asset) if asset.id.chain == Chain::Cardano && asset.id.is_native() => {} + TransactionInputType::Transfer(_) => return SignerError::invalid_input_err("unsupported Cardano token transfer"), + _ => return SignerError::invalid_input_err("unsupported Cardano transaction type"), + } + + let requested_amount = input.value_as_u64()?; + + let utxos = utxos_from_metadata(&input.metadata, &input.sender_address)?; + if utxos.is_empty() { + return SignerError::invalid_input_err("missing input UTXOs"); + } + + let selected_utxos = select_utxos(&utxos, requested_amount, input.is_max_value)?; + let available_amount = sum_amounts(&selected_utxos)?; + + let mut fee = estimate_fee(input, &selected_utxos, requested_amount, input.is_max_value)?; + let amount = if input.is_max_value { + available_amount.checked_sub(fee).ok_or_else(|| SignerError::invalid_input("insufficient balance"))? + } else { + requested_amount + }; + + let spent = amount.checked_add(fee).ok_or_else(|| SignerError::invalid_input("Cardano amount overflow"))?; + if spent > available_amount { + return SignerError::invalid_input_err("insufficient balance"); + } + let mut change = available_amount - spent; + if change > 0 && change < MIN_OUTPUT_LOVELACE { + fee += change; + change = 0; + } + + Ok(TransactionPlan { + utxos: selected_utxos, + amount, + fee, + change, + }) +} + +pub(crate) fn transaction_from_plan(input: &TransactionLoadInput, plan: &TransactionPlan) -> Result { + let destination = ShelleyAddress::parse(&input.destination_address)?; + let expiration_block_number = input.metadata.get_block_number()? + CARDANO_EXPIRATION_BLOCK_OFFSET; + let mut outputs = vec![TransactionOutput { + address: destination.as_bytes().to_vec(), + amount: plan.amount, + }]; + + if plan.change > 0 { + let change = ShelleyAddress::parse(&input.sender_address)?; + outputs.push(TransactionOutput { + address: change.as_bytes().to_vec(), + amount: plan.change, + }); + } + + Ok(Transaction { + inputs: plan.utxos.iter().map(utxo_transaction_input).collect::, _>>()?, + outputs, + fee: plan.fee, + expiration_block_number, + }) +} + +fn utxos_from_metadata(metadata: &TransactionLoadMetadata, sender_address: &str) -> Result, SignerError> { + let sender = ShelleyAddress::parse(sender_address)?; + let utxos = metadata.get_utxos()?; + for utxo in &utxos { + utxo_transaction_input(utxo)?; + utxo_amount(utxo)?; + let address = ShelleyAddress::parse(&utxo.address)?; + if address.payment_hash() != sender.payment_hash() { + return SignerError::invalid_input_err("Cardano UTXO address does not match sender address"); + } + } + Ok(utxos) +} + +fn utxo_transaction_input(utxo: &UTXO) -> Result { + let transaction_hash = decode_hex_array(&utxo.transaction_id).map_err(|_| SignerError::invalid_input("invalid Cardano UTXO transaction id"))?; + let output_index = utxo.vout.to_u64().ok_or_else(|| SignerError::invalid_input("invalid Cardano UTXO output index"))?; + Ok(TransactionInput { transaction_hash, output_index }) +} + +fn utxo_amount(utxo: &UTXO) -> Result { + let amount = utxo.value.parse::().map_err(|_| SignerError::invalid_input("invalid Cardano UTXO amount"))?; + if amount == 0 { + return SignerError::invalid_input_err("invalid Cardano UTXO amount"); + } + Ok(amount) +} + +fn select_utxos(utxos: &[UTXO], amount: u64, max_amount: bool) -> Result, SignerError> { + if max_amount { + return Ok(utxos.to_vec()); + } + + let target = amount + .checked_mul(4) + .and_then(|value| value.checked_div(3)) + .and_then(|value| value.checked_add(FEE_ESTIMATE_LOVELACE)) + .and_then(|value| value.checked_add(MIN_OUTPUT_LOVELACE)) + .ok_or_else(|| SignerError::invalid_input("Cardano amount overflow"))?; + let mut candidates = utxos.iter().map(|utxo| Ok((utxo.clone(), utxo_amount(utxo)?))).collect::, SignerError>>()?; + candidates.sort_by(|(_, left_amount), (_, right_amount)| right_amount.cmp(left_amount)); + + let mut selected = Vec::new(); + let mut selected_amount = 0u64; + for (utxo, amount) in candidates { + selected_amount = selected_amount.checked_add(amount).ok_or_else(|| SignerError::invalid_input("Cardano amount overflow"))?; + selected.push(utxo); + if selected_amount >= target { + break; + } + } + if selected.is_empty() { + return SignerError::invalid_input_err("missing input UTXOs"); + } + Ok(selected) +} + +fn estimate_fee(input: &TransactionLoadInput, selected_utxos: &[UTXO], requested_amount: u64, max_amount: bool) -> Result { + let available_amount = sum_amounts(selected_utxos)?; + let amount = if max_amount { + available_amount.saturating_sub(FEE_ESTIMATE_LOVELACE) + } else { + requested_amount.min(available_amount.saturating_sub(FEE_ESTIMATE_LOVELACE)) + }; + let change = available_amount.saturating_sub(amount).saturating_sub(FEE_ESTIMATE_LOVELACE); + let plan = TransactionPlan { + utxos: selected_utxos.to_vec(), + amount, + fee: FEE_ESTIMATE_LOVELACE, + change, + }; + let transaction = transaction_from_plan(input, &plan)?; + Ok(transaction_fee(transaction.signed_size() as u64)) +} + +fn transaction_fee(transaction_size: u64) -> u64 { + (FEE_CONSTANT_MILLI_LOVELACE + transaction_size * FEE_COEFFICIENT_MILLI_LOVELACE).div_ceil(MILLI_LOVELACE) +} + +fn sum_amounts(utxos: &[UTXO]) -> Result { + utxos.iter().try_fold(0u64, |sum, utxo| { + sum.checked_add(utxo_amount(utxo)?).ok_or_else(|| SignerError::invalid_input("Cardano amount overflow")) + }) +} + +#[cfg(test)] +mod tests { + use primitives::{Asset, AssetType, Chain, GasPriceType, TransactionInputType, TransactionLoadMetadata}; + + use super::*; + + const OWN_ADDRESS_1: &str = "addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk23"; + const TO_ADDRESS: &str = "addr1q92cmkgzv9h4e5q7mnrzsuxtgayvg4qr7y3gyx97ukmz3dfx7r9fu73vqn25377ke6r0xk97zw07dqr9y5myxlgadl2s0dgke5"; + const TEST_EXPIRATION_BLOCK_NUMBER: u64 = 190_000_000; + const TEST_BLOCK_NUMBER: u64 = TEST_EXPIRATION_BLOCK_NUMBER - CARDANO_EXPIRATION_BLOCK_OFFSET; + + fn wallet_core_input(amount: &str, is_max_value: bool) -> TransactionLoadInput { + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Cardano)), + sender_address: OWN_ADDRESS_1.to_string(), + destination_address: TO_ADDRESS.to_string(), + value: amount.to_string(), + gas_price: GasPriceType::regular(0u64), + memo: None, + is_max_value, + metadata: TransactionLoadMetadata::Cardano { + block_number: TEST_BLOCK_NUMBER, + utxos: vec![ + UTXO { + transaction_id: "f074134aabbfb13b8aec7cf5465b1e5a862bde5cb88532cc7e64619179b3e767".to_string(), + vout: 1, + value: "1500000".to_string(), + address: OWN_ADDRESS_1.to_string(), + }, + UTXO { + transaction_id: "554f2fd942a23d06835d26bbd78f0106fa94c8a551114a0bef81927f66467af0".to_string(), + vout: 0, + value: "6500000".to_string(), + address: OWN_ADDRESS_1.to_string(), + }, + ], + }, + } + } + + #[test] + fn test_plan_transfer_vectors() { + let plan = plan_transfer(&wallet_core_input("7000000", false)).unwrap(); + assert_eq!(plan.utxos.len(), 2); + assert_eq!(sum_amounts(&plan.utxos).unwrap(), 8_000_000); + assert_eq!(plan.amount, 7_000_000); + assert_eq!(plan.fee, 1_000_000); + assert_eq!(plan.change, 0); + assert_eq!(plan.utxos[0].value, "6500000"); + assert_eq!(plan.utxos[1].value, "1500000"); + + let plan = plan_transfer(&wallet_core_input("1", false)).unwrap(); + assert_eq!(plan.utxos.len(), 1); + assert_eq!(sum_amounts(&plan.utxos).unwrap(), 6_500_000); + assert_eq!(plan.amount, 1); + assert_eq!(plan.fee, 168_479); + assert_eq!(plan.change, 6_331_520); + + let plan = plan_transfer(&wallet_core_input("2000000", false)).unwrap(); + assert_eq!(plan.utxos.len(), 1); + assert_eq!(sum_amounts(&plan.utxos).unwrap(), 6_500_000); + assert_eq!(plan.amount, 2_000_000); + assert_eq!(plan.fee, 168_655); + assert_eq!(plan.change, 4_331_345); + + let plan = plan_transfer(&wallet_core_input("2000000", true)).unwrap(); + assert_eq!(plan.utxos.len(), 2); + assert_eq!(sum_amounts(&plan.utxos).unwrap(), 8_000_000); + assert_eq!(plan.amount, 7_832_622); + assert_eq!(plan.fee, 167_378); + assert_eq!(plan.change, 0); + assert_eq!(plan.utxos[0].value, "1500000"); + assert_eq!(plan.utxos[1].value, "6500000"); + } + + #[test] + fn test_plan_transfer_android_vector_fee() { + let input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Cardano)), + sender_address: "addr1q9d2dxen8ywvs9yzxxn2w4mvffn797fquauvugt2ug7mfsuqj3lzdq9h0rsketzszrnfm930658swmpe7kpq53c2tmwql4rvtq".to_string(), + destination_address: "addr1q9d2dxen8ywvs9yzxxn2w4mvffn797fquauvugt2ug7mfsuqj3lzdq9h0rsketzszrnfm930658swmpe7kpq53c2tmwql4rvtq".to_string(), + value: "10000".to_string(), + gas_price: GasPriceType::regular(0u64), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Cardano { + block_number: TEST_BLOCK_NUMBER, + utxos: vec![UTXO { + address: "addr1q9d2dxen8ywvs9yzxxn2w4mvffn797fquauvugt2ug7mfsuqj3lzdq9h0rsketzszrnfm930658swmpe7kpq53c2tmwql4rvtq".to_string(), + transaction_id: "412c5a964cf4515210bf4b82f45df6521c38e1e5381f27638fc509bef6679378".to_string(), + value: "7945975".to_string(), + vout: 1, + }], + }, + }; + + let plan = plan_transfer(&input).unwrap(); + assert_eq!(plan.fee, 168_567); + assert_eq!(plan.change, 7_767_408); + } + + #[test] + fn test_plan_transfer_validation() { + let mut input = wallet_core_input("1", false); + input.metadata = TransactionLoadMetadata::Cardano { + utxos: vec![], + block_number: TEST_BLOCK_NUMBER, + }; + assert!(plan_transfer(&input).is_err()); + + input = wallet_core_input("1", false); + input.metadata = TransactionLoadMetadata::Cardano { + block_number: TEST_BLOCK_NUMBER, + utxos: vec![UTXO { + transaction_id: "zz".to_string(), + vout: 0, + value: "1".to_string(), + address: OWN_ADDRESS_1.to_string(), + }], + }; + assert!(plan_transfer(&input).is_err()); + + input = wallet_core_input("1", false); + input.destination_address = "stake1uykptcz226y5r5at5rfqqm00p9n0z0yfajz3gk3j3wm8dxg2sn0r4".to_string(); + assert!(plan_transfer(&input).is_err()); + + input = wallet_core_input("1", false); + input.input_type = TransactionInputType::Transfer(Asset::mock_with_params( + Chain::Cardano, + Some("policy.asset".to_string()), + "Cardano Token".to_string(), + "TOKEN".to_string(), + 0, + AssetType::TOKEN, + )); + assert_eq!(plan_transfer(&input).err().unwrap().to_string(), "Invalid input: unsupported Cardano token transfer"); + + input = wallet_core_input("1", false); + input.metadata = TransactionLoadMetadata::Cardano { + block_number: TEST_BLOCK_NUMBER, + utxos: vec![UTXO { + transaction_id: "f074134aabbfb13b8aec7cf5465b1e5a862bde5cb88532cc7e64619179b3e767".to_string(), + vout: 1, + value: "1500000".to_string(), + address: TO_ADDRESS.to_string(), + }], + }; + assert_eq!( + plan_transfer(&input).err().unwrap().to_string(), + "Invalid input: Cardano UTXO address does not match sender address" + ); + } + + #[test] + fn test_transaction_expiration_block_number() { + let input = wallet_core_input("1", false); + let plan = plan_transfer(&input).unwrap(); + let transaction = transaction_from_plan(&input, &plan).unwrap(); + assert_eq!(transaction.expiration_block_number, TEST_EXPIRATION_BLOCK_NUMBER); + } +} diff --git a/core/crates/gem_cardano/src/provider/balances.rs b/core/crates/gem_cardano/src/provider/balances.rs new file mode 100644 index 0000000000..52123d3492 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/balances.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use std::error::Error; + +use gem_client::Client; +use primitives::AssetBalance; + +use super::balances_mapper::map_balance_coin; +use crate::rpc::client::CardanoClient; + +#[async_trait] +impl ChainBalances for CardanoClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let balance = self.get_balance(&address).await?; + Ok(map_balance_coin(balance, self.get_chain())) + } + + async fn get_balance_tokens(&self, _address: String, _token_ids: Vec) -> Result, Box> { + Ok(vec![]) + } + + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Ok(None) + } + + async fn get_balance_assets(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use num_bigint::BigUint; + + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, create_test_client}; + + #[tokio::test] + async fn test_cardano_get_balance_coin() -> Result<(), Box> { + let client = create_test_client(); + let address = TEST_ADDRESS.to_string(); + let balance = client.get_balance_coin(address).await?; + + println!("Balance: {:?} {}", balance.balance.available, balance.asset_id); + + assert!(balance.balance.available > BigUint::from(0u64)); + Ok(()) + } + + #[tokio::test] + async fn test_cardano_get_balance_tokens() -> Result<(), Box> { + let client = create_test_client(); + let token_ids = vec![]; + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids).await?; + + assert_eq!(balances.len(), 0); + Ok(()) + } + + #[tokio::test] + async fn test_cardano_get_balance_staking() -> Result<(), Box> { + let client = create_test_client(); + let balance = client.get_balance_staking(TEST_ADDRESS.to_string()).await?; + + assert!(balance.is_none()); + Ok(()) + } + + #[tokio::test] + async fn test_cardano_get_balance_assets() -> Result<(), Box> { + let client = create_test_client(); + let assets = client.get_balance_assets(TEST_ADDRESS.to_string()).await?; + assert!(assets.is_empty()); + Ok(()) + } +} diff --git a/core/crates/gem_cardano/src/provider/balances_mapper.rs b/core/crates/gem_cardano/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..7d42ff74d0 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/balances_mapper.rs @@ -0,0 +1,21 @@ +use num_bigint::BigUint; +use primitives::{AssetBalance, Chain}; + +pub fn map_balance_coin(balance: String, chain: Chain) -> AssetBalance { + AssetBalance::new(chain.as_asset_id(), balance.parse::().unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::Chain; + + #[test] + fn test_map_balance_coin() { + let balance = "1000000".to_string(); + let result = map_balance_coin(balance, Chain::Cardano); + + assert_eq!(result.balance.available, BigUint::from(1000000_u64)); + assert_eq!(result.asset_id.chain, Chain::Cardano); + } +} diff --git a/core/crates/gem_cardano/src/provider/mod.rs b/core/crates/gem_cardano/src/provider/mod.rs new file mode 100644 index 0000000000..6446689fd6 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/mod.rs @@ -0,0 +1,27 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod state; +#[cfg(all(test, feature = "chain_integration_tests"))] +pub mod testkit; +pub mod token; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; + +pub use transactions_mapper::map_transaction; + +// Empty ChainAccount implementation +use crate::rpc::client::CardanoClient; +use async_trait::async_trait; +use chain_traits::ChainAccount; +use gem_client::Client; + +#[async_trait] +impl ChainAccount for CardanoClient {} diff --git a/core/crates/gem_cardano/src/provider/preload.rs b/core/crates/gem_cardano/src/provider/preload.rs new file mode 100644 index 0000000000..e5f4d917b4 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/preload.rs @@ -0,0 +1,35 @@ +use std::error::Error; + +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use futures::try_join; +use gem_client::Client; +use primitives::{FeePriority, FeeRate, GasPriceType, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput, UTXO}; + +use super::preload_mapper::{map_transaction_fee, map_transaction_preload}; +use crate::planner::plan_transfer; +use crate::rpc::client::CardanoClient; + +#[async_trait] +impl ChainTransactionLoad for CardanoClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let (utxos, tip) = try_join!(self.get_utxos(&input.sender_address), self.get_tip())?; + Ok(map_transaction_preload(utxos, tip.slot_no)) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let plan = plan_transfer(&input)?; + Ok(TransactionLoadData { + fee: map_transaction_fee(plan.fee), + metadata: input.metadata, + }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(1))]) + } + + async fn get_utxos(&self, address: String) -> Result, Box> { + Ok(CardanoClient::get_utxos(self, &address).await?.into_iter().map(UTXO::from).collect()) + } +} diff --git a/core/crates/gem_cardano/src/provider/preload_mapper.rs b/core/crates/gem_cardano/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..fbe043d738 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/preload_mapper.rs @@ -0,0 +1,17 @@ +use std::collections::HashMap; + +use num_bigint::BigInt; +use primitives::{GasPriceType, TransactionFee, TransactionLoadMetadata, UTXO}; + +use crate::models::utxo::UTXO as CardanoUTXO; + +pub(crate) fn map_transaction_preload(utxos: Vec, block_number: u64) -> TransactionLoadMetadata { + TransactionLoadMetadata::Cardano { + utxos: utxos.into_iter().map(UTXO::from).collect(), + block_number, + } +} + +pub(crate) fn map_transaction_fee(fee: u64) -> TransactionFee { + TransactionFee::new_gas_price_type(GasPriceType::regular(BigInt::from(1u64)), BigInt::from(fee), BigInt::from(1u64), HashMap::new()) +} diff --git a/core/crates/gem_cardano/src/provider/request_classifier.rs b/core/crates/gem_cardano/src/provider/request_classifier.rs new file mode 100644 index 0000000000..4d4ba80ae9 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/request_classifier.rs @@ -0,0 +1,22 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if !request.is_http_post_path("/") { + return ChainRequestType::Unknown; + } + + let Some(body) = request.body_utf8() else { + return ChainRequestType::Unknown; + }; + + if body.contains("\"operationName\":\"SubmitTransaction\"") || body.contains("submitTransaction") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_cardano/src/provider/state.rs b/core/crates/gem_cardano/src/provider/state.rs new file mode 100644 index 0000000000..3ac2c12ee2 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/state.rs @@ -0,0 +1,18 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; + +use crate::rpc::client::CardanoClient; + +#[async_trait] +impl ChainState for CardanoClient { + async fn get_chain_id(&self) -> Result> { + self.get_network_magic().await + } + + async fn get_block_latest_number(&self) -> Result> { + self.get_latest_block().await + } +} diff --git a/core/crates/gem_cardano/src/provider/testkit.rs b/core/crates/gem_cardano/src/provider/testkit.rs new file mode 100644 index 0000000000..366281e7b5 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/testkit.rs @@ -0,0 +1,21 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::CardanoClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "addr1qxf9s6vztx72hukln0r3p795ce6usw5rphsurac22h7f4xt8f32xsvyefel239ly4jev8ump855ynw85q56vh82sxzdsxycpzv"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_test_client() -> CardanoClient { + let settings = get_test_settings(); + let url = if settings.chains.cardano.url.is_empty() { + "https://cardano-mainnet.blockfrost.io/api/v0".to_string() + } else { + settings.chains.cardano.url + }; + let reqwest_client = ReqwestClient::new(url, reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)).build().unwrap()); + CardanoClient::new(reqwest_client) +} diff --git a/core/crates/gem_cardano/src/provider/token.rs b/core/crates/gem_cardano/src/provider/token.rs new file mode 100644 index 0000000000..f3dd522b8e --- /dev/null +++ b/core/crates/gem_cardano/src/provider/token.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::Asset; + +use crate::rpc::client::CardanoClient; + +#[async_trait] +impl ChainToken for CardanoClient { + async fn get_token_data(&self, _token_id: String) -> Result> { + Err("Cardano token data not implemented".into()) + } +} diff --git a/core/crates/gem_cardano/src/provider/transaction_broadcast.rs b/core/crates/gem_cardano/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..1c14fffe6c --- /dev/null +++ b/core/crates/gem_cardano/src/provider/transaction_broadcast.rs @@ -0,0 +1,28 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{ + BroadcastProvider, + transaction_broadcast_mapper::{map_transaction_broadcast_response, map_transaction_broadcast_response_from_str}, + }, + rpc::client::CardanoClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for CardanoClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(data).await?; + map_transaction_broadcast_response(response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_cardano/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_cardano/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..2ed939a58d --- /dev/null +++ b/core/crates/gem_cardano/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,25 @@ +use std::error::Error; + +use primitives::graphql::GraphqlData; + +use crate::models::transaction::TransactionBroadcast; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub(crate) fn map_transaction_broadcast_response(response: String) -> Result> { + map_transaction_broadcast(response) +} + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::>(response)?; + if response.errors.is_some() { + return Err("Failed to broadcast transaction".into()); + } + + let hash = response + .data + .and_then(|data| data.submit_transaction) + .map(|submit_transaction| submit_transaction.hash) + .ok_or("Failed to broadcast transaction")?; + + map_transaction_broadcast_response(hash) +} diff --git a/core/crates/gem_cardano/src/provider/transaction_state.rs b/core/crates/gem_cardano/src/provider/transaction_state.rs new file mode 100644 index 0000000000..074fe33a86 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/transaction_state.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use primitives::{TransactionState, TransactionStateRequest, TransactionUpdate}; +use std::error::Error; + +use gem_client::Client; + +use crate::rpc::client::CardanoClient; + +#[async_trait] +impl ChainTransactionState for CardanoClient { + async fn get_transaction_status(&self, _request: TransactionStateRequest) -> Result> { + Ok(TransactionUpdate::new_state(TransactionState::Confirmed)) + } +} diff --git a/core/crates/gem_cardano/src/provider/transactions.rs b/core/crates/gem_cardano/src/provider/transactions.rs new file mode 100644 index 0000000000..2856e5b545 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/transactions.rs @@ -0,0 +1,27 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::provider::transactions_mapper::map_transaction; +use crate::rpc::client::CardanoClient; + +#[async_trait] +impl ChainTransactions for CardanoClient { + async fn get_transactions_by_block(&self, block_number: u64) -> Result, Box> { + let block = self.get_block(block_number).await?; + let transactions = block + .transactions + .clone() + .into_iter() + .flat_map(|x| map_transaction(self.get_chain(), &block, &x)) + .collect::>(); + Ok(transactions) + } + + async fn get_transactions_by_address(&self, _request: TransactionsRequest) -> Result, Box> { + Ok(vec![]) + } +} diff --git a/core/crates/gem_cardano/src/provider/transactions_mapper.rs b/core/crates/gem_cardano/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..6249753396 --- /dev/null +++ b/core/crates/gem_cardano/src/provider/transactions_mapper.rs @@ -0,0 +1,93 @@ +use crate::models::rpc::{Block, Transaction}; +use chrono::DateTime; +use primitives::{TransactionState, TransactionType, chain::Chain, transaction_utxo::TransactionUtxoInput}; +use std::error::Error; + +pub fn map_transaction_broadcast(hash: String) -> Result> { + if hash.is_empty() { Err("Empty transaction hash".into()) } else { Ok(hash) } +} + +pub fn map_transaction(chain: Chain, block: &Block, transaction: &Transaction) -> Option { + let inputs: Vec = transaction + .inputs + .iter() + .map(|x| TransactionUtxoInput { + address: x.address.clone(), + value: x.value.clone(), + }) + .collect(); + + let outputs: Vec = transaction + .outputs + .iter() + .map(|x| TransactionUtxoInput { + address: x.address.clone(), + value: x.value.clone(), + }) + .collect(); + + if inputs.is_empty() || outputs.is_empty() { + return None; + } + let created_at = DateTime::parse_from_rfc3339(&block.forged_at).ok()?.into(); + + let transaction = primitives::Transaction::new_with_utxo( + transaction.hash.clone(), + chain.as_asset_id(), + TransactionType::Transfer, + TransactionState::Confirmed, + transaction.fee.clone(), + chain.as_asset_id(), + "0".to_string(), + None, + inputs.into(), + outputs.into(), + None, + created_at, + ); + + Some(transaction) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::rpc::{Input, Output}; + + #[test] + fn test_map_transaction_broadcast() { + let hash = "test_hash_123".to_string(); + let result = map_transaction_broadcast(hash).unwrap(); + assert_eq!(result, "test_hash_123"); + } + + #[test] + fn test_map_transaction() { + let block = Block { + number: 123, + hash: "block_hash".to_string(), + forged_at: "2023-01-01T00:00:00Z".to_string(), + transactions: vec![], + }; + + let transaction = Transaction { + hash: "tx_hash".to_string(), + inputs: vec![Input { + address: "addr1".to_string(), + value: "1000".to_string(), + }], + outputs: vec![Output { + address: "addr2".to_string(), + value: "900".to_string(), + }], + fee: "100".to_string(), + }; + + let result = map_transaction(Chain::Cardano, &block, &transaction); + assert!(result.is_some()); + + let mapped_tx = result.unwrap(); + assert_eq!(mapped_tx.id.to_string(), "cardano_tx_hash"); + assert_eq!(mapped_tx.fee, "100"); + } +} diff --git a/core/crates/gem_cardano/src/rpc/client.rs b/core/crates/gem_cardano/src/rpc/client.rs new file mode 100644 index 0000000000..85009f0d63 --- /dev/null +++ b/core/crates/gem_cardano/src/rpc/client.rs @@ -0,0 +1,156 @@ +use std::error::Error; + +use chain_traits::{ChainAddressStatus, ChainPerpetual, ChainProvider, ChainStaking, ChainTraits}; +use gem_client::{Client, ClientExt}; +use primitives::chain::Chain; + +use crate::models::{ + account::BalanceResponse, + block::{Block, BlockData, GenesisData}, + rpc::{Block as RpcBlock, Blocks, Data}, + transaction::TransactionBroadcast, + utxo::{UTXO, UTXOS}, +}; +use primitives::graphql::GraphqlData; + +const TIP_QUERY: &str = "{ cardano { tip { number slotNo } } }"; + +#[derive(Debug)] +pub struct CardanoClient { + client: C, + chain: Chain, +} + +impl CardanoClient { + pub fn new(client: C) -> Self { + Self { client, chain: Chain::Cardano } + } + + pub fn get_chain(&self) -> Chain { + self.chain + } + + pub(crate) async fn get_tip(&self) -> Result> { + let json = serde_json::json!({ + "query": TIP_QUERY + }); + let response: Data = self.client.post("/", &json).await?; + Ok(response.data.cardano.tip) + } + + pub async fn get_block(&self, block_number: u64) -> Result> { + let json = serde_json::json!({ + "query": "query GetBlockByNumber($blockNumber: Int!) { blocks(where: { number: { _eq: $blockNumber } }) { number hash forgedAt transactions { hash inputs { address value } outputs { address value } fee } } }", + "variables": { + "blockNumber": block_number + }, + "operationName": "GetBlockByNumber" + }); + let response: Data = self.client.post("/", &json).await?; + response.data.blocks.first().cloned().ok_or_else(|| "Block not found".into()) + } + + pub async fn get_balance(&self, address: &str) -> Result> { + let json = serde_json::json!({ + "operationName": "GetBalance", + "variables": {"address": address}, + "query": "query GetBalance($address: String!) { utxos: utxos_aggregate(where: { address: { _eq: $address } } ) { aggregate { sum { value } } } }" + }); + let response: GraphqlData = self.client.post("/", &json).await?; + + if let Some(errors) = response.errors + && let Some(error) = errors.first() + { + return Err(error.message.clone().into()); + } + + if let Some(data) = response.data { + Ok(data.utxos.aggregate.sum.value.unwrap_or_else(|| "0".to_string())) + } else { + Ok("0".to_string()) + } + } + + pub async fn get_utxos(&self, address: &str) -> Result, Box> { + let json = serde_json::json!({ + "operationName": "UtxoSetForAddress", + "variables": {"address": address}, + "query": "query UtxoSetForAddress($address: String!) { utxos(order_by: { value: desc } , where: { address: { _eq: $address } } ) { address value txHash index tokens { quantity asset { fingerprint policyId assetName } } } }" + }); + let response: Data>> = self.client.post("/", &json).await?; + Ok(response.data.utxos) + } + + pub async fn get_network_magic(&self) -> Result> { + let json = serde_json::json!({ + "operationName": "GetNetworkMagic", + "variables": {}, + "query": "query GetNetworkMagic { genesis { shelley { networkMagic } } }" + }); + let response: Data = self.client.post("/", &json).await?; + Ok(response.data.genesis.shelley.network_magic.to_string()) + } + + pub async fn broadcast_transaction(&self, data: String) -> Result> { + let json = serde_json::json!({ + "operationName": "SubmitTransaction", + "variables": {"transaction": data}, + "query": "mutation SubmitTransaction($transaction: String!) { submitTransaction(transaction: $transaction) { hash } }" + }); + let response: GraphqlData = self.client.post("/", &json).await?; + + if let Some(errors) = response.errors + && let Some(error) = errors.first() + { + return Err(error.message.clone().into()); + } + + if let Some(data) = response.data + && let Some(submit_transaction) = data.submit_transaction + { + return Ok(submit_transaction.hash); + } + + Err("Failed to broadcast transaction - no data or hash returned".into()) + } + + pub async fn get_latest_block(&self) -> Result> { + Ok(self.get_tip().await?.number) + } +} + +impl ChainStaking for CardanoClient {} + +impl ChainPerpetual for CardanoClient {} + +impl ChainAddressStatus for CardanoClient {} + +impl ChainTraits for CardanoClient {} + +impl ChainProvider for CardanoClient { + fn get_chain(&self) -> Chain { + self.chain + } +} + +#[cfg(test)] +mod tests { + use gem_client::testkit::MockClient; + + use super::*; + + #[tokio::test] + async fn test_get_tip() { + let client = MockClient::new().with_post(|path, body| { + assert_eq!(path, "/"); + let request: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(request["query"], "{ cardano { tip { number slotNo } } }"); + Ok(br#"{"data":{"cardano":{"tip":{"number":13427226,"slotNo":"187400452"}}}}"#.to_vec()) + }); + + let cardano = CardanoClient::new(client); + let tip = cardano.get_tip().await.unwrap(); + assert_eq!(tip.number, 13_427_226); + assert_eq!(tip.slot_no, 187_400_452); + } +} diff --git a/core/crates/gem_cardano/src/rpc/mod.rs b/core/crates/gem_cardano/src/rpc/mod.rs new file mode 100644 index 0000000000..8f3ffcc956 --- /dev/null +++ b/core/crates/gem_cardano/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::CardanoClient; diff --git a/core/crates/gem_cardano/src/signer/chain_signer.rs b/core/crates/gem_cardano/src/signer/chain_signer.rs new file mode 100644 index 0000000000..6cf774b8b6 --- /dev/null +++ b/core/crates/gem_cardano/src/signer/chain_signer.rs @@ -0,0 +1,108 @@ +use gem_hash::blake2::blake2b_224; +use primitives::{ChainSigner, SignerError, SignerInput}; + +use crate::{ + address::ShelleyAddress, + planner::{plan_transfer, transaction_from_plan}, +}; + +use super::extended_key::CardanoExtendedKeyPair; + +pub struct CardanoChainSigner; + +impl ChainSigner for CardanoChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let plan = plan_transfer(&input.input)?; + let key_pair = CardanoExtendedKeyPair::from_private_key(private_key)?; + let public_key = key_pair.public_key(); + let sender = ShelleyAddress::parse(&input.sender_address)?; + let payment_hash = blake2b_224(&public_key); + if sender.payment_hash() != payment_hash.as_slice() { + return SignerError::invalid_input_err("Cardano private key does not match sender address"); + } + let transaction = transaction_from_plan(&input.input, &plan)?; + let transaction_id = transaction.transaction_id(); + let signature = key_pair.sign(&transaction_id); + + Ok(hex::encode(transaction.signed_bytes(&public_key, &signature))) + } +} + +#[cfg(test)] +mod tests { + use primitives::{Asset, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, UTXO}; + + use super::*; + + const PRIVATE_KEY_TEST_1: &str = "089b68e458861be0c44bf9f7967f05cc91e51ede86dc679448a3566990b7785bd48c330875b1e0d03caaed0e67cecc42075dce1c7a13b1c49240508848ac82f603391c68824881ae3fc23a56a1a75ada3b96382db502e37564e84a5413cfaf1290dbd508e5ec71afaea98da2df1533c22ef02a26bb87b31907d0b2738fb7785b38d53aa68fc01230784c9209b2b2a2faf28491b3b1f1d221e63e704bbd0403c4154425dfbb01a2c5c042da411703603f89af89e57faae2946e2a5c18b1c5ca0e"; + const WALLET_CORE_OWN_ADDRESS_1: &str = "addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk23"; + const TO_ADDRESS: &str = "addr1q92cmkgzv9h4e5q7mnrzsuxtgayvg4qr7y3gyx97ukmz3dfx7r9fu73vqn25377ke6r0xk97zw07dqr9y5myxlgadl2s0dgke5"; + const TEST_BLOCK_NUMBER: u64 = 189_992_800; + + fn signer_input(sender_address: &str) -> SignerInput { + SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Cardano)), + sender_address: sender_address.to_string(), + destination_address: TO_ADDRESS.to_string(), + value: "7000000".to_string(), + gas_price: GasPriceType::regular(0u64), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Cardano { + block_number: TEST_BLOCK_NUMBER, + utxos: vec![ + UTXO { + transaction_id: "f074134aabbfb13b8aec7cf5465b1e5a862bde5cb88532cc7e64619179b3e767".to_string(), + vout: 1, + value: "1500000".to_string(), + address: sender_address.to_string(), + }, + UTXO { + transaction_id: "554f2fd942a23d06835d26bbd78f0106fa94c8a551114a0bef81927f66467af0".to_string(), + vout: 0, + value: "6500000".to_string(), + address: sender_address.to_string(), + }, + ], + }, + }, + TransactionFee::default(), + ) + } + + #[test] + fn test_sign_transfer_vector() { + let input = signer_input(WALLET_CORE_OWN_ADDRESS_1); + let private_key = hex::decode(PRIVATE_KEY_TEST_1).unwrap(); + let plan = plan_transfer(&input.input).unwrap(); + let key_pair = CardanoExtendedKeyPair::from_private_key(&private_key).unwrap(); + + let mut transaction = transaction_from_plan(&input.input, &plan).unwrap(); + transaction.expiration_block_number = 53_333_333; + let transaction_id = transaction.transaction_id(); + let signature = key_pair.sign(&transaction_id); + + assert_eq!( + hex::encode(transaction.signed_bytes(&key_pair.public_key(), &signature)), + "84a40082825820554f2fd942a23d06835d26bbd78f0106fa94c8a551114a0bef81927f66467af000825820f074134aabbfb13b8aec7cf5465b1e5a862bde5cb88532cc7e64619179b3e76701018182583901558dd902616f5cd01edcc62870cb4748c45403f1228218bee5b628b526f0ca9e7a2c04d548fbd6ce86f358be139fe680652536437d1d6fd51a006acfc0021a000f4240031a032dcd55a100818258206d8a0b425bd2ec9692af39b1c0cf0e51caa07a603550e22f54091e872c7df29058407519e5e7391f8a47f58c8ded1ce532dc80910ef25b108b1092cb58e86a318964956d00af763087fddabf631d00508d1e9c206eaf762176f538042f5c52f6d902f5f6" + ); + assert_eq!(hex::encode(transaction_id), "92859ce37002afc9185c5a918e6596b90258dd3b59ea686ec625bf1b15a5c101"); + } + + #[test] + fn test_sign_transfer_rejects_invalid_key_length() { + let input = signer_input(WALLET_CORE_OWN_ADDRESS_1); + assert!(CardanoChainSigner.sign_transfer(&input, &[0u8; 32]).is_err()); + } + + #[test] + fn test_sign_transfer_rejects_sender_key_mismatch() { + let input = signer_input(TO_ADDRESS); + let private_key = hex::decode(PRIVATE_KEY_TEST_1).unwrap(); + assert_eq!( + CardanoChainSigner.sign_transfer(&input, &private_key).err().unwrap().to_string(), + "Invalid input: Cardano private key does not match sender address" + ); + } +} diff --git a/core/crates/gem_cardano/src/signer/extended_key.rs b/core/crates/gem_cardano/src/signer/extended_key.rs new file mode 100644 index 0000000000..83284b014f --- /dev/null +++ b/core/crates/gem_cardano/src/signer/extended_key.rs @@ -0,0 +1,102 @@ +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar}; +use primitives::SignerError; +use sha2::{Digest, Sha512}; +use zeroize::Zeroize; + +const EXTENDED_PRIVATE_KEY_LENGTH: usize = 192; +const KEY_LENGTH: usize = 32; +const EXTENSION_OFFSET: usize = 32; + +pub(super) struct CardanoExtendedKeyPair { + secret: [u8; KEY_LENGTH], + extension: [u8; KEY_LENGTH], +} + +impl Drop for CardanoExtendedKeyPair { + fn drop(&mut self) { + self.secret.zeroize(); + self.extension.zeroize(); + } +} + +impl CardanoExtendedKeyPair { + pub(super) fn from_private_key(private_key: &[u8]) -> Result { + if private_key.len() != EXTENDED_PRIVATE_KEY_LENGTH { + return SignerError::invalid_input_err("invalid Cardano private key length"); + } + + Ok(Self { + secret: read_key(private_key, 0), + extension: read_key(private_key, EXTENSION_OFFSET), + }) + } + + pub(super) fn public_key(&self) -> [u8; KEY_LENGTH] { + public_key_from_secret(self.secret) + } + + pub(super) fn sign(&self, message: &[u8]) -> [u8; 64] { + let signing_scalar = Scalar::from_bytes_mod_order(self.secret); + let public_key = self.public_key(); + let r = scalar_from_hash(&[self.extension.as_slice(), message]); + let r_bytes = (&r * ED25519_BASEPOINT_TABLE).compress().to_bytes(); + let k = scalar_from_hash(&[r_bytes.as_slice(), public_key.as_slice(), message]); + let s = k * signing_scalar + r; + + let mut signature = [0u8; 64]; + signature[0..KEY_LENGTH].copy_from_slice(&r_bytes); + signature[KEY_LENGTH..].copy_from_slice(&s.to_bytes()); + signature + } +} + +fn read_key(bytes: &[u8], offset: usize) -> [u8; KEY_LENGTH] { + let mut key = [0u8; KEY_LENGTH]; + key.copy_from_slice(&bytes[offset..offset + KEY_LENGTH]); + key +} + +fn public_key_from_secret(secret: [u8; KEY_LENGTH]) -> [u8; KEY_LENGTH] { + let scalar = Scalar::from_bytes_mod_order(secret); + (&scalar * ED25519_BASEPOINT_TABLE).compress().to_bytes() +} + +fn scalar_from_hash(parts: &[&[u8]]) -> Scalar { + let mut hasher = Sha512::new(); + for part in parts { + hasher.update(part); + } + let bytes: [u8; 64] = hasher.finalize().into(); + Scalar::from_bytes_mod_order_wide(&bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cardano_extended_key_pair() { + let private_key = hex::decode( + "d809b1b4b4c74734037f76aace501730a3fe2fca30b5102df99ad3f7c0103e48\ + d54cde47e9041b31f3e6873d700d83f7a937bea746dadfa2c5b0a6a92502356c\ + 69272d81c376382b8a87c21370a7ae9618df8da708d1a9490939ec54ebe43000\ + 1111111111111111111111111111111111111111111111111111111111111111\ + 1111111111111111111111111111111111111111111111111111111111111111\ + 1111111111111111111111111111111111111111111111111111111111111111", + ) + .unwrap(); + + let key_pair = CardanoExtendedKeyPair::from_private_key(&private_key).unwrap(); + + assert_eq!(hex::encode(key_pair.public_key()), "e6f04522f875c1563682ca876ddb04c2e2e3ae718e3ff9f11c03dd9f9dccf698"); + assert_eq!( + hex::encode(key_pair.sign(b"Hello world")), + "1096ddcfb2ad21a4c0d861ef3fabe18841e8de88105b0d8e36430d7992c588634ead4100c32b2800b31b65e014d54a8238bdda63118d829bf0bcf1b631e86f0e" + ); + } + + #[test] + fn test_cardano_extended_key_pair_rejects_invalid_length() { + assert!(CardanoExtendedKeyPair::from_private_key(&[0u8; 32]).is_err()); + } +} diff --git a/core/crates/gem_cardano/src/signer/mod.rs b/core/crates/gem_cardano/src/signer/mod.rs new file mode 100644 index 0000000000..ddbf2a0e27 --- /dev/null +++ b/core/crates/gem_cardano/src/signer/mod.rs @@ -0,0 +1,4 @@ +mod chain_signer; +mod extended_key; + +pub use chain_signer::CardanoChainSigner; diff --git a/core/crates/gem_cardano/src/transaction.rs b/core/crates/gem_cardano/src/transaction.rs new file mode 100644 index 0000000000..61854479cf --- /dev/null +++ b/core/crates/gem_cardano/src/transaction.rs @@ -0,0 +1,128 @@ +#[cfg(feature = "signer")] +use gem_hash::blake2::blake2b_256; + +use crate::cbor::CborEncoder; + +#[derive(Debug)] +pub(crate) struct TransactionInput { + pub(crate) transaction_hash: [u8; 32], + pub(crate) output_index: u64, +} + +#[derive(Debug)] +pub(crate) struct TransactionOutput { + pub(crate) address: Vec, + pub(crate) amount: u64, +} + +#[derive(Debug)] +pub(crate) struct Transaction { + pub(crate) inputs: Vec, + pub(crate) outputs: Vec, + pub(crate) fee: u64, + pub(crate) expiration_block_number: u64, +} + +impl Transaction { + pub(crate) fn body_bytes(&self) -> Vec { + let mut encoder = CborEncoder::new(); + encoder.map(4); + + encoder.unsigned(0); + encoder.array(self.inputs.len()); + for input in &self.inputs { + encoder.array(2); + encoder.bytes(&input.transaction_hash); + encoder.unsigned(input.output_index); + } + + encoder.unsigned(1); + encoder.array(self.outputs.len()); + for output in &self.outputs { + encoder.array(2); + encoder.bytes(&output.address); + encoder.unsigned(output.amount); + } + + encoder.unsigned(2); + encoder.unsigned(self.fee); + encoder.unsigned(3); + encoder.unsigned(self.expiration_block_number); + + encoder.into_bytes() + } + + #[cfg(feature = "signer")] + pub(crate) fn transaction_id(&self) -> [u8; 32] { + blake2b_256(&self.body_bytes()) + } + + pub(crate) fn signed_bytes(&self, public_key: &[u8; 32], signature: &[u8; 64]) -> Vec { + let body = self.body_bytes(); + let mut encoder = CborEncoder::new(); + encoder.array(4); + encoder.raw(&body); + encoder.map(1); + encoder.unsigned(0); + encoder.array(1); + encoder.array(2); + encoder.bytes(public_key); + encoder.bytes(signature); + encoder.true_value(); + encoder.null(); + encoder.into_bytes() + } + + pub(crate) fn signed_size(&self) -> usize { + self.signed_bytes(&[0u8; 32], &[0u8; 64]).len() + } +} + +#[cfg(all(test, feature = "signer"))] +mod tests { + use super::*; + use crate::address::ShelleyAddress; + + #[test] + fn test_transaction_encode_and_id() { + let transaction = Transaction { + inputs: vec![ + TransactionInput { + transaction_hash: hex::decode("f074134aabbfb13b8aec7cf5465b1e5a862bde5cb88532cc7e64619179b3e767").unwrap().try_into().unwrap(), + output_index: 1, + }, + TransactionInput { + transaction_hash: hex::decode("554f2fd942a23d06835d26bbd78f0106fa94c8a551114a0bef81927f66467af0").unwrap().try_into().unwrap(), + output_index: 0, + }, + ], + outputs: vec![ + TransactionOutput { + address: ShelleyAddress::parse("addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk23") + .unwrap() + .as_bytes() + .to_vec(), + amount: 2_000_000, + }, + TransactionOutput { + address: ShelleyAddress::parse("addr1q92cmkgzv9h4e5q7mnrzsuxtgayvg4qr7y3gyx97ukmz3dfx7r9fu73vqn25377ke6r0xk97zw07dqr9y5myxlgadl2s0dgke5") + .unwrap() + .as_bytes() + .to_vec(), + amount: 16_749_189, + }, + ], + fee: 165_555, + expiration_block_number: 53_333_345, + }; + + assert_eq!( + hex::encode(transaction.body_bytes()), + "a40082825820f074134aabbfb13b8aec7cf5465b1e5a862bde5cb88532cc7e64619179b3e76701825820554f2fd942a23d06835d26bbd78f0106fa94c8a551114a0bef81927f66467af000018282583901df58ee97ce7a46cd8bdeec4e5f3a03297eb197825ed5681191110804df22424b6880b39e4bac8c58de9fe6d23d79aaf44756389d827aa09b1a001e848082583901558dd902616f5cd01edcc62870cb4748c45403f1228218bee5b628b526f0ca9e7a2c04d548fbd6ce86f358be139fe680652536437d1d6fd51a00ff9285021a000286b3031a032dcd61" + ); + assert_eq!( + hex::encode(transaction.transaction_id()), + "cc262713a3e15a0fa245b062f33ffc6c2aa5a64c3ae7bfa793414069914e1bbf" + ); + } +} diff --git a/core/crates/gem_client/Cargo.toml b/core/crates/gem_client/Cargo.toml new file mode 100644 index 0000000000..7649fcc999 --- /dev/null +++ b/core/crates/gem_client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gem_client" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +reqwest = ["dep:reqwest", "dep:tokio"] +testkit = [] + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_urlencoded = { workspace = true } +reqwest = { workspace = true, optional = true } +hex = { workspace = true } +tokio = { workspace = true, features = ["time"], optional = true } diff --git a/core/crates/gem_client/src/client_config.rs b/core/crates/gem_client/src/client_config.rs new file mode 100644 index 0000000000..54cb7b4c78 --- /dev/null +++ b/core/crates/gem_client/src/client_config.rs @@ -0,0 +1,13 @@ +use std::time::Duration; + +pub fn builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(15)) + .pool_idle_timeout(Duration::from_secs(90)) + .pool_max_idle_per_host(20) + .tcp_keepalive(Duration::from_secs(60)) + .gzip(true) + .brotli(true) + .deflate(true) +} diff --git a/core/crates/gem_client/src/content_type.rs b/core/crates/gem_client/src/content_type.rs new file mode 100644 index 0000000000..90fa01bd5f --- /dev/null +++ b/core/crates/gem_client/src/content_type.rs @@ -0,0 +1,44 @@ +use std::str::FromStr; + +pub const CONTENT_TYPE: &str = "Content-Type"; +const APPLICATION_JSON: &str = "application/json"; +const TEXT_PLAIN: &str = "text/plain"; +const APPLICATION_FORM_URL_ENCODED: &str = "application/x-www-form-urlencoded"; +const APPLICATION_X_BINARY: &str = "application/x-binary"; +const APPLICATION_APTOS_BCS: &str = "application/x.aptos.signed_transaction+bcs"; + +#[derive(Debug, Clone, PartialEq)] +pub enum ContentType { + ApplicationJson, + TextPlain, + ApplicationFormUrlEncoded, + ApplicationXBinary, + ApplicationAptosBcs, +} + +impl ContentType { + pub const fn as_str(&self) -> &'static str { + match self { + ContentType::ApplicationJson => APPLICATION_JSON, + ContentType::TextPlain => TEXT_PLAIN, + ContentType::ApplicationFormUrlEncoded => APPLICATION_FORM_URL_ENCODED, + ContentType::ApplicationXBinary => APPLICATION_X_BINARY, + ContentType::ApplicationAptosBcs => APPLICATION_APTOS_BCS, + } + } +} + +impl FromStr for ContentType { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + APPLICATION_JSON => Ok(ContentType::ApplicationJson), + TEXT_PLAIN => Ok(ContentType::TextPlain), + APPLICATION_FORM_URL_ENCODED => Ok(ContentType::ApplicationFormUrlEncoded), + APPLICATION_X_BINARY => Ok(ContentType::ApplicationXBinary), + APPLICATION_APTOS_BCS => Ok(ContentType::ApplicationAptosBcs), + _ => Err("Unknown content type"), + } + } +} diff --git a/core/crates/gem_client/src/lib.rs b/core/crates/gem_client/src/lib.rs new file mode 100644 index 0000000000..6a37c4db38 --- /dev/null +++ b/core/crates/gem_client/src/lib.rs @@ -0,0 +1,94 @@ +mod content_type; +mod types; + +#[cfg(feature = "testkit")] +pub mod testkit; + +#[cfg(feature = "reqwest")] +mod reqwest_client; + +#[cfg(feature = "reqwest")] +pub mod retry; + +#[cfg(feature = "reqwest")] +pub mod client_config; + +pub mod query; + +pub use content_type::{CONTENT_TYPE, ContentType}; +pub use query::build_path_with_query; +pub use types::{ClientError, Response, decode_json_byte_array, deserialize_response}; + +#[cfg(feature = "reqwest")] +pub use reqwest_client::{ReqwestClient, json_response}; + +#[cfg(feature = "reqwest")] +pub use retry::{default_should_retry, retry, retry_policy}; + +#[cfg(feature = "reqwest")] +pub use client_config::builder; + +use async_trait::async_trait; +use serde::{Serialize, de::DeserializeOwned}; +use std::{collections::HashMap, fmt::Debug}; + +pub type Data = Vec; +pub const X_CACHE_TTL: &str = "x-gem-cache-ttl"; + +#[async_trait] +pub trait Client: Send + Sync + Debug { + async fn get_with(&self, path: &str, query: &[(String, String)], headers: HashMap) -> Result + where + R: DeserializeOwned; + + async fn get_url(&self, url: &str) -> Result + where + R: DeserializeOwned; + + async fn post_with(&self, path: &str, body: &T, headers: HashMap) -> Result + where + T: Serialize + Send + Sync, + R: DeserializeOwned; +} + +#[async_trait] +pub trait ClientExt: Client { + async fn get(&self, path: &str) -> Result + where + R: DeserializeOwned + Send, + { + self.get_with(path, &[], HashMap::new()).await + } + + async fn get_with_query(&self, path: &str, query: &[(String, String)]) -> Result + where + R: DeserializeOwned + Send, + { + self.get_with(path, query, HashMap::new()).await + } + + async fn get_with_headers(&self, path: &str, headers: HashMap) -> Result + where + R: DeserializeOwned + Send, + { + self.get_with(path, &[], headers).await + } + + async fn post(&self, path: &str, body: &T) -> Result + where + T: Serialize + Send + Sync, + R: DeserializeOwned + Send, + { + self.post_with(path, body, HashMap::new()).await + } + + async fn post_with_headers(&self, path: &str, body: &T, headers: HashMap) -> Result + where + T: Serialize + Send + Sync, + R: DeserializeOwned + Send, + { + self.post_with(path, body, headers).await + } +} + +impl ClientExt for T {} diff --git a/core/crates/gem_client/src/query.rs b/core/crates/gem_client/src/query.rs new file mode 100644 index 0000000000..b7270dbcb9 --- /dev/null +++ b/core/crates/gem_client/src/query.rs @@ -0,0 +1,39 @@ +use serde::Serialize; + +/// Build a path with query parameters from a serializable struct +pub fn build_path_with_query(path: &str, query: &T) -> Result { + let query_string = serde_urlencoded::to_string(query)?; + Ok(format!("{}?{}", path, query_string)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + + #[derive(Serialize)] + struct CoinQuery { + pub market_data: bool, + pub community_data: bool, + pub tickers: bool, + pub localization: bool, + pub developer_data: bool, + } + + #[test] + fn test_build_path_with_query_coingecko_case() { + let id = "bitcoin"; + let query = CoinQuery { + market_data: false, + community_data: true, + tickers: false, + localization: true, + developer_data: true, + }; + let base_path = format!("/api/v3/coins/{}", id); + let result = build_path_with_query(&base_path, &query).unwrap(); + + let expected = "/api/v3/coins/bitcoin?market_data=false&community_data=true&tickers=false&localization=true&developer_data=true"; + assert_eq!(result, expected); + } +} diff --git a/core/crates/gem_client/src/reqwest_client.rs b/core/crates/gem_client/src/reqwest_client.rs new file mode 100644 index 0000000000..212a43cc7c --- /dev/null +++ b/core/crates/gem_client/src/reqwest_client.rs @@ -0,0 +1,169 @@ +use crate::{CONTENT_TYPE, Client, ClientError, ContentType, Response, deserialize_response, retry_policy}; +use async_trait::async_trait; +use reqwest::RequestBuilder; +use reqwest::header::USER_AGENT; +use serde::{Serialize, de::DeserializeOwned}; +use std::{collections::HashMap, str::FromStr, time::Duration}; + +#[derive(Debug, Clone)] +pub struct ReqwestClient { + base_url: String, + client: reqwest::Client, + user_agent: Option, +} + +impl ReqwestClient { + pub fn new(url: String, client: reqwest::Client) -> Self { + Self { + base_url: url, + client, + user_agent: None, + } + } + + pub fn new_with_user_agent(url: String, client: reqwest::Client, user_agent: String) -> Self { + Self { + base_url: url, + client, + user_agent: Some(user_agent), + } + } + + pub fn new_with_retry(url: String, timeout_secs: u64, max_retries: u32) -> Self { + let client = crate::client_config::builder() + .timeout(Duration::from_secs(timeout_secs)) + .retry(retry_policy(url.clone(), max_retries)) + .build() + .expect("Failed to build reqwest client with retry"); + Self { + base_url: url, + client, + user_agent: None, + } + } + + pub fn new_test_client(url: String) -> Self { + Self::new_with_retry(url, 30, 3) + } + + pub fn with_user_agent(mut self, user_agent: String) -> Self { + self.user_agent = Some(user_agent); + self + } + + fn build_url(&self, path: &str) -> String { + format!("{}{}", self.base_url.trim_end_matches('/'), path) + } + + fn build_request(&self, request: RequestBuilder, headers: HashMap) -> RequestBuilder { + let request = if let Some(ref user_agent) = self.user_agent { + request.header(USER_AGENT, user_agent) + } else { + request + }; + + headers.into_iter().fold(request, |req, (key, value)| req.header(&key, &value)) + } + + async fn send_request(&self, response: reqwest::Response) -> Result + where + R: DeserializeOwned, + { + let status = response.status().as_u16(); + let data = response + .bytes() + .await + .map_err(|e| ClientError::Network(format!("Failed to read response body: {e}")))? + .to_vec(); + + let response = Response { status: Some(status), data }; + deserialize_response(&response) + } + + fn map_reqwest_error(e: reqwest::Error) -> ClientError { + if e.is_timeout() { + ClientError::Timeout + } else if e.is_connect() { + ClientError::Network(format!("Connection error: {e}")) + } else if e.is_builder() { + ClientError::Network(format!("Request builder error: {e:?}")) + } else { + let url = e.url().map(|u| u.as_str()).unwrap_or("unknown"); + ClientError::Network(format!("{e} url={url}")) + } + } +} + +#[async_trait] +impl Client for ReqwestClient { + async fn get_with(&self, path: &str, query: &[(String, String)], headers: HashMap) -> Result + where + R: DeserializeOwned, + { + let url = self.build_url(path); + let request = self.build_request(self.client.get(&url).query(query), headers); + + let response = request.send().await.map_err(Self::map_reqwest_error)?; + self.send_request(response).await + } + + async fn get_url(&self, url: &str) -> Result + where + R: DeserializeOwned, + { + let request = self.build_request(self.client.get(url), HashMap::new()); + let response = request.send().await.map_err(Self::map_reqwest_error)?; + self.send_request(response).await + } + + async fn post_with(&self, path: &str, body: &T, headers: HashMap) -> Result + where + T: Serialize + Send + Sync, + R: DeserializeOwned, + { + let url = self.build_url(path); + let headers = if headers.is_empty() { + HashMap::from([(CONTENT_TYPE.to_string(), ContentType::ApplicationJson.as_str().to_string())]) + } else { + headers + }; + + let content_type = headers.get(CONTENT_TYPE).and_then(|s| ContentType::from_str(s).ok()); + + let request_body = match content_type { + Some(ContentType::TextPlain) | Some(ContentType::ApplicationFormUrlEncoded) | Some(ContentType::ApplicationXBinary) | Some(ContentType::ApplicationAptosBcs) => { + let json_value = serde_json::to_value(body).map_err(|e| ClientError::Serialization(format!("Failed to serialize request: {e}")))?; + match json_value { + serde_json::Value::String(s) => { + if matches!(content_type, Some(ContentType::ApplicationXBinary) | Some(ContentType::ApplicationAptosBcs)) { + hex::decode(&s).map_err(|e| ClientError::Serialization(format!("Failed to decode hex string: {e}")))? + } else { + s.into_bytes() + } + } + serde_json::Value::Array(values) if matches!(content_type, Some(ContentType::ApplicationXBinary) | Some(ContentType::ApplicationAptosBcs)) => { + crate::decode_json_byte_array(values)? + } + _ => return Err(ClientError::Serialization("Expected string body for text/plain or binary content-type".to_string())), + } + } + _ => serde_json::to_vec(body).map_err(|e| ClientError::Serialization(format!("Failed to serialize request: {e}")))?, + }; + + let request = self.build_request(self.client.post(&url).body(request_body), headers); + let response = request.send().await.map_err(Self::map_reqwest_error)?; + + self.send_request(response).await + } +} + +pub async fn json_response(response: reqwest::Response) -> Result { + let status = response.status().as_u16(); + let data = response + .bytes() + .await + .map_err(|e| ClientError::Network(format!("Failed to read response body: {e}")))? + .to_vec(); + let response = Response { status: Some(status), data }; + deserialize_response(&response) +} diff --git a/core/crates/gem_client/src/retry.rs b/core/crates/gem_client/src/retry.rs new file mode 100644 index 0000000000..d7ebe7ca77 --- /dev/null +++ b/core/crates/gem_client/src/retry.rs @@ -0,0 +1,83 @@ +use reqwest::{StatusCode, retry}; +use std::future::Future; +use std::time::Duration; + +#[cfg(feature = "reqwest")] +use tokio::time::sleep; + +pub fn retry_policy(host: S, max_retries: u32) -> retry::Builder +where + S: for<'a> PartialEq<&'a str> + Send + Sync + 'static, +{ + retry::for_host(host).max_retries_per_request(max_retries).classify_fn(|req_rep| { + match req_rep.status() { + Some(StatusCode::TOO_MANY_REQUESTS) + | Some(StatusCode::INTERNAL_SERVER_ERROR) + | Some(StatusCode::BAD_GATEWAY) + | Some(StatusCode::SERVICE_UNAVAILABLE) + | Some(StatusCode::GATEWAY_TIMEOUT) => req_rep.retryable(), + None => req_rep.retryable(), // Network errors + _ => req_rep.success(), + } + }) +} + +pub async fn retry(operation: F, max_retries: u32, should_retry_fn: Option

) -> Result +where + F: Fn() -> Fut, + Fut: Future>, + E: std::fmt::Display, + P: Fn(&E) -> bool, +{ + let mut attempt = 0; + + loop { + match operation().await { + Ok(result) => return Ok(result), + Err(err) => { + let should_retry_error = match &should_retry_fn { + Some(predicate) => predicate(&err), + None => default_should_retry(&err), + }; + + if should_retry_error && attempt < max_retries { + attempt += 1; + // Exponential backoff: 2^attempt seconds (2s, 4s, 8s, ...) with max cap + let delay = Duration::from_secs(2_u64.saturating_pow(attempt).min(1800)); // Cap at 30 minutes + + #[cfg(feature = "reqwest")] + sleep(delay).await; + + #[cfg(not(feature = "reqwest"))] + std::thread::sleep(delay); + + continue; + } + + return Err(err); + } + } + } +} + +/// Default retry predicate for clearly transient errors +/// +/// Retries on: +/// - 401 (Unauthorized - some APIs use this for rate limits) +/// - 429 (Too Many Requests) +/// - 502 (Bad Gateway) +/// - 503 (Service Unavailable) +/// - 504 (Gateway Timeout) +/// - "too many requests", "throttled", and "limited" messages +pub fn default_should_retry(error: &E) -> bool { + let error_str = error.to_string().to_lowercase(); + + error_str.contains("401") || // Unauthorized (rate limit on some APIs) + error_str.contains("429") || // Too Many Requests + error_str.contains("502") || // Bad Gateway + error_str.contains("503") || // Service Unavailable + error_str.contains("504") || // Gateway Timeout + error_str.contains("too many requests") || // Rate limiting messages + error_str.contains("throttled") || // Throttling messages + error_str.contains("request is limited") // CoinGecko rate limit message +} diff --git a/core/crates/gem_client/src/testkit.rs b/core/crates/gem_client/src/testkit.rs new file mode 100644 index 0000000000..1a152f06f0 --- /dev/null +++ b/core/crates/gem_client/src/testkit.rs @@ -0,0 +1,83 @@ +use crate::{Client, ClientError, Response, deserialize_response}; +use async_trait::async_trait; +use serde::{Serialize, de::DeserializeOwned}; +use std::{ + collections::HashMap, + fmt::{Debug, Formatter}, + sync::Arc, +}; + +type GetHandler = Arc Result, ClientError> + Send + Sync>; +type PostHandler = Arc) -> Result, ClientError> + Send + Sync>; + +#[derive(Clone, Default)] +pub struct MockClient { + get_handler: Option, + post_handler: Option, +} + +impl MockClient { + pub fn new() -> Self { + Self::default() + } + + pub fn with_get(mut self, handler: F) -> Self + where + F: Fn(&str) -> Result, ClientError> + Send + Sync + 'static, + { + self.get_handler = Some(Arc::new(handler)); + self + } + + pub fn with_post(mut self, handler: F) -> Self + where + F: Fn(&str, &[u8]) -> Result, ClientError> + Send + Sync + 'static, + { + self.post_handler = Some(Arc::new(move |path, body, _headers| handler(path, body))); + self + } + + pub fn with_post_with_headers(mut self, handler: F) -> Self + where + F: Fn(&str, &[u8], &HashMap) -> Result, ClientError> + Send + Sync + 'static, + { + self.post_handler = Some(Arc::new(handler)); + self + } +} + +impl Debug for MockClient { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MockClient").finish() + } +} + +#[async_trait] +impl Client for MockClient { + async fn get_with(&self, path: &str, _query: &[(String, String)], _headers: HashMap) -> Result + where + R: DeserializeOwned, + { + let handler = self.get_handler.as_ref().ok_or(ClientError::Http { status: 404, body: vec![] })?; + let data = handler(path)?; + deserialize_response(&Response { status: Some(200), data }) + } + + async fn get_url(&self, url: &str) -> Result + where + R: DeserializeOwned, + { + self.get_with(url, &[], HashMap::new()).await + } + + async fn post_with(&self, path: &str, body: &T, headers: HashMap) -> Result + where + T: Serialize + Send + Sync, + R: DeserializeOwned, + { + let handler = self.post_handler.as_ref().ok_or(ClientError::Http { status: 404, body: vec![] })?; + let body_bytes = serde_json::to_vec(body).map_err(|e| ClientError::Serialization(e.to_string()))?; + let data = handler(path, &body_bytes, &headers)?; + deserialize_response(&Response { status: Some(200), data }) + } +} diff --git a/core/crates/gem_client/src/types.rs b/core/crates/gem_client/src/types.rs new file mode 100644 index 0000000000..e6208e2fcb --- /dev/null +++ b/core/crates/gem_client/src/types.rs @@ -0,0 +1,89 @@ +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::fmt; + +#[derive(Debug, Clone)] +pub struct Response { + pub status: Option, + pub data: Vec, +} + +#[derive(Clone)] +pub enum ClientError { + Network(String), + Timeout, + Http { status: u16, body: Vec }, + Serialization(String), +} + +impl fmt::Debug for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Network(msg) => f.debug_tuple("Network").field(msg).finish(), + Self::Timeout => write!(f, "Timeout"), + Self::Http { status, body } => { + let body_str = String::from_utf8_lossy(&body[..body.len().min(256)]); + f.debug_struct("Http").field("status", status).field("body", &body_str).finish() + } + Self::Serialization(msg) => f.debug_tuple("Serialization").field(msg).finish(), + } + } +} + +pub fn decode_json_byte_array(values: Vec) -> Result, ClientError> { + let mut bytes = Vec::with_capacity(values.len()); + for value in values { + let byte = value + .as_u64() + .ok_or_else(|| ClientError::Serialization("Expected byte array for binary content-type".to_string()))?; + if byte > u8::MAX as u64 { + return Err(ClientError::Serialization("Binary body byte out of range".to_string())); + } + bytes.push(byte as u8); + } + Ok(bytes) +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Network(msg) => write!(f, "Network error: {}", msg), + Self::Timeout => write!(f, "Timeout error"), + Self::Http { status, .. } => write!(f, "HTTP error: status {}", status), + Self::Serialization(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for ClientError {} + +impl From for ClientError { + fn from(err: serde_json::Error) -> Self { + ClientError::Serialization(format!("JSON error: {err}")) + } +} + +pub fn deserialize_response(response: &Response) -> Result +where + R: DeserializeOwned, +{ + match serde_json::from_slice(&response.data) { + Ok(value) => Ok(value), + Err(error) => { + validate_http_status(response)?; + Err(ClientError::Serialization(error.to_string())) + } + } +} + +fn validate_http_status(response: &Response) -> Result<(), ClientError> { + if let Some(status) = response.status { + if !(200..400).contains(&status) { + return Err(ClientError::Http { + status, + body: response.data.clone(), + }); + } + } + Ok(()) +} diff --git a/core/crates/gem_cosmos/Cargo.toml b/core/crates/gem_cosmos/Cargo.toml new file mode 100644 index 0000000000..bdc0aee00f --- /dev/null +++ b/core/crates/gem_cosmos/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "gem_cosmos" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +bech32 = { workspace = true } +hex = { workspace = true } +gem_hash = { path = "../gem_hash" } +gem_encoding = { path = "../gem_encoding" } +primitives = { path = "../primitives" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_serializers = { path = "../serde_serializers" } + +# Optional RPC dependencies +chrono = { workspace = true, features = ["serde"], optional = true } +async-trait = { workspace = true, optional = true } +gem_client = { path = "../gem_client", optional = true } +chain_traits = { path = "../chain_traits", optional = true } +futures = { workspace = true, optional = true } +number_formatter = { path = "../number_formatter", optional = true } + +signer = { path = "../signer", optional = true } +k256 = { workspace = true, optional = true } + +num-bigint = { workspace = true } + +[features] +default = [] +rpc = [ + "dep:chrono", + "dep:async-trait", + "dep:gem_client", + "dep:chain_traits", + "dep:futures", + "dep:number_formatter", +] +signer = ["dep:signer", "dep:k256", "gem_encoding/protobuf"] +reqwest = ["gem_client/reqwest"] +unit_tests = ["signer"] +chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] + +[dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } +tokio = { workspace = true, features = ["macros", "rt"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_cosmos/src/address.rs b/core/crates/gem_cosmos/src/address.rs new file mode 100644 index 0000000000..7a2bbb5d77 --- /dev/null +++ b/core/crates/gem_cosmos/src/address.rs @@ -0,0 +1,86 @@ +use bech32::hrp::Hrp; +use primitives::chain_cosmos::CosmosChain; +use primitives::{Address as AddressTrait, Chain}; +use std::error::Error; + +pub struct CosmosAddress { + hrp: String, + bytes: Vec, +} + +impl CosmosAddress { + fn has_chain_hrp(address: &str, chain: Chain) -> bool { + let Some(cosmos_chain) = CosmosChain::from_chain(chain) else { + return false; + }; + Self::try_parse(address).is_some_and(|address| address.hrp == cosmos_chain.hrp()) + } + + pub fn is_valid_for_chain(address: &str, chain: Chain) -> bool { + Self::has_chain_hrp(address, chain) + } + + pub fn convert(address: &str, hrp: &str) -> Result> { + let (_, decoded) = bech32::decode(address)?; + let new_hrp = Hrp::parse(hrp)?; + let encoded = bech32::encode::(new_hrp, decoded.as_slice())?; + + Ok(encoded) + } +} + +impl AddressTrait for CosmosAddress { + fn try_parse(address: &str) -> Option { + let (hrp, bytes) = bech32::decode(address).ok()?; + let hrp = hrp.as_str().to_string(); + (CosmosChain::all().any(|chain| chain.hrp() == hrp) && bytes.len() == 20).then_some(Self { hrp, bytes }) + } + + fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + fn encode(&self) -> String { + let hrp = Hrp::parse(&self.hrp).expect("valid Cosmos address hrp"); + bech32::encode::(hrp, &self.bytes).expect("valid Cosmos address bytes") + } +} + +pub fn validate_address(address: &str, chain: Chain) -> bool { + CosmosAddress::is_valid_for_chain(address, chain) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cosmos_address_convert() { + let cosmos_address = "cosmos1h3laqcrmul79zwtw6j63ncsl0adfj07wgupylj"; + let expected = "osmosis1h3laqcrmul79zwtw6j63ncsl0adfj07wm8vf00"; + + let output_address = CosmosAddress::convert(cosmos_address, "osmosis").unwrap(); + assert_eq!(expected, output_address); + } + + #[test] + fn test_invalid_cosmos_address() { + // invalid checksum + let cosmos_address = "cosmos1h3laqcrmul79zwtw6j63ncsl0adfj07wgu"; + + let result = CosmosAddress::convert(cosmos_address, "osmosis"); + assert!(result.is_err()); + } + + #[test] + fn test_cosmos_address() { + let address = "cosmos1h3laqcrmul79zwtw6j63ncsl0adfj07wgupylj"; + let parsed = CosmosAddress::try_parse(address).unwrap(); + + assert!(validate_address(address, Chain::Cosmos)); + assert_eq!(parsed.as_bytes().len(), 20); + assert_eq!(parsed.encode(), address); + assert!(!validate_address(address, Chain::Osmosis)); + assert!(!validate_address("invalid", Chain::Cosmos)); + } +} diff --git a/core/crates/gem_cosmos/src/constants.rs b/core/crates/gem_cosmos/src/constants.rs new file mode 100644 index 0000000000..12f04e0da7 --- /dev/null +++ b/core/crates/gem_cosmos/src/constants.rs @@ -0,0 +1,34 @@ +use primitives::chain_cosmos::CosmosChain; + +pub const MESSAGE_DELEGATE: &str = "/cosmos.staking.v1beta1.MsgDelegate"; +pub const MESSAGE_UNDELEGATE: &str = "/cosmos.staking.v1beta1.MsgUndelegate"; +pub const MESSAGE_REDELEGATE: &str = "/cosmos.staking.v1beta1.MsgBeginRedelegate"; +pub const MESSAGE_SEND_BETA: &str = "/cosmos.bank.v1beta1.MsgSend"; +pub const MESSAGE_REWARD_BETA: &str = "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward"; +pub const MESSAGE_SEND: &str = "/types.MsgSend"; // thorchain +pub const MESSAGE_EXECUTE_CONTRACT: &str = "/cosmwasm.wasm.v1.MsgExecuteContract"; +pub const MESSAGE_IBC_TRANSFER: &str = "/ibc.applications.transfer.v1.MsgTransfer"; + +pub const SUPPORTED_MESSAGES: &[&str] = &[ + MESSAGE_SEND, + MESSAGE_SEND_BETA, + MESSAGE_DELEGATE, + MESSAGE_UNDELEGATE, + MESSAGE_REDELEGATE, + MESSAGE_REWARD_BETA, +]; + +pub const EVENTS_WITHDRAW_REWARDS_TYPE: &str = "withdraw_rewards"; +pub const EVENTS_ATTRIBUTE_AMOUNT: &str = "amount"; + +pub fn get_base_fee(chain: CosmosChain) -> u64 { + match chain { + CosmosChain::Thorchain => 2_000_000, + CosmosChain::Cosmos => 3_000, + CosmosChain::Osmosis => 10_000, + CosmosChain::Celestia => 3_000, + CosmosChain::Sei => 100_000, + CosmosChain::Injective => 100_000_000_000_000, + CosmosChain::Noble => 25_000, + } +} diff --git a/core/crates/gem_cosmos/src/lib.rs b/core/crates/gem_cosmos/src/lib.rs new file mode 100644 index 0000000000..0c6c414716 --- /dev/null +++ b/core/crates/gem_cosmos/src/lib.rs @@ -0,0 +1,15 @@ +pub mod address; +pub mod constants; + +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +#[cfg(feature = "signer")] +pub mod signer; + +pub mod models; + +pub use address::validate_address; diff --git a/core/crates/gem_cosmos/src/models/account.rs b/core/crates/gem_cosmos/src/models/account.rs new file mode 100644 index 0000000000..ccac15d723 --- /dev/null +++ b/core/crates/gem_cosmos/src/models/account.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + #[serde(deserialize_with = "serde_serializers::deserialize_u64_from_str")] + pub account_number: u64, + #[serde(deserialize_with = "serde_serializers::deserialize_u64_from_str")] + pub sequence: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountResponse { + pub account: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InjectiveAccount { + pub base_account: Account, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Balances { + pub balances: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Balance { + pub denom: String, + pub amount: String, +} diff --git a/core/crates/gem_cosmos/src/models/block.rs b/core/crates/gem_cosmos/src/models/block.rs new file mode 100644 index 0000000000..fc5e2c3996 --- /dev/null +++ b/core/crates/gem_cosmos/src/models/block.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockResponseLegacy { + pub block: BlockLegacy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockLegacy { + pub header: Header, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Header { + pub chain_id: String, + pub height: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeInfoResponse { + pub default_node_info: NodeInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeInfo { + pub network: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Syncing { + pub syncing: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockResponse { + pub block: Block, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + pub header: BlockHeader, + pub data: BlockData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockHeader { + pub height: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockData { + pub txs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InflationResponse { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub inflation: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnnualProvisionsResponse { + pub annual_provisions: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupplyResponse { + pub amount: SupplyAmount, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupplyAmount { + pub denom: String, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub amount: f64, +} diff --git a/core/crates/gem_cosmos/src/models/contract.rs b/core/crates/gem_cosmos/src/models/contract.rs new file mode 100644 index 0000000000..5124fb2d91 --- /dev/null +++ b/core/crates/gem_cosmos/src/models/contract.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +use super::Coin; + +#[derive(Debug, Deserialize)] +pub struct ExecuteContractValue { + pub sender: String, + pub contract: String, + pub msg: String, + pub funds: Vec, +} diff --git a/core/crates/gem_cosmos/src/models/ibc.rs b/core/crates/gem_cosmos/src/models/ibc.rs new file mode 100644 index 0000000000..5c1f301033 --- /dev/null +++ b/core/crates/gem_cosmos/src/models/ibc.rs @@ -0,0 +1,16 @@ +use super::Coin; +use super::long::deserialize_u64_from_long_or_int; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IbcTransferValue { + pub source_port: String, + pub source_channel: String, + pub token: Coin, + pub sender: String, + pub receiver: String, + #[serde(deserialize_with = "deserialize_u64_from_long_or_int")] + pub timeout_timestamp: u64, + pub memo: String, +} diff --git a/core/crates/gem_cosmos/src/models/long.rs b/core/crates/gem_cosmos/src/models/long.rs new file mode 100644 index 0000000000..5cb3db87a2 --- /dev/null +++ b/core/crates/gem_cosmos/src/models/long.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Deserializer}; + +#[derive(Debug, Deserialize)] +pub struct Long { + pub low: i32, + pub high: i32, +} + +impl Long { + pub fn to_uint64(&self) -> u64 { + ((self.high as u32 as u64) << 32) | (self.low as u32 as u64) + } +} + +pub fn deserialize_u64_from_long_or_int<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum LongOrValue { + Number(u64), + Str(String), + Long(Long), + } + + match LongOrValue::deserialize(deserializer)? { + LongOrValue::Number(n) => Ok(n), + LongOrValue::Str(s) => s.parse::().map_err(serde::de::Error::custom), + LongOrValue::Long(l) => Ok(l.to_uint64()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_uint64() { + let l = Long { low: -72998656, high: 412955876 }; + assert_eq!(l.to_uint64(), 1773631986332999936); + } + + #[test] + fn test_to_uint64_small() { + let l = Long { low: 1, high: 0 }; + assert_eq!(l.to_uint64(), 1); + } + + #[derive(Deserialize)] + struct TestTimestamp { + #[serde(deserialize_with = "deserialize_u64_from_long_or_int")] + ts: u64, + } + + #[test] + fn test_deserialize_number() { + let v: TestTimestamp = serde_json::from_str(r#"{"ts": 1773382733549000000}"#).unwrap(); + assert_eq!(v.ts, 1773382733549000000); + } + + #[test] + fn test_deserialize_string() { + let v: TestTimestamp = serde_json::from_str(r#"{"ts": "1773382733549000000"}"#).unwrap(); + assert_eq!(v.ts, 1773382733549000000); + } + + #[test] + fn test_deserialize_long() { + let v: TestTimestamp = serde_json::from_str(r#"{"ts": {"low": -72998656, "high": 412955876, "unsigned": false}}"#).unwrap(); + assert_eq!(v.ts, 1773631986332999936); + } +} diff --git a/core/crates/gem_cosmos/src/models/message.rs b/core/crates/gem_cosmos/src/models/message.rs new file mode 100644 index 0000000000..fae0ca3abe --- /dev/null +++ b/core/crates/gem_cosmos/src/models/message.rs @@ -0,0 +1,288 @@ +use std::str::FromStr; + +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "signer")] +use super::{ExecuteContractValue, IbcTransferValue}; +use crate::constants; +#[cfg(feature = "signer")] +use crate::constants::{ + MESSAGE_DELEGATE, MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER, MESSAGE_REDELEGATE, MESSAGE_REWARD_BETA, MESSAGE_SEND, MESSAGE_SEND_BETA, MESSAGE_UNDELEGATE, +}; +#[cfg(feature = "signer")] +use primitives::SignerError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Message { + #[serde(rename = "/cosmos.bank.v1beta1.MsgSend", alias = "/types.MsgSend")] + MsgSend(MsgSend), + #[serde(rename = "/cosmos.staking.v1beta1.MsgUndelegate")] + MsgUndelegate(MsgUndelegate), + #[serde(rename = "/cosmos.staking.v1beta1.MsgBeginRedelegate")] + MsgBeginRedelegate(MsgBeginRedelegate), + #[serde(rename = "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward")] + MsgWithdrawDelegatorReward(MsgWithdrawDelegatorReward), + #[serde(rename = "/cosmos.staking.v1beta1.MsgDelegate")] + MsgDelegate(MsgDelegate), + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MsgSend { + pub from_address: String, + pub to_address: String, + pub amount: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthInfo { + pub fee: Fee, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Coin { + pub denom: String, + pub amount: String, +} + +impl Coin { + pub fn get_amount(&self) -> Option { + BigInt::from_str(&self.amount).ok() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Fee { + pub amount: Vec, + pub gas_limit: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MsgDelegate { + pub delegator_address: String, + pub validator_address: String, + pub amount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MsgUndelegate { + pub delegator_address: String, + pub validator_address: String, + pub amount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MsgBeginRedelegate { + pub delegator_address: String, + pub validator_src_address: String, + pub validator_dst_address: String, + pub amount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MsgWithdrawDelegatorReward { + pub delegator_address: String, + pub validator_address: String, +} + +impl Message { + pub fn supported_types() -> &'static [&'static str] { + constants::SUPPORTED_MESSAGES + } +} + +impl MsgSend { + pub fn get_amount(&self, denom: &str) -> Option { + Some(self.amount.iter().filter(|c| c.denom == denom).flat_map(Coin::get_amount).sum()) + } +} + +#[cfg(feature = "signer")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageEnvelope { + pub type_url: String, + pub value: serde_json::Value, +} + +#[cfg(feature = "signer")] +pub enum CosmosMessage { + Send { + from_address: String, + to_address: String, + amount: Vec, + }, + ExecuteContract { + sender: String, + contract: String, + msg: Vec, + funds: Vec, + }, + IbcTransfer { + source_port: String, + source_channel: String, + token: Coin, + sender: String, + receiver: String, + timeout_timestamp: u64, + memo: String, + }, + Delegate { + delegator_address: String, + validator_address: String, + amount: Coin, + }, + Undelegate { + delegator_address: String, + validator_address: String, + amount: Coin, + }, + BeginRedelegate { + delegator_address: String, + validator_src_address: String, + validator_dst_address: String, + amount: Coin, + }, + WithdrawDelegatorReward { + delegator_address: String, + validator_address: String, + }, +} + +pub fn send_msg_json(from: &str, to: &str, denom: &str, amount: &str) -> serde_json::Value { + serde_json::json!({ + "typeUrl": constants::MESSAGE_SEND_BETA, + "value": { + "from_address": from, + "to_address": to, + "amount": [{"denom": denom, "amount": amount}] + } + }) +} + +#[cfg(feature = "signer")] +impl CosmosMessage { + pub fn parse_array(data: &str) -> Result, SignerError> { + let arr: Vec = serde_json::from_str(data)?; + arr.iter().map(|v| Self::parse(&v.to_string())).collect() + } + + pub fn parse(data: &str) -> Result { + let envelope: MessageEnvelope = serde_json::from_str(data)?; + + match envelope.type_url.as_str() { + MESSAGE_SEND_BETA | MESSAGE_SEND => { + let v: MsgSend = serde_json::from_value(envelope.value)?; + Ok(Self::Send { + from_address: v.from_address, + to_address: v.to_address, + amount: v.amount, + }) + } + MESSAGE_EXECUTE_CONTRACT => { + let v: ExecuteContractValue = serde_json::from_value(envelope.value)?; + Ok(Self::ExecuteContract { + sender: v.sender, + contract: v.contract, + msg: v.msg.into_bytes(), + funds: v.funds, + }) + } + MESSAGE_IBC_TRANSFER => { + let v: IbcTransferValue = serde_json::from_value(envelope.value)?; + Ok(Self::IbcTransfer { + source_port: v.source_port, + source_channel: v.source_channel, + token: v.token, + sender: v.sender, + receiver: v.receiver, + timeout_timestamp: v.timeout_timestamp, + memo: v.memo, + }) + } + MESSAGE_DELEGATE => { + let v: MsgDelegate = serde_json::from_value(envelope.value)?; + let amount = v.amount.ok_or_else(|| SignerError::invalid_input("missing delegate amount"))?; + Ok(Self::Delegate { + delegator_address: v.delegator_address, + validator_address: v.validator_address, + amount, + }) + } + MESSAGE_UNDELEGATE => { + let v: MsgUndelegate = serde_json::from_value(envelope.value)?; + let amount = v.amount.ok_or_else(|| SignerError::invalid_input("missing undelegate amount"))?; + Ok(Self::Undelegate { + delegator_address: v.delegator_address, + validator_address: v.validator_address, + amount, + }) + } + MESSAGE_REDELEGATE => { + let v: MsgBeginRedelegate = serde_json::from_value(envelope.value)?; + let amount = v.amount.ok_or_else(|| SignerError::invalid_input("missing redelegate amount"))?; + Ok(Self::BeginRedelegate { + delegator_address: v.delegator_address, + validator_src_address: v.validator_src_address, + validator_dst_address: v.validator_dst_address, + amount, + }) + } + MESSAGE_REWARD_BETA => { + let v: MsgWithdrawDelegatorReward = serde_json::from_value(envelope.value)?; + Ok(Self::WithdrawDelegatorReward { + delegator_address: v.delegator_address, + validator_address: v.validator_address, + }) + } + other => SignerError::invalid_input_err(format!("unsupported cosmos message type: {other}")), + } + } +} + +#[cfg(all(test, feature = "signer"))] +mod tests { + use super::*; + + #[test] + fn test_parse_execute_contract() { + let msg = CosmosMessage::parse(include_str!("../../testdata/swap_execute_contract.json")).unwrap(); + match msg { + CosmosMessage::ExecuteContract { sender, contract, funds, .. } => { + assert_eq!(sender, "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"); + assert_eq!(contract, "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"); + assert_eq!(funds.len(), 1); + assert_eq!(funds[0].denom, "uosmo"); + assert_eq!(funds[0].amount, "10000000"); + } + _ => panic!("expected ExecuteContract"), + } + } + + #[test] + fn test_parse_ibc_transfer() { + let msg = CosmosMessage::parse(include_str!("../../testdata/swap_ibc_transfer.json")).unwrap(); + match msg { + CosmosMessage::IbcTransfer { + source_port, + source_channel, + sender, + receiver, + timeout_timestamp, + memo, + .. + } => { + assert_eq!(source_port, "transfer"); + assert_eq!(source_channel, "channel-141"); + assert_eq!(sender, "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"); + assert_eq!(receiver, "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"); + assert_eq!(timeout_timestamp, 1773632858715000064); + assert!(!memo.is_empty()); + } + _ => panic!("expected IbcTransfer"), + } + } +} diff --git a/core/crates/gem_cosmos/src/models/mod.rs b/core/crates/gem_cosmos/src/models/mod.rs new file mode 100644 index 0000000000..6862a233e5 --- /dev/null +++ b/core/crates/gem_cosmos/src/models/mod.rs @@ -0,0 +1,24 @@ +pub mod account; +pub mod block; +pub mod long; +pub mod message; +pub mod staking; +pub mod staking_osmosis; +pub mod transaction; + +#[cfg(feature = "signer")] +pub mod contract; +#[cfg(feature = "signer")] +pub mod ibc; +pub use account::*; +pub use block::*; +pub use long::*; +pub use message::*; +pub use staking::*; +pub use staking_osmosis::*; +pub use transaction::*; + +#[cfg(feature = "signer")] +pub use contract::*; +#[cfg(feature = "signer")] +pub use ibc::*; diff --git a/core/crates/gem_cosmos/src/models/staking.rs b/core/crates/gem_cosmos/src/models/staking.rs new file mode 100644 index 0000000000..67af487bd2 --- /dev/null +++ b/core/crates/gem_cosmos/src/models/staking.rs @@ -0,0 +1,123 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_f64_from_str; + +use super::account::Balance; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Delegations { + pub delegation_responses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Delegation { + pub delegation: DelegationData, + pub balance: Balance, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationData { + pub validator_address: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnbondingDelegations { + pub unbonding_responses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnbondingDelegation { + pub validator_address: String, + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnbondingDelegationEntry { + pub completion_time: String, + pub creation_height: String, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub balance: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rewards { + pub rewards: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reward { + pub validator_address: String, + pub reward: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Validators { + pub validators: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorLegacy { + pub operator_address: String, + pub jailed: bool, + pub status: String, + pub description: ValidatorMoniker, + pub commission: ValidatorCommissionLegacy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorMoniker { + pub moniker: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorCommissionLegacy { + pub commission_rates: ValidatorCommissionRatesLegacy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorCommissionRatesLegacy { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorsResponse { + pub validators: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Validator { + pub operator_address: String, + pub jailed: bool, + pub status: String, + pub description: ValidatorDescription, + pub commission: ValidatorCommission, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorDescription { + pub moniker: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorCommission { + pub commission_rates: ValidatorCommissionRates, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorCommissionRates { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StakingPoolResponse { + pub pool: StakingPool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StakingPool { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub bonded_tokens: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub not_bonded_tokens: f64, +} diff --git a/core/crates/gem_cosmos/src/models/staking_osmosis.rs b/core/crates/gem_cosmos/src/models/staking_osmosis.rs new file mode 100644 index 0000000000..c39f815ed5 --- /dev/null +++ b/core/crates/gem_cosmos/src/models/staking_osmosis.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OsmosisMintParamsResponse { + pub params: OsmosisMintParams, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OsmosisMintParams { + pub epoch_identifier: String, + pub distribution_proportions: OsmosisDistributionProportions, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OsmosisDistributionProportions { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub staking: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OsmosisEpochProvisionsResponse { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub epoch_provisions: f64, +} diff --git a/core/crates/gem_cosmos/src/models/transaction.rs b/core/crates/gem_cosmos/src/models/transaction.rs new file mode 100644 index 0000000000..1f0f7ad3ca --- /dev/null +++ b/core/crates/gem_cosmos/src/models/transaction.rs @@ -0,0 +1,127 @@ +use gem_encoding::decode_base64; +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; +use std::str; +use std::str::FromStr; + +use super::message::{AuthInfo, Message}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BroadcastRequest { + pub mode: String, + pub tx_bytes: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BroadcastResponse { + pub tx_response: Option, + pub code: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionResult { + pub txhash: String, + pub code: i32, + pub raw_log: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionResponseLegacy { + pub tx_response: TransactionResult, +} + +#[derive(Debug, Clone)] +pub struct TransactionDecode { + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionResponse { + pub tx: TransactionResponseTx, + pub tx_response: TransactionResponseData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionsResponse { + pub txs: Vec, + pub tx_responses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionResponseTx { + pub body: TransactionBody, + pub auth_info: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionBody { + pub memo: String, + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionResponseData { + pub code: i64, + pub txhash: String, + pub events: Vec, + pub timestamp: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionEvent { + #[serde(rename = "type")] + pub event_type: String, + pub attributes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionEventAtribute { + pub key: String, + pub value: Option, +} + +impl TransactionResponse { + pub fn get_rewards_value(&self, denom: &str) -> Option { + let attributes = self + .tx_response + .events + .clone() + .into_iter() + .filter(|x| x.event_type == crate::constants::EVENTS_WITHDRAW_REWARDS_TYPE) + .flat_map(|x| x.attributes) + .collect::>(); + + //base64 decoding added for sei/celestia. This is a temporary solution until the issue is resolved in the cosmos-sdk + let value = attributes + .into_iter() + .filter(|x| { + if let Ok(value) = decode_base64(&x.key) { + str::from_utf8(&value).unwrap() == crate::constants::EVENTS_ATTRIBUTE_AMOUNT + } else { + x.key == crate::constants::EVENTS_ATTRIBUTE_AMOUNT + } + }) + .map(|x| { + let value = x.value.unwrap_or_default(); + let decoded_value; + let str_value = if let Ok(decoded) = decode_base64(&value) { + decoded_value = decoded; + str::from_utf8(&decoded_value).unwrap_or_default() + } else { + &value + }; + str_value + .split(',') + .filter(|x| x.contains(denom)) + .collect::>() + .first() + .unwrap_or(&"0") + .to_string() + .replace(denom, "") + }) + .flat_map(|x| BigInt::from_str(&x).ok()) + .sum(); + Some(value) + } +} diff --git a/core/crates/gem_cosmos/src/provider/balances.rs b/core/crates/gem_cosmos/src/provider/balances.rs new file mode 100644 index 0000000000..383d95f86d --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/balances.rs @@ -0,0 +1,105 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use futures::try_join; +use num_bigint::BigUint; +use std::error::Error; + +use gem_client::Client; +use primitives::{AssetBalance, AssetId}; + +use crate::{provider::balances_mapper, rpc::client::CosmosClient}; + +#[async_trait] +impl ChainBalances for CosmosClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let balances = self.get_balances(&address).await?; + let chain = self.get_chain().as_chain(); + let denom = chain.as_denom().ok_or("Chain does not have a denom")?; + + if let Some(balance) = balances.balances.iter().find(|balance| balance.denom == denom) { + Ok(AssetBalance::new(chain.as_asset_id(), balance.amount.parse::().unwrap_or_default())) + } else { + Ok(AssetBalance::new_zero_balance(chain.as_asset_id())) + } + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let balances = self.get_balances(&address).await?; + let token_balances = token_ids + .iter() + .filter_map(|token_id| { + balances.balances.iter().find(|balance| balance.denom == *token_id).map(|balance| { + let asset_id = AssetId { + chain: self.get_chain().as_chain(), + token_id: Some(token_id.clone()), + }; + AssetBalance::new(asset_id, balance.amount.parse::().unwrap_or_default()) + }) + }) + .collect(); + + Ok(token_balances) + } + + async fn get_balance_staking(&self, address: String) -> Result, Box> { + let cosmos_chain = self.get_chain(); + let chain = cosmos_chain.as_chain(); + if !chain.is_stake_supported() { + return Ok(None); + } + let denom = chain.as_denom().ok_or("Chain does not have a denom")?; + + let (delegations, unbonding, rewards) = try_join!( + self.get_delegations(&address), + self.get_unbonding_delegations(&address), + self.get_delegation_rewards(&address) + )?; + + Ok(Some(balances_mapper::map_balance_staking(delegations, unbonding, rewards, chain, denom))) + } + + async fn get_balance_assets(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{TEST_ADDRESS, TEST_EMPTY_ADDRESS, create_cosmos_test_client}; + use chain_traits::ChainBalances; + use num_bigint::BigUint; + + #[tokio::test] + async fn test_cosmos_get_balance_coin() -> Result<(), Box> { + let client = create_cosmos_test_client(); + let address = TEST_ADDRESS.to_string(); + let balance = client.get_balance_coin(address).await?; + + println!("Balance: {:?} {}", balance.balance.available, balance.asset_id); + + assert!(balance.balance.available > BigUint::from(0u64)); + Ok(()) + } + + #[tokio::test] + async fn test_cosmos_get_balance_coin_empty_address() -> Result<(), Box> { + let client = create_cosmos_test_client(); + let address = TEST_EMPTY_ADDRESS.to_string(); + let balance = client.get_balance_coin(address).await?; + + println!("Balance: {:?} {}", balance.balance.available, balance.asset_id); + + assert!(balance.balance.available == BigUint::from(0u64)); + Ok(()) + } + + #[tokio::test] + async fn test_cosmos_get_balance_assets() -> Result<(), Box> { + let client = create_cosmos_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + assert_eq!(assets.len(), 0); + Ok(()) + } +} diff --git a/core/crates/gem_cosmos/src/provider/balances_mapper.rs b/core/crates/gem_cosmos/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..55f176ecaf --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/balances_mapper.rs @@ -0,0 +1,62 @@ +use crate::models::staking::{Delegations, Rewards, UnbondingDelegations}; +use num_bigint::{BigInt, BigUint}; +use number_formatter::BigNumberFormatter; +use primitives::AssetBalance; +use std::str::FromStr; + +pub fn map_balance_staking(delegations: Delegations, unbonding: UnbondingDelegations, rewards: Rewards, chain: primitives::Chain, denom: &str) -> AssetBalance { + let staked = delegations + .delegation_responses + .iter() + .filter(|d| d.balance.denom == denom) + .filter_map(|d| BigNumberFormatter::value_from_amount(&d.balance.amount, 0).ok()) + .filter_map(|v| BigInt::from_str(&v).ok()) + .fold(BigInt::from(0), |acc, amount| acc + amount); + + let pending = unbonding + .unbonding_responses + .iter() + .flat_map(|u| &u.entries) + .filter_map(|entry| BigNumberFormatter::value_from_amount(&entry.balance.to_string(), 0).ok()) + .filter_map(|v| BigInt::from_str(&v).ok()) + .fold(BigInt::from(0), |acc, amount| acc + amount); + + let rewards = rewards + .rewards + .iter() + .flat_map(|r| &r.reward) + .filter(|r| r.denom == denom) + .filter_map(|r| { + let integer_part = r.amount.split('.').next().unwrap_or("0"); + BigInt::from_str(integer_part).ok() + }) + .fold(BigInt::from(0), |acc, amount| acc + amount); + + AssetBalance::new_staking( + chain.as_asset_id(), + BigUint::try_from(staked).unwrap_or_default(), + BigUint::try_from(pending).unwrap_or_default(), + BigUint::try_from(rewards).unwrap_or_default(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::staking::{Delegations, Rewards, UnbondingDelegations}; + use primitives::Chain; + + #[test] + fn test_map_balance_staking() { + let delegations: Delegations = serde_json::from_str(include_str!("../../testdata/staking_delegations.json")).unwrap(); + let unbonding: UnbondingDelegations = serde_json::from_str(r#"{"unbonding_responses": []}"#).unwrap(); + let rewards: Rewards = serde_json::from_str(include_str!("../../testdata/staking_rewards.json")).unwrap(); + + let result = map_balance_staking(delegations, unbonding, rewards, Chain::Cosmos, "uatom"); + + assert_eq!(result.asset_id.to_string(), "cosmos"); + assert_eq!(result.balance.staked, BigUint::from(10250000_u64)); + assert_eq!(result.balance.pending, BigUint::from(0u32)); + assert_eq!(result.balance.rewards, BigUint::from(307413_u64)); + } +} diff --git a/core/crates/gem_cosmos/src/provider/mod.rs b/core/crates/gem_cosmos/src/provider/mod.rs new file mode 100644 index 0000000000..5fda18ce4d --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/mod.rs @@ -0,0 +1,19 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod staking; +pub mod staking_mapper; +pub mod state; +pub mod state_mapper; +pub mod testkit; +pub mod token; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; diff --git a/core/crates/gem_cosmos/src/provider/preload.rs b/core/crates/gem_cosmos/src/provider/preload.rs new file mode 100644 index 0000000000..d5eb0b7008 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/preload.rs @@ -0,0 +1,44 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use std::error::Error; + +use gem_client::Client; +use primitives::{FeeRate, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput}; + +use crate::{ + provider::{preload_mapper::calculate_transaction_fee, state_mapper::calculate_fee_rates}, + rpc::client::CosmosClient, +}; + +#[async_trait] +impl ChainTransactionLoad for CosmosClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let account = self.get_account_info(&input.sender_address).await?; + Ok(TransactionLoadMetadata::Cosmos { + account_number: account.account_number, + sequence: account.sequence, + chain_id: self.get_chain().as_chain().network_id().to_string(), + }) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let account = self.get_account_info(&input.sender_address).await?; + let fee = calculate_transaction_fee(&input.input_type, self.get_chain(), &input.gas_price); + + Ok(TransactionLoadData { + fee, + metadata: TransactionLoadMetadata::Cosmos { + account_number: account.account_number, + sequence: account.sequence, + chain_id: self.get_chain().as_chain().network_id().to_string(), + }, + }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + let base_fee = self.get_base_fee(); + let cosmos_chain = self.get_chain(); + + Ok(calculate_fee_rates(cosmos_chain, base_fee.into())) + } +} diff --git a/core/crates/gem_cosmos/src/provider/preload_mapper.rs b/core/crates/gem_cosmos/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..4341217274 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/preload_mapper.rs @@ -0,0 +1,105 @@ +use num_bigint::BigInt; +use primitives::{GasPriceType, StakeType, SwapProvider, TransactionFee, TransactionInputType, chain_cosmos::CosmosChain}; + +fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { + match chain { + CosmosChain::Thorchain => BigInt::from(2_000_000u64), + CosmosChain::Cosmos => match input_type { + TransactionInputType::Transfer(_) + | TransactionInputType::Deposit(_) + | TransactionInputType::TransferNft(_, _) + | TransactionInputType::Account(_, _) + | TransactionInputType::TokenApprove(_, _) + | TransactionInputType::Generic(_, _, _) + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Earn(_, _, _) => BigInt::from(3_000u64), + TransactionInputType::Swap(_, _, _) => BigInt::from(3_000u64), + TransactionInputType::Stake(_, _) => BigInt::from(25_000u64), + }, + CosmosChain::Osmosis => match input_type { + TransactionInputType::Transfer(_) + | TransactionInputType::Deposit(_) + | TransactionInputType::TransferNft(_, _) + | TransactionInputType::Account(_, _) + | TransactionInputType::TokenApprove(_, _) + | TransactionInputType::Generic(_, _, _) + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Earn(_, _, _) => BigInt::from(10_000u64), + TransactionInputType::Swap(_, _, _) => BigInt::from(10_000u64), + TransactionInputType::Stake(_, _) => BigInt::from(100_000u64), + }, + CosmosChain::Celestia => match input_type { + TransactionInputType::Transfer(_) + | TransactionInputType::Deposit(_) + | TransactionInputType::TransferNft(_, _) + | TransactionInputType::Account(_, _) + | TransactionInputType::TokenApprove(_, _) + | TransactionInputType::Generic(_, _, _) + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Earn(_, _, _) => BigInt::from(3_000u64), + TransactionInputType::Swap(_, _, _) => BigInt::from(3_000u64), + TransactionInputType::Stake(_, _) => BigInt::from(10_000u64), + }, + CosmosChain::Sei => match input_type { + TransactionInputType::Transfer(_) + | TransactionInputType::Deposit(_) + | TransactionInputType::TransferNft(_, _) + | TransactionInputType::Account(_, _) + | TransactionInputType::TokenApprove(_, _) + | TransactionInputType::Generic(_, _, _) + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Earn(_, _, _) => BigInt::from(100_000u64), + TransactionInputType::Swap(_, _, _) => BigInt::from(100_000u64), + TransactionInputType::Stake(_, _) => BigInt::from(200_000u64), + }, + CosmosChain::Injective => match input_type { + TransactionInputType::Transfer(_) + | TransactionInputType::Deposit(_) + | TransactionInputType::TransferNft(_, _) + | TransactionInputType::Account(_, _) + | TransactionInputType::TokenApprove(_, _) + | TransactionInputType::Generic(_, _, _) + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Earn(_, _, _) => BigInt::from(100_000_000_000_000u64), + TransactionInputType::Swap(_, _, _) => BigInt::from(100_000_000_000_000u64), + TransactionInputType::Stake(_, _) => BigInt::from(1_000_000_000_000_000u64), + }, + CosmosChain::Noble => BigInt::from(25_000u64), + } +} + +fn get_gas_limit(input_type: &TransactionInputType, _chain: CosmosChain) -> u64 { + match input_type { + TransactionInputType::Transfer(_) + | TransactionInputType::Deposit(_) + | TransactionInputType::TransferNft(_, _) + | TransactionInputType::Account(_, _) + | TransactionInputType::TokenApprove(_, _) + | TransactionInputType::Generic(_, _, _) + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Earn(_, _, _) => 200_000, + TransactionInputType::Swap(_, _, swap_data) => match swap_data.quote.provider_data.provider { + SwapProvider::Thorchain => 200_000, + _ => 2_000_000, + }, + TransactionInputType::Stake(_, operation) => match operation { + StakeType::Stake(_) | StakeType::Unstake(_) => 1_000_000, + StakeType::Redelegate(_) => 1_250_000, + StakeType::Rewards(_) => 750_000, + StakeType::Withdraw(_) => 750_000, + StakeType::Freeze(_) | StakeType::Unfreeze(_) => panic!("Freeze operations not supported on Cosmos chains"), + }, + } +} + +pub fn calculate_transaction_fee(input_type: &TransactionInputType, chain: CosmosChain, gas_price_type: &GasPriceType) -> TransactionFee { + let gas_limit = get_gas_limit(input_type, chain); + let fee = get_fee(chain, input_type); + + TransactionFee { + fee, + gas_price_type: gas_price_type.clone(), + gas_limit: BigInt::from(gas_limit), + options: std::collections::HashMap::new(), + } +} diff --git a/core/crates/gem_cosmos/src/provider/request_classifier.rs b/core/crates/gem_cosmos/src/provider/request_classifier.rs new file mode 100644 index 0000000000..d1d7d72ade --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/cosmos/tx/v1beta1/txs") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_cosmos/src/provider/staking.rs b/core/crates/gem_cosmos/src/provider/staking.rs new file mode 100644 index 0000000000..6aea7b6a31 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/staking.rs @@ -0,0 +1,133 @@ +use async_trait::async_trait; +use chain_traits::ChainStaking; +use futures::try_join; +use std::collections::HashMap; +use std::error::Error; + +use gem_client::Client; +use primitives::{DelegationBase, DelegationValidator, chain_cosmos::CosmosChain}; + +use crate::{ + provider::staking_mapper::{calculate_network_apy_cosmos, calculate_network_apy_osmosis, map_staking_delegations, map_staking_validators}, + rpc::client::CosmosClient, +}; + +#[async_trait] +impl ChainStaking for CosmosClient { + async fn get_staking_apy(&self) -> Result, Box> { + let chain = self.get_chain(); + match chain { + CosmosChain::Noble | CosmosChain::Thorchain => Ok(None), + CosmosChain::Cosmos | CosmosChain::Injective => { + let denom = chain.denom(); + let (inflation, supply, staking_pool) = try_join!(self.get_inflation(), self.get_supply_by_denom(denom.as_ref()), self.get_staking_pool())?; + Ok(calculate_network_apy_cosmos(inflation, supply, staking_pool)) + } + CosmosChain::Osmosis => { + let (mint_params, epoch_provisions, staking_pool) = try_join!(self.get_osmosis_mint_params(), self.get_osmosis_epoch_provisions(), self.get_staking_pool())?; + + Ok(calculate_network_apy_osmosis(mint_params, epoch_provisions, staking_pool)) + } + CosmosChain::Celestia => Ok(Some(10.55)), + CosmosChain::Sei => Ok(Some(5.62)), + } + } + + async fn get_staking_validators(&self, apy: Option) -> Result, Box> { + let chain = self.get_chain(); + match chain { + CosmosChain::Noble | CosmosChain::Thorchain => Ok(vec![]), + CosmosChain::Cosmos | CosmosChain::Injective | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Sei => { + let validators = self.get_validators().await?; + Ok(map_staking_validators(validators.validators, chain, apy)) + } + } + } + + async fn get_staking_delegations(&self, address: String) -> Result, Box> { + let chain = self.get_chain().as_chain(); + let denom = chain.as_denom().unwrap_or_default(); + let chain = self.get_chain(); + match chain { + CosmosChain::Noble | CosmosChain::Thorchain => Ok(vec![]), + CosmosChain::Cosmos | CosmosChain::Injective | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Sei => { + let (active_delegations, unbonding, rewards, validators, delegation_validators) = try_join!( + self.get_delegations(&address), + self.get_unbonding_delegations(&address), + self.get_delegation_rewards(&address), + self.get_validators(), + self.get_delegations_validators(&address), + )?; + + let all_validators: Vec<_> = delegation_validators + .validators + .into_iter() + .chain(validators.validators) + .map(|v| (v.operator_address.clone(), v)) + .collect::>() + .into_values() + .collect(); + + Ok(map_staking_delegations(active_delegations, unbonding, rewards, all_validators, chain, denom)) + } + } + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{create_cosmos_test_client, create_osmosis_test_client}; + use chain_traits::ChainStaking; + + #[tokio::test] + async fn test_get_osmosis_staking_apy() -> Result<(), Box> { + let client = create_osmosis_test_client(); + let apy = client.get_staking_apy().await?; + + assert!(apy.is_some()); + let apy_value = apy.unwrap(); + + assert!(apy_value > 1.0 && apy_value < 2.0, "APY should be between 1% and 2%, got: {}", apy_value); + assert_ne!(apy_value, 14.0); + + println!("Osmosis staking APY: {}%", apy_value); + + Ok(()) + } + + #[tokio::test] + async fn test_get_cosmos_staking_apy() -> Result<(), Box> { + let client = create_cosmos_test_client(); + let apy = client.get_staking_apy().await?; + + assert!(apy.is_some()); + let apy_value = apy.unwrap(); + + assert!(apy_value > 5.0 && apy_value < 25.0); + + println!("Cosmos staking APY: {}%", apy_value); + + Ok(()) + } + + #[tokio::test] + async fn test_get_cosmos_staking_validators() -> Result<(), Box> { + let client = create_cosmos_test_client(); + let apy = client.get_staking_apy().await?; + let validators = client.get_staking_validators(apy).await?; + + assert!(!validators.is_empty()); + assert!(validators.len() <= 200); + + for validator in validators.iter().take(5) { + assert!(!validator.id.is_empty()); + assert!(!validator.name.is_empty()); + assert!(validator.commission >= 0.0 && validator.commission <= 100.0); + if validator.is_active { + assert!(validator.apr >= 0.0); + } + } + + Ok(()) + } +} diff --git a/core/crates/gem_cosmos/src/provider/staking_mapper.rs b/core/crates/gem_cosmos/src/provider/staking_mapper.rs new file mode 100644 index 0000000000..9311430b54 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/staking_mapper.rs @@ -0,0 +1,297 @@ +use crate::models::staking::{Delegations, Rewards, StakingPoolResponse, UnbondingDelegations, Validator}; +use crate::models::{InflationResponse, OsmosisEpochProvisionsResponse, OsmosisMintParamsResponse, SupplyResponse}; +use num_bigint::BigUint; +use std::str::FromStr; + +#[cfg(test)] +use crate::models::staking::{StakingPool, ValidatorCommission, ValidatorCommissionRates, ValidatorDescription, ValidatorsResponse}; + +#[cfg(test)] +use crate::models::{OsmosisDistributionProportions, OsmosisMintParams, SupplyAmount}; + +use number_formatter::BigNumberFormatter; +use primitives::chain_cosmos::CosmosChain; +use primitives::{DelegationBase, DelegationState, DelegationValidator}; +use std::collections::HashMap; + +const BOND_STATUS_BONDED: &str = "BOND_STATUS_BONDED"; + +/// Convert string amounts to BigUint, handling parsing errors gracefully +fn parse_to_biguint(value: &str) -> BigUint { + BigUint::from_str(value).unwrap_or_default() +} + +pub fn calculate_network_apy_cosmos(inflation: InflationResponse, supply: SupplyResponse, staking_pool: StakingPoolResponse) -> Option { + let bonded_tokens = staking_pool.pool.bonded_tokens; + + if bonded_tokens == 0.0 { + return Some(0.0); + } + + let network_apy = inflation.inflation * (supply.amount.amount / bonded_tokens); + Some(network_apy * 100.0) +} + +pub fn calculate_network_apy_osmosis(mint_params: OsmosisMintParamsResponse, epoch_provisions: OsmosisEpochProvisionsResponse, staking_pool: StakingPoolResponse) -> Option { + let epoch_provisions = epoch_provisions.epoch_provisions; + let staking_distribution = mint_params.params.distribution_proportions.staking; + let bonded_tokens = staking_pool.pool.bonded_tokens; + + if bonded_tokens == 0.0 { + return Some(0.0); + } + + let epochs_per_year = if mint_params.params.epoch_identifier == "day" { 365.0 } else { 52.0 }; + + let annual_issuance = epoch_provisions * epochs_per_year; + let annual_staking_rewards = annual_issuance * staking_distribution; + let staking_apy = (annual_staking_rewards / bonded_tokens) * 100.0; + + Some(staking_apy) +} + +pub fn map_staking_validators(validators: Vec, chain: CosmosChain, apy: Option) -> Vec { + validators + .into_iter() + .map(|validator| { + let commission_rate = validator.commission.commission_rates.rate; + let is_active = !validator.jailed && validator.status == BOND_STATUS_BONDED; + let validator_apr = if is_active { apy.map(|apr| apr - (apr * commission_rate)).unwrap_or(0.0) } else { 0.0 }; + + DelegationValidator::stake( + chain.as_chain(), + validator.operator_address, + validator.description.moniker, + is_active, + commission_rate * 100.0, + validator_apr, + ) + }) + .collect() +} + +pub fn map_staking_delegations( + active_delegations: Delegations, + unbonding_delegations: UnbondingDelegations, + rewards: Rewards, + validators: Vec, + chain: CosmosChain, + denom: &str, +) -> Vec { + let asset_id = chain.as_chain().as_asset_id(); + let mut delegations = Vec::new(); + + let validators_map: HashMap = validators.iter().map(|validator| (validator.operator_address.clone(), validator)).collect(); + + let rewards_map: HashMap = rewards + .rewards + .iter() + .map(|reward| { + let total_reward = reward + .reward + .iter() + .filter(|r| r.denom == denom) + .filter_map(|r| { + let integer_part = r.amount.split('.').next().unwrap_or("0"); + BigUint::from_str(integer_part).ok() + }) + .fold(BigUint::from(0u32), |acc, amount| acc + amount); + (reward.validator_address.clone(), total_reward) + }) + .collect(); + + let active_delegations = active_delegations.delegation_responses.into_iter().filter_map(|delegation| { + let balance_value = BigNumberFormatter::value_from_amount(&delegation.balance.amount, 0).ok()?; + if balance_value == "0" { + return None; + } + + let validator = validators_map.get(&delegation.delegation.validator_address); + let state = if validator.map(|v| !v.jailed && v.status == BOND_STATUS_BONDED).unwrap_or(false) { + DelegationState::Active + } else { + DelegationState::Inactive + }; + + let rewards = rewards_map + .get(&delegation.delegation.validator_address) + .map(|r| r.to_string()) + .unwrap_or_else(|| "0".to_string()); + + Some(DelegationBase { + asset_id: asset_id.clone(), + state, + balance: parse_to_biguint(&delegation.balance.amount), + shares: BigUint::from(0u32), + rewards: parse_to_biguint(&rewards), + completion_date: None, + delegation_id: "".to_string(), + validator_id: delegation.delegation.validator_address, + }) + }); + delegations.extend(active_delegations); + + for unbonding in unbonding_delegations.unbonding_responses { + for entry in unbonding.entries { + let balance = parse_to_biguint(&entry.balance.to_string()); + let rewards = rewards_map.get(&unbonding.validator_address).map(|r| parse_to_biguint(&r.to_string())).unwrap_or_default(); + + delegations.push(DelegationBase { + asset_id: asset_id.clone(), + state: DelegationState::Pending, + balance, + shares: BigUint::from(0u32), + rewards, + completion_date: entry.completion_time.parse::>().ok(), + delegation_id: entry.creation_height, + validator_id: unbonding.validator_address.clone(), + }); + } + } + + delegations +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::staking::{Delegations, Rewards}; + use primitives::Chain; + + #[test] + fn test_map_delegations() { + let delegations: Delegations = serde_json::from_str(include_str!("../../testdata/staking_delegations.json")).unwrap(); + + let mock_validator = Validator { + operator_address: "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3".to_string(), + jailed: false, + status: BOND_STATUS_BONDED.to_string(), + description: ValidatorDescription { + moniker: "Test Validator".to_string(), + }, + commission: ValidatorCommission { + commission_rates: ValidatorCommissionRates { rate: 0.05 }, + }, + }; + + let unbonding = UnbondingDelegations { unbonding_responses: vec![] }; + let rewards = Rewards { rewards: vec![] }; + + let result = map_staking_delegations(delegations, unbonding, rewards, vec![mock_validator], CosmosChain::Cosmos, "uatom"); + + assert_eq!(result.len(), 1); + let delegation = &result[0]; + assert_eq!(delegation.asset_id.to_string(), "cosmos"); + assert!(matches!(delegation.state, DelegationState::Active)); + assert_eq!(delegation.balance.to_string(), "10250000"); + assert_eq!(delegation.validator_id, "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3"); + assert_eq!(delegation.rewards.to_string(), "0"); + assert_eq!(delegation.shares.to_string(), "0"); + assert!(delegation.completion_date.is_none()); + assert_eq!(delegation.delegation_id, ""); + } + + #[test] + fn test_map_delegations_with_rewards() { + let delegations: Delegations = serde_json::from_str(include_str!("../../testdata/staking_delegations.json")).unwrap(); + let rewards: Rewards = serde_json::from_str(include_str!("../../testdata/staking_rewards.json")).unwrap(); + + let mock_validator = Validator { + operator_address: "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3".to_string(), + jailed: false, + status: BOND_STATUS_BONDED.to_string(), + description: ValidatorDescription { + moniker: "Test Validator".to_string(), + }, + commission: ValidatorCommission { + commission_rates: ValidatorCommissionRates { rate: 0.05 }, + }, + }; + + let unbonding = UnbondingDelegations { unbonding_responses: vec![] }; + + let result = map_staking_delegations(delegations, unbonding, rewards, vec![mock_validator], CosmosChain::Cosmos, "uatom"); + + assert_eq!(result.len(), 1); + let delegation = &result[0]; + assert_eq!(delegation.asset_id.to_string(), "cosmos"); + assert!(matches!(delegation.state, DelegationState::Active)); + assert_eq!(delegation.balance.to_string(), "10250000"); + assert_eq!(delegation.validator_id, "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3"); + assert_eq!(delegation.rewards.to_string(), "307413"); // Integer part of decimal amount + assert_eq!(delegation.shares.to_string(), "0"); + assert!(delegation.completion_date.is_none()); + assert_eq!(delegation.delegation_id, ""); + } + + #[test] + fn test_map_validators() { + let validators_response: ValidatorsResponse = serde_json::from_str(include_str!("../../testdata/staking_validators.json")).unwrap(); + + let result = map_staking_validators(validators_response.validators, CosmosChain::Cosmos, Some(18.5)); + + assert_eq!(result.len(), 2); + + let validator = &result[0]; + assert_eq!(validator.chain, Chain::Cosmos); + assert_eq!(validator.id, "cosmosvaloper1q9p73lx07tjqc34vs8jrsu5pg3q4ha534uqv4w"); + assert_eq!(validator.name, "Unstake as we will shut down"); + assert!(validator.is_active); + assert_eq!(validator.commission, 5.0); // Commission in percentage + assert_eq!(validator.apr, 17.575); + + let validator2 = &result[1]; + assert_eq!(validator2.id, "cosmosvaloper1q6d3d089hg59x6gcx92uumx70s5y5wadklue8s"); + assert_eq!(validator2.name, "Ubik Capital"); + } + + #[test] + fn test_calculate_network_apy_cosmos() { + let inflation = InflationResponse { inflation: 0.10 }; + let supply = SupplyResponse { + amount: SupplyAmount { + denom: "uatom".to_string(), + amount: 498707607433890.0, + }, + }; + let staking_pool = StakingPoolResponse { + pool: StakingPool { + bonded_tokens: 294464180546813.0, + not_bonded_tokens: 14480963444282.0, + }, + }; + + let result = calculate_network_apy_cosmos(inflation, supply, staking_pool); + + assert!(result.is_some()); + let apy = result.unwrap(); + + assert_eq!(apy.to_bits(), 0x4030efa48809e989); + } + + #[test] + fn test_calculate_network_apy_osmosis() { + let mint_params = OsmosisMintParamsResponse { + params: OsmosisMintParams { + epoch_identifier: "week".to_string(), + distribution_proportions: OsmosisDistributionProportions { staking: 0.5 }, + }, + }; + + let epoch_provisions = OsmosisEpochProvisionsResponse { epoch_provisions: 1.0 }; + + let staking_pool = StakingPoolResponse { + pool: StakingPool { + bonded_tokens: 50.0, + not_bonded_tokens: 0.0, + }, + }; + + let result = calculate_network_apy_osmosis(mint_params, epoch_provisions, staking_pool); + + assert!(result.is_some()); + let apy = result.unwrap(); + + assert_eq!(apy, 52.0); + } +} diff --git a/core/crates/gem_cosmos/src/provider/state.rs b/core/crates/gem_cosmos/src/provider/state.rs new file mode 100644 index 0000000000..8d61226881 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/state.rs @@ -0,0 +1,42 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; +use primitives::NodeSyncStatus; + +use crate::provider::state_mapper; +use crate::rpc::client::CosmosClient; + +#[async_trait] +impl ChainState for CosmosClient { + async fn get_chain_id(&self) -> Result> { + Ok(self.get_node_info().await?.default_node_info.network) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_block("latest").await?.block.header.height.parse()?) + } + + async fn get_node_status(&self) -> Result> { + let latest_block = self.get_block_latest_number().await?; + state_mapper::map_node_status(latest_block) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::create_cosmos_test_client; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_get_node_status() -> Result<(), Box> { + let client = create_cosmos_test_client(); + let node_status = client.get_node_status().await?; + + assert!(node_status.in_sync); + assert!(node_status.latest_block_number.unwrap_or(0) > 0); + + Ok(()) + } +} diff --git a/core/crates/gem_cosmos/src/provider/state_mapper.rs b/core/crates/gem_cosmos/src/provider/state_mapper.rs new file mode 100644 index 0000000000..cb411aa261 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/state_mapper.rs @@ -0,0 +1,36 @@ +use num_bigint::BigInt; +use primitives::{FeePriority, FeeRate, GasPriceType, NodeSyncStatus, chain_cosmos::CosmosChain}; +use std::error::Error; + +pub fn calculate_fee_rates(chain: CosmosChain, base_fee: BigInt) -> Vec { + match chain { + CosmosChain::Thorchain => { + vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(base_fee))] + } + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Sei | CosmosChain::Injective | CosmosChain::Noble => { + vec![ + FeeRate::new(FeePriority::Normal, GasPriceType::regular(base_fee.clone())), + FeeRate::new(FeePriority::Fast, GasPriceType::regular(&base_fee * BigInt::from(2))), + ] + } + } +} + +pub fn map_node_status(latest_block: u64) -> Result> { + Ok(NodeSyncStatus::synced(latest_block)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_node_status() { + let latest_block = 12345678u64; + let mapped = map_node_status(latest_block).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(12345678)); + assert_eq!(mapped.current_block_number, Some(12345678)); + } +} diff --git a/core/crates/gem_cosmos/src/provider/testkit.rs b/core/crates/gem_cosmos/src/provider/testkit.rs new file mode 100644 index 0000000000..e276a6a663 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/testkit.rs @@ -0,0 +1,41 @@ +#[cfg(test)] +use crate::models::TransactionResponse; +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::CosmosClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use primitives::chain_cosmos::CosmosChain; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj"; +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_EMPTY_ADDRESS: &str = "cosmos19xv76hwfjzf286we9q8ssce4v67h378vfnxvga"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "BC5E330F0AFA34489B9796E8101A2B027CC8AE8E820AFC7901C3C1E75C2895DD"; + +#[cfg(test)] +impl TransactionResponse { + pub fn mock_delegate() -> Self { + serde_json::from_str(include_str!("../../testdata/delegate.json")).unwrap() + } + + pub fn mock_reverted_transfer_spam() -> Self { + serde_json::from_str(include_str!("../../testdata/reverted_transfer_spam.json")).unwrap() + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_osmosis_test_client() -> CosmosClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.osmosis.url, reqwest::Client::new()); + CosmosClient::new(CosmosChain::Osmosis, reqwest_client) +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_cosmos_test_client() -> CosmosClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.cosmos.url, reqwest::Client::new()); + CosmosClient::new(CosmosChain::Cosmos, reqwest_client) +} diff --git a/core/crates/gem_cosmos/src/provider/token.rs b/core/crates/gem_cosmos/src/provider/token.rs new file mode 100644 index 0000000000..79052f8240 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/token.rs @@ -0,0 +1,19 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::Asset; + +use crate::rpc::client::CosmosClient; + +#[async_trait] +impl ChainToken for CosmosClient { + async fn get_token_data(&self, token_id: String) -> Result> { + Err(format!("Token data for {} not implemented", token_id).into()) + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + token_id.starts_with("ibc/") + } +} diff --git a/core/crates/gem_cosmos/src/provider/transaction_broadcast.rs b/core/crates/gem_cosmos/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..2bfaf72118 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/transaction_broadcast.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{BroadcastProvider, transaction_broadcast_mapper::map_transaction_broadcast_response_from_str, transactions_mapper::map_transaction_broadcast}, + rpc::client::CosmosClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for CosmosClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(&data).await?; + map_transaction_broadcast(&response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_cosmos/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_cosmos/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..2bd4c48cbf --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,9 @@ +use std::error::Error; + +use crate::models::BroadcastResponse; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::(response)?; + map_transaction_broadcast(&response) +} diff --git a/core/crates/gem_cosmos/src/provider/transaction_state.rs b/core/crates/gem_cosmos/src/provider/transaction_state.rs new file mode 100644 index 0000000000..ed5294683b --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/transaction_state.rs @@ -0,0 +1,17 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::rpc::client::CosmosClient; + +use super::transaction_state_mapper::map_transaction_status; + +#[async_trait] +impl ChainTransactionState for CosmosClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + Ok(map_transaction_status(self.get_transaction(request.id).await?)) + } +} diff --git a/core/crates/gem_cosmos/src/provider/transaction_state_mapper.rs b/core/crates/gem_cosmos/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..e8695b1383 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/transaction_state_mapper.rs @@ -0,0 +1,51 @@ +use primitives::{TransactionState, TransactionUpdate}; + +use crate::models::TransactionResponse; + +pub fn map_transaction_status(transaction: TransactionResponse) -> TransactionUpdate { + let state = if transaction.tx_response.code == 0 { + TransactionState::Confirmed + } else { + TransactionState::Reverted + }; + + TransactionUpdate::new_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{TransactionBody, TransactionResponseData, TransactionResponseTx}; + + fn create_response(code: i64) -> TransactionResponse { + TransactionResponse { + tx: TransactionResponseTx { + body: TransactionBody { + memo: String::new(), + messages: vec![], + }, + auth_info: None, + }, + tx_response: TransactionResponseData { + code, + txhash: "hash".to_string(), + events: vec![], + timestamp: String::new(), + }, + } + } + + #[test] + fn test_map_transaction_status_confirmed() { + let response = create_response(0); + let update = map_transaction_status(response); + assert_eq!(update.state, TransactionState::Confirmed); + } + + #[test] + fn test_map_transaction_status_reverted() { + let response = create_response(1); + let update = map_transaction_status(response); + assert_eq!(update.state, TransactionState::Reverted); + } +} diff --git a/core/crates/gem_cosmos/src/provider/transactions.rs b/core/crates/gem_cosmos/src/provider/transactions.rs new file mode 100644 index 0000000000..eb3df6c15e --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/transactions.rs @@ -0,0 +1,60 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use futures::{StreamExt, TryStreamExt, stream}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use super::transactions_mapper::{map_transaction_decode, map_transactions}; +use crate::rpc::client::CosmosClient; + +#[async_trait] +impl ChainTransactions for CosmosClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let response = self.get_block(block.to_string().as_str()).await?; + let transaction_ids = response + .block + .data + .txs + .clone() + .into_iter() + .filter(|x| x.len() < 1024) + .flat_map(|x| map_transaction_decode(&x)) + .collect::>(); + + let receipts = stream::iter(transaction_ids) + .map(|txid| async move { self.get_transaction(txid.clone()).await }) + .buffer_unordered(5) + .try_collect() + .await?; + + Ok(map_transactions(self.chain, receipts)) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + Ok(map_transactions(self.chain, vec![self.get_transaction(hash).await?]).into_iter().next()) + } + + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, limit, .. } = request; + let limit = limit.unwrap_or(20); + let transactions = self.get_transactions_by_address_with_limit(&address, limit).await?; + Ok(map_transactions(self.chain, transactions)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{TEST_TRANSACTION_ID, create_cosmos_test_client}; + use chain_traits::ChainTransactions; + + #[tokio::test] + async fn test_cosmos_get_transaction_by_hash() -> Result<(), Box> { + let client = create_cosmos_test_client(); + let transaction = client.get_transaction_by_hash(TEST_TRANSACTION_ID.to_string()).await?.unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + Ok(()) + } +} diff --git a/core/crates/gem_cosmos/src/provider/transactions_mapper.rs b/core/crates/gem_cosmos/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..359f6d4e24 --- /dev/null +++ b/core/crates/gem_cosmos/src/provider/transactions_mapper.rs @@ -0,0 +1,345 @@ +use chrono::DateTime; +use gem_encoding::decode_base64; +use gem_hash::sha2::sha256; +use primitives::chain_cosmos::CosmosChain; +use primitives::{AssetId, StakeValidator, Transaction, TransactionState, TransactionType}; +use std::error::Error; + +use crate::constants::get_base_fee; +use crate::models::BroadcastResponse; +use crate::models::{AuthInfo, Message, TransactionBody, TransactionResponse, Validator}; + +pub fn map_transaction_broadcast(response: &BroadcastResponse) -> Result> { + if let Some(tx_response) = &response.tx_response { + if tx_response.code != 0 { + Err(tx_response.raw_log.clone().into()) + } else { + Ok(tx_response.txhash.clone()) + } + } else if let Some(message) = &response.message { + Err(format!("Broadcast error: {}", message).into()) + } else { + Err("Unknown broadcast error".into()) + } +} + +pub fn map_transaction_decode(body: &str) -> Option { + let bytes = decode_base64(body).ok()?; + let decoded_str = String::from_utf8_lossy(&bytes); + let has_supported_type = crate::constants::SUPPORTED_MESSAGES.iter().any(|msg_type| decoded_str.contains(msg_type)); + if has_supported_type { Some(get_hash(&bytes)) } else { None } +} + +pub fn get_hash(bytes: &[u8]) -> String { + hex::encode(sha256(bytes)).to_uppercase() +} + +pub fn map_transactions(chain: CosmosChain, transactions: Vec) -> Vec { + transactions + .into_iter() + .filter_map(|x| { + let body = x.tx.body.clone(); + let auth_info = x.tx.auth_info.clone(); + map_transaction(chain, body, auth_info, x) + }) + .collect() +} + +fn asset_id_from_denom(chain: primitives::Chain, denom: &str, default_denom: &str) -> AssetId { + if denom == default_denom { chain.as_asset_id() } else { AssetId::token(chain, denom) } +} + +pub fn map_transaction(cosmos_chain: CosmosChain, body: TransactionBody, auth_info: Option, transaction: TransactionResponse) -> Option { + if transaction.tx_response.code != 0 { + return None; + } + + let hash = transaction.tx_response.txhash.clone(); + let chain = cosmos_chain.as_chain(); + let default_denom = chain.as_denom()?.to_string(); + let native_asset_id = chain.as_asset_id(); + + let fee_coin = auth_info.and_then(|info| info.fee.amount.into_iter().next()); + let fee = fee_coin.as_ref().map(|f| f.amount.clone()).unwrap_or_else(|| get_base_fee(cosmos_chain).to_string()); + let fee_asset_id = fee_coin + .as_ref() + .map(|coin| asset_id_from_denom(chain, &coin.denom, &default_denom)) + .unwrap_or_else(|| native_asset_id.clone()); + + let memo = if body.memo.is_empty() { None } else { Some(body.memo.clone()) }; + + let created_at = DateTime::parse_from_rfc3339(&transaction.tx_response.timestamp).ok()?.into(); + + if body.messages.len() != 1 { + return None; + } + + let message = body.messages.into_iter().next()?; + let asset_id: AssetId; + let transaction_type: TransactionType; + let value: String; + let from_address: String; + let to_address: String; + + match message { + Message::MsgSend(message) => { + let coin = message.amount.first()?; + asset_id = asset_id_from_denom(chain, &coin.denom, &default_denom); + transaction_type = TransactionType::Transfer; + value = coin.amount.clone(); + from_address = message.from_address; + to_address = message.to_address; + } + Message::MsgDelegate(message) => { + asset_id = native_asset_id.clone(); + transaction_type = TransactionType::StakeDelegate; + value = message.amount?.amount.clone(); + from_address = message.delegator_address; + to_address = message.validator_address; + } + Message::MsgUndelegate(message) => { + asset_id = native_asset_id.clone(); + transaction_type = TransactionType::StakeUndelegate; + value = message.amount?.amount.clone(); + from_address = message.delegator_address; + to_address = message.validator_address; + } + Message::MsgBeginRedelegate(message) => { + asset_id = native_asset_id.clone(); + transaction_type = TransactionType::StakeRedelegate; + value = message.amount?.amount.clone(); + from_address = message.delegator_address; + to_address = message.validator_dst_address; + } + Message::MsgWithdrawDelegatorReward(message) => { + asset_id = native_asset_id.clone(); + value = transaction.get_rewards_value(&default_denom)?.to_string(); + transaction_type = TransactionType::StakeRewards; + from_address = message.delegator_address; + to_address = message.validator_address; + } + _ => return None, + } + + Some(Transaction::new( + hash, + asset_id, + from_address, + to_address, + None, + transaction_type, + TransactionState::Confirmed, + fee, + fee_asset_id, + value, + memo, + None, + created_at, + )) +} + +pub fn map_validators(validators: Vec) -> Vec { + validators.into_iter().map(|v| StakeValidator::new(v.operator_address, v.description.moniker)).collect() +} + +#[cfg(test)] +mod tests { + use primitives::Chain; + + use super::*; + use crate::models::transaction::{BroadcastResponse, TransactionResult}; + use crate::provider::testkit::TEST_TRANSACTION_ID; + + #[test] + fn test_map_transaction_broadcast_success() { + let response = BroadcastResponse { + tx_response: Some(TransactionResult { + txhash: "ABC123".to_string(), + code: 0, + raw_log: "".to_string(), + }), + code: None, + message: None, + }; + + assert_eq!(map_transaction_broadcast(&response).unwrap(), "ABC123"); + } + + #[test] + fn test_map_transaction_broadcast_failed() { + let response: BroadcastResponse = serde_json::from_str(include_str!("../../testdata/transaction_broadcast_failed.json")).unwrap(); + assert!(map_transaction_broadcast(&response).is_err()); + } + + #[test] + fn test_map_transaction_failed() { + let response: BroadcastResponse = serde_json::from_str(include_str!("../../testdata/transaction_broadcast_failed.json")).unwrap(); + let result = map_transaction_broadcast(&response); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "signature verification failed; please verify account number (1343971) and chain-id (cosmoshub-4): (unable to verify single signer signature): unauthorized" + ); + } + + #[test] + fn test_map_transaction_by_hash() { + let result: TransactionResponse = serde_json::from_str(include_str!("../../testdata/transfer.json")).unwrap(); + let transaction = map_transactions(CosmosChain::Cosmos, vec![result]).first().unwrap().clone(); + + assert_eq!( + transaction, + Transaction::new( + TEST_TRANSACTION_ID.to_string(), + Chain::Cosmos.as_asset_id(), + "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw".to_string(), + "cosmos1hgp84me0lze8t4jfrwsr05aep2kr57zrk4gecx".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "1600".to_string(), + Chain::Cosmos.as_asset_id(), + "50000000".to_string(), + Some("6439432658467882".to_string()), + None, + DateTime::parse_from_rfc3339("2025-06-20T04:09:19Z").unwrap().into(), + ) + ); + } + + #[test] + fn test_map_reverted_transfer_is_ignored() { + let result = TransactionResponse::mock_reverted_transfer_spam(); + let transactions = map_transactions(CosmosChain::Cosmos, vec![result]); + + assert_eq!(transactions, vec![]); + } + + #[test] + fn test_map_reverted_staking_is_ignored() { + let mut result = TransactionResponse::mock_delegate(); + result.tx_response.code = 1; + + let transactions = map_transactions(CosmosChain::Cosmos, vec![result]); + + assert_eq!(transactions, vec![]); + } + + #[test] + fn test_transfer_ibc() { + let result: TransactionResponse = serde_json::from_str(include_str!("../../testdata/transfer_ibc.json")).unwrap(); + let transaction = map_transactions(CosmosChain::Cosmos, vec![result]).first().unwrap().clone(); + + assert_eq!( + transaction, + Transaction::new( + "90BBC25C199B58E6A4C9A2A3448C64E853B4E8DF3E88B1F6E35DE9FBF20400F6".to_string(), + AssetId::token(Chain::Cosmos, "ibc/915992C8486D299941292A913640167F0BA02DC2F599BFFED0732CE932C2FCC4"), + "cosmos1dej28rxfh39axghzlcusd98qhpkdarcqqu23ua".to_string(), + "cosmos1n3wq399w3s6reslvngve5quw85xusf7la5cpfs".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "689".to_string(), + Chain::Cosmos.as_asset_id(), + "20000000000".to_string(), + None, + None, + DateTime::parse_from_rfc3339("2026-03-03T17:24:03Z").unwrap().into(), + ) + ); + } + + #[test] + fn test_transfer_thorchain() { + let result: TransactionResponse = serde_json::from_str(include_str!("../../testdata/transfer_thorchain.json")).unwrap(); + let transaction = map_transactions(CosmosChain::Thorchain, vec![result]).first().unwrap().clone(); + + assert_eq!( + transaction, + Transaction::new( + "C4ED43321E89497C96B7084BE2AA2640EFB10A93A82F396B9FC7A8308F9662AE".to_string(), + Chain::Thorchain.as_asset_id(), + "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp".to_string(), + "thor1tpr8cqs2uncwfsggevmha4q4tc9eelu9r00cxx".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "2000000".to_string(), + Chain::Thorchain.as_asset_id(), + "50000000000".to_string(), + Some("thankyou".to_string()), + None, + DateTime::parse_from_rfc3339("2025-10-03T00:39:55Z").unwrap().into(), + ) + ); + } + + #[test] + fn test_delegate() { + let result: TransactionResponse = serde_json::from_str(include_str!("../../testdata/delegate.json")).unwrap(); + let transaction = map_transactions(CosmosChain::Cosmos, vec![result]).first().unwrap().clone(); + + assert_eq!( + transaction, + Transaction::new( + "FD334515F2D872B6689D7B52598796BF91C42111C857D6E80E984BC6DB4B0575".to_string(), + Chain::Cosmos.as_asset_id(), + "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw".to_string(), + "cosmosvaloper1jlr62guqwrwkdt4m3y00zh2rrsamhjf9num5xr".to_string(), + None, + TransactionType::StakeDelegate, + TransactionState::Confirmed, + "5194".to_string(), + Chain::Cosmos.as_asset_id(), + "17732657".to_string(), + None, + None, + DateTime::parse_from_rfc3339("2025-06-21T20:33:42Z").unwrap().into(), + ) + ); + } + + #[test] + fn test_rewards() { + let result: TransactionResponse = serde_json::from_str(include_str!("../../testdata/rewards.json")).unwrap(); + let transaction = map_transactions(CosmosChain::Cosmos, vec![result]).first().unwrap().clone(); + + assert_eq!( + transaction, + Transaction::new( + "0B615F5DDDB216574DF8AC07B104C3C902B23974C7957DF4275E1572CDDAFCB4".to_string(), + Chain::Cosmos.as_asset_id(), + "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj".to_string(), + "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3".to_string(), + None, + TransactionType::StakeRewards, + TransactionState::Confirmed, + "25000".to_string(), + Chain::Cosmos.as_asset_id(), + "2385518".to_string(), + None, + None, + DateTime::parse_from_rfc3339("2025-06-21T20:51:28Z").unwrap().into(), + ) + ); + } + + #[test] + fn test_decode_supported_transaction() { + let payload = "CtQBCo8BChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEm8KLWNvc21vczF6ODM1Y2p4Zno3MzU5NXd1bnF0bjNmbHg2dGdscnN5MDV5NjczdxItY29zbW9zMXo4MzVjanhmejczNTk1d3VucXRuM2ZseDZ0Z2xyc3kwNXk2NzN3Gg8KBXVhdG9tEgYxMDAwMDASQGFlY2IyY2UwZDU1YTg0NTVhNzc2YTMzOWU2ODY1MDE2NmE2YWE0NTVjMDVlZmRkZjQ5ZTAxMWI0MjAzYTI1YTASZwpRCkYKHy9jb3Ntb3MuY3J5cHRvLnNlY3AyNTZrMS5QdWJLZXkSIwohAjhf6Rbk8v7+0NCc4zugr/adpy2yOQikY1pzi6L/SzH2EgQKAggBGOogEhIKDAoFdWF0b20SAzYxOBChxQcaQOl+CCMVx/uR1/yU0RvPKkUADK3LFwo+zsElulf0M34xP00FnS6/51y4FEgn/ewRJokkxy1mPwPvqmK2FBsFVdY="; + let result = map_transaction_decode(payload); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), "F09F0730AB6C8C60FBD9252F3844184FF8D463ABE4978937AB7166149F5611FD"); + } + + #[test] + fn test_decode_unsupported_transaction() { + let payload = "CooBCocBCiYvc2VpcHJvdG9jb2wuc2VpY2hhaW4ub3JhY2xlLk1zZ0FnZ3JlZ2F0ZUV4Y2hhbmdlUmF0ZVZvdGVdCjQuMjk2ODI3NTE5ODU5MzYzNDIxdWF0b20sMTIwNjgyLjg1MDMyNTUwOTUyMzkwOTIwMnVidGMsNDQ4NS45NDM1MTE1ODEyOTMxMDg2NDZ1ZXRoLDAuMTcxNzU3NzgyMjY4Nzc3Mzg0dW9zbW8sMC4zMDA2MDc3OTA2MDkyMzExNjF1c2VpLDAuOTk5MjM1MTc2MDUxMDgxMDI4dXVzZGMsMS4wMDA0NzA5MTg5NzYyMDM0Njd1dXNkdBIqc2VpMTRxcmN3bXpwZHN6cTBnZWhmcTYzcmFybTdweHF3eG03eGFyY3hqGjFzZWl2YWxvcGVyMTh0cGRldDIya3B2c3d4YXlla3duNTVyeTByNWFjeDRrYWF1dXBrYg=="; + let result = map_transaction_decode(payload); + + assert!(result.is_none()); + } +} diff --git a/core/crates/gem_cosmos/src/rpc/client.rs b/core/crates/gem_cosmos/src/rpc/client.rs new file mode 100644 index 0000000000..bc3d3ae2b7 --- /dev/null +++ b/core/crates/gem_cosmos/src/rpc/client.rs @@ -0,0 +1,174 @@ +use std::error::Error; + +use crate::models::account::Balances; +use crate::models::staking::{Delegations, Rewards, UnbondingDelegations}; +use crate::models::{Account, AccountResponse, BroadcastRequest, BroadcastResponse, InjectiveAccount}; +use crate::models::{ + AnnualProvisionsResponse, BlockResponse, InflationResponse, OsmosisEpochProvisionsResponse, OsmosisMintParamsResponse, StakingPoolResponse, SupplyResponse, + TransactionResponse, TransactionsResponse, ValidatorsResponse, +}; +use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual, ChainTraits}; +use gem_client::{Client, ClientExt}; +use primitives::chain_cosmos::CosmosChain; + +pub struct CosmosClient { + pub chain: CosmosChain, + pub client: C, +} + +impl CosmosClient { + pub fn new(chain: CosmosChain, client: C) -> Self { + Self { chain, client } + } + + pub fn get_chain(&self) -> CosmosChain { + self.chain + } + + pub fn get_amount(&self, coins: Vec) -> Option { + Some( + coins + .into_iter() + .filter(|x| x.denom == self.chain.as_chain().as_denom().unwrap_or_default()) + .collect::>() + .first()? + .amount + .clone(), + ) + } + + pub async fn get_transaction(&self, hash: String) -> Result> { + Ok(self.client.get(&format!("/cosmos/tx/v1beta1/txs/{}", hash)).await?) + } + + pub async fn get_block(&self, block: &str) -> Result> { + Ok(self.client.get(&format!("/cosmos/base/tendermint/v1beta1/blocks/{}", block)).await?) + } + + pub async fn get_transactions_by_address_with_limit(&self, address: &str, limit: usize) -> Result, Box> { + let query_name = match self.chain { + CosmosChain::Cosmos => Some("query"), + CosmosChain::Osmosis => Some("query"), + CosmosChain::Celestia => Some("events"), + CosmosChain::Thorchain => None, + CosmosChain::Injective => Some("query"), + CosmosChain::Sei => Some("events"), + CosmosChain::Noble => Some("query"), + }; + if query_name.is_none() { + return Ok(vec![]); + } + let query_name = query_name.unwrap(); + + let inbound = self.get_transactions_by_query(query_name, &format!("message.sender='{address}'"), limit).await?; + let outbound = self.get_transactions_by_query(query_name, &format!("message.recipient='{address}'"), limit).await?; + let responses = inbound.tx_responses.into_iter().chain(outbound.tx_responses).collect::>(); + let txs = inbound.txs.into_iter().chain(outbound.txs).collect::>(); + Ok(responses + .into_iter() + .zip(txs) + .map(|(response, tx)| TransactionResponse { tx, tx_response: response }) + .collect::>()) + } + + pub async fn get_transactions_by_query(&self, query_name: &str, query: &str, limit: usize) -> Result> { + let url = format!("/cosmos/tx/v1beta1/txs?{}={}&pagination.limit={}&page=1", query_name, query, limit); + Ok(self.client.get(&url).await?) + } + + pub async fn get_validators(&self) -> Result> { + Ok(self + .client + .get("/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED&pagination.limit=1000") + .await?) + } + + pub async fn get_delegations_validators(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/cosmos/staking/v1beta1/delegators/{address}/validators")).await?) + } + + pub async fn get_staking_pool(&self) -> Result> { + Ok(self.client.get("/cosmos/staking/v1beta1/pool").await?) + } + + pub async fn get_inflation(&self) -> Result> { + Ok(self.client.get("/cosmos/mint/v1beta1/inflation").await?) + } + + pub async fn get_annual_provisions(&self) -> Result> { + let url = "/cosmos/mint/v1beta1/annual_provisions"; + Ok(self.client.get(url).await?) + } + + pub async fn get_supply_by_denom(&self, denom: &str) -> Result> { + let url = format!("/cosmos/bank/v1beta1/supply/by_denom?denom={}", denom); + Ok(self.client.get(&url).await?) + } + + pub async fn get_osmosis_mint_params(&self) -> Result> { + let url = "/osmosis/mint/v1beta1/params"; + Ok(self.client.get(url).await?) + } + + pub async fn get_osmosis_epoch_provisions(&self) -> Result> { + let url = "/osmosis/mint/v1beta1/epoch_provisions"; + Ok(self.client.get(url).await?) + } + + pub async fn get_balances(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/cosmos/bank/v1beta1/balances/{}", address)).await?) + } + + pub async fn get_delegations(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/cosmos/staking/v1beta1/delegations/{}", address)).await?) + } + + pub async fn get_unbonding_delegations(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/cosmos/staking/v1beta1/delegators/{}/unbonding_delegations", address)).await?) + } + + pub async fn get_delegation_rewards(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/cosmos/distribution/v1beta1/delegators/{}/rewards", address)).await?) + } + + pub fn get_base_fee(&self) -> u64 { + crate::constants::get_base_fee(self.chain) + } + + pub async fn get_account_info(&self, address: &str) -> Result> { + let url = format!("/cosmos/auth/v1beta1/accounts/{}", address); + match self.chain { + CosmosChain::Injective => { + let response: AccountResponse = self.client.get(&url).await?; + Ok(response.account.base_account) + } + _ => { + let response: AccountResponse = self.client.get(&url).await?; + Ok(response.account) + } + } + } + + pub async fn get_node_info(&self) -> Result> { + Ok(self.client.get("/cosmos/base/tendermint/v1beta1/node_info").await?) + } + + pub async fn broadcast_transaction(&self, data: &str) -> Result> { + let request: BroadcastRequest = serde_json::from_str(data)?; + Ok(self.client.post("/cosmos/tx/v1beta1/txs", &request).await?) + } +} + +impl ChainAccount for CosmosClient {} + +impl ChainPerpetual for CosmosClient {} + +impl ChainAddressStatus for CosmosClient {} + +impl ChainTraits for CosmosClient {} + +impl chain_traits::ChainProvider for CosmosClient { + fn get_chain(&self) -> primitives::Chain { + self.chain.as_chain() + } +} diff --git a/core/crates/gem_cosmos/src/rpc/mod.rs b/core/crates/gem_cosmos/src/rpc/mod.rs new file mode 100644 index 0000000000..7aa19a7a31 --- /dev/null +++ b/core/crates/gem_cosmos/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::CosmosClient; diff --git a/core/crates/gem_cosmos/src/signer/chain_signer.rs b/core/crates/gem_cosmos/src/signer/chain_signer.rs new file mode 100644 index 0000000000..5d9cc3406e --- /dev/null +++ b/core/crates/gem_cosmos/src/signer/chain_signer.rs @@ -0,0 +1,342 @@ +use gem_encoding::encode_base64; +use gem_hash::{keccak::keccak256, sha2::sha256}; +use k256::{PublicKey, elliptic_curve::sec1::ToEncodedPoint}; +use primitives::{ChainSigner, SignerError, SignerInput, TransactionLoadMetadata, chain_cosmos::CosmosChain}; +use signer::{SignatureScheme, Signer}; + +use super::transaction::{self, COSMOS_SECP256K1_PUBKEY_TYPE, CosmosTxParams, INJECTIVE_ETHSECP256K1_PUBKEY_TYPE}; +use crate::models::{Coin, CosmosMessage}; + +const BASE_FEE_GAS_UNITS: u64 = 200_000; +const GAS_BUFFER_NUMERATOR: u64 = 13; +const GAS_BUFFER_DENOMINATOR: u64 = 10; +const DEFAULT_STAKE_MEMO: &str = "Stake via Gem Wallet"; + +#[derive(Default)] +pub struct CosmosChainSigner; + +impl ChainSigner for CosmosChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let chain = Self::chain(input)?; + Self::sign_send(chain, input, chain.denom().as_ref(), private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let chain = Self::chain(input)?; + let denom = input.input_type.get_asset().id.get_token_id()?; + Self::sign_send(chain, input, denom, private_key) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let swap_data = input.input_type.get_swap_data().map_err(SignerError::invalid_input)?; + let chain = Self::chain(input)?; + + let messages = CosmosMessage::parse_array(&swap_data.data.data)?; + // Prefer the provider's gas limit (with buffer); fall back to the preloaded swap gas + // limit scaled by message count when the provider omits it. + let gas_limit = match swap_data.data.gas_limit.as_ref().and_then(|g| g.parse::().ok()).filter(|&g| g > 0) { + Some(provider_gas) => (provider_gas as u128 * GAS_BUFFER_NUMERATOR as u128 / GAS_BUFFER_DENOMINATOR as u128) as u64, + None => Self::gas_limit(input, messages.len())?, + }; + let fee_amount = Self::scale_fee(gas_limit, input.fee.gas_price_u64()?); + let memo = input.memo.as_deref().unwrap_or(""); + + Ok(vec![Self::sign_messages(chain, &input.metadata, messages, gas_limit, fee_amount, memo, private_key)?]) + } + + fn sign_stake(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let chain = Self::chain(input)?; + let messages = transaction::stake_messages(input, chain)?; + let gas_limit = Self::gas_limit(input, messages.len())?; + let fee_amount = input.fee.fee.to_string(); + let memo = input.memo.as_deref().filter(|m| !m.is_empty()).unwrap_or(DEFAULT_STAKE_MEMO); + + Ok(vec![Self::sign_messages(chain, &input.metadata, messages, gas_limit, fee_amount, memo, private_key)?]) + } +} + +impl CosmosChainSigner { + fn chain(input: &SignerInput) -> Result { + CosmosChain::from_chain(input.input_type.get_asset().chain).ok_or_else(|| SignerError::invalid_input("unsupported cosmos chain")) + } + + fn pubkey_type(chain: CosmosChain) -> &'static str { + match chain { + CosmosChain::Injective => INJECTIVE_ETHSECP256K1_PUBKEY_TYPE, + _ => COSMOS_SECP256K1_PUBKEY_TYPE, + } + } + + fn public_key(chain: CosmosChain, private_key: &[u8]) -> Result, SignerError> { + let public_key = signer::secp256k1_public_key(private_key)?; + match chain { + CosmosChain::Injective => Self::uncompress_public_key(&public_key), + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Thorchain | CosmosChain::Sei | CosmosChain::Noble => Ok(public_key), + } + } + + fn uncompress_public_key(public_key: &[u8]) -> Result, SignerError> { + let public_key = PublicKey::from_sec1_bytes(public_key).map_err(|_| SignerError::invalid_input("invalid secp256k1 public key"))?; + Ok(public_key.to_encoded_point(false).as_bytes().to_vec()) + } + + fn sign_doc_digest(chain: CosmosChain, sign_doc_bytes: &[u8]) -> [u8; 32] { + match chain { + CosmosChain::Injective => keccak256(sign_doc_bytes), + _ => sha256(sign_doc_bytes), + } + } + + fn gas_limit(input: &SignerInput, message_count: usize) -> Result { + let message_count = u64::try_from(message_count).map_err(|_| SignerError::invalid_input("too many messages"))?; + input + .fee + .gas_limit()? + .checked_mul(message_count) + .ok_or_else(|| SignerError::invalid_input("gas limit overflow")) + } + + fn scale_fee(gas_limit: u64, base_fee: u64) -> String { + ((gas_limit as u128 * base_fee as u128 / BASE_FEE_GAS_UNITS as u128) as u64).to_string() + } + + fn fee_coins(chain: CosmosChain, fee_amount: String) -> Vec { + match chain { + CosmosChain::Thorchain => vec![], + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Injective | CosmosChain::Sei | CosmosChain::Noble => vec![Coin { + denom: chain.denom().as_ref().to_string(), + amount: fee_amount, + }], + } + } + + fn sign_send(chain: CosmosChain, input: &SignerInput, denom: &str, private_key: &[u8]) -> Result { + let message = transaction::transfer_message(input, denom); + let gas_limit = Self::gas_limit(input, 1)?; + let fee_amount = input.fee.fee.to_string(); + let memo = input.memo.as_deref().unwrap_or(""); + Self::sign_messages(chain, &input.metadata, vec![message], gas_limit, fee_amount, memo, private_key) + } + + fn sign_messages( + chain: CosmosChain, + metadata: &TransactionLoadMetadata, + messages: Vec, + gas_limit: u64, + fee_amount: String, + memo: &str, + private_key: &[u8], + ) -> Result { + let account_number = metadata.get_account_number()?; + let sequence = metadata.get_sequence()?; + let chain_id = metadata.get_chain_id()?; + let encoded: Vec> = messages.iter().map(|m| m.encode_as_any(chain)).collect::, _>>()?; + let body_bytes = CosmosTxParams::encode_tx_body(&encoded, memo); + + let params = CosmosTxParams { + body_bytes, + chain_id: &chain_id, + account_number, + sequence, + fee_coins: Self::fee_coins(chain, fee_amount), + gas_limit, + pubkey_type: Self::pubkey_type(chain), + }; + + Self::encode_and_sign_tx(chain, ¶ms, private_key) + } + + pub fn encode_and_sign_tx(chain: CosmosChain, params: &CosmosTxParams, private_key: &[u8]) -> Result { + let pubkey_bytes = Self::public_key(chain, private_key)?; + let auth_info_bytes = params.encode_auth_info(&pubkey_bytes); + let sign_doc_bytes = params.encode_sign_doc(¶ms.body_bytes, &auth_info_bytes); + + let digest = Self::sign_doc_digest(chain, &sign_doc_bytes); + let mut signature = Signer::sign_digest(SignatureScheme::Secp256k1, &digest, private_key)?; + if signature.len() < 64 { + return Err(SignerError::signing_error("secp256k1 signature too short")); + } + signature.truncate(64); + + let tx_raw = CosmosTxParams::encode_tx_raw(¶ms.body_bytes, &auth_info_bytes, &signature); + let tx_base64 = encode_base64(&tx_raw); + Ok(serde_json::json!({ + "mode": "BROADCAST_MODE_SYNC", + "tx_bytes": tx_base64, + }) + .to_string()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use num_bigint::BigInt; + use primitives::{ + Asset, Chain, Delegation, DelegationValidator, GasPriceType, RedelegateData, StakeType, SwapProvider, TransactionFee, TransactionInputType, TransactionLoadInput, + TransactionLoadMetadata, swap::SwapData, + }; + use serde_json::Value; + + use super::*; + + // Derived from "seminar cruel gown pause law tortoise step stairs size amused pond weapon" via m/44'/118'/0'/0/0. + const OSMO_PRIVATE_KEY_HEX: &str = "325f5eba4c6466ca5a88638c74db5b396edb624efced0924a10aeb897525923c"; + const OSMO_VALIDATOR: &str = "osmovaloper1pxphtfhqnx9ny27d53z4052e3r76e7qq495ehm"; + const OSMO_VALIDATOR_DST: &str = "osmovaloper1z0sh4s80u99l6y9d3vfy582p8jejeeu6tcucs2"; + + fn signed_tx_bytes(signed: &str) -> String { + let value: Value = serde_json::from_str(signed).unwrap(); + assert_eq!(value["mode"], "BROADCAST_MODE_SYNC"); + value["tx_bytes"].as_str().unwrap().to_string() + } + + #[test] + fn test_sign_thorchain_transfer() { + // Source: https://github.com/trustwallet/wallet-core/blob/4.3.22/swift/Tests/Blockchains/THORChainTests.swift + let private_key = hex::decode("7105512f0c020a1dd759e14b865ec0125f59ac31e34d7a2807a228ed50cb343e").unwrap(); + let fee_amount = BigInt::from(200u64); + let input = SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Thorchain)), + sender_address: "thor1z53wwe7md6cewz9sqwqzn0aavpaun0gw0exn2r".to_string(), + destination_address: "thor1e2ryt8asq4gu0h6z2sx9u7rfrykgxwkmr9upxn".to_string(), + value: "38000000".to_string(), + gas_price: GasPriceType::regular(fee_amount.clone()), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Cosmos { + account_number: 593, + sequence: 21, + chain_id: "thorchain-mainnet-v1".to_string(), + }, + }, + TransactionFee::new_gas_price_type(GasPriceType::regular(fee_amount.clone()), fee_amount, BigInt::from(2_500_000u64), HashMap::new()), + ); + + let signed = CosmosChainSigner.sign_transfer(&input, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed), + "ClIKUAoOL3R5cGVzLk1zZ1NlbmQSPgoUFSLnZ9tusZcIsAOAKb+9YHvJvQ4SFMqGRZ+wBVHH30JUDF54aRksgzrbGhAKBHJ1bmUSCDM4MDAwMDAwElkKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQPtmX45bPQpL1/OWkK7pBWZzNXZbjExVKfJ6nBJ3jF8dxIECgIIARgVEgUQoMuYARpAj4gtkfIP83fI0HHaCa95deqwo280CoLDVHJ6BkSGADxQaYoBWJW/NwaMU05d34AkUgesUjJHk1238cG9Am+J0g==" + ); + } + + #[test] + fn test_sign_injective_transfer_matches_expected_tx_bytes() { + // Source: https://github.com/trustwallet/wallet-core/blob/4.3.22/tests/chains/Cosmos/NativeInjective/SignerTests.cpp + let private_key = hex::decode("9ee18daf8e463877aaf497282abc216852420101430482a28e246c179e2c5ef1").unwrap(); + let fee_amount = BigInt::from(100_000_000_000_000u64); + let input = SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Injective)), + sender_address: "inj13u6g7vqgw074mgmf2ze2cadzvkz9snlwcrtq8a".to_string(), + destination_address: "inj1xmpkmxr4as00em23tc2zgmuyy2gr4h3wgcl6vd".to_string(), + value: "10000000000".to_string(), + gas_price: GasPriceType::regular(fee_amount.clone()), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Cosmos { + account_number: 17396, + sequence: 1, + chain_id: "injective-1".to_string(), + }, + }, + TransactionFee::new_gas_price_type(GasPriceType::regular(fee_amount.clone()), fee_amount, BigInt::from(110_000u64), HashMap::new()), + ); + + let signed = CosmosChainSigner.sign_transfer(&input, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed), + "Co8BCowBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEmwKKmluajEzdTZnN3ZxZ3cwNzRtZ21mMnplMmNhZHp2a3o5c25sd2NydHE4YRIqaW5qMXhtcGtteHI0YXMwMGVtMjN0YzJ6Z211eXkyZ3I0aDN3Z2NsNnZkGhIKA2luahILMTAwMDAwMDAwMDASngEKfgp0Ci0vaW5qZWN0aXZlLmNyeXB0by52MWJldGExLmV0aHNlY3AyNTZrMS5QdWJLZXkSQwpBBFoMa4O4vZgn5QcnDK20mbfjqQlSRvaiITKB94PYd8mLJWdCdBsGOfMXdo/k9MJ2JmDCESKDp2hdgVUH3uMikXMSBAoCCAEYARIcChYKA2luahIPMTAwMDAwMDAwMDAwMDAwELDbBhpAx2vkplmzeK7n3puCFGPWhLd0l/ZC/CYkGl+stH+3S3hiCvIe7uwwMpUlNaSwvT8HwF1kNUp+Sx2m0Uo1x5xcFw==" + ); + } + + #[test] + fn test_sign_osmosis_messages() { + let private_key = hex::decode(OSMO_PRIVATE_KEY_HEX).unwrap(); + let signer = CosmosChainSigner; + + let transfer = SignerInput::mock_osmosis( + TransactionInputType::Transfer(Asset::from_chain(Chain::Osmosis)), + "osmo1rcjvzz8wzktqfz8qjf0l9q45kzxvd0z0n7l5cf", + ); + assert_eq!( + signed_tx_bytes(&signer.sign_transfer(&transfer, &private_key).unwrap()), + "CooBCocBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEmcKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSK29zbW8xcmNqdnp6OHd6a3RxZno4cWpmMGw5cTQ1a3p4dmQwejBuN2w1Y2YaCwoFdW9zbW8SAjEwEmgKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQMslcYn7DhPe5b/8lM3FnPXhGBj5SdC15+XI1hZ1gYbBBIECgIIARgKEhQKDgoFdW9zbW8SBTEwMDAwEMCaDBpAVJkDxaS5ZaghmJ6ZtpC9yim7JA8duO8MwOODdJeHEHssH3PQN+4Yl+SVyLtNEW6+IDUKfkG1dfIYOvpRiFlOyg==" + ); + + let stake = SignerInput::mock_osmosis( + TransactionInputType::Stake(Asset::from_chain(Chain::Osmosis), StakeType::Stake(DelegationValidator::mock_osmosis(OSMO_VALIDATOR))), + "", + ); + let signed = signer.sign_stake(&stake, &private_key).unwrap(); + assert_eq!(signed.len(), 1); + assert_eq!( + signed_tx_bytes(&signed[0]), + "Cq4BCpUBCiMvY29zbW9zLnN0YWtpbmcudjFiZXRhMS5Nc2dEZWxlZ2F0ZRJuCitvc21vMWtnbGVtdW11OG1uNjU4ajZnNHo5anpuM3plZjJxZHl5dmtsd2EzEjJvc21vdmFsb3BlcjFweHBodGZocW54OW55MjdkNTN6NDA1MmUzcjc2ZTdxcTQ5NWVobRoLCgV1b3NtbxICMTASFFN0YWtlIHZpYSBHZW0gV2FsbGV0EmgKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQMslcYn7DhPe5b/8lM3FnPXhGBj5SdC15+XI1hZ1gYbBBIECgIIARgKEhQKDgoFdW9zbW8SBTEwMDAwEMCaDBpAxh9uwNZvql2fODCEAp4XhucO1cxXYrz2oMEkat+wvJEP1VDlai4ZnLz+n9mRbgjF143EfsaonoEh36uQKYOWuQ==" + ); + + let undelegate = SignerInput::mock_osmosis( + TransactionInputType::Stake(Asset::from_chain(Chain::Osmosis), StakeType::Unstake(Delegation::mock_osmosis(OSMO_VALIDATOR))), + "", + ); + // Auto-claims pending rewards before unstake. + let signed = signer.sign_stake(&undelegate, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed[0]), + "Cs8CCpwBCjcvY29zbW9zLmRpc3RyaWJ1dGlvbi52MWJldGExLk1zZ1dpdGhkcmF3RGVsZWdhdG9yUmV3YXJkEmEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtCpcBCiUvY29zbW9zLnN0YWtpbmcudjFiZXRhMS5Nc2dVbmRlbGVnYXRlEm4KK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtGgsKBXVvc21vEgIxMBIUU3Rha2UgdmlhIEdlbSBXYWxsZXQSaApQCkYKHy9jb3Ntb3MuY3J5cHRvLnNlY3AyNTZrMS5QdWJLZXkSIwohAyyVxifsOE97lv/yUzcWc9eEYGPlJ0LXn5cjWFnWBhsEEgQKAggBGAoSFAoOCgV1b3NtbxIFMTAwMDAQgLUYGkCA133uwfd5FIq0KwZtG+gduTmeUmvgZ4dFmxLb23a37zBIOAx26XVJQ9PNDD2tFlODaVLjnN+a2saa4KOXz/wG" + ); + + let redelegate = SignerInput::mock_osmosis( + TransactionInputType::Stake( + Asset::from_chain(Chain::Osmosis), + StakeType::Redelegate(RedelegateData { + delegation: Delegation::mock_osmosis(OSMO_VALIDATOR), + to_validator: DelegationValidator::mock_osmosis(OSMO_VALIDATOR_DST), + }), + ), + "", + ); + let signed = signer.sign_stake(&redelegate, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed[0]), + "CokDCpwBCjcvY29zbW9zLmRpc3RyaWJ1dGlvbi52MWJldGExLk1zZ1dpdGhkcmF3RGVsZWdhdG9yUmV3YXJkEmEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtCtEBCiovY29zbW9zLnN0YWtpbmcudjFiZXRhMS5Nc2dCZWdpblJlZGVsZWdhdGUSogEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtGjJvc21vdmFsb3BlcjF6MHNoNHM4MHU5OWw2eTlkM3ZmeTU4MnA4amVqZWV1NnRjdWNzMiILCgV1b3NtbxICMTASFFN0YWtlIHZpYSBHZW0gV2FsbGV0EmgKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQMslcYn7DhPe5b/8lM3FnPXhGBj5SdC15+XI1hZ1gYbBBIECgIIARgKEhQKDgoFdW9zbW8SBTEwMDAwEIC1GBpAPgfCbDv4AFbBsGokEl26JCKuyt7R0PN2/jHsBnva4dQqd7kxKIIwGq2yDmwserV4/2B1I51W2JHL0m8/ZOYT7g==" + ); + + let rewards = SignerInput::mock_osmosis( + TransactionInputType::Stake( + Asset::from_chain(Chain::Osmosis), + StakeType::Rewards(vec![DelegationValidator::mock_osmosis(OSMO_VALIDATOR), DelegationValidator::mock_osmosis(OSMO_VALIDATOR)]), + ), + "", + ); + let signed = signer.sign_stake(&rewards, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed[0]), + "CtQCCpwBCjcvY29zbW9zLmRpc3RyaWJ1dGlvbi52MWJldGExLk1zZ1dpdGhkcmF3RGVsZWdhdG9yUmV3YXJkEmEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtCpwBCjcvY29zbW9zLmRpc3RyaWJ1dGlvbi52MWJldGExLk1zZ1dpdGhkcmF3RGVsZWdhdG9yUmV3YXJkEmEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtEhRTdGFrZSB2aWEgR2VtIFdhbGxldBJoClAKRgofL2Nvc21vcy5jcnlwdG8uc2VjcDI1NmsxLlB1YktleRIjCiEDLJXGJ+w4T3uW//JTNxZz14RgY+UnQteflyNYWdYGGwQSBAoCCAEYChIUCg4KBXVvc21vEgUxMDAwMBCAtRgaQH/U90uCH0zx9AdY+ALIHM5aZ1crBSwYzeZZejb5rWjEMVXRScjOfvng33XFnFHdI4Epp9ykNNtQVUw9BJnZshU=" + ); + } + + // sign_swap should accept a missing/zero provider gas limit and fall back to the preloaded value. + #[test] + fn test_sign_swap_falls_back_when_provider_gas_missing() { + let private_key = hex::decode(OSMO_PRIVATE_KEY_HEX).unwrap(); + let msg_send = r#"[{"typeUrl":"/cosmos.bank.v1beta1.MsgSend","value":{"from_address":"osmo1kglemumu8mn658j6g4z9jzn3zef2qdyyvklwa3","to_address":"osmo1rcjvzz8wzktqfz8qjf0l9q45kzxvd0z0n7l5cf","amount":[{"denom":"uosmo","amount":"10"}]}}]"#; + + for gas_limit in [None, Some("0"), Some("")] { + let swap_data = SwapData::mock_with_provider_data(SwapProvider::Squid, msg_send, gas_limit); + let input = SignerInput::mock_osmosis( + TransactionInputType::Swap(Asset::from_chain(Chain::Osmosis), Asset::from_chain(Chain::Osmosis), swap_data), + "", + ); + let signed = CosmosChainSigner.sign_swap(&input, &private_key).expect("swap should sign"); + assert_eq!(signed.len(), 1); + // Identical bytes to the OSMO native transfer (1 msg, 200000 gas, 10000 uosmo fee). + assert_eq!( + signed_tx_bytes(&signed[0]), + "CooBCocBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEmcKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSK29zbW8xcmNqdnp6OHd6a3RxZno4cWpmMGw5cTQ1a3p4dmQwejBuN2w1Y2YaCwoFdW9zbW8SAjEwEmgKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQMslcYn7DhPe5b/8lM3FnPXhGBj5SdC15+XI1hZ1gYbBBIECgIIARgKEhQKDgoFdW9zbW8SBTEwMDAwEMCaDBpAVJkDxaS5ZaghmJ6ZtpC9yim7JA8duO8MwOODdJeHEHssH3PQN+4Yl+SVyLtNEW6+IDUKfkG1dfIYOvpRiFlOyg==" + ); + } + } +} diff --git a/core/crates/gem_cosmos/src/signer/mod.rs b/core/crates/gem_cosmos/src/signer/mod.rs new file mode 100644 index 0000000000..febba1dc99 --- /dev/null +++ b/core/crates/gem_cosmos/src/signer/mod.rs @@ -0,0 +1,4 @@ +mod chain_signer; +pub mod transaction; + +pub use chain_signer::CosmosChainSigner; diff --git a/core/crates/gem_cosmos/src/signer/transaction.rs b/core/crates/gem_cosmos/src/signer/transaction.rs new file mode 100644 index 0000000000..20f3a3bf7a --- /dev/null +++ b/core/crates/gem_cosmos/src/signer/transaction.rs @@ -0,0 +1,273 @@ +use gem_encoding::protobuf::*; +use primitives::{Address, DelegationValidator, SignerError, SignerInput, StakeType, chain_cosmos::CosmosChain}; + +use crate::address::CosmosAddress; +use crate::constants::{ + MESSAGE_DELEGATE, MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER, MESSAGE_REDELEGATE, MESSAGE_REWARD_BETA, MESSAGE_SEND, MESSAGE_SEND_BETA, MESSAGE_UNDELEGATE, +}; +use crate::models::{Coin, CosmosMessage}; + +pub const COSMOS_SECP256K1_PUBKEY_TYPE: &str = "/cosmos.crypto.secp256k1.PubKey"; +pub const INJECTIVE_ETHSECP256K1_PUBKEY_TYPE: &str = "/injective.crypto.v1beta1.ethsecp256k1.PubKey"; +const SIGN_MODE_DIRECT: u64 = 1; + +pub fn transfer_message(input: &SignerInput, denom: &str) -> CosmosMessage { + CosmosMessage::Send { + from_address: input.sender_address.clone(), + to_address: input.destination_address.clone(), + amount: vec![Coin { + denom: denom.to_string(), + amount: input.value.clone(), + }], + } +} + +pub fn stake_messages(input: &SignerInput, chain: CosmosChain) -> Result, SignerError> { + let stake_type = input.input_type.get_stake_type().map_err(SignerError::invalid_input)?; + let delegator_address = &input.sender_address; + let amount = Coin { + denom: chain.denom().as_ref().to_string(), + amount: input.value.clone(), + }; + + match stake_type { + StakeType::Stake(validator) => Ok(vec![CosmosMessage::Delegate { + delegator_address: delegator_address.clone(), + validator_address: validator.id.clone(), + amount, + }]), + StakeType::Unstake(delegation) => { + let mut messages = reward_messages(delegator_address, std::slice::from_ref(&delegation.validator)); + messages.push(CosmosMessage::Undelegate { + delegator_address: delegator_address.clone(), + validator_address: delegation.validator.id.clone(), + amount, + }); + Ok(messages) + } + StakeType::Redelegate(data) => { + let mut messages = reward_messages(delegator_address, std::slice::from_ref(&data.delegation.validator)); + messages.push(CosmosMessage::BeginRedelegate { + delegator_address: delegator_address.clone(), + validator_src_address: data.delegation.validator.id.clone(), + validator_dst_address: data.to_validator.id.clone(), + amount, + }); + Ok(messages) + } + StakeType::Rewards(validators) => Ok(reward_messages(delegator_address, validators)), + StakeType::Withdraw(_) => SignerError::invalid_input_err("Cosmos withdraw operations are not supported"), + StakeType::Freeze(_) | StakeType::Unfreeze(_) => SignerError::invalid_input_err("Cosmos freeze operations are not supported"), + } +} + +fn encode_send(chain: CosmosChain, from_address: &str, to_address: &str, amount: &[Coin]) -> Result, SignerError> { + let coin_fields: Vec = amount.iter().flat_map(|c| encode_message_field(3, &encode_coin(&c.denom, &c.amount))).collect(); + let address_fields = match chain { + CosmosChain::Thorchain => { + let parse = |addr: &str| CosmosAddress::try_parse(addr).ok_or_else(|| SignerError::invalid_input(format!("invalid cosmos address: {addr}"))); + [encode_bytes_field(1, parse(from_address)?.as_bytes()), encode_bytes_field(2, parse(to_address)?.as_bytes())].concat() + } + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Injective | CosmosChain::Sei | CosmosChain::Noble => { + [encode_string_field(1, from_address), encode_string_field(2, to_address)].concat() + } + }; + Ok([address_fields, coin_fields].concat()) +} + +fn encode_coin(denom: &str, amount: &str) -> Vec { + [encode_string_field(1, denom), encode_string_field(2, amount)].concat() +} + +fn reward_messages(delegator_address: &str, validators: &[DelegationValidator]) -> Vec { + validators + .iter() + .map(|validator| CosmosMessage::WithdrawDelegatorReward { + delegator_address: delegator_address.to_string(), + validator_address: validator.id.clone(), + }) + .collect() +} + +pub struct CosmosTxParams<'a> { + pub body_bytes: Vec, + pub chain_id: &'a str, + pub account_number: u64, + pub sequence: u64, + pub fee_coins: Vec, + pub gas_limit: u64, + pub pubkey_type: &'a str, +} + +impl CosmosTxParams<'_> { + pub fn encode_tx_body(messages: &[Vec], memo: &str) -> Vec { + let msg_fields: Vec = messages.iter().flat_map(|m| encode_message_field(1, m)).collect(); + [msg_fields, encode_string_field(2, memo)].concat() + } + + pub fn encode_auth_info(&self, pubkey_bytes: &[u8]) -> Vec { + [ + encode_message_field(1, &Self::encode_signer_info(self.pubkey_type, pubkey_bytes, self.sequence)), + encode_message_field(2, &Self::encode_fee(&self.fee_coins, self.gas_limit)), + ] + .concat() + } + + pub fn encode_sign_doc(&self, body_bytes: &[u8], auth_info_bytes: &[u8]) -> Vec { + [ + encode_bytes_field(1, body_bytes), + encode_bytes_field(2, auth_info_bytes), + encode_string_field(3, self.chain_id), + encode_varint_field(4, self.account_number), + ] + .concat() + } + + pub fn encode_tx_raw(body_bytes: &[u8], auth_info_bytes: &[u8], signature: &[u8]) -> Vec { + [encode_bytes_field(1, body_bytes), encode_bytes_field(2, auth_info_bytes), encode_bytes_field(3, signature)].concat() + } + + fn encode_pubkey_any(pubkey_type: &str, pubkey_bytes: &[u8]) -> Vec { + [encode_string_field(1, pubkey_type), encode_bytes_field(2, &encode_bytes_field(1, pubkey_bytes))].concat() + } + + fn encode_mode_info_single() -> Vec { + encode_message_field(1, &encode_varint_field(1, SIGN_MODE_DIRECT)) + } + + fn encode_signer_info(pubkey_type: &str, pubkey_bytes: &[u8], sequence: u64) -> Vec { + [ + encode_message_field(1, &Self::encode_pubkey_any(pubkey_type, pubkey_bytes)), + encode_message_field(2, &Self::encode_mode_info_single()), + encode_varint_field(3, sequence), + ] + .concat() + } + + fn encode_fee(coins: &[Coin], gas_limit: u64) -> Vec { + let coin_fields: Vec = coins.iter().flat_map(|c| encode_message_field(1, &encode_coin(&c.denom, &c.amount))).collect(); + [coin_fields, encode_varint_field(2, gas_limit)].concat() + } +} + +impl CosmosMessage { + fn type_url(&self, chain: CosmosChain) -> &str { + match self { + Self::Send { .. } => match chain { + CosmosChain::Thorchain => MESSAGE_SEND, + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Injective | CosmosChain::Sei | CosmosChain::Noble => MESSAGE_SEND_BETA, + }, + Self::ExecuteContract { .. } => MESSAGE_EXECUTE_CONTRACT, + Self::IbcTransfer { .. } => MESSAGE_IBC_TRANSFER, + Self::Delegate { .. } => MESSAGE_DELEGATE, + Self::Undelegate { .. } => MESSAGE_UNDELEGATE, + Self::BeginRedelegate { .. } => MESSAGE_REDELEGATE, + Self::WithdrawDelegatorReward { .. } => MESSAGE_REWARD_BETA, + } + } + + fn encode_value(&self, chain: CosmosChain) -> Result, SignerError> { + match self { + Self::Send { from_address, to_address, amount } => encode_send(chain, from_address, to_address, amount), + Self::ExecuteContract { sender, contract, msg, funds } => { + let fund_fields: Vec = funds.iter().flat_map(|c| encode_message_field(5, &encode_coin(&c.denom, &c.amount))).collect(); + Ok([encode_string_field(1, sender), encode_string_field(2, contract), encode_bytes_field(3, msg), fund_fields].concat()) + } + Self::IbcTransfer { + source_port, + source_channel, + token, + sender, + receiver, + timeout_timestamp, + memo, + } => Ok([ + encode_string_field(1, source_port), + encode_string_field(2, source_channel), + encode_message_field(3, &encode_coin(&token.denom, &token.amount)), + encode_string_field(4, sender), + encode_string_field(5, receiver), + encode_varint_field(7, *timeout_timestamp), + encode_string_field(8, memo), + ] + .concat()), + Self::Delegate { + delegator_address, + validator_address, + amount, + } + | Self::Undelegate { + delegator_address, + validator_address, + amount, + } => Ok([ + encode_string_field(1, delegator_address), + encode_string_field(2, validator_address), + encode_message_field(3, &encode_coin(&amount.denom, &amount.amount)), + ] + .concat()), + Self::BeginRedelegate { + delegator_address, + validator_src_address, + validator_dst_address, + amount, + } => Ok([ + encode_string_field(1, delegator_address), + encode_string_field(2, validator_src_address), + encode_string_field(3, validator_dst_address), + encode_message_field(4, &encode_coin(&amount.denom, &amount.amount)), + ] + .concat()), + Self::WithdrawDelegatorReward { + delegator_address, + validator_address, + } => Ok([encode_string_field(1, delegator_address), encode_string_field(2, validator_address)].concat()), + } + } + + pub fn encode_as_any(&self, chain: CosmosChain) -> Result, SignerError> { + Ok([encode_string_field(1, self.type_url(chain)), encode_bytes_field(2, &self.encode_value(chain)?)].concat()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_execute_contract() { + let msg = CosmosMessage::ExecuteContract { + sender: "osmo1test".to_string(), + contract: "osmo1contract".to_string(), + msg: b"{\"swap\":{}}".to_vec(), + funds: vec![Coin { + denom: "uosmo".to_string(), + amount: "1000000".to_string(), + }], + }; + assert_eq!( + hex::encode(msg.encode_as_any(CosmosChain::Osmosis).unwrap()), + "0a242f636f736d7761736d2e7761736d2e76312e4d736745786563757465436f6e747261637412390a096f736d6f3174657374120d6f736d6f31636f6e74726163741a0b7b2273776170223a7b7d7d2a100a05756f736d6f120731303030303030" + ); + } + + #[test] + fn test_encode_ibc_transfer() { + let msg = CosmosMessage::IbcTransfer { + source_port: "transfer".to_string(), + source_channel: "channel-0".to_string(), + token: Coin { + denom: "uatom".to_string(), + amount: "1000000".to_string(), + }, + sender: "cosmos1test".to_string(), + receiver: "osmo1test".to_string(), + timeout_timestamp: 1773382733549000000, + memo: "{\"ibc_callback\":\"osmo1contract\"}".to_string(), + }; + assert_eq!( + hex::encode(msg.encode_as_any(CosmosChain::Cosmos).unwrap()), + "0a292f6962632e6170706c69636174696f6e732e7472616e736665722e76312e4d73675472616e73666572126b0a087472616e7366657212096368616e6e656c2d301a100a057561746f6d120731303030303030220b636f736d6f7331746573742a096f736d6f317465737438c0aaffdfb4c694ce1842207b226962635f63616c6c6261636b223a226f736d6f31636f6e7472616374227d" + ); + } +} diff --git a/core/crates/gem_cosmos/testdata/delegate.json b/core/crates/gem_cosmos/testdata/delegate.json new file mode 100644 index 0000000000..060dcbd80e --- /dev/null +++ b/core/crates/gem_cosmos/testdata/delegate.json @@ -0,0 +1,483 @@ +{ + "tx": { + "body": { + "messages": [ + { + "@type": "/cosmos.staking.v1beta1.MsgDelegate", + "delegator_address": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "validator_address": "cosmosvaloper1jlr62guqwrwkdt4m3y00zh2rrsamhjf9num5xr", + "amount": { + "denom": "uatom", + "amount": "17732657" + } + } + ], + "memo": "", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "A6m5hnVC/PG12hp5PV4UYZA8+jB5cCocwtoq839xsisj" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_LEGACY_AMINO_JSON" + } + }, + "sequence": "47" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "5194" + } + ], + "gas_limit": "944224", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "0KEZBKLnCWG3PMsSXGFnmQMwXZytLAZjIjizVIOeRdlTWBayWNsmD/v5egCusafuQpwkLBvorE5E5jnUoZHRNA==" + ] + }, + "tx_response": { + "height": "26282836", + "txhash": "FD334515F2D872B6689D7B52598796BF91C42111C857D6E80E984BC6DB4B0575", + "codespace": "", + "code": 0, + "data": "122D0A2B2F636F736D6F732E7374616B696E672E763162657461312E4D736744656C6567617465526573706F6E7365", + "raw_log": "", + "logs": [], + "info": "", + "gas_wanted": "944224", + "gas_used": "492115", + "tx": { + "@type": "/cosmos.tx.v1beta1.Tx", + "body": { + "messages": [ + { + "@type": "/cosmos.staking.v1beta1.MsgDelegate", + "delegator_address": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "validator_address": "cosmosvaloper1jlr62guqwrwkdt4m3y00zh2rrsamhjf9num5xr", + "amount": { + "denom": "uatom", + "amount": "17732657" + } + } + ], + "memo": "", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "A6m5hnVC/PG12hp5PV4UYZA8+jB5cCocwtoq839xsisj" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_LEGACY_AMINO_JSON" + } + }, + "sequence": "47" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "5194" + } + ], + "gas_limit": "944224", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "0KEZBKLnCWG3PMsSXGFnmQMwXZytLAZjIjizVIOeRdlTWBayWNsmD/v5egCusafuQpwkLBvorE5E5jnUoZHRNA==" + ] + }, + "timestamp": "2025-06-21T20:33:42Z", + "events": [ + { + "type": "tx", + "attributes": [ + { + "key": "acc_seq", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw/47", + "index": true + } + ] + }, + { + "type": "tx", + "attributes": [ + { + "key": "signature", + "value": "0KEZBKLnCWG3PMsSXGFnmQMwXZytLAZjIjizVIOeRdlTWBayWNsmD/v5egCusafuQpwkLBvorE5E5jnUoZHRNA==", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + }, + { + "key": "amount", + "value": "5194uatom", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "5194uatom", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "sender", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + }, + { + "key": "amount", + "value": "5194uatom", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "action", + "value": "/cosmos.staking.v1beta1.MsgDelegate", + "index": true + }, + { + "key": "sender", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + }, + { + "key": "module", + "value": "staking", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos1jv65s3grqf6v6jl3dp4t6c9t9rk99cd88lyufl", + "index": true + }, + { + "key": "amount", + "value": "27uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + }, + { + "key": "amount", + "value": "27uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + }, + { + "key": "sender", + "value": "cosmos1jv65s3grqf6v6jl3dp4t6c9t9rk99cd88lyufl", + "index": true + }, + { + "key": "amount", + "value": "27uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos1jv65s3grqf6v6jl3dp4t6c9t9rk99cd88lyufl", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "withdraw_rewards", + "attributes": [ + { + "key": "amount", + "value": "27uatom", + "index": true + }, + { + "key": "validator", + "value": "cosmosvaloper1jlr62guqwrwkdt4m3y00zh2rrsamhjf9num5xr", + "index": true + }, + { + "key": "delegator", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + }, + { + "key": "amount", + "value": "17732657uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos1fl48vsnmsdzcv85q5d2q4z5ajdha8yu34mf0eh", + "index": true + }, + { + "key": "amount", + "value": "17732657uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "delegate", + "attributes": [ + { + "key": "validator", + "value": "cosmosvaloper1jlr62guqwrwkdt4m3y00zh2rrsamhjf9num5xr", + "index": true + }, + { + "key": "delegator", + "value": "cosmos1z64xeecaqudhe2scx0m4mtvh7d0g5khyakpsmw", + "index": true + }, + { + "key": "amount", + "value": "17732657uatom", + "index": true + }, + { + "key": "new_shares", + "value": "17732657.000000000000000000", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "2839uatom", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos1nnu2u874qluh5p2z2pvzlljjjgkg6dc92fv0nr", + "index": true + }, + { + "key": "amount", + "value": "2839uatom", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos1nnu2u874qluh5p2z2pvzlljjjgkg6dc92fv0nr", + "index": true + }, + { + "key": "sender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "2839uatom", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + } + ] + }, + { + "type": "fee_pay", + "attributes": [ + { + "key": "fee", + "value": "2355uatom", + "index": true + } + ] + }, + { + "type": "tip_pay", + "attributes": [ + { + "key": "tip", + "value": "2839uatom", + "index": true + }, + { + "key": "tip_payee", + "value": "cosmos1nnu2u874qluh5p2z2pvzlljjjgkg6dc92fv0nr", + "index": true + } + ] + } + ] + } + } \ No newline at end of file diff --git a/core/crates/gem_cosmos/testdata/reverted_transfer_spam.json b/core/crates/gem_cosmos/testdata/reverted_transfer_spam.json new file mode 100644 index 0000000000..64f96479d6 --- /dev/null +++ b/core/crates/gem_cosmos/testdata/reverted_transfer_spam.json @@ -0,0 +1,148 @@ +{ + "tx": { + "body": { + "messages": [ + { + "@type": "/cosmos.bank.v1beta1.MsgSend", + "from_address": "cosmos1spam28rxfh39axghzlcusd98qhpkdarcq6umev2", + "to_address": "cosmos1hgp84me0lze8t4jfrwsr05aep2kr57zrk4gecx", + "amount": [ + { + "denom": "uatom", + "amount": "99999999999999999999999999999999" + } + ] + } + ], + "memo": "reverted spam transfer", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "A7Y9+R2a6pVf07i5P0dYqv5vLAkUn1d2P7YwW1Q4hH9S" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "42" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "2500" + } + ], + "gas_limit": "200000", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "Q2xpZW50U2lnbmF0dXJlRG9lc05vdE1hdHRlckZvclBhcnNpbmdUZXN0cw==" + ] + }, + "tx_response": { + "height": "26260000", + "txhash": "F8D05E52F23159D4545E516775E87DE6678AD2AA0B01BB8911C61861B8A60D10", + "codespace": "bank", + "code": 5, + "data": "", + "raw_log": "failed to execute message; message index: 0: 99999999999999999999999999999999uatom is larger than available balance: insufficient funds", + "logs": [], + "info": "", + "gas_wanted": "200000", + "gas_used": "81234", + "tx": { + "@type": "/cosmos.tx.v1beta1.Tx", + "body": { + "messages": [ + { + "@type": "/cosmos.bank.v1beta1.MsgSend", + "from_address": "cosmos1spam28rxfh39axghzlcusd98qhpkdarcq6umev2", + "to_address": "cosmos1hgp84me0lze8t4jfrwsr05aep2kr57zrk4gecx", + "amount": [ + { + "denom": "uatom", + "amount": "99999999999999999999999999999999" + } + ] + } + ], + "memo": "reverted spam transfer", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "A7Y9+R2a6pVf07i5P0dYqv5vLAkUn1d2P7YwW1Q4hH9S" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "42" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "2500" + } + ], + "gas_limit": "200000", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "Q2xpZW50U2lnbmF0dXJlRG9lc05vdE1hdHRlckZvclBhcnNpbmdUZXN0cw==" + ] + }, + "timestamp": "2026-05-19T19:05:46Z", + "events": [ + { + "type": "tx", + "attributes": [ + { + "key": "fee", + "value": "2500uatom" + }, + { + "key": "acc_seq", + "value": "cosmos1spam28rxfh39axghzlcusd98qhpkdarcq6umev2/42" + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "action", + "value": "/cosmos.bank.v1beta1.MsgSend" + }, + { + "key": "sender", + "value": "cosmos1spam28rxfh39axghzlcusd98qhpkdarcq6umev2" + } + ] + } + ] + } +} diff --git a/core/crates/gem_cosmos/testdata/rewards.json b/core/crates/gem_cosmos/testdata/rewards.json new file mode 100644 index 0000000000..a86e64c2c0 --- /dev/null +++ b/core/crates/gem_cosmos/testdata/rewards.json @@ -0,0 +1,405 @@ +{ + "tx": { + "body": { + "messages": [ + { + "@type": "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", + "delegator_address": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "validator_address": "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3" + } + ], + "memo": "", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "Awf5uBsxmmhvEmYBwdHwAzTlni8GFyKmFXWfhI1+PnSR" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "49" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "25000" + } + ], + "gas_limit": "750000", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "xqokRpGkGqpIr2OBzvipG92/g+IpX4RJjQcFe1FWtkBhMUOPXExipPBW6uG2KvpegqSgc1qd5CtgCwec2DYZvg==" + ] + }, + "tx_response": { + "height": "26283018", + "txhash": "0B615F5DDDB216574DF8AC07B104C3C902B23974C7957DF4275E1572CDDAFCB4", + "codespace": "", + "code": 0, + "data": "12D7080A3F2F636F736D6F732E646973747269627574696F6E2E763162657461312E4D7367576974686472617744656C656761746F72526577617264526573706F6E73651293080A4C0A446962632F303032354638413837343634413437314536364232333443344639334145433542344441334434324437393836343531413035393237333432363239304444351204313333330A4C0A446962632F303534383932443642423433414638423933414143323841413546443730313944324335394131354441464436463435433146413242463942444132323435341204313037370A4A0A446962632F35434145373434433839424337304145374233383031394131454446383331393942374531304630304631363045374634463132424341374133324137454535120231330A4C0A446962632F364238413346354332414435314344363137314641343141374538433335414435393441423639323236343338444239343435303433364541353742334138391204333432380A4C0A446962632F373135424436333443463444393134433345453933423046384139443235313442373433463646453336424338303236334431424335454534423343354434301204313939320A4B0A446962632F3838444341413433413943443039394531463942424238304239413930463634373832454241313135413834423243443833393837353741444134463442343012033230310A4A0A446962632F41344439394537313644393141353739414333413936383441414237423543423041303836314444334444393432393031443937304544423637383738363045120233390A550A446962632F42303131433141304144354537313746363734424135394644384530354232463934364534464434314339434233333131433935463745443442383135363230120D313532343538373830363532360A4B0A446962632F4230353533394236364237324532373339423938364238363339314535443038463132423844354432433241374638463843463941444636373444464132333112033237370A570A446962632F42333841414130463741334543344437433845313244464133334646393332303546453741343237333841344230353930453246463135424336304136313242120F3431333534373132373335343938390A4C0A446962632F443431454343384645463142374539433442434335384231333632353838343230383533413944304238393845444435313344394237394146464131393543381204323134300A4B0A446962632F4539324530374536383730354641443133333035454539433733363834423330413742363641353246353443393839303332374530413443304631443232453312033130380A4A0A446962632F46413333443232454544363531444332443235313331354141453245374335424139323444333038303831454539373630414536353341413246363636314342120236320A100A057561746F6D120732333835353138", + "raw_log": "", + "logs": [], + "info": "", + "gas_wanted": "750000", + "gas_used": "715754", + "tx": { + "@type": "/cosmos.tx.v1beta1.Tx", + "body": { + "messages": [ + { + "@type": "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", + "delegator_address": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "validator_address": "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3" + } + ], + "memo": "", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "Awf5uBsxmmhvEmYBwdHwAzTlni8GFyKmFXWfhI1+PnSR" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "49" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "25000" + } + ], + "gas_limit": "750000", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "xqokRpGkGqpIr2OBzvipG92/g+IpX4RJjQcFe1FWtkBhMUOPXExipPBW6uG2KvpegqSgc1qd5CtgCwec2DYZvg==" + ] + }, + "timestamp": "2025-06-21T20:51:28Z", + "events": [ + { + "type": "tx", + "attributes": [ + { + "key": "acc_seq", + "value": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj/49", + "index": true + } + ] + }, + { + "type": "tx", + "attributes": [ + { + "key": "signature", + "value": "xqokRpGkGqpIr2OBzvipG92/g+IpX4RJjQcFe1FWtkBhMUOPXExipPBW6uG2KvpegqSgc1qd5CtgCwec2DYZvg==", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "index": true + }, + { + "key": "amount", + "value": "25000uatom", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "25000uatom", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "sender", + "value": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "index": true + }, + { + "key": "amount", + "value": "25000uatom", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "action", + "value": "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", + "index": true + }, + { + "key": "sender", + "value": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "index": true + }, + { + "key": "module", + "value": "distribution", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos1jv65s3grqf6v6jl3dp4t6c9t9rk99cd88lyufl", + "index": true + }, + { + "key": "amount", + "value": "1333ibc/0025F8A87464A471E66B234C4F93AEC5B4DA3D42D7986451A059273426290DD5,1077ibc/054892D6BB43AF8B93AAC28AA5FD7019D2C59A15DAFD6F45C1FA2BF9BDA22454,13ibc/5CAE744C89BC70AE7B38019A1EDF83199B7E10F00F160E7F4F12BCA7A32A7EE5,3428ibc/6B8A3F5C2AD51CD6171FA41A7E8C35AD594AB69226438DB94450436EA57B3A89,1992ibc/715BD634CF4D914C3EE93B0F8A9D2514B743F6FE36BC80263D1BC5EE4B3C5D40,201ibc/88DCAA43A9CD099E1F9BBB80B9A90F64782EBA115A84B2CD8398757ADA4F4B40,39ibc/A4D99E716D91A579AC3A9684AAB7B5CB0A0861DD3DD942901D970EDB6787860E,1524587806526ibc/B011C1A0AD5E717F674BA59FD8E05B2F946E4FD41C9CB3311C95F7ED4B815620,277ibc/B05539B66B72E2739B986B86391E5D08F12B8D5D2C2A7F8F8CF9ADF674DFA231,413547127354989ibc/B38AAA0F7A3EC4D7C8E12DFA33FF93205FE7A42738A4B0590E2FF15BC60A612B,2140ibc/D41ECC8FEF1B7E9C4BCC58B1362588420853A9D0B898EDD513D9B79AFFA195C8,108ibc/E92E07E68705FAD13305EE9C73684B30A7B66A52F54C9890327E0A4C0F1D22E3,62ibc/FA33D22EED651DC2D251315AAE2E7C5BA924D308081EE9760AE653AA2F6661CB,2385518uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "index": true + }, + { + "key": "amount", + "value": "1333ibc/0025F8A87464A471E66B234C4F93AEC5B4DA3D42D7986451A059273426290DD5,1077ibc/054892D6BB43AF8B93AAC28AA5FD7019D2C59A15DAFD6F45C1FA2BF9BDA22454,13ibc/5CAE744C89BC70AE7B38019A1EDF83199B7E10F00F160E7F4F12BCA7A32A7EE5,3428ibc/6B8A3F5C2AD51CD6171FA41A7E8C35AD594AB69226438DB94450436EA57B3A89,1992ibc/715BD634CF4D914C3EE93B0F8A9D2514B743F6FE36BC80263D1BC5EE4B3C5D40,201ibc/88DCAA43A9CD099E1F9BBB80B9A90F64782EBA115A84B2CD8398757ADA4F4B40,39ibc/A4D99E716D91A579AC3A9684AAB7B5CB0A0861DD3DD942901D970EDB6787860E,1524587806526ibc/B011C1A0AD5E717F674BA59FD8E05B2F946E4FD41C9CB3311C95F7ED4B815620,277ibc/B05539B66B72E2739B986B86391E5D08F12B8D5D2C2A7F8F8CF9ADF674DFA231,413547127354989ibc/B38AAA0F7A3EC4D7C8E12DFA33FF93205FE7A42738A4B0590E2FF15BC60A612B,2140ibc/D41ECC8FEF1B7E9C4BCC58B1362588420853A9D0B898EDD513D9B79AFFA195C8,108ibc/E92E07E68705FAD13305EE9C73684B30A7B66A52F54C9890327E0A4C0F1D22E3,62ibc/FA33D22EED651DC2D251315AAE2E7C5BA924D308081EE9760AE653AA2F6661CB,2385518uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "index": true + }, + { + "key": "sender", + "value": "cosmos1jv65s3grqf6v6jl3dp4t6c9t9rk99cd88lyufl", + "index": true + }, + { + "key": "amount", + "value": "1333ibc/0025F8A87464A471E66B234C4F93AEC5B4DA3D42D7986451A059273426290DD5,1077ibc/054892D6BB43AF8B93AAC28AA5FD7019D2C59A15DAFD6F45C1FA2BF9BDA22454,13ibc/5CAE744C89BC70AE7B38019A1EDF83199B7E10F00F160E7F4F12BCA7A32A7EE5,3428ibc/6B8A3F5C2AD51CD6171FA41A7E8C35AD594AB69226438DB94450436EA57B3A89,1992ibc/715BD634CF4D914C3EE93B0F8A9D2514B743F6FE36BC80263D1BC5EE4B3C5D40,201ibc/88DCAA43A9CD099E1F9BBB80B9A90F64782EBA115A84B2CD8398757ADA4F4B40,39ibc/A4D99E716D91A579AC3A9684AAB7B5CB0A0861DD3DD942901D970EDB6787860E,1524587806526ibc/B011C1A0AD5E717F674BA59FD8E05B2F946E4FD41C9CB3311C95F7ED4B815620,277ibc/B05539B66B72E2739B986B86391E5D08F12B8D5D2C2A7F8F8CF9ADF674DFA231,413547127354989ibc/B38AAA0F7A3EC4D7C8E12DFA33FF93205FE7A42738A4B0590E2FF15BC60A612B,2140ibc/D41ECC8FEF1B7E9C4BCC58B1362588420853A9D0B898EDD513D9B79AFFA195C8,108ibc/E92E07E68705FAD13305EE9C73684B30A7B66A52F54C9890327E0A4C0F1D22E3,62ibc/FA33D22EED651DC2D251315AAE2E7C5BA924D308081EE9760AE653AA2F6661CB,2385518uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos1jv65s3grqf6v6jl3dp4t6c9t9rk99cd88lyufl", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "withdraw_rewards", + "attributes": [ + { + "key": "amount", + "value": "1333ibc/0025F8A87464A471E66B234C4F93AEC5B4DA3D42D7986451A059273426290DD5,1077ibc/054892D6BB43AF8B93AAC28AA5FD7019D2C59A15DAFD6F45C1FA2BF9BDA22454,13ibc/5CAE744C89BC70AE7B38019A1EDF83199B7E10F00F160E7F4F12BCA7A32A7EE5,3428ibc/6B8A3F5C2AD51CD6171FA41A7E8C35AD594AB69226438DB94450436EA57B3A89,1992ibc/715BD634CF4D914C3EE93B0F8A9D2514B743F6FE36BC80263D1BC5EE4B3C5D40,201ibc/88DCAA43A9CD099E1F9BBB80B9A90F64782EBA115A84B2CD8398757ADA4F4B40,39ibc/A4D99E716D91A579AC3A9684AAB7B5CB0A0861DD3DD942901D970EDB6787860E,1524587806526ibc/B011C1A0AD5E717F674BA59FD8E05B2F946E4FD41C9CB3311C95F7ED4B815620,277ibc/B05539B66B72E2739B986B86391E5D08F12B8D5D2C2A7F8F8CF9ADF674DFA231,413547127354989ibc/B38AAA0F7A3EC4D7C8E12DFA33FF93205FE7A42738A4B0590E2FF15BC60A612B,2140ibc/D41ECC8FEF1B7E9C4BCC58B1362588420853A9D0B898EDD513D9B79AFFA195C8,108ibc/E92E07E68705FAD13305EE9C73684B30A7B66A52F54C9890327E0A4C0F1D22E3,62ibc/FA33D22EED651DC2D251315AAE2E7C5BA924D308081EE9760AE653AA2F6661CB,2385518uatom", + "index": true + }, + { + "key": "validator", + "value": "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3", + "index": true + }, + { + "key": "delegator", + "value": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "21527uatom", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos14sk4vptumprktehmuvvf0yynarjy4gv0kvaut8", + "index": true + }, + { + "key": "amount", + "value": "21527uatom", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos14sk4vptumprktehmuvvf0yynarjy4gv0kvaut8", + "index": true + }, + { + "key": "sender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "21527uatom", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + } + ] + }, + { + "type": "fee_pay", + "attributes": [ + { + "key": "fee", + "value": "3473uatom", + "index": true + } + ] + }, + { + "type": "tip_pay", + "attributes": [ + { + "key": "tip", + "value": "21527uatom", + "index": true + }, + { + "key": "tip_payee", + "value": "cosmos14sk4vptumprktehmuvvf0yynarjy4gv0kvaut8", + "index": true + } + ] + } + ] + } +} \ No newline at end of file diff --git a/core/crates/gem_cosmos/testdata/staking_delegations.json b/core/crates/gem_cosmos/testdata/staking_delegations.json new file mode 100644 index 0000000000..497c8e3f83 --- /dev/null +++ b/core/crates/gem_cosmos/testdata/staking_delegations.json @@ -0,0 +1,19 @@ +{ + "delegation_responses": [ + { + "delegation": { + "delegator_address": "cosmos1cvh8mpz04az0x7vht6h6ekksg8wd650r39ltwj", + "validator_address": "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3", + "shares": "10250000.000000000000000000" + }, + "balance": { + "denom": "uatom", + "amount": "10250000" + } + } + ], + "pagination": { + "next_key": null, + "total": "1" + } + } \ No newline at end of file diff --git a/core/crates/gem_cosmos/testdata/staking_rewards.json b/core/crates/gem_cosmos/testdata/staking_rewards.json new file mode 100644 index 0000000000..5bf3b1872b --- /dev/null +++ b/core/crates/gem_cosmos/testdata/staking_rewards.json @@ -0,0 +1,107 @@ +{ + "rewards": [ + { + "validator_address": "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3", + "reward": [ + { + "denom": "ibc/054892D6BB43AF8B93AAC28AA5FD7019D2C59A15DAFD6F45C1FA2BF9BDA22454", + "amount": "4.346891322677500000" + }, + { + "denom": "ibc/5CAE744C89BC70AE7B38019A1EDF83199B7E10F00F160E7F4F12BCA7A32A7EE5", + "amount": "0.152511614054750000" + }, + { + "denom": "ibc/6B8A3F5C2AD51CD6171FA41A7E8C35AD594AB69226438DB94450436EA57B3A89", + "amount": "285.603032296846250000" + }, + { + "denom": "ibc/715BD634CF4D914C3EE93B0F8A9D2514B743F6FE36BC80263D1BC5EE4B3C5D40", + "amount": "26.047397140736250000" + }, + { + "denom": "ibc/88DCAA43A9CD099E1F9BBB80B9A90F64782EBA115A84B2CD8398757ADA4F4B40", + "amount": "1.997714358644750000" + }, + { + "denom": "ibc/A4D99E716D91A579AC3A9684AAB7B5CB0A0861DD3DD942901D970EDB6787860E", + "amount": "0.461898095343500000" + }, + { + "denom": "ibc/B011C1A0AD5E717F674BA59FD8E05B2F946E4FD41C9CB3311C95F7ED4B815620", + "amount": "18445129051.170704434755500000" + }, + { + "denom": "ibc/B05539B66B72E2739B986B86391E5D08F12B8D5D2C2A7F8F8CF9ADF674DFA231", + "amount": "3.975457435290500000" + }, + { + "denom": "ibc/B38AAA0F7A3EC4D7C8E12DFA33FF93205FE7A42738A4B0590E2FF15BC60A612B", + "amount": "1225911288432.820839448689250000" + }, + { + "denom": "ibc/D41ECC8FEF1B7E9C4BCC58B1362588420853A9D0B898EDD513D9B79AFFA195C8", + "amount": "33.155925160280250000" + }, + { + "denom": "ibc/E92E07E68705FAD13305EE9C73684B30A7B66A52F54C9890327E0A4C0F1D22E3", + "amount": "1.005940983827000000" + }, + { + "denom": "uatom", + "amount": "307413.550355612684250000" + } + ] + } + ], + "total": [ + { + "denom": "ibc/054892D6BB43AF8B93AAC28AA5FD7019D2C59A15DAFD6F45C1FA2BF9BDA22454", + "amount": "4.346891322677500000" + }, + { + "denom": "ibc/5CAE744C89BC70AE7B38019A1EDF83199B7E10F00F160E7F4F12BCA7A32A7EE5", + "amount": "0.152511614054750000" + }, + { + "denom": "ibc/6B8A3F5C2AD51CD6171FA41A7E8C35AD594AB69226438DB94450436EA57B3A89", + "amount": "285.603032296846250000" + }, + { + "denom": "ibc/715BD634CF4D914C3EE93B0F8A9D2514B743F6FE36BC80263D1BC5EE4B3C5D40", + "amount": "26.047397140736250000" + }, + { + "denom": "ibc/88DCAA43A9CD099E1F9BBB80B9A90F64782EBA115A84B2CD8398757ADA4F4B40", + "amount": "1.997714358644750000" + }, + { + "denom": "ibc/A4D99E716D91A579AC3A9684AAB7B5CB0A0861DD3DD942901D970EDB6787860E", + "amount": "0.461898095343500000" + }, + { + "denom": "ibc/B011C1A0AD5E717F674BA59FD8E05B2F946E4FD41C9CB3311C95F7ED4B815620", + "amount": "18445129051.170704434755500000" + }, + { + "denom": "ibc/B05539B66B72E2739B986B86391E5D08F12B8D5D2C2A7F8F8CF9ADF674DFA231", + "amount": "3.975457435290500000" + }, + { + "denom": "ibc/B38AAA0F7A3EC4D7C8E12DFA33FF93205FE7A42738A4B0590E2FF15BC60A612B", + "amount": "1225911288432.820839448689250000" + }, + { + "denom": "ibc/D41ECC8FEF1B7E9C4BCC58B1362588420853A9D0B898EDD513D9B79AFFA195C8", + "amount": "33.155925160280250000" + }, + { + "denom": "ibc/E92E07E68705FAD13305EE9C73684B30A7B66A52F54C9890327E0A4C0F1D22E3", + "amount": "1.005940983827000000" + }, + { + "denom": "uatom", + "amount": "307413.550355612684250000" + } + ] + } \ No newline at end of file diff --git a/core/crates/gem_cosmos/testdata/staking_validators.json b/core/crates/gem_cosmos/testdata/staking_validators.json new file mode 100644 index 0000000000..53400905ec --- /dev/null +++ b/core/crates/gem_cosmos/testdata/staking_validators.json @@ -0,0 +1,71 @@ +{ + "validators": [ + { + "operator_address": "cosmosvaloper1q9p73lx07tjqc34vs8jrsu5pg3q4ha534uqv4w", + "consensus_pubkey": { + "@type": "/cosmos.crypto.ed25519.PubKey", + "key": "Y3FwPLeVHUhR+Or59OJ1SCq0OiS/tBye2YdKA3dzy/s=" + }, + "jailed": false, + "status": "BOND_STATUS_BONDED", + "tokens": "47803181", + "delegator_shares": "47803181.000000000000000000", + "description": { + "moniker": "Unstake as we will shut down", + "identity": "C58922A0F158B2D1", + "website": "https://www.3stakes.com", + "security_contact": "support@3stakes.com", + "details": "3Stakes.com is a Dutch team validating in the Ecosystem. Our operations will be CO2 neutral as we will offset any emissions created from our operations. We will be looking to create validators for new chains in the Cosmos ecosystem and participate in testnets." + }, + "unbonding_height": "27056706", + "unbonding_time": "2025-09-03T15:08:30.405051966Z", + "commission": { + "commission_rates": { + "rate": "0.050000000000000000", + "max_rate": "0.100000000000000000", + "max_change_rate": "0.010000000000000000" + }, + "update_time": "2022-04-15T08:52:23.793881126Z" + }, + "min_self_delegation": "1", + "unbonding_on_hold_ref_count": "0", + "unbonding_ids": [ + "1395381", + "1395868" + ] + }, + { + "operator_address": "cosmosvaloper1q6d3d089hg59x6gcx92uumx70s5y5wadklue8s", + "consensus_pubkey": { + "@type": "/cosmos.crypto.ed25519.PubKey", + "key": "uEUR1gpesU4bnSWL2TOXOf3org2mCYhQHMYkiCJyMD4=" + }, + "jailed": false, + "status": "BOND_STATUS_BONDED", + "tokens": "938561041511", + "delegator_shares": "938561041511.000000000000000000", + "description": { + "moniker": "Ubik Capital", + "identity": "8265DEAF50B61DF7", + "website": "https://www.ubik.capital", + "security_contact": "", + "details": "Ubik Capital secures major proof of stake networks and is a trusted staking provider with years of industry experience. By delegating to us, you agree to the Terms of Service at: https://ubik.capital" + }, + "unbonding_height": "16925816", + "unbonding_time": "2023-09-30T06:17:37.572905825Z", + "commission": { + "commission_rates": { + "rate": "0.050000000000000000", + "max_rate": "0.200000000000000000", + "max_change_rate": "0.010000000000000000" + }, + "update_time": "2024-03-20T10:40:35.784132044Z" + }, + "min_self_delegation": "1", + "unbonding_on_hold_ref_count": "0", + "unbonding_ids": [ + "131285" + ] + } + ] +} \ No newline at end of file diff --git a/core/crates/gem_cosmos/testdata/swap_execute_contract.json b/core/crates/gem_cosmos/testdata/swap_execute_contract.json new file mode 100644 index 0000000000..ca8760341b --- /dev/null +++ b/core/crates/gem_cosmos/testdata/swap_execute_contract.json @@ -0,0 +1,14 @@ +{ + "typeUrl": "/cosmwasm.wasm.v1.MsgExecuteContract", + "value": { + "sender": "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye", + "contract": "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6", + "msg": "{\"multicall\":{\"calls\":[{\"msg\":{\"stargate\":{\"type_url\":\"/osmosis.gamm.v1beta1.MsgSwapExactAmountIn\",\"value\":{\"sender\":\"osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6\",\"routes\":[{\"pool_id\":\"1135\",\"token_out_denom\":\"ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2\"}],\"token_in\":{\"denom\":\"uosmo\",\"amount\":\"0\"},\"token_out_min_amount\":\"180865\"}}},\"actions\":[{\"native_balance_fetch\":{\"denom\":\"uosmo\",\"replacer\":\"/stargate/value/token_in/amount\"}},{\"field_to_proto_binary\":{\"replacer\":\"/stargate/value\",\"proto_msg_type\":\"osmosis_swap_exact_amt_in\"}}]}],\"fallback_address\":\"osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye\"}}", + "funds": [ + { + "denom": "uosmo", + "amount": "10000000" + } + ] + } +} diff --git a/core/crates/gem_cosmos/testdata/swap_ibc_transfer.json b/core/crates/gem_cosmos/testdata/swap_ibc_transfer.json new file mode 100644 index 0000000000..3c28ba086c --- /dev/null +++ b/core/crates/gem_cosmos/testdata/swap_ibc_transfer.json @@ -0,0 +1,15 @@ +{ + "typeUrl": "/ibc.applications.transfer.v1.MsgTransfer", + "value": { + "sourcePort": "transfer", + "sourceChannel": "channel-141", + "token": { + "denom": "uatom", + "amount": "1000000" + }, + "sender": "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt", + "receiver": "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6", + "timeoutTimestamp": "1773632858715000064", + "memo": "{\"wasm\":{\"squid_request_id\":\"90b2b59ba3c36a5a73736e80288587f0\",\"contract\":\"osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6\",\"msg\":{\"multicall\":{\"calls\":[{\"msg\":{\"stargate\":{\"type_url\":\"/osmosis.gamm.v1beta1.MsgSwapExactAmountIn\",\"value\":{\"sender\":\"osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6\",\"routes\":[{\"pool_id\":\"1\",\"token_out_denom\":\"uosmo\"}],\"token_in\":{\"denom\":\"ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2\",\"amount\":\"0\"},\"token_out_min_amount\":\"54012132\"}}},\"actions\":[{\"native_balance_fetch\":{\"denom\":\"ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2\",\"replacer\":\"/stargate/value/token_in/amount\"}},{\"field_to_proto_binary\":{\"replacer\":\"/stargate/value\",\"proto_msg_type\":\"osmosis_swap_exact_amt_in\"}}]},{\"msg\":{\"bank\":{\"send\":{\"to_address\":\"osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye\",\"amount\":[{\"denom\":\"uosmo\",\"amount\":\"0\"}]}}},\"actions\":[{\"native_balance_fetch\":{\"denom\":\"uosmo\",\"replacer\":\"/bank/send/amount/0/amount\"}}]}],\"fallback_address\":\"osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye\"}}}}" + } +} diff --git a/core/crates/gem_cosmos/testdata/transaction_broadcast_failed.json b/core/crates/gem_cosmos/testdata/transaction_broadcast_failed.json new file mode 100644 index 0000000000..8a60c01942 --- /dev/null +++ b/core/crates/gem_cosmos/testdata/transaction_broadcast_failed.json @@ -0,0 +1,17 @@ +{ + "tx_response": { + "height": "0", + "txhash": "46FCC65619C9B0473C6D35A0672114452901AF326CF6ADC8803333EF2EA22C25", + "codespace": "sdk", + "code": 4, + "data": "", + "raw_log": "signature verification failed; please verify account number (1343971) and chain-id (cosmoshub-4): (unable to verify single signer signature): unauthorized", + "logs": [], + "info": "", + "gas_wanted": "0", + "gas_used": "0", + "tx": null, + "timestamp": "", + "events": [] + } +} \ No newline at end of file diff --git a/core/crates/gem_cosmos/testdata/transaction_broadcast_success.json b/core/crates/gem_cosmos/testdata/transaction_broadcast_success.json new file mode 100644 index 0000000000..08ba5b9565 --- /dev/null +++ b/core/crates/gem_cosmos/testdata/transaction_broadcast_success.json @@ -0,0 +1,17 @@ +{ + "tx_response": { + "height": "0", + "txhash": "62CC30451A83DE94A76D73812A7A16A947802085C77B0166A862C39ABF8B324D", + "codespace": "", + "code": 0, + "data": "", + "raw_log": "", + "logs": [], + "info": "", + "gas_wanted": "0", + "gas_used": "0", + "tx": null, + "timestamp": "", + "events": [] + } +} \ No newline at end of file diff --git a/core/crates/gem_cosmos/testdata/transfer.json b/core/crates/gem_cosmos/testdata/transfer.json new file mode 100644 index 0000000000..045c9b5627 --- /dev/null +++ b/core/crates/gem_cosmos/testdata/transfer.json @@ -0,0 +1,392 @@ +{ + "tx": { + "body": { + "messages": [ + { + "@type": "/cosmos.bank.v1beta1.MsgSend", + "from_address": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "to_address": "cosmos1hgp84me0lze8t4jfrwsr05aep2kr57zrk4gecx", + "amount": [ + { + "denom": "uatom", + "amount": "50000000" + } + ] + } + ], + "memo": "6439432658467882", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "AzMDx9YcjoWC3m7VLmInQI65V6vJjvV1lRTNrBu1zQpC" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "447" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "1600" + } + ], + "gas_limit": "128000", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "A2EIWAUJijHrYPwLXRoAC9Sd17/Udxd83z2m70+rw/EkoiJVla+ZC14Ul5jdzWREjeoJ5TwKmz+WHGnM/ytfnw==" + ] + }, + "tx_response": { + "height": "26257963", + "txhash": "BC5E330F0AFA34489B9796E8101A2B027CC8AE8E820AFC7901C3C1E75C2895DD", + "codespace": "", + "code": 0, + "data": "12260A242F636F736D6F732E62616E6B2E763162657461312E4D736753656E64526573706F6E7365", + "raw_log": "", + "logs": [], + "info": "", + "gas_wanted": "128000", + "gas_used": "105321", + "tx": { + "@type": "/cosmos.tx.v1beta1.Tx", + "body": { + "messages": [ + { + "@type": "/cosmos.bank.v1beta1.MsgSend", + "from_address": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "to_address": "cosmos1hgp84me0lze8t4jfrwsr05aep2kr57zrk4gecx", + "amount": [ + { + "denom": "uatom", + "amount": "50000000" + } + ] + } + ], + "memo": "6439432658467882", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "AzMDx9YcjoWC3m7VLmInQI65V6vJjvV1lRTNrBu1zQpC" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "447" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "1600" + } + ], + "gas_limit": "128000", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "A2EIWAUJijHrYPwLXRoAC9Sd17/Udxd83z2m70+rw/EkoiJVla+ZC14Ul5jdzWREjeoJ5TwKmz+WHGnM/ytfnw==" + ] + }, + "timestamp": "2025-06-20T04:09:19Z", + "events": [ + { + "type": "tx", + "attributes": [ + { + "key": "acc_seq", + "value": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw/447", + "index": true + } + ] + }, + { + "type": "tx", + "attributes": [ + { + "key": "signature", + "value": "A2EIWAUJijHrYPwLXRoAC9Sd17/Udxd83z2m70+rw/EkoiJVla+ZC14Ul5jdzWREjeoJ5TwKmz+WHGnM/ytfnw==", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "index": true + }, + { + "key": "amount", + "value": "1600uatom", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "1600uatom", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "sender", + "value": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "index": true + }, + { + "key": "amount", + "value": "1600uatom", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "action", + "value": "/cosmos.bank.v1beta1.MsgSend", + "index": true + }, + { + "key": "sender", + "value": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "index": true + }, + { + "key": "module", + "value": "bank", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "index": true + }, + { + "key": "amount", + "value": "50000000uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos1hgp84me0lze8t4jfrwsr05aep2kr57zrk4gecx", + "index": true + }, + { + "key": "amount", + "value": "50000000uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos1hgp84me0lze8t4jfrwsr05aep2kr57zrk4gecx", + "index": true + }, + { + "key": "sender", + "value": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "index": true + }, + { + "key": "amount", + "value": "50000000uatom", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos1wev8ptzj27aueu04wgvvl4gvurax6rj5f0v7rw", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "1179uatom", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "cosmos1dj6867rt9u6scyaxpwmh6wv2eqhfqzv9c6t99d", + "index": true + }, + { + "key": "amount", + "value": "1179uatom", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "cosmos1dj6867rt9u6scyaxpwmh6wv2eqhfqzv9c6t99d", + "index": true + }, + { + "key": "sender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + }, + { + "key": "amount", + "value": "1179uatom", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", + "index": true + } + ] + }, + { + "type": "fee_pay", + "attributes": [ + { + "key": "fee", + "value": "421uatom", + "index": true + } + ] + }, + { + "type": "tip_pay", + "attributes": [ + { + "key": "tip", + "value": "1179uatom", + "index": true + }, + { + "key": "tip_payee", + "value": "cosmos1dj6867rt9u6scyaxpwmh6wv2eqhfqzv9c6t99d", + "index": true + } + ] + } + ] + } + } \ No newline at end of file diff --git a/core/crates/gem_cosmos/testdata/transfer_ibc.json b/core/crates/gem_cosmos/testdata/transfer_ibc.json new file mode 100644 index 0000000000..b0e916f32f --- /dev/null +++ b/core/crates/gem_cosmos/testdata/transfer_ibc.json @@ -0,0 +1,121 @@ +{ + "tx": { + "body": { + "messages": [ + { + "@type": "/cosmos.bank.v1beta1.MsgSend", + "from_address": "cosmos1dej28rxfh39axghzlcusd98qhpkdarcqqu23ua", + "to_address": "cosmos1n3wq399w3s6reslvngve5quw85xusf7la5cpfs", + "amount": [ + { + "denom": "ibc/915992C8486D299941292A913640167F0BA02DC2F599BFFED0732CE932C2FCC4", + "amount": "20000000000" + } + ] + } + ], + "memo": "", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "AuS4hp70wrJ+NGtUf1/RT9qfD//J2FBo5My46SB9lNAD" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "37754" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "689" + } + ], + "gas_limit": "137732", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "e+zuKkXx/jW/UFLv2KV9zUrg/ayPcnmR+W8y4Z9nA8MX1pUklWqtu0hBWmjSjpXG6Dr3Cis0CTwERo7idIa00g==" + ] + }, + "tx_response": { + "height": "30033798", + "txhash": "90BBC25C199B58E6A4C9A2A3448C64E853B4E8DF3E88B1F6E35DE9FBF20400F6", + "codespace": "", + "code": 0, + "data": "12260A242F636F736D6F732E62616E6B2E763162657461312E4D736753656E64526573706F6E7365", + "raw_log": "", + "logs": [], + "info": "", + "gas_wanted": "137732", + "gas_used": "115692", + "tx": { + "@type": "/cosmos.tx.v1beta1.Tx", + "body": { + "messages": [ + { + "@type": "/cosmos.bank.v1beta1.MsgSend", + "from_address": "cosmos1dej28rxfh39axghzlcusd98qhpkdarcqqu23ua", + "to_address": "cosmos1n3wq399w3s6reslvngve5quw85xusf7la5cpfs", + "amount": [ + { + "denom": "ibc/915992C8486D299941292A913640167F0BA02DC2F599BFFED0732CE932C2FCC4", + "amount": "20000000000" + } + ] + } + ], + "memo": "", + "timeout_height": "0", + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "AuS4hp70wrJ+NGtUf1/RT9qfD//J2FBo5My46SB9lNAD" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "37754" + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "689" + } + ], + "gas_limit": "137732", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "e+zuKkXx/jW/UFLv2KV9zUrg/ayPcnmR+W8y4Z9nA8MX1pUklWqtu0hBWmjSjpXG6Dr3Cis0CTwERo7idIa00g==" + ] + }, + "timestamp": "2026-03-03T17:24:03Z", + "events": [] + } +} diff --git a/core/crates/gem_cosmos/testdata/transfer_thorchain.json b/core/crates/gem_cosmos/testdata/transfer_thorchain.json new file mode 100644 index 0000000000..42121a189d --- /dev/null +++ b/core/crates/gem_cosmos/testdata/transfer_thorchain.json @@ -0,0 +1,301 @@ +{ + "tx": { + "body": { + "messages": [ + { + "@type": "/types.MsgSend", + "from_address": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "to_address": "thor1tpr8cqs2uncwfsggevmha4q4tc9eelu9r00cxx", + "amount": [ + { + "denom": "rune", + "amount": "50000000000" + } + ] + } + ], + "memo": "thankyou", + "timeout_height": "0", + "unordered": false, + "timeout_timestamp": null, + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "Alrm2LoVQm3LnEYWBathH3pgoRZpGZ7qiSM6XmvzU148" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "939" + } + ], + "fee": { + "amount": [], + "gas_limit": "6000000", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "BvZBPRCggvYYOTyG+a6okZFkUEgY/gG6larWf7B5+L8pda6H+PSzKjKz9DiRZkx9PEV/xB3zC0Des2r6G+3/5g==" + ] + }, + "tx_response": { + "height": "23089913", + "txhash": "C4ED43321E89497C96B7084BE2AA2640EFB10A93A82F396B9FC7A8308F9662AE", + "codespace": "", + "code": 0, + "data": "12110A0F2F74797065732E4D7367456D707479", + "raw_log": "", + "logs": [], + "info": "", + "gas_wanted": "-1", + "gas_used": "76512", + "tx": { + "@type": "/cosmos.tx.v1beta1.Tx", + "body": { + "messages": [ + { + "@type": "/types.MsgSend", + "from_address": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "to_address": "thor1tpr8cqs2uncwfsggevmha4q4tc9eelu9r00cxx", + "amount": [ + { + "denom": "rune", + "amount": "50000000000" + } + ] + } + ], + "memo": "thankyou", + "timeout_height": "0", + "unordered": false, + "timeout_timestamp": null, + "extension_options": [], + "non_critical_extension_options": [] + }, + "auth_info": { + "signer_infos": [ + { + "public_key": { + "@type": "/cosmos.crypto.secp256k1.PubKey", + "key": "Alrm2LoVQm3LnEYWBathH3pgoRZpGZ7qiSM6XmvzU148" + }, + "mode_info": { + "single": { + "mode": "SIGN_MODE_DIRECT" + } + }, + "sequence": "939" + } + ], + "fee": { + "amount": [], + "gas_limit": "6000000", + "payer": "", + "granter": "" + }, + "tip": null + }, + "signatures": [ + "BvZBPRCggvYYOTyG+a6okZFkUEgY/gG6larWf7B5+L8pda6H+PSzKjKz9DiRZkx9PEV/xB3zC0Des2r6G+3/5g==" + ] + }, + "timestamp": "2025-10-03T00:39:55Z", + "events": [ + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "index": true + }, + { + "key": "amount", + "value": "2000000rune", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "thor1dheycdevq39qlkxs2a6wuuzyn4aqxhve4qxtxt", + "index": true + }, + { + "key": "amount", + "value": "2000000rune", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "thor1dheycdevq39qlkxs2a6wuuzyn4aqxhve4qxtxt", + "index": true + }, + { + "key": "sender", + "value": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "index": true + }, + { + "key": "amount", + "value": "2000000rune", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "index": true + } + ] + }, + { + "type": "tx", + "attributes": [ + { + "key": "acc_seq", + "value": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp/939", + "index": true + } + ] + }, + { + "type": "tx", + "attributes": [ + { + "key": "signature", + "value": "BvZBPRCggvYYOTyG+a6okZFkUEgY/gG6larWf7B5+L8pda6H+PSzKjKz9DiRZkx9PEV/xB3zC0Des2r6G+3/5g==", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "action", + "value": "/types.MsgSend", + "index": true + }, + { + "key": "sender", + "value": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "index": true + }, + { + "key": "module", + "value": "MsgSend", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_spent", + "attributes": [ + { + "key": "spender", + "value": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "index": true + }, + { + "key": "amount", + "value": "50000000000rune", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "coin_received", + "attributes": [ + { + "key": "receiver", + "value": "thor1tpr8cqs2uncwfsggevmha4q4tc9eelu9r00cxx", + "index": true + }, + { + "key": "amount", + "value": "50000000000rune", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "transfer", + "attributes": [ + { + "key": "recipient", + "value": "thor1tpr8cqs2uncwfsggevmha4q4tc9eelu9r00cxx", + "index": true + }, + { + "key": "sender", + "value": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "index": true + }, + { + "key": "amount", + "value": "50000000000rune", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + }, + { + "type": "message", + "attributes": [ + { + "key": "sender", + "value": "thor1rr6rahhd4sy76a7rdxkjaen2q4k4pw2g06w7qp", + "index": true + }, + { + "key": "msg_index", + "value": "0", + "index": true + } + ] + } + ] + } +} \ No newline at end of file diff --git a/core/crates/gem_encoding/Cargo.toml b/core/crates/gem_encoding/Cargo.toml new file mode 100644 index 0000000000..a3aba23d6d --- /dev/null +++ b/core/crates/gem_encoding/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "gem_encoding" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = ["base64"] +base32 = ["dep:data-encoding"] +base58 = ["dep:bs58"] +base64 = ["dep:base64"] +protobuf = [] + +[dependencies] +base64 = { workspace = true, optional = true } +bs58 = { workspace = true, optional = true } +data-encoding = { version = "2.11.0", optional = true } diff --git a/core/crates/gem_encoding/src/base32.rs b/core/crates/gem_encoding/src/base32.rs new file mode 100644 index 0000000000..fce509c241 --- /dev/null +++ b/core/crates/gem_encoding/src/base32.rs @@ -0,0 +1,10 @@ +use crate::{EncodingError, EncodingType}; +use data_encoding::BASE32_NOPAD; + +pub fn encode_base32(bytes: &[u8]) -> String { + BASE32_NOPAD.encode(bytes) +} + +pub fn decode_base32(value: &[u8]) -> Result, EncodingError> { + BASE32_NOPAD.decode(value).map_err(|e| EncodingError::Invalid(EncodingType::Base32, e.to_string())) +} diff --git a/core/crates/gem_encoding/src/base58.rs b/core/crates/gem_encoding/src/base58.rs new file mode 100644 index 0000000000..8b21d35130 --- /dev/null +++ b/core/crates/gem_encoding/src/base58.rs @@ -0,0 +1,9 @@ +use crate::{EncodingError, EncodingType}; + +pub fn encode_base58(bytes: &[u8]) -> String { + bs58::encode(bytes).into_string() +} + +pub fn decode_base58(value: &str) -> Result, EncodingError> { + bs58::decode(value).into_vec().map_err(|e| EncodingError::Invalid(EncodingType::Base58, e.to_string())) +} diff --git a/core/crates/gem_encoding/src/base64.rs b/core/crates/gem_encoding/src/base64.rs new file mode 100644 index 0000000000..05a8111197 --- /dev/null +++ b/core/crates/gem_encoding/src/base64.rs @@ -0,0 +1,26 @@ +use crate::EncodingError; +use base64::{Engine, engine::general_purpose}; + +pub fn encode_base64(bytes: &[u8]) -> String { + general_purpose::STANDARD.encode(bytes) +} + +pub fn decode_base64(value: &str) -> Result, EncodingError> { + Ok(general_purpose::STANDARD.decode(value)?) +} + +pub fn decode_base64_no_pad(value: &str) -> Result, EncodingError> { + Ok(general_purpose::STANDARD_NO_PAD.decode(value)?) +} + +pub fn encode_base64_url(bytes: &[u8]) -> String { + general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +pub fn decode_base64_url(value: &str) -> Result, EncodingError> { + Ok(general_purpose::URL_SAFE_NO_PAD.decode(value)?) +} + +pub fn decode_base64_url_padded(value: &str) -> Result, EncodingError> { + Ok(general_purpose::URL_SAFE.decode(value)?) +} diff --git a/core/crates/gem_encoding/src/error.rs b/core/crates/gem_encoding/src/error.rs new file mode 100644 index 0000000000..1c624f8c95 --- /dev/null +++ b/core/crates/gem_encoding/src/error.rs @@ -0,0 +1,46 @@ +use std::fmt; + +#[derive(Debug)] +pub enum EncodingError { + Invalid(EncodingType, String), +} + +#[derive(Debug)] +pub enum EncodingType { + Base32, + Base58, + Base64, +} + +impl fmt::Display for EncodingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(encoding, msg) => write!(f, "invalid {encoding}: {msg}"), + } + } +} + +impl fmt::Display for EncodingType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Base32 => write!(f, "base32"), + Self::Base58 => write!(f, "base58"), + Self::Base64 => write!(f, "base64"), + } + } +} + +impl std::error::Error for EncodingError {} + +impl From for String { + fn from(err: EncodingError) -> Self { + err.to_string() + } +} + +#[cfg(feature = "base64")] +impl From for EncodingError { + fn from(err: base64::DecodeError) -> Self { + Self::Invalid(EncodingType::Base64, err.to_string()) + } +} diff --git a/core/crates/gem_encoding/src/lib.rs b/core/crates/gem_encoding/src/lib.rs new file mode 100644 index 0000000000..a2f9f82a26 --- /dev/null +++ b/core/crates/gem_encoding/src/lib.rs @@ -0,0 +1,19 @@ +mod error; +#[cfg(feature = "protobuf")] +pub mod protobuf; + +#[cfg(feature = "base32")] +mod base32; +#[cfg(feature = "base58")] +mod base58; +#[cfg(feature = "base64")] +mod base64; + +pub use error::{EncodingError, EncodingType}; + +#[cfg(feature = "base32")] +pub use crate::base32::{decode_base32, encode_base32}; +#[cfg(feature = "base58")] +pub use crate::base58::{decode_base58, encode_base58}; +#[cfg(feature = "base64")] +pub use crate::base64::{decode_base64, decode_base64_no_pad, decode_base64_url, decode_base64_url_padded, encode_base64, encode_base64_url}; diff --git a/core/crates/gem_encoding/src/protobuf/decode.rs b/core/crates/gem_encoding/src/protobuf/decode.rs new file mode 100644 index 0000000000..9991476748 --- /dev/null +++ b/core/crates/gem_encoding/src/protobuf/decode.rs @@ -0,0 +1,223 @@ +use super::message::{MessageDecode, MessageResult}; +use super::wire::{WIRE_FIXED32, WIRE_FIXED64, WIRE_LENGTH_DELIMITED, WIRE_VARINT}; + +#[derive(Clone, Copy)] +pub struct Field<'a> { + pub number: u32, + value: FieldValue<'a>, +} + +impl<'a> Field<'a> { + pub fn varint(self) -> MessageResult { + match self.value { + FieldValue::Varint(value) => Ok(value), + _ => Err(format!("protobuf field {} is not a varint", self.number).into()), + } + } + + pub fn fixed64(self) -> MessageResult { + match self.value { + FieldValue::Fixed64(value) => Ok(value), + _ => Err(format!("protobuf field {} is not fixed64", self.number).into()), + } + } + + pub fn fixed32(self) -> MessageResult { + match self.value { + FieldValue::Fixed32(value) => Ok(value), + _ => Err(format!("protobuf field {} is not fixed32", self.number).into()), + } + } + + pub fn bytes(self) -> MessageResult<&'a [u8]> { + match self.value { + FieldValue::Bytes(value) => Ok(value), + _ => Err(format!("protobuf field {} is not length-delimited", self.number).into()), + } + } + + pub fn string(self) -> MessageResult { + Ok(std::str::from_utf8(self.bytes()?)?.to_string()) + } + + pub fn message(self) -> MessageResult { + T::decode(self.bytes()?) + } +} + +#[derive(Clone, Copy)] +enum FieldValue<'a> { + Varint(u64), + Fixed64(u64), + Bytes(&'a [u8]), + Fixed32(u32), +} + +pub fn visit_fields(data: &[u8], mut visitor: impl FnMut(Field<'_>) -> MessageResult<()>) -> MessageResult<()> { + let mut position = 0; + while position < data.len() { + let tag = read_varint(data, &mut position)?; + let number = (tag >> 3) as u32; + let wire_type = (tag & 0x07) as u8; + let value = match wire_type { + WIRE_VARINT => FieldValue::Varint(read_varint(data, &mut position)?), + WIRE_FIXED64 => FieldValue::Fixed64(read_fixed64(data, &mut position)?), + WIRE_LENGTH_DELIMITED => { + let len = read_varint(data, &mut position)? as usize; + let end = position.checked_add(len).ok_or("invalid protobuf length")?; + if end > data.len() { + return Err("truncated protobuf length-delimited field".into()); + } + let value = &data[position..end]; + position = end; + FieldValue::Bytes(value) + } + WIRE_FIXED32 => FieldValue::Fixed32(read_fixed32(data, &mut position)?), + _ => return Err(format!("unsupported protobuf wire type {wire_type}").into()), + }; + visitor(Field { number, value })?; + } + Ok(()) +} + +fn read_varint(data: &[u8], position: &mut usize) -> MessageResult { + let mut result = 0u64; + let mut shift = 0u32; + while *position < data.len() { + let byte = data[*position]; + *position += 1; + let value = u64::from(byte & 0x7f); + if shift == 63 && value > 1 { + return Err("protobuf varint overflows u64".into()); + } + result |= value << shift; + if byte & 0x80 == 0 { + return Ok(result); + } + shift += 7; + if shift >= 64 { + return Err("protobuf varint is too long".into()); + } + } + Err("truncated protobuf varint".into()) +} + +fn read_fixed64(data: &[u8], position: &mut usize) -> MessageResult { + let end = position.checked_add(8).ok_or("invalid protobuf fixed64 length")?; + if end > data.len() { + return Err("truncated protobuf fixed64".into()); + } + let value = u64::from_le_bytes(data[*position..end].try_into()?); + *position = end; + Ok(value) +} + +fn read_fixed32(data: &[u8], position: &mut usize) -> MessageResult { + let end = position.checked_add(4).ok_or("invalid protobuf fixed32 length")?; + if end > data.len() { + return Err("truncated protobuf fixed32".into()); + } + let value = u32::from_le_bytes(data[*position..end].try_into()?); + *position = end; + Ok(value) +} + +#[macro_export] +macro_rules! proto_decode { + ($type:ty { $($number:literal => $field_name:ident : $field_kind:ident),* $(,)? }) => { + impl $crate::protobuf::MessageDecode for $type { + fn decode(data: &[u8]) -> $crate::protobuf::MessageResult { + let mut value = Self::default(); + $crate::protobuf::visit_fields(data, |field| { + match field.number { + $( + $number => { + $crate::protobuf::field_codec::$field_kind::decode(&mut value.$field_name, field)?; + } + )* + _ => {} + } + Ok(()) + })?; + Ok(value) + } + } + }; + ($type:ty { $($number:literal => |$value:ident, $field:ident| $body:expr),* $(,)? }) => { + impl $crate::protobuf::MessageDecode for $type { + fn decode(data: &[u8]) -> $crate::protobuf::MessageResult { + let mut value = Self::default(); + $crate::protobuf::visit_fields(data, |field| { + match field.number { + $( + $number => { + (|$value: &mut Self, $field: $crate::protobuf::Field<'_>| -> $crate::protobuf::MessageResult<()> { + $body; + Ok(()) + })(&mut value, field)?; + } + )* + _ => {} + } + Ok(()) + })?; + Ok(value) + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protobuf::{encode_raw_varint_field, encode_string_field}; + + #[test] + fn test_visit_fields() { + let message = [encode_string_field(1, "test"), encode_raw_varint_field(2, 7)].concat(); + let mut values = Vec::new(); + + visit_fields(&message, |field| { + match field.number { + 1 => values.push(field.string()?), + 2 => values.push(field.varint()?.to_string()), + _ => {} + } + Ok(()) + }) + .unwrap(); + + assert_eq!(values, vec!["test", "7"]); + } + + #[test] + fn test_visit_fields_rejects_varint_overflow() { + let message = [vec![0x08], vec![0xff; 9], vec![0x02]].concat(); + + assert_eq!(visit_fields(&message, |_| Ok(())).unwrap_err().to_string(), "protobuf varint overflows u64"); + } + + #[derive(Debug, Default, PartialEq)] + struct TestMessage { + name: Option, + decimals: Option, + } + + crate::proto_decode!(TestMessage { + 1 => name: optional_string, + 2 => decimals: optional_varint_u32, + }); + + #[test] + fn test_proto_decode() { + let data = [encode_string_field(1, "USDC"), encode_raw_varint_field(2, 6)].concat(); + + assert_eq!( + TestMessage::decode(&data).unwrap(), + TestMessage { + name: Some("USDC".into()), + decimals: Some(6), + } + ); + } +} diff --git a/core/crates/gem_encoding/src/protobuf/encode.rs b/core/crates/gem_encoding/src/protobuf/encode.rs new file mode 100644 index 0000000000..0c82e33565 --- /dev/null +++ b/core/crates/gem_encoding/src/protobuf/encode.rs @@ -0,0 +1,160 @@ +use super::message::MessageEncode; +use super::wire::{WIRE_LENGTH_DELIMITED, WIRE_VARINT}; + +pub fn encode_varint(value: u64) -> Vec { + let mut buf = Vec::new(); + let mut value = value; + + while value >= 0x80 { + buf.push((value as u8) | 0x80); + value >>= 7; + } + + buf.push(value as u8); + buf +} + +fn field_tag(field_number: u32, wire_type: u8) -> Vec { + encode_varint(((field_number as u64) << 3) | wire_type as u64) +} + +pub fn encode_varint_field(field_number: u32, value: u64) -> Vec { + if value == 0 { + return Vec::new(); + } + + encode_raw_varint_field(field_number, value) +} + +pub fn encode_raw_varint_field(field_number: u32, value: u64) -> Vec { + [field_tag(field_number, WIRE_VARINT), encode_varint(value)].concat() +} + +pub fn encode_bytes_field(field_number: u32, data: &[u8]) -> Vec { + if data.is_empty() { + return Vec::new(); + } + + let tag = field_tag(field_number, WIRE_LENGTH_DELIMITED); + let len = encode_varint(data.len() as u64); + let mut buf = Vec::with_capacity(tag.len() + len.len() + data.len()); + buf.extend_from_slice(&tag); + buf.extend_from_slice(&len); + buf.extend_from_slice(data); + buf +} + +pub fn encode_string_field(field_number: u32, value: &str) -> Vec { + encode_bytes_field(field_number, value.as_bytes()) +} + +pub fn encode_message_field(field_number: u32, message: &[u8]) -> Vec { + if message.is_empty() { + return Vec::new(); + } + + encode_bytes_field(field_number, message) +} + +pub fn encode_optional_message_field(field_number: u32, value: Option<&impl MessageEncode>) -> Vec { + value.map(|value| encode_message_field(field_number, &value.encode())).unwrap_or_default() +} + +pub fn encode_optional_string_field(field_number: u32, value: Option<&str>) -> Vec { + value.map(|value| encode_string_field(field_number, value)).unwrap_or_default() +} + +pub fn encode_optional_bytes_field(field_number: u32, value: Option<&[u8]>) -> Vec { + value.map(|value| encode_bytes_field(field_number, value)).unwrap_or_default() +} + +pub fn encode_optional_u64_field(field_number: u32, value: Option) -> Vec { + value.map(|value| encode_raw_varint_field(field_number, value)).unwrap_or_default() +} + +pub fn encode_optional_bool_field(field_number: u32, value: Option) -> Vec { + value.map(|value| encode_raw_varint_field(field_number, u64::from(value))).unwrap_or_default() +} + +#[macro_export] +macro_rules! proto_encode { + ($type:ty { $($number:literal => $field_name:ident : $field_kind:ident),* $(,)? }) => { + impl $crate::protobuf::MessageEncode for $type { + fn encode(&self) -> Vec { + let value = self; + let mut data = Vec::new(); + $( + data.extend($crate::protobuf::field_codec::$field_kind::encode($number, &value.$field_name)); + )* + data + } + } + }; + ($type:ty as $value:ident { $($field:expr),* $(,)? }) => { + impl $crate::protobuf::MessageEncode for $type { + fn encode(&self) -> Vec { + let $value = self; + let mut data = Vec::new(); + $( + data.extend($field); + )* + data + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_varint() { + assert_eq!(encode_varint(0), vec![0]); + assert_eq!(encode_varint(1), vec![1]); + assert_eq!(encode_varint(127), vec![127]); + assert_eq!(encode_varint(128), vec![0x80, 0x01]); + assert_eq!(encode_varint(300), vec![0xAC, 0x02]); + } + + #[test] + fn test_encode_string_field() { + let result = encode_string_field(1, "test"); + + assert_eq!(result, vec![0x0A, 4, b't', b'e', b's', b't']); + } + + #[test] + fn test_empty_fields_omitted() { + assert!(encode_varint_field(1, 0).is_empty()); + assert!(encode_string_field(1, "").is_empty()); + assert!(encode_bytes_field(1, &[]).is_empty()); + } + + #[test] + fn test_encode_optional_u64_field_keeps_present_zero() { + assert_eq!(encode_optional_u64_field(1, Some(0)), vec![0x08, 0x00]); + assert!(encode_optional_u64_field(1, None).is_empty()); + } + + #[derive(Debug, Default)] + struct TestMessage { + name: Option, + decimals: Option, + } + + crate::proto_encode!(TestMessage { + 1 => name: optional_string, + 2 => decimals: optional_varint_u32, + }); + + #[test] + fn test_proto_encode() { + let message = TestMessage { + name: Some("USDC".into()), + decimals: Some(6), + }; + + assert_eq!(message.encode(), [encode_string_field(1, "USDC"), encode_raw_varint_field(2, 6)].concat()); + } +} diff --git a/core/crates/gem_encoding/src/protobuf/field_codec.rs b/core/crates/gem_encoding/src/protobuf/field_codec.rs new file mode 100644 index 0000000000..14d6623b47 --- /dev/null +++ b/core/crates/gem_encoding/src/protobuf/field_codec.rs @@ -0,0 +1,202 @@ +use super::{ + Field, MessageDecode, MessageEncode, MessageResult, encode_bytes_field, encode_message_field, encode_optional_bool_field, encode_optional_bytes_field, + encode_optional_message_field, encode_optional_string_field, encode_optional_u64_field, encode_string_field, +}; + +pub mod optional_string { + use super::*; + + pub fn decode(value: &mut Option, field: Field<'_>) -> MessageResult<()> { + *value = Some(field.string()?); + Ok(()) + } + + pub fn encode(field_number: u32, value: &Option) -> Vec { + encode_optional_string_field(field_number, value.as_deref()) + } +} + +pub mod string { + use super::*; + + pub fn decode(value: &mut String, field: Field<'_>) -> MessageResult<()> { + *value = field.string()?; + Ok(()) + } + + pub fn encode(field_number: u32, value: &str) -> Vec { + encode_string_field(field_number, value) + } +} + +pub mod optional_bytes { + use super::*; + + pub fn decode(value: &mut Option>, field: Field<'_>) -> MessageResult<()> { + *value = Some(field.bytes()?.to_vec()); + Ok(()) + } + + pub fn encode(field_number: u32, value: &Option>) -> Vec { + encode_optional_bytes_field(field_number, value.as_deref()) + } +} + +pub mod optional_bool { + use super::*; + + pub fn decode(value: &mut Option, field: Field<'_>) -> MessageResult<()> { + *value = Some(field.varint()? != 0); + Ok(()) + } + + pub fn encode(field_number: u32, value: &Option) -> Vec { + encode_optional_bool_field(field_number, *value) + } +} + +pub mod optional_varint_u32 { + use super::*; + + pub fn decode(value: &mut Option, field: Field<'_>) -> MessageResult<()> { + *value = Some(field.varint()? as u32); + Ok(()) + } + + pub fn encode(field_number: u32, value: &Option) -> Vec { + encode_optional_u64_field(field_number, value.map(u64::from)) + } +} + +pub mod optional_varint_u64 { + use super::*; + + pub fn decode(value: &mut Option, field: Field<'_>) -> MessageResult<()> { + *value = Some(field.varint()?); + Ok(()) + } + + pub fn encode(field_number: u32, value: &Option) -> Vec { + encode_optional_u64_field(field_number, *value) + } +} + +pub mod varint_u64 { + use super::*; + + pub fn decode(value: &mut u64, field: Field<'_>) -> MessageResult<()> { + *value = field.varint()?; + Ok(()) + } + + pub fn encode(field_number: u32, value: &u64) -> Vec { + encode_optional_u64_field(field_number, Some(*value)) + } +} + +pub mod optional_varint_i32 { + use super::*; + + pub fn decode(value: &mut Option, field: Field<'_>) -> MessageResult<()> { + *value = Some(field.varint()? as i32); + Ok(()) + } + + pub fn encode(field_number: u32, value: &Option) -> Vec { + encode_optional_u64_field(field_number, value.map(|value| value as u64)) + } +} + +pub mod optional_varint_i64 { + use super::*; + + pub fn decode(value: &mut Option, field: Field<'_>) -> MessageResult<()> { + *value = Some(field.varint()? as i64); + Ok(()) + } + + pub fn encode(field_number: u32, value: &Option) -> Vec { + encode_optional_u64_field(field_number, value.map(|value| value as u64)) + } +} + +pub mod varint_i32 { + use super::*; + + pub fn decode(value: &mut i32, field: Field<'_>) -> MessageResult<()> { + *value = field.varint()? as i32; + Ok(()) + } + + pub fn encode(field_number: u32, value: &i32) -> Vec { + encode_optional_u64_field(field_number, Some(*value as u64)) + } +} + +pub mod varint_i64 { + use super::*; + + pub fn decode(value: &mut i64, field: Field<'_>) -> MessageResult<()> { + *value = field.varint()? as i64; + Ok(()) + } + + pub fn encode(field_number: u32, value: &i64) -> Vec { + encode_optional_u64_field(field_number, Some(*value as u64)) + } +} + +pub mod optional_enum_varint { + use super::*; + + pub fn encode>(field_number: u32, value: &Option) -> Vec { + encode_optional_u64_field(field_number, value.map(Into::into)) + } +} + +pub mod optional_message { + use super::*; + + pub fn decode(value: &mut Option, field: Field<'_>) -> MessageResult<()> { + *value = Some(field.message()?); + Ok(()) + } + + pub fn encode(field_number: u32, value: &Option) -> Vec { + encode_optional_message_field(field_number, value.as_ref()) + } +} + +pub mod repeated_message { + use super::*; + + pub fn decode(value: &mut Vec, field: Field<'_>) -> MessageResult<()> { + value.push(field.message()?); + Ok(()) + } + + pub fn encode(field_number: u32, value: &[T]) -> Vec { + value.iter().flat_map(|value| encode_message_field(field_number, &value.encode())).collect() + } +} + +pub mod repeated_string { + use super::*; + + pub fn decode(value: &mut Vec, field: Field<'_>) -> MessageResult<()> { + value.push(field.string()?); + Ok(()) + } + + pub fn encode(field_number: u32, value: &[String]) -> Vec { + value.iter().flat_map(|value| encode_string_field(field_number, value)).collect() + } +} + +pub mod repeated_bytes { + use super::*; + + pub fn encode(field_number: u32, value: &[Vec]) -> Vec { + value.iter().flat_map(|value| encode_bytes_field(field_number, value)).collect() + } +} diff --git a/core/crates/gem_encoding/src/protobuf/grpc.rs b/core/crates/gem_encoding/src/protobuf/grpc.rs new file mode 100644 index 0000000000..d4cf9582ca --- /dev/null +++ b/core/crates/gem_encoding/src/protobuf/grpc.rs @@ -0,0 +1,46 @@ +use super::message::{MessageDecode, MessageEncode, MessageResult}; + +pub fn encode_grpc_frame(payload: &[u8]) -> Vec { + let mut body = Vec::with_capacity(5 + payload.len()); + body.push(0); + body.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + body.extend_from_slice(payload); + body +} + +pub fn encode_grpc_message(message: &M) -> Vec { + encode_grpc_frame(&message.encode()) +} + +pub fn decode_grpc_frame(body: &[u8]) -> MessageResult<&[u8]> { + if body.len() < 5 { + return Err("gRPC response is missing message frame".into()); + } + if body[0] != 0 { + return Err("compressed gRPC responses are not supported".into()); + } + let len = u32::from_be_bytes(body[1..5].try_into()?) as usize; + let end = 5usize.checked_add(len).ok_or("invalid gRPC response frame length")?; + if body.len() < end { + return Err("truncated gRPC response frame".into()); + } + Ok(&body[5..end]) +} + +pub fn decode_grpc_message(body: &[u8]) -> MessageResult { + M::decode(decode_grpc_frame(body)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protobuf::encode_string_field; + + #[test] + fn test_grpc_frame() { + let payload = encode_string_field(1, "test"); + let frame = encode_grpc_frame(&payload); + + assert_eq!(decode_grpc_frame(&frame).unwrap(), payload.as_slice()); + } +} diff --git a/core/crates/gem_encoding/src/protobuf/message.rs b/core/crates/gem_encoding/src/protobuf/message.rs new file mode 100644 index 0000000000..f0c9f03fb7 --- /dev/null +++ b/core/crates/gem_encoding/src/protobuf/message.rs @@ -0,0 +1,15 @@ +use std::error::Error; + +pub type MessageResult = Result>; + +pub trait MessageEncode { + fn encode(&self) -> Vec; +} + +pub trait MessageDecode: Sized { + fn decode(data: &[u8]) -> MessageResult; +} + +pub trait Message: MessageEncode + MessageDecode {} + +impl Message for T where T: MessageEncode + MessageDecode {} diff --git a/core/crates/gem_encoding/src/protobuf/mod.rs b/core/crates/gem_encoding/src/protobuf/mod.rs new file mode 100644 index 0000000000..bceac37c8e --- /dev/null +++ b/core/crates/gem_encoding/src/protobuf/mod.rs @@ -0,0 +1,15 @@ +mod decode; +mod encode; +pub mod field_codec; +mod grpc; +mod message; +mod wire; + +pub use crate::{proto_decode, proto_encode}; +pub use decode::{Field, visit_fields}; +pub use encode::{ + encode_bytes_field, encode_message_field, encode_optional_bool_field, encode_optional_bytes_field, encode_optional_message_field, encode_optional_string_field, + encode_optional_u64_field, encode_raw_varint_field, encode_string_field, encode_varint, encode_varint_field, +}; +pub use grpc::{decode_grpc_frame, decode_grpc_message, encode_grpc_frame, encode_grpc_message}; +pub use message::{Message, MessageDecode, MessageEncode, MessageResult}; diff --git a/core/crates/gem_encoding/src/protobuf/wire.rs b/core/crates/gem_encoding/src/protobuf/wire.rs new file mode 100644 index 0000000000..88c927d54e --- /dev/null +++ b/core/crates/gem_encoding/src/protobuf/wire.rs @@ -0,0 +1,4 @@ +pub(super) const WIRE_VARINT: u8 = 0; +pub(super) const WIRE_FIXED64: u8 = 1; +pub(super) const WIRE_LENGTH_DELIMITED: u8 = 2; +pub(super) const WIRE_FIXED32: u8 = 5; diff --git a/core/crates/gem_evm/Cargo.toml b/core/crates/gem_evm/Cargo.toml new file mode 100644 index 0000000000..6cbbb2a50e --- /dev/null +++ b/core/crates/gem_evm/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "gem_evm" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +rpc = ["dep:async-trait", "dep:chain_traits"] +reqwest = ["gem_jsonrpc/reqwest", "gem_client/reqwest", "dep:reqwest"] +signer = ["dep:alloy-signer-local", "dep:alloy-network", "dep:alloy-consensus"] +chain_integration_tests = ["rpc", "reqwest", "primitives/testkit", "settings/testkit"] +testkit = [] + +[dependencies] +primitives = { path = "../primitives" } +chain_primitives = { path = "../chain_primitives" } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["client"] } +gem_client = { path = "../gem_client" } +gem_hash = { path = "../gem_hash" } +gem_bsc = { path = "../gem_bsc" } +signer = { path = "../signer" } + +hex = { workspace = true } +alloy-primitives = { workspace = true } +alloy-sol-types = { workspace = true, features = ["eip712-serde"] } +alloy-dyn-abi = { workspace = true, features = ["eip712"] } +alloy-json-abi = { workspace = true } +alloy-signer-local = { workspace = true, optional = true } +alloy-network = { workspace = true, optional = true } +alloy-consensus = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +num-bigint = { workspace = true } +num-traits = { workspace = true } +bigdecimal = { workspace = true } +url = { workspace = true } +reqwest = { workspace = true, features = ["json"], optional = true } + +# rpc feature +chrono = { workspace = true } +async-trait = { workspace = true, optional = true } +chain_traits = { path = "../chain_traits", optional = true } + +[dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } +gem_client = { path = "../gem_client", features = ["testkit"] } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["testkit"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +num-bigint = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } +async-trait = { workspace = true } diff --git a/core/crates/gem_evm/src/across/contracts/config_store.rs b/core/crates/gem_evm/src/across/contracts/config_store.rs new file mode 100644 index 0000000000..73da0bdbc5 --- /dev/null +++ b/core/crates/gem_evm/src/across/contracts/config_store.rs @@ -0,0 +1,9 @@ +use alloy_sol_types::sol; + +sol! { + interface AcrossConfigStore { + function l1TokenConfig(address l1Token) returns (string); + } +} + +// cast call 0x3B03509645713718B78951126E0A6de6f10043f5 "l1TokenConfig(address)(string)" 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 --rpc-url diff --git a/core/crates/gem_evm/src/across/contracts/hub_pool.rs b/core/crates/gem_evm/src/across/contracts/hub_pool.rs new file mode 100644 index 0000000000..49077efc21 --- /dev/null +++ b/core/crates/gem_evm/src/across/contracts/hub_pool.rs @@ -0,0 +1,36 @@ +use alloy_sol_types::sol; + +// https://docs.across.to/reference/selected-contract-functions +// https://github.com/across-protocol/contracts/blob/master/contracts/HubPool.sol +sol! { + interface HubPoolInterface { + // Each whitelisted L1 token has an associated pooledToken struct that contains all information used to track the + // cumulative LP positions and if this token is enabled for deposits. + struct PooledToken { + // LP token given to LPs of a specific L1 token. + address lpToken; + // True if accepting new LP's. + bool isEnabled; + // Timestamp of last LP fee update. + uint32 lastLpFeeUpdate; + // Number of LP funds sent via pool rebalances to SpokePools and are expected to be sent + // back later. + int256 utilizedReserves; + // Number of LP funds held in contract less utilized reserves. + uint256 liquidReserves; + // Number of LP funds reserved to pay out to LPs as fees. + uint256 undistributedLpFees; + } + + function paused() external view returns (bool); + function sync(address l1Token) public override nonReentrant; + function getCurrentTime() public view returns (uint256); + function pooledTokens(address l1Token) external view returns (PooledToken memory); + function liquidityUtilizationCurrent(address l1Token) external returns (uint256); + function liquidityUtilizationPostRelay(address l1Token, uint256 relayedAmount) external returns (uint256); + } +} + +// cast call 0xc186fA914353c44b2E33eBE05f21846F1048bEda "liquidityUtilizationCurrent(address)(uint256)" 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 --rpc-url +// cast call 0xc186fA914353c44b2E33eBE05f21846F1048bEda "pooledTokens(address)(address,bool,uint32,int256,uint256,uint256)" 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 --rpc-url +// cast call 0xc186fA914353c44b2E33eBE05f21846F1048bEda "getCurrentTime()(uint256)" 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 --rpc-url diff --git a/core/crates/gem_evm/src/across/contracts/mod.rs b/core/crates/gem_evm/src/across/contracts/mod.rs new file mode 100644 index 0000000000..74fa97dd6a --- /dev/null +++ b/core/crates/gem_evm/src/across/contracts/mod.rs @@ -0,0 +1,8 @@ +pub mod config_store; +pub mod hub_pool; +pub mod multicall_handler; +pub mod spoke_pool; + +pub use config_store::AcrossConfigStore; +pub use hub_pool::HubPoolInterface; +pub use spoke_pool::V3SpokePoolInterface; diff --git a/core/crates/gem_evm/src/across/contracts/multicall_handler.rs b/core/crates/gem_evm/src/across/contracts/multicall_handler.rs new file mode 100644 index 0000000000..96e0b87b3d --- /dev/null +++ b/core/crates/gem_evm/src/across/contracts/multicall_handler.rs @@ -0,0 +1,18 @@ +use alloy_sol_types::sol; + +// https://github.com/across-protocol/contracts/blob/master/contracts/handlers/MulticallHandler.sol +sol! { + struct Call { + address target; + bytes callData; + uint256 value; + } + + struct Instructions { + // Calls that will be attempted. + Call[] calls; + // Where the tokens go if any part of the call fails. + // Leftover tokens are sent here as well if the action succeeds. + address fallbackRecipient; + } +} diff --git a/core/crates/gem_evm/src/across/contracts/spoke_pool.rs b/core/crates/gem_evm/src/across/contracts/spoke_pool.rs new file mode 100644 index 0000000000..66ac7840a3 --- /dev/null +++ b/core/crates/gem_evm/src/across/contracts/spoke_pool.rs @@ -0,0 +1,115 @@ +use alloy_sol_types::sol; + +// https://docs.across.to/reference/selected-contract-functions +// https://github.com/across-protocol/contracts/blob/master/contracts/interfaces/SpokePoolInterface.sol +sol! { + // Contains structs and functions used by SpokePool contracts to facilitate universal settlement. + interface V3SpokePoolInterface { + // This struct represents the data to fully specify a **unique** relay submitted on this chain. + // This data is hashed with the chainId() and saved by the SpokePool to prevent collisions and protect against + // replay attacks on other chains. If any portion of this data differs, the relay is considered to be + // completely distinct. + struct V3RelayData { + // The address that made the deposit on the origin chain. + address depositor; + // The recipient address on the destination chain. + address recipient; + // This is the exclusive relayer who can fill the deposit before the exclusivity deadline. + address exclusiveRelayer; + // Token that is deposited on origin chain by depositor. + address inputToken; + // Token that is received on destination chain by recipient. + address outputToken; + // The amount of input token deposited by depositor. + uint256 inputAmount; + // The amount of output token to be received by recipient. + uint256 outputAmount; + // Origin chain id. + uint256 originChainId; + // The id uniquely identifying this deposit on the origin chain. + uint32 depositId; + // The timestamp on the destination chain after which this deposit can no longer be filled. + uint32 fillDeadline; + // The timestamp on the destination chain after which any relayer can fill the deposit. + uint32 exclusivityDeadline; + // Data that is forwarded to the recipient. + bytes message; + } + + event V3FundsDeposited( + address inputToken, + address outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 indexed destinationChainId, + uint32 indexed depositId, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityDeadline, + address indexed depositor, + address recipient, + address exclusiveRelayer, + bytes message + ); + + event FundsDeposited( + bytes32 inputToken, + bytes32 outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 indexed destinationChainId, + uint256 indexed depositId, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityDeadline, + bytes32 indexed depositor, + bytes32 recipient, + bytes32 exclusiveRelayer, + bytes message + ); + + struct RelayExecutionInfo { + bytes32 updatedRecipient; + bytes32 updatedMessage; + uint256 updatedOutputAmount; + uint8 fillType; + } + + event FilledRelay( + bytes32 inputToken, + bytes32 outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 indexed originChainId, + uint256 indexed depositId, + uint256 destinationChainId, + uint32 quoteTimestamp, + uint32 fillDeadline, + bytes32 indexed depositor, + bytes32 recipient, + bytes32 exclusiveRelayer, + bytes32 relayer, + bytes32 settlementContract, + RelayExecutionInfo relayExecutionInfo + ); + + function getCurrentTime() public view virtual returns (uint256); + + function depositV3( + address depositor, + address recipient, + address inputToken, + address outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + address exclusiveRelayer, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityDeadline, + bytes calldata message + ) external payable; + + function fillV3Relay(V3RelayData calldata relayData, uint256 repaymentChainId) external; + } +} diff --git a/core/crates/gem_evm/src/across/deployment.rs b/core/crates/gem_evm/src/across/deployment.rs new file mode 100644 index 0000000000..7276388a08 --- /dev/null +++ b/core/crates/gem_evm/src/across/deployment.rs @@ -0,0 +1,265 @@ +use super::fees::CapitalCostConfig; +use crate::ether_conv::EtherConv; +use alloy_primitives::map::HashSet; +use num_bigint::BigInt; +use primitives::{AssetId, Chain, asset_constants::*, contract_constants::*}; +use std::{collections::HashMap, vec}; + +/// https://docs.across.to/developer-docs/developers/contract-addresses +pub struct AcrossDeployment { + pub chain_id: u32, + pub spoke_pool: &'static str, +} + +#[derive(Debug)] +pub struct AssetMapping { + pub capital_cost: CapitalCostConfig, + pub set: HashSet, +} + +impl AcrossDeployment { + pub fn deployment_by_chain(chain: &Chain) -> Option { + let chain_id: u32 = chain.network_id().parse().ok()?; + let spoke_pool = match chain { + Chain::Ethereum => ETHEREUM_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Arbitrum => ARBITRUM_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Base => BASE_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Blast => BLAST_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Linea => LINEA_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Optimism => OPTIMISM_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Polygon => POLYGON_ACROSS_SPOKE_POOL_CONTRACT, + Chain::World => WORLD_ACROSS_SPOKE_POOL_CONTRACT, + Chain::ZkSync => ZKSYNC_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Ink => INK_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Unichain => UNICHAIN_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Monad => MONAD_ACROSS_SPOKE_POOL_CONTRACT, + Chain::SmartChain => SMARTCHAIN_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Hyperliquid => HYPEREVM_ACROSS_SPOKE_POOL_CONTRACT, + Chain::Plasma => PLASMA_ACROSS_SPOKE_POOL_CONTRACT, + _ => return None, + }; + Some(Self { chain_id, spoke_pool }) + } + + pub fn multicall_handler(&self) -> String { + match self.chain_id { + // Linea + 59144 => LINEA_ACROSS_MULTICALL_HANDLER_CONTRACT.into(), + // zkSync + 324 => ZKSYNC_ACROSS_MULTICALL_HANDLER_CONTRACT.into(), + // SmartChain + 56 => SMARTCHAIN_ACROSS_MULTICALL_HANDLER_CONTRACT.into(), + // Monad + 143 => MONAD_ACROSS_MULTICALL_HANDLER_CONTRACT.into(), + // HyperEvm | Plasma + 999 => HYPEREVM_ACROSS_MULTICALL_HANDLER_CONTRACT.into(), + 9745 => PLASMA_ACROSS_MULTICALL_HANDLER_CONTRACT.into(), + _ => ETHEREUM_ACROSS_MULTICALL_HANDLER_CONTRACT.into(), + } + } + + pub fn supported_assets() -> HashMap> { + HashMap::from([ + ( + Chain::Ethereum, + vec![ETHEREUM_USDC_ASSET_ID.clone(), ETHEREUM_USDT_ASSET_ID.clone(), ETHEREUM_WETH_ASSET_ID.clone()], + ), + ( + Chain::Optimism, + vec![OPTIMISM_USDT_ASSET_ID.clone(), OPTIMISM_USDC_ASSET_ID.clone(), OPTIMISM_WETH_ASSET_ID.clone()], + ), + ( + Chain::Polygon, + vec![POLYGON_USDC_ASSET_ID.clone(), POLYGON_USDT_ASSET_ID.clone(), POLYGON_WETH_ASSET_ID.clone()], + ), + ( + Chain::Arbitrum, + vec![ARBITRUM_USDT_ASSET_ID.clone(), ARBITRUM_USDC_ASSET_ID.clone(), ARBITRUM_WETH_ASSET_ID.clone()], + ), + (Chain::Base, vec![BASE_WETH_ASSET_ID.clone(), BASE_USDC_ASSET_ID.clone()]), + (Chain::Hyperliquid, vec![HYPEREVM_USDC_ASSET_ID.clone(), HYPEREVM_USDT_ASSET_ID.clone()]), + (Chain::Linea, vec![LINEA_USDT_ASSET_ID.clone(), LINEA_WETH_ASSET_ID.clone()]), + (Chain::ZkSync, vec![ZKSYNC_WETH_ASSET_ID.clone(), ZKSYNC_USDT_ASSET_ID.clone()]), + (Chain::World, vec![WORLD_WETH_ASSET_ID.clone()]), + (Chain::Blast, vec![BLAST_WETH_ASSET_ID.clone()]), + (Chain::Ink, vec![INK_WETH_ASSET_ID.clone(), INK_USDT_ASSET_ID.clone()]), + (Chain::Unichain, vec![UNICHAIN_WETH_ASSET_ID.clone(), UNICHAIN_USDC_ASSET_ID.clone()]), + (Chain::Monad, vec![MONAD_USDC_ASSET_ID.clone(), MONAD_USDT_ASSET_ID.clone()]), + (Chain::SmartChain, vec![SMARTCHAIN_ETH_ASSET_ID.clone()]), + (Chain::Plasma, vec![PLASMA_USDT_ASSET_ID.clone()]), + ]) + } + + pub fn deposit_addresses() -> Vec { + let mut addresses: HashSet = HashSet::default(); + for chain in Chain::all() { + if let Some(deployment) = Self::deployment_by_chain(&chain) { + addresses.insert(deployment.spoke_pool.to_string()); + } + } + addresses.into_iter().collect() + } + + pub fn send_addresses() -> Vec { + let mut addresses: HashSet = HashSet::default(); + for chain in Chain::all() { + if let Some(deployment) = Self::deployment_by_chain(&chain) { + addresses.insert(deployment.spoke_pool.to_string()); + addresses.insert(deployment.multicall_handler()); + } + } + addresses.into_iter().collect() + } + + pub fn asset_mappings() -> Vec { + vec![ + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0001"), + upper_bound: EtherConv::parse_ether("0.000075"), + cutoff: EtherConv::parse_ether("0.3"), + decimals: 18, + }, + set: HashSet::from_iter([ + ARBITRUM_WETH_ASSET_ID.clone(), + BASE_WETH_ASSET_ID.clone(), + BLAST_WETH_ASSET_ID.clone(), + ETHEREUM_WETH_ASSET_ID.clone(), + LINEA_WETH_ASSET_ID.clone(), + OPTIMISM_WETH_ASSET_ID.clone(), + POLYGON_WETH_ASSET_ID.clone(), + ZKSYNC_WETH_ASSET_ID.clone(), + WORLD_WETH_ASSET_ID.clone(), + INK_WETH_ASSET_ID.clone(), + UNICHAIN_WETH_ASSET_ID.clone(), + SMARTCHAIN_ETH_ASSET_ID.clone(), + ]), + }, + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0001"), + upper_bound: BigInt::from(0), + cutoff: EtherConv::parse_ether("100000"), + decimals: 6, + }, + set: HashSet::from_iter([ + ARBITRUM_USDC_ASSET_ID.clone(), + BASE_USDC_ASSET_ID.clone(), + ETHEREUM_USDC_ASSET_ID.clone(), + OPTIMISM_USDC_ASSET_ID.clone(), + POLYGON_USDC_ASSET_ID.clone(), + UNICHAIN_USDC_ASSET_ID.clone(), + HYPEREVM_USDC_ASSET_ID.clone(), + MONAD_USDC_ASSET_ID.clone(), + ]), + }, + // USDC on BSC decimals are 18 + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0001"), + upper_bound: BigInt::from(0), + cutoff: EtherConv::parse_ether("100000"), + decimals: 18, + }, + set: HashSet::from_iter([ETHEREUM_USDC_ASSET_ID.clone(), SMARTCHAIN_USDC_ASSET_ID.clone()]), + }, + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0001"), + upper_bound: EtherConv::parse_ether("0.0001"), + cutoff: EtherConv::parse_ether("1500000"), + decimals: 6, + }, + set: HashSet::from_iter([ + ARBITRUM_USDT_ASSET_ID.clone(), + ETHEREUM_USDT_ASSET_ID.clone(), + LINEA_USDT_ASSET_ID.clone(), + OPTIMISM_USDT_ASSET_ID.clone(), + POLYGON_USDT_ASSET_ID.clone(), + ZKSYNC_USDT_ASSET_ID.clone(), + INK_USDT_ASSET_ID.clone(), + HYPEREVM_USDT_ASSET_ID.clone(), + PLASMA_USDT_ASSET_ID.clone(), + MONAD_USDT_ASSET_ID.clone(), + ]), + }, + // USDT on BSC decimals are 18 + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0001"), + upper_bound: EtherConv::parse_ether("0.0001"), + cutoff: EtherConv::parse_ether("1500000"), + decimals: 18, + }, + set: HashSet::from_iter([ETHEREUM_USDT_ASSET_ID.clone(), SMARTCHAIN_USDT_ASSET_ID.clone()]), + }, + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0001"), + upper_bound: EtherConv::parse_ether("0.0001"), + cutoff: EtherConv::parse_ether("1500000"), + decimals: 18, + }, + set: HashSet::from_iter([ + ARBITRUM_DAI_ASSET_ID.clone(), + BASE_DAI_ASSET_ID.clone(), + ETHEREUM_DAI_ASSET_ID.clone(), + LINEA_DAI_ASSET_ID.clone(), + OPTIMISM_DAI_ASSET_ID.clone(), + POLYGON_DAI_ASSET_ID.clone(), + ZKSYNC_DAI_ASSET_ID.clone(), + ]), + }, + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0001"), + upper_bound: BigInt::from(0), + cutoff: EtherConv::parse_ether("100000"), + decimals: 6, + }, + set: HashSet::from_iter([ + ARBITRUM_USDC_E_ASSET_ID.clone(), + BASE_USDC_E_ASSET_ID.clone(), + ETHEREUM_USDC_E_ASSET_ID.clone(), + LINEA_USDC_E_ASSET_ID.clone(), + OPTIMISM_USDC_E_ASSET_ID.clone(), + POLYGON_USDC_E_ASSET_ID.clone(), + WORLD_USDC_E_ASSET_ID.clone(), + ZKSYNC_USDC_E_ASSET_ID.clone(), + ]), + }, + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0003"), + upper_bound: EtherConv::parse_ether("0.0025"), + cutoff: EtherConv::parse_ether("10"), + decimals: 8, + }, + set: HashSet::from_iter([ + ARBITRUM_WBTC_ASSET_ID.clone(), + BLAST_WBTC_ASSET_ID.clone(), + ETHEREUM_WBTC_ASSET_ID.clone(), + LINEA_WBTC_ASSET_ID.clone(), + OPTIMISM_WBTC_ASSET_ID.clone(), + POLYGON_WBTC_ASSET_ID.clone(), + WORLD_WBTC_ASSET_ID.clone(), + ZKSYNC_WBTC_ASSET_ID.clone(), + ]), + }, + AssetMapping { + capital_cost: CapitalCostConfig { + lower_bound: EtherConv::parse_ether("0.0001"), + upper_bound: EtherConv::parse_ether("0.001"), + cutoff: EtherConv::parse_ether("1000000"), + decimals: 18, + }, + set: HashSet::from_iter([ + ARBITRUM_ACX_ASSET_ID.clone(), + ETHEREUM_ACX_ASSET_ID.clone(), + OPTIMISM_ACX_ASSET_ID.clone(), + POLYGON_ACX_ASSET_ID.clone(), + ]), + }, + ] + } +} diff --git a/core/crates/gem_evm/src/across/fees/lp.rs b/core/crates/gem_evm/src/across/fees/lp.rs new file mode 100644 index 0000000000..4dd740ab9c --- /dev/null +++ b/core/crates/gem_evm/src/across/fees/lp.rs @@ -0,0 +1,144 @@ +// https://github.com/across-protocol/sdk/blob/master/src/lpFeeCalculator/lpFeeCalculator.ts#L10 +use crate::ether_conv::EtherConv; +use num_bigint::BigInt; +use num_traits::{ToPrimitive, Zero}; +use std::cmp::max; + +#[derive(Debug, Clone)] +pub struct RateModel { + pub ubar: BigInt, + pub r0: BigInt, + pub r1: BigInt, + pub r2: BigInt, +} + +/// Converts an APY rate to a one-week rate. +/// R_week = (1 + apy)^(1/52) - 1 +pub fn convert_apy_to_weekly_fee(apy: BigInt) -> BigInt { + let fixed_point_adjustment = 10u64.pow(18) as f64; + + // Perform decimal calculations using floating-point for fractional exponents + let apy_decimal = apy.to_f64().unwrap() / fixed_point_adjustment; + let weekly_fee_pct = ((1.0 + apy_decimal).powf(1.0 / 52.0) - 1.0) * fixed_point_adjustment; + + BigInt::from(weekly_fee_pct.ceil() as u64) +} + +/// Truncate a BigUint to a given number of decimal places (from 18). +pub fn truncate_18_decimal_bn(input: &BigInt, digits: u32) -> BigInt { + let digits_to_drop = 18 - digits; + let multiplier = BigInt::from(10).pow(digits_to_drop); + (input / &multiplier) * multiplier +} + +pub struct LpFeeCalculator { + pub rate_model: RateModel, +} + +impl LpFeeCalculator { + pub fn new(rate_model: RateModel) -> Self { + //! Rate model to be used in this calculation. + Self { rate_model } + } + + /// Calculate the instantaneous rate for a 0 sized deposit (infinitesimally small). + /// + /// # Parameters + /// - util: the utilization rate of the pool + /// + /// # Returns + /// The instantaneous rate for a 0 sized deposit. + pub fn instantaneous_rate(&self, util: &BigInt) -> BigInt { + let model = &self.rate_model; + let one = EtherConv::one(); + let (ubar, r1, r2) = (model.ubar.clone(), model.r1.clone(), model.r2.clone()); + + let before_kink = if model.ubar.is_zero() { BigInt::zero() } else { util.min(&model.ubar) * r1 / &ubar }; + + let after_kink = max(util - &ubar, BigInt::zero()) * r2 / (one - &ubar); + model.r0.clone() + before_kink + after_kink + } + + /// Compute area under curve of the piece-wise linear rate model + /// + /// # Parameters + /// - util: the utilization rate of the pool + /// + /// # Returns + /// The area under the curve of the piece-wise linear rate model.the area under the curve + pub fn area_under_curve(&self, util: &BigInt) -> BigInt { + let model = &self.rate_model; + let fixed_point_adjustment = EtherConv::one(); + let point_5 = EtherConv::one() / 2; + + let util_before_kink = util.min(&model.ubar); + let rect_1 = util_before_kink * &model.r0 / &fixed_point_adjustment; + let triangle_1 = &point_5 * (self.instantaneous_rate(util_before_kink) - &model.r0) * util_before_kink / &fixed_point_adjustment / &fixed_point_adjustment; + + let util_after = max(util - &model.ubar, BigInt::zero()); + let rect_2 = util_after.clone() * (model.r0.clone() + model.r1.clone()) / &fixed_point_adjustment; + let triangle_2 = point_5 * (self.instantaneous_rate(util) - (model.r0.clone() + model.r1.clone())) * util_after / &fixed_point_adjustment / &fixed_point_adjustment; + + rect_1 + triangle_1 + rect_2 + triangle_2 + } + + /// Calculate the realized yearly LP Fee APY Percent for a given rate model, utilization before and after the deposit. + /// + /// # Parameters + /// - util_before: the utilization rate of the pool before the deposit + /// - util_after: the utilization rate of the pool after the deposit + /// + /// # Returns + /// The realized LP fee APY percent. + pub fn apy_from_utilization(&self, util_before: &BigInt, util_after: &BigInt) -> BigInt { + if util_before == util_after { + return self.instantaneous_rate(util_before); + } + + let one = EtherConv::one(); + let area_before = self.area_under_curve(util_before); + let area_after = self.area_under_curve(util_after); + + (area_after - area_before) * one / (util_after - util_before) + } + + /// Calculate the realized LP Fee Percent for a given rate model, utilization before and after the deposit. + /// + /// # Parameters + /// - util_before: The utilization of the pool before the deposit. + /// - util_after: The utilization of the pool after the deposit. + /// - truncate_decimals: Whether to truncate the result to 6 decimals. + /// + /// # Returns + /// The realized LP fee percent. + pub fn realized_lp_fee_pct(&self, util_before: &BigInt, util_after: &BigInt, truncate_decimals: bool) -> BigInt { + let apy = self.apy_from_utilization(util_before, util_after); + let weekly_fee = convert_apy_to_weekly_fee(apy); + + if truncate_decimals { truncate_18_decimal_bn(&weekly_fee, 6) } else { weekly_fee } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apy_from_utilization() { + let eth_model = RateModel { + ubar: EtherConv::parse_ether("0.65"), + r0: BigInt::zero(), + r1: EtherConv::parse_ether("0.08"), + r2: EtherConv::parse_ether("1"), + }; + let calculator = LpFeeCalculator::new(eth_model); + let util_before = BigInt::from(0); + let util_after = EtherConv::parse_ether("0.01"); + + let apy = calculator.apy_from_utilization(&util_before, &util_after); + let apy_fee_pct = calculator.realized_lp_fee_pct(&util_before, &util_after, false); + + assert_eq!(apy, BigInt::from(615384615384600u64)); + assert_eq!(apy_fee_pct, BigInt::from(11830749673481u64)); + } +} diff --git a/core/crates/gem_evm/src/across/fees/mod.rs b/core/crates/gem_evm/src/across/fees/mod.rs new file mode 100644 index 0000000000..cfe9d5e871 --- /dev/null +++ b/core/crates/gem_evm/src/across/fees/mod.rs @@ -0,0 +1,33 @@ +pub mod lp; +pub mod relayer; +pub use lp::*; +pub use relayer::*; + +use alloy_primitives::U256; +use num_bigint::{BigInt, Sign}; + +// percent is in 18 decimals +pub fn multiply(amount: U256, percent: BigInt, decimals: u32) -> U256 { + let amount_big = BigInt::from_bytes_le(Sign::Plus, &amount.to_le_bytes::<32>()); + // for ETH scale factor is 1 + let scale_factor = BigInt::from(10_u64.pow(18 - decimals)); + let token_decimals = BigInt::from(10_u64.pow(decimals)); + let value = amount_big * percent / scale_factor / token_decimals; + U256::from_le_slice(&value.to_signed_bytes_le()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ether_conv::to_bn_wei; + + #[test] + fn test_multiply() { + let amount = U256::from(100000000_u64); // 100 USDC + let percent = to_bn_wei("0.01", 18); + let decimals = 6; + + let result = multiply(amount, percent, decimals); + assert_eq!(result, U256::from(1000000)); + } +} diff --git a/core/crates/gem_evm/src/across/fees/relayer.rs b/core/crates/gem_evm/src/across/fees/relayer.rs new file mode 100644 index 0000000000..fc515c4c7f --- /dev/null +++ b/core/crates/gem_evm/src/across/fees/relayer.rs @@ -0,0 +1,159 @@ +// https://github.com/across-protocol/sdk/blob/master/src/relayFeeCalculator/relayFeeCalculator.ts +use num_bigint::BigInt; +use num_traits::Zero; +use std::cmp::{max, min}; + +#[derive(Debug, Clone)] +pub struct CapitalCostConfig { + pub lower_bound: BigInt, + pub upper_bound: BigInt, + pub cutoff: BigInt, + pub decimals: u32, +} + +pub struct RelayerFeeCalculator { + fixed_point_adjustment: BigInt, + max_big_int: BigInt, +} + +impl Default for RelayerFeeCalculator { + fn default() -> Self { + Self { + fixed_point_adjustment: BigInt::from(10u64.pow(18)), + max_big_int: BigInt::from(i64::MAX), // Number.MAX_SAFE_INTEGER + } + } +} + +impl RelayerFeeCalculator { + /// Calculate the capital fee percent based on the configuration + pub fn capital_fee_percent(&self, amount_to_relay: &BigInt, config: &CapitalCostConfig) -> BigInt { + // If amount is 0, then the capital fee % should be the max 100% + let zero = BigInt::zero(); + if amount_to_relay == &zero { + return self.max_big_int.clone(); + } + + // Scale amount "y" to 18 decimals + let scale_factor = BigInt::from(10).pow(18 - config.decimals); + let y = amount_to_relay * &scale_factor; + + // At a minimum, the fee will be equal to lower bound * y + let min_charge = &config.lower_bound * &y / &self.fixed_point_adjustment; + + // Special case: if cutoff is 0, return upper bound + if config.cutoff == zero { + return config.upper_bound.clone(); + } + + // Calculate triangle portion + let y_triangle = min(&config.cutoff, &y); + + // triangleSlope is slope of fee curve from lower bound to upper bound + let triangle_slope = if config.cutoff == zero { + BigInt::from(0) + } else { + (&config.upper_bound - &config.lower_bound) * &self.fixed_point_adjustment / &config.cutoff + }; + + let triangle_height = &triangle_slope * y_triangle / &self.fixed_point_adjustment; + let triangle_charge = &triangle_height * y_triangle / BigInt::from(2) / &self.fixed_point_adjustment; + + // For amounts above cutoff, apply the remainder charge + let y_diff = &y - &config.cutoff; + let y_remainder = max(&zero, &y_diff); + let remainder_charge = y_remainder * (&config.upper_bound - &config.lower_bound) / &self.fixed_point_adjustment; + + // Calculate final fee percentage + (min_charge + triangle_charge + remainder_charge) * &self.fixed_point_adjustment / &y + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ether_conv::to_bn_wei; + + #[test] + fn test_capital_fee_percent() { + let calculator = RelayerFeeCalculator::default(); + + // Setup ETH config + let _eth_config = CapitalCostConfig { + decimals: 18, + lower_bound: to_bn_wei("0.0001", 18), + upper_bound: to_bn_wei("0.000075", 18), + cutoff: to_bn_wei("0.3", 18), + }; + + // Setup USDC config + let _usdc_config = CapitalCostConfig { + decimals: 6, + lower_bound: to_bn_wei("0.0001", 18), + upper_bound: to_bn_wei("0.00015", 18), + cutoff: BigInt::from(100000), + }; + + // Setup WBTC config + let _wbtc_config = CapitalCostConfig { + decimals: 8, + lower_bound: to_bn_wei("0.0003", 18), + upper_bound: to_bn_wei("0.002", 18), + cutoff: to_bn_wei("15", 18), + }; + + // Setup DAI config + let _dai_config = CapitalCostConfig { + decimals: 18, + lower_bound: to_bn_wei("0.0003", 18), + upper_bound: to_bn_wei("0.0015", 18), + cutoff: to_bn_wei("500000", 18), + }; + + // Setup ZERO_CUTOFF_DAI config + let zero_cutoff_dai_config = CapitalCostConfig { + decimals: 18, + lower_bound: to_bn_wei("0.0003", 18), + upper_bound: to_bn_wei("0.0015", 18), + cutoff: BigInt::from(0), + }; + + // Test 1 ETH + let fee = calculator.capital_fee_percent(&to_bn_wei("1", 18), &_eth_config); + assert_eq!(fee, BigInt::from(78750000000001_u64)); + + // Test 100 USDC + let fee = calculator.capital_fee_percent(&to_bn_wei("100", 6), &_usdc_config); + assert_eq!(fee, BigInt::from(149999999999999_u64)); + + // Test near zero amount for WBTC + let fee = calculator.capital_fee_percent(&to_bn_wei("0.001", 8), &_wbtc_config); + assert_eq!(fee, to_bn_wei("0.000300056666666", 18)); + + // Test near zero amount for DAI + let fee = calculator.capital_fee_percent(&to_bn_wei("1", 18), &_dai_config); + assert_eq!(fee, to_bn_wei("0.0003000012", 18)); + + // Test amount below cutoff for WBTC + let fee = calculator.capital_fee_percent(&to_bn_wei("14.999", 8), &_wbtc_config); + assert_eq!(fee, to_bn_wei("0.00114994333333333", 18)); + + // Test amount below cutoff for DAI + let fee = calculator.capital_fee_percent(&to_bn_wei("499999", 18), &_dai_config); + assert_eq!(fee, to_bn_wei("0.0008999988", 18)); + + // Test amount much larger than cutoff for WBTC + let fee = calculator.capital_fee_percent(&to_bn_wei("600", 8), &_wbtc_config); + assert_eq!(fee, to_bn_wei("0.001978749999999999", 18)); + + // Test amount much larger than cutoff for DAI + let fee = calculator.capital_fee_percent(&to_bn_wei("20000000", 18), &_dai_config); + assert_eq!(fee, to_bn_wei("0.001485", 18)); + + // Test zero cutoff DAI (should always return upper bound) + let fee = calculator.capital_fee_percent(&to_bn_wei("1", 18), &zero_cutoff_dai_config); + assert_eq!(fee, to_bn_wei("0.0015", 18)); + let fee = calculator.capital_fee_percent(&to_bn_wei("499999", 18), &zero_cutoff_dai_config); + assert_eq!(fee, to_bn_wei("0.0015", 18)); + } +} diff --git a/core/crates/gem_evm/src/across/mod.rs b/core/crates/gem_evm/src/across/mod.rs new file mode 100644 index 0000000000..8d20555740 --- /dev/null +++ b/core/crates/gem_evm/src/across/mod.rs @@ -0,0 +1,3 @@ +pub mod contracts; +pub mod deployment; +pub mod fees; diff --git a/core/crates/gem_evm/src/address.rs b/core/crates/gem_evm/src/address.rs new file mode 100644 index 0000000000..f221e5028d --- /dev/null +++ b/core/crates/gem_evm/src/address.rs @@ -0,0 +1,64 @@ +use alloy_primitives::{Address, AddressError}; +use primitives::Address as AddressTrait; +use std::str::FromStr; + +pub fn ethereum_address_checksum(address: &str) -> Result { + Ok(Address::from_str(address)?.to_checksum(None)) +} + +pub fn ethereum_address_from_topic(topic: &str) -> Option { + ethereum_address_checksum(topic.trim_start_matches("0x000000000000000000000000")).ok() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EthereumAddress(Address); + +impl AddressTrait for EthereumAddress { + fn try_parse(address: &str) -> Option { + Address::from_str(address).ok().map(Self) + } + + fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + fn encode(&self) -> String { + self.0.to_checksum(None) + } +} + +pub fn validate_address(address: &str) -> bool { + EthereumAddress::is_valid(address) +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(crate) const VALID_ADDRESS: &str = "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a"; + + #[test] + fn test_ethereum_address() { + let lowercase = VALID_ADDRESS.to_lowercase(); + let uppercase_prefix = lowercase.replacen("0x", "0X", 1); + + assert_eq!(ethereum_address_checksum(&lowercase).unwrap(), VALID_ADDRESS); + assert_eq!(ethereum_address_checksum(lowercase.trim_start_matches("0x")).unwrap(), VALID_ADDRESS); + assert!(ethereum_address_checksum(&uppercase_prefix).is_err()); + assert!(ethereum_address_checksum("invalid").is_err()); + + let parsed = EthereumAddress::try_parse(&lowercase).unwrap(); + assert!(validate_address(&lowercase)); + assert_eq!(parsed.as_bytes().len(), 20); + assert_eq!(parsed.encode(), VALID_ADDRESS); + assert!(!validate_address(&uppercase_prefix)); + } + + #[test] + fn test_ethereum_address_from_topic() { + assert_eq!( + ethereum_address_from_topic("0x0000000000000000000000005615e8ab93b9d695b6d4d6545f7792aa59e1069a"), + Some(VALID_ADDRESS.to_string()) + ); + } +} diff --git a/core/crates/gem_evm/src/address_deserializer.rs b/core/crates/gem_evm/src/address_deserializer.rs new file mode 100644 index 0000000000..d8c5a5955d --- /dev/null +++ b/core/crates/gem_evm/src/address_deserializer.rs @@ -0,0 +1,9 @@ +use crate::address::ethereum_address_checksum; + +pub fn deserialize_ethereum_address_checksum<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let address: String = serde::Deserialize::deserialize(deserializer)?; + ethereum_address_checksum(&address).map_err(serde::de::Error::custom) +} diff --git a/core/crates/gem_evm/src/call_decoder.rs b/core/crates/gem_evm/src/call_decoder.rs new file mode 100644 index 0000000000..36e9fca867 --- /dev/null +++ b/core/crates/gem_evm/src/call_decoder.rs @@ -0,0 +1,219 @@ +use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; +use alloy_json_abi::JsonAbi; +use alloy_primitives::hex; +use alloy_sol_types::SolInterface; +use std::error::Error; + +use crate::contracts::erc20::IERC20::IERC20Calls; + +#[derive(Debug, PartialEq)] +pub struct DecodedCallParam { + pub name: String, + pub r#type: String, + pub value: String, +} + +#[derive(Debug, PartialEq)] +pub struct DecodedCall { + pub function: String, + pub params: Vec, +} + +pub fn decode_call(calldata: &str, abi: Option<&str>) -> Result> { + let calldata = hex::decode(calldata)?; + + // Check minimum calldata length early + if calldata.len() < 4 { + return Err("Calldata too short".into()); + } + + // Try ERC20 interface first if no ABI provided + if abi.is_none() + && let Ok(call) = IERC20Calls::abi_decode(&calldata) + { + return Ok(call.into()); + } + + if let Some(abi_str) = abi { + let abi = serde_json::from_str::(abi_str)?; + let selector = &calldata[..4]; + + for function in abi.functions() { + if function.selector() == selector { + if let Ok(params) = function.abi_decode_input(&calldata[4..]) { + return Ok(DecodedCall { + function: function.name.clone(), + params: function + .inputs + .iter() + .zip(params.iter()) + .map(|(input, output)| DecodedCallParam { + name: input.name.clone(), + r#type: input.ty.to_string(), + value: format_param_value(output), + }) + .collect(), + }); + } else { + return Err(format!("Failed to decode function parameters for {}", function.name).into()); + } + } + } + return Err(format!("No matching function found for selector {:02x?}", selector).into()); + } + + Err("Failed to decode calldata".into()) +} + +pub fn format_param_value(value: &DynSolValue) -> String { + match value { + DynSolValue::Address(addr) => addr.to_string(), + DynSolValue::Uint(val, _) => val.to_string(), + DynSolValue::Int(val, _) => val.to_string(), + DynSolValue::Bool(val) => val.to_string(), + DynSolValue::Bytes(val) => format!("0x{}", hex::encode(val)), + DynSolValue::FixedBytes(val, _) => format!("0x{}", hex::encode(val)), + DynSolValue::String(val) => val.clone(), + DynSolValue::Array(vals) | DynSolValue::FixedArray(vals) => { + let formatted: Vec = vals.iter().map(format_param_value).collect(); + format!("[{}]", formatted.join(", ")) + } + DynSolValue::Tuple(vals) => { + let formatted: Vec = vals.iter().map(format_param_value).collect(); + format!("({})", formatted.join(", ")) + } + _ => format!("{value:?}"), + } +} + +impl From for DecodedCall { + fn from(call: IERC20Calls) -> Self { + let (function, params) = match call { + IERC20Calls::transfer(transfer) => ( + "transfer", + vec![("to", "address", transfer.to.to_string()), ("value", "uint256", transfer.value.to_string())], + ), + IERC20Calls::transferFrom(transfer_from) => ( + "transferFrom", + vec![ + ("from", "address", transfer_from.from.to_string()), + ("to", "address", transfer_from.to.to_string()), + ("value", "uint256", transfer_from.value.to_string()), + ], + ), + IERC20Calls::approve(approve) => ( + "approve", + vec![("spender", "address", approve.spender.to_string()), ("value", "uint256", approve.value.to_string())], + ), + IERC20Calls::name(_) => ("name", vec![]), + IERC20Calls::symbol(_) => ("symbol", vec![]), + IERC20Calls::decimals(_) => ("decimals", vec![]), + IERC20Calls::allowance(allowance) => ( + "allowance", + vec![("owner", "address", allowance.owner.to_string()), ("spender", "address", allowance.spender.to_string())], + ), + }; + + DecodedCall { + function: function.to_string(), + params: params + .into_iter() + .map(|(name, r#type, value)| DecodedCallParam { + name: name.to_string(), + r#type: r#type.to_string(), + value, + }) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_erc20_transfer() { + let calldata = "0xa9059cbb0000000000000000000000002df1c51e09aecf9cacb7bc98cb1742757f163df700000000000000000000000000000000000000000000000000000000005ec1d0"; + let decoded = decode_call(calldata, None).unwrap(); + + assert_eq!(decoded.function, "transfer"); + assert_eq!(decoded.params[0].name, "to"); + assert_eq!(decoded.params[0].r#type, "address"); + assert_eq!(decoded.params[0].value, "0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7"); + assert_eq!(decoded.params[1].name, "value"); + assert_eq!(decoded.params[1].r#type, "uint256"); + assert_eq!(decoded.params[1].value, "6210000"); + } + + #[test] + fn test_decode_custom_abi() { + // Using ERC721 safeTransferFrom as test case + let calldata = "0x42842e0e0000000000000000000000008ba1f109551bd432803012645aac136c0c3def25000000000000000000000000271682deb8c4e0901d1a1550ad2e64d568e69909000000000000000000000000000000000000000000000000000000000000007b"; + let abi = r#"[ + { + "inputs": [ + { + "name": "from", + "type": "address" + }, + { + "name": "to", + "type": "address" + }, + { + "name": "tokenId", + "type": "uint256" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] +"#; + let decoded = decode_call(calldata, Some(abi)).unwrap(); + + assert_eq!(decoded.function, "safeTransferFrom"); + assert_eq!(decoded.params.len(), 3); + assert_eq!(decoded.params[0].name, "from"); + assert_eq!(decoded.params[0].r#type, "address"); + assert_eq!(decoded.params[0].value, "0x8Ba1f109551bd432803012645aAC136C0c3Def25"); + assert_eq!(decoded.params[1].name, "to"); + assert_eq!(decoded.params[1].r#type, "address"); + assert_eq!(decoded.params[1].value, "0x271682DEB8C4E0901D1a1550aD2e64D568E69909"); + assert_eq!(decoded.params[2].name, "tokenId"); + assert_eq!(decoded.params[2].r#type, "uint256"); + assert_eq!(decoded.params[2].value, "123"); + } + + #[test] + fn test_decode_short_calldata() { + // Test that short calldata returns proper error + let result = decode_call("0x1234", None); // Only 2 bytes, need 4 + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Calldata too short")); + } + + #[test] + fn test_decode_erc20_view_functions() { + // Test ERC20 name() function - 0x06fdde03 + let name_calldata = "0x06fdde03"; + let name_result = decode_call(name_calldata, None).unwrap(); + assert_eq!(name_result.function, "name"); + assert_eq!(name_result.params.len(), 0); + + // Test ERC20 allowance(address,address) function - 0xdd62ed3e + let allowance_calldata = "0xdd62ed3e0000000000000000000000008ba1f109551bd432803012645aac136c0c3def25000000000000000000000000271682deb8c4e0901d1a1550ad2e64d568e69909"; + let allowance_result = decode_call(allowance_calldata, None).unwrap(); + assert_eq!(allowance_result.function, "allowance"); + assert_eq!(allowance_result.params.len(), 2); + assert_eq!(allowance_result.params[0].name, "owner"); + assert_eq!(allowance_result.params[0].r#type, "address"); + assert_eq!(allowance_result.params[0].value, "0x8Ba1f109551bd432803012645aAC136C0c3Def25"); + assert_eq!(allowance_result.params[1].name, "spender"); + assert_eq!(allowance_result.params[1].r#type, "address"); + assert_eq!(allowance_result.params[1].value, "0x271682DEB8C4E0901D1a1550aD2e64D568E69909"); + } +} diff --git a/core/crates/gem_evm/src/chainlink/contract.rs b/core/crates/gem_evm/src/chainlink/contract.rs new file mode 100644 index 0000000000..432dd060ba --- /dev/null +++ b/core/crates/gem_evm/src/chainlink/contract.rs @@ -0,0 +1,9 @@ +use alloy_sol_types::sol; +pub use primitives::contract_constants::{ETHEREUM_CHAINLINK_ETH_USD_FEED_CONTRACT, MONAD_CHAINLINK_USD_FEED_CONTRACT}; + +// https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/shared/interfaces/AggregatorInterface.sol +sol! { + interface AggregatorInterface { + function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + } +} diff --git a/core/crates/gem_evm/src/chainlink/mod.rs b/core/crates/gem_evm/src/chainlink/mod.rs new file mode 100644 index 0000000000..2943dbb509 --- /dev/null +++ b/core/crates/gem_evm/src/chainlink/mod.rs @@ -0,0 +1 @@ +pub mod contract; diff --git a/core/crates/gem_evm/src/constants.rs b/core/crates/gem_evm/src/constants.rs new file mode 100644 index 0000000000..cdeb5e4511 --- /dev/null +++ b/core/crates/gem_evm/src/constants.rs @@ -0,0 +1 @@ +pub const STAKING_VALIDATORS_LIMIT: u16 = 128; diff --git a/core/crates/gem_evm/src/contracts/erc1155.rs b/core/crates/gem_evm/src/contracts/erc1155.rs new file mode 100644 index 0000000000..f706985e2f --- /dev/null +++ b/core/crates/gem_evm/src/contracts/erc1155.rs @@ -0,0 +1,9 @@ +use alloy_sol_types::sol; + +sol! { + interface IERC1155 { + function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes data) external; + function safeBatchTransferFrom(address from, address to, uint256[] ids, uint256[] amounts, bytes data) external; + function setApprovalForAll(address operator, bool approved) external; + } +} diff --git a/core/crates/gem_evm/src/contracts/erc20.rs b/core/crates/gem_evm/src/contracts/erc20.rs new file mode 100644 index 0000000000..130d7dc9fe --- /dev/null +++ b/core/crates/gem_evm/src/contracts/erc20.rs @@ -0,0 +1,59 @@ +use alloy_primitives::{U256, hex}; +use alloy_sol_types::SolValue; +use alloy_sol_types::sol; + +sol! { + interface IERC20 { + function name() public view virtual returns (string memory); + function symbol() public view virtual returns (string memory); + function decimals() public view virtual returns (uint8); + function allowance(address owner, address spender) external view returns (uint256); + + function transfer(address to, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); + function approve(address spender, uint256 value) external returns (bool); + } +} + +pub fn decode_abi_string(hex_data: &str) -> Result> { + let bytes_data = hex::decode(hex_data)?; + if bytes_data.is_empty() { + return Ok("".to_string()); + } + // Try to decode as ABI string. If that fails, try to interpret as a direct UTF-8 string. + String::abi_decode(&bytes_data).or_else(|_| { + String::from_utf8(bytes_data) + .map(|s| s.trim_matches('\0').to_string()) + .map_err(|utf8_error| utf8_error.to_string().into()) + }) +} + +pub fn decode_abi_uint8(hex_data: &str) -> Result> { + if hex_data.is_empty() { + return Ok(0); + } + + let bytes_data = hex::decode(hex_data)?; + let value_u256 = U256::abi_decode(&bytes_data)?; + let value: u8 = value_u256.try_into()?; + + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_abi_string() { + let bytes32_data = "0x4d616b6572000000000000000000000000000000000000000000000000000000"; + let result = decode_abi_string(bytes32_data).unwrap(); + + assert_eq!(result, "Maker"); + + let string_data = "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000a5465746865722055534400000000000000000000000000000000000000000000"; + let result = decode_abi_string(string_data).unwrap(); + + assert_eq!(result, "Tether USD"); + } +} diff --git a/core/crates/gem_evm/src/contracts/erc4626.rs b/core/crates/gem_evm/src/contracts/erc4626.rs new file mode 100644 index 0000000000..2326ac3dcf --- /dev/null +++ b/core/crates/gem_evm/src/contracts/erc4626.rs @@ -0,0 +1,9 @@ +use alloy_sol_types::sol; + +sol! { + interface IERC4626 { + function balanceOf(address account) external view returns (uint256); + function totalAssets() external view returns (uint256); + function totalSupply() external view returns (uint256); + } +} diff --git a/core/crates/gem_evm/src/contracts/erc721.rs b/core/crates/gem_evm/src/contracts/erc721.rs new file mode 100644 index 0000000000..da991a3da5 --- /dev/null +++ b/core/crates/gem_evm/src/contracts/erc721.rs @@ -0,0 +1,10 @@ +use alloy_sol_types::sol; + +sol! { + interface IERC721 { + function safeTransferFrom(address from, address to, uint256 tokenId) external; + function transferFrom(address from, address to, uint256 tokenId) external; + function approve(address to, uint256 tokenId) external; + function setApprovalForAll(address operator, bool approved) external; + } +} diff --git a/core/crates/gem_evm/src/contracts/mod.rs b/core/crates/gem_evm/src/contracts/mod.rs new file mode 100644 index 0000000000..59f0d999a7 --- /dev/null +++ b/core/crates/gem_evm/src/contracts/mod.rs @@ -0,0 +1,9 @@ +pub mod erc1155; +pub mod erc20; +pub mod erc4626; +pub mod erc721; + +pub use erc20::IERC20; +pub use erc721::IERC721; +pub use erc1155::IERC1155; +pub use erc4626::IERC4626; diff --git a/core/crates/gem_evm/src/domain.rs b/core/crates/gem_evm/src/domain.rs new file mode 100644 index 0000000000..466d44d1d6 --- /dev/null +++ b/core/crates/gem_evm/src/domain.rs @@ -0,0 +1,78 @@ +use url::Url; + +pub fn extract_host(url_or_domain: &str) -> Option { + if url_or_domain.is_empty() { + return None; + } + + if !url_or_domain.contains("://") { + return Some(url_or_domain.to_string()); + } + + let url = Url::parse(url_or_domain).ok()?; + let host = url.host_str()?; + match url.port() { + Some(p) => Some(format!("{host}:{p}")), + None => Some(host.to_string()), + } +} + +pub fn parse_url(domain: &str) -> Option { + let url_str = if domain.contains("://") { domain.to_string() } else { format!("https://{domain}") }; + Url::parse(&url_str).ok() +} + +pub fn host(url_string: &str) -> String { + Url::parse(url_string) + .ok() + .and_then(|url| url.host_str().map(|h| h.to_lowercase())) + .unwrap_or_else(|| url_string.to_lowercase()) +} + +pub fn host_only(domain_or_url: &str) -> Option { + if domain_or_url.is_empty() { + return None; + } + let url = parse_url(domain_or_url)?; + url.host_str().map(|host| host.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_host() { + assert_eq!(extract_host(""), None); + assert_eq!(extract_host("example.com"), Some("example.com".to_string())); + assert_eq!(extract_host("https://example.com"), Some("example.com".to_string())); + assert_eq!(extract_host("example.com:8080"), Some("example.com:8080".to_string())); + assert_eq!(extract_host("https://example.com:8080"), Some("example.com:8080".to_string())); + } + + #[test] + fn test_parse_url() { + let url = parse_url("example.com").unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host_str(), Some("example.com")); + + let url = parse_url("https://example.com").unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host_str(), Some("example.com")); + } + + #[test] + fn test_host() { + assert_eq!(host("https://example.com"), "example.com"); + assert_eq!(host("https://EXAMPLE.COM"), "example.com"); + assert_eq!(host("EXAMPLE.COM"), "example.com"); + } + + #[test] + fn test_host_only() { + assert_eq!(host_only("example.com"), Some("example.com".to_string())); + assert_eq!(host_only("example.com:8080"), Some("example.com".to_string())); + assert_eq!(host_only("https://EXAMPLE.COM:8080"), Some("example.com".to_string())); + assert_eq!(host_only(""), None); + } +} diff --git a/core/crates/gem_evm/src/eip712.rs b/core/crates/gem_evm/src/eip712.rs new file mode 100644 index 0000000000..16c1e839dd --- /dev/null +++ b/core/crates/gem_evm/src/eip712.rs @@ -0,0 +1,377 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_serializers::deserialize_option_u64_from_str_or_int; +use signer::{hash_eip712, validate_eip712_domain_chain_id_binding}; +use std::collections::HashMap; + +use crate::address::ethereum_address_checksum; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EIP712Domain { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(rename = "chainId")] + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, deserialize_with = "deserialize_option_u64_from_str_or_int")] + pub chain_id: Option, + #[serde(rename = "verifyingContract")] + #[serde(skip_serializing_if = "Option::is_none")] + pub verifying_contract: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub salts: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EIP712Type { + pub name: String, + pub r#type: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EIP712Field { + pub name: String, + pub value: EIP712TypedValue, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum EIP712TypedValue { + Address { value: String }, + Uint256 { value: String }, + Int256 { value: String }, + String { value: String }, + Bool { value: bool }, + Bytes { value: Vec }, + Struct { fields: Vec }, + Array { items: Vec }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EIP712Message { + pub domain: EIP712Domain, + pub primary_type: String, + pub message: Vec, +} + +pub fn eip712_domain_types() -> Vec { + vec![ + EIP712Type { + name: "name".into(), + r#type: "string".into(), + }, + EIP712Type { + name: "version".into(), + r#type: "string".into(), + }, + EIP712Type { + name: "chainId".into(), + r#type: "uint256".into(), + }, + EIP712Type { + name: "verifyingContract".into(), + r#type: "address".into(), + }, + ] +} + +pub fn eip712_hash_message(value: Value) -> Result, String> { + let json = serde_json::to_string(&value).map_err(|e| format!("Invalid EIP712 JSON: serialize error: {e}"))?; + hash_eip712(&json).map(|digest| digest.to_vec()).map_err(|e| e.to_string()) +} + +pub fn validate_eip712_chain_id(data: &str, expected_chain_id: u64) -> Result<(), String> { + let value: serde_json::Value = serde_json::from_str(data).map_err(|e| format!("Invalid EIP712 JSON: {}", e))?; + validate_eip712_domain_chain_id_binding(&value).map_err(|e| e.to_string())?; + let message = parse_eip712_json(&value)?; + + if let Some(chain_id) = message.domain.chain_id + && chain_id != expected_chain_id + { + return Err(format!("Chain ID mismatch: expected {}, got {}", expected_chain_id, chain_id)); + } + + Ok(()) +} + +pub fn parse_eip712_json(value: &Value) -> Result { + let domain_value = value.get("domain").ok_or_else(|| "Invalid EIP712 JSON: missing domain".to_string())?; + let domain: EIP712Domain = serde_json::from_value(domain_value.clone()).map_err(|e| format!("Invalid EIP712 JSON: domain parse error: {e}"))?; + let verifying_contract = match domain.verifying_contract.as_deref() { + Some(verifying_contract) => Some(ethereum_address_checksum(verifying_contract).map_err(|error| error.to_string())?), + None => None, + }; + let domain = EIP712Domain { verifying_contract, ..domain }; + + let types_value = value + .get("types") + .and_then(Value::as_object) + .ok_or_else(|| "Invalid EIP712 JSON: missing or invalid types".to_string())?; + let all_types: HashMap> = types_value + .iter() + .map(|(name, fields_json)| { + serde_json::from_value(fields_json.clone()) + .map(|fields| (name.clone(), fields)) + .map_err(|e| format!("Invalid EIP712 JSON: types field '{name}' parse error: {e}")) + }) + .collect::>()?; + + let primary_type_name = value + .get("primaryType") + .and_then(Value::as_str) + .ok_or_else(|| "Invalid EIP712 JSON: missing or invalid primaryType".to_string())?; + + let message_json_value = value.get("message").ok_or_else(|| "Invalid EIP712 JSON: missing message".to_string())?; + + let message_typed_value = parse_value(primary_type_name, message_json_value, &all_types)?; + + let message_fields = match message_typed_value { + EIP712TypedValue::Struct { fields } => fields, + _ => return Err(format!("Primary type '{primary_type_name}' did not resolve to a Struct")), + }; + + Ok(EIP712Message { + domain, + primary_type: primary_type_name.to_string(), + message: message_fields, + }) +} + +fn parse_numeric_value_as_string(json_value: &Value, type_name: &str) -> Result { + match json_value { + Value::Number(n) => Ok(n.to_string()), + Value::String(s) => Ok(s.clone()), + _ => { + let type_kind = if type_name.starts_with("int") { "int" } else { "uint" }; + Err(format!("Expected number or string for {type_kind} type '{type_name}', got: {json_value:?}")) + } + } +} + +pub fn parse_value(type_name: &str, json_value: &Value, all_types: &HashMap>) -> Result { + // 1. Handle Arrays + if let Some(base_type) = type_name.strip_suffix("[]") { + let items_json = json_value.as_array().ok_or_else(|| format!("Expected array for type '{type_name}', got: {json_value:?}"))?; + let mut items = Vec::with_capacity(items_json.len()); + for item_json in items_json { + items.push(parse_value(base_type, item_json, all_types)?); + } + Ok(EIP712TypedValue::Array { items }) + } else { + // 2. Handle Non-Array Types + match type_name { + "address" => { + let s = json_value.as_str().ok_or_else(|| format!("Expected string for address, got: {json_value:?}"))?; + Ok(EIP712TypedValue::Address { + value: ethereum_address_checksum(s).map_err(|error| format!("Invalid address '{s}': {error}"))?, + }) + } + "string" => { + let s = json_value.as_str().ok_or_else(|| format!("Expected string for string type, got: {json_value:?}"))?; + Ok(EIP712TypedValue::String { value: s.to_string() }) + } + "bool" => { + let b = json_value.as_bool().ok_or_else(|| format!("Expected boolean for bool type, got: {json_value:?}"))?; + Ok(EIP712TypedValue::Bool { value: b }) + } + "bytes" => { + // Dynamic bytes + let s = json_value.as_str().ok_or_else(|| format!("Expected hex string for bytes type, got: {json_value:?}"))?; + let bytes_vec = hex::decode(s.strip_prefix("0x").unwrap_or(s)).map_err(|e| format!("Invalid hex string for bytes type: {s}, error: {e}"))?; + Ok(EIP712TypedValue::Bytes { value: bytes_vec }) + } + // Wildcard for uint, int, bytes, and structs + other_type_name => { + if other_type_name.starts_with("uint") { + let value_str = parse_numeric_value_as_string(json_value, other_type_name)?; + Ok(EIP712TypedValue::Uint256 { value: value_str }) + } else if other_type_name.starts_with("int") { + let value_str = parse_numeric_value_as_string(json_value, other_type_name)?; + Ok(EIP712TypedValue::Int256 { value: value_str }) + } else if other_type_name.starts_with("bytes") { + // Fixed-size bytes + let s = json_value + .as_str() + .ok_or_else(|| format!("Expected hex string for bytes type '{other_type_name}', got: {json_value:?}"))?; + let bytes_vec = + hex::decode(s.strip_prefix("0x").unwrap_or(s)).map_err(|e| format!("Invalid hex string for bytes type '{other_type_name}': {s}, error: {e}"))?; + Ok(EIP712TypedValue::Bytes { value: bytes_vec }) + } else { + // Assume it's a struct type defined in 'all_types' + let defined_fields = all_types.get(other_type_name).ok_or_else(|| format!("Unknown or unsupported type '{other_type_name}'"))?; + + let message_obj = json_value + .as_object() + .ok_or_else(|| format!("Expected object for struct type '{other_type_name}', got: {json_value:?}"))?; + + let mut struct_fields = Vec::with_capacity(defined_fields.len()); + for field_def in defined_fields { + let field_json_value = message_obj + .get(&field_def.name) + .ok_or_else(|| format!("Missing field '{}' for struct type '{}'", field_def.name, other_type_name))?; + + // Recursive call for the struct field's type + let field_typed_value = parse_value(&field_def.r#type, field_json_value, all_types)?; + + struct_fields.push(EIP712Field { + name: field_def.name.clone(), + value: field_typed_value, + }); + } + Ok(EIP712TypedValue::Struct { fields: struct_fields }) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::eip712_mock::mock_eip712_json; + use primitives::{asset_constants::ETHEREUM_USDT_TOKEN_ID, contract_constants::ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT}; + + #[test] + fn test_permit2_json_parsing() { + let value: serde_json::Value = serde_json::from_str(include_str!("../testdata/uniswap_permit2.json")).unwrap(); + let result = parse_eip712_json(&value); + + assert!(result.is_ok(), "Parsing failed: {:?}", result.err()); + + let message = result.unwrap(); + + assert_eq!(message.domain.chain_id, Some(1)); + assert_eq!(message.message.len(), 3); + + match &message.message[0].value { + EIP712TypedValue::Struct { fields } => { + assert_eq!(fields.len(), 4); // token, amount, expiration, nonce + + // 1.1 token (address) + assert_eq!(fields[0].name, "token"); + match &fields[0].value { + EIP712TypedValue::Address { value } => assert_eq!(value, ETHEREUM_USDT_TOKEN_ID), + _ => panic!("Incorrect type for details.token"), + } + // 1.2 amount (uint160 - parsed as Uint256 for now) + // We parse uint160 as Uint256 { value: String } because the JSON value is a string. + assert_eq!(fields[1].name, "amount"); + match &fields[1].value { + EIP712TypedValue::Uint256 { value } => assert_eq!(value, "1461501637330902918203684832716283019655932542975"), + _ => panic!("Incorrect type for details.amount"), + } + // 1.3 expiration (uint48 - parsed as Uint256 for now) + assert_eq!(fields[2].name, "expiration"); + match &fields[2].value { + EIP712TypedValue::Uint256 { value } => assert_eq!(value, "1732780554"), + _ => panic!("Incorrect type for details.expiration"), + } + // 1.4 nonce (uint48 - parsed as Uint256 for now) + assert_eq!(fields[3].name, "nonce"); + match &fields[3].value { + EIP712TypedValue::Uint256 { value } => assert_eq!(value, "0"), + _ => panic!("Incorrect type for details.nonce"), + } + } + _ => panic!("Incorrect type for details field"), + } + + assert_eq!(message.message[1].name, "spender"); + match &message.message[1].value { + EIP712TypedValue::Address { value } => { + assert_eq!(value.to_lowercase(), ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT.to_lowercase()); + } + _ => panic!("Expected spender field to be an Address"), + } + + assert_eq!(message.message[2].name, "sigDeadline"); + match &message.message[2].value { + EIP712TypedValue::Uint256 { value } => { + assert_eq!(value, "1730190354"); + } + _ => panic!("Expected sigDeadline field to be a Uint256"), + } + } + + #[test] + fn test_1inch_permit_json_parsing() { + let value: serde_json::Value = serde_json::from_str(include_str!("../testdata/1inch_permit.json")).unwrap(); + let result = parse_eip712_json(&value); + + assert!(result.is_ok(), "Parsing failed: {:?}", result.err()); + + let message = result.unwrap(); + + assert_eq!(message.domain.chain_id, Some(1)); + assert!(message.message.len() == 5); + } + + #[test] + fn test_validate_eip712_chain_id_match() { + let result = validate_eip712_chain_id(&mock_eip712_json(1), 1); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_eip712_chain_id_mismatch() { + let result = validate_eip712_chain_id(&mock_eip712_json(137), 1); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Chain ID mismatch")); + } + + #[test] + fn test_validate_eip712_polygon() { + let result = validate_eip712_chain_id(&mock_eip712_json(137), 137); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_eip712_chain_id_rejects_unbound_chain_id() { + let missing_schema_field = include_str!("../testdata/eip712_domain_chain_id_without_schema_field.json"); + assert!(validate_eip712_chain_id(missing_schema_field, 1).unwrap_err().contains("chainId")); + + let schema_without_domain_value = include_str!("../testdata/eip712_schema_chain_id_without_domain_value.json"); + assert!(validate_eip712_chain_id(schema_without_domain_value, 1).unwrap_err().contains("missing chainId")); + + let null_domain_chain_id = include_str!("../testdata/eip712_domain_chain_id_null_value.json"); + assert!(validate_eip712_chain_id(null_domain_chain_id, 1).unwrap_err().contains("chainId")); + } + + #[test] + fn test_validate_eip712_chain_id_accepts_declared_non_uint_chain_id_type() { + let json = include_str!("../testdata/eip712_domain_chain_id_schema_string.json"); + assert!(validate_eip712_chain_id(json, 1).is_ok()); + } + + #[test] + fn test_parse_eip712_json_without_domain_chain_id() { + let json = include_str!("../testdata/ens_upload_avatar.json"); + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + let result = parse_eip712_json(&value).unwrap(); + + assert_eq!(result.domain.name.as_deref(), Some("Ethereum Name Service")); + assert_eq!(result.domain.version.as_deref(), Some("1")); + assert_eq!(result.domain.chain_id, None); + assert_eq!(result.primary_type, "Upload"); + assert_eq!(result.message.len(), 4); + assert!(validate_eip712_chain_id(json, 1).is_ok()); + } + + #[test] + fn test_int8_type_parsing() { + let value: serde_json::Value = serde_json::from_str(include_str!("../testdata/eip712_int8_account_registration.json")).unwrap(); + let result = parse_eip712_json(&value); + + assert!(result.is_ok(), "Parsing failed: {:?}", result.err()); + + let message = result.unwrap(); + assert_eq!(message.primary_type, "AccountRegistration"); + assert_eq!(message.message.len(), 1); + + assert_eq!(message.message[0].name, "accountIndex"); + match &message.message[0].value { + EIP712TypedValue::Int256 { value } => assert_eq!(value, "1"), + _ => panic!("Expected Int256 type for accountIndex field"), + } + } +} diff --git a/core/crates/gem_evm/src/encode.rs b/core/crates/gem_evm/src/encode.rs new file mode 100644 index 0000000000..6c17be9663 --- /dev/null +++ b/core/crates/gem_evm/src/encode.rs @@ -0,0 +1,58 @@ +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolCall; +use num_bigint::BigInt; +use std::error::Error; +use std::str::FromStr; + +use crate::contracts::{IERC20, IERC721, IERC1155}; + +pub fn encode_erc20_transfer(to: &str, amount: &BigInt) -> Result, Box> { + Ok(IERC20::transferCall { + to: Address::from_str(to)?, + value: U256::from_str(&amount.to_string())?, + } + .abi_encode()) +} + +pub fn encode_erc20_approve_max_value(spender: &str) -> Result, Box> { + Ok(IERC20::approveCall { + spender: Address::from_str(spender)?, + value: U256::MAX, + } + .abi_encode()) +} + +pub fn encode_erc721_transfer(from: &str, to: &str, token_id: &str) -> Result, Box> { + Ok(IERC721::safeTransferFromCall { + from: Address::from_str(from)?, + to: Address::from_str(to)?, + tokenId: U256::from_str(token_id)?, + } + .abi_encode()) +} + +pub fn encode_erc1155_transfer(from: &str, to: &str, token_id: &str) -> Result, Box> { + Ok(IERC1155::safeTransferFromCall { + from: Address::from_str(from)?, + to: Address::from_str(to)?, + id: U256::from_str(token_id)?, + amount: U256::from(1), + data: vec![].into(), + } + .abi_encode()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_erc20_approve_max_value() { + let spender = "0x2b5AD5c4795c026514f8317c7a215E218dccD6cF"; + let data = encode_erc20_approve_max_value(spender).unwrap(); + let call = IERC20::approveCall::abi_decode(&data).unwrap(); + + assert_eq!(call.spender, Address::from_str(spender).unwrap()); + assert_eq!(call.value, U256::MAX); + } +} diff --git a/core/crates/gem_evm/src/ether_conv.rs b/core/crates/gem_evm/src/ether_conv.rs new file mode 100644 index 0000000000..60ffc48f64 --- /dev/null +++ b/core/crates/gem_evm/src/ether_conv.rs @@ -0,0 +1,46 @@ +use bigdecimal::BigDecimal; +use num_bigint::BigInt; + +pub struct EtherConv {} + +impl EtherConv { + pub fn one() -> BigInt { + BigInt::from(10u64.pow(18)) + } + + /// Parse Ether to Wei as BigInt + pub fn parse_ether(ether: &str) -> BigInt { + to_bn_wei(ether, 18) + } + + pub fn to_gwei(wei: &BigInt) -> String { + let gwei_value = BigDecimal::from_bigint(wei.clone(), 0) / BigDecimal::from(10u64.pow(9)); + gwei_value.to_string() + } +} + +pub fn to_bn_wei(value: &str, decimals: u32) -> BigInt { + let ether_value = value.parse::().unwrap(); + let wei_value = (ðer_value * BigDecimal::from(10u64.pow(decimals))).with_scale(0); + + wei_value.as_bigint_and_exponent().0 +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_ether_conversion() { + let ether = "0.0001"; + let wei = EtherConv::parse_ether(ether); + + assert_eq!(wei.to_string(), "100000000000000"); + + let ether = "1500.123"; + let wei = EtherConv::parse_ether(ether); + + assert_eq!(wei.to_string(), "1500123000000000000000"); + } +} diff --git a/core/crates/gem_evm/src/everstake/client.rs b/core/crates/gem_evm/src/everstake/client.rs new file mode 100644 index 0000000000..34323a0fa4 --- /dev/null +++ b/core/crates/gem_evm/src/everstake/client.rs @@ -0,0 +1,106 @@ +pub const EVERSTAKE_API_BASE_URL: &str = "https://eth-api-b2c.everstake.one"; +pub const EVERSTAKE_STATS_PATH: &str = "/api/v1/stats"; +pub const EVERSTAKE_VALIDATORS_QUEUE_PATH: &str = "/api/v1/validators/queue"; + +use super::{EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting, models::AccountState}; +use crate::multicall3::{IMulticall3, create_call3, decode_call3_return}; + +use alloy_primitives::Address; +use gem_client::Client; +#[cfg(all(feature = "rpc", feature = "reqwest"))] +use gem_client::ClientExt; +use num_bigint::BigUint; +use num_traits::Zero; +use std::{error::Error, str::FromStr}; + +#[cfg(feature = "rpc")] +use crate::rpc::client::EthereumClient; +#[cfg(all(feature = "rpc", feature = "reqwest"))] +use gem_client::ReqwestClient; + +#[cfg(all(feature = "rpc", feature = "reqwest"))] +pub async fn get_everstake_validator_queue() -> Result> { + let client = ReqwestClient::new(EVERSTAKE_API_BASE_URL.to_string(), reqwest::Client::new()); + let response = client.get(EVERSTAKE_VALIDATORS_QUEUE_PATH).await?; + Ok(response) +} + +#[cfg(all(feature = "rpc", feature = "reqwest"))] +pub async fn get_everstake_staking_apy() -> Result, Box> { + let client = ReqwestClient::new(EVERSTAKE_API_BASE_URL.to_string(), reqwest::Client::new()); + let response: super::models::StatsResponse = client.get(EVERSTAKE_STATS_PATH).await?; + + Ok(Some(response.apr * 100.0)) +} + +pub async fn get_everstake_account_state(client: &EthereumClient, address: &str) -> Result> { + let account = Address::from_str(address).map_err(|e| Box::new(e) as Box)?; + let staker = account; + + let calls = vec![ + create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::depositedBalanceOfCall { account }), + create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::pendingBalanceOfCall { account }), + create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::pendingDepositedBalanceOfCall { account }), + create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::withdrawRequestCall { staker }), + create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::restakedRewardOfCall { account }), + ]; + + let call_count = calls.len(); + let multicall_results = client.multicall3(calls).await?; + if multicall_results.len() != call_count { + return Err("Unexpected number of multicall results".into()); + } + + let deposited_balance = decode_balance_result::(&multicall_results[0]); + let pending_balance = decode_balance_result::(&multicall_results[1]); + let pending_deposited_balance = decode_balance_result::(&multicall_results[2]); + let withdraw_request = decode_call3_return::(&multicall_results[3])?; + let restaked_reward = decode_balance_result::(&multicall_results[4]); + + Ok(AccountState { + deposited_balance, + pending_balance, + pending_deposited_balance, + withdraw_request, + restaked_reward, + }) +} + +fn decode_balance_result(result: &IMulticall3::Result) -> BigUint +where + T::Return: Into, +{ + if result.success { + decode_call3_return::(result) + .map(|value| { + let value: alloy_primitives::U256 = value.into(); + let bytes = value.to_be_bytes::<32>(); + BigUint::from_bytes_be(&bytes) + }) + .unwrap_or(BigUint::zero()) + } else { + BigUint::zero() + } +} + +#[cfg(all(test, feature = "rpc", feature = "reqwest", feature = "chain_integration_tests"))] +mod tests { + use crate::everstake::client::get_everstake_validator_queue; + + #[tokio::test] + async fn test_validator_queue() -> Result<(), Box> { + let state = get_everstake_validator_queue().await?; + + assert!(state.validator_activation_time > 0); + assert!(state.validator_exit_time > 0); + assert!(state.validator_withdraw_time > 0); + + let activation_days = (state.validator_activation_time + state.validator_adding_delay) as f64 / (24 * 60 * 60) as f64; + let withdraw_days = (state.validator_withdraw_time + state.validator_exit_time) as f64 / (24 * 60 * 60) as f64; + + println!("Ethereum activation time: {activation_days:.2} days"); + println!("Ethereum withdraw time: {withdraw_days:.2} days"); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/everstake/constants.rs b/core/crates/gem_evm/src/everstake/constants.rs new file mode 100644 index 0000000000..220879aee6 --- /dev/null +++ b/core/crates/gem_evm/src/everstake/constants.rs @@ -0,0 +1,5 @@ +pub const EVERSTAKE_POOL_ADDRESS: &str = "0xD523794C879D9eC028960a231F866758e405bE34"; +pub const EVERSTAKE_ACCOUNTING_ADDRESS: &str = "0x7a7f0b3c23C23a31cFcb0c44709be70d4D545c6e"; +pub const EVERSTAKE_SOURCE: u64 = 23; +pub const MIN_STAKE_AMOUNT_WEI: u64 = 100_000_000_000_000_000; +pub const DEFAULT_ALLOWED_INTERCHANGE_NUM: u16 = 0; diff --git a/core/crates/gem_evm/src/everstake/contracts.rs b/core/crates/gem_evm/src/everstake/contracts.rs new file mode 100644 index 0000000000..1b107226cd --- /dev/null +++ b/core/crates/gem_evm/src/everstake/contracts.rs @@ -0,0 +1,26 @@ +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq)] + interface IPool { + function stake(uint64 source) payable returns (uint256); + function unstake(uint256 value, uint16 allowedInterchangeNum, uint64 source) returns (uint256); + function unstakePending(uint256 amount) returns (uint256); + } + + #[derive(Debug, PartialEq)] + interface IAccounting { + function depositedBalanceOf(address account) view returns (uint256); + function pendingBalanceOf(address account) view returns (uint256); + function pendingDepositedBalanceOf(address account) view returns (uint256); + function restakedRewardOf(address account) view returns (uint256); + function withdrawRequest(address staker) view returns (WithdrawRequest memory); + function claimWithdrawRequest() external; + } + + #[derive(Debug, PartialEq)] + struct WithdrawRequest { + uint256 requested; + uint256 readyForClaim; + } +} diff --git a/core/crates/gem_evm/src/everstake/mapper.rs b/core/crates/gem_evm/src/everstake/mapper.rs new file mode 100644 index 0000000000..27a7211964 --- /dev/null +++ b/core/crates/gem_evm/src/everstake/mapper.rs @@ -0,0 +1,86 @@ +use super::{EVERSTAKE_POOL_ADDRESS, WithdrawRequest}; +use num_bigint::BigUint; +use num_traits::Zero; +use primitives::{AssetId, Chain, DelegationBase, DelegationState}; + +fn delegation_id(validator_id: &str, state: DelegationState) -> String { + format!("{}-{}", validator_id, state.as_ref()) +} + +pub fn map_withdraw_request_to_delegations(withdraw_request: &WithdrawRequest) -> Vec { + let requested = BigUint::from_bytes_be(&withdraw_request.requested.to_be_bytes::<32>()); + let ready_for_claim = BigUint::from_bytes_be(&withdraw_request.readyForClaim.to_be_bytes::<32>()); + + let mut delegations = Vec::new(); + let pending_amount = if requested > ready_for_claim { requested - &ready_for_claim } else { BigUint::zero() }; + + let asset_id = AssetId::from_chain(Chain::Ethereum); + let validator_id = EVERSTAKE_POOL_ADDRESS; + + if pending_amount > BigUint::zero() { + delegations.push(DelegationBase { + asset_id: asset_id.clone(), + state: DelegationState::Deactivating, + balance: pending_amount, + shares: BigUint::zero(), + rewards: BigUint::zero(), + completion_date: None, + delegation_id: delegation_id(validator_id, DelegationState::Deactivating), + validator_id: validator_id.to_string(), + }); + } + + if ready_for_claim > BigUint::zero() { + delegations.push(DelegationBase { + asset_id, + state: DelegationState::AwaitingWithdrawal, + balance: ready_for_claim, + shares: BigUint::zero(), + rewards: BigUint::zero(), + completion_date: None, + delegation_id: delegation_id(validator_id, DelegationState::AwaitingWithdrawal), + validator_id: validator_id.to_string(), + }); + } + + delegations +} + +pub fn map_balance_to_delegation(balance: &BigUint, restaked_reward: &BigUint, state: DelegationState) -> DelegationBase { + DelegationBase { + asset_id: AssetId::from_chain(Chain::Ethereum), + state, + balance: balance.clone(), + shares: BigUint::zero(), + rewards: restaked_reward.clone(), + completion_date: None, + delegation_id: delegation_id(EVERSTAKE_POOL_ADDRESS, state), + validator_id: EVERSTAKE_POOL_ADDRESS.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::U256; + + #[test] + fn test_map_withdraw_request_to_delegations() { + let withdraw_request = WithdrawRequest { + requested: U256::from_str_radix("1000000000000000000", 10).unwrap(), + readyForClaim: U256::from_str_radix("500000000000000000", 10).unwrap(), + }; + + let delegations = map_withdraw_request_to_delegations(&withdraw_request); + + assert_eq!(delegations.len(), 2); + + let pending = delegations.iter().find(|d| matches!(d.state, DelegationState::Deactivating)).unwrap(); + assert_eq!(pending.balance, BigUint::from(500000000000000000_u64)); + assert_eq!(pending.delegation_id, delegation_id(EVERSTAKE_POOL_ADDRESS, DelegationState::Deactivating)); + + let awaiting = delegations.iter().find(|d| matches!(d.state, DelegationState::AwaitingWithdrawal)).unwrap(); + assert_eq!(awaiting.balance, BigUint::from(500000000000000000_u64)); + assert_eq!(awaiting.delegation_id, delegation_id(EVERSTAKE_POOL_ADDRESS, DelegationState::AwaitingWithdrawal)); + } +} diff --git a/core/crates/gem_evm/src/everstake/mod.rs b/core/crates/gem_evm/src/everstake/mod.rs new file mode 100644 index 0000000000..e52e13a28c --- /dev/null +++ b/core/crates/gem_evm/src/everstake/mod.rs @@ -0,0 +1,13 @@ +#[cfg(feature = "rpc")] +pub mod client; +pub mod constants; +pub mod contracts; +pub mod mapper; +pub mod models; + +#[cfg(feature = "rpc")] +pub use client::*; +pub use constants::*; +pub use contracts::*; +pub use mapper::*; +pub use models::*; diff --git a/core/crates/gem_evm/src/everstake/models.rs b/core/crates/gem_evm/src/everstake/models.rs new file mode 100644 index 0000000000..5a07288ffa --- /dev/null +++ b/core/crates/gem_evm/src/everstake/models.rs @@ -0,0 +1,28 @@ +use num_bigint::BigUint; +use serde::Deserialize; +use serde_serializers::deserialize_f64_from_str; + +use super::contracts::WithdrawRequest; + +#[derive(Debug, Deserialize)] +pub struct QueueStatsResponse { + pub validator_activation_time: u64, + pub validator_exit_time: u64, + pub validator_withdraw_time: u64, + pub validator_adding_delay: u64, +} + +#[derive(Debug, Deserialize)] +pub struct StatsResponse { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub apr: f64, +} + +#[derive(Debug)] +pub struct AccountState { + pub deposited_balance: BigUint, + pub pending_balance: BigUint, + pub pending_deposited_balance: BigUint, + pub withdraw_request: WithdrawRequest, + pub restaked_reward: BigUint, +} diff --git a/core/crates/gem_evm/src/fee_calculator.rs b/core/crates/gem_evm/src/fee_calculator.rs new file mode 100644 index 0000000000..9547475667 --- /dev/null +++ b/core/crates/gem_evm/src/fee_calculator.rs @@ -0,0 +1,207 @@ +use std::cmp::min; + +use primitives::{EVMChain, PriorityFeeValue, fee::FeePriority}; + +use crate::models::fee::EthereumFeeHistory; + +use num_bigint::BigInt; +use serde_serializers::bigint_from_hex_str; + +pub fn get_fee_history_blocks(chain: EVMChain) -> u64 { + let block_time = chain.to_chain().block_time(); + min(60 * 1000 / block_time, 15) as u64 +} + +pub fn get_reward_percentiles() -> [u64; 3] { + [20, 40, 60] +} + +pub struct FeeCalculator; + +impl Default for FeeCalculator { + fn default() -> Self { + Self::new() + } +} + +impl FeeCalculator { + pub fn new() -> Self { + Self + } + + pub fn calculate_min_priority_fee(&self, gas_used_ratios: &[f64], base_fee: &BigInt, default_min_priority_fee: u64) -> Result> { + if gas_used_ratios.is_empty() || base_fee == &BigInt::from(0) { + return Ok(default_min_priority_fee); + } + let avg_ratio: f64 = gas_used_ratios.iter().sum::() / gas_used_ratios.len() as f64; + + let result = match avg_ratio { + r if r >= 0.9 => default_min_priority_fee, + r if r >= 0.7 => default_min_priority_fee / 2, + _ => default_min_priority_fee / 10, + }; + Ok(result) + } + + pub fn calculate_priority_fees( + &self, + fee_history: &EthereumFeeHistory, + priorities: &[FeePriority], + min_priority_fee: BigInt, + ) -> Result, Box> { + if fee_history.reward.is_empty() { + return Err("fee_history.reward is empty".into()); + } + + if priorities.len() != fee_history.reward[0].len() { + return Err("priorities.len() != fee_history.reward[0].len()".into()); + } + + let rewards = &fee_history.reward; + + let mut columns: Vec> = vec![Vec::new(); priorities.len()]; + for row in rewards { + for (i, hex_fee) in row.iter().enumerate().take(priorities.len()) { + if let Ok(bn) = bigint_from_hex_str(hex_fee) { + columns[i].push(bn); + } + } + } + + let mut result: Vec = priorities + .iter() + .zip(columns.iter()) + .map(|(&priority, fees)| { + let value = if fees.is_empty() { + min_priority_fee.clone() + } else { + let sum = fees.iter().cloned().fold(BigInt::from(0), |a, b| a + b); + let avg = &sum / BigInt::from(fees.len()); + let min_value = min_priority_fee.clone(); + if avg < min_value { min_value } else { avg } + }; + + PriorityFeeValue { priority, value } + }) + .collect(); + + result.sort_unstable_by(|a, b| a.value.cmp(&b.value)); + result.iter_mut().zip(priorities.iter()).for_each(|(fee, &priority)| fee.priority = priority); + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::fee::FeePriority; + + fn create_test_fee_history() -> EthereumFeeHistory { + EthereumFeeHistory { + reward: vec![ + vec!["0x54e0840".to_string(), "0x31e7fe5d".to_string(), "0x3b9aca04".to_string()], + vec!["0x4b571c0".to_string(), "0x18bf8474".to_string(), "0x3b9aca00".to_string()], + vec!["0x18e20bb9".to_string(), "0x32324960".to_string(), "0x3b9aca00".to_string()], + vec!["0x38444c0".to_string(), "0x7bf60c0".to_string(), "0x31e7fe5d".to_string()], + vec!["0x5f5e100".to_string(), "0x29b92700".to_string(), "0x39fbe24e".to_string()], + ], + base_fee_per_gas: vec![ + BigInt::from(2618877110u64), + BigInt::from(2600645117u64), + BigInt::from(2474034920u64), + BigInt::from(2495024366u64), + BigInt::from(2624620404u64), + BigInt::from(2541053471u64), + ], + gas_used_ratio: vec![0.4787648265769147, 0.30434244444444447, 0.5349411706429458, 0.707018, 0.37411107986145914], + oldest_block: 22832041, + } + } + + #[test] + fn test_get_fee_history_blocks() { + assert!(get_fee_history_blocks(primitives::EVMChain::Ethereum) > 0); + assert!(get_fee_history_blocks(primitives::EVMChain::Arbitrum) > 0); + } + + #[test] + fn test_get_reward_percentiles() { + assert_eq!(get_reward_percentiles(), [20, 40, 60]); + } + + #[test] + fn test_calculate_min_priority_fee() { + let calculator = FeeCalculator::new(); + let default_fee = 1_000_000_000; + let base_fee = BigInt::from(20_000_000_000u64); + + assert_eq!(calculator.calculate_min_priority_fee(&[0.9, 0.95, 1.0], &base_fee, default_fee).unwrap(), 1_000_000_000); + assert_eq!(calculator.calculate_min_priority_fee(&[0.7, 0.8, 0.75], &base_fee, default_fee).unwrap(), 500_000_000); + assert_eq!(calculator.calculate_min_priority_fee(&[0.1, 0.2, 0.3], &base_fee, default_fee).unwrap(), 100_000_000); + assert_eq!(calculator.calculate_min_priority_fee(&[], &base_fee, default_fee).unwrap(), 1_000_000_000); + } + + #[test] + fn test_calculate_priority_fees() { + let calculator = FeeCalculator::new(); + let fee_history = create_test_fee_history(); + let priorities = [FeePriority::Slow, FeePriority::Normal, FeePriority::Fast]; + + let result = calculator.calculate_priority_fees(&fee_history, &priorities, BigInt::from(100_000_000)).unwrap(); + + assert_eq!(result.len(), 3); + + assert_eq!(result[0].priority, FeePriority::Slow); + assert_eq!(result[0].value, BigInt::from(148893464)); + + assert_eq!(result[1].priority, FeePriority::Normal); + assert_eq!(result[1].value, BigInt::from(584926205)); + + assert_eq!(result[2].priority, FeePriority::Fast); + assert_eq!(result[2].value, BigInt::from(962019260)); + } + + #[test] + fn test_calculate_priority_fees_sorts_and_relabels() { + let calculator = FeeCalculator::new(); + let fee_history = EthereumFeeHistory { + reward: vec![vec![ + "0x77359400".to_string(), // 2 Gwei + "0xb2d05e00".to_string(), // 3 Gwei + "0x3b9aca00".to_string(), // 1 Gwei + ]], + base_fee_per_gas: vec![BigInt::from(100_000_000_000u64)], + gas_used_ratio: vec![0.5], + oldest_block: 0, + }; + let priorities = [FeePriority::Slow, FeePriority::Normal, FeePriority::Fast]; + + let result = calculator.calculate_priority_fees(&fee_history, &priorities, BigInt::from(0)).unwrap(); + + assert_eq!(result.len(), 3); + assert_eq!(result[0].priority, FeePriority::Slow); + assert_eq!(result[1].priority, FeePriority::Normal); + assert_eq!(result[2].priority, FeePriority::Fast); + assert!(result[0].value < result[1].value); + assert!(result[1].value < result[2].value); + } + + #[test] + fn test_calculate_priority_fees_errors() { + let calculator = FeeCalculator::new(); + let empty_history = EthereumFeeHistory { + reward: vec![], + base_fee_per_gas: vec![], + gas_used_ratio: vec![], + oldest_block: 0, + }; + + assert!(calculator.calculate_priority_fees(&empty_history, &[FeePriority::Slow], BigInt::from(100)).is_err()); + assert!( + calculator + .calculate_priority_fees(&create_test_fee_history(), &[FeePriority::Slow, FeePriority::Normal], BigInt::from(100)) + .is_err() + ); + } +} diff --git a/core/crates/gem_evm/src/jsonrpc.rs b/core/crates/gem_evm/src/jsonrpc.rs new file mode 100644 index 0000000000..dcc7d4a301 --- /dev/null +++ b/core/crates/gem_evm/src/jsonrpc.rs @@ -0,0 +1,177 @@ +use alloy_primitives::hex; +use gem_jsonrpc::types::{JsonRpcRequest, JsonRpcRequestConvert}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionObject { + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, + pub to: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub gas: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "gasPrice")] + pub gas_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + pub data: String, +} + +impl TransactionObject { + pub fn new_call(to: &str, data: Vec) -> Self { + Self { + from: None, + to: to.to_string(), + gas: None, + gas_price: None, + value: None, + data: hex::encode_prefixed(data), + } + } + + pub fn new_call_to_value(to: &str, value: &str, data: Vec) -> Self { + Self { + from: None, + to: to.to_string(), + gas: None, + gas_price: None, + value: Some(value.to_string()), + data: hex::encode_prefixed(data), + } + } + + pub fn new_call_with_from(from: &str, to: &str, data: Vec) -> Self { + Self { + from: Some(from.to_string()), + to: to.to_string(), + gas: None, + gas_price: None, + value: None, + data: hex::encode_prefixed(data), + } + } + + pub fn new_call_with_from_value(from: &str, to: &str, value: &str, data: Vec) -> Self { + Self { + from: Some(from.to_string()), + to: to.to_string(), + gas: None, + gas_price: None, + value: Some(value.to_string()), + data: hex::encode_prefixed(data), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BlockParameter { + // hexadecimal block number + Number(&'static str), + Latest, + Earliest, + Pending, + Safe, + Finalized, +} + +impl From<&BlockParameter> for &'static str { + fn from(val: &BlockParameter) -> Self { + match val { + BlockParameter::Number(val) => val, + BlockParameter::Latest => "latest", + BlockParameter::Earliest => "earliest", + BlockParameter::Pending => "pending", + BlockParameter::Safe => "safe", + BlockParameter::Finalized => "finalized", + } + } +} + +impl From<&BlockParameter> for serde_json::Value { + fn from(val: &BlockParameter) -> Self { + let str: &str = val.into(); + serde_json::Value::String(str.to_string()) + } +} + +impl From for serde_json::Value { + fn from(val: BlockParameter) -> Self { + serde_json::Value::from(&val) + } +} + +#[derive(Debug, Clone)] +pub enum EthereumRpc { + Call(TransactionObject, BlockParameter), + EstimateGas(TransactionObject, BlockParameter), + GasPrice, + GetBalance(&'static str), + GetTransactionReceipt(String), + FeeHistory { blocks: u64, reward_percentiles: Vec }, + TraceRawTransaction(String), +} + +impl EthereumRpc { + pub fn method_name(&self) -> &'static str { + match self { + EthereumRpc::GasPrice => "eth_gasPrice", + EthereumRpc::GetBalance(_) => "eth_getBalance", + EthereumRpc::Call(_, _) => "eth_call", + EthereumRpc::GetTransactionReceipt(_) => "eth_getTransactionReceipt", + EthereumRpc::EstimateGas(_, _) => "eth_estimateGas", + EthereumRpc::FeeHistory { .. } => "eth_feeHistory", + EthereumRpc::TraceRawTransaction(_) => "trace_rawTransaction", + } + } +} + +impl JsonRpcRequestConvert for EthereumRpc { + fn to_req(&self, id: u64) -> JsonRpcRequest { + let method = self.method_name(); + let params: Vec = match self { + EthereumRpc::GasPrice => vec![], + EthereumRpc::GetBalance(address) => { + vec![json!(address)] + } + EthereumRpc::Call(tx, block) => { + let value = serde_json::to_value(tx).unwrap(); + vec![value, block.into()] + } + EthereumRpc::GetTransactionReceipt(tx_hash) => { + vec![json!(tx_hash)] + } + EthereumRpc::EstimateGas(tx, block) => { + let value = serde_json::to_value(tx).unwrap(); + vec![value, block.into()] + } + EthereumRpc::FeeHistory { blocks, reward_percentiles } => { + vec![ + json!(blocks), + serde_json::Value::from(BlockParameter::Latest), + json!(reward_percentiles.iter().map(|x| json!(x)).collect::>()), + ] + } + EthereumRpc::TraceRawTransaction(raw_tx) => { + vec![json!(raw_tx), json!(vec!["stateDiff"])] + } + }; + + JsonRpcRequest::new(id, method, params.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_call() { + let request = TransactionObject::new_call_with_from("0x46340b20830761efd32832a74d7169b29feb9758", "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", vec![]); + let encoded = serde_json::to_string(&request).unwrap(); + + assert_eq!( + encoded, + r#"{"from":"0x46340b20830761efd32832a74d7169b29feb9758","to":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","data":"0x"}"# + ); + } +} diff --git a/core/crates/gem_evm/src/lib.rs b/core/crates/gem_evm/src/lib.rs new file mode 100644 index 0000000000..ef0f3c1327 --- /dev/null +++ b/core/crates/gem_evm/src/lib.rs @@ -0,0 +1,56 @@ +use alloy_primitives::U256; +use std::str::FromStr; + +pub mod across; +pub mod address; +pub mod address_deserializer; +pub mod call_decoder; +pub mod chainlink; +pub mod constants; +pub mod contracts; +pub mod domain; +pub mod eip712; +pub mod encode; +pub mod ether_conv; +pub mod everstake; +pub mod fee_calculator; +pub mod jsonrpc; +pub mod message; +pub mod monad; +pub mod multicall3; +pub mod permit2; +#[cfg(feature = "rpc")] +pub mod registry; +#[cfg(feature = "signer")] +pub mod signer; +pub mod siwe; +pub mod slippage; +pub mod thorchain; +pub mod u256; +pub mod uniswap; +pub mod weth; + +pub mod models; + +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +#[cfg(any(test, feature = "testkit"))] +pub mod testkit; + +pub const ETHEREUM_MESSAGE_PREFIX: &str = "\x19Ethereum Signed Message:\n"; + +pub use address::{ethereum_address_checksum, validate_address}; +pub use eip712::{EIP712Domain, EIP712Field, EIP712Type, EIP712TypedValue, eip712_domain_types}; +pub use primitives::contract_constants::EVM_ZERO_ADDRESS; + +pub fn parse_u256(value: &str) -> Option { + if let Some(stripped) = value.strip_prefix("0x") { + U256::from_str_radix(stripped, 16).ok() + } else { + U256::from_str(value).ok() + } +} diff --git a/core/crates/gem_evm/src/message.rs b/core/crates/gem_evm/src/message.rs new file mode 100644 index 0000000000..9bd6ac1485 --- /dev/null +++ b/core/crates/gem_evm/src/message.rs @@ -0,0 +1,18 @@ +use gem_hash::message::hash_personal_message; + +use crate::ETHEREUM_MESSAGE_PREFIX; + +pub fn eip191_hash_message(message: &[u8]) -> [u8; 32] { + hash_personal_message(ETHEREUM_MESSAGE_PREFIX, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_eip191_hash_message() { + let hash = eip191_hash_message(b"hello world"); + assert_eq!(hex::encode(hash), "d9eba16ed0ecae432b71fe008c98cc872bb4cc214d3220a36f365326cf807d68"); + } +} diff --git a/core/crates/gem_evm/src/models/block_parameter.rs b/core/crates/gem_evm/src/models/block_parameter.rs new file mode 100644 index 0000000000..e67f73860e --- /dev/null +++ b/core/crates/gem_evm/src/models/block_parameter.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EthereumBlockParameter { + Latest, + Earliest, + Pending, + Finalized, + Safe, +} diff --git a/core/crates/gem_evm/src/models/fee.rs b/core/crates/gem_evm/src/models/fee.rs new file mode 100644 index 0000000000..2a31cccedf --- /dev/null +++ b/core/crates/gem_evm/src/models/fee.rs @@ -0,0 +1,14 @@ +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_bigint_vec_from_hex_str, deserialize_u64_from_str}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EthereumFeeHistory { + pub reward: Vec>, + #[serde(deserialize_with = "deserialize_bigint_vec_from_hex_str")] + pub base_fee_per_gas: Vec, + pub gas_used_ratio: Vec, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub oldest_block: u64, +} diff --git a/core/crates/gem_evm/src/models/mod.rs b/core/crates/gem_evm/src/models/mod.rs new file mode 100644 index 0000000000..e713f9a764 --- /dev/null +++ b/core/crates/gem_evm/src/models/mod.rs @@ -0,0 +1,7 @@ +pub mod block_parameter; +pub mod fee; +pub mod transaction; + +pub use block_parameter::*; +pub use fee::*; +pub use transaction::*; diff --git a/core/crates/gem_evm/src/models/transaction.rs b/core/crates/gem_evm/src/models/transaction.rs new file mode 100644 index 0000000000..90a9e68574 --- /dev/null +++ b/core/crates/gem_evm/src/models/transaction.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EthereumTransactionReciept { + pub status: String, + pub gas_used: String, + pub effective_gas_price: String, + #[serde(rename = "l1Fee")] + pub l1_fee: Option, +} diff --git a/core/crates/gem_evm/src/monad/constants.rs b/core/crates/gem_evm/src/monad/constants.rs new file mode 100644 index 0000000000..222b639193 --- /dev/null +++ b/core/crates/gem_evm/src/monad/constants.rs @@ -0,0 +1,9 @@ +pub use primitives::contract_constants::{MONAD_STAKING_CONTRACT as STAKING_CONTRACT, MONAD_STAKING_LENS_CONTRACT as STAKING_LENS_CONTRACT}; +pub const DEFAULT_WITHDRAW_ID: u8 = 0; + +pub const MONAD_SCALE: f64 = 1e18; + +pub const EVENT_DELEGATE: &str = "0xe4d4df1e1827dd28252fd5c3cd7ebccd3da6e0aa31f74c828f3c8542af49d840"; +pub const EVENT_UNDELEGATE: &str = "0x3e53c8b91747e1b72a44894db10f2a45fa632b161fdcdd3a17bd6be5482bac62"; +pub const EVENT_WITHDRAW: &str = "0x63030e4238e1146c63f38f4ac81b2b23c8be28882e68b03f0887e50d0e9bb18f"; +pub const EVENT_CLAIM_REWARDS: &str = "0xcb607e6b63c89c95f6ae24ece9fe0e38a7971aa5ed956254f1df47490921727b"; diff --git a/core/crates/gem_evm/src/monad/contracts.rs b/core/crates/gem_evm/src/monad/contracts.rs new file mode 100644 index 0000000000..f3e850d38a --- /dev/null +++ b/core/crates/gem_evm/src/monad/contracts.rs @@ -0,0 +1,50 @@ +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq)] + interface IMonadStaking { + function delegate(uint64 validatorId) external payable returns (bool success); + function undelegate(uint64 validatorId, uint256 amount, uint8 withdrawId) external returns (bool success); + function withdraw(uint64 validatorId, uint8 withdrawId) external returns (bool success); + function claimRewards(uint64 validatorId) external returns (bool success); + + } +} + +sol! { + #[derive(Debug, PartialEq)] + interface IMonadStakingLens { + enum DelegationState { + Active, + Activating, + Deactivating, + AwaitingWithdrawal + } + + struct Delegation { + uint64 validatorId; + uint8 withdrawId; + DelegationState state; + uint256 amount; + uint256 rewards; + uint64 withdrawEpoch; + uint64 completionTimestamp; + } + + struct ValidatorInfo { + uint64 validatorId; + uint256 stake; + uint256 commission; + uint64 apyBps; + bool isActive; + } + + function getBalance(address delegator) external returns (uint256 staked, uint256 pending, uint256 rewards); + + function getDelegations(address delegator) external returns (Delegation[] memory positions); + + function getValidators(uint64[] calldata validatorIds) external returns (ValidatorInfo[] memory validators, uint64 networkApyBps); + + function getAPYs(uint64[] calldata validatorIds) external returns (uint64[] memory apysBps); + } +} diff --git a/core/crates/gem_evm/src/monad/mapper.rs b/core/crates/gem_evm/src/monad/mapper.rs new file mode 100644 index 0000000000..9af12310fa --- /dev/null +++ b/core/crates/gem_evm/src/monad/mapper.rs @@ -0,0 +1,233 @@ +use std::error::Error; +use std::str::FromStr; + +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolCall; +use num_bigint::{BigInt, BigUint, Sign}; +use num_traits::Zero; +use primitives::{DelegationState, StakeType}; + +use crate::monad::constants::DEFAULT_WITHDRAW_ID; +use crate::monad::contracts::{IMonadStaking, IMonadStakingLens}; +use crate::u256::u256_to_biguint; + +#[derive(Clone)] +pub struct MonadLensDelegation { + pub validator_id: u64, + pub withdraw_id: u8, + pub state: IMonadStakingLens::DelegationState, + pub amount: BigUint, + pub rewards: BigUint, + pub withdraw_epoch: u64, + pub completion_timestamp: u64, +} + +#[derive(Clone)] +pub struct MonadLensValidatorInfo { + pub validator_id: u64, + pub stake: BigUint, + pub commission: BigUint, + pub apy_bps: u64, + pub is_active: bool, +} + +#[derive(Clone)] +pub struct MonadLensBalance { + pub staked: BigUint, + pub pending: BigUint, + pub rewards: BigUint, +} + +pub fn delegation_id(address: &str, validator_id: u64, state: DelegationState, withdraw_id: u8) -> String { + format!("{}:{}:{}:{}", address, validator_id, state.as_ref(), withdraw_id) +} + +pub(crate) fn get_withdraw_id(delegation_id: &str) -> Option { + delegation_id.rsplit(':').next()?.parse::().ok() +} + +pub fn encode_get_lens_balance(delegator: &str) -> Result, Box> { + let delegator = Address::from_str(delegator)?; + Ok(IMonadStakingLens::getBalanceCall { delegator }.abi_encode()) +} + +pub fn decode_get_lens_balance(data: &[u8]) -> Result> { + let decoded = IMonadStakingLens::getBalanceCall::abi_decode_returns(data)?; + Ok(MonadLensBalance { + staked: u256_to_biguint(&decoded.staked), + pending: u256_to_biguint(&decoded.pending), + rewards: u256_to_biguint(&decoded.rewards), + }) +} + +pub fn encode_get_lens_delegations(delegator: &str) -> Result, Box> { + let delegator = Address::from_str(delegator)?; + Ok(IMonadStakingLens::getDelegationsCall { delegator }.abi_encode()) +} + +pub fn encode_get_lens_apys(validator_ids: &[u64]) -> Vec { + IMonadStakingLens::getAPYsCall { + validatorIds: validator_ids.to_vec(), + } + .abi_encode() +} + +pub fn decode_get_lens_apys(data: &[u8]) -> Result, Box> { + let (apys_bps,): (Vec,) = (IMonadStakingLens::getAPYsCall::abi_decode_returns(data)?,); + Ok(apys_bps) +} + +pub fn decode_get_lens_delegations(data: &[u8]) -> Result, Box> { + let decoded = IMonadStakingLens::getDelegationsCall::abi_decode_returns(data)?; + + Ok(decoded + .into_iter() + .map(|position| MonadLensDelegation { + validator_id: position.validatorId, + withdraw_id: position.withdrawId, + state: position.state, + amount: u256_to_biguint(&position.amount), + rewards: u256_to_biguint(&position.rewards), + withdraw_epoch: position.withdrawEpoch, + completion_timestamp: position.completionTimestamp, + }) + .collect()) +} + +pub fn encode_get_lens_validators(validator_ids: &[u64]) -> Vec { + IMonadStakingLens::getValidatorsCall { + validatorIds: validator_ids.to_vec(), + } + .abi_encode() +} + +pub fn decode_get_lens_validators(data: &[u8]) -> Result<(Vec, u64), Box> { + let decoded = IMonadStakingLens::getValidatorsCall::abi_decode_returns(data)?; + + Ok(( + decoded + .validators + .into_iter() + .map(|validator| MonadLensValidatorInfo { + validator_id: validator.validatorId, + stake: u256_to_biguint(&validator.stake), + commission: u256_to_biguint(&validator.commission), + apy_bps: validator.apyBps, + is_active: validator.isActive, + }) + .collect(), + decoded.networkApyBps, + )) +} + +pub fn encode_monad_staking(stake_type: &StakeType, amount: &BigInt) -> Result<(Vec, BigInt), Box> { + let amount = amount.clone(); + + match stake_type { + StakeType::Stake(validator) => { + let validator_id = validator.id.parse::().map_err(|_| "Invalid validator id for Monad")?; + Ok((IMonadStaking::delegateCall { validatorId: validator_id }.abi_encode(), amount)) + } + StakeType::Unstake(delegation) => { + let validator_id = delegation.base.validator_id.parse::().map_err(|_| "Invalid validator id for Monad")?; + let current_withdraw_id = get_withdraw_id(&delegation.base.delegation_id).unwrap_or(DEFAULT_WITHDRAW_ID); + let next_withdraw_id = current_withdraw_id.saturating_add(1); + if amount.sign() == Sign::Minus { + return Err("Negative values are not supported".into()); + } + let (_, amount_bytes) = amount.to_bytes_be(); + let amount_u256 = U256::from_be_slice(&amount_bytes); + Ok(( + IMonadStaking::undelegateCall { + validatorId: validator_id, + amount: amount_u256, + withdrawId: next_withdraw_id, + } + .abi_encode(), + BigInt::zero(), + )) + } + StakeType::Withdraw(delegation) => { + let validator_id = delegation.base.validator_id.parse::().map_err(|_| "Invalid validator id for Monad")?; + let withdraw_id = get_withdraw_id(&delegation.base.delegation_id).ok_or("Invalid withdraw id for Monad")?; + + Ok(( + IMonadStaking::withdrawCall { + validatorId: validator_id, + withdrawId: withdraw_id, + } + .abi_encode(), + BigInt::zero(), + )) + } + StakeType::Rewards(validators) => { + let validator = validators.first().ok_or("Missing validator for rewards")?; + let validator_id = validator.id.parse::().map_err(|_| "Invalid validator id for Monad")?; + Ok((IMonadStaking::claimRewardsCall { validatorId: validator_id }.abi_encode(), BigInt::zero())) + } + StakeType::Redelegate(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => Err("Unsupported stake type for Monad".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::TEST_MONAD_ADDRESS; + use primitives::{Delegation, DelegationBase, StakeType}; + + #[test] + fn test_delegation_id_is_unique_per_validator_and_state() { + let address = TEST_MONAD_ADDRESS; + + let everstake = delegation_id(address, 9, DelegationState::AwaitingWithdrawal, 1); + let stakin_withdraw = delegation_id(address, 10, DelegationState::Deactivating, 1); + let stakin_active = delegation_id(address, 10, DelegationState::Active, 1); + + assert_ne!(everstake, stakin_withdraw); + assert_ne!(stakin_withdraw, stakin_active); + assert_eq!(everstake, format!("{TEST_MONAD_ADDRESS}:9:awaitingwithdrawal:1")); + } + + #[test] + fn test_get_withdraw_id_supports_legacy_and_extended_ids() { + assert_eq!(get_withdraw_id(&format!("{TEST_MONAD_ADDRESS}:1")), Some(1)); + assert_eq!(get_withdraw_id(&format!("{TEST_MONAD_ADDRESS}:10:active:7")), Some(7)); + assert_eq!(get_withdraw_id("invalid"), None); + } + + #[test] + fn test_encode_monad_staking_reads_last_id_segment() { + let cases = [ + ( + StakeType::Unstake(Delegation::mock_base(DelegationBase { + validator_id: "10".to_string(), + delegation_id: format!("{TEST_MONAD_ADDRESS}:10:active:1"), + ..DelegationBase::mock() + })), + BigInt::from(5u32), + IMonadStaking::undelegateCall { + validatorId: 10, + amount: U256::from(5u32), + withdrawId: 2, + } + .abi_encode(), + ), + ( + StakeType::Withdraw(Delegation::mock_base(DelegationBase { + validator_id: "9".to_string(), + delegation_id: format!("{TEST_MONAD_ADDRESS}:9:awaitingwithdrawal:1"), + ..DelegationBase::mock() + })), + BigInt::zero(), + IMonadStaking::withdrawCall { validatorId: 9, withdrawId: 1 }.abi_encode(), + ), + ]; + + for (stake_type, amount, expected_data) in cases { + let (data, value) = encode_monad_staking(&stake_type, &amount).unwrap(); + + assert_eq!(value, BigInt::zero()); + assert_eq!(data, expected_data); + } + } +} diff --git a/core/crates/gem_evm/src/monad/mod.rs b/core/crates/gem_evm/src/monad/mod.rs new file mode 100644 index 0000000000..e3f23c04ba --- /dev/null +++ b/core/crates/gem_evm/src/monad/mod.rs @@ -0,0 +1,7 @@ +pub mod constants; +pub mod contracts; +pub mod mapper; + +pub use constants::*; +pub use contracts::*; +pub use mapper::*; diff --git a/core/crates/gem_evm/src/multicall3.rs b/core/crates/gem_evm/src/multicall3.rs new file mode 100644 index 0000000000..3334d07fa4 --- /dev/null +++ b/core/crates/gem_evm/src/multicall3.rs @@ -0,0 +1,97 @@ +use alloy_sol_types::{SolCall, sol}; +use primitives::EVMChain; + +// https://www.multicall3.com/ +sol! { + #[derive(Debug)] + interface IMulticall3 { + struct Call { + address target; + bytes callData; + } + + struct Call3 { + address target; + bool allowFailure; + bytes callData; + } + + struct Call3Value { + address target; + bool allowFailure; + uint256 value; + bytes callData; + } + + struct Result { + bool success; + bytes returnData; + } + + function aggregate(Call[] calldata calls) + external + payable + returns (uint256 blockNumber, bytes[] memory returnData); + + function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); + + function aggregate3Value(Call3Value[] calldata calls) + external + payable + returns (Result[] memory returnData); + + function tryAggregate(bool requireSuccess, Call[] calldata calls) + external + payable + returns (Result[] memory returnData); + } +} + +pub fn create_call3(target: &str, call: impl SolCall) -> IMulticall3::Call3 { + IMulticall3::Call3 { + target: target.parse().unwrap(), + allowFailure: true, + callData: call.abi_encode().into(), + } +} + +pub fn decode_call3_return(result: &IMulticall3::Result) -> Result> { + if result.success { + let decoded = T::abi_decode_returns(&result.returnData).map_err(|e| format!("{:?} abi decode error: {:?}", T::SIGNATURE, e))?; + Ok(decoded) + } else { + Err(format!("{:?} failed", T::SIGNATURE).into()) + } +} + +pub fn deployment_by_chain(chain: &EVMChain) -> &'static str { + match chain { + EVMChain::Ethereum + | EVMChain::Base + | EVMChain::Optimism + | EVMChain::Arbitrum + | EVMChain::AvalancheC + | EVMChain::Fantom + | EVMChain::SmartChain + | EVMChain::Polygon + | EVMChain::OpBNB + | EVMChain::Gnosis + | EVMChain::Manta + | EVMChain::Blast + | EVMChain::Linea + | EVMChain::Mantle + | EVMChain::Celo + | EVMChain::World + | EVMChain::Sonic + | EVMChain::SeiEvm + | EVMChain::Berachain + | EVMChain::Ink + | EVMChain::Unichain + | EVMChain::Hyperliquid + | EVMChain::Monad + | EVMChain::XLayer + | EVMChain::Plasma + | EVMChain::Stable => "0xcA11bde05977b3631167028862bE2a173976CA11", + EVMChain::ZkSync | EVMChain::Abstract => "0xF9cda624FBC7e059355ce98a31693d299FACd963", + } +} diff --git a/core/crates/gem_evm/src/permit2.rs b/core/crates/gem_evm/src/permit2.rs new file mode 100644 index 0000000000..fbfb365fef --- /dev/null +++ b/core/crates/gem_evm/src/permit2.rs @@ -0,0 +1,102 @@ +use crate::eip712::EIP712Type; +use alloy_sol_types::sol; +use serde::{Deserialize, Serialize}; + +// https://github.com/Uniswap/permit2/blob/main/src/interfaces/IAllowanceTransfer.sol +sol! { + /// @title AllowanceTransfer + /// @notice Handles ERC20 token permissions through signature based allowance setting and ERC20 token transfers by checking allowed amounts + /// @dev Requires user's token approval on the Permit2 contract + #[derive(Debug, PartialEq)] + interface IAllowanceTransfer { + /// @notice The permit data for a token + struct PermitDetails { + // ERC20 token address + address token; + // the maximum amount allowed to spend + uint160 amount; + // timestamp at which a spender's token allowances become invalid + uint48 expiration; + // an incrementing value indexed per owner,token,and spender for each signature + uint48 nonce; + } + + /// @notice The permit message signed for a single token allowance + struct PermitSingle { + // the permit data for a single token allowance + PermitDetails details; + // address permissioned on the allowed tokens + address spender; + // deadline on the permit signature + uint256 sigDeadline; + } + + /// @notice A mapping from owner address to token address to spender address to PackedAllowance struct, which contains details and conditions of the approval. + /// @notice The mapping is indexed in the above order see: allowance[ownerAddress][tokenAddress][spenderAddress] + /// @dev The packed slot holds the allowed amount, expiration at which the allowed amount is no longer valid, and current nonce thats updated on any signature based approvals. + function allowance(address, address, address) external view returns (uint160, uint48, uint48); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Permit2Types { + #[serde(rename = "EIP712Domain")] + pub eip712_domain: Vec, + #[serde(rename = "PermitSingle")] + pub single_types: Vec, + #[serde(rename = "PermitDetails")] + pub details_types: Vec, +} + +impl Default for Permit2Types { + fn default() -> Self { + Self { + eip712_domain: vec![ + EIP712Type { + name: "name".into(), + r#type: "string".into(), + }, + EIP712Type { + name: "chainId".into(), + r#type: "uint256".into(), + }, + EIP712Type { + name: "verifyingContract".into(), + r#type: "address".into(), + }, + ], + single_types: vec![ + EIP712Type { + name: "details".into(), + r#type: "PermitDetails".into(), + }, + EIP712Type { + name: "spender".into(), + r#type: "address".into(), + }, + EIP712Type { + name: "sigDeadline".into(), + r#type: "uint256".into(), + }, + ], + details_types: vec![ + EIP712Type { + name: "token".into(), + r#type: "address".into(), + }, + EIP712Type { + name: "amount".into(), + r#type: "uint160".into(), + }, + EIP712Type { + name: "expiration".into(), + r#type: "uint48".into(), + }, + EIP712Type { + name: "nonce".into(), + r#type: "uint48".into(), + }, + ], + } + } +} diff --git a/core/crates/gem_evm/src/provider/accounts.rs b/core/crates/gem_evm/src/provider/accounts.rs new file mode 100644 index 0000000000..f1a7a28c38 --- /dev/null +++ b/core/crates/gem_evm/src/provider/accounts.rs @@ -0,0 +1,25 @@ +#[cfg(feature = "rpc")] +use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual, ChainProvider, ChainTraits}; +use gem_client::Client; +use primitives::Chain; + +use crate::rpc::client::EthereumClient; + +#[cfg(feature = "rpc")] +impl ChainTraits for EthereumClient {} + +#[cfg(feature = "rpc")] +impl ChainProvider for EthereumClient { + fn get_chain(&self) -> Chain { + self.get_chain() + } +} + +#[cfg(feature = "rpc")] +impl ChainAccount for EthereumClient {} + +#[cfg(feature = "rpc")] +impl ChainPerpetual for EthereumClient {} + +#[cfg(feature = "rpc")] +impl ChainAddressStatus for EthereumClient {} diff --git a/core/crates/gem_evm/src/provider/balances.rs b/core/crates/gem_evm/src/provider/balances.rs new file mode 100644 index 0000000000..0054dae035 --- /dev/null +++ b/core/crates/gem_evm/src/provider/balances.rs @@ -0,0 +1,131 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainBalances; +use primitives::{AssetBalance, EVMChain}; + +use crate::provider::balances_mapper::{map_assets_balances, map_balance_coin, map_balance_tokens}; +use crate::rpc::client::EthereumClient; +use gem_client::Client; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainBalances for EthereumClient { + async fn get_balance_coin(&self, address: String) -> Result> { + map_balance_coin(self.get_eth_balance(&address).await?, self.get_chain()) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let balance_results = self.batch_token_balance_calls(&address, &token_ids).await?; + map_balance_tokens(balance_results, token_ids, self.get_chain()) + } + + async fn get_balance_staking(&self, address: String) -> Result, Box> { + match self.chain { + EVMChain::Ethereum => self.get_ethereum_staking_balance(&address).await, + EVMChain::SmartChain => self.get_smartchain_staking_balance(&address).await, + EVMChain::Monad => self.get_monad_staking_balance(&address).await, + _ => Ok(None), + } + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + if let Some(ankr_client) = &self.ankr_client { + let balances = ankr_client + .get_token_balances(address.as_str()) + .await? + .assets + .into_iter() + .filter_map(|asset| asset.contract_address.map(|addr| (addr, asset.balance_raw_integer))) + .collect(); + return Ok(map_assets_balances(balances, self.get_chain())); + } + return Ok(vec![]); + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{ + TEST_ADDRESS, TEST_SMARTCHAIN_STAKING_ADDRESS, TOKEN_DAI_ADDRESS, TOKEN_USDC_ADDRESS, create_arbitrum_test_client, create_ethereum_test_client, + create_smartchain_test_client, + }; + use chain_traits::ChainBalances; + use num_bigint::BigUint; + use primitives::Chain; + + #[tokio::test] + async fn test_ethereum_get_balance_coin() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + + println!("Ethereum ETH Balance: {:?}", balance.balance.available); + + assert_eq!(balance.asset_id.chain, Chain::Ethereum); + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + + Ok(()) + } + + #[tokio::test] + async fn test_arbitrum_get_balance_coin() -> Result<(), Box> { + let client = create_arbitrum_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + + println!("Arbitrum ETH Balance: {:?}", balance.balance.available); + + assert_eq!(balance.asset_id.chain, Chain::Arbitrum); + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_balance_coin() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + + println!("Smartchain BNB Balance: {:?}", balance.balance.available); + + assert_eq!(balance.asset_id.chain, Chain::SmartChain); + assert!(balance.balance.available > BigUint::from(0u32)); + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_balance_staking() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let balance = client.get_balance_staking(TEST_SMARTCHAIN_STAKING_ADDRESS.to_string()).await?.unwrap(); + + println!("Smartchain BNB Balance: {:?}", balance); + + assert!(balance.balance.staked > BigUint::from(1_000_000_000_000_000_000u64)); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_balance_tokens() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let token_ids = vec![TOKEN_USDC_ADDRESS.to_string(), TOKEN_DAI_ADDRESS.to_string()]; + + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids).await?; + + println!("USDC Balance: {:?}", balances); + + assert_eq!(balances.len(), 2); + + assert_eq!(balances[0].asset_id.chain, Chain::Ethereum); + assert_eq!(balances[0].asset_id.token_id, Some(TOKEN_USDC_ADDRESS.to_string())); + assert!(balances[0].balance.available > BigUint::from(0u32)); + + assert_eq!(balances[1].asset_id.chain, Chain::Ethereum); + assert_eq!(balances[1].asset_id.token_id, Some(TOKEN_DAI_ADDRESS.to_string())); + assert!(balances[1].balance.available > BigUint::from(0u32)); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/balances_mapper.rs b/core/crates/gem_evm/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..df8d3e4166 --- /dev/null +++ b/core/crates/gem_evm/src/provider/balances_mapper.rs @@ -0,0 +1,55 @@ +use crate::ethereum_address_checksum; +use num_bigint::BigUint; +use num_traits::Zero; +use primitives::{AssetBalance, AssetId, Balance, Chain}; +use serde_serializers::biguint_from_hex_str; +use std::error::Error; + +pub fn map_balance_coin(balance_hex: String, chain: Chain) -> Result> { + Ok(AssetBalance::new_balance(chain.as_asset_id(), Balance::coin_balance(biguint_from_hex_str(&balance_hex)?))) +} + +pub fn map_balance_tokens(balance_data: Vec, token_ids: Vec, chain: Chain) -> Result, Box> { + if balance_data.len() != token_ids.len() { + return Err("Balance data and token IDs length mismatch".into()); + } + + balance_data + .into_iter() + .zip(token_ids) + .map(|(balance_hex, token_id)| { + let asset_id = primitives::AssetId { chain, token_id: Some(token_id) }; + let balance = serde_serializers::biguint_from_hex_str(&balance_hex)?; + Ok(AssetBalance::new_balance(asset_id, Balance::coin_balance(balance))) + }) + .collect::, Box>>() +} + +pub fn map_assets_balances(balances: Vec<(String, BigUint)>, chain: Chain) -> Vec { + balances + .into_iter() + .filter_map(|(token_address, balance)| { + if balance.is_zero() { + return None; + } + + let checksum_address = ethereum_address_checksum(&token_address).ok()?; + let asset_id = AssetId::from_token(chain, &checksum_address); + Some(AssetBalance::new_balance(asset_id, Balance::coin_balance(balance))) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use num_bigint::BigUint; + use primitives::Chain; + + #[test] + fn test_map_balance_coin() { + let result = map_balance_coin("0x1c6bf52634000".to_string(), Chain::Ethereum).unwrap(); + assert_eq!(result.asset_id.chain, Chain::Ethereum); + assert_eq!(result.balance.available, BigUint::from(500000000000000_u64)); + } +} diff --git a/core/crates/gem_evm/src/provider/balances_smartchain.rs b/core/crates/gem_evm/src/provider/balances_smartchain.rs new file mode 100644 index 0000000000..ce582b1c7e --- /dev/null +++ b/core/crates/gem_evm/src/provider/balances_smartchain.rs @@ -0,0 +1,28 @@ +use crate::rpc::client::EthereumClient; +use gem_client::Client; +use num_bigint::BigUint; +use primitives::{AssetBalance, Balance}; +use std::error::Error; +use std::str::FromStr; + +#[cfg(feature = "rpc")] +impl EthereumClient { + pub async fn get_smartchain_staking_balance(&self, address: &str) -> Result, Box> { + let (delegations, undelegations) = self.fetch_smartchain_staking_state(address).await?; + + let staked = delegations + .iter() + .filter_map(|d| BigUint::from_str(&d.amount).ok()) + .fold(BigUint::from(0u32), |acc, amount| acc + amount); + + let pending = undelegations + .iter() + .filter_map(|u| BigUint::from_str(&u.amount).ok()) + .fold(BigUint::from(0u32), |acc, amount| acc + amount); + + Ok(Some(AssetBalance::new_balance( + self.get_chain().as_asset_id(), + Balance::stake_balance(staked, pending, None), + ))) + } +} diff --git a/core/crates/gem_evm/src/provider/mod.rs b/core/crates/gem_evm/src/provider/mod.rs new file mode 100644 index 0000000000..d7fe2f8c42 --- /dev/null +++ b/core/crates/gem_evm/src/provider/mod.rs @@ -0,0 +1,25 @@ +pub mod accounts; +pub mod balances; +pub mod balances_mapper; +pub mod balances_smartchain; +pub mod preload; +pub mod preload_mapper; +pub mod preload_optimism; +pub mod request_classifier; +pub mod staking; +pub mod staking_ethereum; +pub mod staking_monad; +pub mod staking_smartchain; +pub mod state; +pub mod state_mapper; +#[cfg(any(test, feature = "testkit"))] +pub mod testkit; +pub mod token; +pub mod token_mapper; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; + +pub struct BroadcastProvider; diff --git a/core/crates/gem_evm/src/provider/preload.rs b/core/crates/gem_evm/src/provider/preload.rs new file mode 100644 index 0000000000..c30153d8b6 --- /dev/null +++ b/core/crates/gem_evm/src/provider/preload.rs @@ -0,0 +1,313 @@ +#[cfg(feature = "rpc")] +use super::preload_optimism::OptimismGasOracle; +use crate::fee_calculator::{get_fee_history_blocks, get_reward_percentiles}; +use crate::provider::preload_mapper::{ + bigint_to_hex_string, bytes_to_hex_string, calculate_gas_limit_with_increase, get_extra_fee_gas_limit, get_transaction_params, map_transaction_fee_rates, + map_transaction_preload, +}; +use crate::rpc::client::EthereumClient; +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainTransactionLoad; +use gem_client::Client; +#[cfg(feature = "rpc")] +use num_bigint::BigInt; +#[cfg(feature = "rpc")] +use primitives::ContractCallData; +use primitives::GasPriceType; +#[cfg(feature = "rpc")] +use primitives::{FeeRate, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput}; +#[cfg(feature = "rpc")] +use serde_serializers::bigint::bigint_from_hex_str; +use std::collections::HashMap; +use std::error::Error; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainTransactionLoad for EthereumClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let nonce = self.get_transaction_count(&input.sender_address).await?; + let chain_id = self.chain.to_chain().network_id().to_string(); + + map_transaction_preload(nonce, chain_id) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + let fee_history = self.get_fee_history(get_fee_history_blocks(self.chain), get_reward_percentiles().to_vec()).await?; + + map_transaction_fee_rates(self.chain, &fee_history) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + self.map_transaction_load(input).await + } +} + +#[cfg(feature = "rpc")] +impl EthereumClient { + pub async fn map_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let params = get_transaction_params(self.chain, &input)?; + + let gas_estimate = { + let estimate = self + .estimate_gas( + Some(&input.sender_address), + ¶ms.to, + Some(&bigint_to_hex_string(¶ms.value)), + Some(&bytes_to_hex_string(¶ms.data)), + ) + .await?; + bigint_from_hex_str(&estimate)? + }; + let gas_limit = calculate_gas_limit_with_increase(gas_estimate); + let fee = self.calculate_fee(&input, &gas_limit).await?; + + let metadata = if let TransactionInputType::Stake(_, _) = &input.input_type { + match input.metadata { + TransactionLoadMetadata::Evm { nonce, chain_id, .. } => TransactionLoadMetadata::Evm { + nonce, + chain_id, + contract_call: Some(ContractCallData { + contract_address: params.to, + call_data: hex::encode(¶ms.data), + approval: None, + gas_limit: None, + }), + }, + _ => input.metadata, + } + } else { + input.metadata + }; + + Ok(TransactionLoadData { fee, metadata }) + } + + pub async fn calculate_fee(&self, input: &TransactionLoadInput, gas_limit: &BigInt) -> Result> { + if self.chain.is_opstack() { + OptimismGasOracle::new(self.chain, self.clone()).calculate_fee(input, gas_limit).await + } else { + calculate_fee(input, gas_limit) + } + } +} + +#[cfg(feature = "rpc")] +fn calculate_fee(input: &TransactionLoadInput, gas_limit: &BigInt) -> Result> { + let fee_gas_limit = gas_limit + get_extra_fee_gas_limit(input)?; + let fee = input.gas_price.total_fee() * fee_gas_limit; + + Ok(TransactionFee::new_gas_price_type( + GasPriceType::eip1559(input.gas_price.total_fee(), input.gas_price.priority_fee()), + fee, + gas_limit.clone(), + HashMap::new(), + )) +} + +#[cfg(all(test, feature = "rpc"))] +mod tests { + use super::*; + use primitives::{Asset, Chain, TransactionInputType, swap::SwapData}; + + #[test] + fn test_calculate_fee_swap_approval_keeps_transaction_gas_limit() -> Result<(), Box> { + let swap_data = SwapData::mock_with_data_and_approval("0x", Some("200000")); + let input = TransactionLoadInput::mock_evm(TransactionInputType::Swap(Asset::mock_erc20(), Asset::from_chain(Chain::Ethereum), swap_data), "1000000"); + let approval_gas_limit = BigInt::from(80_000u64); + let swap_gas_limit = BigInt::from(200_000u64); + let fee = calculate_fee(&input, &approval_gas_limit)?; + + assert_eq!(fee.gas_limit, approval_gas_limit); + assert_eq!(fee.fee, input.gas_price.total_fee() * (&approval_gas_limit + swap_gas_limit)); + + Ok(()) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, create_arbitrum_test_client, create_ethereum_test_client, create_smartchain_test_client, print_fee_rates}; + use num_bigint::BigInt; + use primitives::{Asset, Chain, FeePriority, GasPriceType, TransactionInputType, TransactionLoadInput}; + + #[tokio::test] + async fn test_ethereum_get_transaction_preload() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Ethereum)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_ADDRESS.to_string(), + }; + + let metadata = client.get_transaction_preload(input).await?; + + println!("metadata: {:#?}", metadata); + + assert!(metadata.get_sequence()? > 0); + assert_eq!(metadata.get_chain_id()?, "1"); + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_transaction_preload() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::SmartChain)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_ADDRESS.to_string(), + }; + + let metadata = client.get_transaction_preload(input).await?; + + println!("metadata: {:#?}", metadata); + + assert!(metadata.get_sequence()? > 0); + assert_eq!(metadata.get_chain_id()?, "56"); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_transaction_fee_rates() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let input_type = TransactionInputType::Transfer(Asset::from_chain(Chain::Ethereum)); + + let fee_rates = client.get_transaction_fee_rates(input_type).await?; + + print_fee_rates(fee_rates.clone()); + + assert_eq!(fee_rates.len(), 3); + + for fee_rate in &fee_rates { + assert!(fee_rate.gas_price_type.gas_price() > BigInt::from(0)); + assert!(fee_rate.gas_price_type.priority_fee() > BigInt::from(0)); + } + + Ok(()) + } + + #[tokio::test] + async fn test_arbitrum_get_transaction_fee_rates() -> Result<(), Box> { + let client = create_arbitrum_test_client(); + let input_type = TransactionInputType::Transfer(Asset::from_chain(Chain::Arbitrum)); + + let fee_rates = client.get_transaction_fee_rates(input_type).await?; + + print_fee_rates(fee_rates.clone()); + + assert_eq!(fee_rates.len(), 3); + + for fee_rate in &fee_rates { + assert!(fee_rate.gas_price_type.gas_price() > BigInt::from(0)); + assert!(fee_rate.gas_price_type.priority_fee() > BigInt::from(0)); + } + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_transaction_fee_rates() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let input_type = TransactionInputType::Transfer(Asset::from_chain(Chain::SmartChain)); + + let fee_rates = client.get_transaction_fee_rates(input_type).await?; + + print_fee_rates(fee_rates.clone()); + + assert_eq!(fee_rates.len(), 3); + + for fee_rate in &fee_rates { + //assert!(fee_rate.gas_price_type.gas_price() >= BigInt::from(100_000_000)); + assert!(fee_rate.gas_price_type.gas_price() < BigInt::from(1_000_000_000)); + assert!(fee_rate.gas_price_type.priority_fee() > BigInt::from(0)); + } + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_preload_transfer() -> Result<(), Box> { + let client = create_ethereum_test_client(); + + let preload_input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Ethereum)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_ADDRESS.to_string(), + }; + let metadata = client.get_transaction_preload(preload_input.clone()).await?; + + let fee_rates = [FeeRate::new(FeePriority::Normal, GasPriceType::eip1559(BigInt::from(177554820), BigInt::from(100000000)))]; + + let gas_price = fee_rates.first().ok_or("No fee rates available")?.gas_price_type.clone(); + + let load_input = TransactionLoadInput { + input_type: preload_input.input_type, + sender_address: preload_input.sender_address.clone(), + destination_address: preload_input.destination_address.clone(), + value: "100000000000000".to_string(), + gas_price, + memo: None, + is_max_value: false, + metadata, + }; + + let load_data = client.get_transaction_load(load_input).await?; + + println!("Transaction load data: {:#?}", load_data); + + assert!(load_data.fee.fee > BigInt::from(372865122u64)); + + assert!(load_data.fee.gas_limit == BigInt::from(21000)); + + assert!(load_data.metadata.get_sequence()? > 0); + assert_eq!(load_data.metadata.get_chain_id()?, "1"); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_preload_transfer_token() -> Result<(), Box> { + let client = create_ethereum_test_client(); + + let preload_input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::mock_erc20()), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_ADDRESS.to_string(), + }; + let metadata = client.get_transaction_preload(preload_input.clone()).await?; + + let fee_rates = [FeeRate::new(FeePriority::Normal, GasPriceType::eip1559(BigInt::from(177554820), BigInt::from(100000000)))]; + + let gas_price = fee_rates.first().ok_or("No fee rates available")?.gas_price_type.clone(); + + let load_input = TransactionLoadInput { + input_type: preload_input.input_type, + sender_address: preload_input.sender_address.clone(), + destination_address: preload_input.destination_address.clone(), + value: "1000000".to_string(), + gas_price, + memo: None, + is_max_value: false, + metadata, + }; + + let load_data = client.get_transaction_load(load_input).await?; + + println!("Token transfer load data: {:#?}", load_data); + + assert!(load_data.fee.fee > BigInt::from(0)); + assert!(load_data.fee.gas_limit > BigInt::from(0)); + + assert!(load_data.fee.gas_limit > BigInt::from(21000)); + assert!(load_data.fee.gas_limit < BigInt::from(75000)); + + assert!(load_data.metadata.get_sequence()? > 0); + assert_eq!(load_data.metadata.get_chain_id()?, "1"); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/preload_mapper.rs b/core/crates/gem_evm/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..937ba0b3c6 --- /dev/null +++ b/core/crates/gem_evm/src/provider/preload_mapper.rs @@ -0,0 +1,655 @@ +use std::error::Error; +use std::str::FromStr; + +use alloy_primitives::{U256, hex}; +use alloy_sol_types::SolCall; +use gem_bsc::stake_hub::STAKE_HUB_ADDRESS; +use num_bigint::BigInt; +use num_traits::Num; +use primitives::swap::SwapQuoteDataType; +use primitives::{ + AssetSubtype, Chain, EVMChain, FeeRate, NFTType, StakeType, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, decode_hex, fee::FeePriority, + fee::GasPriceType, +}; + +use crate::encode::{encode_erc20_approve_max_value, encode_erc20_transfer, encode_erc721_transfer, encode_erc1155_transfer}; +use crate::everstake::{DEFAULT_ALLOWED_INTERCHANGE_NUM, EVERSTAKE_ACCOUNTING_ADDRESS, EVERSTAKE_POOL_ADDRESS, EVERSTAKE_SOURCE, IAccounting, IPool}; +use crate::fee_calculator::FeeCalculator; +use crate::models::fee::EthereumFeeHistory; +use crate::monad::{STAKING_CONTRACT, encode_monad_staking}; + +const GAS_LIMIT_PERCENT_INCREASE: u32 = 50; +const GAS_LIMIT_21000: u64 = 21000; + +pub struct TransactionParams { + pub to: String, + pub data: Vec, + pub value: BigInt, +} + +impl TransactionParams { + pub fn new(to: String, data: Vec, value: BigInt) -> Self { + Self { to, data, value } + } + + pub fn new_approval(to: String, data: Vec) -> Self { + Self { to, data, value: BigInt::from(0) } + } +} + +pub fn bigint_to_hex_string(value: &BigInt) -> String { + format!("0x{:x}", value) +} + +pub fn bytes_to_hex_string(data: &[u8]) -> String { + format!("0x{}", hex::encode(data)) +} + +pub fn map_transaction_preload(nonce_hex: String, chain_id: String) -> Result> { + let nonce = u64::from_str_radix(nonce_hex.trim_start_matches("0x"), 16)?; + Ok(TransactionLoadMetadata::Evm { + nonce, + chain_id: chain_id.parse::()?, + contract_call: None, + }) +} + +pub fn map_transaction_fee_rates(chain: EVMChain, fee_history: &EthereumFeeHistory) -> Result, Box> { + let base_fee = fee_history.base_fee_per_gas.last().ok_or("No base fee available")?; + let min_priority_fee = BigInt::from(chain.min_priority_fee()); + + Ok(FeeCalculator::new() + .calculate_priority_fees(fee_history, &[FeePriority::Slow, FeePriority::Normal, FeePriority::Fast], min_priority_fee.clone())? + .into_iter() + .map(|x| { + let priority_fee = BigInt::max(min_priority_fee.clone(), x.value.clone()); + FeeRate::new(x.priority, GasPriceType::eip1559(base_fee.clone(), priority_fee)) + }) + .collect()) +} + +pub fn get_transaction_params(chain: EVMChain, input: &TransactionLoadInput) -> Result> { + let value = BigInt::from_str_radix(&input.value, 10)?; + + match &input.input_type { + TransactionInputType::Transfer(asset) | TransactionInputType::Deposit(asset) => match asset.id.token_subtype() { + AssetSubtype::NATIVE => Ok(TransactionParams::new(input.destination_address.clone(), vec![], value)), + AssetSubtype::TOKEN => { + let to = asset.token_id.as_ref().ok_or("Missing token ID")?.clone(); + let value = BigInt::from_str_radix(&input.value, 10)?; + let data = encode_erc20_transfer(&input.destination_address, &value)?; + Ok(TransactionParams::new(to, data, BigInt::from(0))) + } + }, + TransactionInputType::TransferNft(_, nft_asset) => { + let contract_address = nft_asset.contract_address.as_ref().ok_or("Missing contract address")?; + let data = match nft_asset.token_type { + NFTType::ERC721 => encode_erc721_transfer(&input.sender_address, &input.destination_address, &nft_asset.token_id)?, + NFTType::ERC1155 => encode_erc1155_transfer(&input.sender_address, &input.destination_address, &nft_asset.token_id)?, + _ => return Err("Unsupported NFT type for EVM".into()), + }; + Ok(TransactionParams::new(contract_address.clone(), data, BigInt::from(0))) + } + TransactionInputType::Swap(from_asset, _, swap_data) => { + if let Some(approval) = &swap_data.data.approval { + Ok(TransactionParams::new( + approval.token.clone(), + encode_erc20_approve_max_value(&approval.spender)?, + BigInt::from(0), + )) + } else { + match from_asset.id.token_subtype() { + AssetSubtype::NATIVE => Ok(TransactionParams::new( + swap_data.data.to.clone(), + hex::decode(swap_data.data.data.clone())?, + BigInt::from_str_radix(&swap_data.data.value, 10)?, + )), + AssetSubtype::TOKEN => match swap_data.data.data_type { + SwapQuoteDataType::Contract => Ok(TransactionParams::new(swap_data.data.to.clone(), hex::decode(swap_data.data.data.clone())?, BigInt::ZERO)), + SwapQuoteDataType::Transfer => { + let to = from_asset.token_id.clone().ok_or("Missing token ID")?.clone(); + let data = encode_erc20_transfer(&swap_data.data.to.clone(), &BigInt::from_str_radix(&input.value, 10)?)?; + Ok(TransactionParams::new(to, data, BigInt::ZERO)) + } + }, + } + } + } + TransactionInputType::TokenApprove(_, approval) => Ok(TransactionParams::new( + approval.token.clone(), + encode_erc20_approve_max_value(&approval.spender)?, + BigInt::from(0), + )), + TransactionInputType::Generic(_, _, extra) => Ok(TransactionParams::new( + extra.to.clone(), + extra.data.clone().unwrap_or_default(), + BigInt::from_str_radix(&input.value, 10)?, + )), + TransactionInputType::Stake(_, stake_type) => match chain.to_chain() { + Chain::SmartChain => { + let data = encode_stake_hub(stake_type, &BigInt::from_str_radix(&input.value, 10)?)?; + let value = match stake_type { + StakeType::Stake(_) => value, + StakeType::Unstake(_) | StakeType::Redelegate(_) | StakeType::Withdraw(_) => BigInt::from(0), + StakeType::Rewards(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => BigInt::from(0), + }; + Ok(TransactionParams::new(STAKE_HUB_ADDRESS.to_string(), data, value)) + } + Chain::Ethereum => { + let to = match stake_type { + StakeType::Stake(_) | StakeType::Unstake(_) => EVERSTAKE_POOL_ADDRESS.to_string(), + StakeType::Withdraw(_) => EVERSTAKE_ACCOUNTING_ADDRESS.to_string(), + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => return Err("Unsupported stake type".into()), + }; + let data = encode_everstake(stake_type, &BigInt::from_str_radix(&input.value, 10)?)?; + let value = match stake_type { + StakeType::Stake(_) => value, + StakeType::Unstake(_) | StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => { + BigInt::from(0) + } + }; + Ok(TransactionParams::new(to, data, value)) + } + Chain::Monad => { + let (data, stake_value) = encode_monad_staking(stake_type, &BigInt::from_str_radix(&input.value, 10)?)?; + Ok(TransactionParams::new(STAKING_CONTRACT.to_string(), data, stake_value)) + } + _ => Err("Unsupported chain for staking".into()), + }, + TransactionInputType::Earn(_, _, earn_data) => { + if let Some(approval) = &earn_data.approval { + Ok(TransactionParams::new_approval(approval.token.clone(), encode_erc20_approve_max_value(&approval.spender)?)) + } else { + Ok(TransactionParams::new( + earn_data.contract_address.clone(), + decode_hex(&earn_data.call_data)?, + BigInt::from(0), + )) + } + } + _ => Err("Unsupported transfer type".into()), + } +} + +pub fn calculate_gas_limit_with_increase(gas_limit: BigInt) -> BigInt { + if gas_limit == BigInt::from(GAS_LIMIT_21000) { + gas_limit + } else { + gas_limit * BigInt::from(100 + GAS_LIMIT_PERCENT_INCREASE) / BigInt::from(100) + } +} + +pub fn get_priority_fee_by_type(input_type: &TransactionInputType, is_max_value: bool, gas_price_type: &GasPriceType) -> BigInt { + match input_type { + TransactionInputType::Transfer(asset) | TransactionInputType::Deposit(asset) | TransactionInputType::TransferNft(asset, _) | TransactionInputType::Account(asset, _) => { + if asset.id.is_native() && is_max_value { + gas_price_type.gas_price() + } else { + gas_price_type.priority_fee() + } + } + _ => gas_price_type.priority_fee(), + } +} + +pub fn get_extra_fee_gas_limit(input: &TransactionLoadInput) -> Result> { + match &input.input_type { + TransactionInputType::Swap(_, _, swap_data) => { + if swap_data.data.approval.is_some() { + if let Some(ref gas_limit) = swap_data.data.gas_limit { + Ok(BigInt::from_str_radix(gas_limit, 10)?) + } else { + Ok(BigInt::from(0)) + } + } else { + Ok(BigInt::from(0)) + } + } + TransactionInputType::Earn(_, _, earn_data) => { + if earn_data.approval.is_some() + && let Some(gas_limit) = &earn_data.gas_limit + { + return Ok(BigInt::from_str_radix(gas_limit, 10)?); + } + Ok(BigInt::from(0)) + } + _ => Ok(BigInt::from(0)), + } +} + +fn big_int_to_u256(value: &BigInt) -> Result> { + if value < &BigInt::from(0) { + return Err("Negative values are not supported".into()); + } + + U256::from_str(&value.to_string()).map_err(|e| e.to_string().into()) +} + +fn encode_everstake(stake_type: &StakeType, amount: &BigInt) -> Result, Box> { + match stake_type { + StakeType::Stake(_) => Ok(IPool::stakeCall { source: EVERSTAKE_SOURCE }.abi_encode()), + StakeType::Unstake(_) => { + let value = big_int_to_u256(amount)?; + Ok(IPool::unstakeCall { + value, + allowedInterchangeNum: DEFAULT_ALLOWED_INTERCHANGE_NUM, + source: EVERSTAKE_SOURCE, + } + .abi_encode()) + } + StakeType::Withdraw(_) => Ok(IAccounting::claimWithdrawRequestCall {}.abi_encode()), + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => Err("Unsupported stake type for Everstake".into()), + } +} + +fn encode_stake_hub(stake_type: &StakeType, amount: &BigInt) -> Result, Box> { + match stake_type { + StakeType::Stake(validator) => gem_bsc::stake_hub::encode_delegate_call(&validator.id, false).map_err(|e| e.to_string().into()), + StakeType::Unstake(delegation) => { + // Calculate shares based on amount and delegation balance/shares ratio + let amount_uint = amount.magnitude().clone(); + let amount_shares = amount_uint * &delegation.base.shares / &delegation.base.balance; + + gem_bsc::stake_hub::encode_undelegate_call(&delegation.validator.id, &amount_shares.to_string()).map_err(|e| e.to_string().into()) + } + StakeType::Redelegate(redelegate_data) => { + // Calculate shares based on amount and delegation balance/shares ratio + let amount_uint = amount.magnitude().clone(); + let amount_shares = amount_uint * &redelegate_data.delegation.base.shares / &redelegate_data.delegation.base.balance; + + gem_bsc::stake_hub::encode_redelegate_call( + &redelegate_data.delegation.validator.id, + &redelegate_data.to_validator.id, + &amount_shares.to_string(), + false, + ) + .map_err(|e| e.to_string().into()) + } + StakeType::Withdraw(delegation) => { + // Request number 0 means claim all + gem_bsc::stake_hub::encode_claim_call(&delegation.validator.id, 0).map_err(|e| e.to_string().into()) + } + StakeType::Rewards(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => Err("Unsupported stake type for StakeHub".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::everstake::{EVERSTAKE_POOL_ADDRESS, IAccounting}; + use num_bigint::BigUint; + use primitives::{Delegation, DelegationBase, DelegationState, DelegationValidator, RedelegateData}; + + fn everstake_validator() -> DelegationValidator { + DelegationValidator::stake(Chain::Ethereum, EVERSTAKE_POOL_ADDRESS.to_string(), "Everstake".to_string(), true, 10.0, 4.2) + } + + #[test] + fn test_map_transaction_preload_with_hex_prefix() -> Result<(), Box> { + let nonce_hex = "0xa".to_string(); + let chain_id = "1".to_string(); + + let result = map_transaction_preload(nonce_hex, chain_id)?; + + match result { + TransactionLoadMetadata::Evm { nonce, chain_id, contract_call } => { + assert_eq!(nonce, 10); + assert_eq!(chain_id, 1); + assert!(contract_call.is_none()); + } + _ => panic!("Expected Evm variant"), + } + + Ok(()) + } + + #[test] + fn test_map_transaction_preload_invalid_nonce() { + let nonce_hex = "invalid".to_string(); + let chain_id_hex = "0x1".to_string(); + + let result = map_transaction_preload(nonce_hex, chain_id_hex); + + assert!(result.is_err()); + } + + #[test] + fn test_map_transaction_preload_invalid_chain_id() { + let nonce_hex = "0x1".to_string(); + let chain_id_hex = "invalid".to_string(); + + let result = map_transaction_preload(nonce_hex, chain_id_hex); + + assert!(result.is_err()); + } + + fn create_test_fee_history_for_mapper() -> EthereumFeeHistory { + EthereumFeeHistory { + reward: vec![vec!["0x5f5e100".to_string(), "0xbebc200".to_string(), "0x11e1a300".to_string()]], + base_fee_per_gas: vec![BigInt::from(20_000_000_000u64)], + gas_used_ratio: vec![0.5], + oldest_block: 0x1234, + } + } + + #[test] + fn test_map_transaction_fee_rates_normal_case() -> Result<(), Box> { + let fee_history = create_test_fee_history_for_mapper(); + + let result = map_transaction_fee_rates(EVMChain::Ethereum, &fee_history)?; + + assert_eq!(result.len(), 3); + + let min_priority_fee = BigInt::from(EVMChain::Ethereum.min_priority_fee()); + for fee_rate in &result { + match &fee_rate.gas_price_type { + GasPriceType::Eip1559 { gas_price, priority_fee } => { + assert!(*gas_price >= min_priority_fee); + assert!(*priority_fee >= min_priority_fee); + } + _ => panic!("Expected EIP-1559 gas price type"), + } + } + + Ok(()) + } + + #[test] + fn test_map_transaction_fee_rates_zero_base_fee() -> Result<(), Box> { + let fee_history = EthereumFeeHistory { + reward: vec![vec!["0x5f5e100".to_string(), "0xbebc200".to_string(), "0x11e1a300".to_string()]], + base_fee_per_gas: vec![BigInt::from(0u64)], // Zero base fee + gas_used_ratio: vec![0.5], + oldest_block: 0x1234, + }; + + let result = map_transaction_fee_rates(EVMChain::SmartChain, &fee_history)?; + + assert_eq!(result.len(), 3); + + assert_eq!(result[0].gas_price_type.gas_price(), BigInt::ZERO); + assert!(result[0].gas_price_type.priority_fee() != BigInt::ZERO); + + Ok(()) + } + + #[test] + fn test_map_transaction_fee_rates_invalid_hex() { + let fee_history = EthereumFeeHistory { + reward: vec![vec!["invalid_hex".to_string()]], + base_fee_per_gas: vec![BigInt::from(20_000_000_000u64)], + gas_used_ratio: vec![0.5], + oldest_block: 0x1234, + }; + + let result = map_transaction_fee_rates(EVMChain::Ethereum, &fee_history); + assert!(result.is_err()); + } + + #[test] + fn test_calculate_gas_limit_with_increase() { + let gas_21000 = BigInt::from(21000); + let result = calculate_gas_limit_with_increase(gas_21000.clone()); + assert_eq!(result, gas_21000); + + let gas_100000 = BigInt::from(100000); + let result = calculate_gas_limit_with_increase(gas_100000); + assert_eq!(result, BigInt::from(150000)); + } + + #[test] + fn test_bigint_to_string_conversion() { + let value = BigInt::from(100_000_000u64); + assert_eq!(value.to_string(), "100000000"); + + let min_priority = BigInt::from(primitives::EVMChain::Ethereum.min_priority_fee()); + assert_eq!(min_priority.to_string(), "100000000"); + } + + #[test] + fn test_encode_stake_hub_delegate() -> Result<(), Box> { + let validator = DelegationValidator::stake( + Chain::SmartChain, + "0x773760b0708a5Cc369c346993a0c225D8e4043B1".to_string(), + "Test Validator".to_string(), + true, + 5.0, + 10.0, + ); + + let stake_type = StakeType::Stake(validator); + let amount = BigInt::from(1_000_000_000_000_000_000u64); // 1 BNB + + let result = encode_stake_hub(&stake_type, &amount)?; + + // Should encode a delegate call + assert!(!result.is_empty()); + // The first 4 bytes should be the function selector for delegate + let selector = &result[0..4]; + assert_eq!(hex::encode(selector), "982ef0a7"); + + Ok(()) + } + + #[test] + fn test_encode_stake_hub_unstake() -> Result<(), Box> { + let delegation = Delegation { + base: DelegationBase { + asset_id: primitives::AssetId::from_chain(Chain::SmartChain), + state: DelegationState::Active, + balance: BigUint::from(2_000_000_000_000_000_000u64), // 2 BNB + shares: BigUint::from(1_900_000_000_000_000_000u64), // Slightly less shares + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: "test".to_string(), + validator_id: "0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A".to_string(), + }, + validator: DelegationValidator::stake( + Chain::SmartChain, + "0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A".to_string(), + "Test Validator".to_string(), + true, + 5.0, + 10.0, + ), + price: None, + }; + + let stake_type = StakeType::Unstake(delegation); + let amount = BigInt::from(1_000_000_000_000_000_000u64); // Unstake 1 BNB + + let result = encode_stake_hub(&stake_type, &amount)?; + + assert!(!result.is_empty()); + // The first 4 bytes should be the function selector for undelegate + let selector = &result[0..4]; + assert_eq!(hex::encode(selector), "4d99dd16"); + + Ok(()) + } + + #[test] + fn test_encode_stake_hub_redelegate() -> Result<(), Box> { + let delegation = Delegation { + base: DelegationBase { + asset_id: primitives::AssetId::from_chain(Chain::SmartChain), + state: DelegationState::Active, + balance: BigUint::from(2_000_000_000_000_000_000u64), // 2 BNB + shares: BigUint::from(1_900_000_000_000_000_000u64), // Slightly less shares + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: "test".to_string(), + validator_id: "0x773760b0708a5Cc369c346993a0c225D8e4043B1".to_string(), + }, + validator: DelegationValidator::stake( + Chain::SmartChain, + "0x773760b0708a5Cc369c346993a0c225D8e4043B1".to_string(), + "Source Validator".to_string(), + true, + 5.0, + 10.0, + ), + price: None, + }; + + let to_validator = DelegationValidator::stake( + Chain::SmartChain, + "0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A".to_string(), + "Target Validator".to_string(), + true, + 3.0, + 12.0, + ); + + let redelegate_data = RedelegateData { delegation, to_validator }; + + let stake_type = StakeType::Redelegate(redelegate_data); + let amount = BigInt::from(1_000_000_000_000_000_000u64); // Redelegate 1 BNB + + let result = encode_stake_hub(&stake_type, &amount)?; + + assert!(!result.is_empty()); + // The first 4 bytes should be the function selector for redelegate + let selector = &result[0..4]; + assert_eq!(hex::encode(selector), "59491871"); + + Ok(()) + } + + #[test] + fn test_encode_stake_hub_withdraw() -> Result<(), Box> { + let delegation = Delegation { + base: DelegationBase { + asset_id: primitives::AssetId::from_chain(Chain::SmartChain), + state: DelegationState::AwaitingWithdrawal, + balance: BigUint::from(1_000_000_000_000_000_000u64), + shares: BigUint::from(1_000_000_000_000_000_000u64), + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: "test".to_string(), + validator_id: "0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A".to_string(), + }, + validator: DelegationValidator::stake( + Chain::SmartChain, + "0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A".to_string(), + "Test Validator".to_string(), + true, + 5.0, + 10.0, + ), + price: None, + }; + + let stake_type = StakeType::Withdraw(delegation); + let amount = BigInt::from(0); // Amount doesn't matter for withdraw + + let result = encode_stake_hub(&stake_type, &amount)?; + + assert!(!result.is_empty()); + // The first 4 bytes should be the function selector for claim + let selector = &result[0..4]; + assert_eq!(hex::encode(selector), "aad3ec96"); + + Ok(()) + } + + #[test] + fn test_encode_everstake_stake() -> Result<(), Box> { + let stake_type = StakeType::Stake(everstake_validator()); + let amount = BigInt::from(1_000_000_000_000_000_000u64); + + let result = encode_everstake(&stake_type, &amount)?; + + let expected_hex = "3a29dbae0000000000000000000000000000000000000000000000000000000000000017"; + assert_eq!(hex::encode(&result), expected_hex); + + Ok(()) + } + + #[test] + fn test_encode_everstake_unstake() -> Result<(), Box> { + let validator = everstake_validator(); + let delegation = Delegation { + base: DelegationBase { + asset_id: primitives::AssetId::from_chain(Chain::Ethereum), + state: DelegationState::Active, + balance: BigUint::from(2_000_000_000_000_000_000u64), + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: "eth-delegation".to_string(), + validator_id: EVERSTAKE_POOL_ADDRESS.to_string(), + }, + validator: validator.clone(), + price: None, + }; + + let stake_type = StakeType::Unstake(delegation); + let amount = BigInt::from(500_000_000_000_000_000u64); + + let result = encode_everstake(&stake_type, &amount)?; + + let expected_hex = "76ec871c00000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000017"; + assert_eq!(hex::encode(&result), expected_hex); + + Ok(()) + } + + #[test] + fn test_encode_everstake_withdraw() -> Result<(), Box> { + let validator = everstake_validator(); + let delegation = Delegation { + base: DelegationBase { + asset_id: primitives::AssetId::from_chain(Chain::Ethereum), + state: DelegationState::AwaitingWithdrawal, + balance: BigUint::from(750_000_000_000_000_000u64), + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: "eth-withdraw".to_string(), + validator_id: EVERSTAKE_POOL_ADDRESS.to_string(), + }, + validator, + price: None, + }; + + let stake_type = StakeType::Withdraw(delegation); + let result = encode_everstake(&stake_type, &BigInt::from(0))?; + + let expected_hex = "33986ffa"; + assert_eq!(hex::encode(&result), expected_hex); + assert_eq!(result, IAccounting::claimWithdrawRequestCall {}.abi_encode()); + + Ok(()) + } + + #[test] + fn test_encode_erc721_transfer() -> Result<(), Box> { + let from = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"; + let to = "0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199"; + let token_id = "1234"; + + let result = encode_erc721_transfer(from, to, token_id)?; + + assert!(!result.is_empty()); + let selector = &result[0..4]; + assert_eq!(hex::encode(selector), "42842e0e"); + + Ok(()) + } + + #[test] + fn test_encode_erc1155_transfer() -> Result<(), Box> { + let from = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"; + let to = "0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199"; + let token_id = "5678"; + + let result = encode_erc1155_transfer(from, to, token_id)?; + + assert!(!result.is_empty()); + let selector = &result[0..4]; + assert_eq!(hex::encode(selector), "f242432a"); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/preload_optimism.rs b/core/crates/gem_evm/src/provider/preload_optimism.rs new file mode 100644 index 0000000000..fd9d442d6d --- /dev/null +++ b/core/crates/gem_evm/src/provider/preload_optimism.rs @@ -0,0 +1,155 @@ +use alloy_primitives::hex; +use num_bigint::BigInt; +use num_traits::Num; +use primitives::{EVMChain, TransactionFee, TransactionInputType, TransactionLoadInput, contract_constants::OPTIMISM_GAS_PRICE_ORACLE_CONTRACT}; +use serde_serializers::bigint::bigint_from_hex_str; +use std::collections::HashMap; +use std::error::Error; + +#[cfg(feature = "rpc")] +use crate::rpc::client::EthereumClient; +#[cfg(feature = "rpc")] +use gem_client::Client; +use primitives::GasPriceType; + +use super::preload_mapper::{bytes_to_hex_string, get_extra_fee_gas_limit, get_transaction_params}; + +#[cfg(feature = "rpc")] +pub struct OptimismGasOracle { + pub chain: EVMChain, + pub client: EthereumClient, +} + +#[cfg(feature = "rpc")] +impl OptimismGasOracle { + pub fn new(chain: EVMChain, client: EthereumClient) -> Self { + Self { chain, client } + } + + pub async fn calculate_fee(&self, input: &TransactionLoadInput, gas_limit: &BigInt) -> Result> { + let params = get_transaction_params(self.chain, input)?; + + let nonce = input.metadata.get_sequence()?; + let chain_id = input.metadata.get_chain_id()?.parse::()?; + + let extra_gas_limit = get_extra_fee_gas_limit(input)?; + + let adjusted_value = match &input.input_type { + TransactionInputType::Transfer(asset) + | TransactionInputType::Deposit(asset) + | TransactionInputType::TransferNft(asset, _) + | TransactionInputType::Account(asset, _) => { + if asset.id.is_native() && input.is_max_value { + let parsed_value = BigInt::from_str_radix(&input.value, 10)?; + parsed_value - gas_limit * &input.gas_price.gas_price() + } else { + params.value + } + } + _ => params.value, + }; + + let encoded = self.encode_transaction_for_l1_fee( + gas_limit, + &input.gas_price.gas_price(), + &input.gas_price.priority_fee(), + nonce, + Some(¶ms.data), + ¶ms.to, + chain_id, + Some(&adjusted_value), + input, + )?; + + let l1_fee = self.get_l1_fee(&encoded).await?; + let l2_fee = &input.gas_price.total_fee() * (gas_limit + &extra_gas_limit); + + let fee = l1_fee + l2_fee; + + Ok(TransactionFee::new_gas_price_type( + GasPriceType::eip1559(input.gas_price.total_fee(), input.gas_price.priority_fee()), + fee, + gas_limit.clone(), + HashMap::new(), + )) + } + + async fn get_l1_fee(&self, data: &[u8]) -> Result> { + let mut call_data = Vec::with_capacity(4 + 32 + data.len()); + call_data.extend_from_slice(&hex::decode("49948e0e")?); + call_data.extend_from_slice(&[0u8; 31]); + call_data.push(0x20); + let data_len = data.len(); + let len_bytes = BigInt::from(data_len).to_bytes_be().1; + let padding = 32_usize.saturating_sub(len_bytes.len()); + call_data.extend_from_slice(&vec![0u8; padding]); + call_data.extend_from_slice(&len_bytes); + call_data.extend_from_slice(data); + let data_padding = data.len().div_ceil(32) * 32 - data.len(); + call_data.extend_from_slice(&vec![0u8; data_padding]); + + let result = self.client.eth_call(OPTIMISM_GAS_PRICE_ORACLE_CONTRACT, &bytes_to_hex_string(&call_data)).await?; + + let result_str: String = result; + bigint_from_hex_str(&result_str) + } + + fn encode_transaction_for_l1_fee( + &self, + gas_limit: &BigInt, + gas_price: &BigInt, + priority_fee: &BigInt, + nonce: u64, + call_data: Option<&[u8]>, + to: &str, + chain_id: u64, + value: Option<&BigInt>, + input: &TransactionLoadInput, + ) -> Result, Box> { + let mut encoded = Vec::new(); + + encoded.push(0x02); + + let mut rlp_data = Vec::new(); + + rlp_data.extend_from_slice(&chain_id.to_be_bytes()); + rlp_data.extend_from_slice(&nonce.to_be_bytes()); + let priority_bytes = priority_fee.to_bytes_be().1; + rlp_data.extend_from_slice(&priority_bytes); + let gas_price_bytes = gas_price.to_bytes_be().1; + rlp_data.extend_from_slice(&gas_price_bytes); + let gas_limit_bytes = gas_limit.to_bytes_be().1; + rlp_data.extend_from_slice(&gas_limit_bytes); + let to_bytes = hex::decode(to.strip_prefix("0x").unwrap_or(to))?; + rlp_data.extend_from_slice(&to_bytes); + if let Some(v) = value { + let value_bytes = v.to_bytes_be().1; + rlp_data.extend_from_slice(&value_bytes); + } else { + rlp_data.push(0x80); + } + if let Some(d) = call_data { + rlp_data.extend_from_slice(d); + } else { + rlp_data.push(0x80); + } + + rlp_data.push(0xc0); + + encoded.extend_from_slice(&rlp_data); + + match &input.input_type { + TransactionInputType::Transfer(asset) + | TransactionInputType::Deposit(asset) + | TransactionInputType::TransferNft(asset, _) + | TransactionInputType::Account(asset, _) + if asset.id.is_native() && encoded.len() > 3 => + { + encoded.remove(2); + } + _ => {} + } + + Ok(encoded) + } +} diff --git a/core/crates/gem_evm/src/provider/request_classifier.rs b/core/crates/gem_evm/src/provider/request_classifier.rs new file mode 100644 index 0000000000..bfa8c15f20 --- /dev/null +++ b/core/crates/gem_evm/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_json_rpc_method("eth_sendRawTransaction") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_evm/src/provider/staking.rs b/core/crates/gem_evm/src/provider/staking.rs new file mode 100644 index 0000000000..2d0d0c4baf --- /dev/null +++ b/core/crates/gem_evm/src/provider/staking.rs @@ -0,0 +1,177 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainStaking; +use primitives::{DelegationBase, DelegationValidator, EVMChain}; + +use crate::rpc::client::EthereumClient; +use gem_client::Client; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainStaking for EthereumClient { + async fn get_staking_apy(&self) -> Result, Box> { + match self.chain { + EVMChain::SmartChain => self.get_smartchain_staking_apy().await, + EVMChain::Ethereum => self.get_ethereum_staking_apy().await, + EVMChain::Monad => self.get_monad_staking_apy().await, + _ => Ok(None), + } + } + + async fn get_staking_validators(&self, apy: Option) -> Result, Box> { + match self.chain { + EVMChain::SmartChain => self.get_smartchain_validators(apy.unwrap_or(0.0)).await, + EVMChain::Ethereum => self.get_ethereum_validators(apy.unwrap_or(0.0)).await, + EVMChain::Monad => self.get_monad_validators().await, + _ => Ok(vec![]), + } + } + + async fn get_staking_delegations(&self, address: String) -> Result, Box> { + match self.chain { + EVMChain::SmartChain => self.get_smartchain_delegations(&address).await, + EVMChain::Ethereum => self.get_ethereum_delegations(&address).await, + EVMChain::Monad => self.get_monad_delegations(&address).await, + _ => Ok(vec![]), + } + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{TEST_MONAD_ADDRESS, TEST_SMARTCHAIN_STAKING_ADDRESS, create_ethereum_test_client, create_monad_test_client, create_smartchain_test_client}; + use chain_traits::ChainStaking; + use num_bigint::BigUint; + use primitives::{Chain, DelegationState}; + + #[tokio::test] + async fn test_smartchain_get_staking_validators() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let validators = client.get_staking_validators(Some(0.0)).await?; + + println!("SmartChain Validators count: {}", validators.len()); + assert!(validators.len() > 24); + + if let Some(validator) = validators.first() { + assert_eq!(validator.chain, Chain::SmartChain); + assert!(!validator.id.is_empty()); + assert!(!validator.name.is_empty()); + } + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_staking_delegations() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let address = TEST_SMARTCHAIN_STAKING_ADDRESS.to_string(); + let delegations = client.get_staking_delegations(address).await?; + + println!("SmartChain Delegations: {:?}", delegations); + + assert!(!delegations.is_empty()); + + for delegation in &delegations { + println!( + "Delegation - Validator: {}, Balance: {}, State: {:?}", + delegation.validator_id, delegation.balance, delegation.state + ); + assert_eq!(delegation.asset_id.chain, Chain::SmartChain); + } + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_staking_apy() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let apy = client.get_staking_apy().await?.unwrap(); + + println!("SmartChain APY: {}", apy); + assert!(apy > 0.1, "Max APY should be greater than 0.1%, got: {}", apy); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_staking_apy() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let apy = client.get_staking_apy().await?.unwrap(); + + assert!(apy > 2.0 && apy < 6.0, "APY should be between 2% and 6%, got: {}", apy); + println!("Ethereum APY: {}", apy); + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_staking_validators() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let validators = client.get_staking_validators(Some(4.2)).await?; + + println!("Ethereum Validators count: {}", validators.len()); + assert_eq!(validators.len(), 1); // Should have exactly one Everstake validator + + if let Some(validator) = validators.first() { + assert_eq!(validator.chain, Chain::Ethereum); + assert_eq!(validator.name, "Everstake"); + assert!(validator.is_active); + assert_eq!(validator.apr, 4.2); + } + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_staking_delegations() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let address = "0xF3A43C831D4462019635C5E08F4c0920218f3b93".to_string(); + let delegations = client.get_staking_delegations(address).await?; + + println!("Ethereum Delegations count: {}", delegations.len()); + println!("Ethereum Delegations: {:?}", delegations); + + for delegation in &delegations { + println!( + "Delegation - Validator: {}, Balance: {}, State: {:?}", + delegation.validator_id, delegation.balance, delegation.state + ); + assert_eq!(delegation.asset_id.chain, Chain::Ethereum); + assert!( + delegation.state == DelegationState::Active + || delegation.state == DelegationState::Activating + || delegation.state == DelegationState::Deactivating + || delegation.state == DelegationState::AwaitingWithdrawal + ); + // Balance should be a valid positive number + assert!(delegation.balance >= BigUint::from(0u32)); + } + + Ok(()) + } + + #[tokio::test] + async fn test_monad_get_staking_delegations() -> Result<(), Box> { + let client = create_monad_test_client(); + let delegations = client.get_staking_delegations(TEST_MONAD_ADDRESS.to_string()).await?; + + assert!(!delegations.is_empty()); + + println!("Monad Delegations count: {}", delegations.len()); + println!("Monad Delegations: {:?}", delegations); + + Ok(()) + } + + #[tokio::test] + async fn test_monad_get_staking_apy() -> Result<(), Box> { + let client = create_monad_test_client(); + let apy = client.get_staking_apy().await?.unwrap(); + + println!("Monad APY: {}", apy); + assert!(apy > 0.0); + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/staking_ethereum.rs b/core/crates/gem_evm/src/provider/staking_ethereum.rs new file mode 100644 index 0000000000..6cede7c1ba --- /dev/null +++ b/core/crates/gem_evm/src/provider/staking_ethereum.rs @@ -0,0 +1,121 @@ +use gem_client::Client; +use num_bigint::BigUint; +use num_traits::Zero; +use primitives::{AssetBalance, AssetId, Balance, Chain, DelegationBase, DelegationState, DelegationValidator}; +use std::error::Error; + +use crate::everstake::{EVERSTAKE_POOL_ADDRESS, get_everstake_account_state, map_balance_to_delegation, map_withdraw_request_to_delegations}; +use crate::rpc::client::EthereumClient; + +#[cfg(all(feature = "rpc", feature = "reqwest"))] +use crate::everstake::client::get_everstake_staking_apy; + +#[cfg(feature = "rpc")] +impl EthereumClient { + pub async fn get_ethereum_staking_apy(&self) -> Result, Box> { + #[cfg(feature = "reqwest")] + { + get_everstake_staking_apy().await + } + + #[cfg(not(feature = "reqwest"))] + { + Ok(None) + } + } + + pub async fn get_ethereum_validators(&self, apy: f64) -> Result, Box> { + Ok(vec![DelegationValidator::stake( + Chain::Ethereum, + EVERSTAKE_POOL_ADDRESS.to_string(), + "Everstake".to_string(), + true, + 0.1, + apy, + )]) + } + + pub async fn get_ethereum_delegations(&self, address: &str) -> Result, Box> { + let state = get_everstake_account_state(self, address).await?; + + let mut delegations = Vec::new(); + + let active_balance = state.deposited_balance; + if active_balance > BigUint::zero() { + delegations.push(map_balance_to_delegation(&active_balance, &state.restaked_reward, DelegationState::Active)); + } + + let pending_balance = state.pending_balance + state.pending_deposited_balance; + if pending_balance > BigUint::zero() { + delegations.push(map_balance_to_delegation(&pending_balance, &BigUint::zero(), DelegationState::Activating)); + } + + let mut withdraw_delegations = map_withdraw_request_to_delegations(&state.withdraw_request); + delegations.append(&mut withdraw_delegations); + + Ok(delegations) + } + + pub async fn get_ethereum_staking_balance(&self, address: &str) -> Result, Box> { + let delegations = self.get_ethereum_delegations(address).await?; + + let mut staked = BigUint::zero(); + let mut rewards = BigUint::zero(); + let mut pending = BigUint::zero(); + for delegation in &delegations { + match delegation.state { + DelegationState::Active => { + staked += &delegation.balance; + rewards += &delegation.rewards; + } + DelegationState::Activating | DelegationState::Deactivating | DelegationState::AwaitingWithdrawal => { + pending += &delegation.balance; + } + _ => {} + } + } + + let balance = Balance::stake_balance(staked, pending, Some(rewards)); + + Ok(Some(AssetBalance::new_balance(AssetId::from_chain(Chain::Ethereum), balance))) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod tests { + use crate::provider::testkit::{TEST_ADDRESS, create_ethereum_test_client}; + use chain_traits::{ChainBalances, ChainStaking}; + use num_bigint::BigUint; + + #[tokio::test] + async fn test_ethereum_get_delegations() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let address = TEST_ADDRESS.to_string(); + let delegations = client.get_staking_delegations(address.clone()).await?; + + println!("Delegations for address: {}", address); + for delegation in &delegations { + println!( + "Delegation - Validator: {}, Balance: {}, Rewards: {}, State: {:?}", + delegation.validator_id, delegation.balance, delegation.rewards, delegation.state + ); + } + + assert_eq!(delegations.len(), 1); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_staking_balance() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let address = TEST_ADDRESS.to_string(); + let balance = client.get_balance_staking(address).await?; + + println!("Ethereum staking balance: {:?}", balance); + + assert!(balance.unwrap().balance.staked > BigUint::from(0u32)); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/staking_monad.rs b/core/crates/gem_evm/src/provider/staking_monad.rs new file mode 100644 index 0000000000..43d8ca5058 --- /dev/null +++ b/core/crates/gem_evm/src/provider/staking_monad.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; +use std::error::Error; + +use alloy_primitives::hex; +use chrono::{DateTime, Utc}; +use gem_client::Client; +use num_bigint::BigUint; +use num_traits::{ToPrimitive, Zero}; +use primitives::{AssetBalance, AssetId, Chain, DelegationBase, DelegationState, DelegationValidator}; + +use crate::jsonrpc::BlockParameter; +use crate::monad::{ + IMonadStakingLens, MONAD_SCALE, MonadLensBalance, MonadLensDelegation, MonadLensValidatorInfo, STAKING_LENS_CONTRACT, decode_get_lens_apys, decode_get_lens_balance, + decode_get_lens_delegations, decode_get_lens_validators, delegation_id, encode_get_lens_apys, encode_get_lens_balance, encode_get_lens_delegations, encode_get_lens_validators, +}; +use crate::rpc::client::EthereumClient; + +const MONAD_VALIDATOR_NAMES: &[(u64, &str)] = &[(16, "MonadVision"), (5, "Alchemy"), (10, "Stakin"), (9, "Everstake")]; + +#[cfg(feature = "rpc")] +impl EthereumClient { + pub async fn get_monad_staking_apy(&self) -> Result, Box> { + let data = encode_get_lens_apys(&[]); + let result = self.call_lens(data).await.ok_or_else(|| "Monad staking lens not configured".to_string())??; + + let apys = decode_get_lens_apys(&result)?; + let apy_bps = apys.into_iter().max().unwrap_or(0); + + if apy_bps == 0 { + return Ok(None); + } + + Ok(Some(apy_bps as f64 / 100.0)) + } + + pub async fn get_monad_validators(&self) -> Result, Box> { + let validator_names: HashMap = MONAD_VALIDATOR_NAMES.iter().copied().collect(); + let validator_ids = Self::monad_curated_validator_ids(); + let data = encode_get_lens_validators(&validator_ids); + let result = self.call_lens(data).await.ok_or_else(|| "Monad staking lens not configured".to_string())??; + + let (validators, network_apy_bps) = decode_get_lens_validators(&result)?; + let network_apy = network_apy_bps as f64 / 100.0; + + Ok(validators + .into_iter() + .map(|validator| self.map_lens_validator(&validator, &validator_names, network_apy)) + .collect()) + } + + pub async fn get_monad_delegations(&self, address: &str) -> Result, Box> { + self.fetch_monad_delegations(address).await + } + + pub async fn get_monad_staking_balance(&self, address: &str) -> Result, Box> { + let balance = self.fetch_monad_balance(address).await?; + Ok(Some(Self::monad_asset_balance(balance.staked, balance.pending, balance.rewards))) + } + + async fn fetch_monad_balance(&self, address: &str) -> Result> { + let data = encode_get_lens_balance(address)?; + let result = self.call_lens(data).await.ok_or_else(|| "Monad staking lens not configured".to_string())??; + + decode_get_lens_balance(&result) + } + + async fn fetch_monad_delegations(&self, address: &str) -> Result, Box> { + let data = encode_get_lens_delegations(address)?; + let Some(result) = self.call_lens(data).await else { + return Ok(Vec::new()); + }; + + let positions = match result { + Ok(bytes) => match decode_get_lens_delegations(&bytes) { + Ok(position_list) => position_list, + Err(_) => return Ok(Vec::new()), + }, + Err(_) => return Ok(Vec::new()), + }; + + if positions.is_empty() { + return Ok(Vec::new()); + } + + let mut delegations = Vec::new(); + + for position in positions { + if position.amount.is_zero() && position.rewards.is_zero() { + continue; + } + + let state = Self::map_lens_state(&position); + let completion_date = if position.completion_timestamp == 0 { + None + } else { + DateTime::::from_timestamp(position.completion_timestamp as i64, 0) + }; + + delegations.push(DelegationBase { + asset_id: AssetId::from_chain(Chain::Monad), + state, + balance: position.amount, + shares: BigUint::zero(), + rewards: position.rewards, + completion_date, + delegation_id: delegation_id(address, position.validator_id, state, position.withdraw_id), + validator_id: position.validator_id.to_string(), + }); + } + + Ok(delegations) + } + + fn map_lens_validator(&self, validator: &MonadLensValidatorInfo, validator_names: &HashMap, network_apy: f64) -> DelegationValidator { + let validator_name = validator_names + .get(&validator.validator_id) + .map(|name| (*name).to_string()) + .unwrap_or_else(|| validator.validator_id.to_string()); + + DelegationValidator::stake( + Chain::Monad, + validator.validator_id.to_string(), + validator_name, + validator.is_active, + Self::lens_commission_rate(&validator.commission), + if validator.apy_bps > 0 { validator.apy_bps as f64 / 100.0 } else { network_apy }, + ) + } + + fn map_lens_state(position: &MonadLensDelegation) -> DelegationState { + match position.state { + IMonadStakingLens::DelegationState::Active => DelegationState::Active, + IMonadStakingLens::DelegationState::Activating => DelegationState::Activating, + IMonadStakingLens::DelegationState::Deactivating => DelegationState::Deactivating, + IMonadStakingLens::DelegationState::AwaitingWithdrawal => DelegationState::AwaitingWithdrawal, + IMonadStakingLens::DelegationState::__Invalid => DelegationState::Inactive, + } + } + + async fn call_lens(&self, data: Vec) -> Option, Box>> { + let call = Self::build_lens_call(&data)?; + + Some( + self.call(call.0, call.1) + .await + .map_err(|err| -> Box { Box::new(err) }) + .and_then(|result: String| hex::decode(result).map_err(|err| -> Box { Box::new(err) })), + ) + } + + fn build_lens_call(data: &[u8]) -> Option<(String, serde_json::Value)> { + Some(( + "eth_call".to_string(), + serde_json::json!([{ + "to": STAKING_LENS_CONTRACT, + "data": hex::encode_prefixed(data) + }, serde_json::Value::from(BlockParameter::Latest)]), + )) + } + + fn lens_commission_rate(commission: &BigUint) -> f64 { + commission.to_f64().unwrap_or(0.0) / MONAD_SCALE + } + + fn monad_asset_balance(staked: BigUint, pending: BigUint, rewards: BigUint) -> AssetBalance { + AssetBalance::new_balance(AssetId::from_chain(Chain::Monad), primitives::Balance::stake_balance(staked, pending, Some(rewards))) + } + + fn monad_curated_validator_ids() -> Vec { + MONAD_VALIDATOR_NAMES.iter().map(|(id, _)| *id).collect() + } +} diff --git a/core/crates/gem_evm/src/provider/staking_smartchain.rs b/core/crates/gem_evm/src/provider/staking_smartchain.rs new file mode 100644 index 0000000000..a22d614f2b --- /dev/null +++ b/core/crates/gem_evm/src/provider/staking_smartchain.rs @@ -0,0 +1,217 @@ +use crate::jsonrpc::BlockParameter; +use crate::{constants::STAKING_VALIDATORS_LIMIT, rpc::client::EthereumClient}; +use alloy_primitives::hex; +use chrono::{DateTime, Utc}; +use gem_bsc::stake_hub::{ + HUB_READER_ADDRESS, STAKE_HUB_ADDRESS, decode_delegations_return, decode_undelegations_return, decode_validators_return, encode_delegations_call, encode_undelegations_call, + encode_validators_call, +}; +use gem_client::Client; +use num_bigint::BigUint; +use primitives::{AssetId, Chain, DelegationBase, DelegationState, DelegationValidator}; +use std::{error::Error, str::FromStr}; + +#[cfg(feature = "rpc")] +impl EthereumClient { + pub async fn get_smartchain_validators(&self, _apy: f64) -> Result, Box> { + let limit = self.get_max_elected_validators().await?; + let call_data = encode_validators_call(0, limit); + + let call = ( + "eth_call".to_string(), + serde_json::json!([{ + "to": HUB_READER_ADDRESS, + "data": hex::encode_prefixed(&call_data) + }, serde_json::Value::from(BlockParameter::Latest)]), + ); + + let result: String = self.call(call.0, call.1).await?; + let result_data = hex::decode(result)?; + let validators = decode_validators_return(&result_data)?; + + Ok(validators + .into_iter() + .map(|v| { + DelegationValidator::stake( + Chain::SmartChain, + v.operator_address.clone(), + v.moniker, + !v.jailed, + v.commission as f64 / 10000.0, + v.apy as f64 / 100.0, + ) + }) + .collect()) + } + + pub async fn get_smartchain_staking_apy(&self) -> Result, Box> { + let validators = self.get_smartchain_validators(0.0).await?; + let max_apr = validators + .into_iter() + .filter(|validator| validator.is_active) + .filter_map(|validator| if validator.apr.is_finite() { Some(validator.apr) } else { None }) + .fold(None, |acc: Option, apr| match acc { + Some(current) if current >= apr => Some(current), + _ => Some(apr), + }); + Ok(max_apr) + } + + pub async fn get_smartchain_delegations(&self, address: &str) -> Result, Box> { + let (delegations, undelegations) = self.fetch_smartchain_staking_state(address).await?; + + let mut result = Vec::new(); + + let asset_id = AssetId { + chain: self.get_chain(), + token_id: None, + }; + + for delegation in delegations { + if let Ok(balance) = BigUint::from_str(&delegation.amount) { + let shares = BigUint::from_str(&delegation.shares).unwrap_or_else(|_| BigUint::from(0u32)); + + result.push(DelegationBase { + asset_id: asset_id.clone(), + delegation_id: delegation.delegator_address.clone(), + validator_id: delegation.validator_address, + balance, + shares, + rewards: BigUint::from(0u32), + completion_date: None, + state: DelegationState::Active, + }); + } + } + + for undelegation in undelegations { + if let Ok(balance) = BigUint::from_str(&undelegation.amount) { + let shares = BigUint::from_str(&undelegation.shares).unwrap_or_else(|_| BigUint::from(0u32)); + + let completion_date = undelegation + .unlock_time + .parse::() + .ok() + .and_then(|unlock_time| DateTime::from_timestamp(unlock_time, 0)); + + let state = if let Some(ref completion_date) = completion_date { + if *completion_date > Utc::now() { + DelegationState::Deactivating + } else { + DelegationState::AwaitingWithdrawal + } + } else { + DelegationState::Deactivating + }; + + result.push(DelegationBase { + asset_id: asset_id.clone(), + delegation_id: undelegation.delegator_address.clone(), + validator_id: undelegation.validator_address, + balance, + shares, + rewards: BigUint::from(0u32), + completion_date, + state, + }); + } + } + + Ok(result) + } + + pub(crate) async fn fetch_smartchain_staking_state( + &self, + address: &str, + ) -> Result<(Vec, Vec), Box> { + let delegations_call_data = encode_delegations_call(address, 0, STAKING_VALIDATORS_LIMIT)?; + let undelegations_call_data = encode_undelegations_call(address, 0, STAKING_VALIDATORS_LIMIT)?; + + let calls = vec![ + ( + "eth_call".to_string(), + serde_json::json!([{ + "to": HUB_READER_ADDRESS, + "data": hex::encode_prefixed(&delegations_call_data) + }, serde_json::Value::from(BlockParameter::Latest)]), + ), + ( + "eth_call".to_string(), + serde_json::json!([{ + "to": HUB_READER_ADDRESS, + "data": hex::encode_prefixed(&undelegations_call_data) + }, serde_json::Value::from(BlockParameter::Latest)]), + ), + ]; + + let results: Vec = self.client.batch_call::(calls).await?.take_all()?; + + let delegations_data = hex::decode(&results[0])?; + let delegations = decode_delegations_return(&delegations_data)?; + + let undelegations_data = hex::decode(&results[1])?; + let undelegations = decode_undelegations_return(&undelegations_data)?; + + Ok((delegations, undelegations)) + } + + async fn get_max_elected_validators(&self) -> Result> { + let call = ( + "eth_call".to_string(), + serde_json::json!([{ + "to": STAKE_HUB_ADDRESS, + "data": "0xc473318f" + }, serde_json::Value::from(BlockParameter::Latest)]), + ); + + let result: String = self.call(call.0, call.1).await?; + let result_data = hex::decode(result.trim_start_matches("0x"))?; + + if result_data.len() >= 32 { + let value = u32::from_be_bytes([result_data[28], result_data[29], result_data[30], result_data[31]]) as u16; + Ok(value) + } else { + Err("Invalid response format for maxElectedValidators".into()) + } + } +} + +#[cfg(test)] +mod tests { + use chrono::DateTime; + + #[test] + fn test_undelegation_completion_date_valid() { + let expected_timestamp = 1716417585i64; + let expected_date = DateTime::from_timestamp(expected_timestamp, 0).unwrap(); + + let completion_date = "1716417585".parse::().ok().and_then(|unlock_time| DateTime::from_timestamp(unlock_time, 0)); + + assert!(completion_date.is_some()); + assert_eq!(completion_date.unwrap(), expected_date); + assert_eq!(completion_date.unwrap().timestamp(), expected_timestamp); + } + + #[test] + fn test_undelegation_completion_date_invalid() { + let completion_date = "invalid".parse::().ok().and_then(|unlock_time| DateTime::from_timestamp(unlock_time, 0)); + + assert!(completion_date.is_none()); + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{TEST_SMARTCHAIN_STAKING_ADDRESS, create_smartchain_test_client}; + + #[tokio::test] + async fn test_get_smartchain_delegations() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let delegations = client.get_smartchain_delegations(TEST_SMARTCHAIN_STAKING_ADDRESS).await?; + + println!("SmartChain Delegations count: {}", delegations.len()); + assert!(!delegations.is_empty()); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/state.rs b/core/crates/gem_evm/src/provider/state.rs new file mode 100644 index 0000000000..7821d105da --- /dev/null +++ b/core/crates/gem_evm/src/provider/state.rs @@ -0,0 +1,99 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainState; + +#[cfg(feature = "rpc")] +use crate::provider::state_mapper; +use crate::rpc::client::EthereumClient; +use gem_client::Client; +#[cfg(feature = "rpc")] +use primitives::NodeSyncStatus; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainState for EthereumClient { + async fn get_chain_id(&self) -> Result> { + let chain_id = EthereumClient::get_chain_id(self).await?; + Ok(u64::from_str_radix(chain_id.trim_start_matches("0x"), 16)?.to_string()) + } + + async fn get_node_status(&self) -> Result> { + let sync_status = self.get_sync_status().await?; + let latest_block = self.get_block_latest_number().await?; + state_mapper::map_node_status(&sync_status, latest_block) + } + + async fn get_block_latest_number(&self) -> Result> { + let block_number = self.get_latest_block().await?; + Ok(block_number) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{create_ethereum_test_client, create_smartchain_test_client}; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_ethereum_get_chain_id() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let chain_id = ChainState::get_chain_id(&client).await?; + + println!("Ethereum Chain ID: {}", chain_id); + + assert_eq!(chain_id, "1"); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_block_latest_number() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let block_number = ChainState::get_block_latest_number(&client).await?; + + println!("Ethereum Latest Block: {}", block_number); + + assert!(block_number > 0); + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_chain_id() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let chain_id = ChainState::get_chain_id(&client).await?; + + println!("SmartChain Chain ID: {}", chain_id); + + assert_eq!(chain_id, "56"); + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_block_latest_number() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let block_number = ChainState::get_block_latest_number(&client).await?; + + println!("SmartChain Latest Block: {}", block_number); + + assert!(block_number > 0); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_node_status() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let node_status = ChainState::get_node_status(&client).await?; + + println!("Ethereum Node Status: {:?}", node_status); + + assert!(node_status.in_sync); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/state_mapper.rs b/core/crates/gem_evm/src/provider/state_mapper.rs new file mode 100644 index 0000000000..4a2f2fe717 --- /dev/null +++ b/core/crates/gem_evm/src/provider/state_mapper.rs @@ -0,0 +1,50 @@ +use crate::rpc::model::EthSyncingStatus; +use primitives::NodeSyncStatus; +use std::error::Error; + +pub fn map_node_status(sync_status: &EthSyncingStatus, latest_block: u64) -> Result> { + match sync_status { + EthSyncingStatus::NotSyncing(_) => Ok(NodeSyncStatus::synced(latest_block)), + EthSyncingStatus::Syncing(info) => { + let latest = info.highest_block.to_string().parse::().ok(); + let current = info.current_block.to_string().parse::().ok(); + Ok(NodeSyncStatus::new(false, latest, current)) + } + } +} + +#[cfg(test)] +mod tests { + use num_bigint::BigUint; + + use crate::rpc::model::EthSyncingInfo; + + use super::*; + + #[test] + fn test_map_node_status_not_syncing() { + let status = EthSyncingStatus::NotSyncing(false); + let latest_block = 12345678u64; + let mapped = map_node_status(&status, latest_block).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(12345678)); + assert_eq!(mapped.current_block_number, Some(12345678)); + } + + #[test] + fn test_map_node_status_syncing() { + let info = EthSyncingInfo { + current_block: BigUint::from(5u64), + highest_block: BigUint::from(10u64), + }; + let status = EthSyncingStatus::Syncing(info); + let latest_block = 12345678u64; + + let mapped = map_node_status(&status, latest_block).unwrap(); + + assert!(!mapped.in_sync); + assert_eq!(mapped.current_block_number, Some(5)); + assert_eq!(mapped.latest_block_number, Some(10)); + } +} diff --git a/core/crates/gem_evm/src/provider/testkit.rs b/core/crates/gem_evm/src/provider/testkit.rs new file mode 100644 index 0000000000..315eb0e913 --- /dev/null +++ b/core/crates/gem_evm/src/provider/testkit.rs @@ -0,0 +1,64 @@ +pub use crate::testkit::{TEST_ADDRESS, TEST_MONAD_ADDRESS, TEST_SMARTCHAIN_STAKING_ADDRESS, TEST_TRANSACTION_ID, TOKEN_DAI_ADDRESS, TOKEN_USDC_ADDRESS}; + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +use primitives::FeeRate; + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +fn build_test_client(chain: primitives::EVMChain, rpc_url: &str) -> crate::rpc::client::EthereumClient { + use crate::rpc::{ankr::AnkrClient, client::EthereumClient}; + use gem_jsonrpc::JsonRpcClient; + + let settings = get_test_settings(); + let rpc_client = JsonRpcClient::new_reqwest(rpc_url.to_string()); + + let ankr_client = AnkrClient::new(JsonRpcClient::new_reqwest(format!("https://rpc.ankr.com/multichain/{}", settings.ankr.key.secret)), chain); + + EthereumClient::new(rpc_client, chain).with_ankr_client(ankr_client) +} + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +pub fn create_ethereum_test_client() -> crate::rpc::client::EthereumClient { + let settings = get_test_settings(); + build_test_client(primitives::EVMChain::Ethereum, &settings.chains.ethereum.url) +} + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +pub fn create_smartchain_test_client() -> crate::rpc::client::EthereumClient { + let settings = get_test_settings(); + build_test_client(primitives::EVMChain::SmartChain, &settings.chains.smartchain.url) +} + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +pub fn create_polygon_test_client() -> crate::rpc::client::EthereumClient { + let settings = get_test_settings(); + build_test_client(primitives::EVMChain::Polygon, &settings.chains.polygon.url) +} + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +pub fn create_arbitrum_test_client() -> crate::rpc::client::EthereumClient { + let settings = get_test_settings(); + build_test_client(primitives::EVMChain::Arbitrum, &settings.chains.arbitrum.url) +} + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +pub fn create_monad_test_client() -> crate::rpc::client::EthereumClient { + let settings = get_test_settings(); + build_test_client(primitives::EVMChain::Monad, &settings.chains.monad.url) +} + +#[cfg(all(test, feature = "rpc", feature = "reqwest"))] +pub fn print_fee_rates(fee_rates: Vec) { + for fee_rate in &fee_rates { + use crate::ether_conv; + println!( + "Fee rate: {:?} total: {}, gas_price: {}, priority_fee: {}", + fee_rate.priority, + ether_conv::EtherConv::to_gwei(&fee_rate.gas_price_type.total_fee()), + ether_conv::EtherConv::to_gwei(&fee_rate.gas_price_type.gas_price()), + ether_conv::EtherConv::to_gwei(&fee_rate.gas_price_type.priority_fee()) + ); + } +} diff --git a/core/crates/gem_evm/src/provider/token.rs b/core/crates/gem_evm/src/provider/token.rs new file mode 100644 index 0000000000..a4ae56cad5 --- /dev/null +++ b/core/crates/gem_evm/src/provider/token.rs @@ -0,0 +1,93 @@ +use std::error::Error; + +use crate::provider::token_mapper::{map_is_token_address, map_token_data}; +use crate::rpc::client::{EthereumClient, FUNCTION_ERC20_DECIMALS, FUNCTION_ERC20_NAME, FUNCTION_ERC20_SYMBOL}; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainToken; +#[cfg(feature = "rpc")] +use gem_client::Client; +#[cfg(feature = "rpc")] +use primitives::Asset; +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainToken for EthereumClient { + async fn get_token_data(&self, token_id: String) -> Result> { + let [name, symbol, decimals] = self + .batch_eth_call(&token_id, [FUNCTION_ERC20_NAME, FUNCTION_ERC20_SYMBOL, FUNCTION_ERC20_DECIMALS]) + .await?; + + map_token_data(self.get_chain(), token_id, name, symbol, decimals) + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + map_is_token_address(token_id) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TOKEN_USDC_ADDRESS, create_ethereum_test_client, create_smartchain_test_client}; + use primitives::asset_constants::{ETHEREUM_USDT_TOKEN_ID, SMARTCHAIN_USDT_TOKEN_ID}; + use primitives::{AssetType, Chain}; + + #[tokio::test] + async fn test_ethereum_get_token_data_usdc() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let usdc_address = TOKEN_USDC_ADDRESS.to_string(); + + let asset = client.get_token_data(usdc_address.clone()).await?; + + println!("USDC Asset: asset={:?}", asset); + + assert_eq!(asset.name, "USD Coin"); + assert_eq!(asset.symbol, "USDC"); + assert_eq!(asset.decimals, 6); + assert_eq!(asset.id.chain, Chain::Ethereum); + assert_eq!(asset.id.token_id, Some(usdc_address)); + assert_eq!(asset.asset_type, AssetType::ERC20); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_token_data_usdt() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let usdt_address = ETHEREUM_USDT_TOKEN_ID.to_string(); + + let asset = client.get_token_data(usdt_address.clone()).await?; + + println!("USDT Asset: asset={:?}", asset); + + assert_eq!(asset.name, "Tether USD"); + assert_eq!(asset.symbol, "USDT"); + assert_eq!(asset.decimals, 6); + assert_eq!(asset.id.chain, Chain::Ethereum); + assert_eq!(asset.id.token_id, Some(usdt_address)); + assert_eq!(asset.asset_type, AssetType::ERC20); + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_token_data_usdt() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let usdt_address = SMARTCHAIN_USDT_TOKEN_ID.to_string(); + + let asset = client.get_token_data(usdt_address.clone()).await?; + + println!("BSC USDT Asset: asset={:?}", asset); + + assert_eq!(asset.name, "Tether USD"); + assert_eq!(asset.symbol, "USDT"); + assert_eq!(asset.decimals, 18); + assert_eq!(asset.id.chain, Chain::SmartChain); + assert_eq!(asset.id.token_id, Some(usdt_address)); + assert_eq!(asset.asset_type, AssetType::BEP20); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/token_mapper.rs b/core/crates/gem_evm/src/provider/token_mapper.rs new file mode 100644 index 0000000000..59a9f704d9 --- /dev/null +++ b/core/crates/gem_evm/src/provider/token_mapper.rs @@ -0,0 +1,78 @@ +use crate::{ + contracts::erc20::{decode_abi_string, decode_abi_uint8}, + ethereum_address_checksum, +}; +use primitives::{Asset, AssetId, Chain}; + +pub fn map_token_data(chain: Chain, token_id: String, name_hex: String, symbol_hex: String, decimals_hex: String) -> Result> { + let name = decode_abi_string(name_hex.trim_start_matches("0x"))?; + let symbol = decode_abi_string(symbol_hex.trim_start_matches("0x"))?; + let decimals = decode_abi_uint8(decimals_hex.trim_start_matches("0x"))?; + let token_id = ethereum_address_checksum(&token_id)?; + + if name.is_empty() { + return Err("Invalid token metadata: name is empty".into()); + } + if symbol.is_empty() { + return Err("Invalid token metadata: symbol is empty".into()); + } + + let asset_id = AssetId { + chain, + token_id: Some(token_id.clone()), + }; + + Ok(Asset::new(asset_id.clone(), name, symbol, decimals.into(), asset_id.chain.default_asset_type().unwrap())) +} + +pub fn map_is_token_address(token_id: &str) -> bool { + token_id.starts_with("0x") && token_id.len() == 42 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::testkit::TOKEN_USDC_ADDRESS; + use primitives::AssetType; + + #[test] + fn test_map_is_token_address() { + assert!(map_is_token_address(TOKEN_USDC_ADDRESS)); + assert!(!map_is_token_address("0x1234")); + assert!(!map_is_token_address(&format!("{TOKEN_USDC_ADDRESS}123"))); + assert!(!map_is_token_address(TOKEN_USDC_ADDRESS.trim_start_matches("0x"))); + assert!(!map_is_token_address("")); + assert!(!map_is_token_address("0x")); + } + + #[test] + fn test_map_token_data() { + let token_id = TOKEN_USDC_ADDRESS.to_ascii_lowercase(); + let chain = Chain::Ethereum; + let name_hex = "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000".to_string(); + let symbol_hex = "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000045553444300000000000000000000000000000000000000000000000000000000".to_string(); + let decimals_hex = "0x0000000000000000000000000000000000000000000000000000000000000006".to_string(); + + let result = map_token_data(chain, token_id.clone(), name_hex, symbol_hex, decimals_hex).unwrap(); + + assert_eq!(result.name, "USD Coin"); + assert_eq!(result.symbol, "USDC"); + assert_eq!(result.decimals, 6); + assert_eq!(result.id.chain, Chain::Ethereum); + assert_eq!(result.chain, Chain::Ethereum); + assert_eq!(result.token_id, Some(TOKEN_USDC_ADDRESS.to_string())); + assert_eq!(result.asset_type, AssetType::ERC20); + } + + #[test] + fn test_map_token_data_invalid_metadata() { + let token_id = TOKEN_USDC_ADDRESS.to_ascii_lowercase(); + let name_hex = "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000".to_string(); + let symbol_hex = "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000045553444300000000000000000000000000000000000000000000000000000000".to_string(); + let decimals_hex = "0x0000000000000000000000000000000000000000000000000000000000000006".to_string(); + + assert!(map_token_data(Chain::Ethereum, token_id.clone(), "".to_string(), symbol_hex, decimals_hex.clone()).is_err()); + assert!(map_token_data(Chain::Ethereum, token_id.clone(), name_hex, "".to_string(), decimals_hex.clone()).is_err()); + assert!(map_token_data(Chain::Ethereum, token_id, "".to_string(), "".to_string(), decimals_hex).is_err()); + } +} diff --git a/core/crates/gem_evm/src/provider/transaction_broadcast.rs b/core/crates/gem_evm/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..3a39bbc9b6 --- /dev/null +++ b/core/crates/gem_evm/src/provider/transaction_broadcast.rs @@ -0,0 +1,33 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainTransactionBroadcast; +use chain_traits::ChainTransactionDecode; +use primitives::BroadcastOptions; + +use crate::{ + provider::{ + BroadcastProvider, + transaction_broadcast_mapper::{map_transaction_broadcast_request, map_transaction_broadcast_response_from_str}, + }, + rpc::client::EthereumClient, +}; +use gem_client::Client; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainTransactionBroadcast for EthereumClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let data = map_transaction_broadcast_request(&data); + let response = self.send_raw_transaction(&data).await?; + Ok(response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_evm/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_evm/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..f537731d13 --- /dev/null +++ b/core/crates/gem_evm/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,22 @@ +use std::error::Error; + +use gem_jsonrpc::types::JsonRpcResult; + +pub fn map_transaction_broadcast_request(data: &str) -> String { + if data.starts_with("0x") { data.to_string() } else { format!("0x{}", data) } +} + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + Ok(serde_json::from_str::>(response)?.take()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_transaction_broadcast_request_encode() { + assert_eq!(map_transaction_broadcast_request("123"), "0x123"); + assert_eq!(map_transaction_broadcast_request("0x123"), "0x123"); + } +} diff --git a/core/crates/gem_evm/src/provider/transaction_state.rs b/core/crates/gem_evm/src/provider/transaction_state.rs new file mode 100644 index 0000000000..0f53f38414 --- /dev/null +++ b/core/crates/gem_evm/src/provider/transaction_state.rs @@ -0,0 +1,65 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainTransactionState; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::EthereumClient}; +use gem_client::Client; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainTransactionState for EthereumClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let receipt = self.get_transaction_receipt(&request.id).await?; + Ok(map_transaction_status(&receipt)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{create_ethereum_test_client, create_smartchain_test_client}; + use chain_traits::ChainTransactionState; + use num_bigint::BigInt; + use primitives::{TransactionChange, TransactionState, TransactionStateRequest}; + + #[tokio::test] + async fn test_ethereum_get_transaction_status_confirmed() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let request = TransactionStateRequest::mock_with_id("0x98dd4d9a586620f84e8066f1b015d663f9c0c94c4e0e02377840c3e6d43e2ad3"); + + let result = client.get_transaction_status(request).await?; + + assert_eq!(result.state, TransactionState::Confirmed); + assert_eq!( + result.changes, + vec![ + TransactionChange::BlockNumber("22820942".to_string()), + TransactionChange::NetworkFee(BigInt::from(42850974395536u64)) + ] + ); + + Ok(()) + } + + #[tokio::test] + async fn test_smartchain_get_transaction_status_confirmed() -> Result<(), Box> { + let client = create_smartchain_test_client(); + let request = TransactionStateRequest::mock_with_id("0xd85c4496230adf8a7c0fc1e98713127fb31a0f8f72874acea443e2f615f3c1b6"); + + let result = client.get_transaction_status(request).await?; + + assert_eq!(result.state, TransactionState::Confirmed); + assert_eq!( + result.changes, + vec![ + TransactionChange::BlockNumber("55082355".to_string()), + TransactionChange::NetworkFee(BigInt::from(27753700000000u64)) + ] + ); + + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/provider/transaction_state_mapper.rs b/core/crates/gem_evm/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..3fac7d1e50 --- /dev/null +++ b/core/crates/gem_evm/src/provider/transaction_state_mapper.rs @@ -0,0 +1,85 @@ +use crate::rpc::model::TransactionReciept; +use num_bigint::BigInt; +use primitives::{TransactionChange, TransactionState, TransactionUpdate}; + +pub fn map_transaction_status(receipt: &TransactionReciept) -> TransactionUpdate { + let state = match receipt.get_state() { + TransactionState::Confirmed => TransactionState::Confirmed, + TransactionState::Reverted => TransactionState::Reverted, + TransactionState::Pending | TransactionState::InTransit | TransactionState::Failed => return TransactionUpdate::new_state(TransactionState::Pending), + }; + let network_fee: BigInt = receipt.get_fee().into(); + TransactionUpdate::new( + state, + vec![TransactionChange::BlockNumber(receipt.block_number.to_string()), TransactionChange::NetworkFee(network_fee)], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use num_bigint::BigUint; + + const BLOCK_HASH: &str = "0x1111111111111111111111111111111111111111111111111111111111111111"; + + fn receipt(status: &str, block_number: u32, block_hash: &str, l1_fee: Option) -> TransactionReciept { + TransactionReciept { + gas_used: BigUint::from(21000u32), + effective_gas_price: BigUint::from(20000000000u64), + l1_fee, + logs: vec![], + status: status.to_string(), + block_hash: block_hash.to_string(), + block_number: BigUint::from(block_number), + } + } + + #[test] + fn test_map_transaction_status() { + let result = map_transaction_status(&receipt("0x1", 0x123, BLOCK_HASH, None)); + + assert_eq!(result.state, TransactionState::Confirmed); + assert_eq!( + result.changes, + vec![ + TransactionChange::BlockNumber("291".to_string()), + TransactionChange::NetworkFee(BigInt::from(420000000000000u64)) + ] + ); + + let result = map_transaction_status(&receipt("0x0", 0x123, BLOCK_HASH, None)); + + assert_eq!(result.state, TransactionState::Reverted); + assert_eq!( + result.changes, + vec![ + TransactionChange::BlockNumber("291".to_string()), + TransactionChange::NetworkFee(BigInt::from(420000000000000u64)) + ] + ); + + let result = map_transaction_status(&receipt("0x2", 0x123, BLOCK_HASH, None)); + + assert_eq!(result.state, TransactionState::Pending); + assert_eq!(result.changes, vec![]); + + let result = map_transaction_status(&receipt("0x1", 0x123, primitives::contract_constants::EVM_ZERO_BLOCK_HASH, None)); + + assert_eq!(result.state, TransactionState::Pending); + assert_eq!(result.changes, vec![]); + + let result = map_transaction_status(&receipt("0x1", 0, BLOCK_HASH, None)); + + assert_eq!(result.state, TransactionState::Pending); + assert_eq!(result.changes, vec![]); + + let result = map_transaction_status(&receipt("0x1", 0x123, BLOCK_HASH, Some(BigUint::from(5000000000000000u64)))); + + assert_eq!(result.state, TransactionState::Confirmed); + let expected_total = BigInt::from(21000u32) * BigInt::from(20000000000u64) + BigInt::from(5000000000000000u64); + assert_eq!( + result.changes, + vec![TransactionChange::BlockNumber("291".to_string()), TransactionChange::NetworkFee(expected_total)] + ); + } +} diff --git a/core/crates/gem_evm/src/provider/transactions.rs b/core/crates/gem_evm/src/provider/transactions.rs new file mode 100644 index 0000000000..eb3c0d7310 --- /dev/null +++ b/core/crates/gem_evm/src/provider/transactions.rs @@ -0,0 +1,167 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::{ChainTransactions, TransactionsRequest}; +use primitives::{NodeType, Transaction}; + +use crate::rpc::{EthereumMapper, client::EthereumClient, mapper::CONTRACT_REGISTRY}; +use gem_client::Client; +use gem_jsonrpc::types::JsonRpcError; + +#[cfg(feature = "rpc")] +async fn load_transactions_by_hashes(client: &EthereumClient, node_type: NodeType, hashes: &[String]) -> Result, JsonRpcError> { + if hashes.is_empty() { + return Ok(vec![]); + } + + let transactions = client.get_transactions_by_hash(hashes).await?; + let receipts = client.get_transactions_receipts(hashes).await?; + let block_ids = receipts.iter().map(|x| format!("0x{}", x.block_number.to_str_radix(16))).collect::>(); + let blocks = client.get_blocks(&block_ids, false).await?; + + let traces = if node_type == NodeType::Archival { + Some(client.trace_replay_transactions(hashes).await?) + } else { + None + }; + + let chain = client.get_chain(); + Ok(transactions + .into_iter() + .zip(receipts) + .zip(blocks) + .enumerate() + .filter_map(|(index, ((transactions, receipt), block))| { + let trace = traces.as_ref().and_then(|entries| entries.get(index)); + EthereumMapper::map_transaction(chain, &transactions, &receipt, trace, &block.timestamp, Some(&CONTRACT_REGISTRY)) + }) + .collect()) +} + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainTransactions for EthereumClient { + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, limit, .. } = request; + let hashes = if let Some(ankr_client) = &self.ankr_client { + ankr_client + .get_ankr_transactions_by_address(address.as_str(), limit) + .await? + .transactions + .into_iter() + .map(|tx| tx.hash) + .collect::>() + } else { + vec![] + }; + Ok(load_transactions_by_hashes(self, self.node_type.clone(), &hashes).await?) + } + + async fn get_transactions_by_block(&self, block_number: u64) -> Result, Box> { + let block = self.get_block(block_number).await?; + let receipts = self.get_block_receipts(block_number).await?; + + if block.transactions.is_empty() { + return Ok(vec![]); + } + + let traces = if self.node_type == NodeType::Archival { + Some(self.trace_replay_block_transactions(block_number).await?) + } else { + None + }; + + let chain = self.get_chain(); + Ok(block + .transactions + .into_iter() + .zip(receipts) + .enumerate() + .filter_map(|(index, (tx, receipt))| { + let trace = traces.as_ref().and_then(|entries| entries.get(index)); + EthereumMapper::map_transaction(chain, &tx, &receipt, trace, &block.timestamp, Some(&CONTRACT_REGISTRY)) + }) + .collect()) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + Ok(load_transactions_by_hashes(self, self.node_type.clone(), &[hash]).await?.into_iter().next()) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{TEST_ADDRESS, TEST_TRANSACTION_ID, create_ethereum_test_client}; + use chain_traits::{ChainBalances, ChainTransactionBroadcast, ChainTransactions, TransactionsRequest}; + use num_bigint::BigUint; + use std::error::Error; + + #[tokio::test] + async fn test_ethereum_get_transactions_by_address() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let transactions = ChainTransactions::get_transactions_by_address(&client, TransactionsRequest::new(TEST_ADDRESS.to_string()).with_limit(5)).await?; + + assert!(!transactions.is_empty()); + + for tx in transactions.iter().take(3) { + assert_eq!(tx.asset_id.chain, client.get_chain()); + assert!(tx.created_at.timestamp() > 0); + } + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_assets_balances() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let balances = ChainBalances::get_balance_assets(&client, TEST_ADDRESS.to_string()).await?; + + println!("Balances: {:#?}", balances); + + assert!(!balances.is_empty()); + + let has_assets = balances + .iter() + .any(|balance| balance.asset_id.token_id.is_some() && balance.balance.available > BigUint::from(0u32)); + assert!(has_assets); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_transaction_broadcast() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let signed_tx = "0xf86c808502540be40082520894d4e56740f876aef8c010b86a40d5f56745a118d0765af9a146000000808081c0a05e1d3c1b2c3b0f8b7c8e9f0a1b2c3d4e5f6789abcdef0123456789abcdef012345a04f2c3a1b0d8e7f9a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1"; + let options = primitives::BroadcastOptions::default(); + + let result = client.transaction_broadcast(signed_tx.to_string(), options).await; + + assert!(result.is_ok() || result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_transaction_broadcast_invalid_data() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let invalid_tx = "0xinvalidtransactiondata"; + let options = primitives::BroadcastOptions::default(); + + let result = client.transaction_broadcast(invalid_tx.to_string(), options).await; + + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_ethereum_get_transaction_by_hash() -> Result<(), Box> { + let client = create_ethereum_test_client(); + let transaction = ChainTransactions::get_transaction_by_hash(&client, TEST_TRANSACTION_ID.to_string()).await?.unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + Ok(()) + } +} diff --git a/core/crates/gem_evm/src/registry.rs b/core/crates/gem_evm/src/registry.rs new file mode 100644 index 0000000000..566a883c3e --- /dev/null +++ b/core/crates/gem_evm/src/registry.rs @@ -0,0 +1,318 @@ +use alloy_primitives::{Address, address}; +use primitives::Chain; + +#[derive(Debug, Clone)] +pub struct ContractEntry { + pub address: Address, + pub provider: &'static str, + pub chain: Chain, +} + +#[derive(Debug, Clone)] +pub struct ContractRegistry { + pub entries: Vec, +} + +impl ContractRegistry { + pub fn new() -> Self { + let entries = vec![ + ContractEntry { + address: address!("0x5968feacba91d55010975e0cfe8acfc32664ad33"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x380aadf63d84d3a434073f1d5d95f02fb23d5228"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x111111125421ca6dc452d289314280a0f8842a65"), + provider: "1inch v6", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x099f84de4fb511e861ca8f635623eae409405873"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x882df4b0fb50a229c3b4124eb18c759911485bfb"), + provider: "QuickSwap v2", + chain: Chain::Polygon, + }, + ContractEntry { + address: address!("0x172fcd41e0913e95784454622d1c3724f546f849"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x7d94b911a51670f78a44a7af3c2bf773c42f2497"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x08a10ae012df633abbf710ef8bd3a9745a9e5816"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0xcf59b8c8baa2dea520e3d549f97d4e49ade17057"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x28e2ea090877bf75740558f6bfb36a5ffee9e9df"), + provider: "Uniswap v4", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0xd7af60112d7dfe0f914724e3407dd54424aaa19b"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x498581ff718922c3f8e6a244956af099b2652b2b"), + provider: "Uniswap v4", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0xc1a780989734a0e5df875cebe410748562e1c5e6"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x1f98400000000000000000000000000000000004"), + provider: "Uniswap v4", + chain: Chain::Unichain, + }, + ContractEntry { + address: address!("0x656840f632cab4757f25a56d42fac9f51e3f49a2"), + provider: "0x Protocol", + chain: Chain::World, + }, + ContractEntry { + address: address!("0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38"), + provider: "PancakeSwap v3", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0xd17a8609b5d95a5f49b290c4d787949bfec5279e"), + provider: "Uniswap v2", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0xcaf2da315f5a5499299a312b8a86faafe4bad959"), + provider: "0x Protocol", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0xf2688fb5b81049dfb7703ada5e770543770612c4"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x36696169c63e42cd08ce11f5deebbcebae652050"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0xea27b3e61144f0417f27aedaa1b9e46fa5a49ff1"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x47a90a2d92a8367a91efa1906bfc8c1e05bf10c4"), + provider: "Uniswap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0xbef8358ab02b1af3b9d8af97e8963e9ca4f92727"), + provider: "SyncSwap v2", + chain: Chain::Ethereum, + }, + ContractEntry { + address: address!("0x6f38e884725a116c9c7fbf208e79fe8828a2595f"), + provider: "Uniswap v3", + chain: Chain::Arbitrum, + }, + ContractEntry { + address: address!("0x16b9a82891338f9ba80e2d6970fdda79d1eb0dae"), + provider: "PancakeSwap v2", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x7fcdc35463e3770c2fb992716cd070b63540b947"), + provider: "PancakeSwap v3", + chain: Chain::Arbitrum, + }, + ContractEntry { + address: address!("0x69b86059c5fb3a44355937e7b505a659443b9a22"), + provider: "PancakeSwap v3", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0xb1026b8e7276e7ac75410f1fcbbe21796e8f7526"), + provider: "Camelot v3", + chain: Chain::Arbitrum, + }, + ContractEntry { + address: address!("0x6131b5fae19ea4f9d964eac0408e4408b66337b5"), + provider: "KyberSwap Meta v2", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0x0ea1f3adb8fa795d64d39beccb7c36f8aed455f3"), + provider: "0x Protocol", + chain: Chain::World, + }, + ContractEntry { + address: address!("0x111111125421ca6dc452d289314280a0f8842a65"), + provider: "1inch v6", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0x1111111254eeb25477b68fb85ed929f73a960582"), + provider: "1inch v5", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0xc82384da1318f167ff453760eb71dd6012896240"), + provider: "0x Protocol", + chain: Chain::Optimism, + }, + ContractEntry { + address: address!("0x19ceead7105607cd444f5ad10dd51356436095a1"), + provider: "Odos v2", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0xa3d370e8a4180828f6756cb8dce359cf21d9d6f7"), + provider: "0x Protocol", + chain: Chain::Polygon, + }, + ContractEntry { + address: address!("0x246475e1f63d8e26d6f4fb6029033da8831ed396"), + provider: "0x Protocol", + chain: Chain::Arbitrum, + }, + ContractEntry { + address: address!("0x1111111254eeb25477b68fb85ed929f73a960582"), + provider: "1inch v5", + chain: Chain::Ethereum, + }, + ContractEntry { + address: address!("0x779a74436eda060911b2c4f209d34ea155f3df09"), + provider: "0x Protocol", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x1111111254eeb25477b68fb85ed929f73a960582"), + provider: "1inch v5", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0x111111125421ca6dc452d289314280a0f8842a65"), + provider: "1inch v6", + chain: Chain::Arbitrum, + }, + ContractEntry { + address: address!("0x5c9bdc801a600c006c388fc032dcb27355154cc9"), + provider: "0x Protocol", + chain: Chain::Base, + }, + ContractEntry { + address: address!("0x5418226af9c8d5d287a78fbbbcd337b86ec07d61"), + provider: "0x Protocol", + chain: Chain::Ethereum, + }, + ContractEntry { + address: address!("0x111111125421ca6dc452d289314280a0f8842a65"), + provider: "1inch v6", + chain: Chain::Ethereum, + }, + ContractEntry { + address: address!("0x111111125421ca6dc452d289314280a0f8842a65"), + provider: "1inch v6", + chain: Chain::Polygon, + }, + ContractEntry { + address: address!("0x402867b638339ad8bec6e5373cfa95da0b462c85"), + provider: "0x Protocol", + chain: Chain::Optimism, + }, + ContractEntry { + address: address!("0x5435453c2e5d31908fa1667f583e37ae26c9f382"), + provider: "0x Protocol", + chain: Chain::Unichain, + }, + ContractEntry { + address: address!("0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae"), + provider: "LI.FI v2", + chain: Chain::Optimism, + }, + ContractEntry { + address: address!("0xd8014f15a920bf9edfdb87159ee10cadc07fcb53"), + provider: "0x Protocol", + chain: Chain::Optimism, + }, + ContractEntry { + address: address!("0x6131b5fae19ea4f9d964eac0408e4408b66337b5"), + provider: "KyberSwap Meta v2", + chain: Chain::SmartChain, + }, + ContractEntry { + address: address!("0x6a000f20005980200259b80c5102003040001068"), + provider: "ParaSwap v6", + chain: Chain::AvalancheC, + }, + ContractEntry { + address: address!("0x7a250d5630b4cf539739df2c5dacb4c659f2488d"), + provider: "Uniswap v2", + chain: Chain::Ethereum, + }, + ]; + + Self { entries } + } + + pub fn get_by_address(&self, address: &Address, chain: Chain) -> Option<&ContractEntry> { + self.entries.iter().find(|entry| entry.address == *address && entry.chain == chain) + } + + pub fn get_by_chain(&self, chain: Chain) -> Vec<&ContractEntry> { + self.entries.iter().filter(|entry| entry.chain == chain).collect() + } +} + +impl Default for ContractRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_by_address() { + let registry = ContractRegistry::new(); + let test_address = address!("0x5968feacba91d55010975e0cfe8acfc32664ad33"); + + let entry = registry.get_by_address(&test_address, Chain::SmartChain).unwrap(); + + assert_eq!(entry.provider, "PancakeSwap v3"); + assert_eq!(entry.chain, Chain::SmartChain); + } + + #[test] + fn test_get_by_chain() { + let registry = ContractRegistry::new(); + let bnb_contracts = registry.get_by_chain(Chain::SmartChain); + + for contract in bnb_contracts { + assert_eq!(contract.chain, Chain::SmartChain); + } + } +} diff --git a/core/crates/gem_evm/src/rpc/ankr/client.rs b/core/crates/gem_evm/src/rpc/ankr/client.rs new file mode 100644 index 0000000000..e47172d6a2 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/ankr/client.rs @@ -0,0 +1,54 @@ +use std::error::Error; + +use gem_client::Client; +use gem_jsonrpc::client::JsonRpcClient as GenericJsonRpcClient; +use primitives::EVMChain; +use serde_json::json; + +use crate::rpc::ankr::model::{TokenBalances, Transactions, ankr_chain}; + +#[derive(Debug, Clone)] +pub struct AnkrClient { + pub chain: EVMChain, + rpc_client: GenericJsonRpcClient, +} + +impl AnkrClient { + pub fn new(client: GenericJsonRpcClient, chain: EVMChain) -> Self { + Self { chain, rpc_client: client } + } +} + +impl AnkrClient { + /// Reference: https://www.ankr.com/docs/advanced-api/query-methods/#ankr_gettransactionsbyaddress + pub async fn get_ankr_transactions_by_address(&self, address: &str, limit: Option) -> Result> { + if let Some(chain) = ankr_chain(self.chain) { + let params = serde_json::json!({ + "address": address, + "blockchain": chain, + "pageSize": limit.unwrap_or(1), + "descOrder": true + }); + Ok(self.rpc_client.call("ankr_getTransactionsByAddress", params).await?) + } else { + Ok(Transactions { transactions: vec![] }) + } + } + + /// Reference: https://www.ankr.com/docs/advanced-api/token-methods/#ankr_getaccountbalance + pub async fn get_token_balances(&self, address: &str) -> Result> { + if let Some(chain) = ankr_chain(self.chain) { + let params = json!([ + { + "walletAddress": address, + "blockchain": chain, + "onlyWhitelisted": true, + } + ]); + + Ok(self.rpc_client.call("ankr_getAccountBalance", params).await?) + } else { + Ok(TokenBalances { assets: vec![] }) + } + } +} diff --git a/core/crates/gem_evm/src/rpc/ankr/mapper.rs b/core/crates/gem_evm/src/rpc/ankr/mapper.rs new file mode 100644 index 0000000000..9a5a1e5a25 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/ankr/mapper.rs @@ -0,0 +1,9 @@ +use crate::rpc::ankr::Transaction; + +pub struct AnkrMapper {} + +impl AnkrMapper { + pub fn map_transactions_ids(transactions: Vec) -> Vec { + transactions.into_iter().map(|x| x.hash).collect() + } +} diff --git a/core/crates/gem_evm/src/rpc/ankr/mod.rs b/core/crates/gem_evm/src/rpc/ankr/mod.rs new file mode 100644 index 0000000000..66403f6ede --- /dev/null +++ b/core/crates/gem_evm/src/rpc/ankr/mod.rs @@ -0,0 +1,7 @@ +pub mod client; +pub mod mapper; +pub mod model; + +pub use client::AnkrClient; +pub use mapper::AnkrMapper; +pub use model::{TokenBalance, Transaction, Transactions}; diff --git a/core/crates/gem_evm/src/rpc/ankr/model.rs b/core/crates/gem_evm/src/rpc/ankr/model.rs new file mode 100644 index 0000000000..dbf68ee4c0 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/ankr/model.rs @@ -0,0 +1,65 @@ +use num_bigint::BigUint; +use primitives::EVMChain; +use serde::Deserialize; +use serde_serializers::deserialize_biguint_from_hex_str; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + pub hash: String, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub timestamp: BigUint, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transactions { + pub transactions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenBalances { + pub assets: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenBalance { + pub contract_address: Option, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub balance_raw_integer: BigUint, +} + +pub fn ankr_chain(chain: EVMChain) -> Option { + match chain { + EVMChain::Ethereum => Some("eth".to_string()), + EVMChain::Polygon => Some("polygon".to_string()), + EVMChain::AvalancheC => Some("avalanche".to_string()), + EVMChain::SmartChain => Some("bsc".to_string()), + EVMChain::Arbitrum => Some("arbitrum".to_string()), + EVMChain::Optimism => Some("optimism".to_string()), + EVMChain::Base => Some("base".to_string()), + EVMChain::OpBNB => None, + EVMChain::Fantom => Some("fantom".to_string()), + EVMChain::Gnosis => Some("gnosis".to_string()), + EVMChain::Manta => None, + EVMChain::Blast => None, //Some("blast".to_string()), + EVMChain::ZkSync => None, //Some("zksync_era".to_string()), + EVMChain::Linea => Some("linea".to_string()), + EVMChain::Mantle => None, //Some("mantle".to_string()), + EVMChain::Celo => None, //Some("celo".to_string()), + EVMChain::World => None, + EVMChain::Sonic => None, //Some("sonic_mainnet".to_string()), + EVMChain::SeiEvm => None, + EVMChain::Abstract => None, + EVMChain::Berachain => None, + EVMChain::Ink => None, + EVMChain::Unichain => None, + EVMChain::Hyperliquid => None, + EVMChain::Plasma => None, + EVMChain::Monad => None, + EVMChain::XLayer => None, + EVMChain::Stable => None, + } +} diff --git a/core/crates/gem_evm/src/rpc/balance_differ.rs b/core/crates/gem_evm/src/rpc/balance_differ.rs new file mode 100644 index 0000000000..e0747f04ed --- /dev/null +++ b/core/crates/gem_evm/src/rpc/balance_differ.rs @@ -0,0 +1,206 @@ +use crate::{ + ethereum_address_checksum, + rpc::{ + mapper::TRANSFER_TOPIC, + model::{Diff, Log, TransactionReciept, TransactionReplayTrace}, + }, +}; +use alloy_primitives::{Address, hex}; +use chain_primitives::{BalanceDiff, BalanceDiffMap}; +use num_bigint::{BigInt, BigUint}; +use num_traits::Num; +use primitives::{AssetId, Chain}; +use std::collections::HashMap; + +struct TransferLog { + pub from: String, + pub to: String, + pub value: BigInt, +} + +#[derive(Debug)] +pub struct BalanceDiffer { + pub chain: Chain, +} + +impl BalanceDiffer { + pub fn new(chain: Chain) -> Self { + Self { chain } + } + + pub fn calculate(&self, trace: &TransactionReplayTrace, receipt: &TransactionReciept) -> BalanceDiffMap { + let mut map: BalanceDiffMap = HashMap::new(); + + // Native balance diff + for (address, state) in &trace.state_diff { + if let Diff::Change(change) = &state.balance { + let checksum_address = ethereum_address_checksum(address).unwrap_or_default(); + let from_value = BigInt::from_str_radix(&change.from_to.from[2..], 16).ok(); + let to_value = BigInt::from_str_radix(&change.from_to.to[2..], 16).ok(); + + if let (Some(from_bigint), Some(to_bigint)) = (from_value.clone(), to_value.clone()) { + let diff_value = to_bigint - from_bigint; + let diff = BalanceDiff { + asset_id: AssetId { + chain: self.chain, + token_id: None, + }, + from_value, + to_value, + diff: diff_value, + }; + map.entry(checksum_address).or_default().push(diff); + } + } + } + + // ERC20 token net transfers - collect all transfers per address/token and calculate net + let mut token_transfers: HashMap> = HashMap::new(); + + for log in &receipt.logs { + if let Some(transfer) = self.parse_log(log) { + let token_address = ethereum_address_checksum(&log.address).unwrap_or_default(); + + // Subtract from sender + *token_transfers.entry(transfer.from).or_default().entry(token_address.clone()).or_default() -= transfer.value.clone(); + + // Add to receiver + *token_transfers.entry(transfer.to).or_default().entry(token_address.clone()).or_default() += transfer.value; + } + } + + // Convert net transfers to BalanceDiff entries + for (address, tokens) in token_transfers { + for (token_address, net_diff) in tokens { + if net_diff != BigInt::from(0) { + let asset_id = AssetId { + chain: self.chain, + token_id: Some(token_address), + }; + + let diff = BalanceDiff { + asset_id, + from_value: None, + to_value: None, + diff: net_diff, + }; + + map.entry(address.clone()).or_default().push(diff); + } + } + } + + map + } + + pub fn get_native_balance_change(&self, trace: &TransactionReplayTrace, receipt: &TransactionReciept, address: &str) -> Option { + let balance_diffs = self.calculate(trace, receipt); + let checksum_address = ethereum_address_checksum(address).ok()?; + let diffs = balance_diffs.get(&checksum_address)?; + + for diff in diffs { + if diff.asset_id.token_id.is_none() && diff.diff > BigInt::from(0) { + let balance_change = diff.diff.to_biguint()?; + let gas_fee = receipt.get_fee(); + return Some(balance_change + gas_fee); + } + } + None + } + + fn parse_log(&self, log: &Log) -> Option { + // Transfer(address,address,uint256) + if log.topics.is_empty() || log.topics[0] != TRANSFER_TOPIC || log.topics.len() < 3 { + return None; + } + + // topics[1] is from, topics[2] is to. They are 32 bytes, address is last 20 bytes. + let from_bytes = hex::decode(&log.topics[1]).ok()?; + let to_bytes = hex::decode(&log.topics[2]).ok()?; + + if from_bytes.len() != 32 || to_bytes.len() != 32 { + return None; + } + + let from = Address::from_slice(&from_bytes[12..]).to_checksum(None); + let to = Address::from_slice(&to_bytes[12..]).to_checksum(None); + + let value = BigUint::from_str_radix(&log.data[2..], 16).ok()?; + + Some(TransferLog { from, to, value: value.into() }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_jsonrpc::types::JsonRpcResponse; + use primitives::Chain; + use std::str::FromStr; + + #[test] + fn test_calculate() { + // https://etherscan.io/tx/0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6 + let trace_replay_transaction = serde_json::from_str::>(include_str!("../../testdata/trace_replay_tx_trace.json")) + .unwrap() + .result; + let receipt = serde_json::from_str::>(include_str!("../../testdata/trace_replay_tx_receipt.json")) + .unwrap() + .result; + + let differ = BalanceDiffer::new(Chain::Ethereum); + let diff_map = differ.calculate(&trace_replay_transaction, &receipt); + + let sender_address = "0x52A07c930157d07D9EffD147ecF41C5cBbC6000c"; + let sender_diffs = diff_map.get(sender_address).unwrap(); + + // Check native balance change: from 0x28268111de83a9d (180821357773732509) to 0x4bd382b322e4810 (341490904926668816) + let native_diff = sender_diffs + .iter() + .find(|d| d.asset_id == AssetId::from_chain(Chain::Ethereum)) + .expect("Native diff not found in sender's diffs"); + + assert_eq!(native_diff.from_value, Some(BigInt::from_str("180821357773732509").unwrap())); + assert_eq!(native_diff.to_value, Some(BigInt::from_str("341490904926668816").unwrap())); + assert_eq!(native_diff.diff, BigInt::from_str("160669547152936307").unwrap()); + + // Check ERC20 token net change: -780 NEWT + let newt_asset_id = AssetId { + chain: Chain::Ethereum, + token_id: Some("0xD0eC028a3D21533Fdd200838F39c85B03679285D".to_string()), + }; + let token_diff = sender_diffs.iter().find(|d| d.asset_id == newt_asset_id).expect("Token diff not found in sender's diffs"); + + assert_eq!(token_diff.from_value, None); + assert_eq!(token_diff.to_value, None); + assert_eq!(token_diff.diff, BigInt::from_str("-780000000000000000000").unwrap()); + + let pool_address = "0x000000000004444c5dc75cB358380D2e3dE08A90"; + let pool_diffs = diff_map.get(pool_address).unwrap(); + + // Check native balance change: from 0x8d849264a8118b46324 to 0x8d846e21f2859c0bab1 + let contract_native_diff = pool_diffs + .iter() + .find(|d| d.asset_id == AssetId::from_chain(Chain::Ethereum)) + .expect("Native diff not found in contract's diffs"); + + assert_eq!(contract_native_diff.from_value, Some(BigInt::from_str("41768699565210634314532").unwrap())); + assert_eq!(contract_native_diff.to_value, Some(BigInt::from_str("41768536262063981378225").unwrap())); + assert_eq!(contract_native_diff.diff, BigInt::from_str("-163303146652936307").unwrap()); // negative diff + + // Check ERC20 token net change: +778.05 NEWT + let pool_token_diff = pool_diffs.iter().find(|d| d.asset_id == newt_asset_id).expect("Token diff not found in contract's diffs"); + + assert_eq!(pool_token_diff.from_value, None); + assert_eq!(pool_token_diff.to_value, None); + assert_eq!(pool_token_diff.diff, BigInt::from_str("778050000000000000000").unwrap()); + + let rabby_address = "0x39041F1B366fE33F9A5a79dE5120F2Aee2577ebc"; + let rabby_diffs = diff_map.get(rabby_address).unwrap(); + let rabby_token_diff = rabby_diffs.iter().find(|d| d.asset_id == newt_asset_id).expect("Token diff not found in Rabby's diffs"); + + assert_eq!(rabby_token_diff.from_value, None); + assert_eq!(rabby_token_diff.to_value, None); + assert_eq!(rabby_token_diff.diff, BigInt::from_str("1950000000000000000").unwrap()); + } +} diff --git a/core/crates/gem_evm/src/rpc/client.rs b/core/crates/gem_evm/src/rpc/client.rs new file mode 100644 index 0000000000..66924c92eb --- /dev/null +++ b/core/crates/gem_evm/src/rpc/client.rs @@ -0,0 +1,301 @@ +use alloy_primitives::{Address, Bytes, hex}; +use gem_client::Client; +use gem_jsonrpc::client::JsonRpcClient as GenericJsonRpcClient; +use gem_jsonrpc::types::{ERROR_INTERNAL_ERROR, JsonRpcError, JsonRpcResult}; + +use num_bigint::{BigInt, Sign}; +use serde::de::DeserializeOwned; +use serde_json::json; +use serde_serializers::biguint_from_hex_str; +use std::any::TypeId; +use std::str::FromStr; + +use super::{ + ankr::AnkrClient, + model::{Block, BlockTransactionsIds, EthSyncingStatus, Log, Transaction, TransactionReciept, TransactionReplayTrace}, +}; +use crate::jsonrpc::BlockParameter; +use crate::models::fee::EthereumFeeHistory; +#[cfg(feature = "rpc")] +use crate::multicall3::{ + IMulticall3, + IMulticall3::{Call3, Result as MulticallResult}, + deployment_by_chain, +}; +#[cfg(feature = "rpc")] +use alloy_sol_types::SolCall; +use primitives::{Chain, EVMChain, NodeType}; + +pub const FUNCTION_ERC20_NAME: &str = "0x06fdde03"; +pub const FUNCTION_ERC20_SYMBOL: &str = "0x95d89b41"; +pub const FUNCTION_ERC20_DECIMALS: &str = "0x313ce567"; + +#[derive(Debug, Clone)] +pub struct EthereumClient { + pub chain: EVMChain, + pub client: GenericJsonRpcClient, + pub(crate) node_type: NodeType, + pub(crate) ankr_client: Option>, +} + +impl EthereumClient { + fn latest_block_parameter() -> serde_json::Value { + BlockParameter::Latest.into() + } + + pub fn new(client: GenericJsonRpcClient, chain: EVMChain) -> Self { + Self { + chain, + client, + node_type: NodeType::Default, + ankr_client: None, + } + } + + pub fn with_node_type(mut self, node_type: NodeType) -> Self { + self.node_type = node_type; + self + } + + pub fn with_ankr_client(mut self, ankr_client: AnkrClient) -> Self { + self.ankr_client = Some(ankr_client); + self + } + + pub fn get_chain(&self) -> Chain { + self.chain.to_chain() + } + + pub async fn call(&self, method: String, params: serde_json::Value) -> Result { + self.client.call(&method, params).await + } + + pub async fn batch_call(&self, calls: Vec<(String, serde_json::Value)>) -> Result>, JsonRpcError> { + Ok(self.client.batch_call::(calls).await?.into_iter().collect()) + } + + pub async fn eth_call(&self, contract_address: &str, call_data: &str) -> Result> { + let to_address = Address::from_str(contract_address)?; + + let params = json!([ + { + "to": to_address.to_string(), + "data": call_data + }, + Self::latest_block_parameter() + ]); + + let result: String = self.client.call("eth_call", params).await?; + let result_bytes = Bytes::from(hex::decode(&result)?); + + // Deserialize T (hex string or struct) from the returned bytes. + if TypeId::of::() == TypeId::of::() { + Ok(serde_json::from_value(serde_json::Value::String(result_bytes.to_string()))?) + } else { + Ok(serde_json::from_slice(&result_bytes)?) + } + } + + pub async fn get_block(&self, block_number: u64) -> Result { + let params = json!([format!("0x{:x}", block_number), true]); + self.client.call("eth_getBlockByNumber", params).await + } + + pub async fn get_block_receipts(&self, block_number: u64) -> Result, JsonRpcError> { + let params = json!([format!("0x{:x}", block_number)]); + self.client.call("eth_getBlockReceipts", params).await + } + + pub async fn get_latest_block(&self) -> Result> { + let block_hex: String = self.client.call("eth_blockNumber", json!([])).await?; + let block_hex = block_hex.trim_start_matches("0x"); + Ok(u64::from_str_radix(block_hex, 16)?) + } + + pub async fn get_blocks(&self, blocks: &[String], include_transactions: bool) -> Result, JsonRpcError> { + let calls: Vec<(String, serde_json::Value)> = blocks + .iter() + .map(|block| ("eth_getBlockByNumber".to_string(), json!([block, include_transactions]))) + .collect(); + self.client.batch_call::(calls).await?.take_all() + } + + pub async fn get_transactions(&self, hashes: &[String]) -> Result, JsonRpcError> { + let transactions = self.get_transactions_by_hash(hashes).await?; + let reciepts = self.get_transactions_receipts(hashes).await?; + let traces = self.trace_replay_transactions(hashes).await?; + let block_ids = reciepts.iter().map(|x| x.block_number.to_string()).collect::>(); + let blocks = self.get_blocks(&block_ids, false).await?; + + Ok(blocks + .into_iter() + .zip(transactions) + .zip(reciepts) + .zip(traces) + .map(|(((block, tx), receipt), trace)| (block, tx, receipt, trace)) + .collect()) + } + + pub async fn get_transactions_by_hash(&self, hashes: &[String]) -> Result, JsonRpcError> { + let calls: Vec<(String, serde_json::Value)> = hashes.iter().map(|hash| ("eth_getTransactionByHash".to_string(), json!([hash]))).collect(); + self.client.batch_call::(calls).await?.take_all() + } + + pub async fn get_transactions_receipts(&self, hashes: &[String]) -> Result, JsonRpcError> { + let calls: Vec<(String, serde_json::Value)> = hashes.iter().map(|hash| ("eth_getTransactionReceipt".to_string(), json!([hash]))).collect(); + self.client.batch_call::(calls).await?.take_all() + } + + pub async fn get_transaction_receipt(&self, hash: &str) -> Result { + let params = json!([hash]); + self.client.call("eth_getTransactionReceipt", params).await + } + + pub async fn trace_replay_block_transactions(&self, block_number: u64) -> Result, JsonRpcError> { + let params = json!([format!("0x{:x}", block_number), json!(["stateDiff"])]); + self.client.call("trace_replayBlockTransactions", params).await + } + + pub async fn trace_replay_transactions(&self, tx_hash: &[String]) -> Result, JsonRpcError> { + let calls: Vec<(String, serde_json::Value)> = tx_hash + .iter() + .map(|hash| ("trace_replayTransaction".to_string(), json!([hash, json!(["stateDiff"])]))) + .collect(); + self.client.batch_call::(calls).await?.take_all() + } + + pub async fn get_eth_balance(&self, address: &str) -> Result { + let params = json!([address, Self::latest_block_parameter()]); + self.client.call("eth_getBalance", params).await + } + + pub async fn get_code(&self, address: &str) -> Result { + let params = json!([address, Self::latest_block_parameter()]); + self.client.call("eth_getCode", params).await + } + + pub async fn gas_price(&self) -> Result { + let value: String = self.client.call("eth_gasPrice", json!([])).await?; + let biguint = biguint_from_hex_str(&value).map_err(|_| JsonRpcError { + code: ERROR_INTERNAL_ERROR, + message: format!("Failed to parse gas price: {value}"), + })?; + Ok(BigInt::from_biguint(Sign::Plus, biguint)) + } + + pub async fn get_chain_id(&self) -> Result { + self.client.call("eth_chainId", json!([])).await + } + + pub async fn get_block_number(&self) -> Result { + self.client.call("eth_blockNumber", json!([])).await + } + + pub async fn get_sync_status(&self) -> Result { + self.client.call("eth_syncing", json!([])).await + } + + pub async fn get_transaction_count(&self, address: &str) -> Result { + let params = json!([address, Self::latest_block_parameter()]); + self.client.call("eth_getTransactionCount", params).await + } + + pub async fn send_raw_transaction(&self, data: &str) -> Result { + let params = json!([data]); + self.client.call("eth_sendRawTransaction", params).await + } + + pub async fn batch_eth_call(&self, contract_address: &str, function_selectors: [&str; N]) -> Result<[String; N], Box> { + let calls: Vec<(String, serde_json::Value)> = function_selectors + .iter() + .map(|selector| ("eth_call".to_string(), json!([{"to": contract_address, "data": selector}, Self::latest_block_parameter()]))) + .collect(); + let results = self.client.batch_call::(calls).await?.take_all()?; + results.try_into().map_err(|_| "Array conversion failed".into()) + } + + pub async fn get_fee_history(&self, blocks: u64, reward_percentiles: Vec) -> Result { + let params = json!([format!("0x{:x}", blocks), Self::latest_block_parameter(), reward_percentiles]); + self.client.call("eth_feeHistory", params).await + } + + pub async fn batch_token_balance_calls(&self, address: &str, contracts: &[String]) -> Result, Box> { + let data = format!("0x70a08231000000000000000000000000{:0>40}", address.strip_prefix("0x").unwrap_or(address)); + let calls: Vec<(String, serde_json::Value)> = contracts + .iter() + .map(|x| ("eth_call".to_string(), json!([{"to": x, "data": &data}, Self::latest_block_parameter()]))) + .collect(); + Ok(self.client.batch_call::(calls).await?.take_all()?) + } + + pub async fn get_logs(&self, address: &str, topics: &[Option], from_block: &str, to_block: &str) -> Result, JsonRpcError> { + let params = json!([{ + "address": address, + "topics": topics, + "fromBlock": from_block, + "toBlock": to_block + }]); + self.client.call("eth_getLogs", params).await + } + + pub async fn estimate_gas(&self, from: Option<&str>, to: &str, value: Option<&str>, data: Option<&str>) -> Result { + let mut params_obj = json!({ + "to": to + }); + + if let Some(from) = from { + params_obj["from"] = json!(from); + } + + if let Some(value) = value { + params_obj["value"] = json!(value); + } + if let Some(data) = data { + params_obj["data"] = json!(data); + } + + let params = json!([params_obj, Self::latest_block_parameter()]); + self.client.call("eth_estimateGas", params).await + } + + #[cfg(feature = "rpc")] + pub async fn multicall3(&self, calls: Vec) -> Result, Box> { + let multicall_address = deployment_by_chain(&self.chain); + let multicall_data = IMulticall3::aggregate3Call { calls }.abi_encode(); + + let call = ( + "eth_call".to_string(), + json!([{ + "to": multicall_address, + "data": hex::encode_prefixed(&multicall_data) + }, Self::latest_block_parameter()]), + ); + + let result: String = self.call(call.0, call.1).await?; + let result_data = hex::decode(&result)?; + let multicall_results = IMulticall3::aggregate3Call::abi_decode_returns(&result_data).map_err(|e| Box::new(e) as Box)?; + + Ok(multicall_results) + } + + #[cfg(feature = "rpc")] + pub async fn call_contract(&self, target: Address, sol_call: T) -> Result> { + let call_data = hex::encode_prefixed(sol_call.abi_encode()); + let params = json!([{ "to": target.to_string(), "data": call_data }, Self::latest_block_parameter()]); + let result: String = self.client.call("eth_call", params).await?; + let result_data = hex::decode(&result)?; + Ok(T::abi_decode_returns(&result_data)?) + } + + #[cfg(feature = "rpc")] + pub async fn multicall3_map( + &self, + items: &[T], + build: impl Fn(&T) -> [Call3; N], + decode: impl Fn(&[MulticallResult]) -> Result>, + ) -> Result, Box> { + let calls = items.iter().flat_map(&build).collect(); + let results = self.multicall3(calls).await?; + results.chunks(N).map(&decode).collect() + } +} diff --git a/core/crates/gem_evm/src/rpc/mapper.rs b/core/crates/gem_evm/src/rpc/mapper.rs new file mode 100644 index 0000000000..efb0f0e688 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/mapper.rs @@ -0,0 +1,527 @@ +use chrono::DateTime; +use num_bigint::BigUint; +use num_traits::Num; +use std::sync::LazyLock; + +use super::parsers::ProtocolParsers; +use crate::{ + address::{ethereum_address_checksum, ethereum_address_from_topic}, + registry::ContractRegistry, + rpc::model::{Block, Transaction, TransactionReciept, TransactionReplayTrace}, +}; +use primitives::{ + AssetId, NFTAssetId, Transaction as PrimitivesTransaction, TransactionType, chain::Chain, hex::decode_hex_utf8, transaction_metadata_types::TransactionNFTTransferMetadata, +}; + +pub const INPUT_0X: &str = "0x"; +pub const FUNCTION_ERC20_TRANSFER: &str = "0xa9059cbb"; +pub const FUNCTION_ERC20_APPROVE: &str = "0x095ea7b3"; +pub const FUNCTION_EIP721_TRANSFER: &str = "0x23b872dd"; // transferFrom(address from, address to, uint256 tokenId) +pub const FUNCTION_EIP1155_TRANSFER: &str = "0xf242432a"; // safeTransferFrom(address from, address to, uint256 tokenId, uint256 amount, bytes data) +pub const TRANSFER_TOPIC: &str = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; +pub const APPROVAL_TOPIC: &str = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"; +pub const TRANSFER_SINGLE: &str = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"; +pub const TRANSFER_GAS_LIMIT: u64 = 21000; + +pub static CONTRACT_REGISTRY: LazyLock = LazyLock::new(ContractRegistry::default); +pub struct EthereumMapper; + +impl EthereumMapper { + pub fn map_transactions(chain: Chain, block: Block, transactions_reciepts: Vec, traces: Option>) -> Vec { + match traces { + Some(traces) => block + .transactions + .into_iter() + .zip(transactions_reciepts.iter()) + .zip(traces.iter()) + .filter_map(|((transaction, receipt), trace)| { + EthereumMapper::map_transaction(chain, &transaction, receipt, Some(trace), &block.timestamp, Some(&CONTRACT_REGISTRY)) + }) + .collect(), + None => block + .transactions + .into_iter() + .zip(transactions_reciepts.iter()) + .filter_map(|(transaction, receipt)| EthereumMapper::map_transaction(chain, &transaction, receipt, None, &block.timestamp, Some(&CONTRACT_REGISTRY))) + .collect(), + } + } + + pub fn map_transaction( + chain: Chain, + transaction: &Transaction, + transaction_reciept: &TransactionReciept, + trace: Option<&TransactionReplayTrace>, + timestamp: &BigUint, + contract_registry: Option<&ContractRegistry>, + ) -> Option { + let state = transaction_reciept.get_state(); + let hash = transaction.hash.clone(); + let value = transaction.value.to_string(); + let fee = transaction_reciept.get_fee().to_string(); + let fee_asset_id = chain.as_asset_id(); + let from = ethereum_address_checksum(&transaction.from.clone()).ok()?; + let to = ethereum_address_checksum(&transaction.to.clone().unwrap_or_default()).ok()?; + let created_at = DateTime::from_timestamp(timestamp.clone().try_into().ok()?, 0)?; + + let is_smart_contract_call = transaction.to.is_some() && transaction.input.len() > 2; + let is_erc20_approve = transaction.input.starts_with(FUNCTION_ERC20_APPROVE); + let is_erc20_transfer = transaction.input.starts_with(FUNCTION_ERC20_TRANSFER); + let is_native_transfer = transaction.input == INPUT_0X && transaction_reciept.gas_used == BigUint::from(TRANSFER_GAS_LIMIT); + let is_native_transfer_with_data = transaction.input.len() > 2 + && transaction.gas > TRANSFER_GAS_LIMIT + && Self::get_data_cost(&transaction.input).is_some_and(|data_cost| transaction_reciept.gas_used <= BigUint::from(TRANSFER_GAS_LIMIT + data_cost)); + + if transaction.to.is_some() + && transaction.input.len() >= 8 + && let Some(tx) = ProtocolParsers::map_transaction(&chain, transaction, transaction_reciept, trace, contract_registry, created_at) + { + return Some(tx); + } + + // nft eip 721 + + if transaction.input.starts_with(FUNCTION_EIP721_TRANSFER) + && transaction_reciept + .logs + .last() + .is_some_and(|log| log.topics.len() == 4 && log.topics.first().is_some_and(|x| x == TRANSFER_TOPIC)) + && let Some(log) = transaction_reciept.logs.last() + { + let address = ethereum_address_from_topic(&log.topics[2])?; + let token_id = BigUint::from_str_radix(&log.topics[3].replace("0x", ""), 16).ok()?; + let contract_address = ethereum_address_checksum(&log.address).ok()?; + let metadata = TransactionNFTTransferMetadata::from_asset_id(NFTAssetId::new(chain, &contract_address, &token_id.to_string())); + + let transaction = PrimitivesTransaction::new( + hash, + AssetId::from_chain(chain), + from.clone(), + address, + None, + TransactionType::TransferNFT, + state, + fee.to_string(), + fee_asset_id, + "0".to_string(), + None, + serde_json::to_value(metadata).ok(), + created_at, + ); + return Some(transaction); + } + + // nft eip 1155 + + if transaction.input.starts_with(FUNCTION_EIP1155_TRANSFER) + && transaction_reciept + .logs + .last() + .is_some_and(|log| log.topics.len() == 4 && log.topics.first().is_some_and(|x| x == TRANSFER_SINGLE)) + && let Some(log) = transaction_reciept.logs.last() + { + let to_address = ethereum_address_from_topic(&log.topics[3])?; + let token_id = BigUint::from_str_radix(&log.data.replace("0x", "")[0..64], 16).ok()?; + let contract_address = ethereum_address_checksum(&log.address).ok()?; + let metadata = TransactionNFTTransferMetadata::from_asset_id(NFTAssetId::new(chain, &contract_address, &token_id.to_string())); + + let transaction = PrimitivesTransaction::new( + hash, + AssetId::from_chain(chain), + from.clone(), + to_address, + None, + TransactionType::TransferNFT, + state, + fee.to_string(), + fee_asset_id, + "0".to_string(), + None, + serde_json::to_value(metadata).ok(), + created_at, + ); + return Some(transaction); + } + + // erc20 approve + if is_erc20_approve + && let Some(log) = transaction_reciept + .logs + .iter() + .find(|log| log.topics.len() == 3 && log.topics.first().is_some_and(|x| x == APPROVAL_TOPIC)) + { + let to_address = ethereum_address_from_topic(&log.topics[2])?; + let value = BigUint::from_str_radix(&log.data.replace("0x", ""), 16).ok()?; + let token_id = ethereum_address_checksum(&log.address).ok()?; + + return Some(PrimitivesTransaction::new( + hash, + AssetId::from_token(chain, &token_id), + from.clone(), + to_address, + None, + TransactionType::TokenApproval, + state, + fee.to_string(), + fee_asset_id, + value.to_string(), + None, + None, + created_at, + )); + } + + // erc20 transfer - check both direct transfer calls and smart contract calls that emit transfer events + let transfer_log = transaction_reciept.logs.iter().find(|log| { + // ERC20 transfers have exactly 3 topics (event signature, from, to) + log.topics.len() == 3 && log.topics.first().is_some_and(|x| x == TRANSFER_TOPIC) + }); + + if let Some(log) = transfer_log { + let from_address_in_log = ethereum_address_from_topic(log.topics.get(1)?)?; + let to_address_in_log = ethereum_address_from_topic(log.topics.get(2)?)?; + let value = BigUint::from_str_radix(&log.data.replace("0x", ""), 16).ok()?; + let token_id = ethereum_address_checksum(&log.address).ok()?; + + // Check if this is a relevant ERC20 transfer + let is_contract_transfer = + !is_erc20_approve && is_smart_contract_call && (from_address_in_log == from || from_address_in_log == to) && transaction_reciept.logs.len() <= 2; + + if is_erc20_transfer || is_contract_transfer { + return Some(PrimitivesTransaction::new( + hash, + AssetId::from_token(chain, &token_id), + from_address_in_log, + to_address_in_log, + None, + TransactionType::Transfer, + state, + fee.to_string(), + fee_asset_id, + value.to_string(), + None, + None, + created_at, + )); + } + } + + let (transaction_type, memo, data) = if is_native_transfer { + (TransactionType::Transfer, None, None) + } else if is_native_transfer_with_data { + let memo = decode_hex_utf8(&transaction.input).filter(|m| !m.is_empty()); + (TransactionType::Transfer, memo, Some(transaction.input.clone())) + } else if is_smart_contract_call { + (TransactionType::SmartContractCall, None, None) + } else { + return None; + }; + + Some( + PrimitivesTransaction::new( + hash, + chain.as_asset_id(), + from, + to, + None, + transaction_type, + state, + fee, + fee_asset_id, + value, + memo, + None, + created_at, + ) + .with_data(data), + ) + } + + fn get_data_cost(input: &str) -> Option { + let bytes = hex::decode(input.trim_start_matches("0x")).ok()?; + let data_cost = bytes.iter().map(|byte| if *byte == 0 { 4 } else { 68 }).sum(); + + Some(data_cost) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::testkit::TEST_TRANSACTION_ID; + use crate::rpc::model::{Log, Transaction, TransactionReciept}; + use num_bigint::BigUint; + use primitives::{ + Chain, JsonRpcResult, + asset_constants::{ARBITRUM_USDC_ASSET_ID, ARBITRUM_USDT_ASSET_ID, ETHEREUM_DAI_ASSET_ID, ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDT_ASSET_ID}, + contract_constants::{ETHEREUM_YO_PROTOCOL_CONTRACT, UNISWAP_PERMIT2_CONTRACT}, + testkit::json_rpc::load_json_rpc_result, + }; + + #[test] + fn test_map_smart_contract_call() { + let contract_call_tx = load_json_rpc_result::(include_str!("../../testdata/contract_call_tx.json")); + let contract_call_receipt = load_json_rpc_result::(include_str!("../../testdata/contract_call_tx_receipt.json")); + + let transaction = EthereumMapper::map_transaction(Chain::Ethereum, &contract_call_tx, &contract_call_receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(transaction.transaction_type, TransactionType::SmartContractCall); + assert_eq!(transaction.hash, "0x876707912c2d625723aa14bf268d83ede36c2657c70da500628e40e6b51577c9"); + assert_eq!(transaction.from, "0x39ab5f6f1269590225EdAF9ad4c5967B09243747"); + assert_eq!(transaction.to, "0xB907Dcc926b5991A149d04Cb7C0a4a25dC2D8f9a"); + } + + #[test] + fn test_erc20_transfer() { + let erc20_transfer_tx = serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/transfer_erc20.json")).unwrap()) + .unwrap() + .result; + let erc20_transfer_receipt = + serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/transfer_erc20_receipt.json")).unwrap()) + .unwrap() + .result; + + let transaction = EthereumMapper::map_transaction(Chain::Arbitrum, &erc20_transfer_tx, &erc20_transfer_receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + assert_eq!(transaction.transaction_type, TransactionType::Transfer); + assert_eq!(transaction.asset_id, ARBITRUM_USDT_ASSET_ID.clone()); + assert_eq!(transaction.from, "0x8d7460E51bCf4eD26877cb77E56f3ce7E9f5EB8F"); + assert_eq!(transaction.to, "0x2Fc617E933a52713247CE25730f6695920B3befe"); + assert_eq!(transaction.value, "4801292"); + } + + #[test] + fn test_map_transaction_by_hash() { + let transaction = serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/transfer_nft_eip721.json")).unwrap()) + .unwrap() + .result; + let transaction_reciept = + serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/transfer_nft_eip721_receipt.json")).unwrap()) + .unwrap() + .result; + + let transaction = EthereumMapper::map_transaction(Chain::Ethereum, &transaction, &transaction_reciept, None, &BigUint::from(1735671600u64), None).unwrap(); + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + assert_eq!(transaction.transaction_type, TransactionType::TransferNFT); + + assert_eq!(transaction.asset_id, AssetId::from_chain(Chain::Ethereum)); + assert_eq!(transaction.from, "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"); + assert_eq!(transaction.to, "0xf1158986419F6058231b0Dbd7A78Ff0674ebBc50"); + assert_eq!(transaction.value, "0"); + assert_eq!( + transaction.metadata, + Some(serde_json::json!({ + "assetId": "ethereum_0x47A00fC8590C11bE4c419D9Ae50DEc267B6E24ee::9143" + })) + ); + } + + #[test] + fn test_nft_eip1155_transfer() { + let transaction = serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/transfer_nft_eip1155.json")).unwrap()) + .unwrap() + .result; + let transaction_reciept = + serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/transfer_nft_eip1155_receipt.json")).unwrap()) + .unwrap() + .result; + + let transaction = EthereumMapper::map_transaction(Chain::Ethereum, &transaction, &transaction_reciept, None, &BigUint::from(1735671600u64), None).unwrap(); + assert_eq!(transaction.transaction_type, TransactionType::TransferNFT); + + assert_eq!(transaction.asset_id, AssetId::from_chain(Chain::Ethereum)); + assert_eq!(transaction.from, "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"); + assert_eq!(transaction.to, "0xEE67a32a55318a211CE4BB5051Ed98c679851143"); + assert_eq!(transaction.value, "0"); + assert_eq!( + transaction.metadata, + Some(serde_json::json!({ + "assetId": "ethereum_0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401::78312089388574796712357673212383836573632856632295981350303734331484536429721" + })) + ); + } + + #[test] + fn test_smart_contract_erc20_transfer() { + let sc_erc20_tx = serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/contract_erc20_tx.json")).unwrap()) + .unwrap() + .result; + let sc_erc20_receipt = + serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/contract_erc20_receipt.json")).unwrap()) + .unwrap() + .result; + + let transaction = EthereumMapper::map_transaction(Chain::Arbitrum, &sc_erc20_tx, &sc_erc20_receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(transaction.transaction_type, TransactionType::Transfer); + assert_eq!(transaction.asset_id, ARBITRUM_USDC_ASSET_ID.clone()); + assert_eq!(transaction.from, "0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7"); + assert_eq!(transaction.to, "0x0D9DAB1A248f63B0a48965bA8435e4de7497a3dC"); + assert_eq!(transaction.value, "930678651"); + } + + #[test] + fn test_native_transfer_high_gas_limit() { + let transaction = serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/transfer_high_gas_limit.json")).unwrap()) + .unwrap() + .result; + let transaction_receipt = + serde_json::from_value::>(serde_json::from_str(include_str!("../../testdata/transfer_high_gas_limit_receipt.json")).unwrap()) + .unwrap() + .result; + + let result = EthereumMapper::map_transaction(Chain::Ethereum, &transaction, &transaction_receipt, None, &BigUint::from(1735671600u64), None); + + assert!(result.is_some()); + let tx = result.unwrap(); + assert_eq!(tx.transaction_type, TransactionType::Transfer); + assert_eq!(tx.asset_id, AssetId::from_chain(Chain::Ethereum)); + assert_eq!(tx.from, "0x8D25Fb438C6efCD08679ffA82766869B50E24608"); + assert_eq!(tx.to, "0x0700572b54ccA24Dad0eD4Cdad2c3d3ab6dB652a"); + assert_eq!(tx.value, "2739900000000000000"); + assert_eq!(tx.id.to_string(), "ethereum_0x0c0626172dbba6984a2e95b3abf1caba39cf11d3c9bc99d7de9ac814671c0cb1"); + } + + #[test] + fn test_erc20_approve() { + let transaction = load_json_rpc_result::(include_str!("../../testdata/approve.json")); + let mut receipt = load_json_rpc_result::(include_str!("../../testdata/approve_receipt.json")); + + let result = EthereumMapper::map_transaction(Chain::Ethereum, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + assert_eq!(result.transaction_type, TransactionType::TokenApproval); + assert_eq!(result.asset_id, ETHEREUM_DAI_ASSET_ID.clone()); + assert_eq!(result.from, "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"); + assert_eq!(result.to, UNISWAP_PERMIT2_CONTRACT); + assert_eq!(result.value, "115792089237316195423570985008687907853269984665640564039457584007913129639935"); + + receipt.logs.push(Log { + address: "0x0000000000000000000000000000000000001010".to_string(), + topics: vec!["0x4dfe1bbbcf077ddc3e01291eea2d5c70c2b422b415d95645b9adcfd678cb1d63".to_string()], + data: "0x".to_string(), + transaction_hash: None, + }); + + let result = EthereumMapper::map_transaction(Chain::Ethereum, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + assert_eq!(result.transaction_type, TransactionType::TokenApproval); + assert_eq!(result.asset_id, ETHEREUM_DAI_ASSET_ID.clone()); + assert_eq!(result.from, "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"); + assert_eq!(result.to, UNISWAP_PERMIT2_CONTRACT); + assert_eq!(result.value, "115792089237316195423570985008687907853269984665640564039457584007913129639935"); + } + + #[test] + fn test_map_smartchain_staking_transaction() { + let transaction = load_json_rpc_result::(include_str!("../../testdata/smartchain/transaction_staking_delegate.json")); + let receipt = load_json_rpc_result::(include_str!("../../testdata/smartchain/transaction_staking_delegate_receipt.json")); + let tx = EthereumMapper::map_transaction(Chain::SmartChain, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(tx.transaction_type, TransactionType::StakeDelegate); + assert_eq!(tx.from, "0x51eD60604637989d19D29e43c5D94B098A0d1Af7"); + assert_eq!(tx.to, "0xd34403249B2d82AAdDB14e778422c966265e5Fb5"); + assert_eq!(tx.contract.as_deref(), Some("0x0000000000000000000000000000000000002002")); + assert_eq!(tx.value, "1000000000000000000"); + assert_eq!(tx.metadata, None); + } + + #[test] + fn test_mayan_native_swap() { + let transaction = load_json_rpc_result::(include_str!("../../testdata/mayan_native_swap_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../testdata/mayan_native_swap_tx_receipt.json")); + + let tx = EthereumMapper::map_transaction(Chain::Polygon, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(tx.transaction_type, TransactionType::SmartContractCall); + assert_eq!(tx.asset_id, AssetId::from_chain(Chain::Polygon)); + assert_eq!(tx.from, "0x551Ac3629eC87F3957b1074FaF48d22A5a26ecec"); + assert_eq!(tx.to, "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2"); + assert_eq!(tx.value, "124798001816181500204"); + } + + #[test] + fn test_mayan_token_swap() { + let transaction = load_json_rpc_result::(include_str!("../../testdata/mayan_token_swap_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../testdata/mayan_token_swap_tx_receipt.json")); + + let tx = EthereumMapper::map_transaction(Chain::Polygon, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(tx.transaction_type, TransactionType::SmartContractCall); + assert_eq!(tx.asset_id, AssetId::from_chain(Chain::Polygon)); + assert_eq!(tx.from, "0x0DC153E9225a0d74460d806C08c961a3EC0ef17D"); + assert_eq!(tx.to, "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2"); + assert_eq!(tx.value, "0"); + } + + #[test] + fn test_native_transfer_with_memo() { + let memo = "=:LTC.LTC:ltc1qexample"; + let input = format!("0x{}", hex::encode(memo)); + + let transaction = Transaction { + hash: "0xabc123".to_string(), + from: "0xf1a3687303606a6fd48179ce503164cdcbabeab6".to_string(), + to: Some("0x0700572b54cca24dad0ed4cdad2c3d3ab6db652a".to_string()), + value: BigUint::from(1_000_000_000_000_000_000u64), + gas: 50000, + input: input.clone(), + block_number: BigUint::from(1000u32), + }; + + let receipt = TransactionReciept { + gas_used: BigUint::from(22496u32), + effective_gas_price: BigUint::from(5_000_000_000u64), + l1_fee: None, + logs: vec![], + status: "0x1".to_string(), + block_hash: "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(), + block_number: BigUint::from(1000u32), + }; + + let tx = EthereumMapper::map_transaction(Chain::SmartChain, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(tx.transaction_type, TransactionType::Transfer); + assert_eq!(tx.asset_id, AssetId::from_chain(Chain::SmartChain)); + assert_eq!(tx.memo, Some(memo.to_string())); + assert_eq!(tx.data, Some(input)); + } + + #[test] + fn test_claim_rewards_erc20_transfer() { + let transaction = load_json_rpc_result::(include_str!("../../testdata/claim_rewards_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../testdata/claim_rewards_receipt.json")); + + let tx = EthereumMapper::map_transaction(Chain::Ethereum, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(tx.transaction_type, TransactionType::Transfer); + assert_eq!(tx.asset_id, ETHEREUM_USDC_ASSET_ID.clone()); + assert_eq!(tx.from, "0x34DeFF97889f3A6A483E3b9255cAFCB9a6e03588"); + assert_eq!(tx.to, "0x0533d3A18D3f812eCFcC838B59B34fEc4d18E4AC"); + assert_eq!(tx.value, "3900075892"); + } + + #[test] + fn test_yo_deposit() { + let transaction = load_json_rpc_result::(include_str!("../../testdata/yo_deposit_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../testdata/yo_deposit_receipt.json")); + + let tx = EthereumMapper::map_transaction(Chain::Ethereum, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(tx.transaction_type, TransactionType::EarnDeposit); + assert_eq!(tx.asset_id, ETHEREUM_USDT_ASSET_ID.clone()); + assert_eq!(tx.from, "0x8d7460E51bCf4eD26877cb77E56f3ce7E9f5EB8F"); + assert_eq!(tx.to, ETHEREUM_YO_PROTOCOL_CONTRACT); + assert_eq!(tx.value, "1466009"); + } + + #[test] + fn test_yo_withdraw() { + let transaction = load_json_rpc_result::(include_str!("../../testdata/yo_withdraw_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../testdata/yo_withdraw_receipt.json")); + + let tx = EthereumMapper::map_transaction(Chain::Ethereum, &transaction, &receipt, None, &BigUint::from(1735671600u64), None).unwrap(); + + assert_eq!(tx.transaction_type, TransactionType::EarnWithdraw); + assert_eq!(tx.asset_id, ETHEREUM_USDT_ASSET_ID.clone()); + assert_eq!(tx.from, "0x8d7460E51bCf4eD26877cb77E56f3ce7E9f5EB8F"); + assert_eq!(tx.to, ETHEREUM_YO_PROTOCOL_CONTRACT); + assert_eq!(tx.value, "1466126"); + } +} diff --git a/core/crates/gem_evm/src/rpc/mod.rs b/core/crates/gem_evm/src/rpc/mod.rs new file mode 100644 index 0000000000..24e57459b8 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/mod.rs @@ -0,0 +1,9 @@ +pub mod ankr; +pub mod balance_differ; +pub mod client; +pub mod mapper; +pub mod model; +mod parsers; + +pub use client::EthereumClient; +pub use mapper::EthereumMapper; diff --git a/core/crates/gem_evm/src/rpc/model.rs b/core/crates/gem_evm/src/rpc/model.rs new file mode 100644 index 0000000000..674bdb2bb1 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/model.rs @@ -0,0 +1,171 @@ +use num_bigint::BigUint; +use num_traits::Zero; +use primitives::{TransactionState, contract_constants::EVM_ZERO_BLOCK_HASH}; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_biguint_from_hex_str, deserialize_biguint_from_option_hex_str, deserialize_u64_from_str_or_int}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EthSyncingStatus { + NotSyncing(bool), + Syncing(EthSyncingInfo), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EthSyncingInfo { + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub current_block: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub highest_block: BigUint, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Block { + pub transactions: Vec, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub timestamp: BigUint, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BlockTransactionsIds { + pub transactions: Vec, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub timestamp: BigUint, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + pub from: String, + #[serde(deserialize_with = "deserialize_u64_from_str_or_int")] + pub gas: u64, + // pub gas_price: String, + // pub max_priority_fee_per_gas: Option, + // pub max_fee_per_gas: Option, + pub hash: String, + pub input: String, + pub to: Option, + #[serde(rename = "blockNumber", default, deserialize_with = "deserialize_biguint_from_hex_str")] + pub block_number: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub value: BigUint, + // #[serde(rename = "type")] + // pub transaction_type: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TransactionReciept { + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub gas_used: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + pub effective_gas_price: BigUint, + #[serde(default, deserialize_with = "deserialize_biguint_from_option_hex_str")] + pub l1_fee: Option, + pub logs: Vec, + pub status: String, + pub block_hash: String, + #[serde(default, deserialize_with = "deserialize_biguint_from_hex_str")] + pub block_number: BigUint, +} + +impl TransactionReciept { + pub fn get_fee(&self) -> BigUint { + let fee = self.gas_used.clone() * self.effective_gas_price.clone(); + if let Some(l1_fee) = self.l1_fee.clone() { + return fee + l1_fee; + } + fee + } + + pub fn has_valid_block_reference(&self) -> bool { + !self.block_number.is_zero() && self.block_hash != EVM_ZERO_BLOCK_HASH + } + + pub fn get_state(&self) -> TransactionState { + if !self.has_valid_block_reference() { + return TransactionState::Pending; + } + + match self.status.as_str() { + "0x1" => TransactionState::Confirmed, + "0x0" => TransactionState::Reverted, + _ => TransactionState::Pending, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Log { + pub address: String, + pub topics: Vec, + pub data: String, + #[serde(default)] + pub transaction_hash: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionReplayTrace { + #[serde(default)] + pub state_diff: HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StateChange { + pub balance: Diff, + pub storage: HashMap>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Diff { + Change(Change), + Add(Add), + Delete(Delete), + Keep(String), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Change { + #[serde(rename = "*")] + pub from_to: FromTo, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Add { + #[serde(rename = "+")] + pub value: T, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Delete { + #[serde(rename = "-")] + pub value: T, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FromTo { + pub from: T, + pub to: T, +} + +#[cfg(test)] +mod tests { + use primitives::testkit::json_rpc::load_json_rpc_result; + + use super::*; + + #[test] + fn test_decode_trace_replay_transaction() { + let trace_replay_transaction = load_json_rpc_result::(include_str!("../../testdata/trace_replay_tx_trace.json")); + + assert!(trace_replay_transaction.state_diff.len() > 1); + } +} diff --git a/core/crates/gem_evm/src/rpc/parsers/dex.rs b/core/crates/gem_evm/src/rpc/parsers/dex.rs new file mode 100644 index 0000000000..c4469319cf --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/dex.rs @@ -0,0 +1,30 @@ +use std::str::FromStr; + +use alloy_primitives::Address; +use primitives::Transaction as PrimitivesTransaction; + +use super::{ParseContext, ProtocolParser, make_swap_transaction, try_map_balance_diff_swap}; + +pub struct DexSwapParser; + +impl ProtocolParser for DexSwapParser { + fn matches(&self, context: &ParseContext<'_>) -> bool { + let Some(registry) = context.contract_registry else { + return false; + }; + context + .transaction + .to + .as_ref() + .is_some_and(|to| Address::from_str(to).ok().and_then(|addr| registry.get_by_address(&addr, *context.chain)).is_some()) + } + + fn parse(&self, context: &ParseContext<'_>) -> Option { + let registry = context.contract_registry?; + let to = Address::from_str(context.transaction.to.as_ref()?).ok()?; + let entry = registry.get_by_address(&to, *context.chain)?; + + let metadata = try_map_balance_diff_swap(context.chain, &context.transaction.from, context.trace, context.receipt, Some(entry.provider.to_string()))?; + make_swap_transaction(context.chain, context.transaction, context.receipt, &metadata, context.created_at) + } +} diff --git a/core/crates/gem_evm/src/rpc/parsers/mod.rs b/core/crates/gem_evm/src/rpc/parsers/mod.rs new file mode 100644 index 0000000000..c2874f94dc --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/mod.rs @@ -0,0 +1,130 @@ +pub mod dex; +pub mod okx; +pub mod pancakeswap; +pub mod staking; +pub mod universal_router; +pub mod yo; + +use chrono::{DateTime, Utc}; +use num_bigint::BigUint; +use num_traits::Num; + +use super::{ + balance_differ::BalanceDiffer, + model::{Transaction, TransactionReciept, TransactionReplayTrace}, +}; +use crate::{ethereum_address_checksum, registry::ContractRegistry}; +use chain_primitives::SwapMapper as BalanceSwapMapper; +use primitives::{AssetId, Chain, Transaction as PrimitivesTransaction, TransactionSwapMetadata, TransactionType}; + +use self::{ + dex::DexSwapParser, + okx::OkxParser, + pancakeswap::PancakeSwapParser, + staking::{EverstakeParser, MonadStakingParser, SmartChainStakingParser}, + universal_router::UniversalRouterParser, + yo::YoParser, +}; + +pub struct ParseContext<'a> { + pub chain: &'a Chain, + pub transaction: &'a Transaction, + pub receipt: &'a TransactionReciept, + pub trace: Option<&'a TransactionReplayTrace>, + pub contract_registry: Option<&'a ContractRegistry>, + pub created_at: DateTime, +} + +pub trait ProtocolParser { + fn matches(&self, context: &ParseContext<'_>) -> bool; + fn parse(&self, context: &ParseContext<'_>) -> Option; +} + +fn ethereum_value_from_log_data(data: &str, start: usize, end: usize) -> Option { + data.trim_start_matches("0x").get(start..end).and_then(|s| BigUint::from_str_radix(s, 16).ok()) +} + +pub struct ProtocolParsers; + +impl ProtocolParsers { + fn parsers() -> [&'static dyn ProtocolParser; 8] { + [ + &EverstakeParser, + &MonadStakingParser, + &SmartChainStakingParser, + &OkxParser, + &YoParser, + &PancakeSwapParser, + &UniversalRouterParser, + &DexSwapParser, + ] + } + + pub fn map_transaction( + chain: &Chain, + transaction: &Transaction, + receipt: &TransactionReciept, + trace: Option<&TransactionReplayTrace>, + contract_registry: Option<&ContractRegistry>, + created_at: DateTime, + ) -> Option { + let context = ParseContext { + chain, + transaction, + receipt, + trace, + contract_registry, + created_at, + }; + + Self::parsers() + .into_iter() + .filter(|parser| parser.matches(&context)) + .find_map(|parser| parser.parse(&context)) + } +} + +pub fn make_swap_transaction( + chain: &Chain, + transaction: &Transaction, + receipt: &TransactionReciept, + metadata: &TransactionSwapMetadata, + created_at: DateTime, +) -> Option { + let from_checksum = ethereum_address_checksum(&transaction.from).ok()?; + let contract_checksum = transaction.to.as_ref().and_then(|to| ethereum_address_checksum(to).ok()); + + Some(PrimitivesTransaction::new( + transaction.hash.clone(), + metadata.from_asset.clone(), + from_checksum.clone(), + from_checksum, + contract_checksum, + TransactionType::Swap, + receipt.get_state(), + receipt.get_fee().to_string(), + AssetId::from_chain(*chain), + transaction.value.to_string(), + None, + serde_json::to_value(metadata).ok(), + created_at, + )) +} + +pub fn try_map_balance_diff_swap( + chain: &Chain, + from: &str, + trace: Option<&TransactionReplayTrace>, + receipt: &TransactionReciept, + provider: Option, +) -> Option { + let trace = trace?; + let from = ethereum_address_checksum(from).ok()?; + let differ = BalanceDiffer::new(*chain); + let diff_map = differ.calculate(trace, receipt); + let diff = diff_map.get(&from)?; + let native_asset_id = chain.as_asset_id(); + let fee = receipt.get_fee(); + + BalanceSwapMapper::map_swap(diff, &fee, &native_asset_id, provider) +} diff --git a/core/crates/gem_evm/src/rpc/parsers/okx.rs b/core/crates/gem_evm/src/rpc/parsers/okx.rs new file mode 100644 index 0000000000..63b3cb8932 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/okx.rs @@ -0,0 +1,384 @@ +use alloy_primitives::Address; +use num_bigint::BigUint; + +use crate::{ + address::ethereum_address_from_topic, + ethereum_address_checksum, + rpc::{mapper::TRANSFER_TOPIC, model::Log}, +}; +use primitives::{AssetId, SwapProvider, Transaction as PrimitivesTransaction, TransactionSwapMetadata}; + +use super::{ParseContext, ProtocolParser, ethereum_value_from_log_data, make_swap_transaction, try_map_balance_diff_swap}; + +pub(crate) const FUNCTION_OKX_DAG_SWAP_BY_ORDER_ID: &str = "0xf2c42696"; +pub(crate) const FUNCTION_OKX_UNISWAP_V3_SWAP_TO: &str = "0x0d5f0e3b"; +pub(crate) const FUNCTION_OKX_UNXSWAP_BY_ORDER_ID: &str = "0x9871efa4"; +const OKX_SWAP_EVENT_TOPIC: &str = "0x1bb43f2da90e35f7b0cf38521ca95a49e68eb42fac49924930a5bd73cdf7576c"; +const NATIVE_TOKEN_ADDRESS: &str = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +const EVENT_WORD_SIZE: usize = 64; + +pub struct OkxParser; + +struct ReceiptTransfer { + token: String, + from: String, + to: String, + value: String, +} + +struct OkxSwapEvent { + from_token: String, + to_token: String, + user: String, + from_amount: BigUint, + to_amount: BigUint, +} + +impl ProtocolParser for OkxParser { + fn matches(&self, context: &ParseContext<'_>) -> bool { + Self::matches_selector(&context.transaction.input) + } + + fn parse(&self, context: &ParseContext<'_>) -> Option { + let metadata = Self::try_map_receipt_swap(context) + .or_else(|| try_map_balance_diff_swap(context.chain, &context.transaction.from, context.trace, context.receipt, Some(Self::provider())))?; + + make_swap_transaction(context.chain, context.transaction, context.receipt, &metadata, context.created_at) + } +} + +impl OkxParser { + fn matches_selector(input: &str) -> bool { + input.starts_with(FUNCTION_OKX_DAG_SWAP_BY_ORDER_ID) || input.starts_with(FUNCTION_OKX_UNISWAP_V3_SWAP_TO) || input.starts_with(FUNCTION_OKX_UNXSWAP_BY_ORDER_ID) + } + + fn provider() -> String { + SwapProvider::Okx.id().to_string() + } + + fn try_map_receipt_swap(context: &ParseContext<'_>) -> Option { + Self::try_map_receipt_event(context).or_else(|| Self::try_map_transfer_swap(context)) + } + + fn try_map_receipt_event(context: &ParseContext<'_>) -> Option { + let event = context + .receipt + .logs + .iter() + .find(|log| log.topics.len() == 1 && log.topics.first().is_some_and(|topic| topic == OKX_SWAP_EVENT_TOPIC)) + .and_then(|log| OkxSwapEvent::decode(&log.data))?; + let from = ethereum_address_checksum(&context.transaction.from).ok()?; + if event.user != from { + return None; + } + + let from_asset = Self::asset_id_from_token(context, &event.from_token)?; + let to_asset = Self::asset_id_from_token(context, &event.to_token)?; + if from_asset == to_asset { + return None; + } + + Some(TransactionSwapMetadata { + from_asset, + from_value: event.from_amount.to_string(), + to_asset, + to_value: event.to_amount.to_string(), + provider: Some(Self::provider()), + }) + } + + fn try_map_transfer_swap(context: &ParseContext<'_>) -> Option { + let from = ethereum_address_checksum(&context.transaction.from).ok()?; + let transfers: Vec = context.receipt.logs.iter().filter_map(ReceiptTransfer::from_log).collect(); + let outgoing: Vec<&ReceiptTransfer> = transfers.iter().filter(|transfer| transfer.from == from && transfer.value != "0").collect(); + let incoming: Vec<&ReceiptTransfer> = transfers.iter().filter(|transfer| transfer.to == from && transfer.value != "0").collect(); + + match (context.transaction.value > BigUint::from(0u8), outgoing.as_slice(), incoming.as_slice()) { + (_, [sent], [received]) if sent.token != received.token => Some(TransactionSwapMetadata { + from_asset: AssetId::from_token(*context.chain, &sent.token), + from_value: sent.value.clone(), + to_asset: AssetId::from_token(*context.chain, &received.token), + to_value: received.value.clone(), + provider: Some(Self::provider()), + }), + (true, [], [received]) => Some(TransactionSwapMetadata { + from_asset: AssetId::from_chain(*context.chain), + from_value: context.transaction.value.to_string(), + to_asset: AssetId::from_token(*context.chain, &received.token), + to_value: received.value.clone(), + provider: Some(Self::provider()), + }), + _ => None, + } + } + + fn asset_id_from_token(context: &ParseContext<'_>, token: &str) -> Option { + let token = ethereum_address_checksum(token).ok()?; + if token == ethereum_address_checksum(NATIVE_TOKEN_ADDRESS).ok()? || token == Address::ZERO.to_checksum(None) { + return Some(AssetId::from_chain(*context.chain)); + } + + Some(AssetId::from_token(*context.chain, &token)) + } +} + +impl ReceiptTransfer { + fn from_log(log: &Log) -> Option { + if log.topics.len() != 3 || log.topics.first().is_none_or(|topic| topic != TRANSFER_TOPIC) { + return None; + } + + Some(Self { + token: ethereum_address_checksum(&log.address).ok()?, + from: ethereum_address_from_topic(log.topics.get(1)?)?, + to: ethereum_address_from_topic(log.topics.get(2)?)?, + value: ethereum_value_from_log_data(&log.data, 0, EVENT_WORD_SIZE)?.to_string(), + }) + } +} + +impl OkxSwapEvent { + fn decode(data: &str) -> Option { + let data = data.trim_start_matches("0x"); + let words = (0..data.len()) + .step_by(EVENT_WORD_SIZE) + .map(|start| data.get(start..start + EVENT_WORD_SIZE)) + .collect::>>()?; + let [from_token, to_token, user, from_amount, to_amount] = words.as_slice() else { + return None; + }; + + let from_token = ethereum_address_from_topic(&format!("0x{from_token}"))?; + let to_token = ethereum_address_from_topic(&format!("0x{to_token}"))?; + let user = ethereum_address_from_topic(&format!("0x{user}"))?; + let from_amount = ethereum_value_from_log_data(from_amount, 0, EVENT_WORD_SIZE)?; + let to_amount = ethereum_value_from_log_data(to_amount, 0, EVENT_WORD_SIZE)?; + + Some(Self { + from_token, + to_token, + user, + from_amount, + to_amount, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ethereum_address_checksum; + use crate::rpc::model::{Log, Transaction, TransactionReciept, TransactionReplayTrace}; + use crate::rpc::parsers::ProtocolParsers; + use chrono::DateTime; + use num_bigint::BigUint; + use primitives::{ + Chain, SwapProvider, TransactionState, + asset_constants::{BASE_USDC_TOKEN_ID, SMARTCHAIN_CAKE_ASSET_ID}, + testkit::json_rpc::load_json_rpc_result, + }; + + fn erc20_transfer_log(token: &str, from: &str, to: &str, value: &str) -> Log { + let value = BigUint::parse_bytes(value.as_bytes(), 10).unwrap(); + + Log { + address: token.to_string(), + topics: vec![ + TRANSFER_TOPIC.to_string(), + format!("0x{:0>64}", from.trim_start_matches("0x")), + format!("0x{:0>64}", to.trim_start_matches("0x")), + ], + data: format!("0x{:0>64}", value.to_str_radix(16)), + transaction_hash: None, + } + } + + fn map_transaction(chain: &Chain, transaction: &Transaction, receipt: &TransactionReciept, trace: Option<&TransactionReplayTrace>) -> PrimitivesTransaction { + ProtocolParsers::map_transaction(chain, transaction, receipt, trace, None, DateTime::from_timestamp(1743373403, 0).unwrap()).unwrap() + } + + #[test] + fn test_map_okx_transactions() { + let receipt_tx = load_json_rpc_result::(include_str!("../../../testdata/okx_base_swap_tx.json")); + let receipt_only = load_json_rpc_result::(include_str!("../../../testdata/okx_base_swap_tx_receipt.json")); + let swap_tx = map_transaction(&Chain::Base, &receipt_tx, &receipt_only, None); + let swap_metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.clone().unwrap()).unwrap(); + assert_eq!(swap_tx.transaction_type, primitives::TransactionType::Swap); + assert_eq!(swap_tx.state, TransactionState::Confirmed); + assert_eq!(swap_metadata.provider, Some(SwapProvider::Okx.id().to_string())); + assert_eq!( + swap_metadata.from_asset, + AssetId { + chain: Chain::Base, + token_id: Some(BASE_USDC_TOKEN_ID.to_string()), + } + ); + assert_eq!( + swap_metadata.to_asset, + AssetId { + chain: Chain::Base, + token_id: Some("0x0000000f2eB9f69274678c76222B35eEc7588a65".to_string()), + } + ); + assert_eq!(swap_metadata.from_value, "995000"); + assert_eq!(swap_metadata.to_value, "928345"); + + let balance_tx = load_json_rpc_result::(include_str!("../../../testdata/okx_bsc_swap_tx.json")); + let balance_receipt = load_json_rpc_result::(include_str!("../../../testdata/okx_bsc_swap_tx_receipt.json")); + let balance_trace = load_json_rpc_result::(include_str!("../../../testdata/okx_bsc_swap_tx_trace.json")); + let balance_swap_tx = map_transaction(&Chain::SmartChain, &balance_tx, &balance_receipt, Some(&balance_trace)); + let balance_metadata: TransactionSwapMetadata = serde_json::from_value(balance_swap_tx.metadata.clone().unwrap()).unwrap(); + assert_eq!(balance_swap_tx.transaction_type, primitives::TransactionType::Swap); + assert_eq!(balance_metadata.provider, Some(SwapProvider::Okx.id().to_string())); + assert_eq!(balance_metadata.from_asset, SMARTCHAIN_CAKE_ASSET_ID.clone()); + assert_eq!( + balance_metadata.to_asset, + AssetId { + chain: Chain::SmartChain, + token_id: None, + } + ); + assert_eq!(balance_metadata.from_value, "1000000000000000000"); + assert_eq!(balance_metadata.to_value, "2255593079375436"); + + let transfer_tx = Transaction { + from: "0x8d7460E51bCf4eD26877cb77E56f3ce7E9f5EB8F".to_string(), + gas: 750000, + hash: "0x77144af6766c014ad05b0ae90979dc5df9978ecb5829c89925659445b8630dd2".to_string(), + input: FUNCTION_OKX_DAG_SWAP_BY_ORDER_ID.to_string(), + to: Some("0x4409921ae43a39a11d90f7b7f96cfd0b8093d9fc".to_string()), + block_number: BigUint::from(1u32), + value: BigUint::from(0u8), + }; + let transfer_receipt = TransactionReciept { + gas_used: BigUint::from(318420u32), + effective_gas_price: BigUint::from(10_000_000u64), + l1_fee: None, + logs: vec![ + erc20_transfer_log( + BASE_USDC_TOKEN_ID, + "0x8d7460E51bCf4eD26877cb77E56f3ce7E9f5EB8F", + "0x4409921ae43a39a11d90f7b7f96cfd0b8093d9fc", + "995000", + ), + erc20_transfer_log( + "0x0000000f2eB9f69274678c76222B35eEc7588a65", + "0x4409921ae43a39a11d90f7b7f96cfd0b8093d9fc", + "0x8d7460E51bCf4eD26877cb77E56f3ce7E9f5EB8F", + "928345", + ), + ], + status: "0x1".to_string(), + block_hash: "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(), + block_number: BigUint::from(1u32), + }; + let transfer_swap_tx = map_transaction(&Chain::Base, &transfer_tx, &transfer_receipt, None); + let transfer_metadata: TransactionSwapMetadata = serde_json::from_value(transfer_swap_tx.metadata.clone().unwrap()).unwrap(); + assert_eq!(transfer_swap_tx.transaction_type, primitives::TransactionType::Swap); + assert_eq!(transfer_metadata.provider, Some(SwapProvider::Okx.id().to_string())); + assert_eq!( + transfer_metadata.from_asset, + AssetId { + chain: Chain::Base, + token_id: Some(BASE_USDC_TOKEN_ID.to_string()), + } + ); + assert_eq!( + transfer_metadata.to_asset, + AssetId { + chain: Chain::Base, + token_id: Some("0x0000000f2eB9f69274678c76222B35eEc7588a65".to_string()), + } + ); + assert_eq!(transfer_metadata.from_value, "995000"); + assert_eq!(transfer_metadata.to_value, "928345"); + + let uniswap_v3_swap_to_tx = Transaction { + from: "0xAdaf6f9B702718E3CEC12F944be7dF8b34E59E2f".to_string(), + gas: 920000, + hash: "0x336524846fec8f7a4a37ebac417f3ddd2d25b6fdf9b9cf0b88e1f69bb5601393".to_string(), + input: "0x0d5f0e3b00000000000000003bbc864aadaf6f9b702718e3cec12f944be7df8b34e59e2f00000000000000000000000000000000000000000000043c33c19375648000000000000000000000000000000000000000000000000000000036e5945adfeb74000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000012000000000000000000000009bdc7dfd19b75b023e28bbb8e197295c51ce55e4777777771111800000000000000000000000000000000000003798ea0b0a14fd777777771111000000000064fa00a9ed787f3793db668bff3e6e6e7db0f92a1b800000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee3ca20afc2bbb0000004c4b400d9dab1a248f63b0a48965ba8435e4de7497a3dc".to_string(), + to: Some("0x5e1f62dac767b0491e3ce72469c217365d5b48cc".to_string()), + block_number: BigUint::from(24717134u32), + value: BigUint::from(0u8), + }; + let uniswap_v3_swap_to_receipt = TransactionReciept { + gas_used: BigUint::from(203405u32), + effective_gas_price: BigUint::from(230068341u32), + l1_fee: None, + logs: vec![Log { + address: "0x5e1f62dac767b0491e3ce72469c217365d5b48cc".to_string(), + topics: vec![OKX_SWAP_EVENT_TOPIC.to_string()], + data: "0x00000000000000000000000052498f8d9791736f1d6398fe95ba3bd868114d10000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000adaf6f9b702718e3cec12f944be7df8b34e59e2f00000000000000000000000000000000000000000000043c33c1937564800000000000000000000000000000000000000000000000000000003798ea0b0a14fd".to_string(), + transaction_hash: None, + }], + status: "0x1".to_string(), + block_hash: "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(), + block_number: BigUint::from(24717134u32), + }; + let uniswap_v3_swap_to = map_transaction(&Chain::Ethereum, &uniswap_v3_swap_to_tx, &uniswap_v3_swap_to_receipt, None); + let uniswap_v3_swap_to_metadata: TransactionSwapMetadata = serde_json::from_value(uniswap_v3_swap_to.metadata.clone().unwrap()).unwrap(); + assert_eq!(uniswap_v3_swap_to.transaction_type, primitives::TransactionType::Swap); + assert_eq!(uniswap_v3_swap_to_metadata.provider, Some(SwapProvider::Okx.id().to_string())); + assert_eq!( + uniswap_v3_swap_to_metadata.from_asset, + AssetId::from_token(Chain::Ethereum, ðereum_address_checksum("0x52498f8d9791736f1d6398fe95ba3bd868114d10").unwrap()) + ); + assert_eq!( + uniswap_v3_swap_to_metadata.to_asset, + AssetId { + chain: Chain::Ethereum, + token_id: None, + } + ); + assert_eq!(uniswap_v3_swap_to_metadata.from_value, "20000000000000000000000"); + assert_eq!(uniswap_v3_swap_to_metadata.to_value, "15649254694065405"); + + let unxswap_by_order_id_tx = Transaction { + from: "0xAdaf6f9B702718E3CEC12F944be7dF8b34E59E2f".to_string(), + gas: 920000, + hash: "0xf3714f46b23016a349e549e6212c6e39fd3f2ef3926039775377ba70d962bfa5".to_string(), + input: "0x9871efa400000000000000003bbc864a249e38ea4102d0cf8264d3701f1a0e39c4f2dc3b000000000000000000000000000000000000000001c47e5d3263f59c9d062a020000000000000000000000000000000000000000000000000020068f78c840c70000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000170000000000000003b6d034097e1fcb93ae7267dbafad23f7b9afaa08264cfd87777777711118000000000000000000000000000000000000020595fca29f3dc777777771111000000000064fa00a9ed787f3793db668bff3e6e6e7db0f92a1b800000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee3ca20afc2bbb0000004c4b400d9dab1a248f63b0a48965ba8435e4de7497a3dc".to_string(), + to: Some("0x5e1f62dac767b0491e3ce72469c217365d5b48cc".to_string()), + block_number: BigUint::from(24717121u32), + value: BigUint::from(0u8), + }; + let unxswap_by_order_id_receipt = TransactionReciept { + gas_used: BigUint::from(176410u32), + effective_gas_price: BigUint::from(221977999u32), + l1_fee: None, + logs: vec![Log { + address: "0x5e1f62dac767b0491e3ce72469c217365d5b48cc".to_string(), + topics: vec![OKX_SWAP_EVENT_TOPIC.to_string()], + data: "0x000000000000000000000000249e38ea4102d0cf8264d3701f1a0e39c4f2dc3b000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000adaf6f9b702718e3cec12f944be7df8b34e59e2f000000000000000000000000000000000000000001c47e5d3263f59c9d062a020000000000000000000000000000000000000000000000000020595fca29f3dc".to_string(), + transaction_hash: None, + }], + status: "0x1".to_string(), + block_hash: "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(), + block_number: BigUint::from(24717121u32), + }; + let unxswap_by_order_id = map_transaction(&Chain::Ethereum, &unxswap_by_order_id_tx, &unxswap_by_order_id_receipt, None); + let unxswap_by_order_id_metadata: TransactionSwapMetadata = serde_json::from_value(unxswap_by_order_id.metadata.clone().unwrap()).unwrap(); + assert_eq!(unxswap_by_order_id.transaction_type, primitives::TransactionType::Swap); + assert_eq!(unxswap_by_order_id_metadata.provider, Some(SwapProvider::Okx.id().to_string())); + assert_eq!( + unxswap_by_order_id_metadata.from_asset, + AssetId::from_token(Chain::Ethereum, ðereum_address_checksum("0x249e38ea4102d0cf8264d3701f1a0e39c4f2dc3b").unwrap()) + ); + assert_eq!( + unxswap_by_order_id_metadata.to_asset, + AssetId { + chain: Chain::Ethereum, + token_id: None, + } + ); + assert_eq!(unxswap_by_order_id_metadata.from_value, "547031207820868594841299458"); + assert_eq!(unxswap_by_order_id_metadata.to_value, "9105467203253212"); + + let mut reverted_receipt = load_json_rpc_result::(include_str!("../../../testdata/okx_base_swap_tx_receipt.json")); + reverted_receipt.status = "0x0".to_string(); + let reverted_tx = map_transaction(&Chain::Base, &receipt_tx, &reverted_receipt, None); + assert_eq!(reverted_tx.transaction_type, primitives::TransactionType::Swap); + assert_eq!(reverted_tx.state, TransactionState::Reverted); + } +} diff --git a/core/crates/gem_evm/src/rpc/parsers/pancakeswap.rs b/core/crates/gem_evm/src/rpc/parsers/pancakeswap.rs new file mode 100644 index 0000000000..7df541511e --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/pancakeswap.rs @@ -0,0 +1,168 @@ +use std::collections::HashMap; + +use num_bigint::{BigInt, BigUint}; + +use crate::{address::ethereum_address_from_topic, ethereum_address_checksum, rpc::mapper::TRANSFER_TOPIC, uniswap::deployment::v3::get_pancakeswap_router_deployment_by_chain}; +use primitives::{AssetId, SwapProvider, Transaction as PrimitivesTransaction, TransactionSwapMetadata, decode_hex}; + +use super::{ParseContext, ProtocolParser, ethereum_value_from_log_data, make_swap_transaction, try_map_balance_diff_swap, universal_router::decode_execute_swap}; + +const EVENT_WORD_SIZE: usize = 64; + +pub struct PancakeSwapParser; + +impl ProtocolParser for PancakeSwapParser { + fn matches(&self, context: &ParseContext<'_>) -> bool { + context + .transaction + .to + .as_ref() + .is_some_and(|to| get_pancakeswap_router_deployment_by_chain(context.chain).is_some_and(|deployment| deployment.universal_router.eq_ignore_ascii_case(to))) + } + + fn parse(&self, context: &ParseContext<'_>) -> Option { + let metadata = Self::try_map_transfer_swap(context) + .or_else(|| try_map_balance_diff_swap(context.chain, &context.transaction.from, context.trace, context.receipt, Some(Self::provider()))) + .or_else(|| Self::try_map_command_swap(context))?; + make_swap_transaction(context.chain, context.transaction, context.receipt, &metadata, context.created_at) + } +} + +impl PancakeSwapParser { + fn provider() -> String { + SwapProvider::PancakeswapV3.id().to_string() + } + + fn try_map_command_swap(context: &ParseContext<'_>) -> Option { + let input_bytes = decode_hex(&context.transaction.input).ok()?; + decode_execute_swap(context.chain, &Self::provider(), &context.transaction.from, &input_bytes, context.receipt) + } + + fn try_map_transfer_swap(context: &ParseContext<'_>) -> Option { + let from = ethereum_address_checksum(&context.transaction.from).ok()?; + let net_by_token = Self::net_erc20_transfers(&from, context); + + let outgoing: Vec<_> = net_by_token.iter().filter(|(_, v)| **v < BigInt::from(0)).collect(); + let incoming: Vec<_> = net_by_token.iter().filter(|(_, v)| **v > BigInt::from(0)).collect(); + let has_native_value = context.transaction.value > BigUint::from(0u8); + + match (has_native_value, outgoing.as_slice(), incoming.as_slice()) { + (_, [(out_token, out_value)], [(in_token, in_value)]) if out_token != in_token => Some(TransactionSwapMetadata { + from_asset: AssetId::from_token(*context.chain, out_token), + from_value: (-(*out_value).clone()).to_string(), + to_asset: AssetId::from_token(*context.chain, in_token), + to_value: (*in_value).to_string(), + provider: Some(Self::provider()), + }), + (true, [], [(in_token, in_value)]) => Some(TransactionSwapMetadata { + from_asset: AssetId::from_chain(*context.chain), + from_value: context.transaction.value.to_string(), + to_asset: AssetId::from_token(*context.chain, in_token), + to_value: (*in_value).to_string(), + provider: Some(Self::provider()), + }), + _ => None, + } + } + + fn net_erc20_transfers(user: &str, context: &ParseContext<'_>) -> HashMap { + let mut net_by_token: HashMap = HashMap::new(); + + for log in &context.receipt.logs { + if log.topics.len() != 3 || log.topics.first().is_none_or(|t| t != TRANSFER_TOPIC) { + continue; + } + let Some(token) = ethereum_address_checksum(&log.address).ok() else { + continue; + }; + let (Some(log_from), Some(log_to)) = ( + log.topics.get(1).and_then(|t| ethereum_address_from_topic(t)), + log.topics.get(2).and_then(|t| ethereum_address_from_topic(t)), + ) else { + continue; + }; + let Some(value) = ethereum_value_from_log_data(&log.data, 0, EVENT_WORD_SIZE) else { + continue; + }; + let value = BigInt::from(value); + + if log_from == user { + *net_by_token.entry(token.clone()).or_default() -= value.clone(); + } + if log_to == user { + *net_by_token.entry(token).or_default() += value; + } + } + + net_by_token + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rpc::model::{Transaction, TransactionReciept, TransactionReplayTrace}; + use crate::rpc::parsers::ProtocolParsers; + use chrono::DateTime; + use primitives::{Chain, SwapProvider, TransactionState, TransactionType, testkit::json_rpc::load_json_rpc_result}; + + fn map_transaction(chain: &Chain, transaction: &Transaction, receipt: &TransactionReciept, trace: Option<&TransactionReplayTrace>) -> PrimitivesTransaction { + ProtocolParsers::map_transaction(chain, transaction, receipt, trace, None, DateTime::from_timestamp(1744602456, 0).unwrap()).unwrap() + } + + #[test] + fn test_map_pancakeswap_token_to_token_swap() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/pancakeswap_bsc_swap_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/pancakeswap_bsc_swap_tx_receipt.json")); + + let swap_tx = map_transaction(&Chain::SmartChain, &transaction, &receipt, None); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.state, TransactionState::Confirmed); + assert_eq!(swap_tx.from, "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"); + assert_eq!(swap_tx.to, "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"); + assert_eq!(swap_tx.contract.unwrap(), "0x1A0A18AC4BECDDbd6389559687d1A73d8927E416"); + assert_eq!(swap_tx.fee_asset_id, AssetId::from_chain(Chain::SmartChain)); + assert_eq!(metadata.provider, Some(SwapProvider::PancakeswapV3.id().to_string())); + assert_eq!(metadata.from_asset, AssetId::from_token(Chain::SmartChain, "0x55d398326f99059fF775485246999027B3197955")); + assert_eq!(metadata.from_value, "2000000000000000000"); + assert_eq!(metadata.to_asset, AssetId::from_token(Chain::SmartChain, "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82")); + assert_eq!(metadata.to_value, "1273682274195871312"); + } + + #[test] + fn test_map_pancakeswap_swap_with_trace_fallback() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/pancakeswap_bsc_native_swap_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/pancakeswap_bsc_native_swap_tx_receipt.json")); + let trace = load_json_rpc_result::(include_str!("../../../testdata/pancakeswap_bsc_native_swap_tx_trace.json")); + + let swap_tx = map_transaction(&Chain::SmartChain, &transaction, &receipt, Some(&trace)); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.state, TransactionState::Confirmed); + assert_eq!(metadata.provider, Some(SwapProvider::PancakeswapV3.id().to_string())); + assert_eq!(metadata.from_asset, AssetId::from_token(Chain::SmartChain, "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82")); + assert_eq!(metadata.from_value, "1000000000000000000"); + assert_eq!(metadata.to_asset, AssetId::from_chain(Chain::SmartChain)); + assert_eq!(metadata.to_value, "2255593079375436"); + } + + #[test] + fn test_map_pancakeswap_native_to_token_swap() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/pancakeswap_bsc_bnb_cake_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/pancakeswap_bsc_bnb_cake_tx_receipt.json")); + + let swap_tx = map_transaction(&Chain::SmartChain, &transaction, &receipt, None); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.state, TransactionState::Confirmed); + assert_eq!(metadata.provider, Some(SwapProvider::PancakeswapV3.id().to_string())); + assert_eq!(metadata.from_asset, AssetId::from_chain(Chain::SmartChain)); + assert_eq!(metadata.from_value, "500000000000000000"); + assert_eq!(metadata.to_asset, AssetId::from_token(Chain::SmartChain, "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82")); + assert_eq!(metadata.to_value, "318420568548967828"); + } +} diff --git a/core/crates/gem_evm/src/rpc/parsers/staking/everstake.rs b/core/crates/gem_evm/src/rpc/parsers/staking/everstake.rs new file mode 100644 index 0000000000..16a03d3d8e --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/staking/everstake.rs @@ -0,0 +1,108 @@ +use crate::{ + ethereum_address_checksum, + everstake::{EVERSTAKE_ACCOUNTING_ADDRESS, EVERSTAKE_POOL_ADDRESS}, + rpc::{balance_differ::BalanceDiffer, model::Log}, +}; +use primitives::{Chain, Transaction as PrimitivesTransaction, TransactionType}; + +use super::{EVENT_WORD_SIZE, ParseContext, ProtocolParser, ethereum_value_from_log_data, make_staking_transaction}; + +const EVENT_STAKED: &str = "0x7d194e8dc0f902cdc51bde00649039561dbd0b01574d671bad333436fdac7692"; +const EVENT_UNSTAKED: &str = "0x0750a71dce555de583ab0225a108df42b9402d22123d7cc9cd95793e43e7db0e"; +const EVENT_WITHDRAWN: &str = "0x262159451c4018521811107ecbe27e3de7d95a70a4a534f733aa59bc4346f03e"; + +pub struct EverstakeParser; + +impl ProtocolParser for EverstakeParser { + fn matches(&self, context: &ParseContext<'_>) -> bool { + if *context.chain != Chain::Ethereum { + return false; + } + + context + .transaction + .to + .as_ref() + .is_some_and(|to| to.eq_ignore_ascii_case(EVERSTAKE_POOL_ADDRESS) || to.eq_ignore_ascii_case(EVERSTAKE_ACCOUNTING_ADDRESS)) + } + + fn parse(&self, context: &ParseContext<'_>) -> Option { + context.receipt.logs.iter().find_map(|log| Self::parse_log(context, log)) + } +} + +impl EverstakeParser { + fn parse_log(context: &ParseContext<'_>, log: &Log) -> Option { + if log.topics.len() != 2 { + return None; + } + + let value = ethereum_value_from_log_data(&log.data, 0, EVENT_WORD_SIZE)?; + let pool_address = ethereum_address_checksum(EVERSTAKE_POOL_ADDRESS).ok()?; + match log.topics.first()?.as_str() { + EVENT_STAKED if log.address.eq_ignore_ascii_case(EVERSTAKE_POOL_ADDRESS) => make_staking_transaction(context, &pool_address, TransactionType::StakeDelegate, value), + EVENT_UNSTAKED if log.address.eq_ignore_ascii_case(EVERSTAKE_POOL_ADDRESS) => make_staking_transaction(context, &pool_address, TransactionType::StakeUndelegate, value), + EVENT_WITHDRAWN if log.address.eq_ignore_ascii_case(EVERSTAKE_ACCOUNTING_ADDRESS) => { + let value = context + .trace + .and_then(|trace| BalanceDiffer::new(*context.chain).get_native_balance_change(trace, context.receipt, &context.transaction.from)) + .unwrap_or(value); + + make_staking_transaction(context, &pool_address, TransactionType::StakeWithdraw, value) + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use primitives::{Chain, TransactionType, testkit::json_rpc::load_json_rpc_result}; + + use crate::rpc::model::{Transaction, TransactionReciept, TransactionReplayTrace}; + + use super::super::{assert_staking_transaction, map_transaction}; + + #[test] + fn test_map_everstake_transactions() { + let stake_transaction = load_json_rpc_result::(include_str!("../../../../testdata/everstake/transaction_stake.json")); + let stake_receipt = load_json_rpc_result::(include_str!("../../../../testdata/everstake/transaction_stake_receipt.json")); + let stake = map_transaction(&Chain::Ethereum, &stake_transaction, &stake_receipt, None); + assert_staking_transaction( + &stake, + Chain::Ethereum, + TransactionType::StakeDelegate, + "0x0D9DAB1A248f63B0a48965bA8435e4de7497a3dC", + "0xD523794C879D9eC028960a231F866758e405bE34", + "0xD523794C879D9eC028960a231F866758e405bE34", + "34800000000000000000", + ); + + let unstake_transaction = load_json_rpc_result::(include_str!("../../../../testdata/everstake/transaction_unstake.json")); + let unstake_receipt = load_json_rpc_result::(include_str!("../../../../testdata/everstake/transaction_unstake_receipt.json")); + let unstake = map_transaction(&Chain::Ethereum, &unstake_transaction, &unstake_receipt, None); + assert_staking_transaction( + &unstake, + Chain::Ethereum, + TransactionType::StakeUndelegate, + "0x1085c5f70F7F7591D97da281A64688385455c2bD", + "0xD523794C879D9eC028960a231F866758e405bE34", + "0xD523794C879D9eC028960a231F866758e405bE34", + "50000000000000000", + ); + + let withdraw_transaction = load_json_rpc_result::(include_str!("../../../../testdata/everstake/transaction_withdraw.json")); + let withdraw_receipt = load_json_rpc_result::(include_str!("../../../../testdata/everstake/transaction_withdraw_receipt.json")); + let withdraw_trace = load_json_rpc_result::(include_str!("../../../../testdata/everstake/transaction_withdraw_trace.json")); + let withdraw = map_transaction(&Chain::Ethereum, &withdraw_transaction, &withdraw_receipt, Some(&withdraw_trace)); + assert_staking_transaction( + &withdraw, + Chain::Ethereum, + TransactionType::StakeWithdraw, + "0x1085c5f70F7F7591D97da281A64688385455c2bD", + "0xD523794C879D9eC028960a231F866758e405bE34", + "0x7a7f0b3c23C23a31cFcb0c44709be70d4D545c6e", + "50000000000000000", + ); + } +} diff --git a/core/crates/gem_evm/src/rpc/parsers/staking/mod.rs b/core/crates/gem_evm/src/rpc/parsers/staking/mod.rs new file mode 100644 index 0000000000..a3c46769f8 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/staking/mod.rs @@ -0,0 +1,41 @@ +mod everstake; +mod monad; +mod smartchain; +mod transaction; + +#[cfg(test)] +use chrono::DateTime; +#[cfg(test)] +use primitives::{AssetId, Chain, Transaction as PrimitivesTransaction, TransactionState, TransactionType}; + +#[cfg(test)] +use crate::rpc::model::{Transaction, TransactionReciept, TransactionReplayTrace}; + +#[cfg(test)] +use super::ProtocolParsers; +use super::{ParseContext, ProtocolParser, ethereum_value_from_log_data}; +use transaction::make_staking_transaction; + +pub use everstake::EverstakeParser; +pub use monad::MonadStakingParser; +pub use smartchain::SmartChainStakingParser; + +const EVENT_WORD_SIZE: usize = 64; + +#[cfg(test)] +fn map_transaction(chain: &Chain, transaction: &Transaction, receipt: &TransactionReciept, trace: Option<&TransactionReplayTrace>) -> PrimitivesTransaction { + ProtocolParsers::map_transaction(chain, transaction, receipt, trace, None, DateTime::default()).unwrap() +} + +#[cfg(test)] +fn assert_staking_transaction(transaction: &PrimitivesTransaction, chain: Chain, transaction_type: TransactionType, from: &str, to: &str, contract: &str, value: &str) { + assert_eq!(transaction.transaction_type, transaction_type); + assert_eq!(transaction.state, TransactionState::Confirmed); + assert_eq!(transaction.asset_id, AssetId::from_chain(chain)); + assert_eq!(transaction.fee_asset_id, AssetId::from_chain(chain)); + assert_eq!(transaction.from, from); + assert_eq!(transaction.to, to); + assert_eq!(transaction.contract.as_deref(), Some(contract)); + assert_eq!(transaction.value, value); + assert_eq!(transaction.metadata, None); +} diff --git a/core/crates/gem_evm/src/rpc/parsers/staking/monad.rs b/core/crates/gem_evm/src/rpc/parsers/staking/monad.rs new file mode 100644 index 0000000000..3b3482c34c --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/staking/monad.rs @@ -0,0 +1,127 @@ +use num_traits::ToPrimitive; + +use crate::{ + monad::{EVENT_CLAIM_REWARDS, EVENT_DELEGATE, EVENT_UNDELEGATE, EVENT_WITHDRAW, STAKING_CONTRACT}, + rpc::model::Log, +}; +use primitives::{Chain, Transaction as PrimitivesTransaction, TransactionType}; + +use super::{EVENT_WORD_SIZE, ParseContext, ProtocolParser, ethereum_value_from_log_data, make_staking_transaction}; + +pub struct MonadStakingParser; + +impl ProtocolParser for MonadStakingParser { + fn matches(&self, context: &ParseContext<'_>) -> bool { + if *context.chain != Chain::Monad { + return false; + } + + context.transaction.to.as_ref().is_some_and(|to| to.eq_ignore_ascii_case(STAKING_CONTRACT)) + } + + fn parse(&self, context: &ParseContext<'_>) -> Option { + context.receipt.logs.iter().find_map(|log| Self::parse_log(context, log)) + } +} + +impl MonadStakingParser { + fn parse_log(context: &ParseContext<'_>, log: &Log) -> Option { + if !log.address.eq_ignore_ascii_case(STAKING_CONTRACT) || log.topics.len() != 3 { + return None; + } + + let validator_id = ethereum_value_from_log_data(log.topics.get(1)?, 0, EVENT_WORD_SIZE)?.to_u64()?.to_string(); + + match log.topics.first()?.as_str() { + EVENT_DELEGATE => make_staking_transaction( + context, + &validator_id, + TransactionType::StakeDelegate, + ethereum_value_from_log_data(&log.data, 0, EVENT_WORD_SIZE)?, + ), + EVENT_UNDELEGATE => make_staking_transaction( + context, + &validator_id, + TransactionType::StakeUndelegate, + ethereum_value_from_log_data(&log.data, EVENT_WORD_SIZE, EVENT_WORD_SIZE * 2)?, + ), + EVENT_WITHDRAW => make_staking_transaction( + context, + &validator_id, + TransactionType::StakeWithdraw, + ethereum_value_from_log_data(&log.data, EVENT_WORD_SIZE, EVENT_WORD_SIZE * 2)?, + ), + EVENT_CLAIM_REWARDS => make_staking_transaction( + context, + &validator_id, + TransactionType::StakeRewards, + ethereum_value_from_log_data(&log.data, 0, EVENT_WORD_SIZE)?, + ), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use primitives::{Chain, TransactionType, testkit::json_rpc::load_json_rpc_result}; + + use crate::rpc::model::{Transaction, TransactionReciept}; + + use super::super::{assert_staking_transaction, map_transaction}; + + #[test] + fn test_map_monad_staking_transactions() { + let delegate_transaction = load_json_rpc_result::(include_str!("../../../../testdata/monad/transaction_staking_delegate.json")); + let delegate_receipt = load_json_rpc_result::(include_str!("../../../../testdata/monad/transaction_staking_delegate_receipt.json")); + let delegate = map_transaction(&Chain::Monad, &delegate_transaction, &delegate_receipt, None); + assert_staking_transaction( + &delegate, + Chain::Monad, + TransactionType::StakeDelegate, + "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7", + "5", + "0x0000000000000000000000000000000000001000", + "2000000000000000000", + ); + + let undelegate_transaction = load_json_rpc_result::(include_str!("../../../../testdata/monad/transaction_staking_undelegate.json")); + let undelegate_receipt = load_json_rpc_result::(include_str!("../../../../testdata/monad/transaction_staking_undelegate_receipt.json")); + let undelegate = map_transaction(&Chain::Monad, &undelegate_transaction, &undelegate_receipt, None); + assert_staking_transaction( + &undelegate, + Chain::Monad, + TransactionType::StakeUndelegate, + "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7", + "10", + "0x0000000000000000000000000000000000001000", + "10000000000000000000", + ); + + let claim_transaction = load_json_rpc_result::(include_str!("../../../../testdata/monad/transaction_staking_claim_rewards.json")); + let claim_receipt = load_json_rpc_result::(include_str!("../../../../testdata/monad/transaction_staking_claim_rewards_receipt.json")); + let claim = map_transaction(&Chain::Monad, &claim_transaction, &claim_receipt, None); + assert_staking_transaction( + &claim, + Chain::Monad, + TransactionType::StakeRewards, + "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7", + "10", + "0x0000000000000000000000000000000000001000", + "315193747607045635", + ); + + let withdraw_transaction = load_json_rpc_result::(include_str!("../../../../testdata/monad/transaction_staking_withdraw.json")); + let withdraw_receipt = load_json_rpc_result::(include_str!("../../../../testdata/monad/transaction_staking_withdraw_receipt.json")); + let withdraw = map_transaction(&Chain::Monad, &withdraw_transaction, &withdraw_receipt, None); + assert_staking_transaction( + &withdraw, + Chain::Monad, + TransactionType::StakeWithdraw, + "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7", + "10", + "0x0000000000000000000000000000000000001000", + "10000521154972741508", + ); + } +} diff --git a/core/crates/gem_evm/src/rpc/parsers/staking/smartchain.rs b/core/crates/gem_evm/src/rpc/parsers/staking/smartchain.rs new file mode 100644 index 0000000000..7708f14b2c --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/staking/smartchain.rs @@ -0,0 +1,171 @@ +use crate::{address::ethereum_address_from_topic, rpc::model::Log}; +use gem_bsc::stake_hub; +use primitives::{Chain, Transaction as PrimitivesTransaction, TransactionType}; + +use super::{EVENT_WORD_SIZE, ParseContext, ProtocolParser, ethereum_value_from_log_data, make_staking_transaction}; + +const EVENT_DELEGATED: &str = "0x24d7bda8602b916d64417f0dbfe2e2e88ec9b1157bd9f596dfdb91ba26624e04"; +const EVENT_UNDELEGATED: &str = "0x3aace7340547de7b9156593a7652dc07ee900cea3fd8f82cb6c9d38b40829802"; +const EVENT_REDELEGATED: &str = "0xfdac6e81913996d95abcc289e90f2d8bd235487ce6fe6f821e7d21002a1915b4"; +const EVENT_CLAIMED: &str = "0xf7a40077ff7a04c7e61f6f26fb13774259ddf1b6bce9ecf26a8276cdd3992683"; + +pub struct SmartChainStakingParser; + +impl ProtocolParser for SmartChainStakingParser { + fn matches(&self, context: &ParseContext<'_>) -> bool { + if *context.chain != Chain::SmartChain { + return false; + } + + context.transaction.to.as_ref().is_some_and(|to| to.eq_ignore_ascii_case(stake_hub::STAKE_HUB_ADDRESS)) + } + + fn parse(&self, context: &ParseContext<'_>) -> Option { + context.receipt.logs.iter().find_map(|log| Self::parse_log(context, log)) + } +} + +impl SmartChainStakingParser { + fn parse_log(context: &ParseContext<'_>, log: &Log) -> Option { + if !log.address.eq_ignore_ascii_case(stake_hub::STAKE_HUB_ADDRESS) { + return None; + } + + match log.topics.first()?.as_str() { + EVENT_DELEGATED => Self::parse_delegated_event(context, log), + EVENT_UNDELEGATED => Self::parse_undelegated_event(context, log), + EVENT_REDELEGATED => Self::parse_redelegated_event(context, log), + EVENT_CLAIMED => Self::parse_claimed_event(context, log), + _ => None, + } + } + + fn parse_delegated_event(context: &ParseContext<'_>, log: &Log) -> Option { + if log.topics.len() != 3 { + return None; + } + + let operator_address = ethereum_address_from_topic(&log.topics[1])?; + make_staking_transaction( + context, + &operator_address, + TransactionType::StakeDelegate, + ethereum_value_from_log_data(&log.data, EVENT_WORD_SIZE, EVENT_WORD_SIZE * 2)?, + ) + } + + fn parse_undelegated_event(context: &ParseContext<'_>, log: &Log) -> Option { + if log.topics.len() != 3 { + return None; + } + + let operator_address = ethereum_address_from_topic(&log.topics[1])?; + make_staking_transaction( + context, + &operator_address, + TransactionType::StakeUndelegate, + ethereum_value_from_log_data(&log.data, EVENT_WORD_SIZE, EVENT_WORD_SIZE * 2)?, + ) + } + + fn parse_redelegated_event(context: &ParseContext<'_>, log: &Log) -> Option { + if log.topics.len() != 4 { + return None; + } + + let dst_validator = ethereum_address_from_topic(&log.topics[2])?; + make_staking_transaction( + context, + &dst_validator, + TransactionType::StakeRedelegate, + ethereum_value_from_log_data(&log.data, EVENT_WORD_SIZE * 2, EVENT_WORD_SIZE * 3)?, + ) + } + + fn parse_claimed_event(context: &ParseContext<'_>, log: &Log) -> Option { + if log.topics.len() != 3 { + return None; + } + + let operator_address = ethereum_address_from_topic(&log.topics[1])?; + make_staking_transaction( + context, + &operator_address, + TransactionType::StakeRewards, + ethereum_value_from_log_data(&log.data, 0, EVENT_WORD_SIZE)?, + ) + } +} + +#[cfg(test)] +mod tests { + use chrono::DateTime; + use primitives::{Chain, TransactionType, testkit::json_rpc::load_json_rpc_result}; + + use crate::rpc::{ + model::{Transaction, TransactionReciept}, + parsers::ProtocolParsers, + }; + + use super::super::{assert_staking_transaction, map_transaction}; + + #[test] + fn test_map_smartchain_staking_transactions() { + let cases = [ + ( + include_str!("../../../../testdata/smartchain/transaction_staking_delegate.json"), + include_str!("../../../../testdata/smartchain/transaction_staking_delegate_receipt.json"), + TransactionType::StakeDelegate, + "0x51eD60604637989d19D29e43c5D94B098A0d1Af7", + "0xd34403249B2d82AAdDB14e778422c966265e5Fb5", + "1000000000000000000", + ), + ( + include_str!("../../../../testdata/smartchain/transaction_staking_undelegate.json"), + include_str!("../../../../testdata/smartchain/transaction_staking_undelegate_receipt.json"), + TransactionType::StakeUndelegate, + "0xa103B70852B1fE3eF3a0B60B818279F9D0D337d9", + "0x5c38FF8Ca2b16099C086bF36546e99b13D152C4c", + "1045889308410801049", + ), + ( + include_str!("../../../../testdata/smartchain/transaction_staking_redelegate.json"), + include_str!("../../../../testdata/smartchain/transaction_staking_redelegate_receipt.json"), + TransactionType::StakeRedelegate, + "0xB5a0A71Be7B79F2A8Bd19B3A4D54d1b85fA2d50b", + "0xB58ac55EB6B10e4f7918D77C92aA1cF5bB2DEd5e", + "2370599727993109265", + ), + ( + include_str!("../../../../testdata/smartchain/transaction_staking_claim_rewards.json"), + include_str!("../../../../testdata/smartchain/transaction_staking_claim_rewards_receipt.json"), + TransactionType::StakeRewards, + "0x47B47f2586089F68Ec17384a437F96800f499274", + "0xB12e8137eF499a1d81552DB11664a9E617fd350A", + "4001085336323661069", + ), + ]; + + for (transaction, receipt, transaction_type, from, to, value) in cases { + let transaction = load_json_rpc_result::(transaction); + let receipt = load_json_rpc_result::(receipt); + let staking_transaction = map_transaction(&Chain::SmartChain, &transaction, &receipt, None); + + assert_staking_transaction( + &staking_transaction, + Chain::SmartChain, + transaction_type, + from, + to, + "0x0000000000000000000000000000000000002002", + value, + ); + } + + let mut transaction = load_json_rpc_result::(include_str!("../../../../testdata/smartchain/transaction_staking_delegate.json")); + let receipt = load_json_rpc_result::(include_str!("../../../../testdata/smartchain/transaction_staking_delegate_receipt.json")); + transaction.to = Some("0x1234567890123456789012345678901234567890".to_string()); + + assert!(ProtocolParsers::map_transaction(&Chain::SmartChain, &transaction, &receipt, None, None, DateTime::default()).is_none()); + } +} diff --git a/core/crates/gem_evm/src/rpc/parsers/staking/transaction.rs b/core/crates/gem_evm/src/rpc/parsers/staking/transaction.rs new file mode 100644 index 0000000000..55f70b6382 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/staking/transaction.rs @@ -0,0 +1,27 @@ +use num_bigint::BigUint; +use primitives::{AssetId, Transaction as PrimitivesTransaction, TransactionType}; + +use crate::ethereum_address_checksum; + +use super::ParseContext; + +pub(super) fn make_staking_transaction(context: &ParseContext<'_>, to: &str, transaction_type: TransactionType, value: BigUint) -> Option { + let from = ethereum_address_checksum(&context.transaction.from).ok()?; + let contract = context.transaction.to.as_ref().and_then(|to| ethereum_address_checksum(to).ok()); + + Some(PrimitivesTransaction::new( + context.transaction.hash.clone(), + AssetId::from_chain(*context.chain), + from, + to.to_string(), + contract, + transaction_type, + context.receipt.get_state(), + context.receipt.get_fee().to_string(), + AssetId::from_chain(*context.chain), + value.to_string(), + None, + None, + context.created_at, + )) +} diff --git a/core/crates/gem_evm/src/rpc/parsers/universal_router.rs b/core/crates/gem_evm/src/rpc/parsers/universal_router.rs new file mode 100644 index 0000000000..5121cccc92 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/universal_router.rs @@ -0,0 +1,472 @@ +use alloy_primitives::Address; +use alloy_sol_types::SolCall; + +use crate::{ + address::ethereum_address_from_topic, + ethereum_address_checksum, + rpc::{mapper::TRANSFER_TOPIC, model::TransactionReciept}, + uniswap::{ + actions::{V4Action, decode_action_data}, + command::{SWEEP_COMMAND, Sweep, UNWRAP_WETH_COMMAND, UnwrapWeth, V3_SWAP_EXACT_IN_COMMAND, V3SwapExactIn, V4_SWAP_COMMAND, WRAP_ETH_COMMAND}, + contracts::IUniversalRouter, + deployment::get_provider_by_chain_contract, + path::decode_path, + }, +}; +use primitives::{AssetId, Chain, Transaction as PrimitivesTransaction, TransactionSwapMetadata, decode_hex}; + +use super::{ParseContext, ProtocolParser, ethereum_value_from_log_data, make_swap_transaction, try_map_balance_diff_swap}; + +const WITHDRAWAL_TOPIC: &str = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65"; + +pub struct UniversalRouterParser; + +impl ProtocolParser for UniversalRouterParser { + fn matches(&self, context: &ParseContext<'_>) -> bool { + context + .transaction + .to + .as_ref() + .is_some_and(|to| get_provider_by_chain_contract(context.chain, to).is_some()) + } + + fn parse(&self, context: &ParseContext<'_>) -> Option { + let to = context.transaction.to.as_ref()?; + let provider = get_provider_by_chain_contract(context.chain, to)?; + let input_bytes = decode_hex(&context.transaction.input).ok()?; + + let metadata = decode_execute_swap(context.chain, &provider, &context.transaction.from, &input_bytes, context.receipt) + .or_else(|| try_map_balance_diff_swap(context.chain, &context.transaction.from, context.trace, context.receipt, Some(provider.clone())))?; + + make_swap_transaction(context.chain, context.transaction, context.receipt, &metadata, context.created_at) + } +} + +pub fn decode_execute_swap(chain: &Chain, provider: &str, from: &str, input_bytes: &[u8], receipt: &TransactionReciept) -> Option { + let execute_call = IUniversalRouter::executeCall::abi_decode(input_bytes).ok()?; + let commands_vec = execute_call.commands; + let inputs_vec = execute_call.inputs; + + let mut from_asset = None; + let mut to_asset = None; + let mut from_value = "".to_string(); + let mut to_value = "".to_string(); + + let mut has_wrap = false; + let mut has_unwrap = false; + let mut has_sweep = false; + let mut unwrap_value = "".to_string(); + let mut sweep_value = "".to_string(); + + for (command, input) in commands_vec.iter().zip(inputs_vec.iter()) { + if command == &WRAP_ETH_COMMAND { + has_wrap = true; + } + if command == &UNWRAP_WETH_COMMAND { + let unwrap_weth = UnwrapWeth::abi_decode(input).ok()?; + has_unwrap = true; + unwrap_value = unwrap_weth.amount_min.to_string(); + } + if command == &SWEEP_COMMAND { + let sweep = Sweep::abi_decode(input).ok()?; + has_sweep = true; + sweep_value = sweep.amount_min.to_string(); + } + } + + for (command, input) in commands_vec.iter().zip(inputs_vec.iter()) { + if command == &V3_SWAP_EXACT_IN_COMMAND { + let swap_exact_in = V3SwapExactIn::abi_decode(input).ok()?; + let token_pair = decode_path(&swap_exact_in.path)?; + let from_token = token_pair.token_in.to_checksum(None); + let to_token = token_pair.token_out.to_checksum(None); + + from_asset = Some(AssetId { + chain: *chain, + token_id: if has_wrap { None } else { Some(from_token.clone()) }, + }); + to_asset = Some(AssetId { + chain: *chain, + token_id: if has_unwrap { None } else { Some(to_token.clone()) }, + }); + from_value = swap_exact_in.amount_in.to_string(); + to_value = if has_unwrap { + withdraw_value_from_receipt(&to_token, receipt).unwrap_or(unwrap_value.clone()) + } else if has_sweep { + transfer_value_from_receipt(from, &to_token, receipt).unwrap_or(sweep_value.clone()) + } else { + transfer_value_from_receipt(from, &to_token, receipt).unwrap_or(swap_exact_in.amount_out_min.to_string()) + } + } + if command == &V4_SWAP_COMMAND + && let Ok(decoded_actions_vec) = decode_action_data(input) + { + for action in decoded_actions_vec { + match action { + V4Action::SWAP_EXACT_IN(params) => { + let path_keys = params.path; + let from_token = params.currencyIn; + let to_token = if path_keys.is_empty() { + continue; + } else { + path_keys[path_keys.len() - 1].intermediateCurrency + }; + from_asset = Some(AssetId { + chain: *chain, + token_id: if from_token == Address::ZERO { None } else { Some(from_token.to_checksum(None)) }, + }); + to_asset = Some(AssetId { + chain: *chain, + token_id: if to_token == Address::ZERO { None } else { Some(to_token.to_checksum(None)) }, + }); + from_value = params.amountIn.to_string(); + to_value = if to_token == Address::ZERO { + sweep_value.clone() + } else { + transfer_value_from_receipt(from, &to_token.to_checksum(None), receipt)? + }; + } + _ => continue, + } + } + } + } + + if let Some(from_asset) = from_asset + && let Some(to_asset) = to_asset + && !from_value.is_empty() + && !to_value.is_empty() + { + return Some(TransactionSwapMetadata { + from_asset, + to_asset, + from_value, + to_value, + provider: Some(provider.to_string()), + }); + } + None +} + +fn withdraw_value_from_receipt(token: &str, receipt: &TransactionReciept) -> Option { + let token = ethereum_address_checksum(token).ok()?; + + receipt.logs.iter().find_map(|log| { + (ethereum_address_checksum(&log.address).ok()? == token && log.topics.len() == 2 && log.topics.first().is_some_and(|topic| topic == WITHDRAWAL_TOPIC)) + .then(|| ethereum_value_from_log_data(&log.data, 0, 64)) + .flatten() + .map(|value| value.to_string()) + }) +} + +fn transfer_value_from_receipt(to: &str, token: &str, receipt: &TransactionReciept) -> Option { + let to = ethereum_address_checksum(to).ok()?; + let token = ethereum_address_checksum(token).ok()?; + + receipt.logs.iter().find_map(|log| { + (ethereum_address_checksum(&log.address).ok()? == token + && log.topics.len() == 3 + && log.topics.first().is_some_and(|topic| topic == TRANSFER_TOPIC) + && ethereum_address_from_topic(log.topics.get(2)?)? == to) + .then(|| ethereum_value_from_log_data(&log.data, 0, 64)) + .flatten() + .map(|value| value.to_string()) + }) +} + +#[cfg(test)] +mod tests { + use crate::provider::testkit::TOKEN_USDC_ADDRESS; + use crate::registry::ContractRegistry; + use crate::rpc::model::{Transaction, TransactionReciept, TransactionReplayTrace}; + use crate::rpc::parsers::ProtocolParsers; + use chrono::DateTime; + use primitives::{ + AssetId, Chain, TransactionSwapMetadata, TransactionType, + asset_constants::{POLYGON_USDT_TOKEN_ID, UNICHAIN_DAI_TOKEN_ID, UNICHAIN_USDC_TOKEN_ID}, + contract_constants::{ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, UNICHAIN_UNISWAP_V4_UNIVERSAL_ROUTER_CONTRACT}, + testkit::json_rpc::load_json_rpc_result, + }; + + fn map_swap(chain: &Chain, transaction: &Transaction, receipt: &TransactionReciept) -> primitives::Transaction { + ProtocolParsers::map_transaction(chain, transaction, receipt, None, None, DateTime::default()).unwrap() + } + + fn map_swap_with_trace( + chain: &Chain, + transaction: &Transaction, + receipt: &TransactionReciept, + trace: &TransactionReplayTrace, + registry: &ContractRegistry, + ) -> primitives::Transaction { + ProtocolParsers::map_transaction(chain, transaction, receipt, Some(trace), Some(registry), DateTime::from_timestamp(1735671600, 0).unwrap()).unwrap() + } + + #[test] + fn test_map_v4_swap_eth_dai() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/v4_eth_dai_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/v4_eth_dai_tx_receipt.json")); + + let swap_tx = map_swap(&Chain::Unichain, &transaction, &receipt); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.from, "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"); + assert_eq!(swap_tx.to, "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"); + assert_eq!(swap_tx.contract.unwrap(), UNICHAIN_UNISWAP_V4_UNIVERSAL_ROUTER_CONTRACT); + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.fee_asset_id, AssetId::from_chain(Chain::Unichain)); + assert_eq!(swap_tx.value, "1000000000000000"); + + assert_eq!( + metadata.from_asset, + AssetId { + chain: Chain::Unichain, + token_id: None + } + ); + assert_eq!(metadata.from_value, "995000000000000"); + assert_eq!( + metadata.to_asset, + AssetId { + chain: Chain::Unichain, + token_id: Some(UNICHAIN_DAI_TOKEN_ID.to_string()) + } + ); + assert_eq!(metadata.to_value, "2696771430516915192"); + } + + #[test] + fn test_map_v4_swap_usdc_eth() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/v4_usdc_eth_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/v4_usdc_eth_tx_receipt.json")); + + let swap_tx = map_swap(&Chain::Unichain, &transaction, &receipt); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.from, "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"); + assert_eq!(swap_tx.to, "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"); + assert_eq!(swap_tx.contract.unwrap(), UNICHAIN_UNISWAP_V4_UNIVERSAL_ROUTER_CONTRACT); + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.fee_asset_id, AssetId::from_chain(Chain::Unichain)); + assert_eq!(swap_tx.value, "0"); + + assert_eq!( + metadata.from_asset, + AssetId { + chain: Chain::Unichain, + token_id: Some(UNICHAIN_USDC_TOKEN_ID.to_string()) + } + ); + assert_eq!(metadata.from_value, "2132953"); + assert_eq!( + metadata.to_asset, + AssetId { + chain: Chain::Unichain, + token_id: None + } + ); + assert_eq!(metadata.to_value, "1155057703771482"); + } + + #[test] + fn test_map_v3_swap_eth_token() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/v3_eth_token_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/v3_eth_token_tx_receipt.json")); + + let swap_tx = map_swap(&Chain::Ethereum, &transaction, &receipt); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.from, "0x10E11c7368552D5Ab9ef5eED496f614fBAAe9F0D"); + assert_eq!(swap_tx.to, "0x10E11c7368552D5Ab9ef5eED496f614fBAAe9F0D"); + assert_eq!(swap_tx.contract.unwrap(), ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT); + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.fee_asset_id, AssetId::from_chain(Chain::Ethereum)); + assert_eq!(swap_tx.value, "18000000000000000"); + + assert_eq!( + metadata.from_asset, + AssetId { + chain: Chain::Ethereum, + token_id: None + } + ); + assert_eq!(metadata.from_value, "17910000000000000"); + assert_eq!( + metadata.to_asset, + AssetId { + chain: Chain::Ethereum, + token_id: Some("0xcf0C122c6b73ff809C693DB761e7BaeBe62b6a2E".to_string()) + } + ); + assert_eq!(metadata.to_value, "512854887193301"); + } + + #[test] + fn test_map_v3_swap_token_eth() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/v3_token_eth_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/v3_token_eth_tx_receipt.json")); + + let swap_tx = map_swap(&Chain::Base, &transaction, &receipt); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.from, "0x985Cf24b63a98510298997Af83a31D8625C09bA5"); + assert_eq!(swap_tx.to, "0x985Cf24b63a98510298997Af83a31D8625C09bA5"); + assert_eq!(swap_tx.contract.unwrap(), "0xFE6508f0015C778Bdcc1fB5465bA5ebE224C9912"); + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.fee_asset_id, AssetId::from_chain(Chain::Base)); + assert_eq!(swap_tx.value, "0"); + + assert_eq!( + metadata.from_asset, + AssetId { + chain: Chain::Base, + token_id: Some("0x532f27101965dd16442E59d40670FaF5eBB142E4".to_string()) + } + ); + assert_eq!(metadata.from_value, "1352497738700000000000"); + assert_eq!( + metadata.to_asset, + AssetId { + chain: Chain::Base, + token_id: None + } + ); + assert_eq!(metadata.to_value, "29020434785385862"); + } + + #[test] + fn test_map_v3_swap_pol_usdt() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/v3_pol_usdt_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/v3_pol_usdt_tx_receipt.json")); + + let swap_tx = map_swap(&Chain::Polygon, &transaction, &receipt); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.from, "0x8f4b6cbF3373e065aEb3FEc6027Ff8Ca9a665DE2"); + assert_eq!(swap_tx.to, "0x8f4b6cbF3373e065aEb3FEc6027Ff8Ca9a665DE2"); + assert_eq!(swap_tx.contract.unwrap(), "0xec7BE89e9d109e7e3Fec59c222CF297125FEFda2"); + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.fee_asset_id, AssetId::from_chain(Chain::Polygon)); + assert_eq!(swap_tx.value, "372000000000000000000"); + + assert_eq!( + metadata.from_asset, + AssetId { + chain: Chain::Polygon, + token_id: None + } + ); + assert_eq!(metadata.from_value, "372000000000000000000"); + assert_eq!( + metadata.to_asset, + AssetId { + chain: Chain::Polygon, + token_id: Some(POLYGON_USDT_TOKEN_ID.to_string()) + } + ); + assert_eq!(metadata.to_value, "78290151"); + } + + #[test] + fn test_map_v3_swap_usdc_paxg() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/v3_usdc_paxg_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/v3_usdc_paxg_receipt.json")); + + let swap_tx = map_swap(&Chain::Ethereum, &transaction, &receipt); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + + assert_eq!(swap_tx.from, "0xBa38FE5b73eA5b93d0733CF9eb10aDea6E1E3a2a"); + assert_eq!(swap_tx.to, "0xBa38FE5b73eA5b93d0733CF9eb10aDea6E1E3a2a"); + assert_eq!(swap_tx.contract.unwrap(), ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT); + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.fee_asset_id, AssetId::from_chain(Chain::Ethereum)); + assert_eq!(swap_tx.value, "0"); + + assert_eq!( + metadata.from_asset, + AssetId { + chain: Chain::Ethereum, + token_id: Some(TOKEN_USDC_ADDRESS.to_string()) + } + ); + assert_eq!(metadata.from_value, "29850000"); + assert_eq!( + metadata.to_asset, + AssetId { + chain: Chain::Ethereum, + token_id: Some("0x45804880De22913dAFE09f4980848ECE6EcbAf78".to_string()) + } + ); + assert_eq!(metadata.to_value, "9017156750431593"); + } + + #[test] + fn test_swap_from_balance_diff() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/trace_replay_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/trace_replay_tx_receipt.json")); + let trace = load_json_rpc_result::(include_str!("../../../testdata/trace_replay_tx_trace.json")); + + let contract_registry = ContractRegistry::default(); + let swap_tx = map_swap_with_trace(&Chain::Ethereum, &transaction, &receipt, &trace, &contract_registry); + + assert_eq!(swap_tx.from, "0x52A07c930157d07D9EffD147ecF41C5cBbC6000c"); + assert_eq!(swap_tx.to, "0x52A07c930157d07D9EffD147ecF41C5cBbC6000c"); + assert_eq!(swap_tx.contract.unwrap(), "0x111111125421cA6dc452d289314280a0f8842A65"); + assert_eq!(swap_tx.transaction_type, TransactionType::Swap); + assert_eq!(swap_tx.fee_asset_id, AssetId::from_chain(Chain::Ethereum)); + assert_eq!(swap_tx.value, "0"); + + let metadata: TransactionSwapMetadata = serde_json::from_value(swap_tx.metadata.unwrap()).unwrap(); + assert_eq!( + metadata.from_asset, + AssetId { + chain: Chain::Ethereum, + token_id: Some("0xD0eC028a3D21533Fdd200838F39c85B03679285D".to_string()) + } + ); + assert_eq!(metadata.from_value, "780000000000000000000"); + assert_eq!( + metadata.to_asset, + AssetId { + chain: Chain::Ethereum, + token_id: None + } + ); + assert_eq!(metadata.to_value, "158035947652936307"); + } + + #[test] + fn test_map_transaction_v2_token_eth() { + let transaction = load_json_rpc_result::(include_str!("../../../testdata/v2_token_eth_tx.json")); + let receipt = load_json_rpc_result::(include_str!("../../../testdata/v2_token_eth_tx_receipt.json")); + let trace = load_json_rpc_result::(include_str!("../../../testdata/v2_token_eth_tx_trace.json")); + + let contract_registry = ContractRegistry::default(); + let swap_tx = map_swap_with_trace(&Chain::Ethereum, &transaction, &receipt, &trace, &contract_registry); + assert!(swap_tx.metadata.is_some()); + } + + #[test] + fn test_v4_swap_empty_path_no_panic() { + use crate::uniswap::{actions::V4Action, contracts::v4::IV4Router}; + use alloy_primitives::Address; + + let action = V4Action::SWAP_EXACT_IN(IV4Router::ExactInputParams { + currencyIn: Address::ZERO, + path: vec![], + amountIn: 1000000000000000000_u128, + amountOutMinimum: 0, + }); + + let encoded_actions = crate::uniswap::actions::encode_actions(&[action]); + let decoded_actions = crate::uniswap::actions::decode_action_data(&encoded_actions); + assert!(decoded_actions.is_ok()); + + let actions = decoded_actions.unwrap(); + assert_eq!(actions.len(), 1); + + if let V4Action::SWAP_EXACT_IN(params) = &actions[0] { + assert!(params.path.is_empty()); + } + } +} diff --git a/core/crates/gem_evm/src/rpc/parsers/yo.rs b/core/crates/gem_evm/src/rpc/parsers/yo.rs new file mode 100644 index 0000000000..b1511e21e0 --- /dev/null +++ b/core/crates/gem_evm/src/rpc/parsers/yo.rs @@ -0,0 +1,65 @@ +use crate::{ + address::{ethereum_address_checksum, ethereum_address_from_topic}, + rpc::mapper::TRANSFER_TOPIC, +}; +use primitives::{AssetId, Transaction as PrimitivesTransaction, TransactionType, contract_constants::ETHEREUM_YO_PROTOCOL_CONTRACT}; + +use super::{ParseContext, ProtocolParser, ethereum_value_from_log_data}; + +pub(crate) const FUNCTION_YO_DEPOSIT: &str = "0x82b78ba7"; +pub(crate) const FUNCTION_YO_WITHDRAW: &str = "0x99519ab8"; + +pub struct YoParser; + +impl ProtocolParser for YoParser { + fn matches(&self, context: &ParseContext<'_>) -> bool { + let Some(to) = context.transaction.to.as_ref().and_then(|to| ethereum_address_checksum(to).ok()) else { + return false; + }; + let Some(contract) = ethereum_address_checksum(ETHEREUM_YO_PROTOCOL_CONTRACT).ok() else { + return false; + }; + + to == contract && (context.transaction.input.starts_with(FUNCTION_YO_DEPOSIT) || context.transaction.input.starts_with(FUNCTION_YO_WITHDRAW)) + } + + fn parse(&self, context: &ParseContext<'_>) -> Option { + let from = ethereum_address_checksum(&context.transaction.from).ok()?; + let to = ethereum_address_checksum(context.transaction.to.as_ref()?).ok()?; + let (transaction_type, topic_index) = if context.transaction.input.starts_with(FUNCTION_YO_DEPOSIT) { + (TransactionType::EarnDeposit, 1) + } else if context.transaction.input.starts_with(FUNCTION_YO_WITHDRAW) { + (TransactionType::EarnWithdraw, 2) + } else { + return None; + }; + + let log = context.receipt.logs.iter().find(|log| { + log.topics.len() == 3 + && log.topics.first().is_some_and(|topic| topic == TRANSFER_TOPIC) + && log + .topics + .get(topic_index) + .and_then(|topic| ethereum_address_from_topic(topic)) + .is_some_and(|address| address == from) + })?; + let token_id = ethereum_address_checksum(&log.address).ok()?; + let value = ethereum_value_from_log_data(&log.data, 0, 64)?; + + Some(PrimitivesTransaction::new( + context.transaction.hash.clone(), + AssetId::from_token(*context.chain, &token_id), + from, + to, + None, + transaction_type, + context.receipt.get_state(), + context.receipt.get_fee().to_string(), + AssetId::from_chain(*context.chain), + value.to_string(), + None, + None, + context.created_at, + )) + } +} diff --git a/core/crates/gem_evm/src/signer/chain_signer.rs b/core/crates/gem_evm/src/signer/chain_signer.rs new file mode 100644 index 0000000000..f442560cbc --- /dev/null +++ b/core/crates/gem_evm/src/signer/chain_signer.rs @@ -0,0 +1,416 @@ +use std::str::FromStr; + +use alloy_consensus::TxEip1559; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use num_bigint::BigInt; +use num_traits::Num; +use primitives::{ChainSigner, EVMChain, NFTType, SignerError, SignerInput, StakeType, decode_hex, swap::SwapQuoteDataType}; + +use super::model::TransactionParams; +use super::sign_eip1559_tx; +use crate::encode::{encode_erc20_approve_max_value, encode_erc20_transfer, encode_erc721_transfer, encode_erc1155_transfer}; + +#[allow(dead_code)] +pub struct EvmChainSigner { + chain: EVMChain, +} + +impl EvmChainSigner { + pub fn new(chain: EVMChain) -> Self { + Self { chain } + } +} + +impl ChainSigner for EvmChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let params = TransactionParams::from_input(input)?; + sign_and_encode( + &build_eip1559_transaction(¶ms, &input.destination_address, value_u256(&input.value)?, Bytes::new())?, + private_key, + ) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let params = TransactionParams::from_input(input)?; + let token_id = input.input_type.get_asset().id.get_token_id()?; + let data = encode_erc20_transfer(&input.destination_address, &BigInt::from_str_radix(&input.value, 10)?)?; + sign_and_encode(&build_eip1559_transaction(¶ms, token_id, U256::ZERO, Bytes::from(data))?, private_key) + } + + fn sign_nft_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let params = TransactionParams::from_input(input)?; + let nft_asset = input.input_type.get_nft_asset()?; + let contract = nft_asset.get_contract_address()?; + let data = match nft_asset.token_type { + NFTType::ERC721 => encode_erc721_transfer(&input.sender_address, &input.destination_address, &nft_asset.token_id), + NFTType::ERC1155 => encode_erc1155_transfer(&input.sender_address, &input.destination_address, &nft_asset.token_id), + _ => return Err(SignerError::invalid_input("unsupported NFT type for EVM")), + }?; + sign_and_encode(&build_eip1559_transaction(¶ms, contract, U256::ZERO, Bytes::from(data))?, private_key) + } + + fn sign_token_approval(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let params = TransactionParams::from_input(input)?; + let approval = input.input_type.get_approval_data()?; + sign_and_encode( + &build_eip1559_transaction(¶ms, &approval.token, U256::ZERO, Bytes::from(encode_erc20_approve_max_value(&approval.spender)?))?, + private_key, + ) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let swap = input.input_type.get_swap_data()?; + let swap_data = &swap.data; + let from_asset = input.input_type.get_asset(); + + match swap_data.data_type { + SwapQuoteDataType::Transfer => { + let params = TransactionParams::from_input(input)?; + if from_asset.id.is_token() { + let token_id = from_asset.id.get_token_id()?; + let amount = BigInt::from_str_radix(&input.value, 10)?; + let data = encode_erc20_transfer(&swap_data.to, &amount)?; + Ok(vec![sign_and_encode( + &build_eip1559_transaction(¶ms, token_id, U256::ZERO, Bytes::from(data))?, + private_key, + )?]) + } else { + Ok(vec![sign_and_encode( + &build_eip1559_transaction(¶ms, &swap_data.to, value_u256(&input.value)?, Bytes::new())?, + private_key, + )?]) + } + } + SwapQuoteDataType::Contract => { + let value = value_u256(&swap_data.value)?; + let gas_limit = match &swap_data.approval { + Some(_) => swap_data.gas_limit.as_ref().and_then(|gl| gl.parse().ok()).ok_or("missing swap gas limit")?, + None => input.fee.gas_limit()?, + }; + sign_contract_call( + input, + &swap_data.to, + decode_hex(&swap_data.data)?, + gas_limit, + value, + swap_data.approval.as_ref(), + private_key, + ) + } + } + } + + fn sign_earn(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let earn_data = input.input_type.get_earn_data()?; + let gas_limit = earn_data.gas_limit.as_ref().and_then(|gl| gl.parse().ok()).map_or_else(|| input.fee.gas_limit(), Ok)?; + sign_contract_call( + input, + &earn_data.contract_address, + decode_hex(&earn_data.call_data)?, + gas_limit, + U256::ZERO, + earn_data.approval.as_ref(), + private_key, + ) + } + + fn sign_stake(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let stake_type = input.input_type.get_stake_type()?; + let contract_call = input.metadata.get_contract_call()?; + let value = match stake_type { + StakeType::Stake(_) => value_u256(&input.value)?, + _ => U256::ZERO, + }; + sign_contract_call( + input, + &contract_call.contract_address, + decode_hex(&contract_call.call_data)?, + input.fee.gas_limit()?, + value, + None, + private_key, + ) + } + + fn sign_data(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let extra = input.input_type.get_generic_data()?; + let base = TransactionParams::from_input(input)?; + let gas_limit = extra.gas_limit.as_ref().and_then(|gl| gl.to_string().parse().ok()).unwrap_or(base.gas_limit); + let params = TransactionParams { gas_limit, ..base }; + sign_and_encode( + &build_eip1559_transaction(¶ms, &extra.to, value_u256(&input.value)?, Bytes::from(extra.data.clone().unwrap_or_default()))?, + private_key, + ) + } + + fn sign_message(&self, message: &[u8], private_key: &[u8]) -> Result { + let json_str = std::str::from_utf8(message).map_err(|_| SignerError::invalid_input("message must be valid UTF-8"))?; + Ok(format!("0x{}", signer::Signer::sign_eip712(json_str, private_key)?)) + } +} + +fn value_u256(value: &str) -> Result { + U256::from_str(value).map_err(SignerError::from_display) +} + +fn build_eip1559_transaction(params: &TransactionParams, to: &str, value: U256, input: Bytes) -> Result { + let to_address = Address::parse_checksummed(to, None) + .or_else(|_| to.parse::

()) + .map_err(|_| SignerError::invalid_input("invalid to address"))?; + + Ok(TxEip1559 { + chain_id: params.chain_id, + nonce: params.nonce, + gas_limit: params.gas_limit, + max_fee_per_gas: params.max_fee_per_gas, + max_priority_fee_per_gas: params.max_priority_fee_per_gas, + to: TxKind::Call(to_address), + value, + access_list: Default::default(), + input, + }) +} + +fn sign_and_encode(transaction: &TxEip1559, private_key: &[u8]) -> Result { + Ok(hex::encode(sign_eip1559_tx(transaction, private_key)?)) +} + +fn sign_contract_call( + input: &SignerInput, + contract_address: &str, + call_data: Vec, + gas_limit: u64, + value: U256, + approval: Option<&primitives::swap::ApprovalData>, + private_key: &[u8], +) -> Result, SignerError> { + let params = TransactionParams::from_input(input)?; + + if let Some(approval) = approval { + let approval_transaction = build_eip1559_transaction(¶ms, &approval.token, U256::ZERO, Bytes::from(encode_erc20_approve_max_value(&approval.spender)?))?; + let main_params = TransactionParams { + nonce: params.nonce + 1, + gas_limit, + ..params + }; + let main_transaction = build_eip1559_transaction(&main_params, contract_address, value, Bytes::from(call_data))?; + Ok(vec![sign_and_encode(&approval_transaction, private_key)?, sign_and_encode(&main_transaction, private_key)?]) + } else { + let main_params = TransactionParams { gas_limit, ..params }; + Ok(vec![sign_and_encode( + &build_eip1559_transaction(&main_params, contract_address, value, Bytes::from(call_data))?, + private_key, + )?]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use primitives::{ + Asset, Chain, ChainSigner, DelegationValidator, EVMChain, NFTType, SignerInput, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, + WalletConnectionSessionAppMetadata, contract_call_data::ContractCallData, nft::NFTAsset, swap::*, + }; + + #[test] + fn test_sign_transfer() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let input = SignerInput::mock_evm(TransactionInputType::Transfer(Asset::from_chain(Chain::Ethereum)), "1000000000000000000", 21000); + assert_eq!( + signer.sign_transfer(&input, &TEST_PRIVATE_KEY).unwrap(), + "02f8730180843b9aca008504a817c800825208942b5ad5c4795c026514f8317c7a215e218dccd6cf880de0b6b3a764000080c001a0ea6700354e2542e163e08c111d7b1d7e2a9d371a06977c9a79c42783c3237af9a001809a71f1fa2309f204b4ebed1a9e68f0e60ab736b98284727f2d8427ab705f" + ); + } + + #[test] + fn test_sign_token_transfer() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let input = SignerInput::mock_evm(TransactionInputType::Transfer(Asset::mock_erc20()), "1000000", 65000); + assert_eq!( + signer.sign_token_transfer(&input, &TEST_PRIVATE_KEY).unwrap(), + "02f8b00180843b9aca008504a817c80082fde894a0b86a33e6441066d64bb38954e41f6b4b925c5980b844a9059cbb0000000000000000000000002b5ad5c4795c026514f8317c7a215e218dccd6cf00000000000000000000000000000000000000000000000000000000000f4240c001a09ca8ae6c1d3e9a70465ae36e44c4ca9982a0b94c3cb8ec7c56e6a183f2d04f16a02275a147339b8a41e36670cbec2df08df035ea3b403eedd8325b150b53a3d7f4" + ); + } + + #[test] + fn test_sign_nft_transfer() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + + let input = SignerInput::mock_evm(TransactionInputType::TransferNft(Asset::from_chain(Chain::Ethereum), NFTAsset::mock()), "0", 100000); + assert_eq!( + signer.sign_nft_transfer(&input, &TEST_PRIVATE_KEY).unwrap(), + "02f8d10180843b9aca008504a817c800830186a094dac17f958d2ee523a2206206994597c13d831ec780b86442842e0e0000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf0000000000000000000000002b5ad5c4795c026514f8317c7a215e218dccd6cf0000000000000000000000000000000000000000000000000000000000000001c080a08371f982a5384532d5ac3336a174239f571cd75663ddc1d6f3892a59c940c983a035902737dfc2f2af6df4244e741f652f4d764230aecf9d5a910d0d027dc4238d" + ); + + let input = SignerInput::mock_evm( + TransactionInputType::TransferNft(Asset::from_chain(Chain::Ethereum), NFTAsset::mock_with_type(NFTType::ERC1155)), + "0", + 100000, + ); + assert_eq!( + signer.sign_nft_transfer(&input, &TEST_PRIVATE_KEY).unwrap(), + "02f901310180843b9aca008504a817c800830186a094dac17f958d2ee523a2206206994597c13d831ec780b8c4f242432a0000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf0000000000000000000000002b5ad5c4795c026514f8317c7a215e218dccd6cf0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000c080a032eb8933adf3d5fb105c292e413bae35339b9ee6b30ac4b8b679e9f14a0009dba00964abf4af5aa48120c18942ef2b64189bda437a97a7616e14b39624c0870329" + ); + } + + #[test] + fn test_sign_token_approval() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let input = SignerInput::mock_evm(TransactionInputType::TokenApprove(Asset::from_chain(Chain::Ethereum), ApprovalData::mock()), "0", 65000); + assert_eq!( + signer.sign_token_approval(&input, &TEST_PRIVATE_KEY).unwrap(), + "02f8b00180843b9aca008504a817c80082fde894dac17f958d2ee523a2206206994597c13d831ec780b844095ea7b30000000000000000000000002b5ad5c4795c026514f8317c7a215e218dccd6cfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc001a02fde3a01cfa4c2349782fa026932003f5eae7077763db75b31be593e7c15f4a3a048071a05fccf3905cb59be79fc9a0442d801907506193c158858cd6b7ef31fa3" + ); + } + + #[test] + fn test_sign_swap_without_approval() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let swap_data = SwapData { + quote: SwapQuote::mock(), + data: SwapQuoteData { + value: "1000000000000000000".to_string(), + data: "abcd".to_string(), + gas_limit: None, + ..SwapQuoteData::mock() + }, + }; + let input = SignerInput::mock_evm( + TransactionInputType::Swap(Asset::from_chain(Chain::Ethereum), Asset::from_chain(Chain::Ethereum), swap_data), + "1000000000000000000", + 200000, + ); + let result = signer.sign_swap(&input, &TEST_PRIVATE_KEY).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + "02f8760180843b9aca008504a817c80083030d40942b5ad5c4795c026514f8317c7a215e218dccd6cf880de0b6b3a764000082abcdc001a0a576e21827f710051c5d9402777cd913469bfa46a6a87281c60b2c48eb620db1a052ec097cab72419fd228b09276db146c1ad8c4e6fa35a34166ba718bdeadc892" + ); + } + + #[test] + fn test_sign_swap_with_approval() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let swap_data = SwapData { + quote: SwapQuote::mock(), + data: SwapQuoteData { + data: "abcd".to_string(), + approval: Some(ApprovalData::mock()), + gas_limit: Some("200000".to_string()), + ..SwapQuoteData::mock() + }, + }; + let input = SignerInput::mock_evm( + TransactionInputType::Swap(Asset::from_chain(Chain::Ethereum), Asset::from_chain(Chain::Ethereum), swap_data), + "0", + 65000, + ); + let result = signer.sign_swap(&input, &TEST_PRIVATE_KEY).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!( + result[0], + "02f8b00180843b9aca008504a817c80082fde894dac17f958d2ee523a2206206994597c13d831ec780b844095ea7b30000000000000000000000002b5ad5c4795c026514f8317c7a215e218dccd6cfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc001a02fde3a01cfa4c2349782fa026932003f5eae7077763db75b31be593e7c15f4a3a048071a05fccf3905cb59be79fc9a0442d801907506193c158858cd6b7ef31fa3" + ); + assert_eq!( + result[1], + "02f86e0101843b9aca008504a817c80083030d40942b5ad5c4795c026514f8317c7a215e218dccd6cf8082abcdc080a02ecc5acc573cb465ae28b24756c384a5dfd5eb4ced9479d73d93e50dea7f30fba01cbb3e7bc343e8294ed26a005b001408498ed364402b5a37830be9b8d850bda4" + ); + } + + #[test] + fn test_sign_stake() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let metadata = TransactionLoadMetadata::Evm { + nonce: 5, + chain_id: 1, + contract_call: Some(ContractCallData::mock_with_call_data( + "3a29dbae0000000000000000000000000000000000000000000000000000000000000017", + )), + }; + let input = SignerInput::mock_evm_with_metadata( + TransactionInputType::Stake(Asset::from_chain(Chain::Ethereum), StakeType::Stake(DelegationValidator::mock())), + "1000000000000000000", + 200000, + metadata, + ); + let result = signer.sign_stake(&input, &TEST_PRIVATE_KEY).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + "02f8980105843b9aca008504a817c80083030d40942b5ad5c4795c026514f8317c7a215e218dccd6cf880de0b6b3a7640000a43a29dbae0000000000000000000000000000000000000000000000000000000000000017c001a067b915da126e46cfb9c78db7aa5c277743b2de43450dd744f2e5bc16146b0954a01fa8f62608f997199eab37464df9cc1821a2c66a6c8f36d6ba438206a7a3556a" + ); + } + + #[test] + fn test_sign_data() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let extra = TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd]); + let input = SignerInput::mock_evm( + TransactionInputType::Generic(Asset::from_chain(Chain::Ethereum), WalletConnectionSessionAppMetadata::mock(), extra), + "0", + 100000, + ); + assert_eq!( + signer.sign_data(&input, &TEST_PRIVATE_KEY).unwrap(), + "02f86e0180843b9aca008504a817c800830186a0942b5ad5c4795c026514f8317c7a215e218dccd6cf8082abcdc080a085087f2d3c999ea4e253274ed68a9e58cc7eb9f2ee7e037897ce371ddc74f0bea06613bd4201e26f5738ca375fcbd19ee8e8a1fd633e7d2f2061a14f3d0ee1173a" + ); + } + + #[test] + fn test_sign_earn() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let input = SignerInput::mock_evm( + TransactionInputType::Earn( + Asset::from_chain(Chain::Ethereum), + primitives::EarnType::Deposit(DelegationValidator::mock()), + ContractCallData::mock(), + ), + "0", + 200000, + ); + let result = signer.sign_earn(&input, &TEST_PRIVATE_KEY).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + "02f86e0180843b9aca008504a817c80083030d40942b5ad5c4795c026514f8317c7a215e218dccd6cf8082abcdc001a00a342182b976d28a460ede1a104708d57d1174a5f4ba383eef91c2a774dfab62a0657d5a65976b2241a13554260c538372066938c51eb8116bbad6211c6b29bfff" + ); + } + + #[test] + fn test_sign_earn_with_approval() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let earn_data = ContractCallData { + approval: Some(ApprovalData::mock()), + gas_limit: Some("200000".to_string()), + ..ContractCallData::mock() + }; + let input = SignerInput::mock_evm( + TransactionInputType::Earn(Asset::from_chain(Chain::Ethereum), primitives::EarnType::Deposit(DelegationValidator::mock()), earn_data), + "0", + 65000, + ); + let result = signer.sign_earn(&input, &TEST_PRIVATE_KEY).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!( + result[0], + "02f8b00180843b9aca008504a817c80082fde894dac17f958d2ee523a2206206994597c13d831ec780b844095ea7b30000000000000000000000002b5ad5c4795c026514f8317c7a215e218dccd6cfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc001a02fde3a01cfa4c2349782fa026932003f5eae7077763db75b31be593e7c15f4a3a048071a05fccf3905cb59be79fc9a0442d801907506193c158858cd6b7ef31fa3" + ); + assert_eq!( + result[1], + "02f86e0101843b9aca008504a817c80083030d40942b5ad5c4795c026514f8317c7a215e218dccd6cf8082abcdc080a02ecc5acc573cb465ae28b24756c384a5dfd5eb4ced9479d73d93e50dea7f30fba01cbb3e7bc343e8294ed26a005b001408498ed364402b5a37830be9b8d850bda4" + ); + } + + #[test] + fn test_invalid_metadata() { + let signer = EvmChainSigner::new(EVMChain::Ethereum); + let input = SignerInput::mock_evm_with_metadata( + TransactionInputType::Transfer(Asset::from_chain(Chain::Ethereum)), + "1000000000000000000", + 21000, + TransactionLoadMetadata::None, + ); + assert!(signer.sign_transfer(&input, &TEST_PRIVATE_KEY).is_err()); + } +} diff --git a/core/crates/gem_evm/src/signer/eip1559.rs b/core/crates/gem_evm/src/signer/eip1559.rs new file mode 100644 index 0000000000..063bc2899c --- /dev/null +++ b/core/crates/gem_evm/src/signer/eip1559.rs @@ -0,0 +1,13 @@ +use alloy_consensus::{SignableTransaction, TxEip1559}; +use alloy_network::TxSignerSync; +use alloy_network::eip2718::Encodable2718; +use alloy_signer_local::PrivateKeySigner; +use std::error::Error; + +pub fn sign_eip1559_tx(tx: &TxEip1559, private_key: &[u8]) -> Result, Box> { + let signer = PrivateKeySigner::from_slice(private_key)?; + let mut tx = tx.clone(); + let signature = signer.sign_transaction_sync(&mut tx)?; + let signed = tx.into_signed(signature); + Ok(signed.encoded_2718()) +} diff --git a/core/crates/gem_evm/src/signer/mod.rs b/core/crates/gem_evm/src/signer/mod.rs new file mode 100644 index 0000000000..4c790be340 --- /dev/null +++ b/core/crates/gem_evm/src/signer/mod.rs @@ -0,0 +1,10 @@ +mod chain_signer; +mod eip1559; +mod model; +mod transaction; + +pub use chain_signer::EvmChainSigner; +pub use eip1559::sign_eip1559_tx; +pub use transaction::create_transfer_tx; + +pub use alloy_consensus::TxEip1559; diff --git a/core/crates/gem_evm/src/signer/model.rs b/core/crates/gem_evm/src/signer/model.rs new file mode 100644 index 0000000000..d2ac372ccc --- /dev/null +++ b/core/crates/gem_evm/src/signer/model.rs @@ -0,0 +1,22 @@ +use primitives::{SignerError, SignerInput}; + +#[derive(Clone, Copy)] +pub struct TransactionParams { + pub nonce: u64, + pub chain_id: u64, + pub max_fee_per_gas: u128, + pub max_priority_fee_per_gas: u128, + pub gas_limit: u64, +} + +impl TransactionParams { + pub fn from_input(input: &SignerInput) -> Result { + Ok(Self { + nonce: input.metadata.get_sequence()?, + chain_id: input.metadata.get_chain_id_u64()?, + max_fee_per_gas: input.fee.gas_price_u64()? as u128, + max_priority_fee_per_gas: input.fee.priority_fee_u64()? as u128, + gas_limit: input.fee.gas_limit()?, + }) + } +} diff --git a/core/crates/gem_evm/src/signer/transaction.rs b/core/crates/gem_evm/src/signer/transaction.rs new file mode 100644 index 0000000000..da34506e68 --- /dev/null +++ b/core/crates/gem_evm/src/signer/transaction.rs @@ -0,0 +1,91 @@ +use crate::contracts::IERC20; +use alloy_consensus::TxEip1559; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_sol_types::SolCall; +use primitives::AssetId; +use std::error::Error; +use std::str::FromStr; + +pub fn create_transfer_tx( + asset_id: &AssetId, + recipient: &str, + amount: &str, + nonce: u64, + chain_id: u64, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + gas_limit: u64, +) -> Result> { + let amount_u256 = U256::from_str(amount)?; + let recipient_address = Address::from_str(recipient)?; + + let (to, value, input) = if let Some(token_address) = &asset_id.token_id { + let contract_address = Address::from_str(token_address)?; + let transfer_data = encode_erc20_transfer(recipient, amount_u256)?; + (contract_address, U256::ZERO, transfer_data) + } else { + (recipient_address, amount_u256, Bytes::new()) + }; + + Ok(TxEip1559 { + chain_id, + nonce, + gas_limit, + max_fee_per_gas, + max_priority_fee_per_gas, + to: TxKind::Call(to), + value, + access_list: Default::default(), + input, + }) +} + +fn encode_erc20_transfer(to: &str, amount: U256) -> Result> { + let to_address = Address::from_str(to)?; + let call = IERC20::transferCall { to: to_address, value: amount }; + Ok(Bytes::from(call.abi_encode())) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Chain, asset_constants::SMARTCHAIN_USDT_TOKEN_ID}; + + #[test] + fn test_create_native_transfer() { + let asset_id = AssetId::from_chain(Chain::SmartChain); + let tx = create_transfer_tx( + &asset_id, + "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4", + "1000000000000000000", + 0, + 56, + 5_000_000_000, + 1_000_000_000, + 21000, + ) + .unwrap(); + + assert_eq!(tx.value, U256::from(1_000_000_000_000_000_000u128)); + assert!(tx.input.is_empty()); + } + + #[test] + fn test_create_token_transfer() { + let asset_id = AssetId::from_token(Chain::SmartChain, SMARTCHAIN_USDT_TOKEN_ID); + let tx = create_transfer_tx( + &asset_id, + "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4", + "1000000", + 0, + 56, + 5_000_000_000, + 1_000_000_000, + 65000, + ) + .unwrap(); + + assert_eq!(tx.value, U256::ZERO); + assert!(!tx.input.is_empty()); + } +} diff --git a/core/crates/gem_evm/src/siwe.rs b/core/crates/gem_evm/src/siwe.rs new file mode 100644 index 0000000000..f2240cf8b1 --- /dev/null +++ b/core/crates/gem_evm/src/siwe.rs @@ -0,0 +1,198 @@ +use alloy_primitives::Address; +use chrono::DateTime; +use primitives::{Chain, ChainType}; +use url::Url; + +use crate::domain::{extract_host, parse_url}; + +const PREAMBLE_SUFFIX: &str = " wants you to sign in with your Ethereum account:"; +const URI_PREFIX: &str = "URI:"; +const VERSION_PREFIX: &str = "Version:"; +const CHAIN_ID_PREFIX: &str = "Chain ID:"; +const NONCE_PREFIX: &str = "Nonce:"; +const ISSUED_AT_PREFIX: &str = "Issued At:"; +const SUPPORTED_VERSION: &str = "1"; +const MIN_NONCE_LENGTH: usize = 8; + +#[derive(Debug, Clone, PartialEq)] +pub struct SiweMessage { + pub domain: String, + pub address: String, + pub uri: String, + pub chain_id: u64, + pub nonce: String, + pub version: String, + pub issued_at: String, +} + +impl SiweMessage { + pub fn try_parse(raw: &str) -> Option { + let lines: Vec<_> = raw.lines().collect(); + + let domain = lines.first()?.trim().strip_suffix(PREAMBLE_SUFFIX)?.trim(); + let domain = extract_host(domain)?; + + let address = lines.get(1)?.trim().parse::
().ok()?.to_checksum(None); + + let body: Vec<_> = lines.iter().skip(2).map(|l| l.trim()).filter(|l| !l.is_empty()).collect(); + + let uri = Self::find_field(&body, URI_PREFIX)?; + let version = Self::find_field(&body, VERSION_PREFIX)?; + let chain_id = Self::find_field(&body, CHAIN_ID_PREFIX)?.parse().ok()?; + let nonce = Self::find_field(&body, NONCE_PREFIX)?; + let issued_at = Self::find_field(&body, ISSUED_AT_PREFIX)?; + + Some(Self { + domain, + address, + uri, + chain_id, + nonce, + version, + issued_at, + }) + } + + pub fn validate(&self, chain: Chain) -> Result<(), String> { + if chain.chain_type() != ChainType::Ethereum { + return Err("Unsupported chain for SIWE".to_string()); + } + + let expected_chain_id = chain.network_id().parse::().map_err(|_| "Invalid chain".to_string())?; + if expected_chain_id != self.chain_id { + return Err("Chain ID mismatch".to_string()); + } + + if self.version != SUPPORTED_VERSION { + return Err("Unsupported version".to_string()); + } + + if self.nonce.len() < MIN_NONCE_LENGTH || !self.nonce.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err("Invalid nonce".to_string()); + } + + DateTime::parse_from_rfc3339(&self.issued_at).map_err(|_| "Invalid timestamp".to_string())?; + + let uri = Url::parse(&self.uri).map_err(|_| "Invalid URI".to_string())?; + let domain_url = parse_url(&self.domain).ok_or("Invalid domain".to_string())?; + + let uri_host = uri.host_str().ok_or("Invalid URI host".to_string())?; + let domain_host = domain_url.host_str().ok_or("Invalid domain host".to_string())?; + + if !uri_host.eq_ignore_ascii_case(domain_host) { + return Err("Origin mismatch".to_string()); + } + + Ok(()) + } + + fn find_field(lines: &[&str], prefix: &str) -> Option { + lines.iter().find(|line| line.starts_with(prefix)).and_then(|line| { + let value = line.strip_prefix(prefix)?.trim(); + if value.is_empty() { + return None; + } + Some(value.to_string()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_message() -> String { + [ + "login.xyz wants you to sign in with your Ethereum account:", + "0x6dD7802E6d44bE89a789C4bD60bD511B68F41c7c", + "", + "Sign in with Ethereum to the app.", + "", + "URI: https://login.xyz", + "Version: 1", + "Chain ID: 1", + "Nonce: 8hK9pX32", + "Issued At: 2024-04-01T12:00:00Z", + "Expiration Time: 2024-04-02T12:00:00Z", + "Not Before: 2024-04-01T11:00:00Z", + "Request ID: abc-123", + "Resources:", + "- https://example.com/terms", + "- https://example.com/privacy", + ] + .join("\n") + } + + #[test] + fn parses_valid_message() { + let message = sample_message(); + let result = SiweMessage::try_parse(&message); + assert!(result.is_some()); + let siwe = result.unwrap(); + assert_eq!(siwe.domain, "login.xyz"); + assert_eq!(siwe.address, "0x6dd7802e6D44be89a789c4Bd60bD511b68f41c7c"); + assert_eq!(siwe.uri, "https://login.xyz"); + assert_eq!(siwe.chain_id, 1); + assert_eq!(siwe.nonce, "8hK9pX32"); + assert_eq!(siwe.version, "1"); + assert_eq!(siwe.issued_at, "2024-04-01T12:00:00Z"); + assert!(siwe.validate(Chain::Ethereum).is_ok()); + } + + #[test] + fn parses_message_with_explicit_scheme() { + let message = sample_message().replacen( + "login.xyz wants you to sign in with your Ethereum account:", + "https://login.xyz wants you to sign in with your Ethereum account:", + 1, + ); + let siwe = SiweMessage::try_parse(&message).unwrap(); + assert_eq!(siwe.domain, "login.xyz"); + } + + #[test] + fn parses_message_with_port() { + let message = sample_message().replacen( + "login.xyz wants you to sign in with your Ethereum account:", + "login.xyz:8080 wants you to sign in with your Ethereum account:", + 1, + ); + let siwe = SiweMessage::try_parse(&message).unwrap(); + assert_eq!(siwe.domain, "login.xyz:8080"); + } + + #[test] + fn ignores_non_siwe_messages() { + let raw = "hello world"; + let result = SiweMessage::try_parse(raw); + assert!(result.is_none()); + } + + #[test] + fn errors_on_chain_mismatch() { + let message = sample_message(); + let siwe = SiweMessage::try_parse(&message).unwrap(); + let err = siwe.validate(Chain::Polygon).unwrap_err(); + assert!(err.contains("mismatch")); + } + + #[test] + fn errors_on_origin_mismatch() { + let message = sample_message(); + let tampered = message.replace("https://login.xyz", "https://malicious.xyz"); + let siwe = SiweMessage::try_parse(&tampered).unwrap(); + let err = siwe.validate(Chain::Ethereum).unwrap_err(); + assert!(err.contains("mismatch")); + } + + #[test] + fn ignores_port_when_matching_origin() { + let message = sample_message().replacen( + "login.xyz wants you to sign in with your Ethereum account:", + "login.xyz:8080 wants you to sign in with your Ethereum account:", + 1, + ); + let siwe = SiweMessage::try_parse(&message).unwrap(); + assert!(siwe.validate(Chain::Ethereum).is_ok()); + } +} diff --git a/core/crates/gem_evm/src/slippage.rs b/core/crates/gem_evm/src/slippage.rs new file mode 100644 index 0000000000..12b2ff49c9 --- /dev/null +++ b/core/crates/gem_evm/src/slippage.rs @@ -0,0 +1,49 @@ +use alloy_primitives::U256; +use std::ops::{Div, Mul}; + +const HUNDRED_PERCENT_IN_BPS: u32 = 10000; + +pub trait BasisPointConvert: Sized + Copy { + fn from_u32(value: u32) -> Self; +} + +impl BasisPointConvert for U256 { + fn from_u32(value: u32) -> Self { + Self::from(value) + } +} + +impl BasisPointConvert for u128 { + fn from_u32(value: u32) -> Self { + value as u128 + } +} + +impl BasisPointConvert for u64 { + fn from_u32(value: u32) -> Self { + value as u64 + } +} + +pub fn apply_slippage_in_bp(amount: &T, bps: u32) -> T +where + T: BasisPointConvert + Mul + Div, +{ + let basis_points = T::from_u32(HUNDRED_PERCENT_IN_BPS); + let slippage = T::from_u32(HUNDRED_PERCENT_IN_BPS - bps.min(HUNDRED_PERCENT_IN_BPS)); + (*amount * slippage) / basis_points +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_slippage_in_bp() { + assert_eq!(apply_slippage_in_bp(&U256::from(100), 300), U256::from(97)); + assert_eq!(apply_slippage_in_bp(&100_u128, 300), 97_u128); + assert_eq!(apply_slippage_in_bp(&1000_u64, 500), 950_u64); + assert_eq!(apply_slippage_in_bp(&U256::from(1000), 0), U256::from(1000)); + assert_eq!(apply_slippage_in_bp(&U256::from(1000), HUNDRED_PERCENT_IN_BPS), U256::ZERO); + } +} diff --git a/core/crates/gem_evm/src/testkit/eip712_mock.rs b/core/crates/gem_evm/src/testkit/eip712_mock.rs new file mode 100644 index 0000000000..6cc5849499 --- /dev/null +++ b/core/crates/gem_evm/src/testkit/eip712_mock.rs @@ -0,0 +1,77 @@ +use crate::eip712::{EIP712Domain, EIP712Field, EIP712Message, EIP712TypedValue, eip712_domain_types}; + +impl EIP712Domain { + pub fn mock(chain_id: u64) -> Self { + Self { + name: Some("Test".to_string()), + version: Some("1".to_string()), + chain_id: Some(chain_id), + verifying_contract: None, + salts: None, + } + } +} + +impl EIP712Message { + pub fn mock(chain_id: u64) -> Self { + Self { + domain: EIP712Domain::mock(chain_id), + primary_type: "Message".to_string(), + message: vec![EIP712Field { + name: "content".to_string(), + value: EIP712TypedValue::String { value: "Hello".to_string() }, + }], + } + } + + pub fn to_json_string(&self) -> String { + serde_json::to_string(&serde_json::json!({ + "types": { + "EIP712Domain": eip712_domain_types(), + "Message": [ + { "name": "content", "type": "string" } + ] + }, + "primaryType": self.primary_type, + "domain": self.domain, + "message": { + "content": "Hello" + } + })) + .unwrap() + } +} + +pub fn mock_eip712_json(chain_id: u64) -> String { + EIP712Message::mock(chain_id).to_json_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_eip712_domain_mock() { + let domain = EIP712Domain::mock(1); + assert_eq!(domain.chain_id, Some(1)); + assert_eq!(domain.name.as_deref(), Some("Test")); + assert_eq!(domain.version, Some("1".to_string())); + } + + #[test] + fn test_eip712_message_mock() { + let message = EIP712Message::mock(1); + assert_eq!(message.domain.chain_id, Some(1)); + assert_eq!(message.primary_type, "Message"); + assert_eq!(message.message.len(), 1); + } + + #[test] + fn test_eip712_message_json() { + let json = mock_eip712_json(1); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(value["domain"]["chainId"], 1); + assert_eq!(value["primaryType"], "Message"); + } +} diff --git a/core/crates/gem_evm/src/testkit/mod.rs b/core/crates/gem_evm/src/testkit/mod.rs new file mode 100644 index 0000000000..75edceb5c6 --- /dev/null +++ b/core/crates/gem_evm/src/testkit/mod.rs @@ -0,0 +1,11 @@ +use primitives::asset_constants::{ETHEREUM_DAI_TOKEN_ID, ETHEREUM_USDC_TOKEN_ID}; + +pub mod eip712_mock; +pub mod siwe_mock; + +pub const TEST_ADDRESS: &str = "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"; +pub const TEST_TRANSACTION_ID: &str = "0x98dd4d9a586620f84e8066f1b015d663f9c0c94c4e0e02377840c3e6d43e2ad3"; +pub const TOKEN_USDC_ADDRESS: &str = ETHEREUM_USDC_TOKEN_ID; +pub const TOKEN_DAI_ADDRESS: &str = ETHEREUM_DAI_TOKEN_ID; +pub const TEST_SMARTCHAIN_STAKING_ADDRESS: &str = "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"; +pub const TEST_MONAD_ADDRESS: &str = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"; diff --git a/core/crates/gem_evm/src/testkit/siwe_mock.rs b/core/crates/gem_evm/src/testkit/siwe_mock.rs new file mode 100644 index 0000000000..dc260a682f --- /dev/null +++ b/core/crates/gem_evm/src/testkit/siwe_mock.rs @@ -0,0 +1,17 @@ +pub fn mock_siwe_message(domain: &str, chain_id: u32) -> String { + [ + &format!("{domain} wants you to sign in with your Ethereum account:"), + "0x9EdcF9Ff72088DB8130C2512E5B4D3b5F34cEaF4", + "", + &format!("URI: https://{domain}"), + "Version: 1", + &format!("Chain ID: {chain_id}"), + "Nonce: gmdhs9w9yfrl2kf2", + "Issued At: 2026-03-06T01:56:42.927Z", + ] + .join("\n") +} + +pub fn mock_siwe_message_hex(domain: &str, chain_id: u32) -> String { + format!("0x{}", hex::encode(mock_siwe_message(domain, chain_id))) +} diff --git a/core/crates/gem_evm/src/thorchain/contracts.rs b/core/crates/gem_evm/src/thorchain/contracts.rs new file mode 100644 index 0000000000..c1c7e30414 --- /dev/null +++ b/core/crates/gem_evm/src/thorchain/contracts.rs @@ -0,0 +1,7 @@ +use alloy_sol_types::sol; + +sol! { + interface RouterInterface { + function depositWithExpiry(address inbound_address, address token_address, uint amount, string memo, uint expiry) external; + } +} diff --git a/core/crates/gem_evm/src/thorchain/mod.rs b/core/crates/gem_evm/src/thorchain/mod.rs new file mode 100644 index 0000000000..3f152f8b75 --- /dev/null +++ b/core/crates/gem_evm/src/thorchain/mod.rs @@ -0,0 +1 @@ +pub mod contracts; diff --git a/core/crates/gem_evm/src/u256.rs b/core/crates/gem_evm/src/u256.rs new file mode 100644 index 0000000000..15eb1f2e88 --- /dev/null +++ b/core/crates/gem_evm/src/u256.rs @@ -0,0 +1,15 @@ +use alloy_primitives::U256; +use num_bigint::BigUint; + +pub fn u256_to_biguint(value: &U256) -> BigUint { + BigUint::from_bytes_be(&value.to_be_bytes::<32>()) +} + +pub fn biguint_to_u256(value: &BigUint) -> Option { + let bytes = value.to_bytes_be(); + if bytes.len() > 32 { + return None; + } + + Some(U256::from_be_slice(&bytes)) +} diff --git a/core/crates/gem_evm/src/uniswap/actions.rs b/core/crates/gem_evm/src/uniswap/actions.rs new file mode 100644 index 0000000000..2390e261e2 --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/actions.rs @@ -0,0 +1,184 @@ +use super::contracts::v4::IV4Router; +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::SolValue; + +pub const SWAP_EXACT_IN_SINGLE_ACTION: u8 = 0x06; +pub const SWAP_EXACT_IN_ACTION: u8 = 0x07; +pub const SWAP_EXACT_OUT_SINGLE_ACTION: u8 = 0x08; +pub const SWAP_EXACT_OUT_ACTION: u8 = 0x09; +pub const SETTLE_ACTION: u8 = 0x0b; +pub const SETTLE_ALL_ACTION: u8 = 0x0c; +pub const TAKE_ACTION: u8 = 0x0e; +pub const TAKE_ALL_ACTION: u8 = 0x0f; +pub const TAKE_PORTION_ACTION: u8 = 0x10; + +// https://github.com/Uniswap/v4-periphery/blob/main/src/libraries/Actions.sol +#[allow(non_camel_case_types)] +#[derive(Debug, PartialEq)] +pub enum V4Action { + SWAP_EXACT_IN_SINGLE(IV4Router::ExactInputSingleParams), + SWAP_EXACT_IN(IV4Router::ExactInputParams), + SWAP_EXACT_OUT_SINGLE(IV4Router::ExactOutputSingleParams), + SWAP_EXACT_OUT(IV4Router::ExactOutputParams), + + SETTLE { currency: Address, amount: U256, payer_is_user: bool }, + SETTLE_ALL { currency: Address, max_amount: U256 }, + TAKE { currency: Address, recipient: Address, amount: U256 }, + TAKE_ALL { currency: Address, min_amount: U256 }, + TAKE_PORTION { currency: Address, recipient: Address, bips: U256 }, +} + +pub fn encode_actions(actions: &[V4Action]) -> Vec { + let encoded_actions = actions.iter().map(|x| x.byte()).collect::>(); + let encoded_data = actions.iter().map(encode_action_data).collect::>(); + (encoded_actions, encoded_data).abi_encode_sequence() +} + +pub fn encode_action_data(action: &V4Action) -> Vec { + match action { + V4Action::SWAP_EXACT_IN_SINGLE(params) => params.abi_encode(), + V4Action::SWAP_EXACT_IN(params) => params.abi_encode(), + V4Action::SWAP_EXACT_OUT_SINGLE(params) => params.abi_encode(), + V4Action::SWAP_EXACT_OUT(params) => params.abi_encode(), + V4Action::SETTLE { currency, amount, payer_is_user } => (currency.to_owned(), amount.to_owned(), payer_is_user.to_owned()).abi_encode(), + V4Action::SETTLE_ALL { currency, max_amount } => (currency.to_owned(), max_amount.to_owned()).abi_encode(), + V4Action::TAKE { currency, recipient, amount } => (currency.to_owned(), recipient.to_owned(), amount.to_owned()).abi_encode(), + V4Action::TAKE_ALL { currency, min_amount } => (currency.to_owned(), min_amount.to_owned()).abi_encode(), + V4Action::TAKE_PORTION { currency, recipient, bips } => (currency.to_owned(), recipient.to_owned(), bips.to_owned()).abi_encode(), + } +} + +pub fn decode_action_data(data: &[u8]) -> Result, alloy_sol_types::Error> { + // The ABI encoding for a sequence of actions is (bytes opcodes, bytes[] action_data) + let (action_opcodes_bytes, action_data_bytes) = <(Bytes, Vec) as SolValue>::abi_decode_sequence(data)?; + + let action_opcodes: Vec = action_opcodes_bytes.to_vec(); + let action_data_list: Vec> = action_data_bytes.into_iter().map(|b| b.to_vec()).collect(); + + if action_opcodes.len() != action_data_list.len() { + return Err(alloy_sol_types::Error::Other("Mismatched opcodes and data lengths".into())); + } + + let mut decoded_actions = Vec::with_capacity(action_opcodes.len()); + + for (i, opcode) in action_opcodes.iter().enumerate() { + let action_data = &action_data_list[i]; + let action_data_slice = action_data.as_slice(); + let action = match *opcode { + SWAP_EXACT_IN_SINGLE_ACTION => V4Action::SWAP_EXACT_IN_SINGLE(::abi_decode(action_data_slice)?), + SWAP_EXACT_IN_ACTION => V4Action::SWAP_EXACT_IN(::abi_decode(action_data_slice)?), + SWAP_EXACT_OUT_SINGLE_ACTION => V4Action::SWAP_EXACT_OUT_SINGLE(::abi_decode(action_data_slice)?), + SWAP_EXACT_OUT_ACTION => V4Action::SWAP_EXACT_OUT(::abi_decode(action_data_slice)?), + SETTLE_ACTION => { + let (currency, amount, payer_is_user) = <(Address, U256, bool) as SolValue>::abi_decode(action_data_slice)?; + V4Action::SETTLE { currency, amount, payer_is_user } + } + SETTLE_ALL_ACTION => { + let (currency, max_amount) = <(Address, U256) as SolValue>::abi_decode(action_data_slice)?; + V4Action::SETTLE_ALL { currency, max_amount } + } + TAKE_ACTION => { + let (currency, recipient, amount) = <(Address, Address, U256) as SolValue>::abi_decode(action_data_slice)?; + V4Action::TAKE { currency, recipient, amount } + } + TAKE_ALL_ACTION => { + let (currency, min_amount) = <(Address, U256) as SolValue>::abi_decode(action_data_slice)?; + V4Action::TAKE_ALL { currency, min_amount } + } + TAKE_PORTION_ACTION => { + let (currency, recipient, bips) = <(Address, Address, U256) as SolValue>::abi_decode(action_data_slice)?; + V4Action::TAKE_PORTION { currency, recipient, bips } + } + _ => return Err(alloy_sol_types::Error::Other(format!("Unknown action opcode: {opcode}").into())), + }; + decoded_actions.push(action); + } + + Ok(decoded_actions) +} + +#[rustfmt::skip] +impl V4Action { + pub fn byte(&self) -> u8 { + match self { + Self::SWAP_EXACT_IN_SINGLE(_) => SWAP_EXACT_IN_SINGLE_ACTION, + Self::SWAP_EXACT_IN(_) => SWAP_EXACT_IN_ACTION, + Self::SWAP_EXACT_OUT_SINGLE(_) => SWAP_EXACT_OUT_SINGLE_ACTION, + Self::SWAP_EXACT_OUT(_) => SWAP_EXACT_OUT_ACTION, + + Self::SETTLE { .. } => SETTLE_ACTION, + Self::SETTLE_ALL { .. } => SETTLE_ALL_ACTION, + Self::TAKE { .. } => TAKE_ACTION, + Self::TAKE_ALL { .. } => TAKE_ALL_ACTION, + Self::TAKE_PORTION { .. } => TAKE_PORTION_ACTION, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::uniswap::{command::ADDRESS_THIS, contracts::v4::PathKey}; + use alloy_primitives::{ + Address, Bytes, U256, + aliases::{I24, U24}, + hex::encode as HexEncode, + }; + use std::str::FromStr; + + #[test] + fn test_encode_action() { + let _1inch_token = Address::from_str("0x111111111117dC0aa78b770fA6A738034120C302").unwrap(); + + let actions = vec![ + V4Action::SWAP_EXACT_IN(IV4Router::ExactInputParams { + currencyIn: Address::ZERO, + path: vec![PathKey { + intermediateCurrency: _1inch_token, + fee: U24::from(10000), + tickSpacing: I24::from_str("200").unwrap(), + hooks: Address::ZERO, + hookData: Bytes::new(), + }], + amountIn: 2000000000000000_u128, + amountOutMinimum: 0, + }), + V4Action::SETTLE { + currency: Address::ZERO, + amount: U256::from(0), + payer_is_user: true, + }, + V4Action::TAKE { + currency: _1inch_token, + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount: U256::from(0), + }, + ]; + + let encoded_data = actions.iter().map(encode_action_data).collect::>(); + + assert_eq!( + HexEncode(&encoded_data[0]), + "00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000071afd498d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000111111111117dc0aa78b770fa6a738034120c302000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000c8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!( + HexEncode(&encoded_data[1]), + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ); + assert_eq!( + HexEncode(&encoded_data[2]), + "000000000000000000000000111111111117dc0aa78b770fa6a738034120c30200000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000" + ); + + let params = encode_actions(&actions); + let expected = "000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003070b0e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000071afd498d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000111111111117dc0aa78b770fa6a738034120c302000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000c8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060000000000000000000000000111111111117dc0aa78b770fa6a738034120c30200000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000"; + + assert_eq!(params.len(), expected.len() / 2); + assert_eq!(HexEncode(¶ms), expected); + + // Test decode_action_data + let decoded_actions = decode_action_data(¶ms).unwrap(); + + assert_eq!(actions, decoded_actions, "Decoded actions do not match original actions"); + } +} diff --git a/core/crates/gem_evm/src/uniswap/command.rs b/core/crates/gem_evm/src/uniswap/command.rs new file mode 100644 index 0000000000..ef2973966b --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/command.rs @@ -0,0 +1,455 @@ +use super::{ + actions::{self, V4Action}, + contracts::IUniversalRouter, +}; +use crate::permit2::IAllowanceTransfer; +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::{SolCall, SolType, sol_data}; + +pub const MSG_SENDER: &str = "0x0000000000000000000000000000000000000001"; +pub const ADDRESS_THIS: &str = "0x0000000000000000000000000000000000000002"; + +pub const V3_SWAP_EXACT_IN_COMMAND: u8 = 0x00; +pub const V3_SWAP_EXACT_OUT_COMMAND: u8 = 0x01; +pub const PERMIT2_TRANSFER_FROM_COMMAND: u8 = 0x02; +pub const PERMIT2_PERMIT_BATCH_COMMAND: u8 = 0x03; +pub const SWEEP_COMMAND: u8 = 0x04; +pub const TRANSFER_COMMAND: u8 = 0x05; +pub const PAY_PORTION_COMMAND: u8 = 0x06; +pub const V2_SWAP_EXACT_IN_COMMAND: u8 = 0x08; +pub const V2_SWAP_EXACT_OUT_COMMAND: u8 = 0x09; +pub const PERMIT2_PERMIT_COMMAND: u8 = 0x0a; +pub const WRAP_ETH_COMMAND: u8 = 0x0b; +pub const UNWRAP_WETH_COMMAND: u8 = 0x0c; +pub const PERMIT2_TRANSFER_FROM_BATCH_COMMAND: u8 = 0x0d; +pub const V4_SWAP_COMMAND: u8 = 0x10; + +#[allow(non_camel_case_types)] +pub enum UniversalRouterCommand { + V3_SWAP_EXACT_IN(V3SwapExactIn), + V3_SWAP_EXACT_OUT(V3SwapExactOut), + PERMIT2_TRANSFER_FROM(Transfer), + PERMIT2_PERMIT_BATCH, + SWEEP(Sweep), + TRANSFER(Transfer), + PAY_PORTION(PayPortion), + V2_SWAP_EXACT_IN, + V2_SWAP_EXACT_OUT, + PERMIT2_PERMIT(Permit2Permit), + WRAP_ETH(WrapEth), + UNWRAP_WETH(UnwrapWeth), + PERMIT2_TRANSFER_FROM_BATCH, + + // V4 + V4_SWAP { actions: Vec }, +} + +impl UniversalRouterCommand { + pub fn raw_value(&self) -> u8 { + match self { + Self::V3_SWAP_EXACT_IN(_) => V3_SWAP_EXACT_IN_COMMAND, + Self::V3_SWAP_EXACT_OUT(_) => V3_SWAP_EXACT_OUT_COMMAND, + Self::PERMIT2_TRANSFER_FROM(_) => PERMIT2_TRANSFER_FROM_COMMAND, + Self::PERMIT2_PERMIT_BATCH => PERMIT2_PERMIT_BATCH_COMMAND, + Self::SWEEP(_) => SWEEP_COMMAND, + Self::TRANSFER(_) => TRANSFER_COMMAND, + Self::PAY_PORTION(_) => PAY_PORTION_COMMAND, + Self::V2_SWAP_EXACT_IN => V2_SWAP_EXACT_IN_COMMAND, + // COMMAND_PLACEHOLDER = 0x07; + Self::V2_SWAP_EXACT_OUT => V2_SWAP_EXACT_OUT_COMMAND, + Self::PERMIT2_PERMIT(_) => PERMIT2_PERMIT_COMMAND, + Self::WRAP_ETH(_) => WRAP_ETH_COMMAND, + Self::UNWRAP_WETH(_) => UNWRAP_WETH_COMMAND, + Self::PERMIT2_TRANSFER_FROM_BATCH => PERMIT2_TRANSFER_FROM_BATCH_COMMAND, + + Self::V4_SWAP { actions: _ } => V4_SWAP_COMMAND, + } + } + + pub fn encode(&self) -> Vec { + match self { + Self::V3_SWAP_EXACT_IN(payload) => payload.abi_encode(), + Self::V3_SWAP_EXACT_OUT(payload) => payload.abi_encode(), + Self::SWEEP(payload) => payload.abi_encode(), + Self::TRANSFER(payload) => payload.abi_encode(), + Self::PAY_PORTION(payload) => payload.abi_encode(), + Self::WRAP_ETH(payload) => payload.abi_encode(), + Self::UNWRAP_WETH(payload) => payload.abi_encode(), + Self::PERMIT2_PERMIT(payload) => payload.abi_encode(), + Self::PERMIT2_TRANSFER_FROM(payload) => payload.abi_encode(), + Self::V4_SWAP { actions } => actions::encode_actions(actions), + Self::PERMIT2_PERMIT_BATCH | Self::PERMIT2_TRANSFER_FROM_BATCH | Self::V2_SWAP_EXACT_IN | Self::V2_SWAP_EXACT_OUT => todo!(), + } + } +} + +type V3SwapExactType = (sol_data::Address, sol_data::Uint<256>, sol_data::Uint<256>, sol_data::Bytes, sol_data::Bool); +type SweepType = (sol_data::Address, sol_data::Address, sol_data::Uint<256>); +type PayPortionType = (sol_data::Address, sol_data::Address, sol_data::Uint<256>); +type TransferType = PayPortionType; +type WrapEthType = (sol_data::Address, sol_data::Uint<256>); +type UnwrapWethType = WrapEthType; +type Permit2PermitType = (IAllowanceTransfer::PermitSingle, sol_data::Bytes); + +#[derive(Debug, PartialEq)] +pub struct V3SwapExactIn { + pub recipient: Address, + pub amount_in: U256, + pub amount_out_min: U256, + pub path: Bytes, + pub payer_is_user: bool, +} + +impl V3SwapExactIn { + pub fn abi_encode(&self) -> Vec { + let data = (self.recipient, self.amount_in, self.amount_out_min, self.path.clone(), self.payer_is_user); + V3SwapExactType::abi_encode_sequence(&data) + } + + pub fn abi_decode(data: &[u8]) -> Result { + let (recipient, amount_in, amount_out_min, path, payer_is_user) = V3SwapExactType::abi_decode_sequence(data)?; + Ok(Self { + recipient, + amount_in, + amount_out_min, + path, + payer_is_user, + }) + } +} + +#[derive(Debug, PartialEq)] +pub struct V3SwapExactOut { + pub recipient: Address, + pub amount_out: U256, + pub amount_in_max: U256, + pub path: Bytes, + pub payer_is_user: bool, +} + +impl V3SwapExactOut { + pub fn abi_encode(&self) -> Vec { + let data = (self.recipient, self.amount_out, self.amount_in_max, self.path.clone(), self.payer_is_user); + V3SwapExactType::abi_encode_sequence(&data) + } +} + +#[derive(Debug, PartialEq)] +pub struct Sweep { + pub token: Address, + pub recipient: Address, + pub amount_min: U256, +} + +impl Sweep { + pub fn abi_encode(&self) -> Vec { + let data = (self.token, self.recipient, self.amount_min); + SweepType::abi_encode_sequence(&data) + } + + pub fn abi_decode(data: &[u8]) -> Result { + let (token, recipient, amount_min) = SweepType::abi_decode_sequence(data)?; + Ok(Self { token, recipient, amount_min }) + } +} + +#[derive(Debug, PartialEq)] +pub struct Transfer { + pub token: Address, + pub recipient: Address, + pub value: U256, +} + +impl Transfer { + pub fn abi_encode(&self) -> Vec { + let data = (self.token, self.recipient, self.value); + TransferType::abi_encode_sequence(&data) + } +} + +#[derive(Debug, PartialEq)] +pub struct PayPortion { + pub token: Address, + pub recipient: Address, + pub bips: U256, +} + +impl PayPortion { + pub fn abi_encode(&self) -> Vec { + let data = (self.token, self.recipient, self.bips); + PayPortionType::abi_encode_sequence(&data) + } +} + +#[derive(Debug, PartialEq)] +pub struct WrapEth { + pub recipient: Address, + pub amount_min: U256, +} + +impl WrapEth { + pub fn abi_encode(&self) -> Vec { + let data = (self.recipient, self.amount_min); + WrapEthType::abi_encode_sequence(&data) + } +} + +#[derive(Debug, PartialEq)] +pub struct UnwrapWeth { + pub recipient: Address, + pub amount_min: U256, +} + +impl UnwrapWeth { + pub fn abi_encode(&self) -> Vec { + let data = (self.recipient, self.amount_min); + UnwrapWethType::abi_encode_sequence(&data) + } + + pub fn abi_decode(data: &[u8]) -> Result { + let (recipient, amount_min) = UnwrapWethType::abi_decode_sequence(data)?; + Ok(Self { recipient, amount_min }) + } +} + +pub struct Permit2Permit { + pub permit_single: IAllowanceTransfer::PermitSingle, + pub signature: Bytes, +} + +impl Permit2Permit { + pub fn abi_encode(&self) -> Vec { + let data = (self.permit_single.clone(), self.signature.clone()); + Permit2PermitType::abi_encode_sequence(&data) + } +} + +pub fn encode_commands(commands: &[UniversalRouterCommand], deadline: U256) -> Vec { + let commands_bytes: Vec = commands.iter().map(|command| command.raw_value()).collect(); + let inputs: Vec = commands.iter().map(|command| Bytes::from_iter(command.encode().iter())).collect(); + let call = IUniversalRouter::executeCall { + commands: Bytes::from_iter(commands_bytes.iter()), + inputs, + deadline, + }; + call.abi_encode() +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{ + Address, Bytes, U160, U256, + aliases::U48, + hex::{decode as HexDecode, encode_prefixed as HexEncode}, + }; + use primitives::{ + asset_constants::{OPTIMISM_USDC_E_TOKEN_ID, OPTIMISM_USDC_TOKEN_ID, OPTIMISM_WETH_TOKEN_ID}, + contract_constants::OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, + }; + use std::str::FromStr; + + const OP_USDC: &str = OPTIMISM_USDC_TOKEN_ID; + const OP_AAVE: &str = "0x76fb31fb4af56892a25e32cfc43de717950c9278"; + const OP_WETH: &str = OPTIMISM_WETH_TOKEN_ID; + const OP_ROUTER: &str = OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT; + + #[test] + fn test_encode_eth_to_usdc() { + // Replicate https://optimistic.etherscan.io/tx/0xcc56d922ad307e9ffff9935f7f28f8cdb7de7e1d0e83d3c6f8520c5eeed69e41 + let amount_in = U256::from(1000000000000000u64); + // WETH / USDC 0.05% pool (5 bps) + let path = HexDecode("0x42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff85").unwrap(); + let fee_receiver = Address::from_str("0x7ffc3dbf3b2b50ff3a1d5523bc24bb5043837b14").unwrap(); + let token_usdc = Address::from_str(OP_USDC).unwrap(); + let amount_min = 2597593; // quote amount x (1 - slippage 0.5% - ref fee 0.25%) + let ref_fee_bp = 25; // 0.25% + + let commands: Vec = vec![ + UniversalRouterCommand::WRAP_ETH(WrapEth { + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount_min: amount_in, + }), + UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount_in, + amount_out_min: U256::from(0), + path: Bytes::from(path), + payer_is_user: false, + }), + UniversalRouterCommand::PAY_PORTION(PayPortion { + token: token_usdc, + recipient: fee_receiver, + bips: U256::from(ref_fee_bp), + }), + UniversalRouterCommand::SWEEP(Sweep { + token: token_usdc, + recipient: Address::from_str(MSG_SENDER).unwrap(), + amount_min: U256::from(amount_min), + }), + ]; + + let deadline = U256::from(1729227095); + let expected = "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006711e95700000000000000000000000000000000000000000000000000000000000000040b000604000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000038d7ea4c680000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002b42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000007ffc3dbf3b2b50ff3a1d5523bc24bb5043837b14000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000027a2d9"; + let encoded = encode_commands(&commands, deadline); + + assert_eq!(HexEncode(encoded), expected); + } + + #[test] + fn test_encode_usdc_to_usdt() { + // https://optimistic.etherscan.io/tx/0xe1d0cc4e6c25c836166dd50daa32c670cc690e4fd7538fe8a709bfda5ce26db8 + // 0.01% pool + let token_usdc = Address::from_str(OP_USDC).unwrap(); + let path = Bytes::from(hex::decode("0b2c639c533813f4aa9d7837caf62653d097ff8500006494b008aa00579c1307b0ef2c499ad98a8ce58e58").unwrap()); + let router = Address::from_str(OP_ROUTER).unwrap(); + let commands = vec![ + UniversalRouterCommand::PERMIT2_PERMIT(Permit2Permit { + permit_single: IAllowanceTransfer::PermitSingle { + details: IAllowanceTransfer::PermitDetails { + token: token_usdc, + amount: U160::from_str("1461501637330902918203684832716283019655932542975").unwrap(), + expiration: U48::from(1732667593), + nonce: U48::from(0), + }, + spender: router, + sigDeadline: U256::from(1730077393), + }, + signature: Bytes::from( + hex::decode("8f32d2e66506a4f424b1b23309ed75d338534d0912129a8aa3381fab4eb8032f160e0988f10f512b19a58c2a689416366c61cc0c483c3b5322dc91f8b60107671b").unwrap(), + ), + }), + UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient: Address::from_str("0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7").unwrap(), + amount_in: U256::from(6500000), + amount_out_min: U256::from(6443500), + path, + payer_is_user: true, + }), + ]; + let deadline = U256::from(1730075326139u64); + let encoded = encode_commands(&commands, deadline); + // drop last 0c byte + let expected = "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000192d08676bb00000000000000000000000000000000000000000000000000000000000000020a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000ffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000674668c90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cb1355ff08ab38bbce60111f1bb2b784be25d7e800000000000000000000000000000000000000000000000000000000671ee2d100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000418f32d2e66506a4f424b1b23309ed75d338534d0912129a8aa3381fab4eb8032f160e0988f10f512b19a58c2a689416366c61cc0c483c3b5322dc91f8b60107671b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb70000000000000000000000000000000000000000000000000000000000632ea000000000000000000000000000000000000000000000000000000000006251ec00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b0b2c639c533813f4aa9d7837caf62653d097ff8500006494b008aa00579c1307b0ef2c499ad98a8ce58e58000000000000000000000000000000000000000000"; + + assert_eq!(HexEncode(encoded), expected); + } + + #[test] + fn test_encode_usdc_to_aave() { + // https://optimistic.etherscan.io/tx/0x68ecc3014bf65dbfdd135bf1922165732bd9d5b95de797dd818d36aec279d3c8 + let token_aave = Address::from_str(OP_AAVE).unwrap(); + let commands: Vec = vec![ + UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount_in: U256::from(5064985), + amount_out_min: U256::from(0), + path: Bytes::from(HexDecode("0b2c639c533813f4aa9d7837caf62653d097ff85000bb876fb31fb4af56892a25e32cfc43de717950c9278").unwrap()), + payer_is_user: true, + }), + UniversalRouterCommand::PAY_PORTION(PayPortion { + token: token_aave, + recipient: Address::from_str("0x3d83ec320541ae96c4c91e9202643870458fb290").unwrap(), + bips: U256::from(25), + }), + UniversalRouterCommand::SWEEP(Sweep { + token: token_aave, + recipient: Address::from_str("0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7").unwrap(), + amount_min: U256::from(32964572478499319u64), + }), + ]; + + let deadline = U256::from(1730115968256u64); + let encoded = encode_commands(&commands, deadline); + // drop last 0c byte + let expected = "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000192d2f29d000000000000000000000000000000000000000000000000000000000000000003000604000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000004d4919000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b0b2c639c533813f4aa9d7837caf62653d097ff85000bb876fb31fb4af56892a25e32cfc43de717950c9278000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000076fb31fb4af56892a25e32cfc43de717950c92780000000000000000000000003d83ec320541ae96c4c91e9202643870458fb2900000000000000000000000000000000000000000000000000000000000000019000000000000000000000000000000000000000000000000000000000000006000000000000000000000000076fb31fb4af56892a25e32cfc43de717950c9278000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb700000000000000000000000000000000000000000000000000751d1aa0c0e9f7"; + + assert_eq!(HexEncode(encoded), expected); + } + + #[test] + fn test_encode_usdce_to_eth() { + // https://optimistic.etherscan.io/tx/0x4a81ba47adfb9720f792eb08cef9a4d444db7f6ff574c9adc4870188acb1cb18 + let token_usdce = Address::from_str(OPTIMISM_USDC_E_TOKEN_ID).unwrap(); + let token_weth = Address::from_str(OP_WETH).unwrap(); + let op_router = Address::from_str(OP_ROUTER).unwrap(); + let commands: Vec = vec![ + UniversalRouterCommand::PERMIT2_PERMIT(Permit2Permit { + permit_single: IAllowanceTransfer::PermitSingle { + details: IAllowanceTransfer::PermitDetails { + token: token_usdce, + amount: U160::from_str("1461501637330902918203684832716283019655932542975").unwrap(), + expiration: U48::from(1732667502), + nonce: U48::from(0u64), + }, + spender: op_router, + sigDeadline: U256::from(1730077302), + }, + signature: Bytes::from( + hex::decode("00e96ed0f5bf5cca62dc9d9753960d83c8be83224456559a1e93a66d972a019f6f328a470f8257d3950b4cb7cd0024d789b4fcd9e80c4eb43d82a38d9e5332f31b").unwrap(), + ), + }), + UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount_in: U256::from(10000000), + amount_out_min: U256::from(0), + path: Bytes::from(hex::decode("7f5c764cbc14f9669b88837ca1490cca17c316070001f44200000000000000000000000000000000000006").unwrap()), + payer_is_user: true, + }), + UniversalRouterCommand::PAY_PORTION(PayPortion { + token: token_weth, + recipient: Address::from_str("0x3d83ec320541aE96C4C91E9202643870458fB290").unwrap(), + bips: U256::from(25), + }), + UniversalRouterCommand::UNWRAP_WETH(UnwrapWeth { + recipient: Address::from_str("0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7").unwrap(), + amount_min: U256::from(3947534142938833u64), + }), + ]; + let deadline = U256::from(1730071269789u64); + let encoded = encode_commands(&commands, deadline); + // drop last 0c byte + let expected = "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000192d048919d00000000000000000000000000000000000000000000000000000000000000040a00060c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a000000000000000000000000000000000000000000000000000000000000001600000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000ffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000006746686e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cb1355ff08ab38bbce60111f1bb2b784be25d7e800000000000000000000000000000000000000000000000000000000671ee27600000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000004100e96ed0f5bf5cca62dc9d9753960d83c8be83224456559a1e93a66d972a019f6f328a470f8257d3950b4cb7cd0024d789b4fcd9e80c4eb43d82a38d9e5332f31b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b7f5c764cbc14f9669b88837ca1490cca17c316070001f44200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000042000000000000000000000000000000000000060000000000000000000000003d83ec320541ae96c4c91e9202643870458fb29000000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000040000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7000000000000000000000000000000000000000000000000000e0642ea541ed1"; + + assert_eq!(HexEncode(encoded), expected); + } + + #[test] + fn test_encode_exact_out_eth_to_usdc() { + // https://optimistic.etherscan.io/tx/0x5e23648378c8461972730a55b3110242aef350d7e188bcc1df7007050926731d + let commands: Vec = vec![ + UniversalRouterCommand::WRAP_ETH(WrapEth { + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount_min: U256::from(2024000164272186u64), + }), + UniversalRouterCommand::V3_SWAP_EXACT_OUT(V3SwapExactOut { + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount_out: U256::from(5012500), + amount_in_max: U256::from(2024000164272186u64), + path: Bytes::from(hex::decode("0b2c639c533813f4aa9d7837caf62653d097ff850001f44200000000000000000000000000000000000006").unwrap()), + payer_is_user: false, + }), + UniversalRouterCommand::TRANSFER(Transfer { + token: Address::from_str(OP_USDC).unwrap(), + recipient: Address::from_str("0x7FFC3DBF3B2b50Ff3A1D5523bc24Bb5043837B14").unwrap(), + value: U256::from(12500), + }), + UniversalRouterCommand::SWEEP(Sweep { + token: Address::from_str(OP_USDC).unwrap(), + recipient: Address::from_str("0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7").unwrap(), + amount_min: U256::from(5000000), + }), + UniversalRouterCommand::UNWRAP_WETH(UnwrapWeth { + recipient: Address::from_str("0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7").unwrap(), + amount_min: U256::from(0), + }), + ]; + + let deadline = U256::from(1730069397558u64); + let encoded = encode_commands(&commands, deadline); + // drop last 0c byte + let expected = "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000192d02c003600000000000000000000000000000000000000000000000000000000000000050b0105040c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000730d142d1183a0000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000004c7c14000000000000000000000000000000000000000000000000000730d142d1183a00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002b0b2c639c533813f4aa9d7837caf62653d097ff850001f4420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000007ffc3dbf3b2b50ff3a1d5523bc24bb5043837b1400000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb700000000000000000000000000000000000000000000000000000000004c4b400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb70000000000000000000000000000000000000000000000000000000000000000"; + + assert_eq!(HexEncode(encoded), expected); + } +} diff --git a/core/crates/gem_evm/src/uniswap/contracts/mod.rs b/core/crates/gem_evm/src/uniswap/contracts/mod.rs new file mode 100644 index 0000000000..dfa2991783 --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/contracts/mod.rs @@ -0,0 +1,17 @@ +use alloy_sol_types::sol; + +pub mod v3; +pub mod v4; + +// https://github.com/Uniswap/universal-router/blob/main/contracts/interfaces/IUniversalRouter.sol +// https://github.com/Uniswap/universal-router/blob/main/contracts/base/Dispatcher.sol +sol! { + /// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired. + /// @param commands A set of concatenated commands, each 1 byte in length + /// @param inputs An array of byte strings containing abi encoded inputs for each command + /// @param deadline The deadline by which the transaction must be executed + #[derive(Debug, PartialEq)] + interface IUniversalRouter { + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; + } +} diff --git a/core/crates/gem_evm/src/uniswap/contracts/v3.rs b/core/crates/gem_evm/src/uniswap/contracts/v3.rs new file mode 100644 index 0000000000..b609dc41d1 --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/contracts/v3.rs @@ -0,0 +1,100 @@ +use alloy_sol_types::sol; + +// https://github.com/Uniswap/v3-periphery/blob/main/contracts/interfaces/IQuoterV2.sol +sol! { + /// @title QuoterV2 Interface + /// @notice Supports quoting the calculated amounts from exact input or exact output swaps. + /// @notice For each pool also tells you the number of initialized ticks crossed and the sqrt price of the pool after the swap. + /// @dev These functions are not marked view because they rely on calling non-view functions and reverting + /// to compute the result. They are also not gas efficient and should not be called on-chain. + #[derive(Debug, PartialEq)] + interface IQuoterV2 { + struct QuoteExactInputSingleParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + struct QuoteExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint256 amount; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + /// @notice Returns the amount out received for a given exact input swap without executing the swap + /// @param path The path of the swap, i.e. each token pair and the pool fee + /// @param amountIn The amount of the first token to swap + /// @return amountOut The amount of the last token that would be received + /// @return sqrtPriceX96AfterList List of the sqrt price after the swap for each pool in the path + /// @return initializedTicksCrossedList List of the initialized ticks that the swap crossed for each pool in the path + /// @return gasEstimate The estimate of the gas that the swap consumes + function quoteExactInput(bytes memory path, uint256 amountIn) + external + returns ( + uint256 amountOut, + uint160[] memory sqrtPriceX96AfterList, + uint32[] memory initializedTicksCrossedList, + uint256 gasEstimate + ); + + /// @notice Returns the amount out received for a given exact input but for a swap of a single pool + /// @param params The params for the quote, encoded as `QuoteExactInputSingleParams` + /// tokenIn The token being swapped in + /// tokenOut The token being swapped out + /// fee The fee of the token pool to consider for the pair + /// amountIn The desired input amount + /// sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + /// @return amountOut The amount of `tokenOut` that would be received + /// @return sqrtPriceX96After The sqrt price of the pool after the swap + /// @return initializedTicksCrossed The number of initialized ticks that the swap crossed + /// @return gasEstimate The estimate of the gas that the swap consumes + function quoteExactInputSingle(QuoteExactInputSingleParams memory params) + external + returns ( + uint256 amountOut, + uint160 sqrtPriceX96After, + uint32 initializedTicksCrossed, + uint256 gasEstimate + ); + + /// @notice Returns the amount in required for a given exact output swap without executing the swap + /// @param path The path of the swap, i.e. each token pair and the pool fee. Path must be provided in reverse order + /// @param amountOut The amount of the last token to receive + /// @return amountIn The amount of first token required to be paid + /// @return sqrtPriceX96AfterList List of the sqrt price after the swap for each pool in the path + /// @return initializedTicksCrossedList List of the initialized ticks that the swap crossed for each pool in the path + /// @return gasEstimate The estimate of the gas that the swap consumes + function quoteExactOutput(bytes memory path, uint256 amountOut) + external + returns ( + uint256 amountIn, + uint160[] memory sqrtPriceX96AfterList, + uint32[] memory initializedTicksCrossedList, + uint256 gasEstimate + ); + + /// @notice Returns the amount in required to receive the given exact output amount but for a swap of a single pool + /// @param params The params for the quote, encoded as `QuoteExactOutputSingleParams` + /// tokenIn The token being swapped in + /// tokenOut The token being swapped out + /// fee The fee of the token pool to consider for the pair + /// amountOut The desired output amount + /// sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + /// @return amountIn The amount required as the input for the swap in order to receive `amountOut` + /// @return sqrtPriceX96After The sqrt price of the pool after the swap + /// @return initializedTicksCrossed The number of initialized ticks that the swap crossed + /// @return gasEstimate The estimate of the gas that the swap consumes + function quoteExactOutputSingle(QuoteExactOutputSingleParams memory params) + external + returns ( + uint256 amountIn, + uint160 sqrtPriceX96After, + uint32 initializedTicksCrossed, + uint256 gasEstimate + ); + } +} diff --git a/core/crates/gem_evm/src/uniswap/contracts/v4.rs b/core/crates/gem_evm/src/uniswap/contracts/v4.rs new file mode 100644 index 0000000000..f80e38b94a --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/contracts/v4.rs @@ -0,0 +1,132 @@ +use alloy_sol_types::sol; + +sol! { + type Currency is address; + // https://github.com/Uniswap/v4-core/blob/main/src/types/PoolKey.sol + #[derive(Debug, PartialEq)] + struct PoolKey { + /// @notice The lower currency of the pool, sorted numerically + Currency currency0; + /// @notice The higher currency of the pool, sorted numerically + Currency currency1; + /// @notice The pool LP fee, capped at 1_000_000. If the highest bit is 1, the pool has a dynamic fee and must be exactly equal to 0x800000 + uint24 fee; + /// @notice Ticks that involve positions must be a multiple of tick spacing + int24 tickSpacing; + /// @notice The hooks of the pool + address hooks; + } + + // https://github.com/Uniswap/v4-periphery/blob/main/src/libraries/PathKey.sol + #[derive(Debug, PartialEq)] + struct PathKey { + Currency intermediateCurrency; + uint24 fee; + int24 tickSpacing; + address hooks; + bytes hookData; + } + + // https://github.com/Uniswap/v4-periphery/blob/main/src/interfaces/IV4Quoter.sol + #[derive(Debug)] + interface IV4Quoter { + #[derive(PartialEq)] + struct QuoteExactSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 exactAmount; + bytes hookData; + } + + #[derive(PartialEq)] + struct QuoteExactParams { + Currency exactCurrency; + PathKey[] path; + uint128 exactAmount; + } + + /// @notice Returns the delta amounts for a given exact input swap of a single pool + /// @param params The params for the quote, encoded as `QuoteExactSingleParams` + /// poolKey The key for identifying a V4 pool + /// zeroForOne If the swap is from currency0 to currency1 + /// exactAmount The desired input amount + /// hookData arbitrary hookData to pass into the associated hooks + /// @return amountOut The output quote for the exactIn swap + /// @return gasEstimate Estimated gas units used for the swap + function quoteExactInputSingle(QuoteExactSingleParams memory params) + external + returns (uint256 amountOut, uint256 gasEstimate); + + /// @notice Returns the delta amounts along the swap path for a given exact input swap + /// @param params the params for the quote, encoded as 'QuoteExactParams' + /// currencyIn The input currency of the swap + /// path The path of the swap encoded as PathKeys that contains currency, fee, tickSpacing, and hook info + /// exactAmount The desired input amount + /// @return amountOut The output quote for the exactIn swap + /// @return gasEstimate Estimated gas units used for the swap + function quoteExactInput(QuoteExactParams memory params) + external + returns (uint256 amountOut, uint256 gasEstimate); + + /// @notice Returns the delta amounts for a given exact output swap of a single pool + /// @param params The params for the quote, encoded as `QuoteExactSingleParams` + /// poolKey The key for identifying a V4 pool + /// zeroForOne If the swap is from currency0 to currency1 + /// exactAmount The desired output amount + /// hookData arbitrary hookData to pass into the associated hooks + /// @return amountIn The input quote for the exactOut swap + /// @return gasEstimate Estimated gas units used for the swap + function quoteExactOutputSingle(QuoteExactSingleParams memory params) + external + returns (uint256 amountIn, uint256 gasEstimate); + + /// @notice Returns the delta amounts along the swap path for a given exact output swap + /// @param params the params for the quote, encoded as 'QuoteExactParams' + /// currencyOut The output currency of the swap + /// path The path of the swap encoded as PathKeys that contains currency, fee, tickSpacing, and hook info + /// exactAmount The desired output amount + /// @return amountIn The input quote for the exactOut swap + /// @return gasEstimate Estimated gas units used for the swap + function quoteExactOutput(QuoteExactParams memory params) + external + returns (uint256 amountIn, uint256 gasEstimate); + } + + // https://github.com/Uniswap/v4-periphery/blob/main/src/interfaces/IV4Router.sol + #[derive(Debug)] + interface IV4Router { + #[derive(PartialEq)] + struct ExactInputSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 amountIn; + uint128 amountOutMinimum; + bytes hookData; + } + + #[derive(PartialEq)] + struct ExactInputParams { + Currency currencyIn; + PathKey[] path; + uint128 amountIn; + uint128 amountOutMinimum; + } + + #[derive(PartialEq)] + struct ExactOutputSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 amountOut; + uint128 amountInMaximum; + bytes hookData; + } + + #[derive(PartialEq)] + struct ExactOutputParams { + Currency currencyOut; + PathKey[] path; + uint128 amountOut; + uint128 amountInMaximum; + } + } +} diff --git a/core/crates/gem_evm/src/uniswap/deployment/mod.rs b/core/crates/gem_evm/src/uniswap/deployment/mod.rs new file mode 100644 index 0000000000..5a7f95fef0 --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/deployment/mod.rs @@ -0,0 +1,67 @@ +pub mod v3; +pub mod v4; + +use primitives::contract_constants::{UNISWAP_PERMIT2_CONTRACT, ZKSYNC_UNISWAP_PERMIT2_CONTRACT}; +use primitives::{Chain, SwapProvider}; + +pub trait Deployment { + fn quoter(&self) -> &'static str; + fn permit2(&self) -> &'static str; + fn universal_router(&self) -> &'static str; +} + +pub fn get_uniswap_permit2_by_chain(chain: &Chain) -> Option<&'static str> { + match chain { + Chain::Ethereum + | Chain::Optimism + | Chain::Arbitrum + | Chain::Polygon + | Chain::AvalancheC + | Chain::Base + | Chain::SmartChain + | Chain::Celo + | Chain::Blast + | Chain::World + | Chain::Unichain + | Chain::Ink + | Chain::Monad + | Chain::Stable => Some(UNISWAP_PERMIT2_CONTRACT), + Chain::ZkSync | Chain::Abstract => Some(ZKSYNC_UNISWAP_PERMIT2_CONTRACT), + _ => None, + } +} + +pub fn get_provider_by_chain_contract(chain: &Chain, contract: &str) -> Option { + let contract = contract.to_lowercase(); + if let Some(deployment) = v3::get_uniswap_router_deployment_by_chain(chain) + && deployment.universal_router.to_lowercase() == contract + { + return Some(SwapProvider::UniswapV3.id().to_string()); + } + if let Some(deployment) = v4::get_uniswap_deployment_by_chain(chain) + && deployment.universal_router.to_lowercase() == contract + { + return Some(SwapProvider::UniswapV4.id().to_string()); + } + if let Some(deployment) = v3::get_pancakeswap_router_deployment_by_chain(chain) + && deployment.universal_router.to_lowercase() == contract + { + return Some(SwapProvider::PancakeswapV3.id().to_string()); + } + if let Some(deployment) = v3::get_oku_deployment_by_chain(chain) + && deployment.universal_router.to_lowercase() == contract + { + return Some(SwapProvider::Oku.id().to_string()); + } + if let Some(deployment) = v3::get_wagmi_router_deployment_by_chain(chain) + && deployment.universal_router.to_lowercase() == contract + { + return Some(SwapProvider::Wagmi.id().to_string()); + } + if let Some(deployment) = v3::get_aerodrome_router_deployment_by_chain(chain) + && deployment.universal_router.to_lowercase() == contract + { + return Some(SwapProvider::Aerodrome.id().to_string()); + } + None +} diff --git a/core/crates/gem_evm/src/uniswap/deployment/v3.rs b/core/crates/gem_evm/src/uniswap/deployment/v3.rs new file mode 100644 index 0000000000..005058f55c --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/deployment/v3.rs @@ -0,0 +1,199 @@ +use super::{Deployment, get_uniswap_permit2_by_chain}; +use primitives::{ + Chain, + contract_constants::{ + BASE_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, + UNICHAIN_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, UNISWAP_PERMIT2_CONTRACT, + }, +}; + +pub struct V3Deployment { + pub quoter_v2: &'static str, + pub permit2: &'static str, + pub universal_router: &'static str, +} + +impl Deployment for V3Deployment { + fn quoter(&self) -> &'static str { + self.quoter_v2 + } + + fn permit2(&self) -> &'static str { + self.permit2 + } + + fn universal_router(&self) -> &'static str { + self.universal_router + } +} + +pub fn get_uniswap_router_deployment_by_chain(chain: &Chain) -> Option { + //https://docs.uniswap.org/contracts/v3/reference/deployments/ + let permit2 = get_uniswap_permit2_by_chain(chain)?; + match chain { + Chain::Ethereum => Some(V3Deployment { + quoter_v2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", + permit2, + universal_router: ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, + }), + Chain::Optimism => Some(V3Deployment { + quoter_v2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", + permit2, + universal_router: OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, + }), + Chain::Arbitrum => Some(V3Deployment { + quoter_v2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", + permit2, + universal_router: "0x5E325eDA8064b456f4781070C0738d849c824258", + }), + Chain::Polygon => Some(V3Deployment { + quoter_v2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", + permit2, + universal_router: "0xec7BE89e9d109e7e3Fec59c222CF297125FEFda2", + }), + Chain::AvalancheC => Some(V3Deployment { + quoter_v2: "0xbe0F5544EC67e9B3b2D979aaA43f18Fd87E6257F", + permit2, + universal_router: "0x4Dae2f939ACf50408e13d58534Ff8c2776d45265", + }), + Chain::Base => Some(V3Deployment { + quoter_v2: "0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a", + permit2, + universal_router: BASE_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, + }), + Chain::SmartChain => Some(V3Deployment { + quoter_v2: "0x78D78E420Da98ad378D7799bE8f4AF69033EB077", + permit2, + universal_router: "0x4Dae2f939ACf50408e13d58534Ff8c2776d45265", + }), + Chain::ZkSync => Some(V3Deployment { + quoter_v2: "0x8Cb537fc92E26d8EBBb760E632c95484b6Ea3e28", + permit2, + universal_router: "0x28731BCC616B5f51dD52CF2e4dF0E78dD1136C06", + }), + Chain::Celo => Some(V3Deployment { + quoter_v2: "0x82825d0554fA07f7FC52Ab63c961F330fdEFa8E8", + permit2, + universal_router: "0x643770E279d5D0733F21d6DC03A8efbABf3255B4", + }), + Chain::Blast => Some(V3Deployment { + quoter_v2: "0x6Cdcd65e03c1CEc3730AeeCd45bc140D57A25C77", + permit2, + universal_router: "0x643770E279d5D0733F21d6DC03A8efbABf3255B4", + }), + Chain::World => Some(V3Deployment { + quoter_v2: "0x10158D43e6cc414deE1Bd1eB0EfC6a5cBCfF244c", + permit2, + universal_router: "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", + }), + + Chain::Unichain => Some(V3Deployment { + quoter_v2: "0x385A5cf5F83e99f7BB2852b6A19C3538b9FA7658", + permit2, + universal_router: UNICHAIN_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, + }), + // See: https://github.com/Uniswap/contracts/blob/main/deployments/143.md + Chain::Monad => Some(V3Deployment { + quoter_v2: "0x661E93cca42AfacB172121EF892830cA3b70F08d", + permit2, + universal_router: "0x0D97Dc33264bfC1c226207428A79b26757fb9dc3", + }), + // See: https://swap.stable.xyz/deployments + Chain::Stable => Some(V3Deployment { + quoter_v2: "0xb070179E7032CdA868b53e6C1742F80c9e940d1A", + permit2, + universal_router: "0x5Be52b52f3d1dbC324d2959637471a4208626144", + }), + _ => None, + } +} + +pub fn get_pancakeswap_router_deployment_by_chain(chain: &Chain) -> Option { + // https://developer.pancakeswap.finance/contracts/universal-router/addresses + // https://docs.pancakeswap.finance/developers/smart-contracts/pancakeswap-exchange/v3-contracts#address + match chain { + Chain::SmartChain => Some(V3Deployment { + quoter_v2: "0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997", + universal_router: "0x1A0A18AC4BECDDbd6389559687d1A73d8927E416", + permit2: "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", + }), + Chain::OpBNB => Some(V3Deployment { + quoter_v2: "0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997", + universal_router: "0xB89a6778D1efE7a5b7096757A21b810CC2886fa1", + permit2: "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", + }), + Chain::Arbitrum | Chain::Linea | Chain::Base => Some(V3Deployment { + quoter_v2: "0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997", + universal_router: "0xFE6508f0015C778Bdcc1fB5465bA5ebE224C9912", + permit2: "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", + }), + Chain::ZkSync => Some(V3Deployment { + quoter_v2: "0x3d146FcE6c1006857750cBe8aF44f76a28041CCc", + universal_router: "0xdAee41E335322C85ff2c5a6745c98e1351806e98", + permit2: "0x686FD50007EaA636F01154d660b96110B6bFe351", + }), + Chain::Monad => Some(V3Deployment { + quoter_v2: "0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997", + universal_router: "0x23682a588CF2601ACa977dF200938634c9F7d552", + permit2: "0xDca6Dd86A5E305dB99A15eaEB2a6ecfc7F579778", + }), + _ => None, + } +} + +pub fn get_oku_deployment_by_chain(chain: &Chain) -> Option { + // https://docs.oku.trade/home/extra-information/deployed-contracts + match chain { + Chain::Sonic => Some(V3Deployment { + quoter_v2: "0x5911cB3633e764939edc2d92b7e1ad375Bb57649", + universal_router: "0x738fD6d10bCc05c230388B4027CAd37f82fe2AF2", + permit2: "0xB952578f3520EE8Ea45b7914994dcf4702cEe578", + }), + Chain::Mantle => Some(V3Deployment { + quoter_v2: "0xdD489C75be1039ec7d843A6aC2Fd658350B067Cf", + universal_router: "0x447B8E40B0CdA8e55F405C86bC635D02d0540aB8", + permit2: "0x5d6b0f5335ec95cD2aB7E52f2A0750dd86502435", + }), + Chain::Gnosis => Some(V3Deployment { + quoter_v2: "0x7E9cB3499A6cee3baBe5c8a3D328EA7FD36578f4", + universal_router: "0x75FC67473A91335B5b8F8821277262a13B38c9b3", + permit2: UNISWAP_PERMIT2_CONTRACT, + }), + Chain::Plasma => Some(V3Deployment { + quoter_v2: "0xaa52bB8110fE38D0d2d2AF0B85C3A3eE622CA455", + universal_router: "0x1b35fbA9357fD9bda7ed0429C8BbAbe1e8CC88fc", + permit2: UNISWAP_PERMIT2_CONTRACT, + }), + Chain::SeiEvm => Some(V3Deployment { + quoter_v2: "0x807F4E281B7A3B324825C64ca53c69F0b418dE40", + universal_router: "0xa683c66045ad16abb1bCE5ad46A64d95f9A25785", + permit2: "0xB952578f3520EE8Ea45b7914994dcf4702cEe578", + }), + _ => None, + } +} + +pub fn get_wagmi_router_deployment_by_chain(chain: &Chain) -> Option { + // https://docs.wagmi.com/wagmi/contracts#sonic + match chain { + Chain::Sonic => Some(V3Deployment { + quoter_v2: "0xDb51CffFf3B989d0cB6b58AbF173371b6F2d0D24", + universal_router: "0xC81dAe2Cdf2f6C0076aE3E174a54985040626D19", + permit2: "0x7Ac9E324c2a211a389fac64b773433A17dB22948", + }), + _ => None, + } +} + +pub fn get_aerodrome_router_deployment_by_chain(chain: &Chain) -> Option { + // https://aerodrome.finance/security + let permit2 = get_uniswap_permit2_by_chain(chain)?; + match chain { + Chain::Base => Some(V3Deployment { + quoter_v2: "0x254cF9E1E6e233aa1AC962CB9B05b2cfeAaE15b0", + universal_router: "0x6Cb442acF35158D5eDa88fe602221b67B400Be3E", + permit2, + }), + _ => None, + } +} diff --git a/core/crates/gem_evm/src/uniswap/deployment/v4.rs b/core/crates/gem_evm/src/uniswap/deployment/v4.rs new file mode 100644 index 0000000000..5cc5dd2e57 --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/deployment/v4.rs @@ -0,0 +1,98 @@ +use super::{Deployment, get_uniswap_permit2_by_chain}; +use primitives::{ + Chain, + contract_constants::{OPTIMISM_UNISWAP_V4_QUOTER_CONTRACT, UNICHAIN_UNISWAP_V4_QUOTER_CONTRACT, UNICHAIN_UNISWAP_V4_UNIVERSAL_ROUTER_CONTRACT}, +}; + +pub struct V4Deployment { + pub quoter: &'static str, // V4 Quoter + pub permit2: &'static str, + pub universal_router: &'static str, +} + +impl Deployment for V4Deployment { + fn quoter(&self) -> &'static str { + self.quoter + } + + fn permit2(&self) -> &'static str { + self.permit2 + } + + fn universal_router(&self) -> &'static str { + self.universal_router + } +} + +pub fn get_uniswap_deployment_by_chain(chain: &Chain) -> Option { + // https://github.com/Uniswap/contracts/blob/main/deployments/index.md + let permit2 = get_uniswap_permit2_by_chain(chain)?; + match chain { + Chain::Ethereum => Some(V4Deployment { + quoter: "0x52f0e24d1c21c8a0cb1e5a5dd6198556bd9e1203", + permit2, + universal_router: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af", + }), + Chain::Optimism => Some(V4Deployment { + quoter: OPTIMISM_UNISWAP_V4_QUOTER_CONTRACT, + permit2, + universal_router: "0x851116d9223fabed8e56c0e6b8ad0c31d98b3507", + }), + Chain::Arbitrum => Some(V4Deployment { + quoter: "0x3972c00f7ed4885e145823eb7c655375d275a1c5", + permit2, + universal_router: "0xa51afafe0263b40edaef0df8781ea9aa03e381a3", + }), + Chain::Polygon => Some(V4Deployment { + quoter: "0xb3d5c3dfc3a7aebff71895a7191796bffc2c81b9", + permit2, + universal_router: "0x1095692a6237d83c6a72f3f5efedb9a670c49223", + }), + Chain::AvalancheC => Some(V4Deployment { + quoter: "0xbe40675bb704506a3c2ccfb762dcfd1e979845c2", + permit2, + universal_router: "0x94b75331ae8d42c1b61065089b7d48fe14aa73b7", + }), + Chain::Base => Some(V4Deployment { + quoter: "0x0d5e0f971ed27fbff6c2837bf31316121532048d", + permit2, + universal_router: "0x6ff5693b99212da76ad316178a184ab56d299b43", + }), + Chain::SmartChain => Some(V4Deployment { + quoter: "0x9f75dd27d6664c475b90e105573e550ff69437b0", + permit2, + universal_router: "0x1906c1d672b88cd1b9ac7593301ca990f94eae07", + }), + Chain::Blast => Some(V4Deployment { + quoter: "0x6f71cdcb0d119ff72c6eb501abceb576fbf62bcf", + permit2, + universal_router: "0xeabbcb3e8e415306207ef514f660a3f820025be3", + }), + Chain::World => Some(V4Deployment { + quoter: "0x55d235b3ff2daf7c3ede0defc9521f1d6fe6c5c0", + permit2, + universal_router: "0x8ac7bee993bb44dab564ea4bc9ea67bf9eb5e743", + }), + Chain::Unichain => Some(V4Deployment { + quoter: UNICHAIN_UNISWAP_V4_QUOTER_CONTRACT, + permit2, + universal_router: UNICHAIN_UNISWAP_V4_UNIVERSAL_ROUTER_CONTRACT, + }), + Chain::Celo => Some(V4Deployment { + quoter: "0x28566da1093609182dFf2cB2A91CFD72e61d66cd", + permit2, + universal_router: "0xcb695bc5D3Aa22cAD1E6DF07801b061a05A0233A", + }), + Chain::Monad => Some(V4Deployment { + quoter: "0xa222Dd357A9076d1091Ed6Aa2e16C9742dD26891", + permit2, + universal_router: "0x0D97Dc33264bfC1c226207428A79b26757fb9dc3", + }), + Chain::Ink => Some(V4Deployment { + quoter: "0x3972C00f7ed4885e145823eb7C655375d275A1C5", + permit2, + universal_router: "0x112908daC86e20e7241B0927479Ea3Bf935d1fa0", + }), + _ => None, + } +} diff --git a/core/crates/gem_evm/src/uniswap/mod.rs b/core/crates/gem_evm/src/uniswap/mod.rs new file mode 100644 index 0000000000..498f03e7b7 --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/mod.rs @@ -0,0 +1,64 @@ +use alloy_primitives::aliases::{I24, U24}; + +pub mod actions; +pub mod command; +pub mod contracts; +pub mod deployment; +pub mod path; + +// hundredths of bps (e.g. 0.3% is 3000) +#[derive(Debug, Copy, Clone, PartialEq)] +#[repr(u32)] +pub enum FeeTier { + Hundred = 100, + FourHundred = 400, + FiveHundred = 500, + ThousandFiveHundred = 1500, + TwoThousandFiveHundred = 2500, + ThreeThousand = 3000, + TenThousand = 10000, +} + +impl FeeTier { + pub fn as_u24(&self) -> U24 { + let fee_bytes = (*self as u32).to_le_bytes(); + U24::from_le_bytes([fee_bytes[0], fee_bytes[1], fee_bytes[2]]) + } + + pub fn default_tick_spacing(&self) -> I24 { + match self { + FeeTier::Hundred => I24::unchecked_from(1), + FeeTier::FourHundred => I24::unchecked_from(10), + FeeTier::FiveHundred => I24::unchecked_from(10), + FeeTier::ThousandFiveHundred => I24::unchecked_from(50), + FeeTier::TwoThousandFiveHundred => I24::unchecked_from(50), + FeeTier::ThreeThousand => I24::unchecked_from(60), + FeeTier::TenThousand => I24::unchecked_from(200), + } + } +} + +impl TryFrom<&str> for FeeTier { + type Error = Box; + + fn try_from(value: &str) -> Result { + let u32_value = value.parse::()?; + Self::try_from(u32_value) + } +} + +impl TryFrom for FeeTier { + type Error = Box; + + fn try_from(value: u32) -> Result { + match value { + 100 => Ok(FeeTier::Hundred), + 500 => Ok(FeeTier::FiveHundred), + 1500 => Ok(FeeTier::ThousandFiveHundred), + 2500 => Ok(FeeTier::TwoThousandFiveHundred), + 3000 => Ok(FeeTier::ThreeThousand), + 10000 => Ok(FeeTier::TenThousand), + _ => Err(format!("Invalid fee tier: {}", value).into()), + } + } +} diff --git a/core/crates/gem_evm/src/uniswap/path.rs b/core/crates/gem_evm/src/uniswap/path.rs new file mode 100644 index 0000000000..1d221d20f7 --- /dev/null +++ b/core/crates/gem_evm/src/uniswap/path.rs @@ -0,0 +1,281 @@ +use alloy_primitives::{Address, Bytes, aliases::U24}; +use std::fmt::Display; + +use super::FeeTier; +use primitives::{EVMChain, asset_constants::*}; + +#[derive(Debug, Clone, PartialEq)] +pub struct TokenPair { + pub token_in: Address, + pub token_out: Address, + pub fee_tier: FeeTier, +} + +impl Display for TokenPair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}-{}->{}", self.token_in, self.fee_tier as u32, self.token_out) + } +} + +#[derive(Debug, Clone)] +pub struct TokenPairs(pub Vec); + +impl Display for TokenPairs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[")?; + let mut iter = self.0.iter(); + if let Some(first) = iter.next() { + write!(f, "{first}")?; // Write first element without a leading comma + for item in iter { + write!(f, ", {item}")?; // Write subsequent elements with a leading comma + } + } + write!(f, "]") + } +} + +impl TokenPair { + pub fn new_two_hop(token_in: &Address, intermediary: &Address, token_out: &Address, fee_tier: FeeTier) -> Vec { + vec![ + TokenPair { + token_in: *token_in, + token_out: *intermediary, + fee_tier, + }, + TokenPair { + token_in: *intermediary, + token_out: *token_out, + fee_tier, + }, + ] + } +} + +#[derive(Debug)] +pub struct BasePair { + pub native: Address, + pub stables: Vec
, + pub alternatives: Vec
, +} + +impl BasePair { + pub fn path_building_array(&self) -> Vec
{ + let mut array = vec![self.native]; + array.extend(self.stables.iter().cloned()); + // alternatives is not used for path building to reduce requests + array + } + + pub fn fee_token_array(&self) -> Vec
{ + let mut array = vec![self.native]; + array.extend(self.stables.iter().cloned()); + array.extend(self.alternatives.iter().cloned()); + array + } +} + +pub fn get_base_pair(chain: &EVMChain, weth_as_native: bool) -> Option { + let native = if weth_as_native { chain.weth_contract()?.parse().ok()? } else { Address::ZERO }; + + let btc: &str = match chain { + EVMChain::Ethereum => ETHEREUM_WBTC_TOKEN_ID, + EVMChain::Polygon => POLYGON_WBTC_TOKEN_ID, + EVMChain::Arbitrum => ARBITRUM_WBTC_TOKEN_ID, + EVMChain::Optimism => OPTIMISM_WBTC_TOKEN_ID, + EVMChain::Base => BASE_WBTC_TOKEN_ID, + EVMChain::AvalancheC => "0x408d4cd0adb7cebd1f1a1c33a0ba2098e1295bab", + EVMChain::Celo => "0xd71ffd0940c920786ec4dbb5a12306669b5b81ef", + EVMChain::SmartChain => "0x7130d2a12b9bcbfae4f2634d864a1ee1ce3ead9c", // BTCB + EVMChain::OpBNB => "0x7c6b91d9be155a6db01f749217d76ff02a7227f2", // BTCB + EVMChain::ZkSync => ZKSYNC_WBTC_TOKEN_ID, + EVMChain::Blast => BLAST_WBTC_TOKEN_ID, + EVMChain::World => WORLD_WBTC_TOKEN_ID, + EVMChain::Sonic => SONIC_WBTC_TOKEN_ID, + EVMChain::Linea => LINEA_WBTC_TOKEN_ID, + EVMChain::SeiEvm => "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", + _ => "", // None + }; + + let usdc = match chain { + EVMChain::Ethereum => ETHEREUM_USDC_TOKEN_ID, + EVMChain::Polygon => POLYGON_USDC_TOKEN_ID, + EVMChain::Arbitrum => ARBITRUM_USDC_TOKEN_ID, + EVMChain::Optimism => OPTIMISM_USDC_TOKEN_ID, + EVMChain::Base => BASE_USDC_TOKEN_ID, + EVMChain::AvalancheC => AVALANCHE_USDC_TOKEN_ID, + EVMChain::Celo => "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", + EVMChain::SmartChain => SMARTCHAIN_USDC_TOKEN_ID, + EVMChain::ZkSync => ZKSYNC_USDC_E_TOKEN_ID, + EVMChain::Blast => "0x4300000000000000000000000000000000000003", // USDB + EVMChain::World => WORLD_USDC_E_TOKEN_ID, + EVMChain::Abstract => "0x84A71ccD554Cc1b02749b35d22F684CC8ec987e1", // USDC.e + EVMChain::Unichain => UNICHAIN_USDC_TOKEN_ID, + EVMChain::Sonic => "0x29219dd400f2bf60e5a23d13be72b486d4038894", // USDC.e + EVMChain::Mantle => "0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9", + EVMChain::Gnosis => GNOSIS_USDC_TOKEN_ID, + EVMChain::Manta => "0xb73603c5d87fa094b7314c74ace2e64d165016fb", + EVMChain::Linea => LINEA_USDC_E_TOKEN_ID, + EVMChain::Ink => "0xF1815bd50389c46847f0Bda824eC8da914045D14", + EVMChain::Monad => MONAD_USDC_TOKEN_ID, + EVMChain::SeiEvm => SEIEVM_USDC_TOKEN_ID, + EVMChain::OpBNB | EVMChain::Plasma => "", + EVMChain::Stable => "0x8a2b28364102bea189d99a475c494330ef2bdd0b", // USDC.e (Stargate) + _ => panic!("USDC is not configured for this chain"), + }; + + let usdt: &str = match chain { + EVMChain::Ethereum => ETHEREUM_USDT_TOKEN_ID, + EVMChain::Polygon => POLYGON_USDT_TOKEN_ID, + EVMChain::Arbitrum => ARBITRUM_USDT_TOKEN_ID, + EVMChain::Optimism => OPTIMISM_USDT_TOKEN_ID, + EVMChain::Base => "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2", + EVMChain::AvalancheC => AVALANCHE_USDT_TOKEN_ID, + EVMChain::Celo => CELO_USDT_TOKEN_ID, + EVMChain::SmartChain => SMARTCHAIN_USDT_TOKEN_ID, + EVMChain::ZkSync => ZKSYNC_USDT_TOKEN_ID, + EVMChain::Abstract => "0x0709F39376dEEe2A2dfC94A58EdEb2Eb9DF012bD", + EVMChain::Unichain => "0x9151434b16b9763660705744891fA906F660EcC5", // USDT0 + EVMChain::Sonic => "0x6047828dc181963ba44974801FF68e538dA5eaF9", + EVMChain::Mantle => "0x201EBa5CC46D216Ce6DC03F6a759e8E766e956aE", + EVMChain::Gnosis => GNOSIS_USDT_TOKEN_ID, + EVMChain::Manta => "0xf417f5a458ec102b90352f697d6e2ac3a3d2851f", + EVMChain::Linea => LINEA_USDT_TOKEN_ID, + EVMChain::OpBNB => "0x9e5AAC1Ba1a2e6aEd6b32689DFcF62A509Ca96f3", + EVMChain::Ink => INK_USDT_TOKEN_ID, + EVMChain::Plasma => PLASMA_USDT_TOKEN_ID, + EVMChain::Monad => MONAD_USDT_TOKEN_ID, + EVMChain::SeiEvm => SEIEVM_USDT_TOKEN_ID, // USDT0 + EVMChain::Stable => "0x779Ded0c9e1022225f8E0630b35a9b54bE713736", // USDT0 + EVMChain::Blast | EVMChain::World => "", // None + _ => panic!("USDT is not configured for this chain"), + }; + + let mut stables = vec![]; + if !usdc.is_empty() { + stables.push(usdc.parse().ok()?); + } + if !usdt.is_empty() { + stables.push(usdt.parse().ok()?); + } + let alternatives = { if btc.is_empty() { vec![] } else { vec![btc.parse().ok()?] } }; + + Some(BasePair { native, stables, alternatives }) +} + +pub fn build_direct_pair(token_in: &Address, token_out: &Address, fee_tier: FeeTier) -> Bytes { + let mut bytes: Vec = vec![]; + let fee = U24::from(fee_tier.as_u24()); + bytes.extend(token_in.as_slice()); + bytes.extend(&fee.to_be_bytes_vec()); + bytes.extend(token_out.as_slice()); + Bytes::from(bytes) +} + +pub fn validate_pairs(token_pairs: &[TokenPair]) -> bool { + // verify token in and out are chained + let mut iter = token_pairs.iter().peekable(); + let mut valid = true; + while let Some(current_pair) = iter.next() { + if let Some(next_pair) = iter.peek() + && current_pair.token_out != next_pair.token_in + { + valid = false; + break; + } + } + valid +} + +pub fn build_pairs(token_pairs: &[TokenPair]) -> Bytes { + let valid = validate_pairs(token_pairs); + if !valid { + panic!("invalid token pairs"); + } + + let mut bytes: Vec = vec![]; + for (idx, token_pair) in token_pairs.iter().enumerate() { + let fee = U24::from(token_pair.fee_tier.as_u24()); + if idx == 0 { + bytes.extend(token_pair.token_in.as_slice()); + } + bytes.extend(&fee.to_be_bytes_vec()); + bytes.extend(token_pair.token_out.as_slice()); + } + Bytes::from(bytes) +} + +pub fn decode_path(path: &Bytes) -> Option { + // Minimum path: token_in | fee | token_out. Length = 20 + 3 + 20 = 43 bytes. + if path.len() < 43 { + return None; + } + + let token_in = Address::from_slice(&path[0..20]); + + // Fee is a uint24, stored in 3 bytes. + let fee_value = u32::from_be_bytes([0, path[20], path[21], path[22]]); + let fee_tier = FeeTier::try_from(fee_value).ok()?; + + let token_out_offset = path.len() - 20; + let token_out = Address::from_slice(&path[token_out_offset..path.len()]); + + Some(TokenPair { token_in, token_out, fee_tier }) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Address, address, hex::encode_prefixed as HexEncode}; + use primitives::asset_constants::OPTIMISM_WETH_TOKEN_ID; + + #[test] + fn test_build_path() { + // Optimism WETH + let token0: Address = OPTIMISM_WETH_TOKEN_ID.parse().unwrap(); + // USDC + let token1: Address = OPTIMISM_USDC_TOKEN_ID.parse().unwrap(); + let bytes = build_direct_pair(&token0, &token1, FeeTier::FiveHundred); + + assert_eq!( + HexEncode(&bytes), + "0x42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff85" + ); + + let pair = decode_path(&bytes).unwrap(); + assert_eq!( + pair, + TokenPair { + token_in: token0, + token_out: token1, + fee_tier: FeeTier::FiveHundred + } + ); + } + + #[test] + fn test_two_hop_path() { + // UNI + let token0 = address!("0x6fd9d7AD17242c41f7131d257212c54A0e816691"); + // WETH + let token1: Address = OPTIMISM_WETH_TOKEN_ID.parse().unwrap(); + // LINK + let token2 = address!("0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6"); + let token_pairs = TokenPair::new_two_hop(&token0, &token1, &token2, FeeTier::ThreeThousand); + let bytes = build_pairs(&token_pairs); + + assert_eq!( + HexEncode(&bytes), + "0x6fd9d7ad17242c41f7131d257212c54a0e816691000bb84200000000000000000000000000000000000006000bb8350a791bfc2c21f9ed5d10980dad2e2638ffa7f6" + ); + + let pair = decode_path(&bytes).unwrap(); + assert_eq!( + pair, + TokenPair { + token_in: token0, + token_out: token2, + fee_tier: FeeTier::ThreeThousand + } + ); + } +} diff --git a/core/crates/gem_evm/src/weth.rs b/core/crates/gem_evm/src/weth.rs new file mode 100644 index 0000000000..93c6290a62 --- /dev/null +++ b/core/crates/gem_evm/src/weth.rs @@ -0,0 +1,8 @@ +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq)] + interface WETH9 { + function withdraw(uint wad) public; + } +} diff --git a/core/crates/gem_evm/testdata/1inch_permit.json b/core/crates/gem_evm/testdata/1inch_permit.json new file mode 100644 index 0000000000..4b011a0896 --- /dev/null +++ b/core/crates/gem_evm/testdata/1inch_permit.json @@ -0,0 +1,31 @@ +{ + "types": { + "Permit": [ + { "name": "owner", "type": "address" }, + { "name": "spender", "type": "address" }, + { "name": "value", "type": "uint256" }, + { "name": "nonce", "type": "uint256" }, + { "name": "deadline", "type": "uint256" } + ], + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ] + }, + "domain": { + "name": "USD Coin", + "version": "2", + "chainId": "0x1", + "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }, + "primaryType": "Permit", + "message": { + "owner": "0x951454cad517fcb54a5a60f20c934df90966b2a7", + "spender": "0x111111125421ca6dc452d289314280a0f8842a65", + "value": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "nonce": "0", + "deadline": "1746640317" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/approve.json b/core/crates/gem_evm/testdata/approve.json new file mode 100644 index 0000000000..f17e8cf22c --- /dev/null +++ b/core/crates/gem_evm/testdata/approve.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0xedd2a2b6d8a45e7e00e128b99f3b9ec4e399bca18c48c158ba9744055cb672a9", + "blockNumber": "0x168f7f1", + "chainId": "0x1", + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gas": "0x8b33b", + "gasPrice": "0xa56eb72", + "hash": "0x82ab971968bd0a4cb4cd1d50963c3bc382c1dd75e70f20bffb8ae5abdcbcad2b", + "input": "0x095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "maxFeePerGas": "0xaf425fc", + "maxPriorityFeePerGas": "0x5f5e100", + "nonce": "0x3c", + "r": "0xb4e6fa32dc8fb0297a7f6b655143d450fcd56018992acd24d7372420d8ba3caf", + "s": "0x5a8a11b6219b3f55b8f803a459828d7eb65b3c628fa808307c6280036e980b51", + "to": "0x6b175474e89094c44da98b954eedeac495271d0f", + "transactionIndex": "0xef", + "type": "0x2", + "v": "0x1", + "value": "0x0", + "yParity": "0x1" + }, + "id": 67 +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/approve_receipt.json b/core/crates/gem_evm/testdata/approve_receipt.json new file mode 100644 index 0000000000..0926f17c17 --- /dev/null +++ b/core/crates/gem_evm/testdata/approve_receipt.json @@ -0,0 +1,36 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockHash": "0xedd2a2b6d8a45e7e00e128b99f3b9ec4e399bca18c48c158ba9744055cb672a9", + "blockNumber": "0x168f7f1", + "contractAddress": null, + "cumulativeGasUsed": "0x1e4fbb5", + "effectiveGasPrice": "0xa56eb72", + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gasUsed": "0xb53e", + "logs": [ + { + "address": "0x6b175474e89094c44da98b954eedeac495271d0f", + "blockHash": "0xedd2a2b6d8a45e7e00e128b99f3b9ec4e399bca18c48c158ba9744055cb672a9", + "blockNumber": "0x168f7f1", + "data": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "logIndex": "0x2fe", + "removed": false, + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "0x000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3" + ], + "transactionHash": "0x82ab971968bd0a4cb4cd1d50963c3bc382c1dd75e70f20bffb8ae5abdcbcad2b", + "transactionIndex": "0xef" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800020000000000000200000000000000000000000004008000000000000000000000800000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000010000000000800000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0x6b175474e89094c44da98b954eedeac495271d0f", + "transactionHash": "0x82ab971968bd0a4cb4cd1d50963c3bc382c1dd75e70f20bffb8ae5abdcbcad2b", + "transactionIndex": "0xef", + "type": "0x2" + }, + "id": 1 +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/claim_rewards_receipt.json b/core/crates/gem_evm/testdata/claim_rewards_receipt.json new file mode 100644 index 0000000000..35e17f8812 --- /dev/null +++ b/core/crates/gem_evm/testdata/claim_rewards_receipt.json @@ -0,0 +1,50 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x1951f2d", + "logs": [ + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x00000000000000000000000034deff97889f3a6a483e3b9255cafcb9a6e03588", + "0x0000000000000000000000000533d3a18d3f812ecfcc838b59b34fec4d18e4ac" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000e8766f74", + "blockHash": "0xeba75f3b17fe47d31ea4d5315b16ff5d29e195dadec89ce93ad65a46ec7817f4", + "blockNumber": "0x177ab22", + "transactionHash": "0xc75567672ae15520127aab8093abb4ac434b982e4ecbf76968f7a9ed4279eadf", + "transactionIndex": "0x14b", + "logIndex": "0x282", + "removed": false + }, + { + "address": "0x34deff97889f3a6a483e3b9255cafcb9a6e03588", + "topics": [ + "0x106f923f993c2149d49b4255ff723acafa1f2d94393f561d3eda32ae348f7241", + "0x0000000000000000000000000533d3a18d3f812ecfcc838b59b34fec4d18e4ac" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000e8766f74", + "blockHash": "0xeba75f3b17fe47d31ea4d5315b16ff5d29e195dadec89ce93ad65a46ec7817f4", + "blockNumber": "0x177ab22", + "transactionHash": "0xc75567672ae15520127aab8093abb4ac434b982e4ecbf76968f7a9ed4279eadf", + "transactionIndex": "0x14b", + "logIndex": "0x283", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000008000008000000000000000000008000000000000000000000000000000000000000000000000000000000000000000080000010100000000000000000004000000080000001000000000000010000000000000000000000080200000000200000000004000000000000000000000000000000000000000000000002000000000000000010000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000", + "transactionHash": "0xc75567672ae15520127aab8093abb4ac434b982e4ecbf76968f7a9ed4279eadf", + "transactionIndex": "0x14b", + "blockHash": "0xeba75f3b17fe47d31ea4d5315b16ff5d29e195dadec89ce93ad65a46ec7817f4", + "blockNumber": "0x177ab22", + "gasUsed": "0x13402", + "effectiveGasPrice": "0x26d6de1", + "from": "0x0533d3a18d3f812ecfcc838b59b34fec4d18e4ac", + "to": "0x34deff97889f3a6a483e3b9255cafcb9a6e03588", + "contractAddress": null + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/claim_rewards_tx.json b/core/crates/gem_evm/testdata/claim_rewards_tx.json new file mode 100644 index 0000000000..666b9c05bb --- /dev/null +++ b/core/crates/gem_evm/testdata/claim_rewards_tx.json @@ -0,0 +1,21 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "chainId": "0x1", + "nonce": "0x37", + "gas": "0x16afc", + "maxFeePerGas": "0x72c0f40", + "maxPriorityFeePerGas": "0x16", + "to": "0x34deff97889f3a6a483e3b9255cafcb9a6e03588", + "value": "0x0", + "input": "0x372500ab", + "hash": "0xc75567672ae15520127aab8093abb4ac434b982e4ecbf76968f7a9ed4279eadf", + "blockHash": "0xeba75f3b17fe47d31ea4d5315b16ff5d29e195dadec89ce93ad65a46ec7817f4", + "blockNumber": "0x177ab22", + "transactionIndex": "0x14b", + "from": "0x0533d3a18d3f812ecfcc838b59b34fec4d18e4ac", + "gasPrice": "0x26d6de1" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/contract_call_tx.json b/core/crates/gem_evm/testdata/contract_call_tx.json new file mode 100644 index 0000000000..427a99b072 --- /dev/null +++ b/core/crates/gem_evm/testdata/contract_call_tx.json @@ -0,0 +1,128 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [ + { + "address": "0xad038eb671c44b853887a7e32528fab35dc5d710", + "storageKeys": [ + "0x29ec3718106d695ba370560b3118d0f50164a1a3cf4848dfbd747f2194ce5d45", + "0x0000000000000000000000000000000000000000000000000000000000000005", + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0xc4097dd046caba2364b435caa30d3c116521e8cfb07dc2ce1a0a1080397168fa", + "0xa0d20158cfdc8ec0e5678c129875672c225ab7a2f77764e5108fded544ab7d57", + "0x0000000000000000000000000000000000000000000000000000000000000006", + "0x3582ce5896db0b6f7c0db7a0527684524bfdd1b7bea8e0e03b954952c00cede1", + "0x3ec87c7fae18b7eed1b24fdf471a353102f77a4be0a3f52ad7e1a138ccc2407f" + ] + }, + { + "address": "0xb907dcc926b5991a149d04cb7c0a4a25dc2d8f9a", + "storageKeys": [ + "0xa0d20158cfdc8ec0e5678c129875672c225ab7a2f77764e5108fded544ab7d57", + "0x0000000000000000000000000000000000000000000000000000000000000008", + "0x0000000000000000000000000000000000000000000000000000000000000007", + "0x0000000000000000000000000000000000000000000000000000000000000004", + "0x0000000000000000000000000000000000000000000000000000000000000005", + "0x000000000000000000000000000000000000000000000000000000000000000b", + "0x0000000000000000000000000000000000000000000000000000000000000006" + ] + }, + { + "address": "0x2521aa2e82de12de07435a6304c5f957ebe05cce", + "storageKeys": [] + }, + { + "address": "0xabe146cf570fd27ddd985895ce9b138a7110cce8", + "storageKeys": [ + "0xbbcf3d3c9fb6b8ac1db279061cc3574831f4fd7fbfaea9024c447b754ef2b7c3", + "0x7b5b864843dca89c709e93518141dcaef55f2592cdea5031138710489038c81a", + "0x3c22b2236d1b6e6c11a7e95df12047f7506b89b72d49698347cb3efe2a4c92f8" + ] + }, + { + "address": "0x918abf2cc91d42078879abd568261afee198b46d", + "storageKeys": [] + }, + { + "address": "0x15f7744c393cd07beac9322ce531bd0cb363536b", + "storageKeys": [ + "0x0000000000000000000000000000000000000000000000000000000000000003" + ] + }, + { + "address": "0x06f277de95041c15e15270a144afcf572a2f636e", + "storageKeys": [ + "0xafd9089d671fd430cb6a56587dd9e07528d98b8d4614eb4c79c270b3f3e7dc0a", + "0x000000000000000000000000000000000000000000000000000000000000000b", + "0x602f436d6dc103da1dc07185ff2983e417ae690fbbeaf60d00061a3516055ba6" + ] + }, + { + "address": "0x5cb542eb054f81b8fa1760c077f44aa80271c75d", + "storageKeys": [] + }, + { + "address": "0x258bebad1562fcb8cd975cf3aa74630c83c7f8a9", + "storageKeys": [ + "0x0000000000000000000000000000000000000000000000000000000000000001" + ] + }, + { + "address": "0x6691dbb44154a9f23f8357c56fc9ff5548a8bdc4", + "storageKeys": [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000010", + "0x0000000000000000000000000000000000000000000000000000000000000011", + "0x0000000000000000000000000000000000000000000000000000000000000003", + "0xef607a97e86a8b56a2f2412e1d89b075a8ceff5b68697a645cca32fca9225483", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000000000000000000000000000000000000000000f", + "0x000000000000000000000000000000000000000000000000000000000000000d", + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000000000000000000000000000012", + "0x0000000000000000000000000000000000000000000000000000000000000028" + ] + }, + { + "address": "0xd30e66cbc869aa808eb9c81f8aad8408767e3a3e", + "storageKeys": [ + "0x10b5a155f2ea52860b490c12559f491f1bb70e8fe4c8884da1f7db4f4eb01476" + ] + }, + { + "address": "0x471a6299c027bd81ed4d66069dc510bd0569f4f8", + "storageKeys": [ + "0x0000000000000000000000000000000000000000000000000000000000000005", + "0x0000000000000000000000000000000000000000000000000000000000000002" + ] + }, + { + "address": "0x865377367054516e17014ccded1e7d814edc9ce4", + "storageKeys": [ + "0x558131671e1088d08f54d3d2e128741df3a06abf15e5c4e466125900976f9134", + "0x9bd4acef8d034350a37810197a81ba343c6f06f9071c650de1d04955c4181138" + ] + } + ], + "blockHash": "0x898490a323c24608389a7d1f47e912c4ebeb4b2e80fe1b7837cd0dca801151b2", + "blockNumber": "0x15b3c5d", + "chainId": "0x1", + "from": "0x39ab5f6f1269590225edaf9ad4c5967b09243747", + "gas": "0x61a80", + "gasPrice": "0x56b6bb64", + "hash": "0x876707912c2d625723aa14bf268d83ede36c2657c70da500628e40e6b51577c9", + "input": "0x651afe83000000000000000000000000cb4a7b790edb7fa3e2731efd7ed85275f92fc74a00000000000000000000000000000000000000000000000024522d08029904b0", + "maxFeePerGas": "0x56b6bb64", + "maxPriorityFeePerGas": "0x56b6bb64", + "nonce": "0x10df", + "r": "0x60a8edbe67fdf576d8f080222b4689a48ef8fe59c3c653ca504104d12b051627", + "s": "0x7577efc5e3d3544377355b79c74cd73330efc541514ef3e1089fd5eb6368899b", + "to": "0xb907dcc926b5991a149d04cb7c0a4a25dc2d8f9a", + "transactionIndex": "0x70", + "type": "0x2", + "v": "0x0", + "value": "0x0", + "yParity": "0x0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/contract_call_tx_receipt.json b/core/crates/gem_evm/testdata/contract_call_tx_receipt.json new file mode 100644 index 0000000000..8252100099 --- /dev/null +++ b/core/crates/gem_evm/testdata/contract_call_tx_receipt.json @@ -0,0 +1,82 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x898490a323c24608389a7d1f47e912c4ebeb4b2e80fe1b7837cd0dca801151b2", + "blockNumber": "0x15b3c5d", + "contractAddress": null, + "cumulativeGasUsed": "0x11d45bb", + "effectiveGasPrice": "0x56b6bb64", + "from": "0x39ab5f6f1269590225edaf9ad4c5967b09243747", + "gasUsed": "0x31de6", + "logs": [ + { + "address": "0xad038eb671c44b853887a7e32528fab35dc5d710", + "blockHash": "0x898490a323c24608389a7d1f47e912c4ebeb4b2e80fe1b7837cd0dca801151b2", + "blockNumber": "0x15b3c5d", + "data": "0x0000000000000000000000000000000000000000000000002a5fe1ffdfc43298", + "logIndex": "0x26d", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000cb4a7b790edb7fa3e2731efd7ed85275f92fc74a", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "transactionHash": "0x876707912c2d625723aa14bf268d83ede36c2657c70da500628e40e6b51577c9", + "transactionIndex": "0x70" + }, + { + "address": "0xad038eb671c44b853887a7e32528fab35dc5d710", + "blockHash": "0x898490a323c24608389a7d1f47e912c4ebeb4b2e80fe1b7837cd0dca801151b2", + "blockNumber": "0x15b3c5d", + "data": "0x00000000000000000000000000000000000000000000000024522d08029904b0", + "logIndex": "0x26e", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000cb4a7b790edb7fa3e2731efd7ed85275f92fc74a" + ], + "transactionHash": "0x876707912c2d625723aa14bf268d83ede36c2657c70da500628e40e6b51577c9", + "transactionIndex": "0x70" + }, + { + "address": "0xad038eb671c44b853887a7e32528fab35dc5d710", + "blockHash": "0x898490a323c24608389a7d1f47e912c4ebeb4b2e80fe1b7837cd0dca801151b2", + "blockNumber": "0x15b3c5d", + "data": "0x00000000000000000000000000000000000000000000000024522d08029904b000000000000000000000000000000000000000000000000013e2c055aa61db8600000000000000000000000000000000000000000000000009f1602ad530edc3", + "logIndex": "0x26f", + "removed": false, + "topics": [ + "0x6a03700072ec60131e7e4ff249dad34d458e1e5785dbfa8146b2265997fbf686", + "0x000000000000000000000000cb4a7b790edb7fa3e2731efd7ed85275f92fc74a", + "0x00000000000000000000000039ab5f6f1269590225edaf9ad4c5967b09243747", + "0x000000000000000000000000b907dcc926b5991a149d04cb7c0a4a25dc2d8f9a" + ], + "transactionHash": "0x876707912c2d625723aa14bf268d83ede36c2657c70da500628e40e6b51577c9", + "transactionIndex": "0x70" + }, + { + "address": "0x865377367054516e17014ccded1e7d814edc9ce4", + "blockHash": "0x898490a323c24608389a7d1f47e912c4ebeb4b2e80fe1b7837cd0dca801151b2", + "blockNumber": "0x15b3c5d", + "data": "0x00000000000000000000000000000000000000000000000009f1602ad530edc3", + "logIndex": "0x270", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000b907dcc926b5991a149d04cb7c0a4a25dc2d8f9a", + "0x00000000000000000000000039ab5f6f1269590225edaf9ad4c5967b09243747" + ], + "transactionHash": "0x876707912c2d625723aa14bf268d83ede36c2657c70da500628e40e6b51577c9", + "transactionIndex": "0x70" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000001040000000000000000000000000000000008000000000000000200000000000000000000000000000000000000000000022008000000000000000000000000000000000000004000000000060000000000000000200800000000000000000000000010000080000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000008008000200000000000000002000000000000001000000000080000000000000000000000000020000000080000000000000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0xb907dcc926b5991a149d04cb7c0a4a25dc2d8f9a", + "transactionHash": "0x876707912c2d625723aa14bf268d83ede36c2657c70da500628e40e6b51577c9", + "transactionIndex": "0x70", + "type": "0x2" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/contract_erc20_receipt.json b/core/crates/gem_evm/testdata/contract_erc20_receipt.json new file mode 100644 index 0000000000..1e655077e9 --- /dev/null +++ b/core/crates/gem_evm/testdata/contract_erc20_receipt.json @@ -0,0 +1,53 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0xd8e31844111e705a9f9a654be75f33aae41ce696ef1a08dad280f874a232df7b", + "blockNumber": "0x155da01d", + "contractAddress": null, + "cumulativeGasUsed": "0x93885", + "effectiveGasPrice": "0x9f8bc0", + "from": "0x58e1b0e63c905d5982324fcd9108582623b8132e", + "gasUsed": "0x1b2ce", + "gasUsedForL1": "0x2ffb", + "l1BlockNumber": "0x15df5d3", + "logs": [ + { + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "blockHash": "0xd8e31844111e705a9f9a654be75f33aae41ce696ef1a08dad280f874a232df7b", + "blockNumber": "0x155da01d", + "data": "0x000000000000000000000000000000000000000000000000000000003779077b", + "logIndex": "0x9", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000002df1c51e09aecf9cacb7bc98cb1742757f163df7", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0x54cfb52e50d0815ecac91de9dbf50ea41dea62e82f93deb9508576bf5007d887", + "transactionIndex": "0x3" + }, + { + "address": "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7", + "blockHash": "0xd8e31844111e705a9f9a654be75f33aae41ce696ef1a08dad280f874a232df7b", + "blockNumber": "0x155da01d", + "data": "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc000000000000000000000000000000000000000000000000000000003779077b00000000000000000000000000000000000000000000000000063a14153353108078be703c759583198c6fdafa4f59660698f97155820a1e6d56f529f5d054c0", + "logIndex": "0xa", + "removed": false, + "topics": [ + "0xe5c7fe3a4ffca1590f26d74c8ba8b0db69557f7f4607a2a43f82e93041611978", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0x54cfb52e50d0815ecac91de9dbf50ea41dea62e82f93deb9508576bf5007d887", + "transactionIndex": "0x3" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000001000000000000000000000000000000001000000000000008000000080000000000000000000000000000000000000000000000000000080000000000084000000000001000000010000000000000000000000000000000000000000000100000000000000000000000000000004000000000000001000000000000000000000000000000000000000000000000002002000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000200000000000020000", + "status": "0x1", + "timeboosted": false, + "to": "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7", + "transactionHash": "0x54cfb52e50d0815ecac91de9dbf50ea41dea62e82f93deb9508576bf5007d887", + "transactionIndex": "0x3", + "type": "0x0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/contract_erc20_tx.json b/core/crates/gem_evm/testdata/contract_erc20_tx.json new file mode 100644 index 0000000000..ca1927d764 --- /dev/null +++ b/core/crates/gem_evm/testdata/contract_erc20_tx.json @@ -0,0 +1,22 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "blockHash": "0xd8e31844111e705a9f9a654be75f33aae41ce696ef1a08dad280f874a232df7b", + "blockNumber": "0x155da01d", + "chainId": "0xa4b1", + "from": "0x58e1b0e63c905d5982324fcd9108582623b8132e", + "gas": "0x989680", + "gasPrice": "0x1bf08eb00", + "hash": "0x54cfb52e50d0815ecac91de9dbf50ea41dea62e82f93deb9508576bf5007d887", + "input": "0xc5bdf3ca000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000018078be703c759583198c6fdafa4f59660698f97155820a1e6d56f529f5d054c0", + "nonce": "0x627f4", + "r": "0xebfe38a42780681ccaae637d778a0bd9817a2cfbf855a7b5c5f6351002352207", + "s": "0x6ac222b04dd05892bb4005739e536b11f9aa304b713ba8c8f666a9d3e364dcea", + "to": "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7", + "transactionIndex": "0x3", + "type": "0x0", + "v": "0x14985", + "value": "0x0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/eip712_domain_chain_id_null_value.json b/core/crates/gem_evm/testdata/eip712_domain_chain_id_null_value.json new file mode 100644 index 0000000000..01149f2545 --- /dev/null +++ b/core/crates/gem_evm/testdata/eip712_domain_chain_id_null_value.json @@ -0,0 +1,18 @@ +{ + "domain": { + "name": "Test", + "chainId": null + }, + "message": { + "content": "Hello" + }, + "primaryType": "Message", + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" } + ], + "Message": [ + { "name": "content", "type": "string" } + ] + } +} diff --git a/core/crates/gem_evm/testdata/eip712_domain_chain_id_schema_string.json b/core/crates/gem_evm/testdata/eip712_domain_chain_id_schema_string.json new file mode 100644 index 0000000000..f355aa210e --- /dev/null +++ b/core/crates/gem_evm/testdata/eip712_domain_chain_id_schema_string.json @@ -0,0 +1,17 @@ +{ + "domain": { + "chainId": "1" + }, + "message": { + "content": "Hello" + }, + "primaryType": "Message", + "types": { + "EIP712Domain": [ + { "name": "chainId", "type": "string" } + ], + "Message": [ + { "name": "content", "type": "string" } + ] + } +} diff --git a/core/crates/gem_evm/testdata/eip712_domain_chain_id_without_schema_field.json b/core/crates/gem_evm/testdata/eip712_domain_chain_id_without_schema_field.json new file mode 100644 index 0000000000..a3c785c69c --- /dev/null +++ b/core/crates/gem_evm/testdata/eip712_domain_chain_id_without_schema_field.json @@ -0,0 +1,18 @@ +{ + "domain": { + "name": "Test", + "chainId": 1 + }, + "message": { + "content": "Hello" + }, + "primaryType": "Message", + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" } + ], + "Message": [ + { "name": "content", "type": "string" } + ] + } +} diff --git a/core/crates/gem_evm/testdata/eip712_int8_account_registration.json b/core/crates/gem_evm/testdata/eip712_int8_account_registration.json new file mode 100644 index 0000000000..23dbecb2da --- /dev/null +++ b/core/crates/gem_evm/testdata/eip712_int8_account_registration.json @@ -0,0 +1,19 @@ +{ + "domain": { + "name": "Test", + "chainId": 1 + }, + "message": { + "accountIndex": 1 + }, + "primaryType": "AccountRegistration", + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "chainId", "type": "uint256" } + ], + "AccountRegistration": [ + { "name": "accountIndex", "type": "int8" } + ] + } +} diff --git a/core/crates/gem_evm/testdata/eip712_schema_chain_id_without_domain_value.json b/core/crates/gem_evm/testdata/eip712_schema_chain_id_without_domain_value.json new file mode 100644 index 0000000000..dab353776c --- /dev/null +++ b/core/crates/gem_evm/testdata/eip712_schema_chain_id_without_domain_value.json @@ -0,0 +1,17 @@ +{ + "domain": { + "name": "Test" + }, + "message": { + "content": "Hello" + }, + "primaryType": "Message", + "types": { + "EIP712Domain": [ + { "name": "chainId", "type": "uint256" } + ], + "Message": [ + { "name": "content", "type": "string" } + ] + } +} diff --git a/core/crates/gem_evm/testdata/ens_upload_avatar.json b/core/crates/gem_evm/testdata/ens_upload_avatar.json new file mode 100644 index 0000000000..4b6512556a --- /dev/null +++ b/core/crates/gem_evm/testdata/ens_upload_avatar.json @@ -0,0 +1,25 @@ +{ + "domain": { + "name": "Ethereum Name Service", + "version": "1" + }, + "message": { + "upload": "avatar", + "expiry": "1775049942284", + "name": "gemdev-test.eth", + "hash": "0x389f958e9b58a3d316d075f6734e1135c0c5ecbca43e35194c65dc18326a856e" + }, + "primaryType": "Upload", + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" } + ], + "Upload": [ + { "name": "upload", "type": "string" }, + { "name": "expiry", "type": "string" }, + { "name": "name", "type": "string" }, + { "name": "hash", "type": "string" } + ] + } +} diff --git a/core/crates/gem_evm/testdata/everstake/transaction_stake.json b/core/crates/gem_evm/testdata/everstake/transaction_stake.json new file mode 100644 index 0000000000..9f709f6d76 --- /dev/null +++ b/core/crates/gem_evm/testdata/everstake/transaction_stake.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0x2c77d83fcc7605922acb0edf491db3949ba7d063e4b5e476b3b1e487fce3858b", + "blockNumber": "0x1681fb2", + "chainId": "0x1", + "from": "0x0d9dab1a248f63b0a48965ba8435e4de7497a3dc", + "gas": "0x6f7b4", + "gasPrice": "0xdb0f18a", + "hash": "0x9fcf61d61c40b19046badf7de49a9548579424e55aec20b6859dc9baef4c5f1c", + "input": "0x3a29dbae0000000000000000000000000000000000000000000000000000000000000017", + "maxFeePerGas": "0xdb0f18a", + "maxPriorityFeePerGas": "0x5f5e100", + "nonce": "0x9", + "r": "0x83c0ccfcf472160766ca68fceb5e0343049fbc2575b9aa484eaa70819647125e", + "s": "0x3f551d798cb9c2a438158506d9abae2d7c4eeb1c2f49d9ecb9c0d06576d702b0", + "to": "0xd523794c879d9ec028960a231f866758e405be34", + "transactionIndex": "0x3e", + "type": "0x2", + "v": "0x0", + "value": "0x1e2f26f9f27980000", + "yParity": "0x0" + }, + "id": 67 +} diff --git a/core/crates/gem_evm/testdata/everstake/transaction_stake_receipt.json b/core/crates/gem_evm/testdata/everstake/transaction_stake_receipt.json new file mode 100644 index 0000000000..efc0e02b5a --- /dev/null +++ b/core/crates/gem_evm/testdata/everstake/transaction_stake_receipt.json @@ -0,0 +1,77 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockHash": "0x2c77d83fcc7605922acb0edf491db3949ba7d063e4b5e476b3b1e487fce3858b", + "blockNumber": "0x1681fb2", + "contractAddress": null, + "cumulativeGasUsed": "0x6eb390", + "effectiveGasPrice": "0xdb0f18a", + "from": "0x0d9dab1a248f63b0a48965ba8435e4de7497a3dc", + "gasUsed": "0x476d3", + "logs": [ + { + "address": "0x7a7f0b3c23c23a31cfcb0c44709be70d4d545c6e", + "blockHash": "0x2c77d83fcc7605922acb0edf491db3949ba7d063e4b5e476b3b1e487fce3858b", + "blockNumber": "0x1681fb2", + "data": "0x000000000000000000000000000000000000000000000001501a000b425bf305", + "logIndex": "0xd0", + "removed": false, + "topics": [ + "0x3ec15e61844217b34b37f44daa682ce56e8aa4c730df93c295de93a67d6765a5", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0x9fcf61d61c40b19046badf7de49a9548579424e55aec20b6859dc9baef4c5f1c", + "transactionIndex": "0x3e" + }, + { + "address": "0x7a7f0b3c23c23a31cfcb0c44709be70d4d545c6e", + "blockHash": "0x2c77d83fcc7605922acb0edf491db3949ba7d063e4b5e476b3b1e487fce3858b", + "blockNumber": "0x1681fb2", + "data": "0x00000000000000000000000000000000000000000000000092d86f93e53c0cfb", + "logIndex": "0xd1", + "removed": false, + "topics": [ + "0xc8724ec5e59eea00f3f35419c3139ead03ff07766e7e9cf00a62381692aac8c7", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0x9fcf61d61c40b19046badf7de49a9548579424e55aec20b6859dc9baef4c5f1c", + "transactionIndex": "0x3e" + }, + { + "address": "0x19449f0f696703aa3b1485dfa2d855f33659397a", + "blockHash": "0x2c77d83fcc7605922acb0edf491db3949ba7d063e4b5e476b3b1e487fce3858b", + "blockNumber": "0x1681fb2", + "data": "0x000000000000000000000000000000000000000000000001501a000b425bf305", + "logIndex": "0xd2", + "removed": false, + "topics": [ + "0xf6f3bf9d00b52384d77c0b88440f0c216676d7ff221073342ec0606c43ccc40c", + "0x000000000000000000000000d523794c879d9ec028960a231f866758e405be34" + ], + "transactionHash": "0x9fcf61d61c40b19046badf7de49a9548579424e55aec20b6859dc9baef4c5f1c", + "transactionIndex": "0x3e" + }, + { + "address": "0xd523794c879d9ec028960a231f866758e405be34", + "blockHash": "0x2c77d83fcc7605922acb0edf491db3949ba7d063e4b5e476b3b1e487fce3858b", + "blockNumber": "0x1681fb2", + "data": "0x000000000000000000000000000000000000000000000001e2f26f9f279800000000000000000000000000000000000000000000000000000000000000000017", + "logIndex": "0xd3", + "removed": false, + "topics": [ + "0x7d194e8dc0f902cdc51bde00649039561dbd0b01574d671bad333436fdac7692", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0x9fcf61d61c40b19046badf7de49a9548579424e55aec20b6859dc9baef4c5f1c", + "transactionIndex": "0x3e" + } + ], + "logsBloom": "0x00400000000000000000000000000000000000000000100000000000000000000010000000008000000000000000000002000000000000000000000000000000000000001020020000000000000000020000000000000000000000000000000000000000000000000000480000000040000008000000000000000000000000000000000000000000208000000000000000100000000000000000000000000000000000020000000000000000000000000800000000000000000000000000020000008000000000000000000000000020010008000000000000800000000000000004000000000000000000000002000000000000000000000000000000080000", + "status": "0x1", + "to": "0xd523794c879d9ec028960a231f866758e405be34", + "transactionHash": "0x9fcf61d61c40b19046badf7de49a9548579424e55aec20b6859dc9baef4c5f1c", + "transactionIndex": "0x3e", + "type": "0x2" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/everstake/transaction_unstake.json b/core/crates/gem_evm/testdata/everstake/transaction_unstake.json new file mode 100644 index 0000000000..23da4e9d1f --- /dev/null +++ b/core/crates/gem_evm/testdata/everstake/transaction_unstake.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0xf9fd18d999aec2562c72a5b268a499397b2b13fdea26abfdbb7a64d82141d092", + "blockNumber": "0x1660f03", + "chainId": "0x1", + "from": "0x1085c5f70f7f7591d97da281a64688385455c2bd", + "gas": "0x51954", + "gasPrice": "0xfa1fa67", + "hash": "0xdb9946d952fa98686f6abf6dc5ebe301567dd13d60e147e48be074ead83dc44a", + "input": "0x76ec871c00000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000017", + "maxFeePerGas": "0xfa1fa67", + "maxPriorityFeePerGas": "0x5f5e100", + "nonce": "0x4", + "r": "0x82d3dd48c6b07c7fe65f0dab0f7125ca1249af655662a4201d92db8a467c022a", + "s": "0x7fe9f1f566ce94d5d27efe21f51194f2225db6f7410d7f3be0b6e7a08f01a4d9", + "to": "0xd523794c879d9ec028960a231f866758e405be34", + "transactionIndex": "0x7a", + "type": "0x2", + "v": "0x0", + "value": "0x0", + "yParity": "0x0" + }, + "id": 67 +} diff --git a/core/crates/gem_evm/testdata/everstake/transaction_unstake_receipt.json b/core/crates/gem_evm/testdata/everstake/transaction_unstake_receipt.json new file mode 100644 index 0000000000..8100f4db4f --- /dev/null +++ b/core/crates/gem_evm/testdata/everstake/transaction_unstake_receipt.json @@ -0,0 +1,49 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockHash": "0xf9fd18d999aec2562c72a5b268a499397b2b13fdea26abfdbb7a64d82141d092", + "blockNumber": "0x1660f03", + "contractAddress": null, + "cumulativeGasUsed": "0xfa37c7", + "effectiveGasPrice": "0xfa1fa67", + "from": "0x1085c5f70f7f7591d97da281a64688385455c2bd", + "gasUsed": "0x34276", + "logs": [ + { + "address": "0x7a7f0b3c23c23a31cfcb0c44709be70d4d545c6e", + "blockHash": "0xf9fd18d999aec2562c72a5b268a499397b2b13fdea26abfdbb7a64d82141d092", + "blockNumber": "0x1660f03", + "data": "0x00000000000000000000000000000000000000000000000000b1a2bc2ec50000", + "logIndex": "0x1a7", + "removed": false, + "topics": [ + "0xd48a0d682e76a5a6d2fdce57c4306331196f4f0b3039ef69be08e354ebd2609b", + "0x0000000000000000000000001085c5f70f7f7591d97da281a64688385455c2bd" + ], + "transactionHash": "0xdb9946d952fa98686f6abf6dc5ebe301567dd13d60e147e48be074ead83dc44a", + "transactionIndex": "0x7a" + }, + { + "address": "0xd523794c879d9ec028960a231f866758e405be34", + "blockHash": "0xf9fd18d999aec2562c72a5b268a499397b2b13fdea26abfdbb7a64d82141d092", + "blockNumber": "0x1660f03", + "data": "0x00000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000000000017", + "logIndex": "0x1a8", + "removed": false, + "topics": [ + "0x0750a71dce555de583ab0225a108df42b9402d22123d7cc9cd95793e43e7db0e", + "0x0000000000000000000000001085c5f70f7f7591d97da281a64688385455c2bd" + ], + "transactionHash": "0xdb9946d952fa98686f6abf6dc5ebe301567dd13d60e147e48be074ead83dc44a", + "transactionIndex": "0x7a" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000010000000000000010000000000000000000000000000000000000102000000010000000000000000000004000000000000020000008000000001020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000080000", + "status": "0x1", + "to": "0xd523794c879d9ec028960a231f866758e405be34", + "transactionHash": "0xdb9946d952fa98686f6abf6dc5ebe301567dd13d60e147e48be074ead83dc44a", + "transactionIndex": "0x7a", + "type": "0x2" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/everstake/transaction_withdraw.json b/core/crates/gem_evm/testdata/everstake/transaction_withdraw.json new file mode 100644 index 0000000000..95a9925030 --- /dev/null +++ b/core/crates/gem_evm/testdata/everstake/transaction_withdraw.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0x8d034f3ef4a4860f666b4ee45e18d556561d19b1f1f5f0a6a0e456e91ea2ad4f", + "blockNumber": "0x16820db", + "chainId": "0x1", + "from": "0x1085c5f70f7f7591d97da281a64688385455c2bd", + "gas": "0x2070a", + "gasPrice": "0xfc3355e", + "hash": "0xdd5ceff157cc8234e6134fc26457dd2538aa9832ebf3ef16ccf96f4f744e0f8f", + "input": "0x33986ffa", + "maxFeePerGas": "0x10985ee6", + "maxPriorityFeePerGas": "0x5f5e100", + "nonce": "0xc", + "r": "0xd2493fd530c7615ecf92e49dc0c00ab8c828b21c33a3642323686dfa4452ab97", + "s": "0x20c6c7e84c1a46bbbfe76ae3c9579eac7b41b624d826ea1a02ae0243deaebda3", + "to": "0x7a7f0b3c23c23a31cfcb0c44709be70d4d545c6e", + "transactionIndex": "0x8a", + "type": "0x2", + "v": "0x1", + "value": "0x0", + "yParity": "0x1" + }, + "id": 67 +} diff --git a/core/crates/gem_evm/testdata/everstake/transaction_withdraw_receipt.json b/core/crates/gem_evm/testdata/everstake/transaction_withdraw_receipt.json new file mode 100644 index 0000000000..91932acc47 --- /dev/null +++ b/core/crates/gem_evm/testdata/everstake/transaction_withdraw_receipt.json @@ -0,0 +1,35 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockHash": "0x8d034f3ef4a4860f666b4ee45e18d556561d19b1f1f5f0a6a0e456e91ea2ad4f", + "blockNumber": "0x16820db", + "contractAddress": null, + "cumulativeGasUsed": "0x1a4889c", + "effectiveGasPrice": "0xfc3355e", + "from": "0x1085c5f70f7f7591d97da281a64688385455c2bd", + "gasUsed": "0x138e3", + "logs": [ + { + "address": "0x7a7f0b3c23c23a31cfcb0c44709be70d4d545c6e", + "blockHash": "0x8d034f3ef4a4860f666b4ee45e18d556561d19b1f1f5f0a6a0e456e91ea2ad4f", + "blockNumber": "0x16820db", + "data": "0x00000000000000000000000000000000000000000000000000b1a2bc2ec50000", + "logIndex": "0x456", + "removed": false, + "topics": [ + "0x262159451c4018521811107ecbe27e3de7d95a70a4a534f733aa59bc4346f03e", + "0x0000000000000000000000001085c5f70f7f7591d97da281a64688385455c2bd" + ], + "transactionHash": "0xdd5ceff157cc8234e6134fc26457dd2538aa9832ebf3ef16ccf96f4f744e0f8f", + "transactionIndex": "0x8a" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010200000000000000000004000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000200000000000000000000000000000000000090000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000", + "status": "0x1", + "to": "0x7a7f0b3c23c23a31cfcb0c44709be70d4d545c6e", + "transactionHash": "0xdd5ceff157cc8234e6134fc26457dd2538aa9832ebf3ef16ccf96f4f744e0f8f", + "transactionIndex": "0x8a", + "type": "0x2" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/everstake/transaction_withdraw_trace.json b/core/crates/gem_evm/testdata/everstake/transaction_withdraw_trace.json new file mode 100644 index 0000000000..baaf2e0670 --- /dev/null +++ b/core/crates/gem_evm/testdata/everstake/transaction_withdraw_trace.json @@ -0,0 +1,68 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "output": "0x", + "stateDiff": { + "0x1085c5f70f7f7591d97da281a64688385455c2bd": { + "balance": { + "*": { + "from": "0x373d9a674d2416", + "to": "0xe8cd12b2ef41bc" + } + }, + "code": "=", + "nonce": { + "*": { + "from": "0xc", + "to": "0xd" + } + }, + "storage": {} + }, + "0x19449f0f696703aa3b1485dfa2d855f33659397a": { + "balance": { + "*": { + "from": "0x1949e04119dd24a209d", + "to": "0x1949d526ee1a385209d" + } + }, + "code": "=", + "nonce": "=", + "storage": {} + }, + "0x7a7f0b3c23c23a31cfcb0c44709be70d4d545c6e": { + "balance": "=", + "code": "=", + "nonce": "=", + "storage": { + "0x945199a2bca19b2987d87f32353a737a8e180213ad3df07952b537f445c48dd3": { + "*": { + "from": "0x00000000000000000000000000000000000000000000000000b1a2bc2ec50000", + "to": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0xe9e5ec1696d202014afdaa787158c4aa504ce914d94b95ddad3045376a1f1b31": { + "*": { + "from": "0x000000000000000000000000000000000000000000002f2a5e3c01694a04e6f3", + "to": "0x000000000000000000000000000000000000000000002f2a5eeda42578c9e6f3" + } + } + } + }, + "0xdadb0d80178819f2319190d340ce9a924f783711": { + "balance": { + "*": { + "from": "0x348286b05356a4ccb", + "to": "0x34828724e28a8cfcb" + } + }, + "code": "=", + "nonce": "=", + "storage": {} + } + }, + "trace": [], + "vmTrace": null + } +} diff --git a/core/crates/gem_evm/testdata/mayan_native_swap_tx.json b/core/crates/gem_evm/testdata/mayan_native_swap_tx.json new file mode 100644 index 0000000000..2780fe4880 --- /dev/null +++ b/core/crates/gem_evm/testdata/mayan_native_swap_tx.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0xde74e2398735485430d4aafee69225b30401d76cbf3a0fa0c781e18eb12a632e", + "blockNumber": "0x4fe7203", + "from": "0x551ac3629ec87f3957b1074faf48d22a5a26ecec", + "gas": "0x10b6c6", + "hash": "0xe6e3d6953318acc94ea78e5050cd78af8619da59dfd9d76fec28c6be8cb31331", + "input": "0xfa74fd43000000000000000000000000000000000000000000000006c3eb9171940d692c", + "to": "0x337685fdab40d39bd02028545a4ffa7d287cc3e2", + "value": "0x6c3eb9171940d692c" + } +} diff --git a/core/crates/gem_evm/testdata/mayan_native_swap_tx_receipt.json b/core/crates/gem_evm/testdata/mayan_native_swap_tx_receipt.json new file mode 100644 index 0000000000..f976d08f67 --- /dev/null +++ b/core/crates/gem_evm/testdata/mayan_native_swap_tx_receipt.json @@ -0,0 +1,46 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockNumber": "0x4fe7203", + "gasUsed": "0x74cfd", + "effectiveGasPrice": "0x882048f404", + "logs": [ + { + "address": "0x0000000000000000000000000000000000001010", + "topics": [ + "0xe6497e3ee548a3372136af2fcb0696db31fc6cf20260707645068bd3fe97f3c4", + "0x0000000000000000000000000000000000000000000000000000000000001010", + "0x000000000000000000000000551ac3629ec87f3957b1074faf48d22a5a26ecec", + "0x000000000000000000000000337685fdab40d39bd02028545a4ffa7d287cc3e2" + ], + "data": "0x000000000000000000000000000000000000000000000006c3eb9171940d692c" + }, + { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000337685fdab40d39bd02028545a4ffa7d287cc3e2", + "0x000000000000000000000000c38e4e6a15593f908255214653d3d947ca1c2338" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000c4e3ec" + }, + { + "address": "0xc38e4e6a15593f908255214653d3d947ca1c2338", + "topics": [ + "0x918554b6bd6e2895ce6553de5de0e1a69db5289aa0e4fe193a0dcd1f14347477" + ], + "data": "0xd4a563723b09e971d354a013b054dbf53ad45e18374ee0a165d717a437e0cdac" + }, + { + "address": "0x337685fdab40d39bd02028545a4ffa7d287cc3e2", + "topics": [ + "0x7cbff921ae1f3ea71284120d2aabde13587df067f2bb5c831ea6e35d7a9242ac" + ], + "data": "0x000000000000000000000000000000000000000000000006c3eb9171940d692c" + } + ], + "status": "0x1" + } +} diff --git a/core/crates/gem_evm/testdata/mayan_token_swap_tx.json b/core/crates/gem_evm/testdata/mayan_token_swap_tx.json new file mode 100644 index 0000000000..cd66330c2f --- /dev/null +++ b/core/crates/gem_evm/testdata/mayan_token_swap_tx.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0xa96634f733c2797980ac14f9a45cb14fae9c0dae53ab0c64264ed686d5ae4464", + "blockNumber": "0x4fe725e", + "from": "0x0dc153e9225a0d74460d806c08c961a3ec0ef17d", + "gas": "0x12044a", + "hash": "0x0fa8750f83a8765c679a2eb0a3158de9c9ec6f4561f4967265ec64dac97c1fcf", + "input": "0x30dedc570000000000000000000000008f3cf7ad23cd3cadbd9735aff958023239c6a063", + "to": "0x337685fdab40d39bd02028545a4ffa7d287cc3e2", + "value": "0x0" + } +} diff --git a/core/crates/gem_evm/testdata/mayan_token_swap_tx_receipt.json b/core/crates/gem_evm/testdata/mayan_token_swap_tx_receipt.json new file mode 100644 index 0000000000..e1e70bf020 --- /dev/null +++ b/core/crates/gem_evm/testdata/mayan_token_swap_tx_receipt.json @@ -0,0 +1,54 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockNumber": "0x4fe725e", + "gasUsed": "0x79b4e", + "effectiveGasPrice": "0x7714140d94", + "logs": [ + { + "address": "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000dc153e9225a0d74460d806c08c961a3ec0ef17d", + "0x000000000000000000000000337685fdab40d39bd02028545a4ffa7d287cc3e2" + ], + "data": "0x000000000000000000000000000000000000000000000042d66641ef7e15c4ee" + }, + { + "address": "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x0000000000000000000000000dc153e9225a0d74460d806c08c961a3ec0ef17d", + "0x000000000000000000000000337685fdab40d39bd02028545a4ffa7d287cc3e2" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000337685fdab40d39bd02028545a4ffa7d287cc3e2", + "0x000000000000000000000000c38e4e6a15593f908255214653d3d947ca1c2338" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000497efcdc" + }, + { + "address": "0xc38e4e6a15593f908255214653d3d947ca1c2338", + "topics": [ + "0x918554b6bd6e2895ce6553de5de0e1a69db5289aa0e4fe193a0dcd1f14347477" + ], + "data": "0x5da5add59ba43e7c3a2a00d2ffe104fe8ab06985e38a14cb97785c8f55274b9d" + }, + { + "address": "0x337685fdab40d39bd02028545a4ffa7d287cc3e2", + "topics": [ + "0x23278f58875126c795a4072b98b5851fe9b21cea19895b02a6224fefbb1e3298" + ], + "data": "0x0000000000000000000000008f3cf7ad23cd3cadbd9735aff958023239c6a063" + } + ], + "status": "0x1" + } +} diff --git a/core/crates/gem_evm/testdata/monad/transaction_staking_claim_rewards.json b/core/crates/gem_evm/testdata/monad/transaction_staking_claim_rewards.json new file mode 100644 index 0000000000..45a621420c --- /dev/null +++ b/core/crates/gem_evm/testdata/monad/transaction_staking_claim_rewards.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "chainId": "0x8f", + "nonce": "0x22", + "gas": "0x419b2", + "maxFeePerGas": "0x186b2700d5", + "maxPriorityFeePerGas": "0x122b018d5", + "to": "0x0000000000000000000000000000000000001000", + "value": "0x0", + "accessList": [], + "input": "0xa76e2ca5000000000000000000000000000000000000000000000000000000000000000a", + "r": "0x752b26b3ca3cc5d23b772421acdf7a54750931217da2b1ba58ba437335d37dea", + "s": "0x5cfac45b0e8bbe481021fb12e441597528f15656ee678bb7544ba3fe6c4bfad1", + "yParity": "0x0", + "v": "0x0", + "hash": "0x8c45c570ee53bc2476aef05cdda49ef3463e0c55a37ab44bea6a023e43b9452b", + "blockHash": "0x9209449016ca81e4f27ce14f97104f71576b85b4961c3cfe67c7f109e4862062", + "blockNumber": "0x430de4f", + "transactionIndex": "0x5", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "gasPrice": "0x186b2700d5" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/monad/transaction_staking_claim_rewards_receipt.json b/core/crates/gem_evm/testdata/monad/transaction_staking_claim_rewards_receipt.json new file mode 100644 index 0000000000..18ed6a6a57 --- /dev/null +++ b/core/crates/gem_evm/testdata/monad/transaction_staking_claim_rewards_receipt.json @@ -0,0 +1,37 @@ +{ + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0xb34f9", + "logs": [ + { + "address": "0x0000000000000000000000000000000000001000", + "topics": [ + "0xcb607e6b63c89c95f6ae24ece9fe0e38a7971aa5ed956254f1df47490921727b", + "0x000000000000000000000000000000000000000000000000000000000000000a", + "0x000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7" + ], + "data": "0x000000000000000000000000000000000000000000000000045fcb0b218ac603000000000000000000000000000000000000000000000000000000000000057f", + "blockHash": "0x9209449016ca81e4f27ce14f97104f71576b85b4961c3cfe67c7f109e4862062", + "blockNumber": "0x430de4f", + "blockTimestamp": "0x69ebf7ab", + "transactionHash": "0x8c45c570ee53bc2476aef05cdda49ef3463e0c55a37ab44bea6a023e43b9452b", + "transactionIndex": "0x5", + "logIndex": "0x6", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000020000000040000000020000000000100000000000000000000000000000000002000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000400000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000400000000000000000000000000000000000000000000000000", + "transactionHash": "0x8c45c570ee53bc2476aef05cdda49ef3463e0c55a37ab44bea6a023e43b9452b", + "transactionIndex": "0x5", + "blockHash": "0x9209449016ca81e4f27ce14f97104f71576b85b4961c3cfe67c7f109e4862062", + "blockNumber": "0x430de4f", + "gasUsed": "0x419b2", + "effectiveGasPrice": "0x186b2700d5", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "to": "0x0000000000000000000000000000000000001000", + "contractAddress": null + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/monad/transaction_staking_delegate.json b/core/crates/gem_evm/testdata/monad/transaction_staking_delegate.json new file mode 100644 index 0000000000..f835a9d26a --- /dev/null +++ b/core/crates/gem_evm/testdata/monad/transaction_staking_delegate.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "chainId": "0x8f", + "nonce": "0x23", + "gas": "0x6889e", + "maxFeePerGas": "0x191971bcaa", + "maxPriorityFeePerGas": "0x1d0fad4aa", + "to": "0x0000000000000000000000000000000000001000", + "value": "0x1bc16d674ec80000", + "accessList": [], + "input": "0x84994fec0000000000000000000000000000000000000000000000000000000000000005", + "r": "0x5d9f85d070996003e358a63590cd568908d0a95035b2585acc03f9a26d98679c", + "s": "0x25c0eff334ce17b14fcfc01a117b0280d7d4a2dabaf99fe255c1611c4627ef7b", + "yParity": "0x1", + "v": "0x1", + "hash": "0x704afce58b57ee93ce4fad0d0979ee6de338f22e5472a4b8d52f3aaff803475f", + "blockHash": "0xc48f1e5e836f1875b1bec50e34ca4d95e152100312f91bd125f3554993ba6893", + "blockNumber": "0x473c869", + "transactionIndex": "0x3", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "gasPrice": "0x191971bcaa" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/monad/transaction_staking_delegate_receipt.json b/core/crates/gem_evm/testdata/monad/transaction_staking_delegate_receipt.json new file mode 100644 index 0000000000..969761dfa1 --- /dev/null +++ b/core/crates/gem_evm/testdata/monad/transaction_staking_delegate_receipt.json @@ -0,0 +1,37 @@ +{ + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x448d42", + "logs": [ + { + "address": "0x0000000000000000000000000000000000001000", + "topics": [ + "0xe4d4df1e1827dd28252fd5c3cd7ebccd3da6e0aa31f74c828f3c8542af49d840", + "0x0000000000000000000000000000000000000000000000000000000000000005", + "0x000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7" + ], + "data": "0x0000000000000000000000000000000000000000000000001bc16d674ec8000000000000000000000000000000000000000000000000000000000000000005d7", + "blockHash": "0xc48f1e5e836f1875b1bec50e34ca4d95e152100312f91bd125f3554993ba6893", + "blockNumber": "0x473c869", + "blockTimestamp": "0x6a06bbbe", + "transactionHash": "0x704afce58b57ee93ce4fad0d0979ee6de338f22e5472a4b8d52f3aaff803475f", + "transactionIndex": "0x3", + "logIndex": "0x23", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000100000010000000000000000000000000002000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000c00000000000000000000000000000000000000000000000000000004000000000000000400000000000000000000008000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "transactionHash": "0x704afce58b57ee93ce4fad0d0979ee6de338f22e5472a4b8d52f3aaff803475f", + "transactionIndex": "0x3", + "blockHash": "0xc48f1e5e836f1875b1bec50e34ca4d95e152100312f91bd125f3554993ba6893", + "blockNumber": "0x473c869", + "gasUsed": "0x6889e", + "effectiveGasPrice": "0x191971bcaa", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "to": "0x0000000000000000000000000000000000001000", + "contractAddress": null + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/monad/transaction_staking_undelegate.json b/core/crates/gem_evm/testdata/monad/transaction_staking_undelegate.json new file mode 100644 index 0000000000..61187b8d5c --- /dev/null +++ b/core/crates/gem_evm/testdata/monad/transaction_staking_undelegate.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "chainId": "0x8f", + "nonce": "0x24", + "gas": "0x3eeab", + "maxFeePerGas": "0x1880d9a05c", + "maxPriorityFeePerGas": "0x13862b85c", + "to": "0x0000000000000000000000000000000000001000", + "value": "0x0", + "accessList": [], + "input": "0x5cf41514000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000000000000000001", + "r": "0x76b4e990e0b277b20c602a161a5316d77f84459a26a910fdae14b2b079f0932f", + "s": "0x1d6101770e9df6d3c103c7f53eeb1f8fe65a9f09498134c44e0a7e7e38412080", + "yParity": "0x0", + "v": "0x0", + "hash": "0x6e30f4ae7c2142e362df38f5f3caa7236c8365a4d8a5b246e4dea5e9ce4f8f21", + "blockHash": "0xe5f1274542716c4658ad22e8f69c16938bcc844785252f7ddeae6dd7fb0e0e71", + "blockNumber": "0x473c8ba", + "transactionIndex": "0x1", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "gasPrice": "0x1880d9a05c" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/monad/transaction_staking_undelegate_receipt.json b/core/crates/gem_evm/testdata/monad/transaction_staking_undelegate_receipt.json new file mode 100644 index 0000000000..dde5098b1a --- /dev/null +++ b/core/crates/gem_evm/testdata/monad/transaction_staking_undelegate_receipt.json @@ -0,0 +1,37 @@ +{ + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x3eeab", + "logs": [ + { + "address": "0x0000000000000000000000000000000000001000", + "topics": [ + "0x3e53c8b91747e1b72a44894db10f2a45fa632b161fdcdd3a17bd6be5482bac62", + "0x000000000000000000000000000000000000000000000000000000000000000a", + "0x000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000000005d7", + "blockHash": "0xe5f1274542716c4658ad22e8f69c16938bcc844785252f7ddeae6dd7fb0e0e71", + "blockNumber": "0x473c8ba", + "blockTimestamp": "0x6a06bbdf", + "transactionHash": "0x6e30f4ae7c2142e362df38f5f3caa7236c8365a4d8a5b246e4dea5e9ce4f8f21", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000000020000000400100000000010000000000000000000000082000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "transactionHash": "0x6e30f4ae7c2142e362df38f5f3caa7236c8365a4d8a5b246e4dea5e9ce4f8f21", + "transactionIndex": "0x1", + "blockHash": "0xe5f1274542716c4658ad22e8f69c16938bcc844785252f7ddeae6dd7fb0e0e71", + "blockNumber": "0x473c8ba", + "gasUsed": "0x3eeab", + "effectiveGasPrice": "0x1880d9a05c", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "to": "0x0000000000000000000000000000000000001000", + "contractAddress": null + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/monad/transaction_staking_withdraw.json b/core/crates/gem_evm/testdata/monad/transaction_staking_withdraw.json new file mode 100644 index 0000000000..de8538a8a6 --- /dev/null +++ b/core/crates/gem_evm/testdata/monad/transaction_staking_withdraw.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "chainId": "0x8f", + "nonce": "0x20", + "gas": "0x214f6", + "maxFeePerGas": "0x183ed49155", + "maxPriorityFeePerGas": "0xf65da955", + "to": "0x0000000000000000000000000000000000001000", + "value": "0x0", + "accessList": [], + "input": "0xaed2ee73000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000001", + "r": "0x1873eae7706c6a41d9d50e2fa313282c564fdb2413d9c789a8140399f0dc2fa3", + "s": "0x16a345c07cb3e6479b2c1391312e880c3e22390029c1994c7720623faeedd1d3", + "yParity": "0x0", + "v": "0x0", + "hash": "0x8eaaaa09b939ee1bf16688bcbdf4708c50e94f13b013afdd0545288bdb4eb633", + "blockHash": "0x5fc961dc55a70c837179a5599555cc0480629170a019c869204d148253a0620a", + "blockNumber": "0x430de0e", + "transactionIndex": "0x4", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "gasPrice": "0x183ed49155" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/monad/transaction_staking_withdraw_receipt.json b/core/crates/gem_evm/testdata/monad/transaction_staking_withdraw_receipt.json new file mode 100644 index 0000000000..e36547f09f --- /dev/null +++ b/core/crates/gem_evm/testdata/monad/transaction_staking_withdraw_receipt.json @@ -0,0 +1,37 @@ +{ + "jsonrpc": "2.0", + "result": { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0xc424a", + "logs": [ + { + "address": "0x0000000000000000000000000000000000001000", + "topics": [ + "0x63030e4238e1146c63f38f4ac81b2b23c8be28882e68b03f0887e50d0e9bb18f", + "0x000000000000000000000000000000000000000000000000000000000000000a", + "0x000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000008ac8fd0162edef84000000000000000000000000000000000000000000000000000000000000057f", + "blockHash": "0x5fc961dc55a70c837179a5599555cc0480629170a019c869204d148253a0620a", + "blockNumber": "0x430de0e", + "blockTimestamp": "0x69ebf791", + "transactionHash": "0x8eaaaa09b939ee1bf16688bcbdf4708c50e94f13b013afdd0545288bdb4eb633", + "transactionIndex": "0x4", + "logIndex": "0x6", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000000020000000000100000000000000000000000000000000002000000000400000004000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000400000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "transactionHash": "0x8eaaaa09b939ee1bf16688bcbdf4708c50e94f13b013afdd0545288bdb4eb633", + "transactionIndex": "0x4", + "blockHash": "0x5fc961dc55a70c837179a5599555cc0480629170a019c869204d148253a0620a", + "blockNumber": "0x430de0e", + "gasUsed": "0x214f6", + "effectiveGasPrice": "0x183ed49155", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "to": "0x0000000000000000000000000000000000001000", + "contractAddress": null + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/okx_base_swap_tx.json b/core/crates/gem_evm/testdata/okx_base_swap_tx.json new file mode 100644 index 0000000000..abe546879a --- /dev/null +++ b/core/crates/gem_evm/testdata/okx_base_swap_tx.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0x8d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "gas": "0xc3500", + "hash": "0x77144af6766c014ad05b0ae90979dc5df9978ecb5829c89925659445b8630dd2", + "input": "0xf2c42696", + "to": "0x4409921ae43a39a11d90f7b7f96cfd0b8093d9fc", + "blockNumber": "0x298603f", + "value": "0x0" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/okx_base_swap_tx_receipt.json b/core/crates/gem_evm/testdata/okx_base_swap_tx_receipt.json new file mode 100644 index 0000000000..f29cae36d4 --- /dev/null +++ b/core/crates/gem_evm/testdata/okx_base_swap_tx_receipt.json @@ -0,0 +1,22 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "gasUsed": "0x4dbd4", + "effectiveGasPrice": "0x989680", + "l1Fee": "0xf04a9bba", + "logs": [ + { + "address": "0x4409921ae43a39a11d90f7b7f96cfd0b8093d9fc", + "topics": [ + "0x1bb43f2da90e35f7b0cf38521ca95a49e68eb42fac49924930a5bd73cdf7576c" + ], + "data": "0x000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000f2eb9f69274678c76222b35eec7588a650000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f00000000000000000000000000000000000000000000000000000000000f2eb800000000000000000000000000000000000000000000000000000000000e2a59", + "transactionHash": "0x77144af6766c014ad05b0ae90979dc5df9978ecb5829c89925659445b8630dd2" + } + ], + "status": "0x1", + "blockNumber": "0x298603f" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/okx_bsc_swap_tx.json b/core/crates/gem_evm/testdata/okx_bsc_swap_tx.json new file mode 100644 index 0000000000..30b8b82b91 --- /dev/null +++ b/core/crates/gem_evm/testdata/okx_bsc_swap_tx.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gas": "0xe09c0", + "hash": "0xfae009821bc1a4442bcde724cdafa89d0cc0b4c41b26168630df66766575f494", + "input": "0xf2c42696000000000000000000000000000000000000000000000000000000003bbc864a0000000000000000000000000e09fabb73bd3ade0a17ecc321fd13a19e81ce82000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000007ef139738603e0000000000000000000000000000000000000000000000000000000069cb05e200000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000e09fabb73bd3ade0a17ecc321fd13a19e81ce8200000000000000000000000000000000000000000000000000000000000000010000000000000000000000007a7ad9aa93cd0a2d0255326e5fb145cec14997ff00000000000000000000000000000000000000000000000000000000000000010000000000000000000000007a7ad9aa93cd0a2d0255326e5fb145cec14997ff00000000000000000000000000000000000000000000000000000000000000010000000000000000000127101e213600fa9317feac4ef4087acdf5d0e25d71870000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000e09fabb73bd3ade0a17ecc321fd13a19e81ce82000000000000000000000000bb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c77777777111180000000000000000000000000000000000000080397aad944c6777777771111000000000064fa00a9ed787f3793db668bff3e6e6e7db0f92a1b800000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee3ca20afc2bbb0000004c4b400d9dab1a248f63b0a48965ba8435e4de7497a3dc", + "to": "0x3156020dff8d99af1ddc523ebdfb1ad2018554a0", + "blockNumber": "0x5588ce8", + "value": "0x0" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/okx_bsc_swap_tx_receipt.json b/core/crates/gem_evm/testdata/okx_bsc_swap_tx_receipt.json new file mode 100644 index 0000000000..2e9e030582 --- /dev/null +++ b/core/crates/gem_evm/testdata/okx_bsc_swap_tx_receipt.json @@ -0,0 +1,39 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "gasUsed": "0x34185", + "effectiveGasPrice": "0x2faf080", + "logs": [ + { + "address": "0x3156020dff8d99af1ddc523ebdfb1ad2018554a0", + "topics": [ + "0x7724394874fdd8ad13292ec739b441f85c6559f10dc4141b8d4c0fa4cbf55bdb" + ], + "data": "0x000000000000000000000000000000000000000000000000000000003bbc864a", + "transactionHash": "0xfae009821bc1a4442bcde724cdafa89d0cc0b4c41b26168630df66766575f494" + }, + { + "address": "0x3156020dff8d99af1ddc523ebdfb1ad2018554a0", + "topics": [ + "0x7970b0744fdb6cf0b120e5e0a5f4da3ab8cbec6d5d9ec8a4f327ccc1d8a5eb8b" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000000", + "transactionHash": "0xfae009821bc1a4442bcde724cdafa89d0cc0b4c41b26168630df66766575f494" + }, + { + "address": "0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "0x0000000000000000000000007a7ad9aa93cd0a2d0255326e5fb145cec14997ff" + ], + "data": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "transactionHash": "0xfae009821bc1a4442bcde724cdafa89d0cc0b4c41b26168630df66766575f494" + } + ], + "status": "0x1", + "blockNumber": "0x5588ce8" + } +} diff --git a/core/crates/gem_evm/testdata/okx_bsc_swap_tx_trace.json b/core/crates/gem_evm/testdata/okx_bsc_swap_tx_trace.json new file mode 100644 index 0000000000..2d88ef236f --- /dev/null +++ b/core/crates/gem_evm/testdata/okx_bsc_swap_tx_trace.json @@ -0,0 +1,18 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "output": "0x", + "stateDiff": { + "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4": { + "balance": { + "*": { + "from": "0x0", + "to": "0x80d27453078cc" + } + }, + "storage": {} + } + } + } +} diff --git a/core/crates/gem_evm/testdata/pancakeswap_bsc_bnb_cake_tx.json b/core/crates/gem_evm/testdata/pancakeswap_bsc_bnb_cake_tx.json new file mode 100644 index 0000000000..c94b4a71ad --- /dev/null +++ b/core/crates/gem_evm/testdata/pancakeswap_bsc_bnb_cake_tx.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gas": "0x493e0", + "hash": "0xdef456789012345678901234567890123456789012345678901234567890abcd", + "input": "0x24856bc30000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001", + "to": "0x1a0a18ac4becddbd6389559687d1a73d8927e416", + "blockNumber": "0x2faf080", + "value": "0x6f05b59d3b20000" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/pancakeswap_bsc_bnb_cake_tx_receipt.json b/core/crates/gem_evm/testdata/pancakeswap_bsc_bnb_cake_tx_receipt.json new file mode 100644 index 0000000000..a3c09b3442 --- /dev/null +++ b/core/crates/gem_evm/testdata/pancakeswap_bsc_bnb_cake_tx_receipt.json @@ -0,0 +1,23 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "gasUsed": "0x34185", + "effectiveGasPrice": "0x2faf080", + "logs": [ + { + "address": "0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000172fcd41e0913e95784454622d1c3724f546f849", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4" + ], + "data": "0x000000000000000000000000000000000000000000000000046b41d1ed8a0594", + "transactionHash": "0xdef456789012345678901234567890123456789012345678901234567890abcd" + } + ], + "status": "0x1", + "blockNumber": "0x2faf080" + } +} diff --git a/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx.json b/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx.json new file mode 100644 index 0000000000..57703ee186 --- /dev/null +++ b/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gas": "0x493e0", + "hash": "0xabc123def456789012345678901234567890123456789012345678901234abcd", + "input": "0x24856bc30000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001", + "to": "0x1a0a18ac4becddbd6389559687d1a73d8927e416", + "blockNumber": "0x2faf080", + "value": "0x0" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx_receipt.json b/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx_receipt.json new file mode 100644 index 0000000000..c504e78846 --- /dev/null +++ b/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx_receipt.json @@ -0,0 +1,23 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "gasUsed": "0x34185", + "effectiveGasPrice": "0x2faf080", + "logs": [ + { + "address": "0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "0x000000000000000000000000172fcd41e0913e95784454622d1c3724f546f849" + ], + "data": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "transactionHash": "0xabc123def456789012345678901234567890123456789012345678901234abcd" + } + ], + "status": "0x1", + "blockNumber": "0x2faf080" + } +} diff --git a/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx_trace.json b/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx_trace.json new file mode 100644 index 0000000000..2d88ef236f --- /dev/null +++ b/core/crates/gem_evm/testdata/pancakeswap_bsc_native_swap_tx_trace.json @@ -0,0 +1,18 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "output": "0x", + "stateDiff": { + "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4": { + "balance": { + "*": { + "from": "0x0", + "to": "0x80d27453078cc" + } + }, + "storage": {} + } + } + } +} diff --git a/core/crates/gem_evm/testdata/pancakeswap_bsc_swap_tx.json b/core/crates/gem_evm/testdata/pancakeswap_bsc_swap_tx.json new file mode 100644 index 0000000000..8e18353841 --- /dev/null +++ b/core/crates/gem_evm/testdata/pancakeswap_bsc_swap_tx.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gas": "0x493e0", + "hash": "0x1927c0c8a503fbedc86055d2f6adfa5b4e486a91052c7b0b080d3c138ed2b316", + "input": "0x24856bc30000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001", + "to": "0x1a0a18ac4becddbd6389559687d1a73d8927e416", + "blockNumber": "0x2faf080", + "value": "0x0" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/pancakeswap_bsc_swap_tx_receipt.json b/core/crates/gem_evm/testdata/pancakeswap_bsc_swap_tx_receipt.json new file mode 100644 index 0000000000..2d9f2c8361 --- /dev/null +++ b/core/crates/gem_evm/testdata/pancakeswap_bsc_swap_tx_receipt.json @@ -0,0 +1,43 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "gasUsed": "0x390e6", + "effectiveGasPrice": "0x2faf080", + "logs": [ + { + "address": "0x55d398326f99059ff775485246999027b3197955", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "0x0000000000000000000000001a0a18ac4becddbd6389559687d1a73d8927e416" + ], + "data": "0x0000000000000000000000000000000000000000000000001bc16d674ec80000", + "transactionHash": "0x1927c0c8a503fbedc86055d2f6adfa5b4e486a91052c7b0b080d3c138ed2b316" + }, + { + "address": "0x55d398326f99059ff775485246999027b3197955", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001a0a18ac4becddbd6389559687d1a73d8927e416", + "0x000000000000000000000000172fcd41e0913e95784454622d1c3724f546f849" + ], + "data": "0x0000000000000000000000000000000000000000000000001b9de674df070000", + "transactionHash": "0x1927c0c8a503fbedc86055d2f6adfa5b4e486a91052c7b0b080d3c138ed2b316" + }, + { + "address": "0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000172fcd41e0913e95784454622d1c3724f546f849", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4" + ], + "data": "0x00000000000000000000000000000000000000000000000011ad0747b6281650", + "transactionHash": "0x1927c0c8a503fbedc86055d2f6adfa5b4e486a91052c7b0b080d3c138ed2b316" + } + ], + "status": "0x1", + "blockNumber": "0x2faf080" + } +} diff --git a/core/crates/gem_evm/testdata/smartchain/transaction_staking_claim_rewards.json b/core/crates/gem_evm/testdata/smartchain/transaction_staking_claim_rewards.json new file mode 100644 index 0000000000..bf033070c7 --- /dev/null +++ b/core/crates/gem_evm/testdata/smartchain/transaction_staking_claim_rewards.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0x47B47f2586089F68Ec17384a437F96800f499274", + "gas": "0x249f0", + "hash": "0xdf26bfaf989ac4f17b425fb36cc14b64332d0390f67e95a70fca875860fc14d9", + "input": "0x9482f0a7000000000000000000000000b12e8137ef499a1d81552db11664a9e617fd350a", + "to": "0x0000000000000000000000000000000000002002", + "blockNumber": "0x1234", + "value": "0x0" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/smartchain/transaction_staking_claim_rewards_receipt.json b/core/crates/gem_evm/testdata/smartchain/transaction_staking_claim_rewards_receipt.json new file mode 100644 index 0000000000..964f85f000 --- /dev/null +++ b/core/crates/gem_evm/testdata/smartchain/transaction_staking_claim_rewards_receipt.json @@ -0,0 +1,23 @@ +{ + "jsonrpc": "2.0", + "result": { + "gasUsed": "0x186a0", + "effectiveGasPrice": "0x4a817c800", + "logs": [ + { + "address": "0x0000000000000000000000000000000000002002", + "topics": [ + "0xf7a40077ff7a04c7e61f6f26fb13774259ddf1b6bce9ecf26a8276cdd3992683", + "0x000000000000000000000000B12e8137eF499a1d81552DB11664a9E617fd350A", + "0x00000000000000000000000047B47f2586089F68Ec17384a437F96800f499274" + ], + "data": "0x0000000000000000000000000000000000000000000000003786b5ea2b989d0d", + "transactionHash": null + } + ], + "status": "0x1", + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockNumber": "0x1234" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/smartchain/transaction_staking_delegate.json b/core/crates/gem_evm/testdata/smartchain/transaction_staking_delegate.json new file mode 100644 index 0000000000..43b5eb5797 --- /dev/null +++ b/core/crates/gem_evm/testdata/smartchain/transaction_staking_delegate.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0x51ed60604637989d19d29e43c5d94b098a0d1af7", + "gas": "0x4474b", + "hash": "0xd85c4496230adf8a7c0fc1e98713127fb31a0f8f72874acea443e2f615f3c1b6", + "input": "0x982ef0a7000000000000000000000000d34403249b2d82aaddb14e778422c966265e5fb50000000000000000000000000000000000000000000000000000000000000000", + "to": "0x0000000000000000000000000000000000002002", + "blockNumber": "0x1234", + "value": "0xde0b6b3a7640000" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/smartchain/transaction_staking_delegate_receipt.json b/core/crates/gem_evm/testdata/smartchain/transaction_staking_delegate_receipt.json new file mode 100644 index 0000000000..7c0d3826dd --- /dev/null +++ b/core/crates/gem_evm/testdata/smartchain/transaction_staking_delegate_receipt.json @@ -0,0 +1,23 @@ +{ + "jsonrpc": "2.0", + "result": { + "gasUsed": "0x186a0", + "effectiveGasPrice": "0x4a817c800", + "logs": [ + { + "address": "0x0000000000000000000000000000000000002002", + "topics": [ + "0x24d7bda8602b916d64417f0dbfe2e2e88ec9b1157bd9f596dfdb91ba26624e04", + "0x000000000000000000000000d34403249B2d82AAdDB14e778422c966265e5Fb5", + "0x00000000000000000000000051eD60604637989d19D29e43c5D94B098A0d1Af7" + ], + "data": "0x00000000000000000000000000000000000000000000000d5cc0065cf2d900aa0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "transactionHash": null + } + ], + "status": "0x1", + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockNumber": "0x1234" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/smartchain/transaction_staking_redelegate.json b/core/crates/gem_evm/testdata/smartchain/transaction_staking_redelegate.json new file mode 100644 index 0000000000..3acf7142af --- /dev/null +++ b/core/crates/gem_evm/testdata/smartchain/transaction_staking_redelegate.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0xb5a0a71be7b79f2a8bd19b3a4d54d1b85fa2d50b", + "gas": "0x7690a", + "hash": "0xc31c1ff67a9b6784d5eb2aafe51fb8d93c64034514ab7423a0d12aa8ced3ee9c", + "input": "0x678dd4060000000000000000000000000813d0d092b97c157a8e68a65ccdf41b956883ae000000000000000000000000b58ac55eb6b10e4f7918d77c92aa1cf5bb2ded5e", + "to": "0x0000000000000000000000000000000000002002", + "blockNumber": "0x1234", + "value": "0x0" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/smartchain/transaction_staking_redelegate_receipt.json b/core/crates/gem_evm/testdata/smartchain/transaction_staking_redelegate_receipt.json new file mode 100644 index 0000000000..1d552bf44b --- /dev/null +++ b/core/crates/gem_evm/testdata/smartchain/transaction_staking_redelegate_receipt.json @@ -0,0 +1,24 @@ +{ + "jsonrpc": "2.0", + "result": { + "gasUsed": "0x186a0", + "effectiveGasPrice": "0x4a817c800", + "logs": [ + { + "address": "0x0000000000000000000000000000000000002002", + "topics": [ + "0xfdac6e81913996d95abcc289e90f2d8bd235487ce6fe6f821e7d21002a1915b4", + "0x0000000000000000000000000813D0D092b97C157A8e68A65ccdF41b956883ae", + "0x000000000000000000000000B58ac55EB6B10e4f7918D77C92aA1cF5bB2DEd5e", + "0x000000000000000000000000B5a0A71Be7B79F2A8Bd19B3A4D54d1b85fA2d50b" + ], + "data": "0x000000000000000000000000000000000000000000000000206ebdb8157d551f0000000000000000000000000000000000000000000000002068edb30143ec5300000000000000000000000000000000000000000000000020e60fe483aabb11", + "transactionHash": null + } + ], + "status": "0x1", + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockNumber": "0x1234" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/smartchain/transaction_staking_undelegate.json b/core/crates/gem_evm/testdata/smartchain/transaction_staking_undelegate.json new file mode 100644 index 0000000000..58d34434e7 --- /dev/null +++ b/core/crates/gem_evm/testdata/smartchain/transaction_staking_undelegate.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "result": { + "from": "0xa103B70852B1fE3eF3a0B60B818279F9D0D337d9", + "gas": "0x5dd94", + "hash": "0x564b45165bf777355c6e7de2dbd5b25f7cef5862385eb7cd67795c47f4358620", + "input": "0x5314b3e50000000000000000000000005c38ff8ca2b16099c086bf36546e99b13d152c4c", + "to": "0x0000000000000000000000000000000000002002", + "blockNumber": "0x1234", + "value": "0x0" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/smartchain/transaction_staking_undelegate_receipt.json b/core/crates/gem_evm/testdata/smartchain/transaction_staking_undelegate_receipt.json new file mode 100644 index 0000000000..13eebc6a18 --- /dev/null +++ b/core/crates/gem_evm/testdata/smartchain/transaction_staking_undelegate_receipt.json @@ -0,0 +1,23 @@ +{ + "jsonrpc": "2.0", + "result": { + "gasUsed": "0x186a0", + "effectiveGasPrice": "0x4a817c800", + "logs": [ + { + "address": "0x0000000000000000000000000000000000002002", + "topics": [ + "0x3aace7340547de7b9156593a7652dc07ee900cea3fd8f82cb6c9d38b40829802", + "0x0000000000000000000000005c38FF8Ca2b16099C086bF36546e99b13D152C4c", + "0x000000000000000000000000a103B70852B1fE3eF3a0B60B818279F9D0D337d9" + ], + "data": "0x0000000000000000000000000000000000000000000000000e539ee6df39e04c0000000000000000000000000000000000000000000000000e83bec8de346b99", + "transactionHash": null + } + ], + "status": "0x1", + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockNumber": "0x1234" + }, + "id": 1 +} diff --git a/core/crates/gem_evm/testdata/trace_replay_tx.json b/core/crates/gem_evm/testdata/trace_replay_tx.json new file mode 100644 index 0000000000..55512c0b56 --- /dev/null +++ b/core/crates/gem_evm/testdata/trace_replay_tx.json @@ -0,0 +1,22 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "chainId": "0x1", + "from": "0x52a07c930157d07d9effd147ecf41c5cbbc6000c", + "gas": "0x59988", + "gasPrice": "0x271d94900", + "hash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "input": "0x07ed23790000000000000000000000005141b82f5ffda4c6fe1e372978f1c5427640a190000000000000000000000000d0ec028a3d21533fdd200838f39c85b03679285d000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000005141b82f5ffda4c6fe1e372978f1c5427640a19000000000000000000000000052a07c930157d07d9effd147ecf41c5cbbc6000c00000000000000000000000000000000000000000000002a48acab6204b000000000000000000000000000000000000000000000000000000233f66d11f6e5d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000009c00000000000000000000000000000000009a20009880004f80004de00004e00a0744c8c09d0ec028a3d21533fdd200838f39c85b03679285d39041f1b366fe33f9a5a79de5120f2aee2577ebc0000000000000000000000000000000000000000000000001b0fcaab20030000492066a9893cc07d91d95644aedd05d03f95e1dba8afd0ec028a3d21533fdd200838f39c85b03679285d02e424856bc30000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000380000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003060b0e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000026000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000d0ec028a3d21533fdd200838f39c85b03679285d000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000c80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000017388be3000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000d0ec028a3d21533fdd200838f39c85b03679285d800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000005141b82f5ffda4c6fe1e372978f1c5427640a19000000000000000000000000000000000000000000000000000000000000000000020d6bdbf78a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48492066a9893cc07d91d95644aedd05d03f95e1dba8afa0b86991c6218b36c1d19d4a2e9eb0ce3606eb4802e424856bc30000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000380000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003060b0e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000260000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000233f66d11f6e5d0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb488000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111111125421ca6dc452d289314280a0f8842a65000000000000000000000000000000000000000000000000000000000000000000206b4be0b9111111125421ca6dc452d289314280a0f8842a652a6f45f2", + "nonce": "0xb7", + "r": "0xd6e87d6535476bc1cd93056e8c66e8e87df1e7928de302ba718667cc97083cce", + "s": "0x76c400b9817665177e310426b19933269965990526ca0f97210cf2760835ffd7", + "to": "0x111111125421ca6dc452d289314280a0f8842a65", + "transactionIndex": "0x1", + "type": "0x0", + "v": "0x25", + "value": "0x0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/trace_replay_tx_receipt.json b/core/crates/gem_evm/testdata/trace_replay_tx_receipt.json new file mode 100644 index 0000000000..debff4cee0 --- /dev/null +++ b/core/crates/gem_evm/testdata/trace_replay_tx_receipt.json @@ -0,0 +1,156 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "contractAddress": null, + "cumulativeGasUsed": "0x5207f", + "effectiveGasPrice": "0x271d94900", + "from": "0x52a07c930157d07d9effd147ecf41c5cbbc6000c", + "gasUsed": "0x3d3c3", + "logs": [ + { + "address": "0xd0ec028a3d21533fdd200838f39c85b03679285d", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x00000000000000000000000000000000000000000000002a48acab6204b00000", + "logIndex": "0x2", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x00000000000000000000000052a07c930157d07d9effd147ecf41c5cbbc6000c", + "0x0000000000000000000000005141b82f5ffda4c6fe1e372978f1c5427640a190" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + }, + { + "address": "0xd0ec028a3d21533fdd200838f39c85b03679285d", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x0000000000000000000000000000000000000000000000001b0fcaab20030000", + "logIndex": "0x3", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000005141b82f5ffda4c6fe1e372978f1c5427640a190", + "0x00000000000000000000000039041f1b366fe33f9a5a79de5120f2aee2577ebc" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + }, + { + "address": "0xd0ec028a3d21533fdd200838f39c85b03679285d", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x00000000000000000000000000000000000000000000002a2d9ce0b6e4ad0000", + "logIndex": "0x4", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000005141b82f5ffda4c6fe1e372978f1c5427640a190", + "0x00000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + }, + { + "address": "0x000000000004444c5dc75cb358380d2e3de08a90", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x0000000000000000000000000000000000000000000000000000000017e368e0ffffffffffffffffffffffffffffffffffffffffffffffd5d2631f491b5300000000000000000000000000000000000000152afabff164bbb4a450d341ee2bd600000000000000000000000000000000000000000000000005cbdcb556fc9fc400000000000000000000000000000000000000000000000000000000000450f60000000000000000000000000000000000000000000000000000000000002710", + "logIndex": "0x5", + "removed": false, + "topics": [ + "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f", + "0x8c5fe30cbd791d7333b6a77046d62275d37daaa7cedff74735945be86abe4a8c", + "0x00000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + }, + { + "address": "0xd0ec028a3d21533fdd200838f39c85b03679285d", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x00000000000000000000000000000000000000000000002a2d9ce0b6e4ad0000", + "logIndex": "0x6", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x00000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af", + "0x000000000000000000000000000000000004444c5dc75cb358380d2e3de08a90" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + }, + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x0000000000000000000000000000000000000000000000000000000017e368e0", + "logIndex": "0x7", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000000000000004444c5dc75cb358380d2e3de08a90", + "0x0000000000000000000000005141b82f5ffda4c6fe1e372978f1c5427640a190" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + }, + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x0000000000000000000000000000000000000000000000000000000017e368e0", + "logIndex": "0x8", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000005141b82f5ffda4c6fe1e372978f1c5427640a190", + "0x00000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + }, + { + "address": "0x000000000004444c5dc75cb358380d2e3de08a90", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x00000000000000000000000000000000000000000000000002442b58bef3a873ffffffffffffffffffffffffffffffffffffffffffffffffffffffffe81c9720000000000000000000000000000000000000000000033eef8820cf390a12091c00000000000000000000000000000000000000000000000025fe70492a02f8eafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffcf98200000000000000000000000000000000000000000000000000000000000001f4", + "logIndex": "0x9", + "removed": false, + "topics": [ + "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f", + "0x21c67e77068de97969ba93d4aab21826d33ca12bb9f565d8496e8fda8a82ca27", + "0x00000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + }, + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "blockHash": "0x8b2418375fdc6b40b0e65d7530447ba4e8999a717dffc3e7987fb60c536fa78d", + "blockNumber": "0x15b913a", + "data": "0x0000000000000000000000000000000000000000000000000000000017e368e0", + "logIndex": "0xa", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x00000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af", + "0x000000000000000000000000000000000004444c5dc75cb358380d2e3de08a90" + ], + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000020008000000000000000020080000001000800800000000000000040000000000000000000000000000000000000008000088004000000000000000000020000000000000000000000000000000000000001000000000000000000000000000000010000000000000080800000000000000000008000000000000010000000000000000000080080000000000280000000000100000010000000000000000000000000001000000000802000000000000001000000010000400400000000000000000000000000000000000080000004000000000000000000000000000000000000000004000", + "status": "0x1", + "to": "0x111111125421ca6dc452d289314280a0f8842a65", + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6", + "transactionIndex": "0x1", + "type": "0x0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/trace_replay_tx_trace.json b/core/crates/gem_evm/testdata/trace_replay_tx_trace.json new file mode 100644 index 0000000000..7e6ba7b072 --- /dev/null +++ b/core/crates/gem_evm/testdata/trace_replay_tx_trace.json @@ -0,0 +1,107 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "output": "0x00000000000000000000000000000000000000000000000002442b58bef3a87300000000000000000000000000000000000000000000002a48acab6204b00000", + "stateDiff": { + "0x000000000004444c5dc75cb358380d2e3de08a90": { + "balance": { + "*": { + "from": "0x8d849264a8118b46324", + "to": "0x8d846e21f2859c0bab1" + } + }, + "code": "=", + "nonce": "=", + "storage": { + "0x3913160fdb98a2fbe4a3fffcce590368855e5447df2d136f9d9744ee009cf7c8": { + "*": { + "from": "0x0000000027100000000450dc00000000001523c68b4a0593d19bce59e3c4a730", + "to": "0x0000000027100000000450f60000000000152afabff164bbb4a450d341ee2bd6" + } + }, + "0x3913160fdb98a2fbe4a3fffcce590368855e5447df2d136f9d9744ee009cf7ca": { + "*": { + "from": "0x0000000000000000000000000000073fc58e3fe050f2af4289feb871617bf75c", + "to": "0x00000000000000000000000000000752666926fa93e34162cb97b49c6e933206" + } + }, + "0xda8cac368d67cd2f2d8aaa5cc531768e0fa3b1d205c5c5de60da078e1f59bdfc": { + "*": { + "from": "0x0000000001f4000000fcf982000000000000000000033eeee7403fc734899a24", + "to": "0x0000000001f4000000fcf982000000000000000000033eef8820cf390a12091c" + } + }, + "0xda8cac368d67cd2f2d8aaa5cc531768e0fa3b1d205c5c5de60da078e1f59bdfe": { + "*": { + "from": "0x0000000000000000000000000000000000000ecf94a1c524065b670166835db7", + "to": "0x0000000000000000000000000000000000000ecf94b65f688766a097651c7113" + } + } + } + }, + "0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97": { + "balance": { + "*": { + "from": "0xca1fdfef3905c746", + "to": "0xca23eda98fc1bb04" + } + }, + "code": "=", + "nonce": "=", + "storage": {} + }, + "0x52a07c930157d07d9effd147ecf41c5cbbc6000c": { + "balance": { + "*": { + "from": "0x28268111de83a9d", + "to": "0x4bd382b322e4810" + } + }, + "code": "=", + "nonce": { + "*": { + "from": "0xb7", + "to": "0xb8" + } + }, + "storage": {} + }, + "0xd0ec028a3d21533fdd200838f39c85b03679285d": { + "balance": "=", + "code": "=", + "nonce": "=", + "storage": { + "0x717c6048be880b767a272c90264933f547a2181e4cb6c7c35de37357665cfb3c": { + "*": { + "from": "0x000000000000000000000000000000000000000000000907250e441d936ce6b8", + "to": "0x00000000000000000000000000000000000000000000093152ab24d47819e6b8" + } + }, + "0xa7ccc64583250b8b577a061487f409cf7bbce9f183b3ad3c7debc8d4f6394920": { + "*": { + "from": "0x00000000000000000000000000000000000000000000002a48acab6204b00000", + "to": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0xc8d3d8d0cc58a41f500646258ab3f9951c5ca8955ac19a8ffd35f744f3c56eb8": { + "*": { + "from": "0x00000000000000000000000000000000000000000000002a48acab6204b00000", + "to": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0xd4f4f89bbe367bf5bfda5e4bd1ebe495c25ad6970264652b61fcaff1833da29c": { + "*": { + "from": "0x000000000000000000000000000000000000000000000030896caacc1bb3fb3b", + "to": "0x000000000000000000000000000000000000000000000030a47c75773bb6fb3b" + } + } + } + } + }, + "trace": [], + "vmTrace": null, + "transactionHash": "0x23fe2ead060a3812a1f03c2e082b6fc8888b7c655a8f58f4ed19de00e8c9aaa6" + } + } + \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/transfer_erc20.json b/core/crates/gem_evm/testdata/transfer_erc20.json new file mode 100644 index 0000000000..9cae9e04df --- /dev/null +++ b/core/crates/gem_evm/testdata/transfer_erc20.json @@ -0,0 +1,25 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": null, + "blockNumber": "0x178fb", + "chainId": "0xa4b1", + "from": "0x8d7460E51bCf4eD26877cb77E56f3ce7E9f5EB8F", + "gas": "0x178fb", + "gasPrice": "0xa7d8c0", + "hash": "0xd6878ac03656ac15c9bc24cc4daf3ff276de637ec2d9708c420186f6cba9dc06", + "input": "0xa9059cbb0000000000000000000000002fc617e933a52713247ce25730f6695920b3befe000000000000000000000000000000000000000000000000000000000049430c66e0e7", + "maxFeePerGas": "0xa7d8c0", + "maxPriorityFeePerGas": "0xf4240", + "nonce": "0x5", + "r": "0x0", + "s": "0x0", + "to": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "transactionIndex": null, + "type": "0x2", + "v": "0x0", + "value": "0x0" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/transfer_erc20_receipt.json b/core/crates/gem_evm/testdata/transfer_erc20_receipt.json new file mode 100644 index 0000000000..ebffb32ff1 --- /dev/null +++ b/core/crates/gem_evm/testdata/transfer_erc20_receipt.json @@ -0,0 +1,39 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0xf419bef94067414619a3b7f396ab112f7f2c25a30f84d14b2e98f8f84d5ccccc", + "blockNumber": "0x150db7d1", + "contractAddress": null, + "cumulativeGasUsed": "0x11d421", + "effectiveGasPrice": "0x989680", + "from": "0x8d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "gasUsed": "0xd66f", + "gasUsedForL1": "0x3d0e", + "l1BlockNumber": "0x15c4d99", + "logs": [ + { + "address": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", + "blockHash": "0xf419bef94067414619a3b7f396ab112f7f2c25a30f84d14b2e98f8f84d5ccccc", + "blockNumber": "0x150db7d1", + "data": "0x000000000000000000000000000000000000000000000000000000000049430c", + "logIndex": "0x18", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "0x0000000000000000000000002fc617e933a52713247ce25730f6695920b3befe" + ], + "transactionHash": "0xd6878ac03656ac15c9bc24cc4daf3ff276de637ec2d9708c420186f6cba9dc06", + "transactionIndex": "0x7" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000060010000000000000000008000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008002000000000000000000000000000000000000000000000000000000000000000000000000000000080000000010000000000000000000000000000000", + "status": "0x1", + "timeboosted": false, + "to": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", + "transactionHash": "0xd6878ac03656ac15c9bc24cc4daf3ff276de637ec2d9708c420186f6cba9dc06", + "transactionIndex": "0x7", + "type": "0x2" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/transfer_high_gas_limit.json b/core/crates/gem_evm/testdata/transfer_high_gas_limit.json new file mode 100644 index 0000000000..c1b74be31a --- /dev/null +++ b/core/crates/gem_evm/testdata/transfer_high_gas_limit.json @@ -0,0 +1,22 @@ +{ + "jsonrpc": "2.0", + "id": 67, + "result": { + "type": "0x0", + "chainId": "0x3e7", + "nonce": "0x4123", + "gasPrice": "0x41d83264", + "gas": "0xc350", + "to": "0x0700572b54cca24dad0ed4cdad2c3d3ab6db652a", + "value": "0x260614888c17c000", + "input": "0x", + "r": "0xbf097311b31d5f4c840dd0a1bd9162f646a0b45b7e78e8036128635fc47553b9", + "s": "0x26f011d8fc2b646de1277d818e2f34afcda2e1d0cb252aa28f6d836d46d40f34", + "v": "0x7f2", + "hash": "0x0c0626172dbba6984a2e95b3abf1caba39cf11d3c9bc99d7de9ac814671c0cb1", + "blockHash": "0xb0976437659670cb247e1842fc870cd2978e42c9847d42597adeb221ad7aebec", + "blockNumber": "0x9919dc", + "transactionIndex": "0x4", + "from": "0x8d25fb438c6efcd08679ffa82766869b50e24608" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/transfer_high_gas_limit_receipt.json b/core/crates/gem_evm/testdata/transfer_high_gas_limit_receipt.json new file mode 100644 index 0000000000..393afc82ec --- /dev/null +++ b/core/crates/gem_evm/testdata/transfer_high_gas_limit_receipt.json @@ -0,0 +1,20 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "type": "0x0", + "status": "0x1", + "cumulativeGasUsed": "0xcfd40", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "transactionHash": "0x0c0626172dbba6984a2e95b3abf1caba39cf11d3c9bc99d7de9ac814671c0cb1", + "transactionIndex": "0x4", + "blockHash": "0xb0976437659670cb247e1842fc870cd2978e42c9847d42597adeb221ad7aebec", + "blockNumber": "0x9919dc", + "gasUsed": "0x5208", + "effectiveGasPrice": "0x41d83264", + "from": "0x8d25fb438c6efcd08679ffa82766869b50e24608", + "to": "0x0700572b54cca24dad0ed4cdad2c3d3ab6db652a", + "contractAddress": null + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/transfer_nft_eip1155.json b/core/crates/gem_evm/testdata/transfer_nft_eip1155.json new file mode 100644 index 0000000000..4063028ba4 --- /dev/null +++ b/core/crates/gem_evm/testdata/transfer_nft_eip1155.json @@ -0,0 +1,26 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0xcd733885191f005a16dcc5042024445436ff3e035d5d919fb4cd18acf67e8199", + "blockNumber": "0x142e927", + "chainId": "0x1", + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gas": "0xdc6f", + "gasPrice": "0x3cfb934a9", + "hash": "0x7fc1f2b8a60a153a91f4d51edb6e4d85a7fa1f56b8404fab275f625498b68fe4", + "input": "0xf242432a000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4000000000000000000000000ee67a32a55318a211ce4bb5051ed98c679851143ad2312645535838deee888a3d0fdb2f0d25750713906b641d1acc248fe49dc99000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "maxFeePerGas": "0x53fb36972", + "maxPriorityFeePerGas": "0x7478f41a", + "nonce": "0x26", + "r": "0x5288f8370f72c9d902ea92bda39cbb21ba805156cbd039c44740bd1539503a55", + "s": "0x44a283573d7e29dddf226d29b9878266f1d8174c5bd7417c079b3191e7904c5a", + "to": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "transactionIndex": "0x42", + "type": "0x2", + "v": "0x1", + "value": "0x0", + "yParity": "0x1" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/transfer_nft_eip1155_receipt.json b/core/crates/gem_evm/testdata/transfer_nft_eip1155_receipt.json new file mode 100644 index 0000000000..91bc945266 --- /dev/null +++ b/core/crates/gem_evm/testdata/transfer_nft_eip1155_receipt.json @@ -0,0 +1,37 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0xcd733885191f005a16dcc5042024445436ff3e035d5d919fb4cd18acf67e8199", + "blockNumber": "0x142e927", + "contractAddress": null, + "cumulativeGasUsed": "0x5dacc5", + "effectiveGasPrice": "0x3cfb934a9", + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gasUsed": "0x9188", + "logs": [ + { + "address": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "blockHash": "0xcd733885191f005a16dcc5042024445436ff3e035d5d919fb4cd18acf67e8199", + "blockNumber": "0x142e927", + "data": "0xad2312645535838deee888a3d0fdb2f0d25750713906b641d1acc248fe49dc990000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0xc7", + "removed": false, + "topics": [ + "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "0x000000000000000000000000ee67a32a55318a211ce4bb5051ed98c679851143" + ], + "transactionHash": "0x7fc1f2b8a60a153a91f4d51edb6e4d85a7fa1f56b8404fab275f625498b68fe4", + "transactionIndex": "0x42" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000020000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000040000000000000000800000008000000000000000000000080000000000800000000000000000000000000000000000000080000000000", + "status": "0x1", + "to": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "transactionHash": "0x7fc1f2b8a60a153a91f4d51edb6e4d85a7fa1f56b8404fab275f625498b68fe4", + "transactionIndex": "0x42", + "type": "0x2" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/transfer_nft_eip721.json b/core/crates/gem_evm/testdata/transfer_nft_eip721.json new file mode 100644 index 0000000000..36861b93cf --- /dev/null +++ b/core/crates/gem_evm/testdata/transfer_nft_eip721.json @@ -0,0 +1,26 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0x7dc4876f2271575d7376bb3df992fa1c0ade9d12a4c73aa39e9a794b1f9d9cba", + "blockNumber": "0x15c384e", + "chainId": "0x1", + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gas": "0x19ed6", + "gasPrice": "0x25ffa761", + "hash": "0x98dd4d9a586620f84e8066f1b015d663f9c0c94c4e0e02377840c3e6d43e2ad3", + "input": "0x23b872dd000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4000000000000000000000000f1158986419f6058231b0dbd7a78ff0674ebbc5000000000000000000000000000000000000000000000000000000000000023b7", + "maxFeePerGas": "0x25ffa761", + "maxPriorityFeePerGas": "0x331df00", + "nonce": "0x36", + "r": "0x6b39ddc0b602680658ba5c4dc5ae0227c264df9a0d10e2c7a37799048f1156bd", + "s": "0x1a7d85b0e9054a9bac4d6098f869dd1eeaf346c3c919d0aa58d219155ab72473", + "to": "0x47a00fc8590c11be4c419d9ae50dec267b6e24ee", + "transactionIndex": "0xe9", + "type": "0x2", + "v": "0x0", + "value": "0x0", + "yParity": "0x0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/transfer_nft_eip721_receipt.json b/core/crates/gem_evm/testdata/transfer_nft_eip721_receipt.json new file mode 100644 index 0000000000..204f21c408 --- /dev/null +++ b/core/crates/gem_evm/testdata/transfer_nft_eip721_receipt.json @@ -0,0 +1,53 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x7dc4876f2271575d7376bb3df992fa1c0ade9d12a4c73aa39e9a794b1f9d9cba", + "blockNumber": "0x15c384e", + "contractAddress": null, + "cumulativeGasUsed": "0x12f54e6", + "effectiveGasPrice": "0x25ffa761", + "from": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "gasUsed": "0x10690", + "logs": [ + { + "address": "0x47a00fc8590c11be4c419d9ae50dec267b6e24ee", + "blockHash": "0x7dc4876f2271575d7376bb3df992fa1c0ade9d12a4c73aa39e9a794b1f9d9cba", + "blockNumber": "0x15c384e", + "data": "0x", + "logIndex": "0x25e", + "removed": false, + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x00000000000000000000000000000000000000000000000000000000000023b7" + ], + "transactionHash": "0x98dd4d9a586620f84e8066f1b015d663f9c0c94c4e0e02377840c3e6d43e2ad3", + "transactionIndex": "0xe9" + }, + { + "address": "0x47a00fc8590c11be4c419d9ae50dec267b6e24ee", + "blockHash": "0x7dc4876f2271575d7376bb3df992fa1c0ade9d12a4c73aa39e9a794b1f9d9cba", + "blockNumber": "0x15c384e", + "data": "0x", + "logIndex": "0x25f", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "0x000000000000000000000000f1158986419f6058231b0dbd7a78ff0674ebbc50", + "0x00000000000000000000000000000000000000000000000000000000000023b7" + ], + "transactionHash": "0x98dd4d9a586620f84e8066f1b015d663f9c0c94c4e0e02377840c3e6d43e2ad3", + "transactionIndex": "0xe9" + } + ], + "logsBloom": "0x00000000000000000000000000000000800000000000000000000002000000000000000000000000000000000000000000000000000020000000000000200000000000010000000000000008000000000000000002000000000000000000000000000000020000000000000000000800000000000000001000000010000000000000000000000080000000000000000000000000000000000000000000000000020000000000000000810000020000000000000200000000000000000000000000000002000000000000000000000000000000000000000000000000000020000010000000000800000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0x47a00fc8590c11be4c419d9ae50dec267b6e24ee", + "transactionHash": "0x98dd4d9a586620f84e8066f1b015d663f9c0c94c4e0e02377840c3e6d43e2ad3", + "transactionIndex": "0xe9", + "type": "0x2" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/uniswap_permit2.json b/core/crates/gem_evm/testdata/uniswap_permit2.json new file mode 100644 index 0000000000..e2164ddab9 --- /dev/null +++ b/core/crates/gem_evm/testdata/uniswap_permit2.json @@ -0,0 +1,66 @@ +{ + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitSingle": [ + { + "name": "details", + "type": "PermitDetails" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "sigDeadline", + "type": "uint256" + } + ], + "PermitDetails": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint160" + }, + { + "name": "expiration", + "type": "uint48" + }, + { + "name": "nonce", + "type": "uint48" + } + ] + }, + "primaryType": "PermitSingle", + "message": { + "details": { + "token": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "amount": "1461501637330902918203684832716283019655932542975", + "expiration": "1732780554", + "nonce": "0" + }, + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "sigDeadline": "1730190354" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v2_token_eth_tx.json b/core/crates/gem_evm/testdata/v2_token_eth_tx.json new file mode 100644 index 0000000000..196d35b3e1 --- /dev/null +++ b/core/crates/gem_evm/testdata/v2_token_eth_tx.json @@ -0,0 +1,26 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0x0e3e65227453b7415d1091254894c5ba32924f9b67e2c73f780914f4de2a408e", + "blockNumber": "0x15c632a", + "chainId": "0x1", + "from": "0x8167ae7e480b7ea52262470cf5ea8c74d9a1b548", + "gas": "0x7a120", + "gasPrice": "0x3bbef48f", + "hash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0", + "input": "0x791ac947000000000000000000000000000000000000000000015df593bb4734800000000000000000000000000000000000000000000000000000000435c9befa70280000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000008167ae7e480b7ea52262470cf5ea8c74d9a1b54800000000000000000000000000000000000000000000000000000000686538d30000000000000000000000000000000000000000000000000000000000000002000000000000000000000000292fcdd1b104de5a00250febba9bc6a5092a0076000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "maxFeePerGas": "0xe8681291", + "maxPriorityFeePerGas": "0x68e7780", + "nonce": "0x18fc", + "r": "0x47396963bbff22a99cfc2eebc2a62fc7ae4cb9ea80b4ae715e41d6cef29f0eb3", + "s": "0x3a9a8f6183901f0635f0244525eca5a3803399cfd2f662364ac44bc4854192f6", + "to": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + "transactionIndex": "0x92", + "type": "0x2", + "v": "0x0", + "value": "0x0", + "yParity": "0x0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v2_token_eth_tx_receipt.json b/core/crates/gem_evm/testdata/v2_token_eth_tx_receipt.json new file mode 100644 index 0000000000..2cfca0626b --- /dev/null +++ b/core/crates/gem_evm/testdata/v2_token_eth_tx_receipt.json @@ -0,0 +1,108 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x0e3e65227453b7415d1091254894c5ba32924f9b67e2c73f780914f4de2a408e", + "blockNumber": "0x15c632a", + "contractAddress": null, + "cumulativeGasUsed": "0x123470c", + "effectiveGasPrice": "0x3bbef48f", + "from": "0x8167ae7e480b7ea52262470cf5ea8c74d9a1b548", + "gasUsed": "0x2105d", + "logs": [ + { + "address": "0x292fcdd1b104de5a00250febba9bc6a5092a0076", + "blockHash": "0x0e3e65227453b7415d1091254894c5ba32924f9b67e2c73f780914f4de2a408e", + "blockNumber": "0x15c632a", + "data": "0x000000000000000000000000000000000000000000015df593bb473480000000", + "logIndex": "0x1e7", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000008167ae7e480b7ea52262470cf5ea8c74d9a1b548", + "0x000000000000000000000000f07a84f0732dfe8eea0d3961bcd8f62c761ff508" + ], + "transactionHash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0", + "transactionIndex": "0x92" + }, + { + "address": "0x292fcdd1b104de5a00250febba9bc6a5092a0076", + "blockHash": "0x0e3e65227453b7415d1091254894c5ba32924f9b67e2c73f780914f4de2a408e", + "blockNumber": "0x15c632a", + "data": "0x0000000000000000000000000000000000000000c1263b8e4e332dd1dda40000", + "logIndex": "0x1e8", + "removed": false, + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x0000000000000000000000008167ae7e480b7ea52262470cf5ea8c74d9a1b548", + "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d" + ], + "transactionHash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0", + "transactionIndex": "0x92" + }, + { + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "blockHash": "0x0e3e65227453b7415d1091254894c5ba32924f9b67e2c73f780914f4de2a408e", + "blockNumber": "0x15c632a", + "data": "0x0000000000000000000000000000000000000000000000000436ddef9fd901e0", + "logIndex": "0x1e9", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000f07a84f0732dfe8eea0d3961bcd8f62c761ff508", + "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d" + ], + "transactionHash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0", + "transactionIndex": "0x92" + }, + { + "address": "0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508", + "blockHash": "0x0e3e65227453b7415d1091254894c5ba32924f9b67e2c73f780914f4de2a408e", + "blockNumber": "0x15c632a", + "data": "0x0000000000000000000000000000000000000000087483d80d9c124dbd94d39000000000000000000000000000000000000000000000001a20b217882db904df", + "logIndex": "0x1ea", + "removed": false, + "topics": [ + "0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1" + ], + "transactionHash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0", + "transactionIndex": "0x92" + }, + { + "address": "0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508", + "blockHash": "0x0e3e65227453b7415d1091254894c5ba32924f9b67e2c73f780914f4de2a408e", + "blockNumber": "0x15c632a", + "data": "0x000000000000000000000000000000000000000000015df593bb473480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000436ddef9fd901e0", + "logIndex": "0x1eb", + "removed": false, + "topics": [ + "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822", + "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d", + "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d" + ], + "transactionHash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0", + "transactionIndex": "0x92" + }, + { + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "blockHash": "0x0e3e65227453b7415d1091254894c5ba32924f9b67e2c73f780914f4de2a408e", + "blockNumber": "0x15c632a", + "data": "0x0000000000000000000000000000000000000000000000000436ddef9fd901e0", + "logIndex": "0x1ec", + "removed": false, + "topics": [ + "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65", + "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d" + ], + "transactionHash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0", + "transactionIndex": "0x92" + } + ], + "logsBloom": "0x00208000000000000000000080000000000000000000004000010000000000000000000000000080000000000000000002000000080000000000000000200000000000000200000000000008080020200000000000400000000000000000008000000000000000000000000000000000000000000000040000000010000000000000000000000000004000000000000000000000000000080000004000000000020000000000000000040000000000000000000000000000000000000100000000000002000000000000000000000000000000000000001000000002000020000010200000000000000000000000000000010000000004000000000000000040", + "status": "0x1", + "to": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + "transactionHash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0", + "transactionIndex": "0x92", + "type": "0x2" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v2_token_eth_tx_trace.json b/core/crates/gem_evm/testdata/v2_token_eth_tx_trace.json new file mode 100644 index 0000000000..78aae527df --- /dev/null +++ b/core/crates/gem_evm/testdata/v2_token_eth_tx_trace.json @@ -0,0 +1,107 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "output": "0x", + "stateDiff": { + "0x292fcdd1b104de5a00250febba9bc6a5092a0076": { + "balance": "=", + "code": "=", + "nonce": "=", + "storage": { + "0x21f4dff8f228be71209a889ce2341292561eff3c9b3f931dd6f9fb1ad1b9680b": { + "*": { + "from": "0x000000000000000000000000000000000000000000015df593bb473480000000", + "to": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0x5e953b5c4671e6ccd800955b0447e17289d4d2ce41534d0b6ecc5510029b8f52": { + "*": { + "from": "0x0000000000000000000000000000000000000000c1279983e1ee75065da40000", + "to": "0x0000000000000000000000000000000000000000c1263b8e4e332dd1dda40000" + } + }, + "0x8372d7f2e58c2dc1f2d0838d3bc7bdeebd3ff3e339973dc0866d35e20f9df54b": { + "*": { + "from": "0x0000000000000000000000000000000000000000087325e279e0cb193d94d390", + "to": "0x0000000000000000000000000000000000000000087483d80d9c124dbd94d390" + } + } + } + }, + "0x8167ae7e480b7ea52262470cf5ea8c74d9a1b548": { + "balance": { + "*": { + "from": "0xb4b5b5b57d30a9fc", + "to": "0xb8ec18559043e3e9" + } + }, + "code": "=", + "nonce": { + "*": { + "from": "0x18fc", + "to": "0x18fd" + } + }, + "storage": {} + }, + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": { + "balance": { + "*": { + "from": "0x23aae6c03d2c40f124c2e", + "to": "0x23aae67ccf4d46f394a4e" + } + }, + "code": "=", + "nonce": "=", + "storage": { + "0x0a052f88da135fe1a7e66421535ade2e03bc45f39788162e78eccf9d16843721": { + "*": { + "from": "0x00000000000000000000000000000000000000000000001a24e8f577cd9206bf", + "to": "0x00000000000000000000000000000000000000000000001a20b217882db904df" + } + } + } + }, + "0xdadb0d80178819f2319190d340ce9a924f783711": { + "balance": { + "*": { + "from": "0x2d6b65d4495ae54ea", + "to": "0x2d6b66acccde7be6a" + } + }, + "code": "=", + "nonce": "=", + "storage": {} + }, + "0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508": { + "balance": "=", + "code": "=", + "nonce": "=", + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000008": { + "*": { + "from": "0x6865361f00000000001a24e8f577cd9206bf0000087325e279e0cb193d94d390", + "to": "0x686537ab00000000001a20b217882db904df0000087483d80d9c124dbd94d390" + } + }, + "0x0000000000000000000000000000000000000000000000000000000000000009": { + "*": { + "from": "0x00000000000000000000000000000000000afae876a943313f4220714d52b914", + "to": "0x00000000000000000000000000000000000afaed3fe8b468d4482fa83baabf48" + } + }, + "0x000000000000000000000000000000000000000000000000000000000000000a": { + "*": { + "from": "0x000000000000000000000000f18c440d92bc261156fa00772ddec90667b2b934", + "to": "0x000000000000000000000000f18cc40a49d8117bfa8d99cacea23e136b87cd30" + } + } + } + } + }, + "trace": [], + "vmTrace": null, + "transactionHash": "0xd0735ead9772e2e6a72d8fabc90f718beba823f52158c96f648f8bfe460d15f0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v3_eth_token_tx.json b/core/crates/gem_evm/testdata/v3_eth_token_tx.json new file mode 100644 index 0000000000..859a40e788 --- /dev/null +++ b/core/crates/gem_evm/testdata/v3_eth_token_tx.json @@ -0,0 +1,26 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0x6035fd70b6a12c50d236ef5bffbdc51ecffe8825aa3ff917842aa281489363f8", + "blockNumber": "0x158fbcb", + "chainId": "0x1", + "from": "0x10e11c7368552d5ab9ef5eed496f614fbaae9f0d", + "gas": "0x4a766", + "gasPrice": "0x77bdc03a", + "hash": "0xfdbc3270b7edf1e63c0aaec9466a71348a1e63bdf069af2d51e9902f996e9d75", + "input": "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000683c25b000000000000000000000000000000000000000000000000000000000000000030b050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000003ff2e795f500000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc000000000000000000000000000000000000000000000000000051dac207a000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000010e11c7368552d5ab9ef5eed496f614fbaae9f0d000000000000000000000000000000000000000000000000003fa10cd3ed60000000000000000000000000000000000000000000000000000001c6d9095173d200000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2002710cf0c122c6b73ff809c693db761e7baebe62b6a2e000000000000000000000000000000000000000000", + "maxFeePerGas": "0x7c3f03d6", + "maxPriorityFeePerGas": "0x3b9aca00", + "nonce": "0x1", + "r": "0x6895172faa40c5a51381447f6fb3956b85aee6b0b9f253074964dc004054f34a", + "s": "0x38703c03b38ed56ac20dc98e40215b63b4018f19326eadaec7b639b40414e999", + "to": "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "transactionIndex": "0x2c", + "type": "0x2", + "v": "0x1", + "value": "0x3ff2e795f50000", + "yParity": "0x1" + } +} diff --git a/core/crates/gem_evm/testdata/v3_eth_token_tx_receipt.json b/core/crates/gem_evm/testdata/v3_eth_token_tx_receipt.json new file mode 100644 index 0000000000..55bf278cd9 --- /dev/null +++ b/core/crates/gem_evm/testdata/v3_eth_token_tx_receipt.json @@ -0,0 +1,95 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x6035fd70b6a12c50d236ef5bffbdc51ecffe8825aa3ff917842aa281489363f8", + "blockNumber": "0x158fbcb", + "contractAddress": null, + "cumulativeGasUsed": "0x56c021", + "effectiveGasPrice": "0x77bdc03a", + "from": "0x10e11c7368552d5ab9ef5eed496f614fbaae9f0d", + "gasUsed": "0x2a8b7", + "logs": [ + { + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "blockHash": "0x6035fd70b6a12c50d236ef5bffbdc51ecffe8825aa3ff917842aa281489363f8", + "blockNumber": "0x158fbcb", + "data": "0x000000000000000000000000000000000000000000000000003ff2e795f50000", + "logIndex": "0xa4", + "removed": false, + "topics": [ + "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c", + "0x0000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad" + ], + "transactionHash": "0xfdbc3270b7edf1e63c0aaec9466a71348a1e63bdf069af2d51e9902f996e9d75", + "transactionIndex": "0x2c" + }, + { + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "blockHash": "0x6035fd70b6a12c50d236ef5bffbdc51ecffe8825aa3ff917842aa281489363f8", + "blockNumber": "0x158fbcb", + "data": "0x000000000000000000000000000000000000000000000000000051dac207a000", + "logIndex": "0xa5", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0xfdbc3270b7edf1e63c0aaec9466a71348a1e63bdf069af2d51e9902f996e9d75", + "transactionIndex": "0x2c" + }, + { + "address": "0xcf0c122c6b73ff809c693db761e7baebe62b6a2e", + "blockHash": "0x6035fd70b6a12c50d236ef5bffbdc51ecffe8825aa3ff917842aa281489363f8", + "blockNumber": "0x158fbcb", + "data": "0x0000000000000000000000000000000000000000000000000001d270555f5ad5", + "logIndex": "0xa6", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000e41552e6212cb6f7faa381c7bc9434c58bf28ce1", + "0x00000000000000000000000010e11c7368552d5ab9ef5eed496f614fbaae9f0d" + ], + "transactionHash": "0xfdbc3270b7edf1e63c0aaec9466a71348a1e63bdf069af2d51e9902f996e9d75", + "transactionIndex": "0x2c" + }, + { + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "blockHash": "0x6035fd70b6a12c50d236ef5bffbdc51ecffe8825aa3ff917842aa281489363f8", + "blockNumber": "0x158fbcb", + "data": "0x000000000000000000000000000000000000000000000000003fa10cd3ed6000", + "logIndex": "0xa7", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "0x000000000000000000000000e41552e6212cb6f7faa381c7bc9434c58bf28ce1" + ], + "transactionHash": "0xfdbc3270b7edf1e63c0aaec9466a71348a1e63bdf069af2d51e9902f996e9d75", + "transactionIndex": "0x2c" + }, + { + "address": "0xe41552e6212cb6f7faa381c7bc9434c58bf28ce1", + "blockHash": "0x6035fd70b6a12c50d236ef5bffbdc51ecffe8825aa3ff917842aa281489363f8", + "blockNumber": "0x158fbcb", + "data": "0x000000000000000000000000000000000000000000000000003fa10cd3ed6000fffffffffffffffffffffffffffffffffffffffffffffffffffe2d8faaa0a52b00000000000000000000000000000000000000002a9e37922972a5a21b4fe19500000000000000000000000000000000000000000000000000fab5ae79357ccaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff73ec", + "logIndex": "0xa8", + "removed": false, + "topics": [ + "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67", + "0x0000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "0x00000000000000000000000010e11c7368552d5ab9ef5eed496f614fbaae9f0d" + ], + "transactionHash": "0xfdbc3270b7edf1e63c0aaec9466a71348a1e63bdf069af2d51e9902f996e9d75", + "transactionIndex": "0x2c" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000010080020000004000000000000000000081000000800000008000000000000000000000000000000008020000000100000000000000000080000000000000000000000000000000010000800000008000000000010000000000000000000100001000000000000000000000000000000000000000000000000000000000000000000000000002000800000000000100002000000000000002000000000000002000000000000000000000000000000200000000000100000000000000000001000000000400000000000000000", + "status": "0x1", + "to": "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "transactionHash": "0xfdbc3270b7edf1e63c0aaec9466a71348a1e63bdf069af2d51e9902f996e9d75", + "transactionIndex": "0x2c", + "type": "0x2" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v3_pol_usdt_tx.json b/core/crates/gem_evm/testdata/v3_pol_usdt_tx.json new file mode 100644 index 0000000000..ae5824e689 --- /dev/null +++ b/core/crates/gem_evm/testdata/v3_pol_usdt_tx.json @@ -0,0 +1,25 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "chainId": "0x89", + "from": "0x8f4b6cbF3373e065aEb3FEc6027Ff8Ca9a665DE2", + "gas": "0x4bc36", + "gasPrice": "0xae7497b56", + "hash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "input": "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000683c54ba00000000000000000000000000000000000000000000000000000000000000040b000604000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000142a897d0f3d500000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000142a897d0f3d500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002b0d500b1d8e8ef31e21c99d1db9a6444d3adf12700001f4c2132d05d31c914a87c6611c10748aeb04b58e8f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c2132d05d31c914a87c6611c10748aeb04b58e8f0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc00000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c2132d05d31c914a87c6611c10748aeb04b58e8f0000000000000000000000008f4b6cbf3373e065aeb3fec6027ff8ca9a665de200000000000000000000000000000000000000000000000000000000048cecb3", + "maxFeePerGas": "0xae7497b56", + "maxPriorityFeePerGas": "0xae7497b0d", + "nonce": "0x0", + "r": "0x0", + "s": "0x0", + "to": "0xec7BE89e9d109e7e3Fec59c222CF297125FEFda2", + "transactionIndex": null, + "type": "0x2", + "v": "0x0", + "value": "0x142a897d0f3d500000" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v3_pol_usdt_tx_receipt.json b/core/crates/gem_evm/testdata/v3_pol_usdt_tx_receipt.json new file mode 100644 index 0000000000..d35cd3a5d8 --- /dev/null +++ b/core/crates/gem_evm/testdata/v3_pol_usdt_tx_receipt.json @@ -0,0 +1,158 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "contractAddress": null, + "cumulativeGasUsed": "0x84d567", + "effectiveGasPrice": "0xae7497b56", + "from": "0x8f4b6cbf3373e065aeb3fec6027ff8ca9a665de2", + "gasUsed": "0x266a8", + "logs": [ + { + "address": "0x0000000000000000000000000000000000001010", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x0000000000000000000000000000000000000000000000142a897d0f3d5000000000000000000000000000000000000000000000000000143cc721ee9938a3dc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000123da4df5be8a3dc0000000000000000000000000000000000000000000000142a897d0f3d500000", + "logIndex": "0x103", + "removed": false, + "topics": [ + "0xe6497e3ee548a3372136af2fcb0696db31fc6cf20260707645068bd3fe97f3c4", + "0x0000000000000000000000000000000000000000000000000000000000001010", + "0x0000000000000000000000008f4b6cbf3373e065aeb3fec6027ff8ca9a665de2", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + }, + { + "address": "0x0000000000000000000000000000000000001010", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x0000000000000000000000000000000000000000000000142a897d0f3d5000000000000000000000000000000000000000000000000000142a897d0f3d500000000000000000000000000000000000000000000000f326f8e6bbc905f160105e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f3270d114546152eb0105e", + "logIndex": "0x104", + "removed": false, + "topics": [ + "0xe6497e3ee548a3372136af2fcb0696db31fc6cf20260707645068bd3fe97f3c4", + "0x0000000000000000000000000000000000000000000000000000000000001010", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2", + "0x0000000000000000000000000d500b1d8e8ef31e21c99d1db9a6444d3adf1270" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + }, + { + "address": "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x0000000000000000000000000000000000000000000000142a897d0f3d500000", + "logIndex": "0x105", + "removed": false, + "topics": [ + "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + }, + { + "address": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x0000000000000000000000000000000000000000000000000000000004b09db0", + "logIndex": "0x106", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000009b08288c3be4f62bbf8d1c20ac9c5e6f9467d8b7", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + }, + { + "address": "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x0000000000000000000000000000000000000000000000142a897d0f3d500000", + "logIndex": "0x107", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2", + "0x0000000000000000000000009b08288c3be4f62bbf8d1c20ac9c5e6f9467d8b7" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + }, + { + "address": "0x9b08288c3be4f62bbf8d1c20ac9c5e6f9467d8b7", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x0000000000000000000000000000000000000000000000142a897d0f3d500000fffffffffffffffffffffffffffffffffffffffffffffffffffffffffb4f62500000000000000000000000000000000000000000000007b7c1b44d4f8129aaa20000000000000000000000000000000000000000000000005cab9f7b9e3bff6dfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffb8bf1", + "logIndex": "0x108", + "removed": false, + "topics": [ + "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + }, + { + "address": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x00000000000000000000000000000000000000000000000000000000000600c9", + "logIndex": "0x109", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + }, + { + "address": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x0000000000000000000000000000000000000000000000000000000004aa9ce7", + "logIndex": "0x10a", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ec7be89e9d109e7e3fec59c222cf297125fefda2", + "0x0000000000000000000000008f4b6cbf3373e065aeb3fec6027ff8ca9a665de2" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + }, + { + "address": "0x0000000000000000000000000000000000001010", + "blockHash": "0xc1d60b7d87b6d1ff8c618874b443f8c6129ea786ef3c28ff6cd0dd6964a858de", + "blockNumber": "0x44e2be1", + "data": "0x000000000000000000000000000000000000000000000000001a2de20559ee880000000000000000000000000000000000000000000000143cfac33b46a3d0000000000000000000000000000000000000000000000004cd0d474f30d839b4e20000000000000000000000000000000000000000000000143ce095594149e1780000000000000000000000000000000000000000000004cd0d617d12dd93a36a", + "logIndex": "0x10b", + "removed": false, + "topics": [ + "0x4dfe1bbbcf077ddc3e01291eea2d5c70c2b422b415d95645b9adcfd678cb1d63", + "0x0000000000000000000000000000000000000000000000000000000000001010", + "0x0000000000000000000000008f4b6cbf3373e065aeb3fec6027ff8ca9a665de2", + "0x000000000000000000000000b9ede6f94d192073d8eaf85f8db677133d483249" + ], + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c" + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000100008000000020000000000000000000000000001000400800000008000000800000040000000000000100008000020000000000000000000020080000000000000000000000000080000010404920000000004000080000000000000000000000100001000000000000000000000000200000020000000000000000000000004140000000000000000000000000004000000002000000000001000000000000600000000000800000108000000000000004000000000004100000000000000000800000000000600000000800100800", + "status": "0x1", + "to": "0xec7be89e9d109e7e3fec59c222cf297125fefda2", + "transactionHash": "0x815759e89e4290873109e482f1f3284cdaca3eb76ff24591a9ac2c6056a2dbcc", + "transactionIndex": "0x4c", + "type": "0x2" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v3_token_eth_tx.json b/core/crates/gem_evm/testdata/v3_token_eth_tx.json new file mode 100644 index 0000000000..d4fe5da466 --- /dev/null +++ b/core/crates/gem_evm/testdata/v3_token_eth_tx.json @@ -0,0 +1,26 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "chainId": "0x2105", + "from": "0x985cf24b63a98510298997af83a31d8625c09ba5", + "gas": "0x699f3", + "gasPrice": "0x5fc1eb0", + "hash": "0xc6c2898ddc2d2165bc6c018ec6ebf58d99922c74b9a0e323b50c029d10b09858", + "input": "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000683c245200000000000000000000000000000000000000000000000000000000000000040a00060c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a00000000000000000000000000000000000000000000000000000000000000160000000000000000000000000532f27101965dd16442e59d40670faf5ebb142e400000000000000000000000000000000000000000000004951ad3781ec96f800000000000000000000000000000000000000000000000000000000006863a3420000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fe6508f0015c778bdcc1fb5465ba5ebe224c991200000000000000000000000000000000000000000000000000000000683c1d4a00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041795942589480f4d517180ccce533a68f9468ea405e415ef1778b5fe32a20623d2eecd1fc538c7e69c6c643704e8e5784a5cc6dcb1d5e55b79497af4041609e531b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000004951ad3781ec96f800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b532f27101965dd16442e59d40670faf5ebb142e40009c44200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc00000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000040000000000000000000000000985cf24b63a98510298997af83a31d8625c09ba500000000000000000000000000000000000000000000000000648a0adfd9de3d", + "maxFeePerGas": "0x5fc364f", + "maxPriorityFeePerGas": "0x5f5e100", + "nonce": "0x3", + "r": "0x77ea335bc23312a54c252afafc3302310b8c6b851b1f2843123745ee2fb059d1", + "s": "0x677a93966f7392038972119f739a0e41217e85e6c8124e2089f86d20a0ea4eca", + "to": "0xfe6508f0015c778bdcc1fb5465ba5ebe224c9912", + "transactionIndex": "0x1", + "type": "0x2", + "v": "0x0", + "value": "0x0", + "yParity": "0x0" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v3_token_eth_tx_receipt.json b/core/crates/gem_evm/testdata/v3_token_eth_tx_receipt.json new file mode 100644 index 0000000000..6f60c03c5f --- /dev/null +++ b/core/crates/gem_evm/testdata/v3_token_eth_tx_receipt.json @@ -0,0 +1,117 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "contractAddress": null, + "cumulativeGasUsed": "0x488a6", + "effectiveGasPrice": "0x5fc1eb0", + "from": "0x985cf24b63a98510298997af83a31d8625c09ba5", + "gasUsed": "0x3d45a", + "l1BaseFeeScalar": "0x8dd", + "l1BlobBaseFee": "0x1", + "l1BlobBaseFeeScalar": "0x101c12", + "l1Fee": "0x2cdf17f46", + "l1GasPrice": "0x39ca26c6", + "l1GasUsed": "0x1563", + "logs": [ + { + "address": "0x31c2f6fcff4f8759b3bd5bf0e1084a055615c768", + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "data": "0x00000000000000000000000000000000000000000000004951ad3781ec96f800000000000000000000000000000000000000000000000000000000006863a3420000000000000000000000000000000000000000000000000000000000000000", + "logIndex": "0x0", + "removed": false, + "topics": [ + "0xc6a377bfc4eb120024a8ac08eef205be16b817020812c73223e81d1bdb9708ec", + "0x000000000000000000000000985cf24b63a98510298997af83a31d8625c09ba5", + "0x000000000000000000000000532f27101965dd16442e59d40670faf5ebb142e4", + "0x000000000000000000000000fe6508f0015c778bdcc1fb5465ba5ebe224c9912" + ], + "transactionHash": "0xc6c2898ddc2d2165bc6c018ec6ebf58d99922c74b9a0e323b50c029d10b09858", + "transactionIndex": "0x1" + }, + { + "address": "0x4200000000000000000000000000000000000006", + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "data": "0x00000000000000000000000000000000000000000000000000679e90834442fa", + "logIndex": "0x1", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x00000000000000000000000075cc10fdcea4b7d13c115abb08240ac9c9be6f2f", + "0x000000000000000000000000fe6508f0015c778bdcc1fb5465ba5ebe224c9912" + ], + "transactionHash": "0xc6c2898ddc2d2165bc6c018ec6ebf58d99922c74b9a0e323b50c029d10b09858", + "transactionIndex": "0x1" + }, + { + "address": "0x532f27101965dd16442e59d40670faf5ebb142e4", + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "data": "0x00000000000000000000000000000000000000000000004951ad3781ec96f800", + "logIndex": "0x2", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000985cf24b63a98510298997af83a31d8625c09ba5", + "0x00000000000000000000000075cc10fdcea4b7d13c115abb08240ac9c9be6f2f" + ], + "transactionHash": "0xc6c2898ddc2d2165bc6c018ec6ebf58d99922c74b9a0e323b50c029d10b09858", + "transactionIndex": "0x1" + }, + { + "address": "0x75cc10fdcea4b7d13c115abb08240ac9c9be6f2f", + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "data": "0xffffffffffffffffffffffffffffffffffffffffffffffffff98616f7cbbbd0600000000000000000000000000000000000000000000004951ad3781ec96f80000000000000000000000000000000000000000d71ae21d26dcac767785c80d9900000000000000000000000000000000000000000000045b0e3fedd1361124a9000000000000000000000000000000000000000000000000000000000001a3a300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f04079f3bc93c00", + "logIndex": "0x3", + "removed": false, + "topics": [ + "0x19b47279256b2a23a1665c810c8d55a1758940ee09377d4f8d26497a3577dc83", + "0x000000000000000000000000fe6508f0015c778bdcc1fb5465ba5ebe224c9912", + "0x000000000000000000000000fe6508f0015c778bdcc1fb5465ba5ebe224c9912" + ], + "transactionHash": "0xc6c2898ddc2d2165bc6c018ec6ebf58d99922c74b9a0e323b50c029d10b09858", + "transactionIndex": "0x1" + }, + { + "address": "0x4200000000000000000000000000000000000006", + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "data": "0x000000000000000000000000000000000000000000000000000084a200a80574", + "logIndex": "0x4", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fe6508f0015c778bdcc1fb5465ba5ebe224c9912", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0xc6c2898ddc2d2165bc6c018ec6ebf58d99922c74b9a0e323b50c029d10b09858", + "transactionIndex": "0x1" + }, + { + "address": "0x4200000000000000000000000000000000000006", + "blockHash": "0xeda53c77a676c3121ab874c9e959809b1d7d8d12913442e1e1f84609fd0c3efc", + "blockNumber": "0x1d8dcb7", + "data": "0x000000000000000000000000000000000000000000000000006719ee829c3d86", + "logIndex": "0x5", + "removed": false, + "topics": [ + "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65", + "0x000000000000000000000000fe6508f0015c778bdcc1fb5465ba5ebe224c9912" + ], + "transactionHash": "0xc6c2898ddc2d2165bc6c018ec6ebf58d99922c74b9a0e323b50c029d10b09858", + "transactionIndex": "0x1" + } + ], + "logsBloom": "0x00000000200000000000000000000000000000000000000000040000000000000000000000000000000000100002000000010000000000008400000000000000040000001040000000000008800000000000000000400000020000000000000000000000180000000400080000000000000000000000040000000110000000000000004000000000000000080000000000100000002400010000000000200000000000000000000010000000000000000400000040000000000040000000000008000002000000000000000000000000000000000000000000000022000000000000000000000000000020000000000000000000000000000000000200000000", + "status": "0x1", + "to": "0xfe6508f0015c778bdcc1fb5465ba5ebe224c9912", + "transactionHash": "0xc6c2898ddc2d2165bc6c018ec6ebf58d99922c74b9a0e323b50c029d10b09858", + "transactionIndex": "0x1", + "type": "0x2" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v3_usdc_paxg_receipt.json b/core/crates/gem_evm/testdata/v3_usdc_paxg_receipt.json new file mode 100644 index 0000000000..91775aaa7d --- /dev/null +++ b/core/crates/gem_evm/testdata/v3_usdc_paxg_receipt.json @@ -0,0 +1,112 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0xa4b64405f05fd95ecb5e276f89cc082586ca09e5b1342cce58c9b8dc49089c61", + "blockNumber": "0x158ddc9", + "contractAddress": null, + "cumulativeGasUsed": "0xd7a311", + "effectiveGasPrice": "0x69120c61", + "from": "0xba38fe5b73ea5b93d0733cf9eb10adea6e1e3a2a", + "gasUsed": "0x2baf3", + "logs": [ + { + "address": "0x000000000022d473030f116ddee9f6b43ac78ba3", + "blockHash": "0xa4b64405f05fd95ecb5e276f89cc082586ca09e5b1342cce58c9b8dc49089c61", + "blockNumber": "0x158ddc9", + "data": "0x0000000000000000000000000000000000000000000000000000000001c9c38000000000000000000000000000000000000000000000000000000000683c529d0000000000000000000000000000000000000000000000000000000000000002", + "logIndex": "0x171", + "removed": false, + "topics": [ + "0xc6a377bfc4eb120024a8ac08eef205be16b817020812c73223e81d1bdb9708ec", + "0x000000000000000000000000ba38fe5b73ea5b93d0733cf9eb10adea6e1e3a2a", + "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "0x0000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad" + ], + "transactionHash": "0x65b5ff389386caf23a9998318d936e434c5bbca850877f1ca03eb246b3ad82e1", + "transactionIndex": "0xb3" + }, + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "blockHash": "0xa4b64405f05fd95ecb5e276f89cc082586ca09e5b1342cce58c9b8dc49089c61", + "blockNumber": "0x158ddc9", + "data": "0x00000000000000000000000000000000000000000000000000000000000249f0", + "logIndex": "0x172", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ba38fe5b73ea5b93d0733cf9eb10adea6e1e3a2a", + "0x0000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc" + ], + "transactionHash": "0x65b5ff389386caf23a9998318d936e434c5bbca850877f1ca03eb246b3ad82e1", + "transactionIndex": "0xb3" + }, + { + "address": "0x45804880de22913dafe09f4980848ece6ecbaf78", + "blockHash": "0xa4b64405f05fd95ecb5e276f89cc082586ca09e5b1342cce58c9b8dc49089c61", + "blockNumber": "0x158ddc9", + "data": "0x0000000000000000000000000000000000000000000000000020090e68fe5569", + "logIndex": "0x173", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000005ae13baaef0620fdae1d355495dc51a17adb4082", + "0x000000000000000000000000ba38fe5b73ea5b93d0733cf9eb10adea6e1e3a2a" + ], + "transactionHash": "0x65b5ff389386caf23a9998318d936e434c5bbca850877f1ca03eb246b3ad82e1", + "transactionIndex": "0xb3" + }, + { + "address": "0x45804880de22913dafe09f4980848ece6ecbaf78", + "blockHash": "0xa4b64405f05fd95ecb5e276f89cc082586ca09e5b1342cce58c9b8dc49089c61", + "blockNumber": "0x158ddc9", + "data": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logIndex": "0x174", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000005ae13baaef0620fdae1d355495dc51a17adb4082", + "0x00000000000000000000000038699d04656ff537ef8671b6b595402ebdbdf6f4" + ], + "transactionHash": "0x65b5ff389386caf23a9998318d936e434c5bbca850877f1ca03eb246b3ad82e1", + "transactionIndex": "0xb3" + }, + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "blockHash": "0xa4b64405f05fd95ecb5e276f89cc082586ca09e5b1342cce58c9b8dc49089c61", + "blockNumber": "0x158ddc9", + "data": "0x0000000000000000000000000000000000000000000000000000000001c77990", + "logIndex": "0x175", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ba38fe5b73ea5b93d0733cf9eb10adea6e1e3a2a", + "0x0000000000000000000000005ae13baaef0620fdae1d355495dc51a17adb4082" + ], + "transactionHash": "0x65b5ff389386caf23a9998318d936e434c5bbca850877f1ca03eb246b3ad82e1", + "transactionIndex": "0xb3" + }, + { + "address": "0x5ae13baaef0620fdae1d355495dc51a17adb4082", + "blockHash": "0xa4b64405f05fd95ecb5e276f89cc082586ca09e5b1342cce58c9b8dc49089c61", + "blockNumber": "0x158ddc9", + "data": "0xffffffffffffffffffffffffffffffffffffffffffffffffffdff6f19701aa970000000000000000000000000000000000000000000000000000000001c7799000000000000000000000000000000000000000000003c50c5c969ef1890ff25100000000000000000000000000000000000000000000000003fe97b078d07885fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd0533", + "logIndex": "0x176", + "removed": false, + "topics": [ + "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67", + "0x0000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "0x000000000000000000000000ba38fe5b73ea5b93d0733cf9eb10adea6e1e3a2a" + ], + "transactionHash": "0x65b5ff389386caf23a9998318d936e434c5bbca850877f1ca03eb246b3ad82e1", + "transactionIndex": "0xb3" + } + ], + "logsBloom": "0x00010020000000000000000000000000000000000000000000000000040000000000000000000020000002000000040000010000000020000000000000080000000000081000000808000008000000000000000000000000000000000020000000000100000000000000080000000000000800000000000000000010000800000000004000000000000000001000000200100000010000000000000000000000000100000000200000000000000000000400000000000000000800000000000000000002010000000200000000000000000800000000000000800000000000001000000000000000000000000000000004001000020000000000000000000400", + "status": "0x1", + "to": "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "transactionHash": "0x65b5ff389386caf23a9998318d936e434c5bbca850877f1ca03eb246b3ad82e1", + "transactionIndex": "0xb3", + "type": "0x2" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v3_usdc_paxg_tx.json b/core/crates/gem_evm/testdata/v3_usdc_paxg_tx.json new file mode 100644 index 0000000000..9e4334f1a3 --- /dev/null +++ b/core/crates/gem_evm/testdata/v3_usdc_paxg_tx.json @@ -0,0 +1,26 @@ +{ + "id": 67, + "jsonrpc": "2.0", + "result": { + "accessList": [], + "blockHash": "0xa4b64405f05fd95ecb5e276f89cc082586ca09e5b1342cce58c9b8dc49089c61", + "blockNumber": "0x158ddc9", + "chainId": "0x1", + "from": "0xba38fe5b73ea5b93d0733cf9eb10adea6e1e3a2a", + "gas": "0x43bab", + "gasPrice": "0x69120c61", + "hash": "0x65b5ff389386caf23a9998318d936e434c5bbca850877f1ca03eb246b3ad82e1", + "input": "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000683abace00000000000000000000000000000000000000000000000000000000000000030a020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000002600000000000000000000000000000000000000000000000000000000000000160000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000001c9c38000000000000000000000000000000000000000000000000000000000683c529d00000000000000000000000000000000000000000000000000000000000000020000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad00000000000000000000000000000000000000000000000000000000683ab3c500000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041687bc13d56206b8bd9bab8dfec8932e9eb9edc59ad792001c32ceca64bbf34f458252a8e04dcc3f8e23f9c69842674c2d62cd6d5255f83a5a18a0daad05c654d1c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc00000000000000000000000000000000000000000000000000000000000249f00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000ba38fe5b73ea5b93d0733cf9eb10adea6e1e3a2a0000000000000000000000000000000000000000000000000000000001c77990000000000000000000000000000000000000000000000000001f3d4292084b8700000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb480001f445804880de22913dafe09f4980848ece6ecbaf78000000000000000000000000000000000000000000", + "maxFeePerGas": "0x6bc13d26", + "maxPriorityFeePerGas": "0x3b9aca00", + "nonce": "0x4", + "r": "0x4c9bfb0851f08a117490ae002fa837a1f8546bf8158f3a20f4c50e956a4d352", + "s": "0xe535e2722137c6f96890dbc3cf612d3d42f2b8f33557cef69d556ffd970c85a", + "to": "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "transactionIndex": "0xb3", + "type": "0x2", + "v": "0x0", + "value": "0x0", + "yParity": "0x0" + } + } \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v4_eth_dai_tx.json b/core/crates/gem_evm/testdata/v4_eth_dai_tx.json new file mode 100644 index 0000000000..88b7c3bdfc --- /dev/null +++ b/core/crates/gem_evm/testdata/v4_eth_dai_tx.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x98edb7367dd3a247fa1fbe38eeda2bd73f02cbef50dc073b9357b3fe114fe085", + "blockNumber": "0x8dffa9", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "gas": "0x2fbee", + "gasPrice": "0xf433c", + "maxFeePerGas": "0xf433c", + "maxPriorityFeePerGas": "0xf4240", + "hash": "0xcc9364cd575e4085d06c3a9697056b543bd8f9c138e6aeb1e1cd59643d726116", + "input": "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000067b72d7a000000000000000000000000000000000000000000000000000000000000000205100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc0000000000000000000000000000000000000000000000000000048c2739500000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003070b0e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000388f27d8d300000000000000000000000000000000000000000000000000024dd24d6890979780000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000020cab320a855b39f724131c69424240519573f810000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000020cab320a855b39f724131c69424240519573f81000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb70000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x9", + "to": "0xef740bf23acae26f6492b10de645d6b98dc8eaf3", + "transactionIndex": "0x2", + "value": "0x38d7ea4c68000", + "type": "0x2", + "accessList": [], + "chainId": "0x82", + "v": "0x1", + "r": "0xd6c3e82e4bec5409af50f0487ba0e9b7f7ca8bcba27d10cd917e5cfcedec7cc1", + "s": "0x258c65d11be84b28ad0d23ec18225704c02a1cb95c079865e7b0435674dd14ba", + "yParity": "0x1" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v4_eth_dai_tx_receipt.json b/core/crates/gem_evm/testdata/v4_eth_dai_tx_receipt.json new file mode 100644 index 0000000000..b7f932775a --- /dev/null +++ b/core/crates/gem_evm/testdata/v4_eth_dai_tx_receipt.json @@ -0,0 +1,57 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x98edb7367dd3a247fa1fbe38eeda2bd73f02cbef50dc073b9357b3fe114fe085", + "blockNumber": "0x8dffa9", + "contractAddress": null, + "cumulativeGasUsed": "0x2fb26", + "effectiveGasPrice": "0xf433c", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "gasUsed": "0x1f4c0", + "l1BaseFeeScalar": "0x7d0", + "l1BlobBaseFee": "0xa01a0dca", + "l1BlobBaseFeeScalar": "0xdbba0", + "l1Fee": "0xaa7670cc7f", + "l1GasPrice": "0x31bec4dd", + "l1GasUsed": "0x12b8", + "logs": [ + { + "address": "0x1f98400000000000000000000000000000000004", + "topics": [ + "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f", + "0xe452cd9b74c641fb3f6c2ff593c3d34f90f2da9155e5ab66798f72bee4f5fe8e", + "0x000000000000000000000000ef740bf23acae26f6492b10de645d6b98dc8eaf3" + ], + "data": "0xfffffffffffffffffffffffffffffffffffffffffffffffffffc770d8272d000000000000000000000000000000000000000000000000000256cdb53f461fbf800000000000000000000000000000000000000340ce8c87820193c1bc42f4066000000000000000000000000000000000000000000000000d30409b3aa7e46b500000000000000000000000000000000000000000000000000000000000134c80000000000000000000000000000000000000000000000000000000000000bb8", + "blockNumber": "0x8dffa9", + "transactionHash": "0xcc9364cd575e4085d06c3a9697056b543bd8f9c138e6aeb1e1cd59643d726116", + "transactionIndex": "0x2", + "blockHash": "0x98edb7367dd3a247fa1fbe38eeda2bd73f02cbef50dc073b9357b3fe114fe085", + "logIndex": "0x1", + "removed": false + }, + { + "address": "0x20cab320a855b39f724131c69424240519573f81", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001f98400000000000000000000000000000000004", + "0x000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7" + ], + "data": "0x000000000000000000000000000000000000000000000000256cdb53f461fbf8", + "blockNumber": "0x8dffa9", + "transactionHash": "0xcc9364cd575e4085d06c3a9697056b543bd8f9c138e6aeb1e1cd59643d726116", + "transactionIndex": "0x2", + "blockHash": "0x98edb7367dd3a247fa1fbe38eeda2bd73f02cbef50dc073b9357b3fe114fe085", + "logIndex": "0x2", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000020000000040000000002000000000100000000000000000000000008000000000400000000000020000000000000100000000000000002000000000000000000000000100000000000000010000000000000010000000000000000000000000000000000000000000000000000000000000001008000000000000000100000000040000000000000000000000000000000000003000000000000000000000020000000800000000000000000000000000080000000080000004000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0xef740bf23acae26f6492b10de645d6b98dc8eaf3", + "transactionHash": "0xcc9364cd575e4085d06c3a9697056b543bd8f9c138e6aeb1e1cd59643d726116", + "transactionIndex": "0x2", + "type": "0x2" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v4_usdc_eth_tx.json b/core/crates/gem_evm/testdata/v4_usdc_eth_tx.json new file mode 100644 index 0000000000..14f3d73b76 --- /dev/null +++ b/core/crates/gem_evm/testdata/v4_usdc_eth_tx.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "id": 67, + "result": { + "blockHash": "0x618296eb8b25fe685b46d4dd23792c152a8467212555076ca243928b5761388a", + "blockNumber": "0xc61e83", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "gas": "0x3d19a", + "gasPrice": "0xf433c", + "maxFeePerGas": "0xf433c", + "maxPriorityFeePerGas": "0xf4240", + "hash": "0xf44962b3a75d552ae68bdcb5dfcbca14f3c77a04fc26b096a9607f733715b6af", + "input": "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000067ef4c5300000000000000000000000000000000000000000000000000000000000000040a1006040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005e000000000000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000160000000000000000000000000078d782b760474a361dda0af3839290b0ef57ad60000000000000000000000000000000000000000000000000000000000208bd9000000000000000000000000000000000000000000000000000000006816cb420000000000000000000000000000000000000000000000000000000000000001000000000000000000000000ef740bf23acae26f6492b10de645d6b98dc8eaf30000000000000000000000000000000000000000000000000000000067ef454a00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041d038ea53a6484cb46227673413c36df6822b26c1c9c8a20212c751f0f83cd61e42fff872f8c6b29957c22b2d89ef4f3f6c5c33951c8bee34bdae0e80f70912501b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003070b0e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000078d782b760474a361dda0af3839290b0ef57ad600000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000208bd9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000078d782b760474a361dda0af3839290b0ef57ad6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d9dab1a248f63b0a48965ba8435e4de7497a3dc000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb700000000000000000000000000000000000000000000000000041a84d420dd5a", + "nonce": "0xf", + "to": "0xef740bf23acae26f6492b10de645d6b98dc8eaf3", + "transactionIndex": "0x1", + "value": "0x0", + "type": "0x2", + "accessList": [], + "chainId": "0x82", + "v": "0x1", + "r": "0xf92a759a3d5f05e64b19980e9c0ef2a862bd87cb6223f9a7d46f7d9f8493b6a6", + "s": "0x5594b0154bc5f5a366601edcc77b323a7e86b369e86ff419e94b94b7fbad5b1d", + "yParity": "0x1" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/v4_usdc_eth_tx_receipt.json b/core/crates/gem_evm/testdata/v4_usdc_eth_tx_receipt.json new file mode 100644 index 0000000000..d9abe08d82 --- /dev/null +++ b/core/crates/gem_evm/testdata/v4_usdc_eth_tx_receipt.json @@ -0,0 +1,73 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0x618296eb8b25fe685b46d4dd23792c152a8467212555076ca243928b5761388a", + "blockNumber": "0xc61e83", + "contractAddress": null, + "cumulativeGasUsed": "0x31aa2", + "effectiveGasPrice": "0xf433c", + "from": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "gasUsed": "0x26f30", + "l1BaseFeeScalar": "0x7d0", + "l1BlobBaseFee": "0x2", + "l1BlobBaseFeeScalar": "0xdbba0", + "l1Fee": "0x1e1002fed", + "l1GasPrice": "0x254608ac", + "l1GasUsed": "0x1934", + "logs": [ + { + "address": "0x000000000022d473030f116ddee9f6b43ac78ba3", + "topics": [ + "0xc6a377bfc4eb120024a8ac08eef205be16b817020812c73223e81d1bdb9708ec", + "0x000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "0x000000000000000000000000078d782b760474a361dda0af3839290b0ef57ad6", + "0x000000000000000000000000ef740bf23acae26f6492b10de645d6b98dc8eaf3" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000208bd9000000000000000000000000000000000000000000000000000000006816cb420000000000000000000000000000000000000000000000000000000000000001", + "blockNumber": "0xc61e83", + "transactionHash": "0xf44962b3a75d552ae68bdcb5dfcbca14f3c77a04fc26b096a9607f733715b6af", + "transactionIndex": "0x1", + "blockHash": "0x618296eb8b25fe685b46d4dd23792c152a8467212555076ca243928b5761388a", + "logIndex": "0x0", + "removed": false + }, + { + "address": "0x1f98400000000000000000000000000000000004", + "topics": [ + "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f", + "0x3258f413c7a88cda2fa8709a589d221a80f6574f63df5a5b6774485d8acc39d9", + "0x000000000000000000000000ef740bf23acae26f6492b10de645d6b98dc8eaf3" + ], + "data": "0x00000000000000000000000000000000000000000000000000043ab3f931bd73ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdf742700000000000000000000000000000000000000000002c607163040a8b913936000000000000000000000000000000000000000000000000000016388a2f351a0fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffced3900000000000000000000000000000000000000000000000000000000000001f4", + "blockNumber": "0xc61e83", + "transactionHash": "0xf44962b3a75d552ae68bdcb5dfcbca14f3c77a04fc26b096a9607f733715b6af", + "transactionIndex": "0x1", + "blockHash": "0x618296eb8b25fe685b46d4dd23792c152a8467212555076ca243928b5761388a", + "logIndex": "0x1", + "removed": false + }, + { + "address": "0x078d782b760474a361dda0af3839290b0ef57ad6", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "0x0000000000000000000000001f98400000000000000000000000000000000004" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000208bd9", + "blockNumber": "0xc61e83", + "transactionHash": "0xf44962b3a75d552ae68bdcb5dfcbca14f3c77a04fc26b096a9607f733715b6af", + "transactionIndex": "0x1", + "blockHash": "0x618296eb8b25fe685b46d4dd23792c152a8467212555076ca243928b5761388a", + "logIndex": "0x2", + "removed": false + } + ], + "logsBloom": "0x000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0010000040000000000000000080100000000000000000000000808000000000400200000000820000000000000100000000000000002000000000000000000000000000000000000000010008000000000014000000000000000000000000000000000080000000000000000000000000000008000000000000000100000000400000000000000000000000000000000000002000000000000000000000020000000800000000000000000000000000080000000080000004000000000000000402000000000200000000000000400", + "status": "0x1", + "to": "0xef740bf23acae26f6492b10de645d6b98dc8eaf3", + "transactionHash": "0xf44962b3a75d552ae68bdcb5dfcbca14f3c77a04fc26b096a9607f733715b6af", + "transactionIndex": "0x1", + "type": "0x2" + } +} \ No newline at end of file diff --git a/core/crates/gem_evm/testdata/yo_deposit_receipt.json b/core/crates/gem_evm/testdata/yo_deposit_receipt.json new file mode 100644 index 0000000000..3ba7a8df70 --- /dev/null +++ b/core/crates/gem_evm/testdata/yo_deposit_receipt.json @@ -0,0 +1,127 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "blockNumber": "0x178999d", + "contractAddress": null, + "cumulativeGasUsed": "0xad1c83", + "effectiveGasPrice": "0x8f34720", + "from": "0x8d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "gasUsed": "0x28814", + "logs": [ + { + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000165e99", + "blockNumber": "0x178999d", + "transactionHash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "transactionIndex": "0x4f", + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "logIndex": "0xc0", + "removed": false + }, + { + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa", + "0x000000000000000000000000b9a7da9e90d3b428083bae04b860faa6325b721e" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000165e99", + "blockNumber": "0x178999d", + "transactionHash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "transactionIndex": "0x4f", + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "logIndex": "0xc1", + "removed": false + }, + { + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa", + "0x000000000000000000000000b9a7da9e90d3b428083bae04b860faa6325b721e" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000165e99", + "blockNumber": "0x178999d", + "transactionHash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "transactionIndex": "0x4f", + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "logIndex": "0xc2", + "removed": false + }, + { + "address": "0xb9a7da9e90d3b428083bae04b860faa6325b721e", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000014dff4", + "blockNumber": "0x178999d", + "transactionHash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "transactionIndex": "0x4f", + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "logIndex": "0xc3", + "removed": false + }, + { + "address": "0xb9a7da9e90d3b428083bae04b860faa6325b721e", + "topics": [ + "0xdcbc1c05240f31ff3ad067ef1ee35ce4997762752e3a095284754544f4c709d7", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000165e99000000000000000000000000000000000000000000000000000000000014dff4", + "blockNumber": "0x178999d", + "transactionHash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "transactionIndex": "0x4f", + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "logIndex": "0xc4", + "removed": false + }, + { + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000b9a7da9e90d3b428083bae04b860faa6325b721e", + "0x0000000000000000000000000000000f2eb9f69274678c76222b35eec7588a65" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000154044", + "blockNumber": "0x178999d", + "transactionHash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "transactionIndex": "0x4f", + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "logIndex": "0xc5", + "removed": false + }, + { + "address": "0xf1eee0957267b1a474323ff9cff7719e964969fa", + "topics": [ + "0x602719d5e7950d7a41fb9350d0992c81ae5a39d78bc35a6d77a9ca7e97cc2e1e", + "0x0000000000000000000000000000000000000000000000000000000000001994", + "0x000000000000000000000000b9a7da9e90d3b428083bae04b860faa6325b721e", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f" + ], + "data": "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f0000000000000000000000000000000000000000000000000000000000165e99000000000000000000000000000000000000000000000000000000000014dff4", + "blockNumber": "0x178999d", + "transactionHash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "transactionIndex": "0x4f", + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "logIndex": "0xc6", + "removed": false + } + ], + "logsBloom": "0x000000100000000000000000000000000000000000000000000000000004000000001000040000000000000800000100000400000000000000000002002000200601004000000000000000080000000000001010000010000000020000000000000000000200000000000000000028000000000000440000000000100000000000000000000000000020000000000000000000000000000400000000001000000200000000000000000000c0000000000000000000000000000000100000200000000002000000000000000000000000000000000008000002000000010020000010000000000000000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0xf1eee0957267b1a474323ff9cff7719e964969fa", + "transactionHash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "transactionIndex": "0x4f", + "type": "0x2" + } +} diff --git a/core/crates/gem_evm/testdata/yo_deposit_tx.json b/core/crates/gem_evm/testdata/yo_deposit_tx.json new file mode 100644 index 0000000000..487e93f870 --- /dev/null +++ b/core/crates/gem_evm/testdata/yo_deposit_tx.json @@ -0,0 +1,24 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x80b79bd29cac6de12be19241e2a6356b5b31c893afda5327cbd34009836e6f45", + "blockNumber": "0x178999d", + "from": "0x8d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "gas": "0x493e0", + "gasPrice": "0x8f34720", + "maxFeePerGas": "0x8f34720", + "maxPriorityFeePerGas": "0x5f5e100", + "hash": "0x50f21fcdc7b70e5e15310daa1c50d25035bcaeae794708a9ed940eba73c931e1", + "input": "0x82b78ba7000000000000000000000000b9a7da9e90d3b428083bae04b860faa6325b721e0000000000000000000000000000000000000000000000000000000000165e99000000000000000000000000000000000000000000000000000000000014c53b0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f0000000000000000000000000000000000000000000000000000000000001994", + "nonce": "0x16", + "to": "0xf1eee0957267b1a474323ff9cff7719e964969fa", + "transactionIndex": "0x4f", + "value": "0x0", + "type": "0x2", + "chainId": "0x1", + "v": "0x1", + "r": "0xea742b4cc30c0f6d44f9501b8d572d11a90b93dc8e1b85c542e84c287cecf0eb", + "s": "0x2d7cbdd58db02232b4d93210110cf7119f832db9e4e99ecea81c2c9436a9f9f5" + } +} diff --git a/core/crates/gem_evm/testdata/yo_withdraw_receipt.json b/core/crates/gem_evm/testdata/yo_withdraw_receipt.json new file mode 100644 index 0000000000..6a358c4373 --- /dev/null +++ b/core/crates/gem_evm/testdata/yo_withdraw_receipt.json @@ -0,0 +1,114 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x37cb3d60eac1ba6515bd85ef00a44d6ed37d478c6e4a9455f41ff4f6a318bf50", + "blockNumber": "0x178b04a", + "contractAddress": null, + "cumulativeGasUsed": "0xe551e1", + "effectiveGasPrice": "0x141b3b13", + "from": "0x8d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "gasUsed": "0x22ed6", + "logs": [ + { + "address": "0xb9a7da9e90d3b428083bae04b860faa6325b721e", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000014dff4", + "blockNumber": "0x178b04a", + "transactionHash": "0xc1afb479b9afd1c104e0e66b9951fea1a6d3d3f9ce3e264c2d0a25b48619f15e", + "transactionIndex": "0x79", + "blockHash": "0x37cb3d60eac1ba6515bd85ef00a44d6ed37d478c6e4a9455f41ff4f6a318bf50", + "logIndex": "0x20c", + "removed": false + }, + { + "address": "0xb9a7da9e90d3b428083bae04b860faa6325b721e", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000014dff4", + "blockNumber": "0x178b04a", + "transactionHash": "0xc1afb479b9afd1c104e0e66b9951fea1a6d3d3f9ce3e264c2d0a25b48619f15e", + "transactionIndex": "0x79", + "blockHash": "0x37cb3d60eac1ba6515bd85ef00a44d6ed37d478c6e4a9455f41ff4f6a318bf50", + "logIndex": "0x20d", + "removed": false + }, + { + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000b9a7da9e90d3b428083bae04b860faa6325b721e", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000165f0e", + "blockNumber": "0x178b04a", + "transactionHash": "0xc1afb479b9afd1c104e0e66b9951fea1a6d3d3f9ce3e264c2d0a25b48619f15e", + "transactionIndex": "0x79", + "blockHash": "0x37cb3d60eac1ba6515bd85ef00a44d6ed37d478c6e4a9455f41ff4f6a318bf50", + "logIndex": "0x20e", + "removed": false + }, + { + "address": "0xb9a7da9e90d3b428083bae04b860faa6325b721e", + "topics": [ + "0xfbde797d201c681b91056529119e0b02407c7bb96a4a2c75c01fc9667232c8db", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000165f0e000000000000000000000000000000000000000000000000000000000014dff4", + "blockNumber": "0x178b04a", + "transactionHash": "0xc1afb479b9afd1c104e0e66b9951fea1a6d3d3f9ce3e264c2d0a25b48619f15e", + "transactionIndex": "0x79", + "blockHash": "0x37cb3d60eac1ba6515bd85ef00a44d6ed37d478c6e4a9455f41ff4f6a318bf50", + "logIndex": "0x20f", + "removed": false + }, + { + "address": "0xb9a7da9e90d3b428083bae04b860faa6325b721e", + "topics": [ + "0x9a626d8a4952950c7f8b9f5c92a2804e44e147ce4dd0add2f5928f1faea72590", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "0x000000000000000000000000f1eee0957267b1a474323ff9cff7719e964969fa", + "0x0000000000000000000000000000000000000000000000000000000000000001" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000165f1d000000000000000000000000000000000000000000000000000000000014dff4", + "blockNumber": "0x178b04a", + "transactionHash": "0xc1afb479b9afd1c104e0e66b9951fea1a6d3d3f9ce3e264c2d0a25b48619f15e", + "transactionIndex": "0x79", + "blockHash": "0x37cb3d60eac1ba6515bd85ef00a44d6ed37d478c6e4a9455f41ff4f6a318bf50", + "logIndex": "0x210", + "removed": false + }, + { + "address": "0xf1eee0957267b1a474323ff9cff7719e964969fa", + "topics": [ + "0xb01a16c497615a528ca49e6871230c9e96ffb4fcadca641c6325bbd04bc8da4f", + "0x0000000000000000000000000000000000000000000000000000000000001994", + "0x000000000000000000000000b9a7da9e90d3b428083bae04b860faa6325b721e", + "0x0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000014dff40000000000000000000000000000000000000000000000000000000000165f1d0000000000000000000000000000000000000000000000000000000000000001", + "blockNumber": "0x178b04a", + "transactionHash": "0xc1afb479b9afd1c104e0e66b9951fea1a6d3d3f9ce3e264c2d0a25b48619f15e", + "transactionIndex": "0x79", + "blockHash": "0x37cb3d60eac1ba6515bd85ef00a44d6ed37d478c6e4a9455f41ff4f6a318bf50", + "logIndex": "0x211", + "removed": false + } + ], + "logsBloom": "0x000000100000004000000000000000000000000000000000000000000004000000001000040000000000000800000100000400000000000000010002000400000600000000000000000000080000000000000010000410000000000000000010000000000200000000400000000028040000000000400000000000100000000000000000000000000000000000000000000000000000000400000000001000000000000000000000000000c0000000020000000000000000000000100000200000000002000000000000000000000000000000000000000000000000010060000000000000002200000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0xf1eee0957267b1a474323ff9cff7719e964969fa", + "transactionHash": "0xc1afb479b9afd1c104e0e66b9951fea1a6d3d3f9ce3e264c2d0a25b48619f15e", + "transactionIndex": "0x79", + "type": "0x2" + } +} diff --git a/core/crates/gem_evm/testdata/yo_withdraw_tx.json b/core/crates/gem_evm/testdata/yo_withdraw_tx.json new file mode 100644 index 0000000000..45f34ce8c2 --- /dev/null +++ b/core/crates/gem_evm/testdata/yo_withdraw_tx.json @@ -0,0 +1,24 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blockHash": "0x37cb3d60eac1ba6515bd85ef00a44d6ed37d478c6e4a9455f41ff4f6a318bf50", + "blockNumber": "0x178b04a", + "from": "0x8d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "gas": "0x493e0", + "gasPrice": "0x141b3b13", + "maxFeePerGas": "0x141b3b13", + "maxPriorityFeePerGas": "0x5f5e100", + "hash": "0xc1afb479b9afd1c104e0e66b9951fea1a6d3d3f9ce3e264c2d0a25b48619f15e", + "input": "0x99519ab8000000000000000000000000b9a7da9e90d3b428083bae04b860faa6325b721e000000000000000000000000000000000000000000000000000000000014dff4000000000000000000000000000000000000000000000000000000000016427b0000000000000000000000008d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f0000000000000000000000000000000000000000000000000000000000001994", + "nonce": "0x17", + "to": "0xf1eee0957267b1a474323ff9cff7719e964969fa", + "transactionIndex": "0x79", + "value": "0x0", + "type": "0x2", + "chainId": "0x1", + "v": "0x0", + "r": "0xbf8f9c441cf1813c75da08e20ca6950eed48dd3edc336a447ef7566cb0795c69", + "s": "0x3432f0050210d4a8af39a883028ef49c5e1853ecd74cdd706289a3832ab339d4" + } +} diff --git a/core/crates/gem_hash/Cargo.toml b/core/crates/gem_hash/Cargo.toml new file mode 100644 index 0000000000..1f0a2bf4cb --- /dev/null +++ b/core/crates/gem_hash/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "gem_hash" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +blake2 = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } +sha3 = { workspace = true } diff --git a/core/crates/gem_hash/src/blake2.rs b/core/crates/gem_hash/src/blake2.rs new file mode 100644 index 0000000000..460f967d10 --- /dev/null +++ b/core/crates/gem_hash/src/blake2.rs @@ -0,0 +1,25 @@ +use blake2::{ + Blake2b, Blake2b512, Digest, + digest::consts::{U28, U32}, +}; + +type Blake2b224 = Blake2b; +type Blake2b256 = Blake2b; + +pub fn blake2b_224(bytes: &[u8]) -> [u8; 28] { + let mut hasher = Blake2b224::new(); + Digest::update(&mut hasher, bytes); + hasher.finalize().into() +} + +pub fn blake2b_256(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Blake2b256::new(); + Digest::update(&mut hasher, bytes); + hasher.finalize().into() +} + +pub fn blake2b_512(bytes: &[u8]) -> [u8; 64] { + let mut hasher = Blake2b512::new(); + Digest::update(&mut hasher, bytes); + hasher.finalize().into() +} diff --git a/core/crates/gem_hash/src/keccak.rs b/core/crates/gem_hash/src/keccak.rs new file mode 100644 index 0000000000..13215f4721 --- /dev/null +++ b/core/crates/gem_hash/src/keccak.rs @@ -0,0 +1,16 @@ +use sha3::{Digest, Keccak256}; + +pub fn keccak256(bytes: &[u8]) -> [u8; 32] { + Keccak256::digest(bytes).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_keccak256() { + assert_eq!(hex::encode(keccak256(b"hello")), "1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8"); + assert_eq!(hex::encode(keccak256(b"")), "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"); + } +} diff --git a/core/crates/gem_hash/src/lib.rs b/core/crates/gem_hash/src/lib.rs new file mode 100644 index 0000000000..82f95002de --- /dev/null +++ b/core/crates/gem_hash/src/lib.rs @@ -0,0 +1,5 @@ +pub mod blake2; +pub mod keccak; +pub mod message; +pub mod sha2; +pub mod sha3; diff --git a/core/crates/gem_hash/src/message.rs b/core/crates/gem_hash/src/message.rs new file mode 100644 index 0000000000..6f27df77d6 --- /dev/null +++ b/core/crates/gem_hash/src/message.rs @@ -0,0 +1,6 @@ +use crate::keccak::keccak256; + +pub fn hash_personal_message(prefix: &str, message: &[u8]) -> [u8; 32] { + let header = format!("{prefix}{}", message.len()); + keccak256(&[header.as_bytes(), message].concat()) +} diff --git a/core/crates/gem_hash/src/sha2.rs b/core/crates/gem_hash/src/sha2.rs new file mode 100644 index 0000000000..b2630fb16e --- /dev/null +++ b/core/crates/gem_hash/src/sha2.rs @@ -0,0 +1,31 @@ +use sha2::{Digest, Sha256, Sha512, Sha512_256}; + +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let result = hasher.finalize(); + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&result); + hash +} + +pub fn sha512_256(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha512_256::new(); + hasher.update(bytes); + let result = hasher.finalize(); + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&result); + hash +} + +pub fn sha512_half(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha512::new(); + hasher.update(bytes); + let result = hasher.finalize(); + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&result[..32]); + hash +} diff --git a/core/crates/gem_hash/src/sha3.rs b/core/crates/gem_hash/src/sha3.rs new file mode 100644 index 0000000000..33ed8158ce --- /dev/null +++ b/core/crates/gem_hash/src/sha3.rs @@ -0,0 +1,11 @@ +use sha3::{Digest, Sha3_256}; + +pub fn sha3_256(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha3_256::new(); + hasher.update(bytes); + let result = hasher.finalize(); + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&result); + hash +} diff --git a/core/crates/gem_hypercore/Cargo.toml b/core/crates/gem_hypercore/Cargo.toml new file mode 100644 index 0000000000..84a8e16e6b --- /dev/null +++ b/core/crates/gem_hypercore/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "gem_hypercore" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +reqwest = ["gem_client/reqwest"] +signer = [] +testkit = [] +chain_integration_tests = ["reqwest", "settings/testkit"] + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +chrono = { workspace = true } +num-bigint = { workspace = true } +alloy-primitives = { workspace = true } +rmp-serde = { version = "1.3.1" } +futures = { workspace = true } +strum = { workspace = true } + +gem_client = { path = "../gem_client" } +gem_hash = { path = "../gem_hash" } +gem_evm = { path = "../gem_evm" } +serde_serializers = { path = "../serde_serializers" } +number_formatter = { path = "../number_formatter" } +primitives = { path = "../primitives" } +chain_traits = { path = "../chain_traits" } +k256 = { workspace = true } +signer = { path = "../signer" } + +[dev-dependencies] +reqwest = { workspace = true } +gem_client = { path = "../gem_client", features = ["reqwest", "testkit"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +primitives = { path = "../primitives", features = ["testkit"] } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_hypercore/src/agent.rs b/core/crates/gem_hypercore/src/agent.rs new file mode 100644 index 0000000000..5ae0a53ff2 --- /dev/null +++ b/core/crates/gem_hypercore/src/agent.rs @@ -0,0 +1,175 @@ +use alloy_primitives::{Address, hex}; +use gem_hash::keccak::keccak256; +use k256::{ + SecretKey, + elliptic_curve::{rand_core::OsRng, sec1::ToEncodedPoint}, +}; +use primitives::Preferences; +use std::{error::Error, sync::Arc}; + +pub struct Agent { + preferences: Arc, +} + +impl Agent { + pub fn new(preferences: Arc) -> Self { + Self { preferences } + } + + fn address_key(&self, sender_address: &str) -> String { + format!("{}_agent_address", sender_address) + } + + fn private_key_key(&self, sender_address: &str) -> String { + format!("{}_agent_key", sender_address) + } + + pub fn get_or_create_credentials(&self, sender_address: &str) -> Result<(String, String), Box> { + let address_key = self.address_key(sender_address); + let private_key_key = self.private_key_key(sender_address); + + if let (Some(address), Some(private_key)) = (self.preferences.get(address_key.clone())?, self.preferences.get(private_key_key.clone())?) { + return Ok((address, private_key)); + } + + self.create_new_credentials(sender_address) + } + + pub fn regenerate_credentials(&self, sender_address: &str) -> Result<(String, String), Box> { + let address_key = self.address_key(sender_address); + let private_key_key = self.private_key_key(sender_address); + + self.preferences.remove(address_key)?; + self.preferences.remove(private_key_key)?; + + self.create_new_credentials(sender_address) + } + + fn create_new_credentials(&self, sender_address: &str) -> Result<(String, String), Box> { + let agent_private_key = self.generate_private_key()?; + let agent_address = self.derive_address(&agent_private_key)?; + + let address_key = self.address_key(sender_address); + let private_key_key = self.private_key_key(sender_address); + + self.preferences.set(address_key, agent_address.clone())?; + self.preferences.set(private_key_key, agent_private_key.clone())?; + + Ok((agent_address, agent_private_key)) + } + + fn generate_private_key(&self) -> Result> { + let mut rng = OsRng; + let secret_key = SecretKey::random(&mut rng); + Ok(hex::encode(secret_key.to_bytes())) + } + + fn derive_address(&self, private_key_hex: &str) -> Result> { + let private_key_bytes = hex::decode(private_key_hex)?; + let secret_key = SecretKey::from_slice(&private_key_bytes).map_err(|_| "Invalid private key")?; + let public_key = secret_key.public_key(); + let encoded_point = public_key.to_encoded_point(false); + let hash = keccak256(&encoded_point.as_bytes()[1..]); + Ok(Address::from_slice(&hash[12..]).to_string().to_lowercase()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + struct MockPreferences { + data: Mutex>, + } + + impl MockPreferences { + fn new() -> Self { + Self { data: Mutex::new(HashMap::new()) } + } + } + + impl Preferences for MockPreferences { + fn get(&self, key: String) -> Result, Box> { + Ok(self.data.lock().unwrap().get(&key).cloned()) + } + + fn set(&self, key: String, value: String) -> Result<(), Box> { + self.data.lock().unwrap().insert(key, value); + Ok(()) + } + + fn remove(&self, key: String) -> Result<(), Box> { + self.data.lock().unwrap().remove(&key); + Ok(()) + } + } + + #[test] + fn test_derive_address_known_key() { + let preferences = Arc::new(MockPreferences::new()); + let agent = Agent::new(preferences); + let private_key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let address = agent.derive_address(private_key).unwrap(); + + assert_eq!(address, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + } + + #[test] + fn test_derive_address_another_known_key() { + let preferences = Arc::new(MockPreferences::new()); + let agent = Agent::new(preferences); + let private_key = "0000000000000000000000000000000000000000000000000000000000000001"; + let address = agent.derive_address(private_key).unwrap(); + + assert_eq!(address, "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf"); + } + + #[test] + fn test_generate_private_key() { + let preferences = Arc::new(MockPreferences::new()); + let agent = Agent::new(preferences); + let private_key = agent.generate_private_key().unwrap(); + + assert_eq!(private_key.len(), 64); + assert!(private_key.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_address_derivation_consistency() { + let preferences = Arc::new(MockPreferences::new()); + let agent = Agent::new(preferences); + let private_key = agent.generate_private_key().unwrap(); + + let address1 = agent.derive_address(&private_key).unwrap(); + let address2 = agent.derive_address(&private_key).unwrap(); + assert_eq!(address1, address2); + } + + #[test] + fn test_get_or_create_credentials() { + let preferences = Arc::new(MockPreferences::new()); + let agent = Agent::new(preferences); + + let (addr1, key1) = agent.get_or_create_credentials("test_wallet").unwrap(); + let (addr2, key2) = agent.get_or_create_credentials("test_wallet").unwrap(); + + assert_eq!(addr1, addr2); + assert_eq!(key1, key2); + assert_eq!(addr1.len(), 42); + assert_eq!(key1.len(), 64); + } + + #[test] + fn test_regenerate_credentials() { + let preferences = Arc::new(MockPreferences::new()); + let agent = Agent::new(preferences); + + let (addr1, key1) = agent.get_or_create_credentials("test_wallet").unwrap(); + let (addr2, key2) = agent.regenerate_credentials("test_wallet").unwrap(); + + assert_ne!(addr1, addr2); + assert_ne!(key1, key2); + } +} diff --git a/core/crates/gem_hypercore/src/config.rs b/core/crates/gem_hypercore/src/config.rs new file mode 100644 index 0000000000..e651dd87ce --- /dev/null +++ b/core/crates/gem_hypercore/src/config.rs @@ -0,0 +1,18 @@ +#[derive(Debug, Clone, PartialEq)] +pub struct HypercoreConfig { + pub builder_address: String, + pub referral_code: String, + pub max_builder_fee_bps: u32, + pub enabled_hip3_markets: Vec, +} + +impl Default for HypercoreConfig { + fn default() -> Self { + Self { + builder_address: "0x0d9dab1a248f63b0a48965ba8435e4de7497a3dc".to_string(), + referral_code: "GEMWALLET".to_string(), + max_builder_fee_bps: 45, + enabled_hip3_markets: vec![], + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/agent/mod.rs b/core/crates/gem_hypercore/src/core/actions/agent/mod.rs new file mode 100644 index 0000000000..9c2a895543 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/agent/mod.rs @@ -0,0 +1,7 @@ +pub mod order; +pub mod set_referrer; +pub mod update_leverage; + +pub use order::*; +pub use set_referrer::*; +pub use update_leverage::*; diff --git a/core/crates/gem_hypercore/src/core/actions/agent/order.rs b/core/crates/gem_hypercore/src/core/actions/agent/order.rs new file mode 100644 index 0000000000..a4f20be78f --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/agent/order.rs @@ -0,0 +1,215 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub enum TpslType { + #[serde(rename = "tp")] + TakeProfit, + #[serde(rename = "sl")] + StopLoss, +} + +// IMPORTANT: Field order matters for msgpack serialization and hash calculation +// Do not change field order unless you know the exact order in Python SDK. +#[derive(Clone, Serialize, Deserialize)] +pub struct PlaceOrder { + pub r#type: String, + pub orders: Vec, + pub grouping: Grouping, + #[serde(skip_serializing_if = "Option::is_none")] + pub builder: Option, +} + +impl PlaceOrder { + pub fn new(orders: Vec, grouping: Grouping, builder: Option) -> Self { + Self { + r#type: "order".to_string(), + orders, + grouping, + builder, + } + } +} + +// IMPORTANT: Field order matters for msgpack serialization and hash calculation +// Do not change field order unless you know the exact order in Python SDK. +#[derive(Clone, Serialize, Deserialize)] +pub struct Order { + #[serde(rename = "a")] + pub asset: u32, + #[serde(rename = "b")] + pub is_buy: bool, + #[serde(rename = "p")] + pub price: String, + /// Use "0" to apply to entire position (for position TP/SL orders) + #[serde(rename = "s")] + pub size: String, + #[serde(rename = "r")] + pub reduce_only: bool, + #[serde(rename = "t")] + pub order_type: OrderType, + #[serde(rename = "c", skip_serializing_if = "Option::is_none")] + pub client_order_id: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum OrderType { + Limit { limit: LimitOrder }, + Trigger { trigger: Trigger }, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct LimitOrder { + pub tif: TimeInForce, +} + +impl LimitOrder { + pub fn new(tif: TimeInForce) -> Self { + Self { tif } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Trigger { + #[serde(rename = "isMarket")] + pub is_market: bool, + #[serde(rename = "triggerPx")] + pub trigger_px: String, + pub tpsl: TpslType, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub enum TimeInForce { + #[serde(rename = "Alo")] + AddLiquidityOnly, + #[serde(rename = "Ioc")] + ImmediateOrCancel, + #[serde(rename = "Gtc")] + GoodTillCancel, + #[serde(rename = "FrontendMarket")] + FrontendMarket, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum Grouping { + Na, + NormalTpsl, + PositionTpsl, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Builder { + #[serde(rename = "b")] + pub builder_address: String, + #[serde(rename = "f")] + pub fee: u32, // tenths of a basis point , 10 means 1bp +} + +pub fn make_market_order(asset: u32, is_buy: bool, price: &str, size: &str, reduce_only: bool, builder: Option) -> PlaceOrder { + PlaceOrder::new( + vec![Order { + asset, + is_buy, + price: price.to_string(), + size: size.to_string(), + reduce_only, + order_type: make_market_order_type(), + client_order_id: None, + }], + Grouping::Na, + builder, + ) +} + +// size 0 - means entire position +// TP/SL orders are always reduce_only=true +pub fn make_position_tp_sl(asset: u32, is_buy: bool, size: &str, tp_trigger: Option, sl_trigger: Option, builder: Option, is_market: bool) -> PlaceOrder { + let mut orders = Vec::new(); + + if let Some(sl_trigger) = sl_trigger { + orders.push(make_tpsl_order(asset, is_buy, size, sl_trigger, TpslType::StopLoss, is_market)); + } + + if let Some(tp_trigger) = tp_trigger { + orders.push(make_tpsl_order(asset, is_buy, size, tp_trigger, TpslType::TakeProfit, is_market)); + } + + PlaceOrder::new(orders, Grouping::PositionTpsl, builder) +} + +fn make_market_order_type() -> OrderType { + OrderType::Limit { + limit: LimitOrder::new(TimeInForce::FrontendMarket), + } +} + +fn make_tpsl_order(asset: u32, is_buy: bool, size: &str, trigger: String, tpsl_type: TpslType, is_market: bool) -> Order { + Order { + asset, + is_buy: !is_buy, + price: trigger.clone(), + size: size.to_string(), + reduce_only: true, + order_type: OrderType::Trigger { + trigger: Trigger { + is_market, + trigger_px: trigger, + tpsl: tpsl_type, + }, + }, + client_order_id: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_make_position_tp_sl_long_market() { + let result = make_position_tp_sl(1, true, "1.0", Some("110".to_string()), Some("95".to_string()), None, true); + + assert_eq!(result.orders.len(), 2); + assert_eq!(result.grouping, Grouping::PositionTpsl); + assert_eq!(result.orders[0].price, "95"); + assert!(!result.orders[0].is_buy); + assert_eq!(result.orders[1].price, "110"); + assert!(!result.orders[1].is_buy); + + if let OrderType::Trigger { trigger } = &result.orders[0].order_type { + assert!(trigger.is_market); + assert_eq!(trigger.trigger_px, "95"); + } + } + + #[test] + fn test_make_position_tp_sl_short_market() { + let result = make_position_tp_sl(1, false, "1.0", Some("90".to_string()), Some("105".to_string()), None, true); + + assert_eq!(result.orders.len(), 2); + assert_eq!(result.orders[0].price, "105"); + assert!(result.orders[0].is_buy); + assert_eq!(result.orders[1].price, "90"); + assert!(result.orders[1].is_buy); + + if let OrderType::Trigger { trigger } = &result.orders[1].order_type { + assert!(trigger.is_market); + assert_eq!(trigger.trigger_px, "90"); + } + } + + #[test] + fn test_make_position_tp_sl_limit() { + let result = make_position_tp_sl(1, true, "1.0", Some("110".to_string()), Some("95".to_string()), None, false); + + assert_eq!(result.orders.len(), 2); + assert_eq!(result.orders[0].price, "95"); + assert_eq!(result.orders[1].price, "110"); + + if let OrderType::Trigger { trigger } = &result.orders[0].order_type { + assert!(!trigger.is_market); + assert_eq!(trigger.trigger_px, "95"); + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/agent/set_referrer.rs b/core/crates/gem_hypercore/src/core/actions/agent/set_referrer.rs new file mode 100644 index 0000000000..8be87abff3 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/agent/set_referrer.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +// IMPORTANT: Field order matters for msgpack serialization and hash calculation +// Do not change field order unless you know the exact order in Python SDK. + +#[derive(Clone, Serialize, Deserialize)] +pub struct SetReferrer { + pub r#type: String, + pub code: String, +} + +impl SetReferrer { + pub fn new(code: String) -> Self { + Self { + r#type: "setReferrer".to_string(), + code, + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/agent/update_leverage.rs b/core/crates/gem_hypercore/src/core/actions/agent/update_leverage.rs new file mode 100644 index 0000000000..00e11c9237 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/agent/update_leverage.rs @@ -0,0 +1,45 @@ +use primitives::PerpetualMarginType; +use serde::{Deserialize, Serialize}; + +// IMPORTANT: Field order matters for msgpack serialization and hash calculation +// Do not change field order unless you know the exact order in Python SDK. +#[derive(Clone, Serialize, Deserialize)] +pub struct UpdateLeverage { + pub r#type: String, + pub asset: u32, + #[serde(rename = "isCross")] + pub is_cross: bool, + pub leverage: u8, +} + +impl UpdateLeverage { + pub fn new(asset: u32, is_cross: bool, leverage: u8) -> Self { + Self { + r#type: "updateLeverage".to_string(), + asset, + is_cross, + leverage, + } + } + + pub fn from_margin_type(asset: u32, margin_type: &PerpetualMarginType, leverage: u8) -> Self { + Self::new(asset, *margin_type == PerpetualMarginType::Cross, leverage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_margin_type_cross() { + let leverage = UpdateLeverage::from_margin_type(1, &PerpetualMarginType::Cross, 10); + assert!(leverage.is_cross); + } + + #[test] + fn test_from_margin_type_isolated() { + let leverage = UpdateLeverage::from_margin_type(1, &PerpetualMarginType::Isolated, 10); + assert!(!leverage.is_cross); + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/mod.rs b/core/crates/gem_hypercore/src/core/actions/mod.rs new file mode 100644 index 0000000000..2dc02acc61 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/mod.rs @@ -0,0 +1,8 @@ +pub mod agent; +pub mod user; + +pub use agent::*; +pub use user::*; + +pub const MAINNET: &str = "Mainnet"; +pub const SIGNATURE_CHAIN_ID: &str = "0xa4b1"; diff --git a/core/crates/gem_hypercore/src/core/actions/user/approve_agent.rs b/core/crates/gem_hypercore/src/core/actions/user/approve_agent.rs new file mode 100644 index 0000000000..57949fe01f --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/approve_agent.rs @@ -0,0 +1,29 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct ApproveAgent { + #[serde(rename = "agentAddress")] + pub agent_address: String, + #[serde(rename = "agentName")] + pub agent_name: String, + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, + pub nonce: u64, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + pub r#type: String, +} + +impl ApproveAgent { + pub fn new(agent_address: String, agent_name: String, nonce: u64) -> Self { + Self { + agent_address: agent_address.to_lowercase(), + agent_name, + hyperliquid_chain: MAINNET.to_string(), + nonce, + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + r#type: "approveAgent".to_string(), + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/approve_builder_fee.rs b/core/crates/gem_hypercore/src/core/actions/user/approve_builder_fee.rs new file mode 100644 index 0000000000..e7a1dec4c0 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/approve_builder_fee.rs @@ -0,0 +1,28 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct ApproveBuilderFee { + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, + #[serde(rename = "maxFeeRate")] + pub max_fee_rate: String, // percent string 0.001% + pub builder: String, + pub nonce: u64, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + pub r#type: String, +} + +impl ApproveBuilderFee { + pub fn new(max_fee_rate: String, builder: String, nonce: u64) -> Self { + Self { + hyperliquid_chain: MAINNET.to_string(), + max_fee_rate, + builder: builder.to_lowercase(), + nonce, + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + r#type: "approveBuilderFee".to_string(), + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/c_deposit.rs b/core/crates/gem_hypercore/src/core/actions/user/c_deposit.rs new file mode 100644 index 0000000000..f52e7795c6 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/c_deposit.rs @@ -0,0 +1,25 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct CDeposit { + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, + pub nonce: u64, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + pub r#type: String, + pub wei: u64, +} + +impl CDeposit { + pub fn new(wei: u64, nonce: u64) -> Self { + Self { + hyperliquid_chain: MAINNET.to_string(), + nonce, + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + r#type: "cDeposit".to_string(), + wei, + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/c_withdraw.rs b/core/crates/gem_hypercore/src/core/actions/user/c_withdraw.rs new file mode 100644 index 0000000000..940096d64d --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/c_withdraw.rs @@ -0,0 +1,25 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct CWithdraw { + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, + pub nonce: u64, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + pub r#type: String, + pub wei: u64, +} + +impl CWithdraw { + pub fn new(wei: u64, nonce: u64) -> Self { + Self { + hyperliquid_chain: MAINNET.to_string(), + nonce, + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + r#type: "cWithdraw".to_string(), + wei, + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/cancel_order.rs b/core/crates/gem_hypercore/src/core/actions/user/cancel_order.rs new file mode 100644 index 0000000000..624d62a896 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/cancel_order.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +// IMPORTANT: Field order matters for msgpack serialization and hash calculation +// Do not change field order unless you know the exact order in Python SDK. + +#[derive(Clone, Serialize, Deserialize)] +pub struct Cancel { + pub r#type: String, + pub cancels: Vec, +} + +impl Cancel { + pub fn new(cancels: Vec) -> Self { + Self { + r#type: "cancel".to_string(), + cancels, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct CancelOrder { + #[serde(rename = "a")] + pub asset: u32, + #[serde(rename = "o")] + pub order_id: u64, +} + +impl CancelOrder { + pub fn new(asset: u32, order_id: u64) -> Self { + Self { asset, order_id } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/mod.rs b/core/crates/gem_hypercore/src/core/actions/user/mod.rs new file mode 100644 index 0000000000..d2a36e0940 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/mod.rs @@ -0,0 +1,21 @@ +pub mod approve_agent; +pub mod approve_builder_fee; +pub mod c_deposit; +pub mod c_withdraw; +pub mod cancel_order; +pub mod spot_send; +pub mod token_delegate; +pub mod usd_class_transfer; +pub mod usd_send; +pub mod withdrawal; + +pub use approve_agent::*; +pub use approve_builder_fee::*; +pub use c_deposit::*; +pub use c_withdraw::*; +pub use cancel_order::*; +pub use spot_send::*; +pub use token_delegate::*; +pub use usd_class_transfer::*; +pub use usd_send::*; +pub use withdrawal::*; diff --git a/core/crates/gem_hypercore/src/core/actions/user/spot_send.rs b/core/crates/gem_hypercore/src/core/actions/user/spot_send.rs new file mode 100644 index 0000000000..76b0579798 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/spot_send.rs @@ -0,0 +1,29 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct SpotSend { + pub destination: String, + pub amount: String, + pub token: String, + pub time: u64, + pub r#type: String, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, +} + +impl SpotSend { + pub fn new(amount: String, destination: String, time: u64, token: String) -> Self { + Self { + destination: destination.to_lowercase(), + amount, + token, + time, + r#type: "spotSend".to_string(), + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + hyperliquid_chain: MAINNET.to_string(), + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/token_delegate.rs b/core/crates/gem_hypercore/src/core/actions/user/token_delegate.rs new file mode 100644 index 0000000000..9dbea83be9 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/token_delegate.rs @@ -0,0 +1,30 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct TokenDelegate { + pub validator: String, + pub wei: u64, + #[serde(rename = "isUndelegate")] + pub is_undelegate: bool, + pub nonce: u64, + pub r#type: String, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, +} + +impl TokenDelegate { + pub fn new(validator: String, wei: u64, is_undelegate: bool, nonce: u64) -> Self { + Self { + validator: validator.to_lowercase(), + wei, + is_undelegate, + nonce, + r#type: "tokenDelegate".to_string(), + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + hyperliquid_chain: MAINNET.to_string(), + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/usd_class_transfer.rs b/core/crates/gem_hypercore/src/core/actions/user/usd_class_transfer.rs new file mode 100644 index 0000000000..a2dd66647f --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/usd_class_transfer.rs @@ -0,0 +1,28 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct UsdClassTransfer { + pub r#type: String, + pub amount: String, + #[serde(rename = "toPerp")] + pub to_perp: bool, + pub nonce: u64, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, +} + +impl UsdClassTransfer { + pub fn new(amount: String, to_perp: bool, nonce: u64) -> Self { + Self { + r#type: "usdClassTransfer".to_string(), + amount, + to_perp, + nonce, + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + hyperliquid_chain: MAINNET.to_string(), + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/usd_send.rs b/core/crates/gem_hypercore/src/core/actions/user/usd_send.rs new file mode 100644 index 0000000000..4605bc4f29 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/usd_send.rs @@ -0,0 +1,27 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct UsdSend { + pub destination: String, + pub amount: String, + pub time: u64, + pub r#type: String, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, +} + +impl UsdSend { + pub fn new(amount: String, destination: String, time: u64) -> Self { + Self { + destination: destination.to_lowercase(), + amount, + time, + r#type: "usdSend".to_string(), + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + hyperliquid_chain: MAINNET.to_string(), + } + } +} diff --git a/core/crates/gem_hypercore/src/core/actions/user/withdrawal.rs b/core/crates/gem_hypercore/src/core/actions/user/withdrawal.rs new file mode 100644 index 0000000000..0a97ca7990 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/actions/user/withdrawal.rs @@ -0,0 +1,27 @@ +use crate::core::actions::{MAINNET, SIGNATURE_CHAIN_ID}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct WithdrawalRequest { + pub amount: String, + pub destination: String, + #[serde(rename = "hyperliquidChain")] + pub hyperliquid_chain: String, + #[serde(rename = "signatureChainId")] + pub signature_chain_id: String, + pub time: u64, + pub r#type: String, +} + +impl WithdrawalRequest { + pub fn new(amount: String, time: u64, destination: String) -> Self { + Self { + amount, + destination: destination.to_lowercase(), + hyperliquid_chain: MAINNET.to_string(), + signature_chain_id: SIGNATURE_CHAIN_ID.to_string(), + time, + r#type: "withdraw3".to_string(), + } + } +} diff --git a/core/crates/gem_hypercore/src/core/eip712.rs b/core/crates/gem_hypercore/src/core/eip712.rs new file mode 100644 index 0000000000..84e0f8c046 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/eip712.rs @@ -0,0 +1,266 @@ +use alloy_primitives::Address; +use gem_evm::eip712::{EIP712Type, eip712_domain_types}; +use primitives::Chain; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +use super::models::PhantomAgent; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HyperLiquidEIP712Domain { + #[serde(rename = "chainId")] + pub chain_id: u64, + pub name: String, + #[serde(rename = "verifyingContract")] + pub verifying_contract: Option, + pub version: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HyperLiquidEIP712Message { + pub domain: HyperLiquidEIP712Domain, + pub message: Value, + #[serde(rename = "primaryType")] + pub primary_type: String, + pub types: BTreeMap>, +} + +pub fn create_l1_eip712_json(phantom_agent: &PhantomAgent) -> String { + let domain = HyperLiquidEIP712Domain { + chain_id: Chain::HyperCore.network_id().parse().unwrap(), + name: "Exchange".to_string(), + verifying_contract: Some(Address::ZERO.to_string()), + version: Some("1".to_string()), + }; + + let message = serde_json::to_value(phantom_agent).unwrap(); + + let mut types = BTreeMap::new(); + types.insert("EIP712Domain".to_string(), eip712_domain_types()); + types.insert("Agent".to_string(), agent_types()); + + let eip712_message = HyperLiquidEIP712Message { + domain, + message, + primary_type: "Agent".to_string(), + types, + }; + + serde_json::to_string_pretty(&eip712_message).unwrap() +} + +pub fn create_user_signed_eip712_json(action: &Value, primary_type: &str, action_types: Vec) -> String { + let arbitrum_chain_id: u64 = Chain::Arbitrum.network_id().parse().unwrap(); + let chain_id = if let Some(sig_chain_id) = action.get("signatureChainId").and_then(|v| v.as_str()) { + u64::from_str_radix(sig_chain_id.trim_start_matches("0x"), 16).unwrap_or(arbitrum_chain_id) + } else { + arbitrum_chain_id + }; + + let domain = HyperLiquidEIP712Domain { + chain_id, + name: "HyperliquidSignTransaction".to_string(), + verifying_contract: Some(Address::ZERO.to_string()), + version: Some("1".to_string()), + }; + + let mut types = BTreeMap::new(); + types.insert("EIP712Domain".to_string(), eip712_domain_types()); + types.insert(primary_type.to_string(), action_types); + + let eip712_message = HyperLiquidEIP712Message { + domain, + message: action.clone(), + primary_type: primary_type.to_string(), + types, + }; + + serde_json::to_string_pretty(&eip712_message).unwrap() +} + +// Helper functions for HyperLiquid-specific EIP712 type definitions +pub fn agent_types() -> Vec { + vec![ + EIP712Type { + name: "source".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "connectionId".to_string(), + r#type: "bytes32".to_string(), + }, + ] +} + +pub fn withdraw_types() -> Vec { + vec![ + EIP712Type { + name: "hyperliquidChain".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "destination".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "amount".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "time".to_string(), + r#type: "uint64".to_string(), + }, + ] +} + +pub fn approve_agent_types() -> Vec { + vec![ + EIP712Type { + name: "hyperliquidChain".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "agentAddress".to_string(), + r#type: "address".to_string(), + }, + EIP712Type { + name: "agentName".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "nonce".to_string(), + r#type: "uint64".to_string(), + }, + ] +} + +pub fn approve_builder_fee_types() -> Vec { + vec![ + EIP712Type { + name: "hyperliquidChain".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "maxFeeRate".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "builder".to_string(), + r#type: "address".to_string(), + }, + EIP712Type { + name: "nonce".to_string(), + r#type: "uint64".to_string(), + }, + ] +} + +pub fn spot_send_types() -> Vec { + vec![ + EIP712Type { + name: "hyperliquidChain".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "destination".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "token".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "amount".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "time".to_string(), + r#type: "uint64".to_string(), + }, + ] +} + +pub fn usd_send_types() -> Vec { + vec![ + EIP712Type { + name: "hyperliquidChain".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "destination".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "amount".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "time".to_string(), + r#type: "uint64".to_string(), + }, + ] +} + +pub fn usd_class_transfer_types() -> Vec { + vec![ + EIP712Type { + name: "hyperliquidChain".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "amount".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "toPerp".to_string(), + r#type: "bool".to_string(), + }, + EIP712Type { + name: "nonce".to_string(), + r#type: "uint64".to_string(), + }, + ] +} + +pub fn c_deposit_types() -> Vec { + vec![ + EIP712Type { + name: "hyperliquidChain".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "wei".to_string(), + r#type: "uint64".to_string(), + }, + EIP712Type { + name: "nonce".to_string(), + r#type: "uint64".to_string(), + }, + ] +} + +pub fn token_delegate_types() -> Vec { + vec![ + EIP712Type { + name: "hyperliquidChain".to_string(), + r#type: "string".to_string(), + }, + EIP712Type { + name: "validator".to_string(), + r#type: "address".to_string(), + }, + EIP712Type { + name: "wei".to_string(), + r#type: "uint64".to_string(), + }, + EIP712Type { + name: "isUndelegate".to_string(), + r#type: "bool".to_string(), + }, + EIP712Type { + name: "nonce".to_string(), + r#type: "uint64".to_string(), + }, + ] +} diff --git a/core/crates/gem_hypercore/src/core/hahser.rs b/core/crates/gem_hypercore/src/core/hahser.rs new file mode 100644 index 0000000000..e82bdf538d --- /dev/null +++ b/core/crates/gem_hypercore/src/core/hahser.rs @@ -0,0 +1,32 @@ +use alloy_primitives::hex; +use gem_hash::keccak::keccak256; +use rmp_serde; +use serde_json::Value; + +pub fn action_hash(action: &Value, vault_address: Option<&str>, nonce: u64, expires_after: Option) -> Result { + // Serialize action with msgpack + let mut data = rmp_serde::to_vec(action).map_err(|e| format!("Failed to serialize action: {e}"))?; + + // Add nonce (8 bytes, big endian) + data.extend_from_slice(&nonce.to_be_bytes()); + + // Handle vault address + if let Some(vault) = vault_address { + data.push(0x01); + // Parse vault address and add as bytes + let vault_bytes = hex::decode(vault.trim_start_matches("0x")).map_err(|e| format!("Invalid vault address: {e}"))?; + data.extend_from_slice(&vault_bytes); + } else { + data.push(0x00); + } + + // Handle expiration + if let Some(expires) = expires_after { + data.push(0x00); + data.extend_from_slice(&expires.to_be_bytes()); + } + + // Calculate keccak256 hash + let hash = keccak256(&data); + Ok(hex::encode(hash)) +} diff --git a/core/crates/gem_hypercore/src/core/hypercore.rs b/core/crates/gem_hypercore/src/core/hypercore.rs new file mode 100644 index 0000000000..389b73cb8f --- /dev/null +++ b/core/crates/gem_hypercore/src/core/hypercore.rs @@ -0,0 +1,408 @@ +use serde_json::Value; + +use super::{actions::*, eip712, hahser::action_hash, models::PhantomAgent}; + +fn l1_action_typed_data(action: Value, nonce: u64) -> String { + let hash = action_hash(&action, None, nonce, None).unwrap(); + let phantom_agent = PhantomAgent::new(hash); + eip712::create_l1_eip712_json(&phantom_agent) +} + +fn spot_send_typed_data(spot_send: SpotSend) -> String { + let action_value = serde_json::to_value(&spot_send).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:SpotSend", eip712::spot_send_types()) +} + +fn usd_class_transfer_typed_data(usd_class_transfer: UsdClassTransfer) -> String { + let action_value = serde_json::to_value(&usd_class_transfer).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:UsdClassTransfer", eip712::usd_class_transfer_types()) +} + +// L1 payload +pub fn place_order_typed_data(order: PlaceOrder, nonce: u64) -> String { + let action_value = serde_json::to_value(&order).unwrap(); + l1_action_typed_data(action_value, nonce) +} + +// L1 payload +pub fn set_referrer_typed_data(referrer: SetReferrer, nonce: u64) -> String { + let action_value = serde_json::to_value(&referrer).unwrap(); + l1_action_typed_data(action_value, nonce) +} + +// L1 payload +pub fn update_leverage_typed_data(update_leverage: UpdateLeverage, nonce: u64) -> String { + let action_value = serde_json::to_value(&update_leverage).unwrap(); + l1_action_typed_data(action_value, nonce) +} + +// L1 payload +pub fn cancel_order_typed_data(cancel: Cancel, nonce: u64) -> String { + let action_value = serde_json::to_value(&cancel).unwrap(); + l1_action_typed_data(action_value, nonce) +} + +// User signed payload +pub fn withdrawal_request_typed_data(request: WithdrawalRequest) -> String { + let action_value = serde_json::to_value(&request).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:Withdraw", eip712::withdraw_types()) +} + +// User signed payload +pub fn approve_agent_typed_data(agent: ApproveAgent) -> String { + let action_value = serde_json::to_value(&agent).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:ApproveAgent", eip712::approve_agent_types()) +} + +// User signed payload +pub fn approve_builder_fee_typed_data(fee: ApproveBuilderFee) -> String { + let action_value = serde_json::to_value(&fee).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:ApproveBuilderFee", eip712::approve_builder_fee_types()) +} + +pub fn transfer_to_hyper_evm_typed_data(spot_send: SpotSend) -> String { + spot_send_typed_data(spot_send) +} + +pub fn send_spot_token_to_address_typed_data(spot_send: SpotSend) -> String { + spot_send_typed_data(spot_send) +} + +pub fn send_perps_usd_to_address_typed_data(usd_send: UsdSend) -> String { + let action_value = serde_json::to_value(&usd_send).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:UsdSend", eip712::usd_send_types()) +} + +pub fn transfer_spot_to_perps_typed_data(usd_class_transfer: UsdClassTransfer) -> String { + usd_class_transfer_typed_data(usd_class_transfer) +} + +pub fn transfer_perps_to_spot_typed_data(usd_class_transfer: UsdClassTransfer) -> String { + usd_class_transfer_typed_data(usd_class_transfer) +} + +// User signed payload +pub fn c_deposit_typed_data(c_deposit: CDeposit) -> String { + let action_value = serde_json::to_value(&c_deposit).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:CDeposit", eip712::c_deposit_types()) +} + +pub fn c_withdraw_typed_data(c_withdraw: CWithdraw) -> String { + let action_value = serde_json::to_value(&c_withdraw).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:CWithdraw", eip712::c_deposit_types()) // same as c_deposit_types +} + +// User signed payload +pub fn token_delegate_typed_data(token_delegate: TokenDelegate) -> String { + let action_value = serde_json::to_value(&token_delegate).unwrap(); + eip712::create_user_signed_eip712_json(&action_value, "HyperliquidTransaction:TokenDelegate", eip712::token_delegate_types()) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{asset_constants::HYPERCORE_CORE_HYPE_TOKEN_ID, contract_constants::HYPERCORE_SYSTEM_ADDRESS}; + + #[test] + fn test_action_open_long() { + let order = make_market_order(5, true, "200.21", "0.28", false, None); + let generated_action: serde_json::Value = serde_json::to_value(&order).unwrap(); + + // Load expected data from test file + let test_data: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_action_open_long_order.json")).unwrap(); + let expected_action = &test_data["action"]; + + assert_eq!(generated_action, *expected_action); + } + + #[test] + fn test_action_open_short() { + let order = make_market_order(25, false, "3.032", "1", false, None); + let generated_action: serde_json::Value = serde_json::to_value(&order).unwrap(); + + // Load expected data from test file + let test_data: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_action_open_short_order.json")).unwrap(); + let expected_action = &test_data["action"]; + + assert_eq!(generated_action, *expected_action); + } + + #[test] + fn test_eip712_approve_agent() { + let agent = ApproveAgent::new("0xbec81216a5edeaed508709d8526078c750e307ad".to_string(), "".to_string(), 1753576844319); + + let eip712_json = approve_agent_typed_data(agent); + + // Pretty print the generated JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let pretty_generated = serde_json::to_string_pretty(&parsed).unwrap(); + + // Load expected test data + let expected = include_str!("../../testdata/hl_eip712_approve_agent.json").trim(); + + assert_eq!(pretty_generated, expected); + } + + #[test] + fn test_eip712_withdrawal() { + let withdrawal = WithdrawalRequest::new("2".to_string(), 1753577591421, "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7".to_string()); + + let eip712_json = withdrawal_request_typed_data(withdrawal); + + // Pretty print the generated JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let pretty_generated = serde_json::to_string_pretty(&parsed).unwrap(); + + // Load expected test data + let expected = include_str!("../../testdata/hl_eip712_withdraw.json").trim(); + + assert_eq!(pretty_generated, expected); + } + + #[test] + fn test_l1_action_hash() { + // https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/tests/signing_test.py#L20 + // ETH buy order, sz=0.0147, limit_px=1670.1, asset=4, is_buy=true, reduce_only=false, IoC + + let order = PlaceOrder::new( + vec![Order { + asset: 4, + is_buy: true, + price: "1670.1".to_string(), + reduce_only: false, + size: "0.0147".to_string(), + order_type: OrderType::Limit { + limit: LimitOrder::new(TimeInForce::ImmediateOrCancel), + }, + client_order_id: None, + }], + Grouping::Na, + None, + ); + + let action_value = serde_json::to_value(&order).unwrap(); + let nonce = 1677777606040u64; + let hash = action_hash(&action_value, None, nonce, None).unwrap(); + let expected_connection_id = "0x0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908"; + let phantom_agent = PhantomAgent::new(hash.clone()); + + assert_eq!(phantom_agent.source, "a"); + assert_eq!(phantom_agent.connection_id, expected_connection_id); + + assert_eq!(action_value["type"], "order"); + assert_eq!(action_value["grouping"], "na"); + assert_eq!(action_value["orders"][0]["a"], 4); + assert_eq!(action_value["orders"][0]["b"], true); + assert_eq!(action_value["orders"][0]["p"], "1670.1"); + assert_eq!(action_value["orders"][0]["s"], "0.0147"); + assert_eq!(action_value["orders"][0]["r"], false); + assert_eq!(action_value["orders"][0]["t"]["limit"]["tif"], "Ioc"); + } + + #[test] + fn test_address_lowercasing_in_actions() { + // Test that addresses are properly lowercased in action constructors + let uppercase_address = "0xBEC81216A5EDEAED508709D8526078C750E307AD"; + let expected_lowercase = "0xbec81216a5edeaed508709d8526078c750e307ad"; + + // Test withdrawal request + let withdrawal = WithdrawalRequest::new("2".to_string(), 1753577591421, uppercase_address.to_string()); + assert_eq!(withdrawal.destination, expected_lowercase); + + // Test approve agent + let agent = ApproveAgent::new(uppercase_address.to_string(), "test".to_string(), 1753576844319); + assert_eq!(agent.agent_address, expected_lowercase); + + // Test approve builder fee + let fee = ApproveBuilderFee::new("0.001".to_string(), uppercase_address.to_string(), 1753576844319); + assert_eq!(fee.builder, expected_lowercase); + } + + #[test] + fn test_user_signed_action_fields_added_during_encoding() { + // Test that hyperliquidChain and signatureChainId are added during encoding + let agent = ApproveAgent::new("0xbec81216a5edeaed508709d8526078c750e307ad".to_string(), "".to_string(), 1753576844319); + + let eip712_json = approve_agent_typed_data(agent); + + // Parse the JSON to verify the fields are present + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let message = &parsed["message"]; + + assert_eq!(message["signatureChainId"], "0xa4b1"); + assert_eq!(message["hyperliquidChain"], "Mainnet"); + + // Verify original action fields are present + assert_eq!(message["agentAddress"], "0xbec81216a5edeaed508709d8526078c750e307ad"); + assert_eq!(message["agentName"], ""); + assert_eq!(message["nonce"], 1753576844319u64); + } + + #[test] + fn test_update_leverage_typed_data() { + let update_leverage = UpdateLeverage::new(25, true, 10); + let nonce = 1753577591421u64; + + let eip712_json = update_leverage_typed_data(update_leverage, nonce); + + // Parse the JSON to verify structure + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + + // Verify EIP712 structure is present + assert!(parsed["types"].is_object()); + assert!(parsed["domain"].is_object()); + assert!(parsed["message"].is_object()); + assert_eq!(parsed["primaryType"], "Agent"); + + // Verify the action was properly serialized + let action_value = serde_json::to_value(UpdateLeverage::new(25, true, 10)).unwrap(); + assert_eq!(action_value["type"], "updateLeverage"); + assert_eq!(action_value["asset"], 25); + assert_eq!(action_value["isCross"], true); + assert_eq!(action_value["leverage"], 10); + } + + #[test] + fn test_eip712_spot_send_core_to_evm() { + let spot_send = SpotSend::new( + "0.1".to_string(), + HYPERCORE_SYSTEM_ADDRESS.to_string(), + 1754996222238, + HYPERCORE_CORE_HYPE_TOKEN_ID.to_string(), + ); + + let eip712_json = transfer_to_hyper_evm_typed_data(spot_send); + + // Parse both generated and expected JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_eip712_core_to_evm.json")).unwrap(); + + assert_eq!(parsed, expected); + } + + #[test] + fn test_eip712_spot_send_l1() { + let spot_send = SpotSend::new( + "0.02".to_string(), + "0x1085c5f70f7f7591d97da281a64688385455c2bd".to_string(), + 1755004027201, + "USDC:0x6d1e7cde53ba9467b783cb7c530ce054".to_string(), + ); + + let eip712_json = send_spot_token_to_address_typed_data(spot_send); + + // Parse both generated and expected JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_eip712_spot_send_l1.json")).unwrap(); + + assert_eq!(parsed, expected); + } + + #[test] + fn test_eip712_usd_send() { + let usd_send = UsdSend::new("1".to_string(), "0xe51d0862078098c84346b6203b50b996f7dafe28".to_string(), 1754987223323); + + let eip712_json = send_perps_usd_to_address_typed_data(usd_send); + + // Parse both generated and expected JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_eip712_perp_send_l1.json")).unwrap(); + + assert_eq!(parsed, expected); + } + + #[test] + fn test_eip712_usd_class_transfer_perp_to_spot() { + let usd_class_transfer = UsdClassTransfer::new( + "10".to_string(), + false, // perp to spot + 1754986301493, + ); + + let eip712_json = transfer_perps_to_spot_typed_data(usd_class_transfer); + + // Parse both generated and expected JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_eip712_perp_to_spot.json")).unwrap(); + + assert_eq!(parsed, expected); + } + + #[test] + fn test_eip712_usd_class_transfer_spot_to_perp_structure() { + // Test the spot to perp transfer structure (no corresponding test file yet) + let usd_class_transfer = UsdClassTransfer::new( + "10".to_string(), + true, // spot to perp + 1754986567194, + ); + + let eip712_json = transfer_spot_to_perps_typed_data(usd_class_transfer); + + // Parse and verify structure + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + + // Verify domain + assert_eq!(parsed["domain"]["name"], "HyperliquidSignTransaction"); + assert_eq!(parsed["domain"]["version"], "1"); + assert_eq!(parsed["primaryType"], "HyperliquidTransaction:UsdClassTransfer"); + + // Verify message + assert_eq!(parsed["message"]["type"], "usdClassTransfer"); + assert_eq!(parsed["message"]["amount"], "10"); + assert_eq!(parsed["message"]["toPerp"], true); + assert_eq!(parsed["message"]["nonce"], 1754986567194u64); + assert_eq!(parsed["message"]["signatureChainId"], "0xa4b1"); + assert_eq!(parsed["message"]["hyperliquidChain"], "Mainnet"); + } + + #[test] + fn test_eip712_c_deposit() { + let c_deposit = CDeposit::new(10000000, 1755231476741); + + let eip712_json = c_deposit_typed_data(c_deposit); + + // Parse both generated and expected JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_eip712_spot_to_stake_balance.json")).unwrap(); + + assert_eq!(parsed, expected); + } + + #[test] + fn test_eip712_c_withdraw() { + let c_withdraw = CWithdraw::new(10000000, 1758983015647); + + let eip712_json = c_withdraw_typed_data(c_withdraw); + + // Parse both generated and expected JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_eip712_c_withdraw.json")).unwrap(); + + assert_eq!(parsed, expected); + } + + #[test] + fn test_eip712_token_delegate() { + let token_delegate = TokenDelegate::new("0x5ac99df645f3414876c816caa18b2d234024b487".to_string(), 10000000, false, 1755231522831); + + let eip712_json = token_delegate_typed_data(token_delegate); + + // Parse both generated and expected JSON for comparison + let parsed: serde_json::Value = serde_json::from_str(&eip712_json).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_eip712_stake_to_validator.json")).unwrap(); + + assert_eq!(parsed, expected); + } + + #[test] + fn test_action_cancel_orders() { + let cancel = Cancel::new(vec![CancelOrder::new(0, 133614972850), CancelOrder::new(7, 133610221604)]); + let generated_action: serde_json::Value = serde_json::to_value(&cancel).unwrap(); + + let test_data: serde_json::Value = serde_json::from_str(include_str!("../../testdata/hl_action_cancel_orders.json")).unwrap(); + let expected_action = &test_data["action"]; + + assert_eq!(generated_action, *expected_action); + } +} diff --git a/core/crates/gem_hypercore/src/core/mod.rs b/core/crates/gem_hypercore/src/core/mod.rs new file mode 100644 index 0000000000..cc9e4f94f1 --- /dev/null +++ b/core/crates/gem_hypercore/src/core/mod.rs @@ -0,0 +1,5 @@ +pub mod actions; +pub mod eip712; +pub mod hahser; +pub mod hypercore; +pub mod models; diff --git a/core/crates/gem_hypercore/src/core/models.rs b/core/crates/gem_hypercore/src/core/models.rs new file mode 100644 index 0000000000..cc4e37243e --- /dev/null +++ b/core/crates/gem_hypercore/src/core/models.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PhantomAgent { + pub source: String, + #[serde(rename = "connectionId")] + pub connection_id: String, +} + +impl PhantomAgent { + pub fn new(action_hash: String) -> Self { + Self { + source: "a".to_string(), + connection_id: format!("0x{action_hash}"), + } + } +} diff --git a/core/crates/gem_hypercore/src/lib.rs b/core/crates/gem_hypercore/src/lib.rs new file mode 100644 index 0000000000..444288d059 --- /dev/null +++ b/core/crates/gem_hypercore/src/lib.rs @@ -0,0 +1,23 @@ +pub mod agent; +pub mod config; +pub mod core; +pub mod models; +pub mod perpetual_formatter; +pub mod provider; +pub mod rpc; + +#[cfg(feature = "signer")] +pub mod signer; + +#[cfg(any(test, feature = "testkit"))] +pub mod testkit; + +use primitives::Chain; + +pub fn is_bridge_swap(from_chain: Chain, to_chain: Chain) -> bool { + (from_chain == Chain::HyperCore && to_chain == Chain::Hyperliquid) || (from_chain == Chain::Hyperliquid && to_chain == Chain::HyperCore) +} + +pub fn is_spot_swap(from_chain: Chain, to_chain: Chain) -> bool { + from_chain == Chain::HyperCore && to_chain == Chain::HyperCore +} diff --git a/core/crates/gem_hypercore/src/models/action.rs b/core/crates/gem_hypercore/src/models/action.rs new file mode 100644 index 0000000000..5acf997f28 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/action.rs @@ -0,0 +1,44 @@ +use serde::Deserialize; + +pub const ACTION_ID_KEY: &str = "action"; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeRequest { + pub action: ExchangeAction, + pub nonce: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum ExchangeAction { + Order, + CDeposit { + wei: u64, + }, + CWithdraw { + wei: u64, + }, + TokenDelegate { + wei: u64, + is_undelegate: bool, + }, + #[serde(other)] + Other, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exchange_request_parses_nonce() { + let request = include_str!("../../testdata/hl_action_update_position_tp_sl.json").trim(); + assert_eq!(serde_json::from_str::(request).unwrap().nonce, 1755132472149); + } + + #[test] + fn test_exchange_request_rejects_invalid_json() { + assert!(serde_json::from_str::("not-json").is_err()); + } +} diff --git a/core/crates/gem_hypercore/src/models/balance.rs b/core/crates/gem_hypercore/src/models/balance.rs new file mode 100644 index 0000000000..3533293a29 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/balance.rs @@ -0,0 +1,89 @@ +use gem_evm::ethereum_address_checksum; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Balance { + pub coin: String, + pub token: u32, + pub total: String, + pub hold: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Balances { + pub balances: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tokens { + pub tokens: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Token { + pub name: String, + pub wei_decimals: i32, + pub index: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StakeBalance { + pub delegated: String, + pub undelegated: String, + pub total_pending_withdrawal: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegationBalance { + pub validator: String, + pub amount: String, + pub locked_until_timestamp: u64, +} + +impl DelegationBalance { + pub fn validator_address(&self) -> String { + ethereum_address_checksum(&self.validator).unwrap_or(self.validator.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Validator { + pub validator: String, + pub name: String, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub commission: f64, + pub is_active: bool, + pub stats: Vec<(String, ValidatorStats)>, +} + +impl Validator { + pub fn validator_address(&self) -> String { + ethereum_address_checksum(&self.validator).unwrap_or(self.validator.clone()) + } +} + +impl Validator { + pub fn max_apr(validators: Vec) -> f64 { + validators + .into_iter() + .filter(|x| x.is_active) + .map(|x| x.stats.into_iter().map(|(_, stat)| stat.predicted_apr).fold(0.0, f64::max)) + .fold(0.0, f64::max) + * 100.0 + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidatorStats { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub predicted_apr: f64, +} diff --git a/core/crates/gem_hypercore/src/models/candlestick.rs b/core/crates/gem_hypercore/src/models/candlestick.rs new file mode 100644 index 0000000000..ebd7182d0c --- /dev/null +++ b/core/crates/gem_hypercore/src/models/candlestick.rs @@ -0,0 +1,40 @@ +use chrono::{DateTime, Utc}; +use primitives::chart::{ChartCandleStick, ChartCandleUpdate}; +use serde::{Deserialize, Serialize}; + +use crate::models::UInt64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Candlestick { + pub t: UInt64, // Open time (timestamp in milliseconds) + pub s: String, // Symbol (coin) + pub i: String, // Interval + pub o: String, // Open price + pub h: String, // High price + pub l: String, // Low price + pub c: String, // Close price + pub v: String, // Volume +} + +impl From<&Candlestick> for ChartCandleStick { + fn from(c: &Candlestick) -> Self { + ChartCandleStick { + date: DateTime::from_timestamp(c.t as i64 / 1000, 0).unwrap_or(Utc::now()), + open: c.o.parse().unwrap_or(0.0), + high: c.h.parse().unwrap_or(0.0), + low: c.l.parse().unwrap_or(0.0), + close: c.c.parse().unwrap_or(0.0), + volume: c.v.parse().unwrap_or(0.0), + } + } +} + +impl From for ChartCandleUpdate { + fn from(c: Candlestick) -> Self { + ChartCandleUpdate { + coin: c.s.clone(), + interval: c.i.clone(), + candle: ChartCandleStick::from(&c), + } + } +} diff --git a/core/crates/gem_hypercore/src/models/metadata.rs b/core/crates/gem_hypercore/src/models/metadata.rs new file mode 100644 index 0000000000..ad8fa138a8 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/metadata.rs @@ -0,0 +1,74 @@ +use primitives::{AssetId, Chain}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetMetadata { + pub funding: String, + pub open_interest: String, + pub prev_day_px: String, + pub day_ntl_vlm: String, + pub premium: Option, + pub oracle_px: String, + pub mark_px: String, + pub mid_px: Option, + pub impact_pxs: Option>, + pub day_base_vlm: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UniverseAsset { + pub name: String, + pub sz_decimals: i32, + pub max_leverage: i32, + pub only_isolated: Option, +} + +impl UniverseAsset { + pub fn asset_id(&self) -> AssetId { + perpetual_asset_id(&self.name) + } +} + +pub fn perpetual_asset_id(coin: &str) -> AssetId { + let token_id = AssetId::sub_token_id(&["perpetual".to_string(), coin.to_string()]); + AssetId::from(Chain::HyperCore, Some(token_id)) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HypercoreUniverseResponse { + pub universe: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HypercoreMetadataResponse(pub HypercoreUniverseResponse, pub Vec); + +impl HypercoreMetadataResponse { + pub fn universe(&self) -> &HypercoreUniverseResponse { + &self.0 + } + + pub fn asset_metadata(&self) -> &Vec { + &self.1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_asset_id() { + let asset = UniverseAsset { + name: "BTC".to_string(), + sz_decimals: 5, + max_leverage: 50, + only_isolated: None, + }; + let asset_id = asset.asset_id(); + + assert_eq!(asset_id.chain, Chain::HyperCore); + assert_eq!(asset_id.token_id, Some("perpetual::BTC".to_string())); + } +} diff --git a/core/crates/gem_hypercore/src/models/mod.rs b/core/crates/gem_hypercore/src/models/mod.rs new file mode 100644 index 0000000000..d5aa81c77f --- /dev/null +++ b/core/crates/gem_hypercore/src/models/mod.rs @@ -0,0 +1,18 @@ +pub mod action; +pub mod balance; +pub mod candlestick; +pub mod metadata; +pub mod order; +pub mod perp_dex; +pub mod portfolio; +pub mod position; +pub mod referral; +pub mod response; +pub mod spot; +pub mod timestamp; +pub mod token; +pub mod transaction_id; +pub mod user; +pub mod websocket; + +pub type UInt64 = u64; diff --git a/core/crates/gem_hypercore/src/models/order.rs b/core/crates/gem_hypercore/src/models/order.rs new file mode 100644 index 0000000000..44f862f551 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/order.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_serializers::{deserialize_f64_from_str, deserialize_option_f64_from_str}; +use strum::{Display, EnumString}; + +use crate::models::UInt64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + pub coin: String, + pub limit_px: String, + pub sz: String, + pub oid: UInt64, + pub is_trigger: bool, + pub trigger_px: Option, + pub is_position_tpsl: bool, + pub orig_sz: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenOrder { + pub coin: String, + pub oid: UInt64, + #[serde(deserialize_with = "serde_serializers::deserialize_option_f64_from_str")] + pub trigger_px: Option, + #[serde(deserialize_with = "serde_serializers::deserialize_option_f64_from_str")] + pub limit_px: Option, + pub is_position_tpsl: bool, + pub order_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Display, EnumString)] +#[serde(from = "String", into = "String")] +pub enum FillDirection { + #[strum(serialize = "Buy")] + Buy, + #[strum(serialize = "Sell")] + Sell, + #[strum(serialize = "Open Long")] + OpenLong, + #[strum(serialize = "Open Short")] + OpenShort, + #[strum(serialize = "Close Long")] + CloseLong, + #[strum(serialize = "Close Short")] + CloseShort, + #[strum(default)] + Other(String), +} + +impl From for FillDirection { + fn from(value: String) -> Self { + match value.parse() { + Ok(direction) => direction, + Err(_) => FillDirection::Other(value), + } + } +} + +impl From for String { + fn from(value: FillDirection) -> Self { + value.to_string() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserFill { + pub coin: String, + pub hash: String, + pub oid: UInt64, + pub sz: String, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub closed_pnl: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub fee: f64, + #[serde(default, deserialize_with = "deserialize_option_f64_from_str")] + pub builder_fee: Option, + pub fee_token: Option, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub px: f64, + pub dir: FillDirection, + pub time: u64, + #[serde(default)] + pub liquidation: Option, +} diff --git a/core/crates/gem_hypercore/src/models/perp_dex.rs b/core/crates/gem_hypercore/src/models/perp_dex.rs new file mode 100644 index 0000000000..ea48f6214c --- /dev/null +++ b/core/crates/gem_hypercore/src/models/perp_dex.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PerpDex { + pub name: String, + pub is_active: Option, +} diff --git a/core/crates/gem_hypercore/src/models/portfolio.rs b/core/crates/gem_hypercore/src/models/portfolio.rs new file mode 100644 index 0000000000..a38b96b999 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/portfolio.rs @@ -0,0 +1,46 @@ +use chrono::DateTime; +use primitives::{chart::ChartDateValue, portfolio::PerpetualPortfolioTimeframeData}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(from = "(i64, String)")] +pub struct HypercoreDataPoint { + pub timestamp_ms: i64, + pub value: f64, +} + +impl From<(i64, String)> for HypercoreDataPoint { + fn from((timestamp_ms, value): (i64, String)) -> Self { + Self { + timestamp_ms, + value: value.parse().unwrap_or(0.0), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HypercorePortfolioTimeframeData { + pub account_value_history: Vec, + pub pnl_history: Vec, + pub vlm: String, +} + +impl From for PerpetualPortfolioTimeframeData { + fn from(data: HypercorePortfolioTimeframeData) -> Self { + fn map_data_point(p: HypercoreDataPoint) -> Option { + DateTime::from_timestamp_millis(p.timestamp_ms).map(|date| ChartDateValue { date, value: p.value }) + } + Self { + account_value_history: data.account_value_history.into_iter().filter_map(map_data_point).collect(), + pnl_history: data.pnl_history.into_iter().filter_map(map_data_point).collect(), + volume: data.vlm.parse().unwrap_or(0.0), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(transparent)] +pub struct HypercorePortfolioResponse { + pub timeframes: Vec<(String, HypercorePortfolioTimeframeData)>, +} diff --git a/core/crates/gem_hypercore/src/models/position.rs b/core/crates/gem_hypercore/src/models/position.rs new file mode 100644 index 0000000000..0616d60edd --- /dev/null +++ b/core/crates/gem_hypercore/src/models/position.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetPositions { + pub asset_positions: Vec, + pub margin_summary: MarginSummary, + pub cross_margin_summary: MarginSummary, + pub cross_maintenance_margin_used: String, + pub withdrawable: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarginSummary { + pub account_value: String, + pub total_ntl_pos: String, + pub total_raw_usd: String, + pub total_margin_used: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetPosition { + #[serde(rename = "type")] + pub position_type: PositionType, + pub position: Position, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PositionType { + OneWay, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Position { + pub coin: String, + pub szi: String, + pub leverage: Leverage, + pub entry_px: String, + pub position_value: String, + pub unrealized_pnl: String, + pub return_on_equity: String, + pub liquidation_px: Option, + pub margin_used: String, + pub max_leverage: u32, + pub cum_funding: CumulativeFunding, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Leverage { + #[serde(rename = "type")] + pub leverage_type: LeverageType, + pub value: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LeverageType { + Cross, + Isolated, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CumulativeFunding { + pub all_time: String, + pub since_open: String, +} diff --git a/core/crates/gem_hypercore/src/models/referral.rs b/core/crates/gem_hypercore/src/models/referral.rs new file mode 100644 index 0000000000..eeedffc7d7 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/referral.rs @@ -0,0 +1,57 @@ +use gem_evm::address_deserializer::deserialize_ethereum_address_checksum; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_f64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Referral { + pub referred_by: Option, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub cum_vlm: f64, + pub referrer_state: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferredBy { + pub code: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferrerState { + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferrerData { + pub referral_states: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferralUser { + #[serde(deserialize_with = "deserialize_ethereum_address_checksum")] + pub user: String, +} + +#[cfg(test)] +mod tests { + use super::Referral; + + #[test] + fn referral_need_to_trade_payload() { + let referral: Referral = serde_json::from_str(include_str!("../../testdata/referral_need_to_trade.json")).unwrap(); + + assert_eq!(referral.cum_vlm, 0.0); + assert!(referral.referrer_state.unwrap().data.unwrap().referral_states.is_none()); + } + + #[test] + fn referral_need_to_create_code_payload() { + let referral: Referral = serde_json::from_str(include_str!("../../testdata/referral_need_to_create_code.json")).unwrap(); + + assert!(referral.referrer_state.unwrap().data.is_none()); + } +} diff --git a/core/crates/gem_hypercore/src/models/response.rs b/core/crates/gem_hypercore/src/models/response.rs new file mode 100644 index 0000000000..d281320837 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/response.rs @@ -0,0 +1,193 @@ +use serde::{Deserialize, Serialize}; + +use crate::models::{UInt64, transaction_id::HyperCoreTransactionId}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Response { + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorResponse { + pub response: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusErrorResponse { + pub status: String, + pub response: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderResponse { + pub status: String, + pub response: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderResponseData { + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderData { + pub statuses: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderStatus { + pub filled: Option, + pub resting: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderFilled { + pub oid: UInt64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderResting { + pub oid: UInt64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TransactionBroadcastResponse { + OrderResponse(OrderResponse), + StatusErrorResponse(StatusErrorResponse), + SimpleResponse(Response), + ErrorResponse(ErrorResponse), +} + +#[derive(Debug)] +pub enum BroadcastResult { + Success(String), + Error(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExplorerTransactionResponse { + pub tx: ExplorerTransaction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExplorerTransaction { + pub time: i64, + pub user: String, +} + +impl TransactionBroadcastResponse { + pub fn into_result(self, action_id: Option) -> BroadcastResult { + match self { + TransactionBroadcastResponse::OrderResponse(order) => { + if order.status == "ok" { + if let Some(status) = order.response.and_then(|r| r.data).and_then(|d| d.statuses).and_then(|s| s.first().cloned()) { + if let Some(error) = status.error { + return BroadcastResult::Error(error); + } + if let Some(filled) = status.filled { + return BroadcastResult::Success(HyperCoreTransactionId::Order(filled.oid).to_string()); + } + if let Some(resting) = status.resting { + return BroadcastResult::Success(HyperCoreTransactionId::Order(resting.oid).to_string()); + } + } + match action_id { + Some(id) => BroadcastResult::Success(id), + None => BroadcastResult::Error("Failed to parse action id".to_string()), + } + } else { + BroadcastResult::Error("Order failed".to_string()) + } + } + TransactionBroadcastResponse::StatusErrorResponse(status_error) => { + if status_error.status == "err" { + BroadcastResult::Error(status_error.response) + } else { + BroadcastResult::Error(format!("Request failed with status: {}", status_error.status)) + } + } + TransactionBroadcastResponse::SimpleResponse(simple) => match (simple.status.as_str(), action_id) { + ("ok", Some(id)) => BroadcastResult::Success(id), + ("ok", None) => BroadcastResult::Error("Failed to parse action id".to_string()), + _ => BroadcastResult::Error("Request failed".to_string()), + }, + TransactionBroadcastResponse::ErrorResponse(error) => BroadcastResult::Error(error.response), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_order_broadcast_error() { + let result = serde_json::from_str::(include_str!("../../testdata/order_broadcast_error.json")) + .unwrap() + .into_result(None); + let BroadcastResult::Error(_) = result else { panic!("Expected error") }; + } + + #[test] + fn test_order_broadcast_filled() { + let result = serde_json::from_str::(include_str!("../../testdata/order_broadcast_filled.json")) + .unwrap() + .into_result(None); + let BroadcastResult::Success(oid) = result else { panic!("Expected success") }; + assert_eq!(oid, "order:134896397196"); + } + + #[test] + fn test_order_broadcast_resting() { + let result = serde_json::from_str::(include_str!("../../testdata/order_broadcast_resting.json")) + .unwrap() + .into_result(None); + let BroadcastResult::Success(oid) = result else { panic!("Expected success") }; + assert_eq!(oid, "order:789012"); + } + + #[test] + fn test_order_broadcast_simple_error() { + let result = serde_json::from_str::(include_str!("../../testdata/order_broadcast_simple_error.json")) + .unwrap() + .into_result(None); + let BroadcastResult::Error(_) = result else { panic!("Expected error") }; + } + + #[test] + fn test_order_broadcast_without_order_id_uses_action_id() { + let result = serde_json::from_str::(r#"{"status":"ok","response":{"type":"order"}}"#) + .unwrap() + .into_result(Some("action:123".to_string())); + let BroadcastResult::Success(id) = result else { panic!("Expected success") }; + assert_eq!(id, "action:123"); + } + + #[test] + fn test_simple_broadcast_uses_action_id() { + let result = serde_json::from_str::(r#"{"status":"ok"}"#) + .unwrap() + .into_result(Some("action:456".to_string())); + let BroadcastResult::Success(id) = result else { panic!("Expected success") }; + assert_eq!(id, "action:456"); + } + + #[test] + fn test_order_broadcast_without_order_id_and_action_id_errors() { + let result = serde_json::from_str::(r#"{"status":"ok","response":{"type":"order"}}"#) + .unwrap() + .into_result(None); + let BroadcastResult::Error(_) = result else { panic!("Expected error") }; + } +} diff --git a/core/crates/gem_hypercore/src/models/spot/mod.rs b/core/crates/gem_hypercore/src/models/spot/mod.rs new file mode 100644 index 0000000000..b95cbdbfa6 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/spot/mod.rs @@ -0,0 +1,5 @@ +mod orderbook; +mod spot_market; + +pub use orderbook::{OrderbookLevel, OrderbookResponse}; +pub use spot_market::{SpotMarket, SpotMeta}; diff --git a/core/crates/gem_hypercore/src/models/spot/orderbook.rs b/core/crates/gem_hypercore/src/models/spot/orderbook.rs new file mode 100644 index 0000000000..b0ab9c2203 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/spot/orderbook.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct OrderbookResponse { + pub levels: Vec>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OrderbookLevel { + pub px: String, + pub sz: String, +} diff --git a/core/crates/gem_hypercore/src/models/spot/spot_market.rs b/core/crates/gem_hypercore/src/models/spot/spot_market.rs new file mode 100644 index 0000000000..ff210507d0 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/spot/spot_market.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use super::super::token::SpotToken; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpotMarket { + pub tokens: Vec, + pub index: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpotMeta { + pub tokens: Vec, + pub universe: Vec, +} diff --git a/core/crates/gem_hypercore/src/models/timestamp.rs b/core/crates/gem_hypercore/src/models/timestamp.rs new file mode 100644 index 0000000000..9f7be93229 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/timestamp.rs @@ -0,0 +1,8 @@ +use serde::Deserialize; +use serde_serializers::deserialize_u64_from_str_or_int; + +#[derive(Debug, Clone, Deserialize)] +pub struct TimestampField { + #[serde(alias = "time", alias = "nonce", deserialize_with = "deserialize_u64_from_str_or_int")] + pub value: u64, +} diff --git a/core/crates/gem_hypercore/src/models/token.rs b/core/crates/gem_hypercore/src/models/token.rs new file mode 100644 index 0000000000..5fafd8c4e2 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/token.rs @@ -0,0 +1,45 @@ +use primitives::{AssetId, Chain}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotToken { + pub name: String, + pub wei_decimals: i32, + pub index: i32, + pub token_id: String, + pub sz_decimals: u32, +} + +impl SpotToken { + pub fn asset_id(&self, chain: Chain) -> AssetId { + let token_id = AssetId::sub_token_id(&[self.name.clone(), self.token_id.clone(), self.index.to_string()]); + AssetId::from(chain, Some(token_id)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpotTokensResponse { + pub tokens: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::asset_constants::HYPERCORE_SPOT_USDC_TOKEN_ID; + + #[test] + fn test_asset_id() { + let token = SpotToken { + name: "USDC".to_string(), + wei_decimals: 8, + index: 0, + token_id: "0x6d1e7cde53ba9467b783cb7c530ce054".to_string(), + sz_decimals: 2, + }; + let asset_id = token.asset_id(Chain::HyperCore); + + assert_eq!(asset_id.chain, Chain::HyperCore); + assert_eq!(asset_id.token_id, Some(HYPERCORE_SPOT_USDC_TOKEN_ID.to_string())); + } +} diff --git a/core/crates/gem_hypercore/src/models/transaction_id.rs b/core/crates/gem_hypercore/src/models/transaction_id.rs new file mode 100644 index 0000000000..09d387fb85 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/transaction_id.rs @@ -0,0 +1,158 @@ +use std::fmt::{Display, Formatter}; + +use crate::models::action::{ACTION_ID_KEY, ExchangeAction, ExchangeRequest}; + +const ACTION_ORDER: &str = "order"; +const ACTION_C_DEPOSIT: &str = "cDeposit"; +const ACTION_C_WITHDRAW: &str = "cWithdraw"; +const ACTION_TOKEN_DELEGATE: &str = "tokenDelegate"; +const TOKEN_DELEGATE_STAKE: &str = "stake"; +const TOKEN_DELEGATE_UNSTAKE: &str = "unstake"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HyperCoreTransactionId { + Order(u64), + Action(HyperCoreActionId), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HyperCoreActionId { + Nonce(u64), + Order(u64), + CDeposit { wei: u64, nonce: u64 }, + CWithdraw { wei: u64, nonce: u64 }, + TokenDelegate { wei: u64, is_undelegate: bool, nonce: u64 }, +} + +impl HyperCoreActionId { + pub fn nonce(&self) -> u64 { + match self { + Self::Nonce(nonce) | Self::Order(nonce) | Self::CDeposit { nonce, .. } | Self::CWithdraw { nonce, .. } | Self::TokenDelegate { nonce, .. } => *nonce, + } + } +} + +impl From for HyperCoreActionId { + fn from(request: ExchangeRequest) -> Self { + match request.action { + ExchangeAction::Order => Self::Order(request.nonce), + ExchangeAction::CDeposit { wei } => Self::CDeposit { wei, nonce: request.nonce }, + ExchangeAction::CWithdraw { wei } => Self::CWithdraw { wei, nonce: request.nonce }, + ExchangeAction::TokenDelegate { wei, is_undelegate } => Self::TokenDelegate { + wei, + is_undelegate, + nonce: request.nonce, + }, + ExchangeAction::Other => Self::Nonce(request.nonce), + } + } +} + +impl HyperCoreTransactionId { + pub fn parse(id: &str) -> Option { + match id.split_once(':') { + Some((ACTION_ID_KEY, rest)) => parse_action_id(rest).map(Self::Action), + Some((ACTION_ORDER, value)) => value.parse().ok().map(Self::Order), + _ => id.parse().ok().map(Self::Order), + } + } +} + +impl Display for HyperCoreTransactionId { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Order(value) => write!(formatter, "{ACTION_ORDER}:{value}"), + Self::Action(action) => write!(formatter, "{ACTION_ID_KEY}:{action}"), + } + } +} + +impl Display for HyperCoreActionId { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Nonce(nonce) => write!(formatter, "{nonce}"), + Self::Order(nonce) => write!(formatter, "{ACTION_ORDER}:{nonce}"), + Self::CDeposit { wei, nonce } => write!(formatter, "{ACTION_C_DEPOSIT}:{wei}:{nonce}"), + Self::CWithdraw { wei, nonce } => write!(formatter, "{ACTION_C_WITHDRAW}:{wei}:{nonce}"), + Self::TokenDelegate { wei, is_undelegate, nonce } => { + let direction = if *is_undelegate { TOKEN_DELEGATE_UNSTAKE } else { TOKEN_DELEGATE_STAKE }; + write!(formatter, "{ACTION_TOKEN_DELEGATE}:{wei}:{direction}:{nonce}") + } + } + } +} + +fn parse_action_id(value: &str) -> Option { + let parts = value.split(':').collect::>(); + match parts.as_slice() { + [nonce] => nonce.parse().ok().map(HyperCoreActionId::Nonce), + [ACTION_ORDER, nonce] => Some(HyperCoreActionId::Order(nonce.parse().ok()?)), + [ACTION_C_DEPOSIT, wei, nonce] => Some(HyperCoreActionId::CDeposit { + wei: wei.parse().ok()?, + nonce: nonce.parse().ok()?, + }), + [ACTION_C_WITHDRAW, wei, nonce] => Some(HyperCoreActionId::CWithdraw { + wei: wei.parse().ok()?, + nonce: nonce.parse().ok()?, + }), + [ACTION_TOKEN_DELEGATE, wei, TOKEN_DELEGATE_STAKE, nonce] => Some(HyperCoreActionId::TokenDelegate { + wei: wei.parse().ok()?, + is_undelegate: false, + nonce: nonce.parse().ok()?, + }), + [ACTION_TOKEN_DELEGATE, wei, TOKEN_DELEGATE_UNSTAKE, nonce] => Some(HyperCoreActionId::TokenDelegate { + wei: wei.parse().ok()?, + is_undelegate: true, + nonce: nonce.parse().ok()?, + }), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hypercore_transaction_id() { + assert_eq!(HyperCoreTransactionId::parse("order:413978262893"), Some(HyperCoreTransactionId::Order(413978262893))); + assert_eq!(HyperCoreTransactionId::parse("413978262893"), Some(HyperCoreTransactionId::Order(413978262893))); + assert_eq!( + HyperCoreTransactionId::parse("action:1778110454168"), + Some(HyperCoreTransactionId::Action(HyperCoreActionId::Nonce(1778110454168))) + ); + assert_eq!( + HyperCoreTransactionId::parse("action:order:1778110454168"), + Some(HyperCoreTransactionId::Action(HyperCoreActionId::Order(1778110454168))) + ); + assert_eq!( + HyperCoreTransactionId::parse("action:cDeposit:1000000:1778110454168"), + Some(HyperCoreTransactionId::Action(HyperCoreActionId::CDeposit { + wei: 1000000, + nonce: 1778110454168 + })) + ); + assert_eq!( + HyperCoreTransactionId::parse("action:tokenDelegate:1000000:unstake:1778110454168"), + Some(HyperCoreTransactionId::Action(HyperCoreActionId::TokenDelegate { + wei: 1000000, + is_undelegate: true, + nonce: 1778110454168 + })) + ); + assert_eq!(HyperCoreTransactionId::parse("0xba3b"), None); + + assert_eq!(HyperCoreTransactionId::Order(413978262893).to_string(), "order:413978262893"); + assert_eq!(HyperCoreTransactionId::Action(HyperCoreActionId::Nonce(1778110454168)).to_string(), "action:1778110454168"); + assert_eq!( + HyperCoreTransactionId::Action(HyperCoreActionId::TokenDelegate { + wei: 1000000, + is_undelegate: false, + nonce: 1778110454168 + }) + .to_string(), + "action:tokenDelegate:1000000:stake:1778110454168" + ); + assert_eq!(HyperCoreActionId::Nonce(1778110454168).nonce(), 1778110454168); + } +} diff --git a/core/crates/gem_hypercore/src/models/user.rs b/core/crates/gem_hypercore/src/models/user.rs new file mode 100644 index 0000000000..2451ccd6c4 --- /dev/null +++ b/core/crates/gem_hypercore/src/models/user.rs @@ -0,0 +1,100 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::f64::deserialize_f64_from_str; + +use crate::models::UInt64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserRole { + pub role: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum UserAbstractionMode { + Default, + Disabled, + DexAbstraction, + UnifiedAccount, + PortfolioMargin, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSession { + pub address: String, + pub valid_until: UInt64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserFee { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub user_cross_rate: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub user_spot_cross_rate: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub active_referral_discount: f64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LedgerUpdate { + pub time: u64, + pub hash: String, + pub delta: LedgerDelta, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegatorHistoryUpdate { + pub time: u64, + pub hash: String, + pub delta: DelegatorHistoryDelta, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegatorHistoryDelta { + pub c_deposit: Option, + pub delegate: Option, + pub withdrawal: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegatorAmountDelta { + pub amount: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegatorDelegateDelta { + pub amount: String, + pub is_undelegate: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegatorWithdrawalDelta { + pub amount: String, + pub phase: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum LedgerDelta { + Send { + nonce: u64, + }, + SpotTransfer { + nonce: u64, + }, + CStakingTransfer { + token: String, + amount: String, + is_deposit: bool, + }, + #[serde(other)] + Other, +} diff --git a/core/crates/gem_hypercore/src/models/websocket.rs b/core/crates/gem_hypercore/src/models/websocket.rs new file mode 100644 index 0000000000..629556a5bb --- /dev/null +++ b/core/crates/gem_hypercore/src/models/websocket.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; + +use primitives::chart::ChartCandleUpdate; +use primitives::perpetual::PerpetualBalance; +use primitives::{PerpetualMarketData, PerpetualPosition}; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_f64_from_str, deserialize_option_f64_from_str}; + +use super::candlestick::Candlestick; +use super::order::OpenOrder; +use super::position::AssetPositions; + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "channel", content = "data")] +pub enum RawSocketMessage { + #[serde(rename = "clearinghouseState")] + AccountState(AccountStateData), + + #[serde(rename = "openOrders")] + OpenOrders(OpenOrdersData), + + #[serde(rename = "candle")] + Candle(Candlestick), + + #[serde(rename = "activeAssetCtx")] + MarketData(ActiveAssetCtxData), + + #[serde(rename = "allMids")] + MarketPrices(AllMidsData), + + #[serde(rename = "subscriptionResponse")] + SubscriptionResponse(SubscriptionResponseData), + + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum HyperliquidMethod { + Subscribe, + Unsubscribe, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] +#[serde(tag = "type")] +pub enum HyperliquidSubscription { + #[serde(rename = "clearinghouseState")] + AccountState { + #[serde(rename = "user")] + address: String, + }, + + #[serde(rename = "openOrders")] + OpenOrders { + #[serde(rename = "user")] + address: String, + }, + + #[serde(rename = "candle")] + Candle { + #[serde(rename = "coin")] + symbol: String, + interval: String, + }, + + #[serde(rename = "activeAssetCtx")] + MarketData { + #[serde(rename = "coin")] + symbol: String, + }, + + #[serde(rename = "allMids")] + MarketPrices, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct HyperliquidRequest { + pub method: HyperliquidMethod, + pub subscription: HyperliquidSubscription, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AllMidsData { + pub mids: HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ActiveAssetCtxData { + #[serde(rename = "coin")] + pub symbol: String, + pub ctx: ActiveAssetCtx, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveAssetCtx { + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub day_ntl_vlm: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub prev_day_px: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub mark_px: f64, + #[serde(default, deserialize_with = "deserialize_option_f64_from_str")] + pub mid_px: Option, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub funding: f64, + #[serde(deserialize_with = "deserialize_f64_from_str")] + pub open_interest: f64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountStateData { + pub clearinghouse_state: AssetPositions, + #[serde(rename = "user")] + pub address: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OpenOrdersData { + #[serde(rename = "user")] + pub address: String, + pub orders: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SubscriptionResponseData { + pub subscription: Subscription, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Subscription { + #[serde(rename = "type")] + pub subscription_type: String, +} + +#[derive(Debug)] +pub struct PositionsDiff { + pub delete_position_ids: Vec, + pub positions: Vec, +} + +#[derive(Debug)] +pub enum HyperliquidSocketMessage { + AccountState { balance: PerpetualBalance, positions: Vec }, + OpenOrders { orders: Vec }, + Candle { candle: ChartCandleUpdate }, + MarketData { market: PerpetualMarketData }, + MarketPrices { prices: HashMap }, + SubscriptionResponse { subscription_type: String }, + Unknown, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn test_encode_websocket_request() { + let request = HyperliquidRequest { + method: HyperliquidMethod::Subscribe, + subscription: HyperliquidSubscription::Candle { + symbol: "ETH".to_string(), + interval: "30m".to_string(), + }, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "method": "subscribe", + "subscription": { + "type": "candle", + "coin": "ETH", + "interval": "30m", + }, + }) + ); + } +} diff --git a/core/crates/gem_hypercore/src/perpetual_formatter.rs b/core/crates/gem_hypercore/src/perpetual_formatter.rs new file mode 100644 index 0000000000..bc656d5437 --- /dev/null +++ b/core/crates/gem_hypercore/src/perpetual_formatter.rs @@ -0,0 +1,138 @@ +// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size +// https://hyperliquid.gitbook.io/hyperliquid-docs/trading/contract-specifications + +const MIN_ORDER_VALUE_USD: f64 = 10.0; +const USDC_CENTS_MULTIPLIER: f64 = 100.0; +pub const USDC_DECIMALS_MULTIPLIER: f64 = 1_000_000.0; + +pub fn usdc_value(amount: f64) -> String { + ((amount * USDC_DECIMALS_MULTIPLIER).round() as u64).to_string() +} + +pub struct PerpetualFormatter; + +impl PerpetualFormatter { + /// Hyperliquid requires minimum $10 notional value (size × price). + pub fn minimum_order_usd_amount(price: f64, sz_decimals: i32, leverage: u8) -> u64 { + let size_multiplier = 10_f64.powi(sz_decimals); + let rounded_size = ((MIN_ORDER_VALUE_USD / price) * size_multiplier).ceil() / size_multiplier; + let min_usd = ((rounded_size * price / f64::from(leverage)) * USDC_CENTS_MULTIPLIER).ceil() / USDC_CENTS_MULTIPLIER; + + (min_usd * USDC_DECIMALS_MULTIPLIER) as u64 + } + + pub fn format_price(price: f64, sz_decimals: i32) -> String { + if price == 0.0 { + return "0".to_string(); + } + + let max_decimals = (6 - sz_decimals).max(0); + let magnitude = price.abs().log10().floor(); + let sig_fig_decimals = (4.0 - magnitude).max(0.0); + let decimals = sig_fig_decimals.min(max_decimals as f64) as usize; + + format_and_trim(price, decimals) + } + + pub fn format_size(size: f64, sz_decimals: i32) -> String { + let decimals = sz_decimals.max(0) as usize; + let multiplier = 10_f64.powi(sz_decimals); + let value = (size * multiplier + 0.5).floor() / multiplier; + + format_and_trim(value, decimals) + } +} + +fn format_and_trim(value: f64, decimals: usize) -> String { + let formatted = format!("{:.decimals$}", value, decimals = decimals); + + if formatted.contains('.') { + formatted.trim_end_matches('0').trim_end_matches('.').to_string() + } else { + formatted + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_minimum_order_usd_amount() { + assert_eq!(PerpetualFormatter::minimum_order_usd_amount(100_000.0, 5, 1), 10_000_000); + assert_eq!(PerpetualFormatter::minimum_order_usd_amount(3_500.0, 4, 3), 3_390_000); + assert_eq!(PerpetualFormatter::minimum_order_usd_amount(487.0, 2, 1), 14_610_000); + assert_eq!(PerpetualFormatter::minimum_order_usd_amount(200.0, 1, 10), 2_000_000); + assert_eq!(PerpetualFormatter::minimum_order_usd_amount(0.5, 0, 1), 10_000_000); + } + + #[test] + fn test_format_price() { + assert_eq!(PerpetualFormatter::format_price(0.002877, 0), "0.002877"); + assert_eq!(PerpetualFormatter::format_price(0.00284, 0), "0.00284"); + assert_eq!(PerpetualFormatter::format_price(0.003003, 0), "0.003003"); + assert_eq!(PerpetualFormatter::format_price(12345.678, 0), "12346"); + assert_eq!(PerpetualFormatter::format_price(1234.5, 0), "1234.5"); + assert_eq!(PerpetualFormatter::format_price(123.45, 0), "123.45"); + assert_eq!(PerpetualFormatter::format_price(3397.10, 0), "3397.1"); + assert_eq!(PerpetualFormatter::format_price(3532.984, 0), "3533"); + assert_eq!(PerpetualFormatter::format_price(3261.216, 0), "3261.2"); + assert_eq!(PerpetualFormatter::format_price(99.999, 0), "99.999"); + assert_eq!(PerpetualFormatter::format_price(0.005849, 0), "0.005849"); + assert_eq!(PerpetualFormatter::format_price(0.0061415, 0), "0.006142"); + assert_eq!(PerpetualFormatter::format_price(0.0052641, 0), "0.005264"); + + assert_eq!(PerpetualFormatter::format_price(1234.567, 1), "1234.6"); + assert_eq!(PerpetualFormatter::format_price(123.456, 1), "123.46"); + assert_eq!(PerpetualFormatter::format_price(0.0012345, 1), "0.00123"); + + assert_eq!(PerpetualFormatter::format_price(123.456, 2), "123.46"); + assert_eq!(PerpetualFormatter::format_price(12.3456, 2), "12.346"); + assert_eq!(PerpetualFormatter::format_price(1.23456, 2), "1.2346"); + + assert_eq!(PerpetualFormatter::format_price(3397.10, 4), "3397.1"); + assert_eq!(PerpetualFormatter::format_price(3532.984, 4), "3533"); + assert_eq!(PerpetualFormatter::format_price(0.005849, 4), "0.01"); + + assert_eq!(PerpetualFormatter::format_price(0.0, 6), "0"); + assert_eq!(PerpetualFormatter::format_price(1.0, 6), "1"); + assert_eq!(PerpetualFormatter::format_price(0.000001, 6), "0"); + assert_eq!(PerpetualFormatter::format_price(123.456, 6), "123"); + + assert_eq!(PerpetualFormatter::format_price(-123.456, 0), "-123.46"); + assert_eq!(PerpetualFormatter::format_price(0.0000001, 0), "0"); + assert_eq!(PerpetualFormatter::format_price(999999.0, 0), "999999"); + } + + #[test] + fn test_format_size() { + assert_eq!(PerpetualFormatter::format_size(123.456789, 0), "123"); + assert_eq!(PerpetualFormatter::format_size(0.123456, 0), "0"); + assert_eq!(PerpetualFormatter::format_size(1000.5, 0), "1001"); + assert_eq!(PerpetualFormatter::format_size(0.9, 0), "1"); + assert_eq!(PerpetualFormatter::format_size(0.4, 0), "0"); + + assert_eq!(PerpetualFormatter::format_size(123.456, 1), "123.5"); + assert_eq!(PerpetualFormatter::format_size(0.123456, 1), "0.1"); + assert_eq!(PerpetualFormatter::format_size(1.05, 1), "1.1"); + + assert_eq!(PerpetualFormatter::format_size(0.123456, 3), "0.123"); + assert_eq!(PerpetualFormatter::format_size(1.234567, 3), "1.235"); + + assert_eq!(PerpetualFormatter::format_size(0.123456789, 6), "0.123457"); + + assert_eq!(PerpetualFormatter::format_size(-123.456, 2), "-123.46"); + } + + #[test] + fn test_format_and_trim() { + assert_eq!(format_and_trim(123.456, 2), "123.46"); + assert_eq!(format_and_trim(123.0, 2), "123"); + assert_eq!(format_and_trim(123.400, 3), "123.4"); + assert_eq!(format_and_trim(0.0, 2), "0"); + assert_eq!(format_and_trim(1.0000, 4), "1"); + assert_eq!(format_and_trim(1.2000, 4), "1.2"); + assert_eq!(format_and_trim(123.0, 0), "123"); + assert_eq!(format_and_trim(-123.450, 2), "-123.45"); + } +} diff --git a/core/crates/gem_hypercore/src/provider/balances.rs b/core/crates/gem_hypercore/src/provider/balances.rs new file mode 100644 index 0000000000..c25277198a --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/balances.rs @@ -0,0 +1,106 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use futures::try_join; +use std::error::Error; + +use gem_client::Client; +use number_formatter::BigNumberFormatter; +use primitives::{Asset, AssetBalance}; + +use super::balances_mapper::{map_balance_assets, map_balance_coin, map_balance_staking, map_balance_tokens}; +use crate::rpc::client::HyperCoreClient; + +const NATIVE_TOKEN_INDEX: u32 = 150; + +#[async_trait] +impl ChainBalances for HyperCoreClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let total = self + .get_spot_balances(&address) + .await? + .balances + .into_iter() + .find(|balance| balance.token == NATIVE_TOKEN_INDEX) + .map(|balance| balance.total) + .unwrap_or_else(|| "0".to_string()); + let native_decimals = Asset::from_chain(self.chain).decimals as u32; + let available: String = BigNumberFormatter::value_from_amount(&total, native_decimals)?; + Ok(map_balance_coin(available, self.chain)) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let (spot_balances, spot_meta) = try_join!(self.get_spot_balances(&address), self.get_spot_meta())?; + Ok(map_balance_tokens(&spot_balances, &spot_meta.tokens, &token_ids, self.chain)) + } + + async fn get_balance_staking(&self, address: String) -> Result, Box> { + let balance = self.get_stake_balance(&address).await?; + Ok(Some(map_balance_staking(&balance, self.chain)?)) + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + let (spot_balances, spot_meta) = try_join!(self.get_spot_balances(&address), self.get_spot_meta())?; + Ok(map_balance_assets(&spot_balances, &spot_meta.tokens, self.chain)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod integration_tests { + use crate::provider::testkit::{TEST_ADDRESS, USDC_TOKEN_ID, create_hypercore_test_client}; + use chain_traits::ChainBalances; + use num_bigint::BigUint; + + #[tokio::test] + async fn test_hypercore_get_balance_coin() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let address = TEST_ADDRESS.to_string(); + let balance = client.get_balance_coin(address).await?; + + println!("Hypercore coin balance: {:?} {}", balance.balance.available, balance.asset_id); + + assert!(balance.balance.available >= BigUint::from(0u64)); + assert_eq!(balance.asset_id.chain, primitives::Chain::HyperCore); + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_balance_tokens() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let address = TEST_ADDRESS.to_string(); + let token_balances = client.get_balance_tokens(address, vec![USDC_TOKEN_ID.to_string()]).await?; + + println!("Hypercore token balances: {:?}", token_balances); + + assert!(!token_balances.is_empty()); + assert_eq!(token_balances[0].asset_id.chain, primitives::Chain::HyperCore); + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_balance_staking() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let address = TEST_ADDRESS.to_string(); + let balance = client.get_balance_staking(address).await?.ok_or("not found")?; + + println!("Hypercore staking balance: {:?}", balance.balance.staked); + + assert!(balance.balance.staked >= BigUint::from(0u64)); + assert_eq!(balance.asset_id.chain, primitives::Chain::HyperCore); + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_balance_assets() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + println!("Hypercore asset balances: {:?}", assets); + + for asset in &assets { + assert_eq!(asset.asset_id.chain, primitives::Chain::HyperCore); + assert!(asset.asset_id.token_id.is_some()); + } + Ok(()) + } +} diff --git a/core/crates/gem_hypercore/src/provider/balances_mapper.rs b/core/crates/gem_hypercore/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..11c49245c2 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/balances_mapper.rs @@ -0,0 +1,150 @@ +use crate::models::{ + balance::{Balances, StakeBalance}, + token::SpotToken, +}; +use num_bigint::BigUint; +use number_formatter::BigNumberFormatter; +use primitives::{Asset, AssetBalance, AssetId, Balance, Chain}; +use std::error::Error; + +pub fn map_balance_coin(balance: String, chain: Chain) -> AssetBalance { + AssetBalance::new(chain.as_asset_id(), balance.parse::().unwrap_or_default()) +} + +pub fn map_balance_token(asset_id: AssetId, balance: String, decimals: i32) -> Result> { + let available = BigNumberFormatter::value_from_amount_biguint(&balance, decimals as u32)?; + + Ok(AssetBalance::new(asset_id, available)) +} + +pub fn map_balance_assets(spot_balances: &Balances, spot_tokens: &[SpotToken], chain: Chain) -> Vec { + spot_balances + .balances + .iter() + .filter_map(|x| { + let token = spot_tokens.iter().find(|t| t.index as u32 == x.token)?; + map_balance_token(token.asset_id(chain), x.total.clone(), token.wei_decimals).ok() + }) + .collect() +} + +pub fn map_balance_tokens(spot_balances: &Balances, spot_tokens: &[SpotToken], token_ids: &[String], chain: Chain) -> Vec { + token_ids + .iter() + .filter_map(|token_id| { + let parts = AssetId::decode_token_id(token_id); + let symbol = parts.first()?; + let token = spot_tokens.iter().find(|t| &t.name == symbol)?; + let asset_id = AssetId::from(chain, Some(token_id.clone())); + if let Some(balance) = spot_balances.balances.iter().find(|b| b.token == token.index as u32) { + map_balance_token(asset_id, balance.total.clone(), token.wei_decimals).ok() + } else { + Some(AssetBalance::new_zero_balance(asset_id)) + } + }) + .collect() +} + +pub fn map_balance_staking(balance: &StakeBalance, chain: Chain) -> Result> { + let native_decimals = Asset::from_chain(chain).decimals as u32; + let available_biguint = BigNumberFormatter::value_from_amount_biguint(&balance.delegated, native_decimals).unwrap_or_default(); + let pending_biguint = BigNumberFormatter::value_from_amount_biguint(&balance.total_pending_withdrawal, native_decimals).unwrap_or_default(); + + Ok(AssetBalance::new_balance( + chain.as_asset_id(), + Balance::stake_balance(available_biguint, pending_biguint, None), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::balance::Balance; + use primitives::{Chain, asset_constants::HYPERCORE_SPOT_USDC_TOKEN_ID}; + + #[test] + fn test_map_balance_coin() { + let balance = "1000000000000000000".to_string(); + let result = map_balance_coin(balance, Chain::SmartChain); + + assert_eq!(result.balance.available, BigUint::from(1000000000000000000_u64)); + assert_eq!(result.asset_id.chain, Chain::SmartChain); + } + + #[test] + fn test_map_balance_token() { + let asset_id = AssetId::from(Chain::HyperCore, Some("USDC::0".to_string())); + let result = map_balance_token(asset_id, "56003537".to_string(), 8).unwrap(); + + assert_eq!(result.balance.available, "5600353700000000".parse::().unwrap()); + assert_eq!(result.asset_id.chain, Chain::HyperCore); + assert_eq!(result.asset_id.token_id, Some("USDC::0".to_string())); + } + + #[test] + fn test_map_balance_tokens() { + let spot_balances = Balances { + balances: vec![Balance { + coin: "USDC".to_string(), + token: 0, + total: "56003537".to_string(), + hold: "0".to_string(), + }], + }; + + let spot_tokens = vec![SpotToken { + name: "USDC".to_string(), + wei_decimals: 8, + index: 0, + token_id: "0x6d1e7cde53ba9467b783cb7c530ce054".to_string(), + sz_decimals: 2, + }]; + + let token_ids_by_symbol = vec!["USDC".to_string()]; + let results = map_balance_tokens(&spot_balances, &spot_tokens, &token_ids_by_symbol, Chain::HyperCore); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].asset_id.chain, Chain::HyperCore); + assert_eq!(results[0].balance.available, "5600353700000000".parse::().unwrap()); + + let token_ids_full = vec![HYPERCORE_SPOT_USDC_TOKEN_ID.to_string()]; + let results_full = map_balance_tokens(&spot_balances, &spot_tokens, &token_ids_full, Chain::HyperCore); + + assert_eq!(results_full.len(), 1); + assert_eq!(results_full[0].asset_id.chain, Chain::HyperCore); + assert_eq!(results_full[0].balance.available, "5600353700000000".parse::().unwrap()); + } + + #[test] + fn test_map_balance_tokens_missing_balance() { + let spot_balances = Balances { balances: vec![] }; + + let spot_tokens = vec![SpotToken { + name: "USDC".to_string(), + wei_decimals: 8, + index: 0, + token_id: "0x6d1e7cde53ba9467b783cb7c530ce054".to_string(), + sz_decimals: 2, + }]; + + let token_ids = vec!["USDC".to_string()]; + let results = map_balance_tokens(&spot_balances, &spot_tokens, &token_ids, Chain::HyperCore); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].asset_id.chain, Chain::HyperCore); + assert_eq!(results[0].balance.available, BigUint::from(0u64)); + } + + #[test] + fn test_map_balance_staking() { + let stake_balance = StakeBalance { + delegated: "100.0".to_string(), + undelegated: "0.0".to_string(), + total_pending_withdrawal: "10.0".to_string(), + }; + let result = map_balance_staking(&stake_balance, Chain::HyperCore).unwrap(); + + assert_eq!(result.balance.staked, BigUint::from(10_000_000_000u64)); + assert_eq!(result.balance.pending, BigUint::from(1_000_000_000u64)); + } +} diff --git a/core/crates/gem_hypercore/src/provider/fee_calculator.rs b/core/crates/gem_hypercore/src/provider/fee_calculator.rs new file mode 100644 index 0000000000..d65a0f889d --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/fee_calculator.rs @@ -0,0 +1,100 @@ +use num_bigint::BigInt; +use number_formatter::BigNumberFormatter; +use primitives::{Asset, asset_constants::HYPERCORE_SPOT_USDC_ASSET_ID, swap::SwapData}; +use std::error::Error; + +use crate::perpetual_formatter::USDC_DECIMALS_MULTIPLIER; + +const HYPERCORE_BUILDER_FEE_RATE_SCALE: f64 = 100_000.0; +const HYPERCORE_PERPETUAL_USDC_DECIMALS: i32 = 6; + +pub fn calculate_perpetual_fee_amount(fiat_value: f64, fee_rate: f64) -> BigInt { + let result = fiat_value * fee_rate * USDC_DECIMALS_MULTIPLIER; + BigInt::from(result as i64) +} + +pub fn calculate_spot_fee_amount(swap_data: &SwapData, from_asset: &Asset, to_asset: &Asset, fee_rate: f64, builder_fee_bps: u32) -> Result> { + let fiat_value = calculate_spot_usdc_value(swap_data, from_asset, to_asset, builder_fee_bps)?; + let usdc_decimals = spot_usdc_decimals(from_asset, to_asset)?; + let trade_fee = calculate_perpetual_fee_amount(fiat_value * decimal_scale(usdc_decimals - HYPERCORE_PERPETUAL_USDC_DECIMALS), fee_rate); + let builder_fee = BigInt::from((fiat_value * f64::from(builder_fee_bps) * decimal_scale(usdc_decimals - 5)) as i64); + + Ok(trade_fee + builder_fee) +} + +fn calculate_spot_usdc_value(swap_data: &SwapData, from_asset: &Asset, to_asset: &Asset, builder_fee_bps: u32) -> Result> { + let usdc_from = from_asset.id == *HYPERCORE_SPOT_USDC_ASSET_ID; + let usdc_to = to_asset.id == *HYPERCORE_SPOT_USDC_ASSET_ID; + + match (usdc_from, usdc_to) { + (true, false) => quote_value(&swap_data.quote.from_value, from_asset.decimals), + (false, true) => { + let net_output = quote_value(&swap_data.quote.to_value, to_asset.decimals)?; + let fee_factor = 1.0 - f64::from(builder_fee_bps) / HYPERCORE_BUILDER_FEE_RATE_SCALE; + Ok(net_output / fee_factor) + } + _ => Err("spot swap quote must have exactly one USDC leg".into()), + } +} + +fn quote_value(value: &str, decimals: i32) -> Result> { + Ok(BigNumberFormatter::value(value, decimals)?.parse::()?) +} + +fn spot_usdc_decimals(from_asset: &Asset, to_asset: &Asset) -> Result> { + if from_asset.id == *HYPERCORE_SPOT_USDC_ASSET_ID { + return Ok(from_asset.decimals); + } + if to_asset.id == *HYPERCORE_SPOT_USDC_ASSET_ID { + return Ok(to_asset.decimals); + } + Err("spot swap quote must have exactly one USDC leg".into()) +} + +fn decimal_scale(power: i32) -> f64 { + 10_i64.pow(power as u32) as f64 +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{ + SwapProvider, + known_assets::{HYPERCORE_SPOT_HYPE, HYPERCORE_SPOT_USDC}, + }; + + #[test] + fn calculate_perpetual_fee_amount_cases() { + for (fiat_value, fee_rate, expected) in [ + (100.0, 0.00045, 45_000_i64), + (1000.0, 0.000315, 315_000), + (1000.0, 0.0, 0), + (0.0, 0.000025, 0), + (1.0, 0.000043, 43), + (10000.0, 0.001, 10_000_000), + ] { + assert_eq!(calculate_perpetual_fee_amount(fiat_value, fee_rate), BigInt::from(expected)); + } + } + + #[test] + fn calculate_spot_fee_amount_cases() { + for (swap_data, from_asset, to_asset, expected) in [ + ( + SwapData::mock_with_values(SwapProvider::Hyperliquid, "30000000", "1181917897"), + &HYPERCORE_SPOT_HYPE, + &HYPERCORE_SPOT_USDC, + 1_194_273_u64, + ), + ( + SwapData::mock_with_values(SwapProvider::Hyperliquid, "1197900000", "29986500"), + &HYPERCORE_SPOT_USDC, + &HYPERCORE_SPOT_HYPE, + 1_209_878_u64, + ), + ] { + let result = calculate_spot_fee_amount(&swap_data, from_asset, to_asset, 0.00056, 45).unwrap(); + assert_eq!(result, BigInt::from(expected)); + } + } +} diff --git a/core/crates/gem_hypercore/src/provider/mod.rs b/core/crates/gem_hypercore/src/provider/mod.rs new file mode 100644 index 0000000000..52fbfd1e72 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/mod.rs @@ -0,0 +1,31 @@ +use async_trait::async_trait; +use chain_traits::ChainAccount; +use gem_client::Client; + +pub mod balances; +pub mod balances_mapper; +pub mod fee_calculator; +pub mod perpetual; +pub mod perpetual_mapper; +pub mod preload; +pub mod preload_cache; +pub mod preload_mapper; +pub mod request_classifier; +pub mod staking; +pub mod staking_mapper; +pub mod state; +pub mod testkit; +pub mod token; +pub mod transaction_broadcast; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; +pub mod websocket_mapper; + +pub struct BroadcastProvider; + +use crate::rpc::client::HyperCoreClient; + +#[async_trait] +impl ChainAccount for HyperCoreClient {} diff --git a/core/crates/gem_hypercore/src/provider/perpetual.rs b/core/crates/gem_hypercore/src/provider/perpetual.rs new file mode 100644 index 0000000000..ebf0736330 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/perpetual.rs @@ -0,0 +1,503 @@ +use std::error::Error; + +use async_trait::async_trait; +use chain_traits::{ChainAddressStatus, ChainPerpetual}; +use futures::{future::try_join_all, try_join}; +use gem_client::Client; +use primitives::{ + ChartPeriod, + chart::ChartCandleStick, + perpetual::{PerpetualBalance, PerpetualData, PerpetualPositionsSummary}, + portfolio::PerpetualPortfolio, +}; + +use crate::{ + config::HypercoreConfig, + models::{order::OpenOrder, perp_dex::PerpDex, position::AssetPositions, user::UserAbstractionMode}, + provider::perpetual_mapper::{ + map_account_summary_aggregate, map_candlesticks, map_perpetual_balance_from_spot, map_perpetual_portfolio, map_perpetuals_data, map_positions, merge_perpetual_portfolios, + }, + rpc::client::HyperCoreClient, +}; + +fn filter_active_dex(perp_dexs: &[Option], enabled_hip3_markets: &[String]) -> Vec<(u32, Option)> { + perp_dexs + .iter() + .enumerate() + .filter_map(|(index, entry)| { + if index == 0 { + return Some((0, None)); + } + let dex = entry.as_ref()?; + if dex.is_active == Some(false) { + return None; + } + if dex.name.is_empty() { + return None; + } + if enabled_hip3_markets.is_empty() { + return None; + } + if !enabled_hip3_markets.iter().any(|market| market == &dex.name) { + return None; + } + Some((index as u32, Some(dex.name.clone()))) + }) + .collect() +} + +pub fn candle_interval(period: &ChartPeriod) -> &'static str { + match period { + ChartPeriod::Hour => "1m", + ChartPeriod::Day => "30m", + ChartPeriod::Week => "4h", + ChartPeriod::Month => "12h", + ChartPeriod::Year => "1w", + ChartPeriod::All => "1M", + } +} + +impl HyperCoreClient { + async fn get_active_dex_entries(&self) -> Vec<(u32, Option)> { + if self.config.enabled_hip3_markets.is_empty() { + return vec![(0, None)]; + } + + self.get_perp_dexs() + .await + .map(|dexs| filter_active_dex(&dexs, &self.config.enabled_hip3_markets)) + .unwrap_or_else(|_| vec![(0, None)]) + } + + async fn get_positions_for_dex(&self, address: String, dex: Option) -> Result> { + match dex.as_deref() { + Some(dex) => self.get_clearinghouse_state_with_dex(&address, dex).await, + None => self.get_clearinghouse_state(&address).await, + } + } + + async fn get_open_orders_for_dex(&self, address: String, dex: Option) -> Result, Box> { + match dex.as_deref() { + Some(dex) => self.get_open_orders_with_dex(&address, dex).await, + None => self.get_open_orders(&address).await, + } + } + + async fn get_portfolio_for_dex(&self, address: String, dex: Option) -> Result<(PerpetualPortfolio, AssetPositions), Box> { + let (response, positions) = match dex.as_deref() { + Some(dex) => try_join!(self.get_perpetual_portfolio_with_dex(&address, dex), self.get_clearinghouse_state_with_dex(&address, dex))?, + None => try_join!(self.get_perpetual_portfolio(&address), self.get_clearinghouse_state(&address))?, + }; + Ok((map_perpetual_portfolio(response, &positions), positions)) + } +} + +#[async_trait] +impl ChainPerpetual for HyperCoreClient { + async fn get_positions(&self, address: String) -> Result> { + let (mode, dex_entries) = futures::join!(self.get_user_abstraction(&address), self.get_active_dex_entries()); + let mode = mode?; + let summaries = try_join_all(dex_entries.into_iter().map(|(_, dex)| { + let address = address.clone(); + async move { + let positions = self.get_positions_for_dex(address.clone(), dex.clone()).await?; + let orders = if positions.asset_positions.is_empty() { + Vec::new() + } else { + self.get_open_orders_for_dex(address.clone(), dex).await? + }; + Ok::<_, Box>(map_positions(positions, address, &orders)) + } + })) + .await?; + + let (positions, balance) = summaries.into_iter().fold( + ( + Vec::new(), + PerpetualBalance { + available: 0.0, + reserved: 0.0, + withdrawable: 0.0, + }, + ), + |(mut acc_pos, mut acc_bal), summary| { + acc_pos.extend(summary.positions); + acc_bal.available += summary.balance.available; + acc_bal.reserved += summary.balance.reserved; + acc_bal.withdrawable += summary.balance.withdrawable; + (acc_pos, acc_bal) + }, + ); + + let balance = match mode { + UserAbstractionMode::Default | UserAbstractionMode::Disabled | UserAbstractionMode::DexAbstraction => balance, + UserAbstractionMode::UnifiedAccount | UserAbstractionMode::PortfolioMargin => { + let spot = self.get_spot_balances(&address).await?; + map_perpetual_balance_from_spot(&spot) + } + }; + + Ok(PerpetualPositionsSummary { positions, balance }) + } + + async fn get_perpetuals_data(&self) -> Result, Box> { + let dex_entries = self.get_active_dex_entries().await; + let requests: Vec<_> = dex_entries + .iter() + .map(|(_, dex)| async move { + match dex.as_deref() { + Some(dex) => self.get_metadata_with_dex(dex).await, + None => self.get_metadata().await, + } + }) + .collect(); + let metadata = try_join_all(requests).await?; + + Ok(dex_entries.iter().zip(metadata).flat_map(|((index, _), meta)| map_perpetuals_data(meta, *index)).collect()) + } + + async fn get_perpetual_candlesticks(&self, symbol: String, period: ChartPeriod) -> Result, Box> { + let interval = candle_interval(&period); + + let end_time = chrono::Utc::now().timestamp() * 1000; + let start_time = match period { + ChartPeriod::Hour => end_time - 60 * 60 * 1000, + ChartPeriod::Day => end_time - 24 * 60 * 60 * 1000, + ChartPeriod::Week => end_time - 7 * 24 * 60 * 60 * 1000, + ChartPeriod::Month => end_time - 30 * 24 * 60 * 60 * 1000, + ChartPeriod::Year => end_time - 365 * 24 * 60 * 60 * 1000, + ChartPeriod::All => 0, + }; + + let candlesticks = self.get_candlesticks(&symbol, interval, start_time, end_time).await?; + Ok(map_candlesticks(candlesticks)) + } + + async fn get_perpetual_portfolio(&self, address: String) -> Result> { + let dex_entries = self.get_active_dex_entries().await; + let requests: Vec<_> = dex_entries.iter().map(|(_, dex)| self.get_portfolio_for_dex(address.clone(), dex.clone())).collect(); + let results = try_join_all(requests).await?; + let (portfolios, positions): (Vec<_>, Vec<_>) = results.into_iter().unzip(); + let account_summary = Some(map_account_summary_aggregate(&positions)); + Ok(merge_perpetual_portfolios(portfolios, account_summary)) + } + + async fn get_perpetual_referred_addresses(&self) -> Result, Box> { + let config = HypercoreConfig::default(); + let referral = self.get_referral(&config.builder_address).await?; + let referral_states = referral.referrer_state.and_then(|s| s.data).and_then(|d| d.referral_states).unwrap_or_default(); + Ok(referral_states.into_iter().map(|r| r.user).collect()) + } +} + +#[async_trait] +impl ChainAddressStatus for HyperCoreClient {} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + use gem_client::{ClientError, testkit::MockClient}; + use primitives::testkit::json::load_testdata; + use primitives::{InMemoryPreferences, PerpetualId, PerpetualProvider}; + use serde_json::Value; + + #[test] + fn test_filter_active_dex_filters_inactive() { + let dexs = vec![ + None, + Some(PerpDex { + name: "dex1".to_string(), + is_active: Some(true), + }), + Some(PerpDex { + name: "dex2".to_string(), + is_active: Some(false), + }), + Some(PerpDex { + name: "dex3".to_string(), + is_active: None, + }), + ]; + + let enabled_hip3_markets = vec!["dex1".to_string(), "dex3".to_string()]; + let entries = filter_active_dex(&dexs, &enabled_hip3_markets); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0], (0, None)); + assert_eq!(entries[1], (1, Some("dex1".to_string()))); + assert_eq!(entries[2], (3, Some("dex3".to_string()))); + } + + #[test] + fn test_filter_active_dex_skips_empty_names() { + let dexs = vec![ + None, + Some(PerpDex { + name: "".to_string(), + is_active: Some(true), + }), + ]; + + let enabled_hip3_markets = vec!["dex1".to_string()]; + let entries = filter_active_dex(&dexs, &enabled_hip3_markets); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0], (0, None)); + } + + #[test] + fn test_filter_active_dex_limits_to_enabled_market() { + let dexs = vec![ + None, + Some(PerpDex { + name: "dex1".to_string(), + is_active: Some(true), + }), + Some(PerpDex { + name: "xyz".to_string(), + is_active: Some(true), + }), + ]; + + let enabled_hip3_markets = vec!["xyz".to_string()]; + let entries = filter_active_dex(&dexs, &enabled_hip3_markets); + assert_eq!(entries, vec![(0, None), (2, Some("xyz".to_string()))]); + } + + #[test] + fn test_filter_active_dex_skips_hip3_when_enabled_markets_empty() { + let dexs = vec![ + None, + Some(PerpDex { + name: "xyz".to_string(), + is_active: Some(true), + }), + ]; + + let enabled_hip3_markets = Vec::new(); + let entries = filter_active_dex(&dexs, &enabled_hip3_markets); + assert_eq!(entries, vec![(0, None)]); + } + + #[tokio::test] + async fn test_get_positions_skips_open_orders_for_dexes_without_positions() { + let perp_dexs_request: Value = load_testdata("perpetual_positions_request_perp_dexs.json"); + let clearinghouse_state_request: Value = load_testdata("perpetual_positions_request_clearinghouse_state.json"); + let clearinghouse_state_dex1_request: Value = load_testdata("perpetual_positions_request_clearinghouse_state_dex1.json"); + let clearinghouse_state_dex2_request: Value = load_testdata("perpetual_positions_request_clearinghouse_state_dex2.json"); + let open_orders_request: Value = load_testdata("perpetual_positions_request_open_orders.json"); + let open_orders_dex1_request: Value = load_testdata("perpetual_positions_request_open_orders_dex1.json"); + let open_orders_dex2_request: Value = load_testdata("perpetual_positions_request_open_orders_dex2.json"); + + let responses = Arc::new(vec![ + ( + serde_json::json!({"type": "userAbstraction", "user": "0x123"}), + include_bytes!("../../testdata/perpetual_positions_response_user_abstraction_default.json").to_vec(), + ), + (perp_dexs_request, include_bytes!("../../testdata/perpetual_positions_response_perp_dexs.json").to_vec()), + ( + clearinghouse_state_request, + include_bytes!("../../testdata/perpetual_positions_response_clearinghouse_state.json").to_vec(), + ), + ( + clearinghouse_state_dex1_request, + include_bytes!("../../testdata/perpetual_positions_response_clearinghouse_state_dex1.json").to_vec(), + ), + ( + clearinghouse_state_dex2_request, + include_bytes!("../../testdata/perpetual_positions_response_clearinghouse_state_dex2.json").to_vec(), + ), + ( + open_orders_request.clone(), + include_bytes!("../../testdata/perpetual_positions_response_open_orders.json").to_vec(), + ), + ( + open_orders_dex1_request.clone(), + include_bytes!("../../testdata/perpetual_positions_response_open_orders_dex1.json").to_vec(), + ), + ]); + let seen_requests = Arc::new(Mutex::new(Vec::new())); + let responses_clone = Arc::clone(&responses); + let seen_requests_clone = Arc::clone(&seen_requests); + let client = MockClient::new().with_post(move |path, body| { + assert_eq!(path, "/info"); + + let request: Value = serde_json::from_slice(body).unwrap(); + seen_requests_clone.lock().unwrap().push(request.clone()); + + responses_clone + .iter() + .find(|(expected_request, _)| *expected_request == request) + .map(|(_, response)| response.clone()) + .ok_or_else(|| ClientError::Http { status: 404, body: body.to_vec() }) + }); + + let preferences = Arc::new(InMemoryPreferences::new()); + let secure_preferences = Arc::new(InMemoryPreferences::new()); + let mut client = HyperCoreClient::new_with_preferences(client, preferences, secure_preferences); + client.config.enabled_hip3_markets = vec!["dex1".to_string(), "dex2".to_string()]; + + let summary = client.get_positions("0x123".to_string()).await.unwrap(); + let seen_requests = seen_requests.lock().unwrap().clone(); + + let btc = summary + .positions + .iter() + .find(|position| position.perpetual_id == PerpetualId::new(PerpetualProvider::Hypercore, "BTC")) + .unwrap(); + let eth = summary + .positions + .iter() + .find(|position| position.perpetual_id == PerpetualId::new(PerpetualProvider::Hypercore, "ETH")) + .unwrap(); + + assert!(seen_requests.contains(&open_orders_request)); + assert!(seen_requests.contains(&open_orders_dex1_request)); + assert!(!seen_requests.contains(&open_orders_dex2_request)); + assert_eq!(summary.positions.len(), 2); + assert_eq!(btc.take_profit.as_ref().map(|order| order.price), Some(110.0)); + assert_eq!(eth.stop_loss.as_ref().map(|order| order.price), Some(90.0)); + } + + #[tokio::test] + async fn test_get_positions_skips_hip3_requests_when_enabled_markets_empty() { + let perp_dexs_request: Value = load_testdata("perpetual_positions_request_perp_dexs.json"); + let clearinghouse_state_request: Value = load_testdata("perpetual_positions_request_clearinghouse_state.json"); + let clearinghouse_state_dex1_request: Value = load_testdata("perpetual_positions_request_clearinghouse_state_dex1.json"); + let clearinghouse_state_dex2_request: Value = load_testdata("perpetual_positions_request_clearinghouse_state_dex2.json"); + let open_orders_request: Value = load_testdata("perpetual_positions_request_open_orders.json"); + let open_orders_dex1_request: Value = load_testdata("perpetual_positions_request_open_orders_dex1.json"); + let open_orders_dex2_request: Value = load_testdata("perpetual_positions_request_open_orders_dex2.json"); + + let responses = Arc::new(vec![ + ( + serde_json::json!({"type": "userAbstraction", "user": "0x123"}), + include_bytes!("../../testdata/perpetual_positions_response_user_abstraction_default.json").to_vec(), + ), + ( + clearinghouse_state_request.clone(), + include_bytes!("../../testdata/perpetual_positions_response_clearinghouse_state.json").to_vec(), + ), + ( + open_orders_request.clone(), + include_bytes!("../../testdata/perpetual_positions_response_open_orders.json").to_vec(), + ), + ]); + let seen_requests = Arc::new(Mutex::new(Vec::new())); + let responses_clone = Arc::clone(&responses); + let seen_requests_clone = Arc::clone(&seen_requests); + let client = MockClient::new().with_post(move |path, body| { + assert_eq!(path, "/info"); + + let request: Value = serde_json::from_slice(body).unwrap(); + seen_requests_clone.lock().unwrap().push(request.clone()); + + responses_clone + .iter() + .find(|(expected_request, _)| *expected_request == request) + .map(|(_, response)| response.clone()) + .ok_or_else(|| ClientError::Http { status: 404, body: body.to_vec() }) + }); + + let preferences = Arc::new(InMemoryPreferences::new()); + let secure_preferences = Arc::new(InMemoryPreferences::new()); + let client = HyperCoreClient::new_with_preferences(client, preferences, secure_preferences); + + let summary = client.get_positions("0x123".to_string()).await.unwrap(); + let seen_requests = seen_requests.lock().unwrap().clone(); + + assert_eq!(summary.positions.len(), 1); + assert!(!seen_requests.contains(&perp_dexs_request)); + assert!(seen_requests.contains(&clearinghouse_state_request)); + assert!(seen_requests.contains(&open_orders_request)); + assert!(!seen_requests.contains(&clearinghouse_state_dex1_request)); + assert!(!seen_requests.contains(&clearinghouse_state_dex2_request)); + assert!(!seen_requests.contains(&open_orders_dex1_request)); + assert!(!seen_requests.contains(&open_orders_dex2_request)); + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod integration_tests { + use crate::provider::testkit::{TEST_ADDRESS, create_hypercore_test_client}; + use chain_traits::ChainPerpetual; + use primitives::ChartPeriod; + + #[tokio::test] + async fn test_hypercore_get_perp_dexs() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let dexs = client.get_perp_dexs().await?; + + assert!(!dexs.is_empty()); + + println!("Perp DEXs count: {}", dexs.len()); + for (i, dex) in dexs.iter().enumerate() { + println!(" DEX {}: {:?}", i, dex.as_ref().map(|d| (&d.name, &d.is_active))); + } + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_positions() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let summary = client.get_positions(TEST_ADDRESS.to_string()).await?; + + println!("Positions count: {}", summary.positions.len()); + println!( + "Balance: available={}, reserved={}, withdrawable={}", + summary.balance.available, summary.balance.reserved, summary.balance.withdrawable + ); + + for pos in &summary.positions { + println!(" {} {:?} size={} leverage={}", pos.perpetual_id, pos.direction, pos.size, pos.leverage); + } + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_perpetuals_data() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let data = client.get_perpetuals_data().await?; + + assert!(!data.is_empty()); + + println!("Perpetuals count: {}", data.len()); + for d in data.iter().take(5) { + println!( + " {} identifier={} price={} leverage={}", + d.perpetual.name, d.perpetual.identifier, d.perpetual.price, d.perpetual.max_leverage + ); + } + + let btc = data.iter().find(|d| d.perpetual.name == "BTC"); + assert!(btc.is_some(), "BTC perpetual should exist"); + assert_eq!(btc.unwrap().perpetual.identifier, "0"); + + let builder_assets: Vec<_> = data.iter().filter(|d| d.perpetual.identifier.parse::().unwrap_or(0) >= 100_000).collect(); + println!("Builder DEX assets: {}", builder_assets.len()); + + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_perpetual_portfolio() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let portfolio = ChainPerpetual::get_perpetual_portfolio(&client, TEST_ADDRESS.to_string()).await?; + + println!("Perpetual portfolio day: {:?}", portfolio.day.is_some()); + + assert!(portfolio.day.is_some() || portfolio.week.is_some() || portfolio.month.is_some() || portfolio.all_time.is_some()); + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_perpetual_candlesticks() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let candlesticks = client.get_perpetual_candlesticks("BTC".to_string(), ChartPeriod::Day).await?; + + println!("Perpetual candlesticks count: {:?}", candlesticks.len()); + + assert!(!candlesticks.is_empty()); + Ok(()) + } +} diff --git a/core/crates/gem_hypercore/src/provider/perpetual_mapper.rs b/core/crates/gem_hypercore/src/provider/perpetual_mapper.rs new file mode 100644 index 0000000000..f7460cefa4 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -0,0 +1,907 @@ +use crate::models::{ + balance::Balances, + candlestick::Candlestick, + metadata::HypercoreMetadataResponse, + order::OpenOrder, + portfolio::HypercorePortfolioResponse, + position::{AssetPositions, LeverageType, Position}, +}; +use primitives::{ + Asset, AssetId, AssetType, Chain, Perpetual, PerpetualBalance, PerpetualDirection, PerpetualId, PerpetualMarginType, PerpetualOrderType, PerpetualPosition, PerpetualProvider, + PerpetualTriggerOrder, + chart::{ChartCandleStick, ChartDateValue}, + known_assets::USDC_SYMBOL, + perpetual::{PerpetualData, PerpetualMetadata, PerpetualPositionsSummary}, + portfolio::{PerpetualAccountSummary, PerpetualPortfolio, PerpetualPortfolioTimeframeData}, +}; +use std::collections::BTreeMap; + +const HIP3_PERP_ASSET_OFFSET: u32 = 100_000; +const HIP3_PERP_ASSET_STRIDE: u32 = 10_000; + +pub fn create_perpetual_asset_id(coin: &str) -> AssetId { + crate::models::metadata::perpetual_asset_id(coin) +} + +pub fn create_perpetual_id(coin: &str) -> PerpetualId { + PerpetualId::new(PerpetualProvider::Hypercore, coin) +} + +pub fn map_positions(positions: AssetPositions, address: String, orders: &[OpenOrder]) -> PerpetualPositionsSummary { + let balance = map_perpetual_balance(&positions); + let positions: Vec = positions.asset_positions.into_iter().map(|x| map_position(x.position, address.clone(), orders)).collect(); + PerpetualPositionsSummary { positions, balance } +} + +pub fn map_perpetual_balance(positions: &AssetPositions) -> PerpetualBalance { + let equity = positions.margin_summary.account_value.parse().unwrap_or(0.0); + let margin_used = positions.cross_margin_summary.total_margin_used.parse().unwrap_or(0.0); + let reserved = f64::min(f64::max(margin_used, 0.0), f64::max(equity, 0.0)); + let available = f64::max(equity - reserved, 0.0); + let withdrawable = positions.withdrawable.parse().unwrap_or(0.0); + + PerpetualBalance { + available, + reserved, + withdrawable, + } +} + +pub fn map_perpetual_balance_from_spot(balances: &Balances) -> PerpetualBalance { + let usdc = balances.balances.iter().find(|b| b.coin == USDC_SYMBOL); + let total = usdc.and_then(|b| b.total.parse::().ok()).unwrap_or(0.0); + let hold = usdc.and_then(|b| b.hold.parse::().ok()).unwrap_or(0.0); + let reserved = f64::min(f64::max(hold, 0.0), f64::max(total, 0.0)); + let available = f64::max(total - reserved, 0.0); + + PerpetualBalance { + available, + reserved, + withdrawable: available, + } +} + +pub fn map_position(position: Position, address: String, orders: &[OpenOrder]) -> PerpetualPosition { + let size: f64 = position.szi.parse().unwrap_or(0.0); + let direction = if size >= 0.0 { PerpetualDirection::Long } else { PerpetualDirection::Short }; + + let raw_funding = position.cum_funding.since_open.parse::().unwrap_or(0.0); + let funding_value = match direction { + PerpetualDirection::Long => Some(-raw_funding), + PerpetualDirection::Short => { + if raw_funding < 0.0 { + Some(-raw_funding) + } else { + Some(raw_funding) + } + } + }; + let perpetual_id = create_perpetual_id(&position.coin); + let asset_id = create_perpetual_asset_id(&position.coin); + + let (take_profit, stop_loss) = map_tp_sl_from_orders(orders, &position.coin); + + PerpetualPosition { + id: format!("{}_{}", address.to_lowercase(), position.coin.clone()), + perpetual_id, + asset_id, + size: size.abs(), + size_value: position.position_value.parse::().unwrap_or(0.0).abs(), + leverage: position.leverage.value as u8, + entry_price: position.entry_px.parse().unwrap_or(0.0), + liquidation_price: position.liquidation_px.and_then(|p| p.parse().ok()), + margin_type: match position.leverage.leverage_type { + LeverageType::Cross => PerpetualMarginType::Cross, + LeverageType::Isolated => PerpetualMarginType::Isolated, + }, + direction, + margin_amount: position.margin_used.parse().unwrap_or(0.0), + take_profit, + stop_loss, + pnl: position.unrealized_pnl.parse().unwrap_or(0.0), + funding: funding_value, + } +} + +pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse, perp_dex_index: u32) -> Vec { + let universe = metadata.universe(); + let asset_metadata = metadata.asset_metadata(); + + universe + .universe + .iter() + .enumerate() + .map(|(index, universe_asset)| { + let metadata_item = asset_metadata.get(index); + + let asset_id = universe_asset.asset_id(); + let asset_index = perp_asset_index(perp_dex_index, index as u32); + + let current_price = metadata_item + .and_then(|m| m.mid_px.as_ref().and_then(|mid| mid.parse().ok()).or_else(|| m.mark_px.parse().ok())) + .unwrap_or(0.0); + + let prev_price = metadata_item.and_then(|m| m.prev_day_px.parse().ok()).unwrap_or(0.0); + + let price_change_24h = if prev_price > 0.0 { ((current_price - prev_price) / prev_price) * 100.0 } else { 0.0 }; + + let funding_rate = metadata_item.and_then(|m| m.funding.parse::().ok()).unwrap_or(0.0) * 100.0; + + let open_interest_coins = metadata_item.and_then(|m| m.open_interest.parse::().ok()).unwrap_or(0.0); + let open_interest_usd = open_interest_coins * current_price; + + let perpetual_id = create_perpetual_id(&universe_asset.name); + let perpetual = Perpetual { + id: perpetual_id, + name: universe_asset.name.clone(), + provider: PerpetualProvider::Hypercore, + asset_id: asset_id.clone(), + identifier: asset_index.to_string(), + price: current_price, + price_percent_change_24h: price_change_24h, + open_interest: open_interest_usd, + volume_24h: metadata_item.and_then(|m| m.day_ntl_vlm.parse().ok()).unwrap_or(0.0), + funding: funding_rate, + max_leverage: universe_asset.max_leverage as u8, + is_isolated_only: universe_asset.only_isolated.unwrap_or(false), + }; + + let asset = Asset { + id: asset_id, + chain: Chain::HyperCore, + token_id: Some(universe_asset.name.clone()), + name: universe_asset.name.clone(), + symbol: universe_asset.name.clone(), + decimals: universe_asset.sz_decimals, + asset_type: AssetType::PERPETUAL, + }; + + let metadata = PerpetualMetadata { is_pinned: false }; + + PerpetualData { perpetual, asset, metadata } + }) + .collect() +} + +pub fn map_candlesticks(candlesticks: Vec) -> Vec { + candlesticks.iter().map(ChartCandleStick::from).collect() +} + +pub fn map_account_summary(positions: &AssetPositions) -> PerpetualAccountSummary { + let account_value = positions.margin_summary.account_value.parse::().unwrap_or(0.0); + let total_ntl_pos = positions.margin_summary.total_ntl_pos.parse::().unwrap_or(0.0); + let total_margin_used = positions.margin_summary.total_margin_used.parse::().unwrap_or(0.0); + + let account_leverage = if account_value > 0.0 { total_ntl_pos / account_value } else { 0.0 }; + let margin_usage = if account_value > 0.0 { total_margin_used / account_value } else { 0.0 }; + + let unrealized_pnl: f64 = positions.asset_positions.iter().map(|p| p.position.unrealized_pnl.parse::().unwrap_or(0.0)).sum(); + + PerpetualAccountSummary { + account_value, + account_leverage, + margin_usage, + unrealized_pnl, + } +} + +pub fn map_perpetual_portfolio(response: HypercorePortfolioResponse, positions: &AssetPositions) -> PerpetualPortfolio { + let (day, week, month, all_time) = response + .timeframes + .into_iter() + .fold((None, None, None, None), |(day, week, month, all_time), (timeframe, data)| match timeframe.as_str() { + "perpDay" => (Some(data.into()), week, month, all_time), + "perpWeek" => (day, Some(data.into()), month, all_time), + "perpMonth" => (day, week, Some(data.into()), all_time), + "perpAllTime" => (day, week, month, Some(data.into())), + _ => (day, week, month, all_time), + }); + + PerpetualPortfolio { + day, + week, + month, + all_time, + account_summary: Some(map_account_summary(positions)), + } +} + +fn perp_asset_index(perp_dex_index: u32, meta_index: u32) -> u32 { + if perp_dex_index == 0 { + meta_index + } else { + HIP3_PERP_ASSET_OFFSET + perp_dex_index * HIP3_PERP_ASSET_STRIDE + meta_index + } +} + +pub fn map_account_summary_aggregate(positions: &[AssetPositions]) -> PerpetualAccountSummary { + let account_value: f64 = positions.iter().map(|p| p.margin_summary.account_value.parse().unwrap_or(0.0)).sum(); + let total_ntl_pos: f64 = positions.iter().map(|p| p.margin_summary.total_ntl_pos.parse().unwrap_or(0.0)).sum(); + let total_margin_used: f64 = positions.iter().map(|p| p.margin_summary.total_margin_used.parse().unwrap_or(0.0)).sum(); + let unrealized_pnl: f64 = positions + .iter() + .flat_map(|p| &p.asset_positions) + .map(|p| p.position.unrealized_pnl.parse().unwrap_or(0.0)) + .sum(); + + let account_leverage = if account_value > 0.0 { total_ntl_pos / account_value } else { 0.0 }; + let margin_usage = if account_value > 0.0 { total_margin_used / account_value } else { 0.0 }; + + PerpetualAccountSummary { + account_value, + account_leverage, + margin_usage, + unrealized_pnl, + } +} + +pub fn merge_perpetual_portfolios(portfolios: Vec, account_summary: Option) -> PerpetualPortfolio { + let mut day = Vec::new(); + let mut week = Vec::new(); + let mut month = Vec::new(); + let mut all_time = Vec::new(); + + for portfolio in portfolios { + day.extend(portfolio.day); + week.extend(portfolio.week); + month.extend(portfolio.month); + all_time.extend(portfolio.all_time); + } + + PerpetualPortfolio { + day: merge_portfolio_timeframes(day), + week: merge_portfolio_timeframes(week), + month: merge_portfolio_timeframes(month), + all_time: merge_portfolio_timeframes(all_time), + account_summary, + } +} + +fn merge_portfolio_timeframes(values: Vec) -> Option { + if values.is_empty() { + return None; + } + + let volume: f64 = values.iter().map(|v| v.volume).sum(); + let (account_value_histories, pnl_histories): (Vec<_>, Vec<_>) = values.into_iter().map(|v| (v.account_value_history, v.pnl_history)).unzip(); + + Some(PerpetualPortfolioTimeframeData { + account_value_history: merge_chart_histories(account_value_histories), + pnl_history: merge_chart_histories(pnl_histories), + volume, + }) +} + +fn merge_chart_histories(values: Vec>) -> Vec { + let mut grouped = BTreeMap::new(); + for history in values { + for point in history { + let entry = grouped.entry(point.date).or_insert(0.0); + *entry += point.value; + } + } + + grouped.into_iter().map(|(date, value)| ChartDateValue { date, value }).collect() +} + +fn determine_order_type(order_type_str: &str) -> PerpetualOrderType { + if order_type_str.to_lowercase().contains("market") { + PerpetualOrderType::Market + } else { + PerpetualOrderType::Limit + } +} + +pub fn map_tp_sl_from_orders(orders: &[OpenOrder], coin: &str) -> (Option, Option) { + orders + .iter() + .filter(|o| o.is_position_tpsl && o.coin == coin) + .fold((None, None), |(tp, sl), order| match order.trigger_px { + Some(price) if order.order_type.to_lowercase().contains("take profit") => ( + Some(PerpetualTriggerOrder { + price, + order_type: determine_order_type(&order.order_type), + order_id: order.oid.to_string(), + }), + sl, + ), + Some(price) if order.order_type.to_lowercase().contains("stop") => ( + tp, + Some(PerpetualTriggerOrder { + price, + order_type: determine_order_type(&order.order_type), + order_id: order.oid.to_string(), + }), + ), + _ => (tp, sl), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ + balance::{Balance, Balances}, + metadata::{AssetMetadata, HypercoreUniverseResponse, UniverseAsset}, + position::{AssetPosition, AssetPositions, CumulativeFunding, Leverage, LeverageType, MarginSummary, Position, PositionType}, + }; + use primitives::{PerpetualDirection, PerpetualMarginType, perpetual_provider::PerpetualProvider}; + + #[test] + fn test_map_positions_basic() { + let positions = AssetPositions { + asset_positions: vec![AssetPosition { + position_type: PositionType::OneWay, + position: Position { + coin: "BTC".to_string(), + szi: "1.5".to_string(), + leverage: Leverage { + leverage_type: LeverageType::Cross, + value: 10, + }, + entry_px: "50000".to_string(), + position_value: "75000".to_string(), + unrealized_pnl: "5000".to_string(), + return_on_equity: "0.1".to_string(), + liquidation_px: Some("40000".to_string()), + margin_used: "7500".to_string(), + max_leverage: 20, + cum_funding: CumulativeFunding { + all_time: "100".to_string(), + since_open: "50".to_string(), + }, + }, + }], + margin_summary: MarginSummary { + account_value: "100000".to_string(), + total_ntl_pos: "10000".to_string(), + total_raw_usd: "10000".to_string(), + total_margin_used: "5000".to_string(), + }, + cross_margin_summary: MarginSummary { + account_value: "100000".to_string(), + total_ntl_pos: "10000".to_string(), + total_raw_usd: "10000".to_string(), + total_margin_used: "8000".to_string(), + }, + cross_maintenance_margin_used: "3000".to_string(), + withdrawable: "92000".to_string(), + }; + + let result = map_positions(positions, "test_address".to_string(), &[]); + + assert_eq!(result.positions.len(), 1); + assert_eq!(result.positions[0].id, "test_address_BTC"); + assert_eq!(result.positions[0].size, 1.5); + assert_eq!(result.positions[0].direction, PerpetualDirection::Long); + assert_eq!(result.positions[0].margin_type, PerpetualMarginType::Cross); + assert_eq!(result.positions[0].leverage, 10); + assert_eq!(result.positions[0].pnl, 5000.0); + assert_eq!(result.positions[0].funding, Some(-50.0)); + + assert_eq!(result.balance.available, 92000.0); + assert_eq!(result.balance.reserved, 8000.0); + assert_eq!(result.balance.withdrawable, 92000.0); + } + + #[test] + fn test_map_perpetuals_data() { + let universe_response = HypercoreUniverseResponse { + universe: vec![UniverseAsset { + only_isolated: Some(false), + ..UniverseAsset::mock() + }], + }; + + let asset_metadata = vec![AssetMetadata { + premium: Some("1.5".to_string()), + impact_pxs: Some(vec!["2100".to_string(), "2105".to_string()]), + ..AssetMetadata::mock() + }]; + + let metadata_response = HypercoreMetadataResponse(universe_response, asset_metadata); + let result = map_perpetuals_data(metadata_response, 0); + + assert_eq!(result.len(), 1); + + let eth_data = &result[0]; + assert_eq!(eth_data.perpetual.id, PerpetualId::new(PerpetualProvider::Hypercore, "ETH")); + assert_eq!(eth_data.perpetual.name, "ETH"); + assert_eq!(eth_data.perpetual.provider, PerpetualProvider::Hypercore); + assert_eq!(eth_data.perpetual.price, 2102.5); + assert_eq!(eth_data.perpetual.funding, 0.05); + assert_eq!(eth_data.perpetual.max_leverage, 50); + assert_eq!(eth_data.perpetual.volume_24h, 500000.0); + + assert!(!eth_data.perpetual.is_isolated_only); + + assert_eq!(eth_data.asset.name, "ETH"); + assert_eq!(eth_data.asset.symbol, "ETH"); + assert_eq!(eth_data.asset.decimals, 4); + assert_eq!(eth_data.asset.id.to_string(), "hypercore_perpetual::ETH"); + } + + #[test] + fn test_map_perpetuals_data_builder_asset_index() { + let universe_response = HypercoreUniverseResponse { + universe: vec![UniverseAsset { + name: "FOO".to_string(), + ..UniverseAsset::mock() + }], + }; + + let asset_metadata = vec![AssetMetadata::mock()]; + + let metadata_response = HypercoreMetadataResponse(universe_response, asset_metadata); + let result = map_perpetuals_data(metadata_response, 2); + + assert_eq!(result[0].perpetual.identifier, "120000"); + } + + #[test] + fn test_map_perpetuals_data_only_isolated() { + let universe_response = HypercoreUniverseResponse { + universe: vec![ + UniverseAsset { + name: "ISOLATED_TOKEN".to_string(), + only_isolated: Some(true), + ..UniverseAsset::mock() + }, + UniverseAsset { + name: "DEFAULT_TOKEN".to_string(), + only_isolated: None, + ..UniverseAsset::mock() + }, + ], + }; + + let asset_metadata = vec![AssetMetadata::mock(), AssetMetadata::mock()]; + + let metadata_response = HypercoreMetadataResponse(universe_response, asset_metadata); + let result = map_perpetuals_data(metadata_response, 0); + + assert_eq!(result.len(), 2); + assert!(result[0].perpetual.is_isolated_only); + assert!(!result[1].perpetual.is_isolated_only); + } + + #[test] + fn test_map_candlesticks() { + use crate::models::candlestick::Candlestick; + + let candlesticks = vec![ + Candlestick { + t: 1640995200000u64, // 2022-01-01 00:00:00 UTC + s: "BTC".to_string(), + i: "1h".to_string(), + o: "50000.0".to_string(), + h: "51000.0".to_string(), + l: "49000.0".to_string(), + c: "50500.0".to_string(), + v: "100.5".to_string(), + }, + Candlestick { + t: 1640998800000u64, // 2022-01-01 01:00:00 UTC + s: "BTC".to_string(), + i: "1h".to_string(), + o: "50500.0".to_string(), + h: "52000.0".to_string(), + l: "50000.0".to_string(), + c: "51000.0".to_string(), + v: "75.2".to_string(), + }, + ]; + + let result = map_candlesticks(candlesticks); + + assert_eq!(result.len(), 2); + + let first_candle = &result[0]; + assert_eq!(first_candle.open, 50000.0); + assert_eq!(first_candle.high, 51000.0); + assert_eq!(first_candle.low, 49000.0); + assert_eq!(first_candle.close, 50500.0); + assert_eq!(first_candle.volume, 100.5); + + let second_candle = &result[1]; + assert_eq!(second_candle.open, 50500.0); + assert_eq!(second_candle.high, 52000.0); + assert_eq!(second_candle.low, 50000.0); + assert_eq!(second_candle.close, 51000.0); + assert_eq!(second_candle.volume, 75.2); + } + + #[test] + fn test_map_hypercore_positions_to_perpetual_positions_summary() { + let positions = AssetPositions { + asset_positions: vec![ + AssetPosition { + position_type: PositionType::OneWay, + position: Position { + coin: "SOL".to_string(), + szi: "-10.0".to_string(), + leverage: Leverage { + leverage_type: LeverageType::Cross, + value: 20, + }, + entry_px: "195.39".to_string(), + position_value: "2029.2".to_string(), + unrealized_pnl: "-75.3".to_string(), + return_on_equity: "-0.77076616".to_string(), + liquidation_px: Some("558.9517436098".to_string()), + margin_used: "101.46".to_string(), + max_leverage: 20, + cum_funding: CumulativeFunding { + all_time: "-1.3358".to_string(), + since_open: "-1.3".to_string(), + }, + }, + }, + AssetPosition { + position_type: PositionType::OneWay, + position: Position { + coin: "BTC".to_string(), + szi: "3.0".to_string(), + leverage: Leverage { + leverage_type: LeverageType::Isolated, + value: 10, + }, + entry_px: "766.34".to_string(), + position_value: "2332.2".to_string(), + unrealized_pnl: "33.18".to_string(), + return_on_equity: "0.1443223634".to_string(), + liquidation_px: None, + margin_used: "233.22".to_string(), + max_leverage: 10, + cum_funding: CumulativeFunding { + all_time: "1.686397".to_string(), + since_open: "1.1".to_string(), + }, + }, + }, + ], + margin_summary: MarginSummary { + account_value: "1000".to_string(), + total_ntl_pos: "100".to_string(), + total_raw_usd: "100".to_string(), + total_margin_used: "100".to_string(), + }, + cross_margin_summary: MarginSummary { + account_value: "1000".to_string(), + total_ntl_pos: "100".to_string(), + total_raw_usd: "100".to_string(), + total_margin_used: "100".to_string(), + }, + cross_maintenance_margin_used: "50".to_string(), + withdrawable: "500".to_string(), + }; + + let summary = map_positions(positions, "test_user".to_string(), &[]); + + assert_eq!(summary.positions.len(), 2); + + let sol_position = summary.positions.iter().find(|p| p.id == "test_user_SOL").unwrap(); + assert_eq!(sol_position.size, 10.0); + assert_eq!(sol_position.size_value, 2029.2); + assert_eq!(sol_position.leverage, 20); + assert_eq!(sol_position.margin_type, PerpetualMarginType::Cross); + assert_eq!(sol_position.direction, PerpetualDirection::Short); + assert_eq!(sol_position.margin_amount, 101.46); + assert_eq!(sol_position.pnl, -75.3); + assert_eq!(sol_position.funding, Some(1.3)); + + let btc_position = summary.positions.iter().find(|p| p.id == "test_user_BTC").unwrap(); + assert_eq!(btc_position.size, 3.0); + assert_eq!(btc_position.size_value, 2332.2); + assert_eq!(btc_position.leverage, 10); + assert_eq!(btc_position.margin_type, PerpetualMarginType::Isolated); + assert_eq!(btc_position.direction, PerpetualDirection::Long); + assert_eq!(btc_position.margin_amount, 233.22); + assert_eq!(btc_position.pnl, 33.18); + assert_eq!(btc_position.funding, Some(-1.1)); + } + + #[test] + fn test_map_position_funding_sign_reversal() { + let position = Position { + coin: "BTC".to_string(), + szi: "3.0".to_string(), // Long position + leverage: Leverage { + leverage_type: LeverageType::Cross, + value: 10, + }, + entry_px: "100".to_string(), + position_value: "300".to_string(), + unrealized_pnl: "0".to_string(), + return_on_equity: "0".to_string(), + liquidation_px: None, + margin_used: "30".to_string(), + max_leverage: 10, + cum_funding: CumulativeFunding { + all_time: "1.5".to_string(), + since_open: "1.5".to_string(), + }, + }; + + let perpetual_position = map_position(position, "user123".to_string(), &[]); + assert_eq!(perpetual_position.funding, Some(-1.5)); // Long position reverses sign + + let short_position = Position { + coin: "ETH".to_string(), + szi: "-5.0".to_string(), // Short position + leverage: Leverage { + leverage_type: LeverageType::Cross, + value: 10, + }, + entry_px: "100".to_string(), + position_value: "500".to_string(), + unrealized_pnl: "0".to_string(), + return_on_equity: "0".to_string(), + liquidation_px: None, + margin_used: "50".to_string(), + max_leverage: 10, + cum_funding: CumulativeFunding { + all_time: "-1.5".to_string(), + since_open: "-1.5".to_string(), + }, + }; + + let short_perpetual = map_position(short_position, "user123".to_string(), &[]); + assert_eq!(short_perpetual.size, 5.0); // Size is always positive (absolute value) + assert_eq!(short_perpetual.funding, Some(1.5)); // Short position with negative funding + } + + #[test] + fn test_map_perpetual_balance() { + let positions = AssetPositions { + asset_positions: vec![], + margin_summary: MarginSummary { + account_value: "5000.50".to_string(), + total_ntl_pos: "100".to_string(), + total_raw_usd: "100".to_string(), + total_margin_used: "100".to_string(), + }, + cross_margin_summary: MarginSummary { + account_value: "1000".to_string(), + total_ntl_pos: "100".to_string(), + total_raw_usd: "100".to_string(), + total_margin_used: "1500.25".to_string(), + }, + cross_maintenance_margin_used: "50".to_string(), + withdrawable: "2500.75".to_string(), + }; + + let summary = map_positions(positions, "balance_test".to_string(), &[]); + + assert_eq!(summary.balance.reserved, 1500.25); + assert_eq!(summary.balance.available, 3500.25); + assert_eq!(summary.balance.withdrawable, 2500.75); + } + + #[test] + fn test_map_perpetual_balance_with_real_data() { + let positions = AssetPositions { + asset_positions: vec![], + margin_summary: MarginSummary { + account_value: "706.364534".to_string(), + total_ntl_pos: "12013.47849".to_string(), + total_raw_usd: "2737.835324".to_string(), + total_margin_used: "926.155026".to_string(), + }, + cross_margin_summary: MarginSummary { + account_value: "706.364534".to_string(), + total_ntl_pos: "12013.47849".to_string(), + total_raw_usd: "2737.835324".to_string(), + total_margin_used: "926.155026".to_string(), + }, + cross_maintenance_margin_used: "400.689965".to_string(), + withdrawable: "305.674569".to_string(), + }; + + let summary = map_positions(positions, "real_data_test".to_string(), &[]); + + assert_eq!(summary.balance.reserved, 706.364534); + assert_eq!(summary.balance.available, 0.0); + assert_eq!(summary.balance.withdrawable, 305.674569); + } + + #[test] + fn test_map_position_asset_id_uses_subtoken_pattern() { + let position = Position { + coin: "BTC".to_string(), + szi: "1.0".to_string(), + leverage: Leverage { + leverage_type: LeverageType::Cross, + value: 10, + }, + entry_px: "50000".to_string(), + position_value: "50000".to_string(), + unrealized_pnl: "0".to_string(), + return_on_equity: "0".to_string(), + liquidation_px: None, + margin_used: "5000".to_string(), + max_leverage: 10, + cum_funding: CumulativeFunding { + all_time: "0".to_string(), + since_open: "0".to_string(), + }, + }; + + let perpetual_position = map_position(position, "address123".to_string(), &[]); + + assert_eq!(perpetual_position.asset_id.chain, primitives::Chain::HyperCore); + assert_eq!(perpetual_position.asset_id.token_id, Some("perpetual::BTC".to_string())); + assert_eq!(perpetual_position.asset_id.to_string(), "hypercore_perpetual::BTC"); + } + + #[test] + fn test_map_tp_sl_from_orders_limit() { + use crate::testkit::*; + + let orders = vec![ + OpenOrder::mock("HYPE", 191395165138, "Stop Limit", 35.0, Some(33.5)), + OpenOrder::mock("HYPE", 191394991415, "Take Profit Limit", 55.0, Some(56.0)), + ]; + + let (take_profit, stop_loss) = map_tp_sl_from_orders(&orders, "HYPE"); + + let tp = take_profit.unwrap(); + assert_eq!(tp.price, 55.0); + assert_eq!(tp.order_type, PerpetualOrderType::Limit); + assert_eq!(tp.order_id, "191394991415"); + + let sl = stop_loss.unwrap(); + assert_eq!(sl.price, 35.0); + assert_eq!(sl.order_type, PerpetualOrderType::Limit); + assert_eq!(sl.order_id, "191395165138"); + } + + #[test] + fn test_map_tp_sl_from_orders_market() { + use crate::testkit::*; + + let orders = vec![ + OpenOrder::mock("BTC", 123456789, "Stop Market", 40000.0, None), + OpenOrder::mock("BTC", 987654321, "Take Profit Market", 60000.0, None), + ]; + + let (take_profit, stop_loss) = map_tp_sl_from_orders(&orders, "BTC"); + + let tp = take_profit.unwrap(); + assert_eq!(tp.price, 60000.0); + assert_eq!(tp.order_type, PerpetualOrderType::Market); + assert_eq!(tp.order_id, "987654321"); + + let sl = stop_loss.unwrap(); + assert_eq!(sl.price, 40000.0); + assert_eq!(sl.order_type, PerpetualOrderType::Market); + assert_eq!(sl.order_id, "123456789"); + } + + #[test] + fn test_map_perpetual_portfolio() { + use crate::testkit::*; + + let response = HypercorePortfolioResponse { + timeframes: vec![ + ("perpDay".to_string(), HypercorePortfolioTimeframeData::mock("100")), + ("perpWeek".to_string(), HypercorePortfolioTimeframeData::mock("500")), + ("perpMonth".to_string(), HypercorePortfolioTimeframeData::mock("2000")), + ("perpAllTime".to_string(), HypercorePortfolioTimeframeData::mock("50000")), + ], + }; + let positions = AssetPositions::mock(); + + let result = map_perpetual_portfolio(response, &positions); + + assert_eq!(result.day.unwrap().volume, 100.0); + assert_eq!(result.week.unwrap().volume, 500.0); + assert_eq!(result.month.unwrap().volume, 2000.0); + assert_eq!(result.all_time.unwrap().volume, 50000.0); + + let summary = result.account_summary.unwrap(); + assert_eq!(summary.account_value, 10000.0); + assert_eq!(summary.account_leverage, 0.5); + assert_eq!(summary.margin_usage, 0.2); + assert_eq!(summary.unrealized_pnl, 0.0); + } + + #[test] + fn test_map_account_summary() { + use crate::testkit::*; + + let positions = AssetPositions::mock(); + let summary = map_account_summary(&positions); + + assert_eq!(summary.account_value, 10000.0); + assert_eq!(summary.account_leverage, 0.5); + assert_eq!(summary.margin_usage, 0.2); + assert_eq!(summary.unrealized_pnl, 0.0); + } + + #[test] + fn test_map_account_summary_aggregate() { + use crate::testkit::*; + + let positions = vec![AssetPositions::mock(), AssetPositions::mock()]; + let summary = map_account_summary_aggregate(&positions); + + assert_eq!(summary.account_value, 20000.0); + assert_eq!(summary.account_leverage, 0.5); + assert_eq!(summary.margin_usage, 0.2); + assert_eq!(summary.unrealized_pnl, 0.0); + } + + #[test] + fn test_perp_asset_index() { + assert_eq!(perp_asset_index(0, 0), 0); + assert_eq!(perp_asset_index(0, 5), 5); + assert_eq!(perp_asset_index(1, 0), 110_000); + assert_eq!(perp_asset_index(1, 3), 110_003); + assert_eq!(perp_asset_index(2, 0), 120_000); + assert_eq!(perp_asset_index(2, 7), 120_007); + } + + #[test] + fn test_merge_chart_histories() { + use chrono::{TimeZone, Utc}; + + let d1 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let d2 = Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(); + let d3 = Utc.with_ymd_and_hms(2024, 1, 3, 0, 0, 0).unwrap(); + + let histories = vec![ + vec![ChartDateValue { date: d1, value: 100.0 }, ChartDateValue { date: d2, value: 200.0 }], + vec![ChartDateValue { date: d1, value: 50.0 }, ChartDateValue { date: d3, value: 300.0 }], + ]; + + let merged = merge_chart_histories(histories); + assert_eq!(merged.len(), 3); + assert_eq!(merged[0].value, 150.0); + assert_eq!(merged[1].value, 200.0); + assert_eq!(merged[2].value, 300.0); + } + + #[test] + fn test_merge_perpetual_portfolios() { + let portfolios = vec![PerpetualPortfolio::mock(), PerpetualPortfolio::mock()]; + + let summary = PerpetualAccountSummary { + account_value: 1000.0, + account_leverage: 2.0, + margin_usage: 0.5, + unrealized_pnl: 30.0, + }; + + let merged = merge_perpetual_portfolios(portfolios, Some(summary)); + + let day = merged.day.unwrap(); + assert_eq!(day.volume, 10000.0); + assert_eq!(day.account_value_history.len(), 1); + assert_eq!(day.account_value_history[0].value, 2000.0); + assert_eq!(day.pnl_history[0].value, 100.0); + + assert!(merged.week.is_none()); + + let summary = merged.account_summary.unwrap(); + assert_eq!(summary.account_value, 1000.0); + } + + #[test] + fn test_map_perpetual_balance_from_spot() { + let balances = Balances { + balances: vec![Balance { + coin: "USDC".to_string(), + token: 0, + total: "100.0".to_string(), + hold: "30.0".to_string(), + }], + }; + + let balance = map_perpetual_balance_from_spot(&balances); + + assert_eq!(balance.available, 70.0); + assert_eq!(balance.reserved, 30.0); + assert_eq!(balance.withdrawable, 70.0); + } +} diff --git a/core/crates/gem_hypercore/src/provider/preload.rs b/core/crates/gem_hypercore/src/provider/preload.rs new file mode 100644 index 0000000000..d80bc36de0 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/preload.rs @@ -0,0 +1,121 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use num_bigint::BigInt; +use std::error::Error; + +use gem_client::Client; +use primitives::{ + FeePriority, FeeRate, GasPriceType, HyperliquidOrder, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, + TransactionPreloadInput, perpetual::PerpetualType, +}; + +use crate::is_spot_swap; +use crate::provider::fee_calculator::{calculate_perpetual_fee_amount, calculate_spot_fee_amount}; +use crate::provider::preload_cache::{HyperCoreCache, UserFeeRates}; +use crate::provider::preload_mapper::get_approvals_and_credentials; +use crate::rpc::client::HyperCoreClient; + +impl HyperCoreClient { + async fn get_order(&self, sender_address: &str) -> Result<(HyperliquidOrder, UserFeeRates), Box> { + let cache = HyperCoreCache::new(self.preferences.clone(), self.config.clone()); + let (agent_required, referral_required, builder_required, fee_rates, agent_address, agent_private_key) = get_approvals_and_credentials( + &cache, + sender_address, + self.secure_preferences.clone(), + self.get_extra_agents(sender_address), + self.get_referral(sender_address), + self.get_builder_fee(sender_address, &self.config.builder_address), + self.get_user_fees(sender_address), + ) + .await?; + + Ok(( + HyperliquidOrder { + approve_agent_required: agent_required, + approve_referral_required: referral_required, + approve_builder_required: builder_required, + builder_fee_bps: self.config.max_builder_fee_bps, + agent_address, + agent_private_key, + }, + fee_rates, + )) + } +} + +#[async_trait] +impl ChainTransactionLoad for HyperCoreClient { + async fn get_transaction_preload(&self, _input: TransactionPreloadInput) -> Result> { + Ok(TransactionLoadMetadata::None) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + match &input.input_type { + TransactionInputType::Transfer(_) | TransactionInputType::TransferNft(_, _) | TransactionInputType::Account(_, _) | TransactionInputType::Stake(_, _) => { + // Only signature is required + Ok(TransactionLoadData { + fee: TransactionFee::new_from_fee(BigInt::from(0)), + metadata: TransactionLoadMetadata::Hyperliquid { order: None }, + }) + } + TransactionInputType::Swap(from_asset, to_asset, _) => { + let (fee_amount, order) = if is_spot_swap(from_asset.chain(), to_asset.chain()) { + let (order, fee_rates) = self.get_order(&input.sender_address).await?; + let swap_data = input.input_type.get_swap_data().map_err(|err| err.to_string())?; + let fee_amount = calculate_spot_fee_amount(swap_data, from_asset, to_asset, fee_rates.spot_cross, self.config.max_builder_fee_bps)?; + + (fee_amount, Some(order)) + } else { + (BigInt::from(0), None) + }; + + Ok(TransactionLoadData { + fee: TransactionFee::new_from_fee(fee_amount), + metadata: TransactionLoadMetadata::Hyperliquid { order }, + }) + } + TransactionInputType::Perpetual(_, perpetual_type) => { + let fiat_value = match perpetual_type { + PerpetualType::Open(data) => data.fiat_value, + PerpetualType::Increase(data) => data.fiat_value, + PerpetualType::Reduce(reduce_data) => reduce_data.data.fiat_value, + PerpetualType::Close(data) => data.fiat_value, + PerpetualType::Modify(_) => 0.0, + }; + let (order, fee_rates) = self.get_order(&input.sender_address).await?; + let fee_amount = calculate_perpetual_fee_amount(fiat_value, fee_rates.perpetual_cross); + + Ok(TransactionLoadData { + fee: TransactionFee::new_from_fee(fee_amount), + metadata: TransactionLoadMetadata::Hyperliquid { order: Some(order) }, + }) + } + _ => Err("Unsupported input type".to_string().into()), + } + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1)))]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod integration_tests { + use super::*; + use crate::provider::testkit::create_hypercore_test_client; + use primitives::{Asset, Chain, TransactionLoadInput}; + + #[tokio::test] + async fn test_get_transaction_load_transfer() { + let client = create_hypercore_test_client(); + let input = TransactionLoadInput::mock_with_input_type(TransactionInputType::Transfer(Asset::from_chain(Chain::HyperCore))); + + let result = client.get_transaction_load(input).await.unwrap(); + + assert_eq!(result.fee.fee, BigInt::from(0)); + let TransactionLoadMetadata::Hyperliquid { order } = result.metadata else { + panic!("invalid metadata"); + }; + assert!(order.is_none()); + } +} diff --git a/core/crates/gem_hypercore/src/provider/preload_cache.rs b/core/crates/gem_hypercore/src/provider/preload_cache.rs new file mode 100644 index 0000000000..19a76c91f4 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/preload_cache.rs @@ -0,0 +1,150 @@ +use crate::config::HypercoreConfig; +use crate::models::referral::Referral; +use crate::models::user::{AgentSession, UserFee}; +use crate::rpc::client::agent_owner_cache_key; +use primitives::{Preferences, PreferencesExt}; +use std::error::Error; +use std::future::Future; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) struct UserFeeRates { + pub(crate) perpetual_cross: f64, + pub(crate) spot_cross: f64, +} + +pub(crate) struct HyperCoreCache { + preferences: Arc, + config: HypercoreConfig, +} + +impl HyperCoreCache { + const REFERRAL_APPROVED_KEY: &'static str = "referral_approved"; + const BUILDER_FEE_APPROVED_KEY: &'static str = "builder_fee_approved"; + const AGENT_VALID_UNTIL_KEY: &'static str = "agent_valid_until"; + const USER_PERPETUAL_FEE_RATE_KEY: &'static str = "user_perpetual_fee_rate"; + const USER_SPOT_FEE_RATE_KEY: &'static str = "user_spot_fee_rate"; + const USER_FEES_TTL: u64 = 86_400 * 7; + const USER_FEE_RATE_SCALE: f64 = 1_000_000_000.0; + + pub(crate) fn new(preferences: Arc, config: HypercoreConfig) -> Self { + Self { preferences, config } + } + + fn cache_key(&self, address: &str, key: &str) -> String { + format!("{}_{}", address, key) + } + + fn current_time() -> Result> { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) + } + + pub(crate) async fn needs_referral_approval(&self, address: &str, checker: F) -> Result> + where + F: Future>>, + { + let cache_key = self.cache_key(address, Self::REFERRAL_APPROVED_KEY); + + if let Some(true) = self.preferences.get_bool(&cache_key)? { + return Ok(false); + } + + let referral = checker.await?; + let needs_approval = referral.referred_by.is_none() && referral.cum_vlm < 10000.0; + + if !needs_approval { + self.preferences.set_bool(&cache_key, true)?; + } + + Ok(needs_approval) + } + + pub(crate) async fn needs_builder_fee_approval(&self, address: &str, checker: F) -> Result> + where + F: Future>>, + { + let cache_key = self.cache_key(address, Self::BUILDER_FEE_APPROVED_KEY); + + if let Some(true) = self.preferences.get_bool(&cache_key)? { + return Ok(false); + } + + let fee = checker.await?; + let needs_approval = self.config.max_builder_fee_bps > fee; + + if !needs_approval { + self.preferences.set_bool(&cache_key, true)?; + } + + Ok(needs_approval) + } + + pub(crate) async fn get_user_fee_rates(&self, address: &str, fetcher: F) -> Result> + where + F: Future>>, + { + let perpetual_cache_key = self.cache_key(address, Self::USER_PERPETUAL_FEE_RATE_KEY); + let spot_cache_key = self.cache_key(address, Self::USER_SPOT_FEE_RATE_KEY); + + if let (Some(perpetual_cross), Some(spot_cross)) = ( + self.preferences.get_i64_with_ttl(&perpetual_cache_key, Self::USER_FEES_TTL)?, + self.preferences.get_i64_with_ttl(&spot_cache_key, Self::USER_FEES_TTL)?, + ) { + return Ok(UserFeeRates { + perpetual_cross: perpetual_cross as f64 / Self::USER_FEE_RATE_SCALE, + spot_cross: spot_cross as f64 / Self::USER_FEE_RATE_SCALE, + }); + } + + let user_fees = fetcher.await?; + let discount = 1.0 - user_fees.active_referral_discount; + let perpetual_cross = (user_fees.user_cross_rate * discount * Self::USER_FEE_RATE_SCALE).round() as i64; + let spot_cross = (user_fees.user_spot_cross_rate * discount * Self::USER_FEE_RATE_SCALE).round() as i64; + + self.preferences.set_i64_with_ttl(&perpetual_cache_key, perpetual_cross, Self::USER_FEES_TTL)?; + self.preferences.set_i64_with_ttl(&spot_cache_key, spot_cross, Self::USER_FEES_TTL)?; + Ok(UserFeeRates { + perpetual_cross: perpetual_cross as f64 / Self::USER_FEE_RATE_SCALE, + spot_cross: spot_cross as f64 / Self::USER_FEE_RATE_SCALE, + }) + } + + pub(crate) async fn manage_agent( + &self, + sender_address: &str, + secure_preferences: Arc, + get_agents: F, + ) -> Result<(bool, String, String), Box> + where + F: Future, Box>>, + { + let agent = crate::agent::Agent::new(secure_preferences); + let (agent_address, agent_private_key) = agent.get_or_create_credentials(sender_address)?; + self.preferences.set(agent_owner_cache_key(&agent_address), sender_address.to_lowercase())?; + let cache_key = self.cache_key(&agent_address, Self::AGENT_VALID_UNTIL_KEY); + let current_time = Self::current_time()?; + + if let Some(cached_valid_until) = self.preferences.get_i64(&cache_key)? + && current_time < cached_valid_until + { + return Ok((false, agent_address, agent_private_key)); + } + + let agents = get_agents.await?; + + if let Some(api_agent) = agents.iter().find(|a| a.address.to_lowercase() == agent_address.to_lowercase()) { + let valid_until = (api_agent.valid_until / 1000) as i64; + self.preferences.set_i64(&cache_key, valid_until)?; + + if current_time >= valid_until { + let (new_address, new_key) = agent.regenerate_credentials(sender_address)?; + self.preferences.set(agent_owner_cache_key(&new_address), sender_address.to_lowercase())?; + Ok((true, new_address, new_key)) + } else { + Ok((false, agent_address, agent_private_key)) + } + } else { + Ok((true, agent_address, agent_private_key)) + } + } +} diff --git a/core/crates/gem_hypercore/src/provider/preload_mapper.rs b/core/crates/gem_hypercore/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..63e0a0f03f --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/preload_mapper.rs @@ -0,0 +1,25 @@ +use crate::models::referral::Referral; +use crate::models::user::{AgentSession, UserFee}; +use crate::provider::preload_cache::{HyperCoreCache, UserFeeRates}; +use std::error::Error; +use std::future::Future; +use std::sync::Arc; + +pub(crate) async fn get_approvals_and_credentials( + cache: &HyperCoreCache, + sender_address: &str, + secure_preferences: Arc, + get_agents: impl Future, Box>>, + get_referral: impl Future>>, + get_builder_fee: impl Future>>, + get_user_fees: impl Future>>, +) -> Result<(bool, bool, bool, UserFeeRates, String, String), Box> { + let ((agent_required, agent_address, agent_private_key), referral_required, builder_required, fee_rates) = futures::try_join!( + cache.manage_agent(sender_address, secure_preferences.clone(), get_agents), + cache.needs_referral_approval(sender_address, get_referral), + cache.needs_builder_fee_approval(sender_address, get_builder_fee), + cache.get_user_fee_rates(sender_address, get_user_fees), + )?; + + Ok((agent_required, referral_required, builder_required, fee_rates, agent_address, agent_private_key)) +} diff --git a/core/crates/gem_hypercore/src/provider/request_classifier.rs b/core/crates/gem_hypercore/src/provider/request_classifier.rs new file mode 100644 index 0000000000..5fe9468c2b --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/exchange") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_hypercore/src/provider/staking.rs b/core/crates/gem_hypercore/src/provider/staking.rs new file mode 100644 index 0000000000..69ef9de08b --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/staking.rs @@ -0,0 +1,28 @@ +use async_trait::async_trait; +use chain_traits::ChainStaking; +use futures::try_join; +use std::error::Error; + +use gem_client::Client; +use primitives::{DelegationBase, DelegationValidator}; + +use crate::{models::balance::Validator, provider::staking_mapper, rpc::client::HyperCoreClient}; + +#[async_trait] +impl ChainStaking for HyperCoreClient { + async fn get_staking_apy(&self) -> Result, Box> { + let validators = self.get_validators().await?; + let apy = Validator::max_apr(validators); + Ok(Some(apy)) + } + + async fn get_staking_validators(&self, apy: Option) -> Result, Box> { + let validators = self.get_validators().await?; + Ok(staking_mapper::map_staking_validators(validators, self.chain, apy)) + } + + async fn get_staking_delegations(&self, address: String) -> Result, Box> { + let (delegations, stake_balance) = try_join!(self.get_staking_delegations(&address), self.get_stake_balance(&address))?; + Ok(staking_mapper::map_staking_delegations(delegations, stake_balance, self.chain)) + } +} diff --git a/core/crates/gem_hypercore/src/provider/staking_mapper.rs b/core/crates/gem_hypercore/src/provider/staking_mapper.rs new file mode 100644 index 0000000000..99489ffaee --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/staking_mapper.rs @@ -0,0 +1,147 @@ +use crate::models::balance::{DelegationBalance, StakeBalance, Validator}; +use num_bigint::BigUint; +use number_formatter::BigNumberFormatter; +use primitives::{Asset, Chain, DelegationBase, DelegationState, DelegationValidator}; + +pub fn map_staking_validators(validators: Vec, chain: Chain, apy: Option) -> Vec { + let calculated_apy = apy.unwrap_or_else(|| Validator::max_apr(validators.clone())); + let mut result: Vec = validators + .into_iter() + .map(|x| DelegationValidator::stake(chain, x.validator_address(), x.name, x.is_active, x.commission, calculated_apy)) + .collect(); + + result.push(DelegationValidator::system(chain)); + + result +} + +pub fn map_staking_delegations(delegations: Vec, stake_balance: StakeBalance, chain: Chain) -> Vec { + let native_decimals = Asset::from_chain(chain).decimals as u32; + let mut result: Vec = delegations + .into_iter() + .map(|x| DelegationBase { + asset_id: chain.as_asset_id(), + state: DelegationState::Active, + balance: BigNumberFormatter::value_from_amount_biguint(&x.amount, native_decimals).unwrap_or_default(), + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: x.validator_address(), + validator_id: x.validator_address(), + }) + .collect(); + + let pending = BigNumberFormatter::value_from_amount_biguint(&stake_balance.total_pending_withdrawal, native_decimals).unwrap_or_default(); + if pending > BigUint::from(0u32) { + result.push(DelegationBase { + asset_id: chain.as_asset_id(), + state: DelegationState::Pending, + balance: pending, + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: DelegationValidator::SYSTEM_ID.to_string(), + validator_id: DelegationValidator::SYSTEM_ID.to_string(), + }); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::balance::ValidatorStats; + use primitives::{Chain, DelegationState}; + + fn stake_balance(total_pending_withdrawal: &str) -> StakeBalance { + StakeBalance { + delegated: "0".to_string(), + undelegated: "0".to_string(), + total_pending_withdrawal: total_pending_withdrawal.to_string(), + } + } + + #[test] + fn test_map_staking_validators() { + let validators = vec![Validator { + validator: "0x5ac99df645f3414876c816caa18b2d234024b487".to_string(), + name: "Test Validator".to_string(), + commission: 5.0, + is_active: true, + stats: vec![("test".to_string(), ValidatorStats { predicted_apr: 0.15 })], + }]; + + let result = map_staking_validators(validators, Chain::HyperCore, None); + assert_eq!(result.len(), 2); + assert_eq!(result[0].name, "Test Validator"); + assert_eq!(result[0].id, "0x5aC99df645F3414876C816Caa18b2d234024b487"); + assert_eq!(result[0].chain, Chain::HyperCore); + assert!(result[0].is_active); + assert_eq!(result[0].commission, 5.0); + assert_eq!(result[0].apr, 15.0); + + let system = &result[1]; + assert_eq!(system.id, DelegationValidator::SYSTEM_ID); + assert_eq!(system.name, DelegationValidator::SYSTEM_NAME); + assert!(system.is_active); + } + + #[test] + fn test_map_staking_validators_with_apy() { + let validators = vec![Validator { + validator: "0x5ac99df645f3414876c816caa18b2d234024b487".to_string(), + name: "Test Validator".to_string(), + commission: 5.0, + is_active: true, + stats: vec![], + }]; + + let result = map_staking_validators(validators, Chain::HyperCore, Some(10.0)); + assert_eq!(result.len(), 2); + assert_eq!(result[0].apr, 10.0); + assert_eq!(result[1].id, DelegationValidator::SYSTEM_ID); + } + + #[test] + fn test_map_staking_delegations() { + let delegations: Vec = serde_json::from_str(include_str!("../../testdata/staking_delegations.json")).unwrap(); + + let result = map_staking_delegations(delegations, stake_balance("0"), Chain::HyperCore); + + assert_eq!(result.len(), 2); + + let delegation1 = &result[0]; + assert_eq!(delegation1.asset_id.chain, Chain::HyperCore); + assert_eq!(delegation1.validator_id, "0x5aC99df645F3414876C816Caa18b2d234024b487"); + assert_eq!(delegation1.delegation_id, "0x5aC99df645F3414876C816Caa18b2d234024b487"); + assert_eq!(delegation1.balance.to_string(), "271936493373"); + assert_eq!(delegation1.state, DelegationState::Active); + assert_eq!(delegation1.shares, num_bigint::BigUint::from(0u32)); + assert_eq!(delegation1.rewards, num_bigint::BigUint::from(0u32)); + assert!(delegation1.completion_date.is_none()); + + let delegation2 = &result[1]; + assert_eq!(delegation2.validator_id, "0xaBCDefF4b3727B83A23697500EEf089020DF2cD2"); + assert_eq!(delegation2.balance.to_string(), "1814578086"); + } + + #[test] + fn test_map_staking_delegations_pending_withdrawal() { + let result = map_staking_delegations(vec![], stake_balance("0.015"), Chain::HyperCore); + + assert_eq!(result.len(), 1); + let pending = &result[0]; + assert_eq!(pending.state, DelegationState::Pending); + assert_eq!(pending.validator_id, DelegationValidator::SYSTEM_ID); + assert_eq!(pending.balance.to_string(), "1500000"); + assert!(pending.completion_date.is_none()); + } + + #[test] + fn test_map_staking_delegations_no_pending_withdrawal() { + let result = map_staking_delegations(vec![], stake_balance("0"), Chain::HyperCore); + + assert!(result.is_empty()); + } +} diff --git a/core/crates/gem_hypercore/src/provider/state.rs b/core/crates/gem_hypercore/src/provider/state.rs new file mode 100644 index 0000000000..0145db1e13 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/state.rs @@ -0,0 +1,18 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; + +use crate::rpc::client::HyperCoreClient; + +#[async_trait] +impl ChainState for HyperCoreClient { + async fn get_chain_id(&self) -> Result> { + Ok("1".to_string()) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(1) + } +} diff --git a/core/crates/gem_hypercore/src/provider/testkit.rs b/core/crates/gem_hypercore/src/provider/testkit.rs new file mode 100644 index 0000000000..7706fe0c86 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/testkit.rs @@ -0,0 +1,32 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::HyperCoreClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use primitives::InMemoryPreferences; +#[cfg(all(test, feature = "chain_integration_tests"))] +use std::sync::Arc; + +#[cfg(all(test, feature = "chain_integration_tests"))] +use primitives::asset_constants::HYPERCORE_SPOT_USDC_TOKEN_ID; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "0x9b4d63110c57f2e19cc7042ce90e300202f500f6a75b11b33f160e63cb5bcccc"; +#[cfg(test)] +pub const TEST_TRANSACTION_ORDER_ID: &str = "187530505765"; +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const USDC_TOKEN_ID: &str = HYPERCORE_SPOT_USDC_TOKEN_ID; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_hypercore_test_client() -> HyperCoreClient { + let preferences = Arc::new(InMemoryPreferences::new()); + let secure_preferences = Arc::new(InMemoryPreferences::new()); + + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.hypercore.url, reqwest::Client::new()); + HyperCoreClient::new_with_preferences(reqwest_client, preferences, secure_preferences) +} diff --git a/core/crates/gem_hypercore/src/provider/token.rs b/core/crates/gem_hypercore/src/provider/token.rs new file mode 100644 index 0000000000..71af4d30d6 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/token.rs @@ -0,0 +1,51 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::{Asset, AssetType}; + +use crate::rpc::client::HyperCoreClient; + +#[async_trait] +impl ChainToken for HyperCoreClient { + async fn get_token_data(&self, token_id: String) -> Result> { + let spot_meta = self.get_spot_meta().await?; + let token = spot_meta + .tokens + .iter() + .find(|t| t.name == token_id) + .ok_or(format!("Token not found with symbol: {}", token_id))?; + + let asset_id = token.asset_id(self.chain); + + Ok(Asset { + id: asset_id.clone(), + chain: self.chain, + token_id: asset_id.token_id, + name: token.name.clone(), + symbol: token.name.clone(), + decimals: token.wei_decimals, + asset_type: AssetType::TOKEN, + }) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod tests { + use super::*; + use crate::provider::testkit::{USDC_TOKEN_ID, create_hypercore_test_client}; + + #[tokio::test] + async fn test_get_token_data_usdc() { + let client = create_hypercore_test_client(); + + let asset = client.get_token_data("USDC".to_string()).await.unwrap(); + + assert_eq!(asset.symbol, "USDC"); + assert_eq!(asset.decimals, 8); + assert_eq!(asset.chain, primitives::Chain::HyperCore); + assert_eq!(asset.asset_type, AssetType::TOKEN); + assert_eq!(asset.token_id, Some(USDC_TOKEN_ID.to_string())); + } +} diff --git a/core/crates/gem_hypercore/src/provider/transaction_broadcast.rs b/core/crates/gem_hypercore/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..72e3235e82 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/transaction_broadcast.rs @@ -0,0 +1,148 @@ +use alloy_primitives::Address; +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use gem_hash::keccak::keccak256; +use k256::ecdsa::{RecoveryId, Signature, VerifyingKey}; +use serde::Deserialize; +use std::error::Error; + +use gem_client::Client; +use primitives::{BroadcastOptions, hex::decode_hex_array}; + +use crate::{ + core::{ + actions::{ + ApproveAgent, ApproveBuilderFee, CDeposit, CWithdraw, Cancel, PlaceOrder, SetReferrer, SpotSend, TokenDelegate, UpdateLeverage, UsdClassTransfer, UsdSend, + WithdrawalRequest, + }, + hypercore::{ + approve_agent_typed_data, approve_builder_fee_typed_data, c_deposit_typed_data, c_withdraw_typed_data, cancel_order_typed_data, place_order_typed_data, + send_perps_usd_to_address_typed_data, send_spot_token_to_address_typed_data, set_referrer_typed_data, token_delegate_typed_data, transfer_perps_to_spot_typed_data, + update_leverage_typed_data, withdrawal_request_typed_data, + }, + }, + models::{ + action::ExchangeRequest, + transaction_id::{HyperCoreActionId, HyperCoreTransactionId}, + }, + provider::{ + BroadcastProvider, + transactions_mapper::{map_transaction_broadcast, map_transaction_broadcast_from_str}, + }, + rpc::client::HyperCoreClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for HyperCoreClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let action_id = serde_json::from_str::(&data).ok().map(HyperCoreActionId::from); + let request: serde_json::Value = serde_json::from_str(&data)?; + let response = self.exchange(request).await?; + let transaction_id = map_transaction_broadcast(response, action_id)?; + let _ = cache_transaction_sender(self, &data, &transaction_id); + Ok(transaction_id) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_from_str(response).ok() + } +} + +#[derive(Debug, Deserialize)] +struct SignedExchangeRequest { + action: serde_json::Value, + nonce: u64, + signature: SignedExchangeSignature, +} + +#[derive(Debug, Deserialize)] +struct SignedExchangeSignature { + r: String, + s: String, + v: u8, +} + +fn cache_transaction_sender(client: &HyperCoreClient, data: &str, transaction_id: &str) -> Result<(), Box> { + let request: SignedExchangeRequest = serde_json::from_str(data)?; + let signer = recover_sender_address(&request)?; + let sender = client.get_cached_agent_owner(&signer)?.unwrap_or(signer); + client.cache_transaction_sender(transaction_id, &sender)?; + client.cache_transaction_sender(&HyperCoreTransactionId::Action(HyperCoreActionId::Nonce(request.nonce)).to_string(), &sender)?; + + Ok(()) +} + +fn recover_sender_address(request: &SignedExchangeRequest) -> Result> { + let typed_data = typed_data_for_request(request)?; + let digest = signer::hash_eip712(&typed_data)?; + let signature = signature_from_request(&request.signature)?; + let recovery_id = recovery_id_from_v(request.signature.v)?; + let public_key = VerifyingKey::recover_from_prehash(&digest, &signature, recovery_id)?; + let encoded_point = public_key.to_encoded_point(false); + let hash = keccak256(&encoded_point.as_bytes()[1..]); + Ok(Address::from_slice(&hash[12..]).to_string().to_lowercase()) +} + +fn typed_data_for_request(request: &SignedExchangeRequest) -> Result> { + let action_type = request.action.get("type").and_then(serde_json::Value::as_str).ok_or("Missing action type")?; + + Ok(match action_type { + "order" => place_order_typed_data(serde_json::from_value::(request.action.clone())?, request.nonce), + "setReferrer" => set_referrer_typed_data(serde_json::from_value::(request.action.clone())?, request.nonce), + "updateLeverage" => update_leverage_typed_data(serde_json::from_value::(request.action.clone())?, request.nonce), + "cancel" => cancel_order_typed_data(serde_json::from_value::(request.action.clone())?, request.nonce), + "withdraw3" => withdrawal_request_typed_data(serde_json::from_value::(request.action.clone())?), + "approveAgent" => approve_agent_typed_data(serde_json::from_value::(request.action.clone())?), + "approveBuilderFee" => approve_builder_fee_typed_data(serde_json::from_value::(request.action.clone())?), + "spotSend" => send_spot_token_to_address_typed_data(serde_json::from_value::(request.action.clone())?), + "usdSend" => send_perps_usd_to_address_typed_data(serde_json::from_value::(request.action.clone())?), + "usdClassTransfer" => transfer_perps_to_spot_typed_data(serde_json::from_value::(request.action.clone())?), + "cDeposit" => c_deposit_typed_data(serde_json::from_value::(request.action.clone())?), + "cWithdraw" => c_withdraw_typed_data(serde_json::from_value::(request.action.clone())?), + "tokenDelegate" => token_delegate_typed_data(serde_json::from_value::(request.action.clone())?), + other => return Err(format!("Unsupported Hypercore action type: {other}").into()), + }) +} + +fn signature_from_request(signature: &SignedExchangeSignature) -> Result> { + let r = decode_hex_array(&signature.r)?; + let s = decode_hex_array(&signature.s)?; + Ok(Signature::from_scalars(r, s)?) +} + +fn recovery_id_from_v(v: u8) -> Result> { + let normalized = if v >= 27 { v - 27 } else { v }; + Ok(RecoveryId::try_from(normalized)?) +} + +#[cfg(test)] +mod tests { + use super::{cache_transaction_sender, recover_sender_address}; + use crate::rpc::client::HyperCoreClient; + use gem_client::testkit::MockClient; + + #[test] + fn test_recover_sender_address() { + let request = serde_json::from_str(include_str!("../../testdata/hl_action_open_long_order.json").trim()).unwrap(); + let address = recover_sender_address(&request).unwrap(); + + assert_eq!(address, "0xbbb0187503c3b5f08b03d674b9ac86ec30d790d2"); + } + + #[test] + fn test_cache_transaction_sender_uses_cached_agent_owner() { + let client = HyperCoreClient::::mock(); + client + .cache_agent_owner("0xbbb0187503c3b5f08b03d674b9ac86ec30d790d2", "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4") + .unwrap(); + + cache_transaction_sender(&client, include_str!("../../testdata/hl_action_open_long_order.json").trim(), "order:187530505765").unwrap(); + + assert_eq!( + client.get_cached_transaction_sender("order:187530505765").unwrap().as_deref(), + Some("0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4") + ); + } +} diff --git a/core/crates/gem_hypercore/src/provider/transaction_state.rs b/core/crates/gem_hypercore/src/provider/transaction_state.rs new file mode 100644 index 0000000000..84db68d20e --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/transaction_state.rs @@ -0,0 +1,97 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use primitives::{TransactionStateRequest, TransactionUpdate}; +use std::error::Error; + +use gem_client::Client; + +use crate::{ + models::transaction_id::{HyperCoreActionId, HyperCoreTransactionId}, + provider::transaction_state_mapper, + rpc::client::HyperCoreClient, +}; + +#[async_trait] +impl ChainTransactionState for HyperCoreClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + self.transaction_state(request).await + } +} + +impl HyperCoreClient { + pub async fn transaction_state(&self, request: TransactionStateRequest) -> Result> { + let id = HyperCoreTransactionId::parse(&request.id).ok_or("Invalid Hypercore transaction id")?; + + match id { + HyperCoreTransactionId::Order(oid) => { + let start_time = request.created_at.timestamp_millis() - transaction_state_mapper::ACTION_HISTORY_QUERY_LOOKBACK_MS as i64; + let fills = self.get_user_fills_by_time(&request.sender_address, start_time).await?; + Ok(transaction_state_mapper::map_transaction_state_order(fills, oid, request.id)) + } + HyperCoreTransactionId::Action(action_id) => self.action_state(&request, action_id).await, + } + } + + async fn action_state(&self, request: &TransactionStateRequest, action_id: HyperCoreActionId) -> Result> { + match &action_id { + HyperCoreActionId::Order(nonce) => { + let start_time = nonce.saturating_sub(transaction_state_mapper::ACTION_HISTORY_QUERY_LOOKBACK_MS) as i64; + let fills = self.get_user_fills_by_time(&request.sender_address, start_time).await?; + Ok(transaction_state_mapper::map_transaction_state_order_action(fills, *nonce, request.id.clone())) + } + HyperCoreActionId::CDeposit { .. } | HyperCoreActionId::CWithdraw { .. } | HyperCoreActionId::TokenDelegate { .. } => { + let updates = self.get_delegator_history(&request.sender_address).await?; + Ok(transaction_state_mapper::map_transaction_state_staking_action(updates, action_id, request.id.clone())) + } + HyperCoreActionId::Nonce(nonce) => { + let updates = self + .get_ledger_updates( + &request.sender_address, + nonce.saturating_sub(transaction_state_mapper::ACTION_HISTORY_QUERY_LOOKBACK_MS) as i64, + ) + .await?; + Ok(transaction_state_mapper::map_transaction_state_action(updates, action_id, request.id.clone())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use gem_client::testkit::MockClient; + use primitives::{TransactionChange, TransactionState, TransactionStateRequest}; + + #[tokio::test] + async fn test_transaction_state_uses_delegator_history_for_c_withdraw() { + let client = HyperCoreClient::new(MockClient::new().with_post(|_, body| { + let payload: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(payload["type"], "delegatorHistory"); + Ok( + r#"[{"time":1780078270596,"hash":"0x7b435a1210afafef7cbd043c84b8d402064e00f7aba2cec11f0c0564cfa389da","delta":{"withdrawal":{"amount":"0.03001423","phase":"initiated"}}}]"# + .as_bytes() + .to_vec(), + ) + })); + let request_id = "action:cWithdraw:3001423:1780078264489".to_string(); + let update = client + .transaction_state(TransactionStateRequest { + id: request_id.clone(), + sender_address: "0x9EdcF9Ff72088DB8130C2512E5B4D3b5F34cEaF4".to_string(), + created_at: Utc.timestamp_millis_opt(1780078264489).unwrap(), + block_number: 0, + }) + .await + .unwrap(); + + assert_eq!(update.state, TransactionState::Confirmed); + assert_eq!( + update.changes, + vec![TransactionChange::HashChange { + old: request_id, + new: "0x7b435a1210afafef7cbd043c84b8d402064e00f7aba2cec11f0c0564cfa389da".to_string(), + }] + ); + } +} diff --git a/core/crates/gem_hypercore/src/provider/transaction_state_mapper.rs b/core/crates/gem_hypercore/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..c1847e5ddd --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/transaction_state_mapper.rs @@ -0,0 +1,539 @@ +use number_formatter::BigNumberFormatter; +use primitives::{ + PerpetualDirection, PerpetualProvider, TransactionChange, TransactionMetadata, TransactionPerpetualMetadata, TransactionState, TransactionType, TransactionUpdate, + known_assets::HYPERCORE_HYPE, +}; + +use crate::models::{ + order::{FillDirection, UserFill}, + transaction_id::HyperCoreActionId, + user::{DelegatorHistoryDelta, DelegatorHistoryUpdate, LedgerDelta, LedgerUpdate}, +}; +use crate::perpetual_formatter::usdc_value; + +pub const ACTION_HISTORY_QUERY_LOOKBACK_MS: u64 = 5_000; +const ACTION_HISTORY_MATCH_WINDOW_MS: u64 = 5 * 60 * 1_000; +const DELEGATOR_WITHDRAWAL_INITIATED: &str = "initiated"; + +fn perpetual_fill_type_and_direction(dir: &FillDirection) -> Option<(TransactionType, PerpetualDirection)> { + match dir { + FillDirection::OpenLong => Some((TransactionType::PerpetualOpenPosition, PerpetualDirection::Long)), + FillDirection::OpenShort => Some((TransactionType::PerpetualOpenPosition, PerpetualDirection::Short)), + FillDirection::CloseLong => Some((TransactionType::PerpetualClosePosition, PerpetualDirection::Long)), + FillDirection::CloseShort => Some((TransactionType::PerpetualClosePosition, PerpetualDirection::Short)), + FillDirection::Buy | FillDirection::Sell | FillDirection::Other(_) => None, + } +} + +pub fn prepare_perpetual_fill(matching_fills: &[&UserFill], last_fill: &UserFill) -> Option<(TransactionType, TransactionPerpetualMetadata)> { + let (transaction_type, direction) = perpetual_fill_type_and_direction(&last_fill.dir)?; + let pnl: f64 = matching_fills.iter().map(|fill| fill.closed_pnl).sum(); + let is_liquidation = matching_fills.iter().any(|fill| fill.liquidation.is_some()); + + Some(( + transaction_type, + TransactionPerpetualMetadata { + pnl, + price: last_fill.px, + direction, + is_liquidation: Some(is_liquidation), + provider: Some(PerpetualProvider::Hypercore), + }, + )) +} + +pub fn map_transaction_state_order(fills: Vec, oid: u64, request_id: String) -> TransactionUpdate { + let matching_fills: Vec<_> = fills.iter().filter(|fill| fill.oid == oid).collect(); + + let last_fill = match matching_fills.last() { + Some(fill) => fill, + None => return TransactionUpdate::new_state(TransactionState::Pending), + }; + + let mut update = TransactionUpdate::new_state(TransactionState::Confirmed); + + match &last_fill.dir { + FillDirection::Buy | FillDirection::Sell => {} + FillDirection::OpenLong | FillDirection::OpenShort | FillDirection::CloseLong | FillDirection::CloseShort => { + if let Some(changes) = perpetual_fill_changes(&matching_fills, last_fill) { + update.changes.extend(changes); + } + } + FillDirection::Other(_) => return TransactionUpdate::new_state(TransactionState::Pending), + } + + if !last_fill.hash.is_empty() && last_fill.hash != request_id { + update.changes.push(TransactionChange::HashChange { + old: request_id, + new: last_fill.hash.clone(), + }); + } + + update +} + +pub fn map_transaction_state_order_action(fills: Vec, nonce: u64, request_id: String) -> TransactionUpdate { + let Some(fill) = order_action_fill(&fills, nonce) else { + return TransactionUpdate::new_state(TransactionState::Pending); + }; + + let hash = fill.hash.clone(); + let matching_fills: Vec<_> = fills.iter().filter(|item| item.oid == fill.oid).collect(); + let mut update = TransactionUpdate::new(TransactionState::Confirmed, vec![TransactionChange::HashChange { old: request_id, new: hash }]); + + if let Some(last_fill) = matching_fills.iter().max_by_key(|fill| fill.time) + && let Some(changes) = perpetual_fill_changes(&matching_fills, last_fill) + { + update.changes.extend(changes); + } + + update +} + +pub(crate) fn order_action_fill(fills: &[UserFill], nonce: u64) -> Option<&UserFill> { + fills + .iter() + .filter_map(|fill| action_history_time_delta(fill.time, nonce).filter(|_| !fill.hash.is_empty()).map(|delta| (delta, fill))) + .min_by_key(|(delta, _)| *delta) + .map(|(_, fill)| fill) +} + +pub fn map_transaction_state_action(updates: Vec, action_id: HyperCoreActionId, request_id: String) -> TransactionUpdate { + transaction_update_from_hash(ledger_action_hash(&updates, &action_id), request_id) +} + +pub fn map_transaction_state_staking_action(updates: Vec, action_id: HyperCoreActionId, request_id: String) -> TransactionUpdate { + transaction_update_from_hash(delegator_history_action_hash(&updates, &action_id), request_id) +} + +pub fn ledger_action_hash(updates: &[LedgerUpdate], action_id: &HyperCoreActionId) -> Option { + let nonce = action_id.nonce(); + updates + .iter() + .filter_map(|update| ledger_match_delta(update, action_id, nonce).map(|delta| (delta, update))) + .min_by_key(|(delta, _)| *delta) + .map(|(_, update)| update.hash.clone()) +} + +fn delegator_history_action_hash(updates: &[DelegatorHistoryUpdate], action_id: &HyperCoreActionId) -> Option { + let nonce = action_id.nonce(); + updates + .iter() + .filter_map(|update| delegator_history_match_delta(update, action_id, nonce).map(|delta| (delta, update))) + .min_by_key(|(delta, _)| *delta) + .map(|(_, update)| update.hash.clone()) +} + +fn ledger_match_delta(update: &LedgerUpdate, action_id: &HyperCoreActionId, nonce: u64) -> Option { + match &update.delta { + LedgerDelta::Send { nonce: update_nonce } | LedgerDelta::SpotTransfer { nonce: update_nonce } if *update_nonce == nonce => Some(0), + LedgerDelta::CStakingTransfer { token, amount, is_deposit } => { + let (wei, expected_deposit) = match action_id { + HyperCoreActionId::CDeposit { wei, .. } => (*wei, true), + HyperCoreActionId::CWithdraw { wei, .. } => (*wei, false), + HyperCoreActionId::TokenDelegate { wei, is_undelegate, .. } => (*wei, !*is_undelegate), + HyperCoreActionId::Nonce(_) | HyperCoreActionId::Order(_) => return None, + }; + + if token != HYPERCORE_HYPE.symbol.as_str() || *is_deposit != expected_deposit { + return None; + } + + action_history_time_delta(update.time, nonce).filter(|_| amount_matches_wei(amount, wei)) + } + LedgerDelta::Send { .. } | LedgerDelta::SpotTransfer { .. } | LedgerDelta::Other => None, + } +} + +fn delegator_history_match_delta(update: &DelegatorHistoryUpdate, action_id: &HyperCoreActionId, nonce: u64) -> Option { + let matches_action = match (&update.delta, action_id) { + (DelegatorHistoryDelta { c_deposit: Some(delta), .. }, HyperCoreActionId::CDeposit { wei, .. }) => amount_matches_wei(&delta.amount, *wei), + (DelegatorHistoryDelta { delegate: Some(delta), .. }, HyperCoreActionId::TokenDelegate { wei, is_undelegate, .. }) => { + delta.is_undelegate == *is_undelegate && amount_matches_wei(&delta.amount, *wei) + } + (DelegatorHistoryDelta { withdrawal: Some(delta), .. }, HyperCoreActionId::CWithdraw { wei, .. }) => { + delta.phase == DELEGATOR_WITHDRAWAL_INITIATED && amount_matches_wei(&delta.amount, *wei) + } + _ => false, + }; + + if matches_action { action_history_time_delta(update.time, nonce) } else { None } +} + +fn amount_matches_wei(amount: &str, wei: u64) -> bool { + match BigNumberFormatter::value_from_amount(amount, HYPERCORE_HYPE.decimals as u32) { + Ok(update_wei) => update_wei == wei.to_string(), + Err(_) => false, + } +} + +fn action_history_time_delta(time: u64, nonce: u64) -> Option { + let delta = time.checked_sub(nonce)?; + if delta <= ACTION_HISTORY_MATCH_WINDOW_MS { Some(delta) } else { None } +} + +fn transaction_update_from_hash(hash: Option, request_id: String) -> TransactionUpdate { + match hash { + Some(hash) => TransactionUpdate::new(TransactionState::Confirmed, vec![TransactionChange::HashChange { old: request_id, new: hash }]), + None => TransactionUpdate::new_state(TransactionState::Pending), + } +} + +fn perpetual_fill_changes(matching_fills: &[&UserFill], last_fill: &UserFill) -> Option> { + let (_, metadata) = prepare_perpetual_fill(matching_fills, last_fill)?; + let fee: f64 = matching_fills.iter().map(|fill| fill.fee + fill.builder_fee.unwrap_or(0.0)).sum(); + let network_fee = usdc_value(fee).parse().ok()?; + + Some(vec![ + TransactionChange::Metadata(TransactionMetadata::Perpetual(metadata)), + TransactionChange::NetworkFee(network_fee), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::order::{FillDirection, UserFill}; + use num_bigint::BigInt; + + #[test] + fn test_map_transaction_state_order() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_multiple.json")).unwrap(); + let oid = 187530505765u64; + let request_id = oid.to_string(); + + let update = map_transaction_state_order(fills, oid, request_id.clone()); + + assert_eq!(update.state, TransactionState::Confirmed); + assert_eq!(update.changes.len(), 3); + + let metadata_change = update.changes.iter().find_map(|change| { + if let TransactionChange::Metadata(TransactionMetadata::Perpetual(metadata)) = change { + Some(metadata) + } else { + None + } + }); + let metadata = metadata_change.unwrap(); + assert_eq!(metadata.pnl, 36.5); + assert_eq!(metadata.price, 47.904); + assert_eq!(metadata.direction, PerpetualDirection::Long); + assert_eq!(metadata.is_liquidation, Some(false)); + assert_eq!(metadata.provider, Some(PerpetualProvider::Hypercore)); + + let network_fee_change = update + .changes + .iter() + .find_map(|change| if let TransactionChange::NetworkFee(fee) = change { Some(fee) } else { None }); + assert_eq!(network_fee_change, Some(&BigInt::from(666786))); + + let hash_change = update.changes.iter().find_map(|change| { + if let TransactionChange::HashChange { old, new } = change { + Some((old, new)) + } else { + None + } + }); + let (old, new) = hash_change.unwrap(); + assert_eq!(old, &request_id); + assert_eq!(new, "0x9b4d63110c57f2e19cc7042ce90e300202f500f6a75b11b33f160e63cb5bcccc"); + } + + #[test] + fn test_map_transaction_state_order_no_matching_fills() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_multiple.json")).unwrap(); + let update = map_transaction_state_order(fills, 999999999u64, "999999999".to_string()); + + assert_eq!(update.state, TransactionState::Pending); + assert!(update.changes.is_empty()); + } + + #[test] + fn test_map_transaction_state_order_non_perpetual_fill_stays_pending() { + let fills = vec![UserFill { + coin: "HYPE".to_string(), + hash: String::new(), + oid: 123, + sz: "1".to_string(), + closed_pnl: 0.0, + fee: 0.0, + builder_fee: None, + fee_token: None, + px: 42.0, + dir: FillDirection::Other(String::new()), + time: 0, + liquidation: None, + }]; + + let update = map_transaction_state_order(fills, 123, "123".to_string()); + + assert_eq!(update.state, TransactionState::Pending); + assert!(update.changes.is_empty()); + } + + #[test] + fn test_map_transaction_state_order_spot_fill_confirms() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_spot_swap.json")).unwrap(); + + let request_id = "355101232455".to_string(); + let update = map_transaction_state_order(fills, 355101232455, request_id.clone()); + + assert_eq!(update.state, TransactionState::Confirmed); + assert_eq!(update.changes.len(), 1); + assert_eq!( + update.changes[0], + TransactionChange::HashChange { + old: request_id, + new: "0xd16518b18533f577d2de043763f8ad020482009720371449752dc4044437cf62".to_string(), + } + ); + } + + #[test] + fn test_map_transaction_state_order_action_includes_perpetual_fee() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_multiple.json")).unwrap(); + let request_id = "action:order:1759700579000".to_string(); + let update = map_transaction_state_order_action(fills, 1759700579000, request_id.clone()); + + assert_eq!(update.state, TransactionState::Confirmed); + assert_eq!( + update.changes[0], + TransactionChange::HashChange { + old: request_id, + new: "0x9b4d63110c57f2e19cc7042ce90e300202f500f6a75b11b33f160e63cb5bcccc".to_string(), + } + ); + let network_fee_change = update + .changes + .iter() + .find_map(|change| if let TransactionChange::NetworkFee(fee) = change { Some(fee) } else { None }); + assert_eq!(network_fee_change, Some(&BigInt::from(666786))); + } + + #[test] + fn test_map_transaction_state_order_action_confirms_closest_fill() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_spot_swap.json")).unwrap(); + let request_id = "action:order:1773977221000".to_string(); + let update = map_transaction_state_order_action(fills, 1773977221000, request_id.clone()); + + assert_eq!( + update, + TransactionUpdate::new( + TransactionState::Confirmed, + vec![TransactionChange::HashChange { + old: request_id, + new: "0xd16518b18533f577d2de043763f8ad020482009720371449752dc4044437cf62".to_string(), + }] + ) + ); + } + + #[test] + fn test_map_transaction_state_action_without_matching_nonce_stays_pending() { + let updates = serde_json::from_str(include_str!("../../testdata/user_non_funding_ledger_updates_action_hash.json")).unwrap(); + let update = map_transaction_state_action(updates, HyperCoreActionId::Nonce(1777960893093), "action:1777960893093".to_string()); + + assert_eq!(update.state, TransactionState::Pending); + assert!(update.changes.is_empty()); + } + + #[test] + fn test_map_transaction_state_action_confirms_with_hash_change() { + let updates = serde_json::from_str(include_str!("../../testdata/user_non_funding_ledger_updates_action_hash.json")).unwrap(); + let request_id = "action:1777960893092".to_string(); + let update = map_transaction_state_action(updates, HyperCoreActionId::Nonce(1777960893092), request_id.clone()); + + assert_eq!( + update, + TransactionUpdate::new( + TransactionState::Confirmed, + vec![TransactionChange::HashChange { + old: request_id, + new: "0xba3bce0950157157bbb5043aaee1060201e300eeeb1890295e04795c0f194b42".to_string(), + }] + ) + ); + } + + #[test] + fn test_map_transaction_state_action_confirms_spot_transfer_nonce() { + let updates = serde_json::from_str(include_str!("../../testdata/user_non_funding_ledger_updates_spot_transfer.json")).unwrap(); + let request_id = "action:1761611679622".to_string(); + let update = map_transaction_state_action(updates, HyperCoreActionId::Nonce(1761611679622), request_id.clone()); + + assert_eq!( + update, + TransactionUpdate::new( + TransactionState::Confirmed, + vec![TransactionChange::HashChange { + old: request_id, + new: "0x1210f05525bce189138a042e558d8002126b003ac0b0005bb5d99ba7e4b0bb73".to_string(), + }] + ) + ); + } + + #[test] + fn test_map_transaction_state_action_confirms_staking_transfer_with_typed_action() { + let updates = serde_json::from_str(include_str!("../../testdata/user_non_funding_ledger_updates_c_staking_transfer.json")).unwrap(); + let request_id = "action:cDeposit:1000000:1779376553779".to_string(); + let action_id = HyperCoreActionId::CDeposit { + wei: 1_000_000, + nonce: 1779376553779, + }; + let update = map_transaction_state_action(updates, action_id, request_id.clone()); + + assert_eq!( + update, + TransactionUpdate::new( + TransactionState::Confirmed, + vec![TransactionChange::HashChange { + old: request_id, + new: "0xf0515f4aee4cd625f1cb043be9536a0203ca0030894ff4f8941a0a9dad40b010".to_string(), + }] + ) + ); + } + + #[test] + fn test_map_transaction_state_action_resolves_token_delegate_to_staking_transfer() { + let updates = serde_json::from_str(include_str!("../../testdata/user_non_funding_ledger_updates_c_staking_transfer.json")).unwrap(); + let request_id = "action:tokenDelegate:1000000:stake:1779376553780".to_string(); + let action_id = HyperCoreActionId::TokenDelegate { + wei: 1_000_000, + is_undelegate: false, + nonce: 1779376553780, + }; + let update = map_transaction_state_action(updates, action_id, request_id.clone()); + + assert_eq!( + update, + TransactionUpdate::new( + TransactionState::Confirmed, + vec![TransactionChange::HashChange { + old: request_id, + new: "0xf0515f4aee4cd625f1cb043be9536a0203ca0030894ff4f8941a0a9dad40b010".to_string(), + }] + ) + ); + } + + #[test] + fn test_map_transaction_state_staking_action_confirms_delegator_history_actions() { + let updates: Vec = serde_json::from_str(include_str!("../../testdata/delegator_history_staking_actions.json")).unwrap(); + + for (request_id, action_id, expected_hash) in [ + ( + "action:cDeposit:1000000:1780081714468", + HyperCoreActionId::CDeposit { + wei: 1_000_000, + nonce: 1780081714468, + }, + "0x945b910697cd885a95d5043c857c0d0201b300ec32c0a72c38243c5956c16245", + ), + ( + "action:tokenDelegate:1000000:stake:1780081715280", + HyperCoreActionId::TokenDelegate { + wei: 1_000_000, + is_undelegate: false, + nonce: 1780081715280, + }, + "0x0cfde0fb239ef8630e77043c857c1502025000e0be921735b0c68c4de292d24d", + ), + ( + "action:cWithdraw:3001423:1780078264489", + HyperCoreActionId::CWithdraw { + wei: 3_001_423, + nonce: 1780078264489, + }, + "0x7b435a1210afafef7cbd043c84b8d402064e00f7aba2cec11f0c0564cfa389da", + ), + ( + "action:tokenDelegate:3001423:unstake:1780078264488", + HyperCoreActionId::TokenDelegate { + wei: 3_001_423, + is_undelegate: true, + nonce: 1780078264488, + }, + "0xc24f99bd90d6d68ac3c9043c84b8c90201c000a32bd9f55c661845104fdab075", + ), + ] { + assert_eq!( + map_transaction_state_staking_action(updates.clone(), action_id, request_id.to_string()), + TransactionUpdate::new( + TransactionState::Confirmed, + vec![TransactionChange::HashChange { + old: request_id.to_string(), + new: expected_hash.to_string(), + }] + ) + ); + } + } + + #[test] + fn test_map_transaction_state_action_without_matching_window_stays_pending() { + let updates = serde_json::from_str(include_str!("../../testdata/user_non_funding_ledger_updates_c_staking_transfer.json")).unwrap(); + let action_id = HyperCoreActionId::TokenDelegate { + wei: 1_000_000, + is_undelegate: false, + nonce: 1779376558000, + }; + let update = map_transaction_state_action(updates, action_id, "action:tokenDelegate:1000000:stake:1779376558000".to_string()); + + assert_eq!(update.state, TransactionState::Pending); + assert!(update.changes.is_empty()); + } + + #[test] + fn test_prepare_perpetual_fill_maps_transaction_type() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_multiple.json")).unwrap(); + let oid = 187530505765u64; + let matching: Vec<_> = fills.iter().filter(|fill| fill.oid == oid).collect(); + let last_fill = matching.last().copied().unwrap(); + + let (transaction_type, metadata) = prepare_perpetual_fill(&matching, last_fill).unwrap(); + assert_eq!(transaction_type, TransactionType::PerpetualOpenPosition); + assert_eq!(metadata.direction, PerpetualDirection::Long); + assert_eq!(metadata.is_liquidation, Some(false)); + } + + #[test] + fn test_prepare_perpetual_fill_returns_none_for_unknown_direction() { + let fill = UserFill { + coin: "HYPE".to_string(), + hash: String::new(), + oid: 123, + sz: "1".to_string(), + closed_pnl: 0.0, + fee: 0.0, + builder_fee: None, + fee_token: None, + px: 42.0, + dir: FillDirection::Other("Unsupported".to_string()), + time: 0, + liquidation: None, + }; + + assert!(prepare_perpetual_fill(&[&fill], &fill).is_none()); + } + + #[test] + fn test_prepare_perpetual_fill_returns_none_for_spot_fill() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_spot_swap.json")).unwrap(); + let matching: Vec<_> = fills.iter().collect(); + let last_fill = matching.last().copied().unwrap(); + + assert!(prepare_perpetual_fill(&matching, last_fill).is_none()); + } + + #[test] + fn test_prepare_perpetual_fill_marks_liquidation() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_liquidation.json")).unwrap(); + let matching: Vec<_> = fills.iter().collect(); + let last_fill = matching.last().copied().unwrap(); + + let (_, metadata) = prepare_perpetual_fill(&matching, last_fill).unwrap(); + assert_eq!(metadata.is_liquidation, Some(true)); + } +} diff --git a/core/crates/gem_hypercore/src/provider/transactions.rs b/core/crates/gem_hypercore/src/provider/transactions.rs new file mode 100644 index 0000000000..e3c8f446aa --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/transactions.rs @@ -0,0 +1,151 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::{ + models::{ + order::UserFill, + spot::SpotMeta, + transaction_id::{HyperCoreActionId, HyperCoreTransactionId}, + }, + provider::{ + transaction_state_mapper, + transactions_mapper::{map_user_fill_by_hash, map_user_fill_by_oid, map_user_fills}, + }, + rpc::client::HyperCoreClient, +}; + +const TRANSACTION_ID_PREFIX: &str = "hypercore_"; + +#[async_trait] +impl ChainTransactions for HyperCoreClient { + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let start_time = request.from_timestamp.map(|ts| ts as i64 * 1000).unwrap_or(0); + let fills = self.get_user_fills_by_time(&request.address, start_time).await?; + let spot_meta = load_spot_meta_if_needed(self, &fills).await?; + let transactions = map_user_fills(&request.address, fills, spot_meta.as_ref()); + + match request.asset_id { + Some(asset_id) => Ok(transactions.into_iter().filter(|tx| tx.asset_ids().contains(&asset_id)).collect()), + None => Ok(transactions), + } + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + let hash = hash.strip_prefix(TRANSACTION_ID_PREFIX).unwrap_or(&hash); + + if hash.starts_with("0x") { + return self.get_transaction_by_tx_hash(hash).await; + } + + match HyperCoreTransactionId::parse(hash) { + Some(HyperCoreTransactionId::Order(oid)) => self.get_transaction_by_order_id(oid, hash).await, + Some(HyperCoreTransactionId::Action(action_id)) => self.get_transaction_by_action_id(hash, action_id).await, + None => Ok(None), + } + } +} + +impl HyperCoreClient { + async fn get_transaction_by_tx_hash(&self, hash: &str) -> Result, Box> { + let response = self.get_transaction_details(hash).await?; + let sender = response.tx.user.to_lowercase(); + self.cache_transaction_sender(hash, &sender)?; + + self.map_user_fills_with_spot_meta( + &sender, + response.tx.time.saturating_sub(transaction_state_mapper::ACTION_HISTORY_QUERY_LOOKBACK_MS as i64), + |fills, spot_meta| map_user_fill_by_hash(&sender, fills, hash, spot_meta), + ) + .await + } + + async fn get_transaction_by_order_id(&self, oid: u64, id: &str) -> Result, Box> { + let Some(sender) = self.get_cached_transaction_sender(id)? else { + return Ok(None); + }; + + self.map_user_fills_with_spot_meta(&sender, 0, |fills, spot_meta| map_user_fill_by_oid(&sender, fills, oid, spot_meta)) + .await + } + + async fn get_transaction_by_action_id(&self, id: &str, action_id: HyperCoreActionId) -> Result, Box> { + match action_id { + HyperCoreActionId::Order(nonce) => self.get_transaction_by_order_action_id(id, nonce).await, + HyperCoreActionId::Nonce(_) | HyperCoreActionId::CDeposit { .. } | HyperCoreActionId::CWithdraw { .. } | HyperCoreActionId::TokenDelegate { .. } => { + self.get_transaction_by_ledger_action_id(id, action_id).await + } + } + } + + async fn get_transaction_by_order_action_id(&self, id: &str, nonce: u64) -> Result, Box> { + let Some(sender) = self.get_cached_transaction_sender(id)? else { + return Ok(None); + }; + + self.map_user_fills_with_spot_meta( + &sender, + nonce.saturating_sub(transaction_state_mapper::ACTION_HISTORY_QUERY_LOOKBACK_MS) as i64, + |fills, spot_meta| { + let oid = transaction_state_mapper::order_action_fill(&fills, nonce)?.oid; + map_user_fill_by_oid(&sender, fills, oid, spot_meta) + }, + ) + .await + } + + async fn get_transaction_by_ledger_action_id(&self, id: &str, action_id: HyperCoreActionId) -> Result, Box> { + let Some(sender) = self.get_cached_transaction_sender(id)? else { + return Ok(None); + }; + + let updates = self + .get_ledger_updates(&sender, action_id.nonce().saturating_sub(transaction_state_mapper::ACTION_HISTORY_QUERY_LOOKBACK_MS) as i64) + .await?; + let Some(hash) = transaction_state_mapper::ledger_action_hash(&updates, &action_id) else { + return Ok(None); + }; + self.get_transaction_by_tx_hash(&hash).await + } + + async fn map_user_fills_with_spot_meta(&self, sender: &str, start_time: i64, map: F) -> Result, Box> + where + F: FnOnce(Vec, Option<&SpotMeta>) -> Option, + { + let fills = self.get_user_fills_by_time(sender, start_time).await?; + let spot_meta = load_spot_meta_if_needed(self, &fills).await?; + Ok(map(fills, spot_meta.as_ref())) + } +} + +async fn load_spot_meta_if_needed(client: &HyperCoreClient, fills: &[UserFill]) -> Result, Box> { + if fills.iter().any(|fill| fill.coin.starts_with('@')) { + return Ok(Some(client.get_spot_meta().await?)); + } + Ok(None) +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod integration_tests { + use super::*; + use crate::provider::testkit::{TEST_TRANSACTION_ID, TEST_TRANSACTION_ORDER_ID, create_hypercore_test_client}; + + #[tokio::test] + async fn test_hypercore_get_transaction_by_hash() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let transaction = client.get_transaction_by_hash(TEST_TRANSACTION_ID.to_string()).await?.unwrap(); + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + + let sender = client.get_cached_transaction_sender(TEST_TRANSACTION_ID)?.unwrap(); + let order_id = HyperCoreTransactionId::Order(TEST_TRANSACTION_ORDER_ID.parse()?).to_string(); + client.cache_transaction_sender(&order_id, &sender)?; + + let transaction = client.get_transaction_by_hash(format!("hypercore_{order_id}")).await?.unwrap(); + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + + Ok(()) + } +} diff --git a/core/crates/gem_hypercore/src/provider/transactions_mapper.rs b/core/crates/gem_hypercore/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..30a713bdcb --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/transactions_mapper.rs @@ -0,0 +1,346 @@ +use std::collections::HashMap; +use std::error::Error; + +use chrono::{DateTime, Utc}; +use number_formatter::BigNumberFormatter; +use primitives::{AssetId, Chain, SwapProvider, Transaction, TransactionState, TransactionSwapMetadata, TransactionType, asset_constants::HYPERCORE_SPOT_USDC_ASSET_ID}; + +use crate::models::order::{FillDirection, UserFill}; +use crate::models::response::{BroadcastResult, TransactionBroadcastResponse}; +use crate::models::spot::SpotMeta; +use crate::models::token::SpotToken; +use crate::models::transaction_id::{HyperCoreActionId, HyperCoreTransactionId}; +use crate::perpetual_formatter::usdc_value; +use crate::provider::perpetual_mapper::create_perpetual_asset_id; +use crate::provider::transaction_state_mapper::prepare_perpetual_fill; + +pub fn map_transaction_broadcast(response: serde_json::Value, action_id: Option) -> Result> { + let response = serde_json::from_value::(response)?; + let action_id = action_id.map(|id| HyperCoreTransactionId::Action(id).to_string()); + map_transaction_broadcast_result(response.into_result(action_id)) +} + +pub fn map_transaction_broadcast_from_str(response: &str) -> Result> { + let response = serde_json::from_str::(response)?; + map_transaction_broadcast_result(response.into_result(None)) +} + +fn map_transaction_broadcast_result(result: BroadcastResult) -> Result> { + match result { + BroadcastResult::Success(result) => Ok(result), + BroadcastResult::Error(error) => Err(error.into()), + } +} + +pub fn map_user_fills(address: &str, fills: Vec, spot_meta: Option<&SpotMeta>) -> Vec { + let groups = fills.into_iter().fold(HashMap::>::new(), |mut acc, fill| { + acc.entry(fill.oid).or_default().push(fill); + acc + }); + groups.into_values().filter_map(|fills| map_fill_group(address, fills, spot_meta)).collect() +} + +pub fn map_user_fill_by_oid(address: &str, fills: Vec, oid: u64, spot_meta: Option<&SpotMeta>) -> Option { + let fills = fills.into_iter().filter(|fill| fill.oid == oid).collect::>(); + map_fill_group(address, fills, spot_meta) +} + +pub fn map_user_fill_by_hash(address: &str, fills: Vec, hash: &str, spot_meta: Option<&SpotMeta>) -> Option { + let fills = fills.into_iter().filter(|fill| fill.hash == hash).collect::>(); + map_fill_group(address, fills, spot_meta) +} + +fn map_fill_group(address: &str, fills: Vec, spot_meta: Option<&SpotMeta>) -> Option { + let last_fill = fills.iter().max_by_key(|fill| fill.time)?.clone(); + + match &last_fill.dir { + FillDirection::Buy | FillDirection::Sell => map_spot_fill_group(address, fills, &last_fill, spot_meta), + FillDirection::OpenLong | FillDirection::OpenShort | FillDirection::CloseLong | FillDirection::CloseShort | FillDirection::Other(_) => { + map_perpetual_fill_group(address, fills, &last_fill) + } + } +} + +fn map_perpetual_fill_group(address: &str, fills: Vec, last_fill: &UserFill) -> Option { + let fill_refs = fills.iter().collect::>(); + let (transaction_type, metadata) = prepare_perpetual_fill(&fill_refs, last_fill)?; + let fee: f64 = fills.iter().map(|fill| fill.fee + fill.builder_fee.unwrap_or(0.0)).sum(); + let value = fills.iter().try_fold(0.0, |sum, fill| Some(sum + fill.px * fill.sz.parse::().ok()?))?; + let metadata = serde_json::to_value(metadata).ok()?; + + build_fill_transaction( + address, + last_fill, + create_perpetual_asset_id(&last_fill.coin), + transaction_type, + usdc_value(fee), + HYPERCORE_SPOT_USDC_ASSET_ID.clone(), + usdc_value(value), + metadata, + ) +} + +fn map_spot_fill_group(address: &str, fills: Vec, last_fill: &UserFill, spot_meta: Option<&SpotMeta>) -> Option { + let spot_meta = spot_meta?; + let market_index = last_fill.coin.strip_prefix('@')?.parse::().ok()?; + let market = spot_meta.universe.iter().find(|market| market.index == market_index && market.tokens.len() == 2)?; + let base_token = spot_meta.tokens.iter().find(|token| token.index == market.tokens[0])?; + let quote_token = spot_meta.tokens.iter().find(|token| token.index == market.tokens[1])?; + + let (base_amount, quote_amount) = fills.iter().try_fold((0.0, 0.0), |(base_sum, quote_sum), fill| { + let size = fill.sz.parse::().ok()?; + Some((base_sum + size, quote_sum + fill.px * size)) + })?; + let (fee, fee_asset_id) = map_spot_fee(&fills, base_token, quote_token)?; + + let ((from_token, from_amount), (to_token, to_amount)) = match &last_fill.dir { + FillDirection::Sell => ((base_token, base_amount), (quote_token, quote_amount)), + FillDirection::Buy => ((quote_token, quote_amount), (base_token, base_amount)), + _ => return None, + }; + let from_asset = from_token.asset_id(Chain::HyperCore); + let from_value = amount_to_value(from_amount, from_token.wei_decimals)?; + let to_asset = to_token.asset_id(Chain::HyperCore); + let to_value = amount_to_value(to_amount, to_token.wei_decimals)?; + + let metadata = serde_json::to_value(TransactionSwapMetadata { + from_asset: from_asset.clone(), + from_value: from_value.clone(), + to_asset, + to_value, + provider: Some(SwapProvider::Hyperliquid.id().to_string()), + }) + .ok()?; + + build_fill_transaction(address, last_fill, from_asset, TransactionType::Swap, fee, fee_asset_id, from_value, metadata) +} + +fn map_spot_fee(fills: &[UserFill], base_token: &SpotToken, quote_token: &SpotToken) -> Option<(String, primitives::AssetId)> { + let fee_amount: f64 = fills.iter().map(|fill| fill.fee + fill.builder_fee.unwrap_or(0.0)).sum(); + let fee_token = fills.iter().rev().find_map(|fill| fill.fee_token.as_deref()).unwrap_or(quote_token.name.as_str()); + let fee_token = if fee_token == base_token.name { base_token } else { quote_token }; + + Some((amount_to_value(fee_amount, fee_token.wei_decimals)?, fee_token.asset_id(Chain::HyperCore))) +} + +fn amount_to_value(amount: f64, decimals: i32) -> Option { + let precision: usize = decimals.try_into().ok()?; + BigNumberFormatter::value_from_amount(&format!("{amount:.precision$}"), precision as u32).ok() +} + +fn build_fill_transaction( + address: &str, + last_fill: &UserFill, + asset_id: AssetId, + transaction_type: TransactionType, + fee: String, + fee_asset_id: AssetId, + value: String, + metadata: serde_json::Value, +) -> Option { + if last_fill.hash.is_empty() { + return None; + } + + let created_at = DateTime::::from_timestamp_millis(last_fill.time as i64)?; + let address = address.to_string(); + + Some(Transaction::new( + last_fill.hash.clone(), + asset_id, + address.clone(), + address, + None, + transaction_type, + TransactionState::Confirmed, + fee, + fee_asset_id, + value, + None, + Some(metadata), + created_at, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::action::ExchangeRequest; + use crate::models::spot::SpotMeta; + use crate::provider::testkit::{TEST_TRANSACTION_ID, TEST_TRANSACTION_ORDER_ID}; + use primitives::{PerpetualDirection, TransactionPerpetualMetadata, TransactionType, asset_constants::HYPERCORE_SPOT_HYPE_ASSET_ID}; + + fn action_id(data: &str) -> Option { + serde_json::from_str::(data).ok().map(HyperCoreActionId::from) + } + + fn spot_meta() -> SpotMeta { + serde_json::from_str(include_str!("../../testdata/spot_meta_spot_swap.json")).unwrap() + } + + #[test] + fn test_map_transaction_broadcast_success() { + let response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/order_broadcast_filled.json")).unwrap(); + let data = include_str!("../../testdata/hl_action_open_long_order.json").trim().to_string(); + assert_eq!(map_transaction_broadcast(response, action_id(&data)).unwrap(), "order:134896397196"); + } + + #[test] + fn test_map_transaction_broadcast_error() { + let response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/order_broadcast_error.json")).unwrap(); + let data = include_str!("../../testdata/hl_action_open_long_order.json").trim().to_string(); + assert!(map_transaction_broadcast(response, action_id(&data)).is_err()); + } + + #[test] + fn test_map_transaction_broadcast_extra_agent_error() { + let response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/transaction_broadcast_error_extra_agent.json")).unwrap(); + let data = include_str!("../../testdata/hl_action_open_long_order.json").trim().to_string(); + let result = map_transaction_broadcast(response, action_id(&data)); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Extra agent already used."); + } + + #[test] + fn test_map_transaction_broadcast_order_without_order_id_uses_action_id() { + let response: serde_json::Value = serde_json::from_str(r#"{"status":"ok","response":{"type":"order"}}"#).unwrap(); + let data = include_str!("../../testdata/hl_action_update_position_tp_sl.json").trim().to_string(); + let result = map_transaction_broadcast(response, action_id(&data)).unwrap(); + assert_eq!(result, "action:order:1755132472149"); + } + + #[test] + fn test_map_transaction_broadcast_waiting_for_trigger_uses_action_nonce() { + let response: serde_json::Value = serde_json::from_str(r#"{"status":"ok","response":{"type":"order","data":{"statuses":["waitingForTrigger"]}}}"#).unwrap(); + let data = include_str!("../../testdata/hl_action_update_position_tp_sl.json").trim().to_string(); + let result = map_transaction_broadcast(response, action_id(&data)).unwrap(); + assert_eq!(result, "action:order:1755132472149"); + } + + #[test] + fn test_map_transaction_broadcast_default_uses_staking_action_id() { + let response: serde_json::Value = serde_json::from_str(r#"{"status":"ok","response":{"type":"default"}}"#).unwrap(); + let data = include_str!("../../testdata/hl_action_spot_to_stake.json").trim().to_string(); + let result = map_transaction_broadcast(response, action_id(&data)).unwrap(); + assert_eq!(result, "action:cDeposit:10000000:1755231476741"); + } + + #[test] + fn test_map_transaction_broadcast_default_uses_token_delegate_action_id() { + let response: serde_json::Value = serde_json::from_str(r#"{"status":"ok","response":{"type":"default"}}"#).unwrap(); + let data = include_str!("../../testdata/hl_action_stake_to_validator.json").trim().to_string(); + let result = map_transaction_broadcast(response, action_id(&data)).unwrap(); + assert_eq!(result, "action:tokenDelegate:10000000:stake:1755231522831"); + } + + #[test] + fn test_map_transaction_by_hash() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_multiple.json")).unwrap(); + let transaction = map_user_fill_by_hash("0xabc", fills.clone(), TEST_TRANSACTION_ID, None).unwrap(); + let by_order_id = map_user_fill_by_oid("0xabc", fills, TEST_TRANSACTION_ORDER_ID.parse().unwrap(), None).unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + assert_eq!(transaction.transaction_type, TransactionType::PerpetualOpenPosition); + assert_eq!(by_order_id.hash, TEST_TRANSACTION_ID); + assert_eq!(transaction.asset_id.to_string(), "hypercore_perpetual::HYPE"); + assert_eq!(transaction.fee_asset_id, HYPERCORE_SPOT_USDC_ASSET_ID.clone()); + assert_eq!(transaction.fee, "666786"); + assert_eq!(transaction.from, "0xabc"); + assert_eq!(transaction.to, "0xabc"); + + let metadata: TransactionPerpetualMetadata = serde_json::from_value(transaction.metadata.clone().unwrap()).unwrap(); + assert_eq!(metadata.direction, PerpetualDirection::Long); + assert_eq!(metadata.is_liquidation, Some(false)); + } + + #[test] + fn test_map_perpetual_fills_ignores_unknown_direction() { + let fills = vec![UserFill { + coin: "HYPE".to_string(), + hash: "0xhash".to_string(), + oid: 1, + sz: "1".to_string(), + closed_pnl: 0.0, + fee: 0.1, + builder_fee: None, + fee_token: None, + px: 42.0, + dir: FillDirection::Other("Unknown".to_string()), + time: 1, + liquidation: None, + }]; + + assert!(map_user_fills("0xabc", fills, Some(&spot_meta())).is_empty()); + } + + #[test] + fn test_map_perpetual_fills_maps_liquidation_to_close() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_liquidation.json")).unwrap(); + let transactions = map_user_fills("0xabc", fills, Some(&spot_meta())); + + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].transaction_type, TransactionType::PerpetualClosePosition); + + let metadata: TransactionPerpetualMetadata = serde_json::from_value(transactions[0].metadata.clone().unwrap()).unwrap(); + assert_eq!(metadata.direction, PerpetualDirection::Long); + assert_eq!(metadata.is_liquidation, Some(true)); + } + + #[test] + fn test_map_perpetual_fills_keeps_distinct_oids_for_shared_hash() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_shared_hash.json")).unwrap(); + let transactions = map_user_fills("0xabc", fills, Some(&spot_meta())); + + assert_eq!(transactions.len(), 2); + assert_eq!(transactions[0].hash, "0xshared"); + assert_eq!(transactions[1].hash, "0xshared"); + + let mut fees = transactions.iter().map(|tx| tx.fee.as_str()).collect::>(); + fees.sort_unstable(); + assert_eq!(fees, vec!["24236", "85686"]); + } + + #[test] + fn test_map_spot_swap_fill_group() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_spot_swap.json")).unwrap(); + let transactions = map_user_fills("0xabc", fills, Some(&spot_meta())); + + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].transaction_type, TransactionType::Swap); + assert_eq!(transactions[0].hash, "0xd16518b18533f577d2de043763f8ad020482009720371449752dc4044437cf62"); + assert_eq!(transactions[0].asset_id, HYPERCORE_SPOT_HYPE_ASSET_ID.clone()); + assert_eq!(transactions[0].fee, "1858810"); + assert_eq!(transactions[0].fee_asset_id, HYPERCORE_SPOT_USDC_ASSET_ID.clone()); + assert!(transactions[0].asset_ids().contains(&HYPERCORE_SPOT_USDC_ASSET_ID.clone())); + assert!(transactions[0].asset_ids().contains(&HYPERCORE_SPOT_HYPE_ASSET_ID.clone())); + + let metadata: TransactionSwapMetadata = serde_json::from_value(transactions[0].metadata.clone().unwrap()).unwrap(); + assert_eq!(metadata.from_asset, HYPERCORE_SPOT_HYPE_ASSET_ID.clone()); + assert_eq!(metadata.from_value, "30000000"); + assert_eq!(metadata.to_asset, HYPERCORE_SPOT_USDC_ASSET_ID.clone()); + assert_eq!(metadata.to_value, "1182450000"); + assert_eq!(metadata.provider.as_deref(), Some("hyperliquid")); + } + + #[test] + fn test_map_spot_buy_fill_group() { + let fills: Vec = serde_json::from_str(include_str!("../../testdata/user_fills_spot_swap_buy.json")).unwrap(); + let transactions = map_user_fills("0xabc", fills, Some(&spot_meta())); + + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].transaction_type, TransactionType::Swap); + assert_eq!(transactions[0].hash, "0xbf8b52bd13095a59c105043764964e02028200a2ae0c792b6353fe0fd20d3444"); + assert_eq!(transactions[0].asset_id, HYPERCORE_SPOT_USDC_ASSET_ID.clone()); + assert_eq!(transactions[0].fee, "20159"); + assert_eq!(transactions[0].fee_asset_id, HYPERCORE_SPOT_HYPE_ASSET_ID.clone()); + assert!(transactions[0].asset_ids().contains(&HYPERCORE_SPOT_USDC_ASSET_ID.clone())); + assert!(transactions[0].asset_ids().contains(&HYPERCORE_SPOT_HYPE_ASSET_ID.clone())); + + let metadata: TransactionSwapMetadata = serde_json::from_value(transactions[0].metadata.clone().unwrap()).unwrap(); + assert_eq!(metadata.from_asset, HYPERCORE_SPOT_USDC_ASSET_ID.clone()); + assert_eq!(metadata.from_value, "1197900000"); + assert_eq!(metadata.to_asset, HYPERCORE_SPOT_HYPE_ASSET_ID.clone()); + assert_eq!(metadata.to_value, "30000000"); + assert_eq!(metadata.provider.as_deref(), Some("hyperliquid")); + } +} diff --git a/core/crates/gem_hypercore/src/provider/websocket_mapper.rs b/core/crates/gem_hypercore/src/provider/websocket_mapper.rs new file mode 100644 index 0000000000..814f168018 --- /dev/null +++ b/core/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -0,0 +1,230 @@ +use std::collections::{HashMap, HashSet}; + +use primitives::{AssetId, PerpetualMarketData, PerpetualPosition}; + +use crate::models::{ + order::OpenOrder, + websocket::{ActiveAssetCtxData, HyperliquidSocketMessage, PositionsDiff, RawSocketMessage}, +}; + +use super::perpetual_mapper::{map_positions, map_tp_sl_from_orders}; + +pub fn parse_websocket_data(data: &[u8]) -> Result { + let raw: RawSocketMessage = serde_json::from_slice(data)?; + + match raw { + RawSocketMessage::AccountState(data) => { + let summary = map_positions(data.clearinghouse_state, data.address, &[]); + Ok(HyperliquidSocketMessage::AccountState { + balance: summary.balance, + positions: summary.positions, + }) + } + RawSocketMessage::OpenOrders(data) => Ok(HyperliquidSocketMessage::OpenOrders { orders: data.orders }), + RawSocketMessage::Candle(candlestick) => Ok(HyperliquidSocketMessage::Candle { candle: candlestick.into() }), + RawSocketMessage::MarketData(data) => Ok(HyperliquidSocketMessage::MarketData { + market: map_active_asset_ctx(data)?, + }), + RawSocketMessage::MarketPrices(data) => Ok(HyperliquidSocketMessage::MarketPrices { + prices: data + .mids + .into_iter() + .filter_map(|(coin, price)| price.parse::().ok().map(|price| (coin, price))) + .collect(), + }), + RawSocketMessage::SubscriptionResponse(data) => Ok(HyperliquidSocketMessage::SubscriptionResponse { + subscription_type: data.subscription.subscription_type, + }), + RawSocketMessage::Unknown => Ok(HyperliquidSocketMessage::Unknown), + } +} + +fn map_active_asset_ctx(data: ActiveAssetCtxData) -> Result { + let ActiveAssetCtxData { symbol, ctx } = data; + let mark_price = ctx.mark_px; + let price = ctx.mid_px.unwrap_or(mark_price); + let prev_price = ctx.prev_day_px; + let price_percent_change_24h = if prev_price > 0.0 { ((price - prev_price) / prev_price) * 100.0 } else { 0.0 }; + let open_interest = ctx.open_interest * price; + let volume_24h = ctx.day_ntl_vlm; + let funding = ctx.funding * 100.0; + + Ok(PerpetualMarketData { + coin: symbol, + price, + price_percent_change_24h, + open_interest, + volume_24h, + funding, + }) +} + +pub fn diff_clearinghouse_positions(new_positions: Vec, existing_positions: Vec) -> PositionsDiff { + let existing_map: HashMap<&str, &PerpetualPosition> = existing_positions.iter().map(|p| (p.id.as_str(), p)).collect(); + + let positions: Vec = new_positions + .into_iter() + .map(|pos| match existing_map.get(pos.id.as_str()) { + Some(existing) => PerpetualPosition { + take_profit: existing.take_profit.clone(), + stop_loss: existing.stop_loss.clone(), + ..pos + }, + None => pos, + }) + .collect(); + + let new_ids: HashSet<&str> = positions.iter().map(|p| p.id.as_str()).collect(); + let delete_position_ids: Vec = existing_positions.iter().filter(|p| !new_ids.contains(p.id.as_str())).map(|p| p.id.clone()).collect(); + + PositionsDiff { delete_position_ids, positions } +} + +pub fn diff_open_orders_positions(orders: &[OpenOrder], existing_positions: Vec) -> PositionsDiff { + let positions: Vec = existing_positions + .into_iter() + .filter_map(|pos| { + let coin = pos.asset_id.token_id.as_ref().and_then(|t| AssetId::decode_token_id(t).into_iter().nth(1))?; + let (take_profit, stop_loss) = map_tp_sl_from_orders(orders, &coin); + + if pos.take_profit != take_profit || pos.stop_loss != stop_loss { + Some(PerpetualPosition { take_profit, stop_loss, ..pos }) + } else { + None + } + }) + .collect(); + + PositionsDiff { + delete_position_ids: vec![], + positions, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Chain, PerpetualDirection, PerpetualMarginType}; + + #[test] + fn test_parse_all_mids() { + let json = include_bytes!("../../testdata/ws_all_mids.json"); + let HyperliquidSocketMessage::MarketPrices { prices } = parse_websocket_data(json).unwrap() else { + panic!("expected MarketPrices"); + }; + + assert_eq!(prices.len(), 5); + assert_eq!(prices["BTC"], 104633.0); + assert_eq!(prices["ETH"], 3321.1); + assert_eq!(prices["SOL"], 260.48); + assert_eq!(prices["DOGE"], 0.40381); + assert_eq!(prices["HYPE"], 26.65); + } + + #[test] + fn test_parse_active_asset_ctx() { + let json = include_bytes!("../../testdata/ws_active_asset_ctx.json"); + let HyperliquidSocketMessage::MarketData { market } = parse_websocket_data(json).unwrap() else { + panic!("expected MarketData"); + }; + + assert_eq!( + market, + PerpetualMarketData { + coin: "ETH".to_string(), + price: 2236.45, + price_percent_change_24h: 11.822499999999991, + open_interest: 1_118_225.0, + volume_24h: 1_169_046.294_06, + funding: 0.00125, + } + ); + } + + #[test] + fn test_parse_active_asset_ctx_rejects_invalid_numbers() { + let json = br#"{ + "channel": "activeAssetCtx", + "data": { + "coin": "ETH", + "ctx": { + "dayNtlVlm": "1169046.29406", + "prevDayPx": "invalid", + "markPx": "2236.40", + "midPx": "2236.45", + "funding": "0.0000125", + "openInterest": "500" + } + } + }"#; + + assert!(parse_websocket_data(json).is_err()); + } + + #[test] + fn test_parse_candle() { + let json = include_bytes!("../../testdata/ws_candle.json"); + let HyperliquidSocketMessage::Candle { candle: update } = parse_websocket_data(json).unwrap() else { + panic!("expected Candle"); + }; + + assert_eq!(update.coin, "ETH"); + assert_eq!(update.interval, "1h"); + assert_eq!(update.candle.open, 3300.5); + assert_eq!(update.candle.close, 3321.1); + assert_eq!(update.candle.high, 3345.0); + assert_eq!(update.candle.low, 3290.2); + assert_eq!(update.candle.volume, 12450.8); + } + + #[test] + fn test_parse_open_orders() { + let json = include_bytes!("../../testdata/ws_open_orders.json"); + let HyperliquidSocketMessage::OpenOrders { orders } = parse_websocket_data(json).unwrap() else { + panic!("expected OpenOrders"); + }; + + assert_eq!(orders.len(), 2); + assert_eq!(orders[0].coin, "BTC"); + assert_eq!(orders[0].oid, 8804521338); + assert_eq!(orders[0].trigger_px, Some(110000.0)); + assert_eq!(orders[0].limit_px, Some(110000.0)); + assert!(orders[0].is_position_tpsl); + assert_eq!(orders[0].order_type, "Take Profit Market"); + assert_eq!(orders[1].coin, "BTC"); + assert_eq!(orders[1].oid, 8804521339); + assert_eq!(orders[1].trigger_px, Some(95000.0)); + assert_eq!(orders[1].limit_px, Some(95000.0)); + assert!(orders[1].is_position_tpsl); + assert_eq!(orders[1].order_type, "Stop Market"); + } + + #[test] + fn test_parse_clearinghouse_state() { + let json = include_bytes!("../../testdata/ws_clearinghouse_state.json"); + let HyperliquidSocketMessage::AccountState { balance, positions } = parse_websocket_data(json).unwrap() else { + panic!("expected AccountState"); + }; + + assert_eq!(balance.available, 15230.5 - 830.5); + assert_eq!(balance.reserved, 830.5); + assert_eq!(balance.withdrawable, 14400.0); + + assert_eq!(positions.len(), 1); + let pos = &positions[0]; + assert_eq!(pos.id, "0xc64cc00b46150e2681a6c0e57b4b12fd2b68fbc4_ETH"); + assert_eq!(pos.asset_id.chain, Chain::HyperCore); + assert_eq!(pos.size, 2.5); + assert_eq!(pos.size_value, 8305.0); + assert_eq!(pos.leverage, 10); + assert_eq!(pos.entry_price, 3200.0); + assert_eq!(pos.liquidation_price, Some(2850.5)); + assert_eq!(pos.margin_type, PerpetualMarginType::Cross); + assert_eq!(pos.direction, PerpetualDirection::Long); + assert_eq!(pos.margin_amount, 830.5); + assert_eq!(pos.pnl, 305.0); + assert_eq!(pos.funding, Some(-1.82)); + assert_eq!(pos.take_profit, None); + assert_eq!(pos.stop_loss, None); + } +} diff --git a/core/crates/gem_hypercore/src/rpc/client.rs b/core/crates/gem_hypercore/src/rpc/client.rs new file mode 100644 index 0000000000..bc82014e63 --- /dev/null +++ b/core/crates/gem_hypercore/src/rpc/client.rs @@ -0,0 +1,331 @@ +use crate::models::{ + balance::{Balances, DelegationBalance, StakeBalance, Validator}, + candlestick::Candlestick, + metadata::HypercoreMetadataResponse, + order::{OpenOrder, UserFill}, + perp_dex::PerpDex, + portfolio::HypercorePortfolioResponse, + position::AssetPositions, + referral::Referral, + response::ExplorerTransactionResponse, + spot::{OrderbookResponse, SpotMeta}, + user::{AgentSession, DelegatorHistoryUpdate, LedgerUpdate, UserAbstractionMode, UserFee, UserRole}, +}; +use chain_traits::ChainTraits; +use gem_client::{CONTENT_TYPE, Client, ClientExt, ContentType}; +use primitives::InMemoryPreferences; +use serde::de::DeserializeOwned; +use std::{collections::HashMap, error::Error, sync::Arc}; + +use crate::config::HypercoreConfig; +use gem_client::X_CACHE_TTL; +use primitives::{Chain, Preferences}; +use serde_json::json; + +const SPOT_META_CACHE_TTL_SECS: u64 = 3600; +const USER_ABSTRACTION_CACHE_TTL_SECS: u64 = 3600; +const EXPLORER_PATH: &str = "/explorer"; +const TRANSACTION_SENDER_CACHE_PREFIX: &str = "hypercore_transaction_sender_"; +pub(crate) const AGENT_OWNER_CACHE_PREFIX: &str = "hypercore_agent_owner_"; + +fn info_cache_headers(ttl_secs: u64) -> HashMap { + HashMap::from([ + (String::from(CONTENT_TYPE), ContentType::ApplicationJson.as_str().to_string()), + (String::from(X_CACHE_TTL), ttl_secs.to_string()), + ]) +} + +fn transaction_sender_cache_key(id: &str) -> String { + format!("{TRANSACTION_SENDER_CACHE_PREFIX}{id}") +} + +pub(crate) fn agent_owner_cache_key(agent_address: &str) -> String { + format!("{AGENT_OWNER_CACHE_PREFIX}{}", agent_address.to_lowercase()) +} + +pub struct HyperCoreClient { + client: C, + pub chain: Chain, + pub config: HypercoreConfig, + pub preferences: Arc, + pub secure_preferences: Arc, +} + +impl std::fmt::Debug for HyperCoreClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HyperCoreClient") + .field("chain", &self.chain) + .field("config", &self.config) + .field("preferences", &"") + .field("secure_preferences", &"") + .finish() + } +} + +impl HyperCoreClient { + pub fn new(client: C) -> Self { + let preferences = Arc::new(InMemoryPreferences::new()); + let secure_preferences = Arc::new(InMemoryPreferences::new()); + Self { + client, + chain: Chain::HyperCore, + config: HypercoreConfig::default(), + preferences, + secure_preferences, + } + } + + pub fn new_with_preferences(client: C, preferences: Arc, secure_preferences: Arc) -> Self { + Self { + client, + chain: Chain::HyperCore, + config: HypercoreConfig::default(), + preferences, + secure_preferences, + } + } + + async fn info(&self, payload: serde_json::Value) -> Result> + where + T: DeserializeOwned + Send, + { + Ok(self.client.post("/info", &payload).await?) + } + + async fn info_with_cache(&self, payload: serde_json::Value, ttl_secs: u64) -> Result> + where + T: DeserializeOwned + Send, + { + Ok(self.client.post_with_headers("/info", &payload, info_cache_headers(ttl_secs)).await?) + } + + pub async fn exchange(&self, payload: serde_json::Value) -> Result> { + Ok(self.client.post("/exchange", &payload).await?) + } + + pub async fn get_transaction_details(&self, hash: &str) -> Result> { + Ok(self.client.post(EXPLORER_PATH, &json!({ "type": "txDetails", "hash": hash })).await?) + } + + pub async fn get_validators(&self) -> Result, Box> { + self.info(json!({"type": "validatorSummaries"})).await + } + + pub async fn get_staking_delegations(&self, user: &str) -> Result, Box> { + self.info(json!({"type": "delegations", "user": user})).await + } + + pub async fn get_staking_apy(&self) -> Result> { + let validators = self.get_validators().await?; + Ok(Validator::max_apr(validators)) + } + + pub async fn get_spot_balances(&self, user: &str) -> Result> { + self.info(json!({ + "type": "spotClearinghouseState", + "user": user + })) + .await + } + + pub async fn get_stake_balance(&self, user: &str) -> Result> { + self.info(json!({ + "type": "delegatorSummary", + "user": user + })) + .await + } + + pub async fn get_user_fills_by_time(&self, user: &str, start_time: i64) -> Result, Box> { + self.info(json!({ + "type": "userFillsByTime", + "user": user, + "startTime": start_time + })) + .await + } + + pub async fn get_clearinghouse_state(&self, user: &str) -> Result> { + self.info(json!({"type": "clearinghouseState", "user": user})).await + } + + pub async fn get_clearinghouse_state_with_dex(&self, user: &str, dex: &str) -> Result> { + self.info(json!({"type": "clearinghouseState", "user": user, "dex": dex})).await + } + + pub async fn get_metadata(&self) -> Result> { + self.info(json!({"type": "metaAndAssetCtxs"})).await + } + + pub async fn get_metadata_with_dex(&self, dex: &str) -> Result> { + self.info(json!({"type": "metaAndAssetCtxs", "dex": dex})).await + } + + pub async fn get_perp_dexs(&self) -> Result>, Box> { + self.info(json!({"type": "perpDexs"})).await + } + + pub async fn get_spot_meta(&self) -> Result> { + self.info_with_cache(json!({ "type": "spotMeta" }), SPOT_META_CACHE_TTL_SECS).await + } + + pub async fn get_spot_orderbook(&self, coin: &str) -> Result> { + let response = self.info(json!({ "type": "l2Book", "coin": coin })).await?; + Ok(serde_json::from_value(response)?) + } + + pub async fn get_candlesticks(&self, coin: &str, interval: &str, start_time: i64, end_time: i64) -> Result, Box> { + self.info(json!({ + "type": "candleSnapshot", + "req": { + "coin": coin, + "interval": interval, + "startTime": start_time, + "endTime": end_time + } + })) + .await + } + + pub async fn get_user_role(&self, user: &str) -> Result> { + self.info(json!({ + "type": "userRole", + "user": user + })) + .await + } + + pub async fn get_user_abstraction(&self, user: &str) -> Result> { + self.info_with_cache( + json!({ + "type": "userAbstraction", + "user": user + }), + USER_ABSTRACTION_CACHE_TTL_SECS, + ) + .await + } + + pub async fn get_referral(&self, user: &str) -> Result> { + self.info(json!({ + "type": "referral", + "user": user + })) + .await + } + + pub async fn get_extra_agents(&self, user: &str) -> Result, Box> { + self.info(json!({ + "type": "extraAgents", + "user": user + })) + .await + } + + pub async fn get_builder_fee(&self, user: &str, builder: &str) -> Result> { + self.info(json!({ + "type": "maxBuilderFee", + "user": user, + "builder": builder + })) + .await + } + + pub async fn get_user_fees(&self, user: &str) -> Result> { + self.info(json!({ + "type": "userFees", + "user": user + })) + .await + } + + pub async fn get_ledger_updates(&self, user: &str, start_time: i64) -> Result, Box> { + self.info(json!({ + "type": "userNonFundingLedgerUpdates", + "user": user, + "startTime": start_time + })) + .await + } + + pub async fn get_delegator_history(&self, user: &str) -> Result, Box> { + self.info(json!({ + "type": "delegatorHistory", + "user": user + })) + .await + } + + pub async fn get_open_orders(&self, user: &str) -> Result, Box> { + self.info(json!({"type": "frontendOpenOrders", "user": user})).await + } + + pub async fn get_open_orders_with_dex(&self, user: &str, dex: &str) -> Result, Box> { + self.info(json!({"type": "frontendOpenOrders", "user": user, "dex": dex})).await + } + + pub async fn get_perpetual_portfolio(&self, user: &str) -> Result> { + self.info(json!({"type": "portfolio", "user": user})).await + } + + pub async fn get_perpetual_portfolio_with_dex(&self, user: &str, dex: &str) -> Result> { + self.info(json!({"type": "portfolio", "user": user, "dex": dex})).await + } + + pub fn cache_transaction_sender(&self, id: &str, sender: &str) -> Result<(), Box> { + self.preferences.set(transaction_sender_cache_key(id), sender.to_lowercase()) + } + + pub fn get_cached_transaction_sender(&self, id: &str) -> Result, Box> { + self.preferences.get(transaction_sender_cache_key(id)) + } + + pub fn cache_agent_owner(&self, agent_address: &str, sender_address: &str) -> Result<(), Box> { + self.preferences.set(agent_owner_cache_key(agent_address), sender_address.to_lowercase()) + } + + pub fn get_cached_agent_owner(&self, agent_address: &str) -> Result, Box> { + self.preferences.get(agent_owner_cache_key(agent_address)) + } +} + +impl ChainTraits for HyperCoreClient {} + +impl chain_traits::ChainProvider for HyperCoreClient { + fn get_chain(&self) -> primitives::Chain { + Chain::HyperCore + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_client::testkit::MockClient; + use std::sync::Mutex; + + #[tokio::test] + async fn test_get_user_abstraction_sets_cache_ttl_header() { + let seen_headers = Arc::new(Mutex::new(Vec::new())); + let seen_headers_clone = Arc::clone(&seen_headers); + let client = MockClient::new().with_post_with_headers(move |path, body, headers| { + assert_eq!(path, "/info"); + let request: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!( + request, + json!({ + "type": "userAbstraction", + "user": "0x123" + }) + ); + seen_headers_clone.lock().unwrap().push(headers.clone()); + Ok(br#""default""#.to_vec()) + }); + let client = HyperCoreClient::new(client); + + let mode = client.get_user_abstraction("0x123").await.unwrap(); + let recorded_headers = seen_headers.lock().unwrap().clone(); + + assert_eq!(mode, UserAbstractionMode::Default); + assert_eq!(recorded_headers, vec![info_cache_headers(USER_ABSTRACTION_CACHE_TTL_SECS)]); + } +} diff --git a/core/crates/gem_hypercore/src/rpc/mod.rs b/core/crates/gem_hypercore/src/rpc/mod.rs new file mode 100644 index 0000000000..b9babe5bc1 --- /dev/null +++ b/core/crates/gem_hypercore/src/rpc/mod.rs @@ -0,0 +1 @@ +pub mod client; diff --git a/core/crates/gem_hypercore/src/signer/core_signer.rs b/core/crates/gem_hypercore/src/signer/core_signer.rs new file mode 100644 index 0000000000..72cbebac9f --- /dev/null +++ b/core/crates/gem_hypercore/src/signer/core_signer.rs @@ -0,0 +1,525 @@ +use ::signer::Signer; +use alloy_primitives::hex; +use number_formatter::BigNumberFormatter; +use primitives::{ + ChainSigner, HyperliquidOrder, NumberIncrementer, PerpetualConfirmData, PerpetualDirection, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualType, + SignerError, SignerInput, TransactionInputType, asset_constants::HYPERCORE_CORE_HYPE_TOKEN_ID, decode_hex, stake_type::StakeType, +}; +use serde::Serialize; +use serde_json::{self, Value}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{ + core::{ + actions::{ + ApproveAgent, ApproveBuilderFee, Builder, CDeposit, CWithdraw, Cancel, CancelOrder, PlaceOrder, SetReferrer, SpotSend, TokenDelegate, UpdateLeverage, + WithdrawalRequest, make_market_order, make_position_tp_sl, + }, + hypercore::{ + approve_agent_typed_data, approve_builder_fee_typed_data, c_deposit_typed_data, c_withdraw_typed_data, cancel_order_typed_data, place_order_typed_data, + send_spot_token_to_address_typed_data, set_referrer_typed_data, token_delegate_typed_data, update_leverage_typed_data, withdrawal_request_typed_data, + }, + }, + is_spot_swap, + models::timestamp::TimestampField, +}; + +const AGENT_NAME_PREFIX: &str = "gemwallet_"; +const REFERRAL_CODE: &str = "GEMWALLET"; +const BUILDER_ADDRESS: &str = "0x0d9dab1a248f63b0a48965ba8435e4de7497a3dc"; + +type SignerResult = Result; + +#[derive(Default)] +pub struct HyperCoreSigner; + +impl HyperCoreSigner { + fn sign_transfer_action(&self, input: &SignerInput, private_key: &[u8]) -> SignerResult { + let asset = input.input_type.get_asset(); + let amount = BigNumberFormatter::value(&input.value, asset.decimals).map_err(|err| SignerError::InvalidInput(err.to_string()))?; + self.sign_spot_send(&amount, &input.destination_address, HYPERCORE_CORE_HYPE_TOKEN_ID, private_key) + } + + fn sign_approval_transactions(&self, order: &HyperliquidOrder, private_key: &[u8], timestamp_incrementer: &mut NumberIncrementer) -> SignerResult> { + let mut transactions = Vec::new(); + + if order.approve_referral_required { + transactions.push(self.sign_set_referrer(private_key, REFERRAL_CODE, timestamp_incrementer.next_val())?); + } + if order.approve_agent_required { + transactions.push(self.sign_approve_agent(&order.agent_address, private_key, timestamp_incrementer.next_val())?); + } + if order.approve_builder_required { + transactions.push(self.sign_approve_builder_address(private_key, BUILDER_ADDRESS, order.builder_fee_bps, timestamp_incrementer.next_val())?); + } + + Ok(transactions) + } + + fn sign_token_transfer_action(&self, input: &SignerInput, private_key: &[u8]) -> SignerResult { + let asset = input.input_type.get_asset(); + let amount = BigNumberFormatter::value(&input.value, asset.decimals).map_err(|err| SignerError::InvalidInput(err.to_string()))?; + let token_id = asset.id.get_token_id()?; + self.sign_spot_send(&amount, &input.destination_address, token_id, private_key) + } + + fn sign_swap_action(&self, input: &SignerInput, private_key: &[u8]) -> SignerResult> { + let swap_data = input.input_type.get_swap_data().map_err(SignerError::invalid_input)?; + + if let TransactionInputType::Swap(from_asset, to_asset, _) = &input.input_type + && is_spot_swap(from_asset.chain(), to_asset.chain()) + { + let hl_order = input.metadata.get_hyperliquid_order()?; + let agent_key = decode_hex(&hl_order.agent_private_key).map_err(|_| SignerError::InvalidInput("Invalid agent private key".to_string()))?; + let builder = get_builder(BUILDER_ADDRESS, hl_order.builder_fee_bps as i32).ok(); + + let mut order: PlaceOrder = serde_json::from_str(&swap_data.data.data)?; + order.builder = builder; + + let mut timestamp_incrementer = NumberIncrementer::new(Self::timestamp_ms()); + let mut transactions = self.sign_approval_transactions(hl_order, private_key, &mut timestamp_incrementer)?; + + transactions.push(self.sign_place_order(order, timestamp_incrementer.next_val(), &agent_key)?); + return Ok(transactions); + } + + let signature = self.sign_typed_action(&swap_data.data.data, private_key)?; + Ok(vec![signature]) + } + + fn sign_stake_action(&self, input: &SignerInput, private_key: &[u8]) -> SignerResult> { + let stake_type = input.input_type.get_stake_type().map_err(SignerError::invalid_input)?; + let mut nonce_incrementer = NumberIncrementer::new(Self::timestamp_ms()); + + match stake_type { + StakeType::Stake(validator) => { + let wei = BigNumberFormatter::value_as_u64(&input.value, 0).map_err(|err| SignerError::InvalidInput(err.to_string()))?; + + let deposit_request = CDeposit::new(wei, nonce_incrementer.next_val()); + let deposit_action = self.sign_c_deposit(deposit_request, private_key)?; + + let delegate_request = TokenDelegate::new(validator.id.clone(), wei, false, nonce_incrementer.next_val()); + let delegate_action = self.sign_token_delegate(delegate_request, private_key)?; + Ok(vec![deposit_action, delegate_action]) + } + StakeType::Unstake(delegation) => { + let wei = BigNumberFormatter::value_as_u64(&input.value, 0).map_err(|err| SignerError::InvalidInput(err.to_string()))?; + + let undelegate_request = TokenDelegate::new(delegation.validator.id.clone(), wei, true, nonce_incrementer.next_val()); + let undelegate_action = self.sign_token_delegate(undelegate_request, private_key)?; + + let withdraw_request = CWithdraw::new(wei, nonce_incrementer.next_val()); + let withdraw_action = self.sign_c_withdraw(withdraw_request, private_key)?; + Ok(vec![undelegate_action, withdraw_action]) + } + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => { + Err(SignerError::SigningError("Stake type not supported".to_string())) + } + } + } + + fn sign_perpetual_action(&self, input: &SignerInput, private_key: &[u8]) -> SignerResult> { + let perpetual_type = input.input_type.get_perpetual_type().map_err(SignerError::invalid_input)?; + let order = input.metadata.get_hyperliquid_order()?; + + let agent_key = decode_hex(&order.agent_private_key).map_err(|_| SignerError::InvalidInput("Invalid agent private key".to_string()))?; + let builder = get_builder(BUILDER_ADDRESS, order.builder_fee_bps as i32).ok(); + let mut timestamp_incrementer = NumberIncrementer::new(Self::timestamp_ms()); + + let mut transactions = self.sign_approval_transactions(order, private_key, &mut timestamp_incrementer)?; + transactions.extend(self.sign_market_message(perpetual_type, agent_key.as_slice(), builder.as_ref(), &mut timestamp_incrementer)?); + + Ok(transactions) + } + + fn sign_typed_action(&self, typed_data_json: &str, private_key: &[u8]) -> SignerResult { + let typed_data: Value = serde_json::from_str(typed_data_json).map_err(|err| SignerError::InvalidInput(format!("Invalid typed data JSON: {err}")))?; + + let message = typed_data + .get("message") + .ok_or_else(|| SignerError::InvalidInput("Typed data missing message field".to_string()))?; + + let timestamp = serde_json::from_value::(message.clone()).map_err(|err| SignerError::InvalidInput(format!("Failed to parse time or nonce: {err}")))?; + let action = serde_json::to_string(message).map_err(|err| SignerError::InvalidInput(format!("Failed to serialize action payload: {err}")))?; + + self.sign_action(typed_data_json, &action, timestamp.value, private_key) + } + + fn sign_approve_agent(&self, agent_address: &str, private_key: &[u8], timestamp: u64) -> SignerResult { + let agent_name = format!("{}{}", AGENT_NAME_PREFIX, &agent_address[agent_address.len().saturating_sub(6)..]); + let agent = ApproveAgent::new(agent_address.to_string(), agent_name, timestamp); + self.sign_serialized_action(agent, timestamp, private_key, approve_agent_typed_data, "approve agent") + } + + fn sign_approve_builder_address(&self, agent_key: &[u8], builder_address: &str, rate_bps: u32, timestamp: u64) -> SignerResult { + let max_fee_rate = fee_rate(rate_bps); + let request = ApproveBuilderFee::new(max_fee_rate, builder_address.to_string(), timestamp); + self.sign_serialized_action(request, timestamp, agent_key, approve_builder_fee_typed_data, "approve builder fee") + } + + fn sign_set_referrer(&self, agent_key: &[u8], code: &str, timestamp: u64) -> SignerResult { + let referer = SetReferrer::new(code.to_string()); + self.sign_serialized_action(referer, timestamp, agent_key, |value| set_referrer_typed_data(value, timestamp), "set referrer") + } + + fn sign_spot_send(&self, amount: &str, destination: &str, token: &str, private_key: &[u8]) -> SignerResult { + let timestamp = Self::timestamp_ms(); + let spot_send = SpotSend::new(amount.to_string(), destination.to_string(), timestamp, token.to_string()); + self.sign_serialized_action(spot_send, timestamp, private_key, send_spot_token_to_address_typed_data, "spot send") + } + + fn sign_c_deposit(&self, deposit: CDeposit, private_key: &[u8]) -> SignerResult { + let timestamp = deposit.nonce; + self.sign_serialized_action(deposit, timestamp, private_key, c_deposit_typed_data, "c deposit") + } + + fn sign_c_withdraw(&self, withdraw: CWithdraw, private_key: &[u8]) -> SignerResult { + let timestamp = withdraw.nonce; + self.sign_serialized_action(withdraw, timestamp, private_key, c_withdraw_typed_data, "c withdraw") + } + + fn sign_token_delegate(&self, delegate: TokenDelegate, private_key: &[u8]) -> SignerResult { + let timestamp = delegate.nonce; + self.sign_serialized_action(delegate, timestamp, private_key, token_delegate_typed_data, "token delegate") + } + + fn sign_update_leverage(&self, update_leverage: UpdateLeverage, nonce: u64, private_key: &[u8]) -> SignerResult { + self.sign_serialized_action(update_leverage, nonce, private_key, |value| update_leverage_typed_data(value, nonce), "update leverage") + } + + fn sign_market_message( + &self, + perpetual_type: &PerpetualType, + agent_key: &[u8], + builder: Option<&Builder>, + timestamp_incrementer: &mut NumberIncrementer, + ) -> SignerResult> { + let (data, is_open) = match perpetual_type { + PerpetualType::Modify(modify_data) => return self.sign_modify_orders(modify_data, agent_key, builder, timestamp_incrementer), + PerpetualType::Open(data) => return self.sign_open_orders(data, agent_key, builder, timestamp_incrementer), + PerpetualType::Increase(data) => (data, true), + PerpetualType::Close(data) => (data, false), + PerpetualType::Reduce(reduce_data) => (&reduce_data.data, false), + }; + + let order = Self::market_order_from_confirm_data(data, is_open, builder); + Ok(vec![self.sign_place_order(order, timestamp_incrementer.next_val(), agent_key)?]) + } + + fn sign_open_orders( + &self, + data: &PerpetualConfirmData, + agent_key: &[u8], + builder: Option<&Builder>, + timestamp_incrementer: &mut NumberIncrementer, + ) -> SignerResult> { + let is_buy = data.direction == PerpetualDirection::Long; + let asset = data.asset_index as u32; + + let leverage = self.sign_update_leverage( + UpdateLeverage::from_margin_type(asset, &data.margin_type, data.leverage), + timestamp_incrementer.next_val(), + agent_key, + )?; + let market = self.sign_place_order( + make_market_order(asset, is_buy, &data.price, &data.size, false, builder.cloned()), + timestamp_incrementer.next_val(), + agent_key, + )?; + + let tpsl = match (data.take_profit.as_ref(), data.stop_loss.as_ref()) { + (None, None) => None, + _ => { + let order = make_position_tp_sl(asset, is_buy, "0", data.take_profit.clone(), data.stop_loss.clone(), builder.cloned(), true); + Some(self.sign_place_order(order, timestamp_incrementer.next_val(), agent_key)?) + } + }; + + Ok(vec![leverage, market].into_iter().chain(tpsl).collect()) + } + + fn sign_modify_orders( + &self, + modify_data: &PerpetualModifyConfirmData, + agent_key: &[u8], + builder: Option<&Builder>, + timestamp_incrementer: &mut NumberIncrementer, + ) -> SignerResult> { + modify_data + .modify_types + .iter() + .map(|modify_type| match modify_type { + PerpetualModifyPositionType::Cancel(orders) => { + let cancels = orders.iter().map(|o| CancelOrder::new(o.asset_index as u32, o.order_id)).collect(); + self.sign_cancel_order(Cancel::new(cancels), timestamp_incrementer.next_val(), agent_key) + } + PerpetualModifyPositionType::Tpsl(tpsl) => { + let order = make_position_tp_sl( + modify_data.asset_index as u32, + tpsl.direction == PerpetualDirection::Long, + &tpsl.size, + tpsl.take_profit.clone(), + tpsl.stop_loss.clone(), + builder.cloned(), + true, + ); + self.sign_place_order(order, timestamp_incrementer.next_val(), agent_key) + } + }) + .collect() + } + + fn market_order_from_confirm_data(data: &PerpetualConfirmData, is_open: bool, builder: Option<&Builder>) -> PlaceOrder { + let is_buy = if is_open { + data.direction == PerpetualDirection::Long + } else { + data.direction == PerpetualDirection::Short + }; + make_market_order(data.asset_index as u32, is_buy, &data.price, &data.size, !is_open, builder.cloned()) + } + + fn sign_place_order(&self, order: PlaceOrder, nonce: u64, private_key: &[u8]) -> SignerResult { + self.sign_serialized_action(order, nonce, private_key, |value| place_order_typed_data(value, nonce), "place order") + } + + fn sign_cancel_order(&self, cancel: Cancel, nonce: u64, private_key: &[u8]) -> SignerResult { + self.sign_serialized_action(cancel, nonce, private_key, |value| cancel_order_typed_data(value, nonce), "cancel order") + } + + fn sign_action(&self, typed_data: &str, action: &str, timestamp: u64, private_key: &[u8]) -> SignerResult { + let signature = Signer::sign_eip712(typed_data, private_key).map_err(|err| SignerError::InvalidInput(format!("Failed to sign typed data: {err}")))?; + self.build_signed_request(signature, action, timestamp) + } + + fn sign_serialized_action(&self, value: T, timestamp: u64, private_key: &[u8], typed_data_fn: F, action_name: &'static str) -> SignerResult + where + T: Serialize, + F: FnOnce(T) -> String, + { + let action = serde_json::to_string(&value).map_err(|err| SignerError::InvalidInput(format!("Failed to serialize {action_name} action: {err}")))?; + let typed_data = typed_data_fn(value); + self.sign_action(&typed_data, &action, timestamp, private_key) + } + + fn build_signed_request(&self, signature: String, action: &str, timestamp: u64) -> SignerResult { + let sig_bytes = decode_hex(&signature).map_err(|err| SignerError::InvalidInput(format!("Invalid signature hex: {err}")))?; + + if sig_bytes.len() < 65 { + return Err(SignerError::InvalidInput("Signature must be 65 bytes".to_string())); + } + + let r = hex::encode_prefixed(&sig_bytes[0..32]); + let s = hex::encode_prefixed(&sig_bytes[32..64]); + let v = sig_bytes[64] as u64; + + let action_json: Value = serde_json::from_str(action).map_err(|err| SignerError::InvalidInput(format!("Invalid action JSON: {err}")))?; + + let signed_request = serde_json::json!({ + "action": action_json, + "signature": { + "r": r, + "s": s, + "v": v + }, + "nonce": timestamp, + "isFrontend": true + }); + + serde_json::to_string(&signed_request).map_err(|err| SignerError::InvalidInput(format!("Failed to serialize signed request: {err}"))) + } + + fn timestamp_ms() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 + } +} + +impl ChainSigner for HyperCoreSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + self.sign_transfer_action(input, private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + self.sign_token_transfer_action(input, private_key) + } + + fn sign_nft_transfer(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("NFT transfer not supported".to_string())) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + self.sign_swap_action(input, private_key) + } + + fn sign_token_approval(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("Token approval not supported".to_string())) + } + + fn sign_stake(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + self.sign_stake_action(input, private_key) + } + + fn sign_account_action(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("Account action not supported".to_string())) + } + + fn sign_perpetual(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + self.sign_perpetual_action(input, private_key) + } + + fn sign_withdrawal(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let asset = input.input_type.get_asset(); + let amount = BigNumberFormatter::value(&input.value, asset.decimals).map_err(|err| SignerError::InvalidInput(err.to_string()))?; + let timestamp = Self::timestamp_ms(); + + let withdrawal_request = WithdrawalRequest::new(amount, timestamp, input.destination_address.clone()); + self.sign_serialized_action(withdrawal_request, timestamp, private_key, withdrawal_request_typed_data, "withdrawal") + } + + fn sign_data(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("Data signing not supported".to_string())) + } +} + +fn get_builder(builder: &str, fee: i32) -> Result { + if fee < 0 { + return Err(SignerError::InvalidInput("Builder fee cannot be negative".to_string())); + } + Ok(Builder { + builder_address: builder.to_string(), + fee: fee as u32, + }) +} + +fn fee_rate(tenths_bps: u32) -> String { + format!("{}%", (tenths_bps as f64) * 0.001) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::actions::Grouping; + use num_bigint::BigUint; + use primitives::{ + Asset, Chain, Delegation, DelegationBase, DelegationState, DelegationValidator, SignerInput, StakeType, TransactionFee, TransactionInputType, TransactionLoadInput, + }; + + #[test] + fn stake_actions_preserve_wei_and_nonces() { + let signer = HyperCoreSigner; + let asset = Asset::from_chain(Chain::HyperCore); + let validator = DelegationValidator::stake(Chain::HyperCore, "0x5ac99df645f3414876c816caa18b2d234024b487".into(), "Validator".into(), true, 0.0, 0.0); + let input = TransactionLoadInput { + value: "150000000".into(), + sender_address: "0xsender".into(), + destination_address: "".into(), + ..TransactionLoadInput::mock_with_input_type(TransactionInputType::Stake(asset.clone(), StakeType::Stake(validator))) + }; + let input = SignerInput::new(input, TransactionFee::default()); + let private_key = [2u8; 32]; + + let responses = signer.sign_stake_action(&input, &private_key).expect("should sign"); + assert_eq!(responses.len(), 2); + + let deposit: serde_json::Value = serde_json::from_str(&responses[0]).expect("json"); + let delegate: serde_json::Value = serde_json::from_str(&responses[1]).expect("json"); + + assert_eq!(deposit["action"]["type"], "cDeposit"); + assert_eq!(delegate["action"]["type"], "tokenDelegate"); + + let deposit_wei = deposit["action"]["wei"].as_u64().expect("deposit wei"); + let delegate_wei = delegate["action"]["wei"].as_u64().expect("delegate wei"); + assert_eq!(deposit_wei, 150000000); + assert_eq!(delegate_wei, 150000000); + + let deposit_nonce = deposit["action"]["nonce"].as_u64().expect("nonce"); + let delegate_nonce = delegate["action"]["nonce"].as_u64().expect("nonce"); + assert!(deposit_nonce < delegate_nonce); + } + + #[test] + fn unstake_uses_entered_amount_and_unique_nonces() { + let signer = HyperCoreSigner; + let asset = Asset::from_chain(Chain::HyperCore); + let delegation = Delegation { + base: DelegationBase { + asset_id: asset.id.clone(), + state: DelegationState::Active, + balance: BigUint::from(150_000_000u64), + shares: BigUint::from(0u64), + rewards: BigUint::from(0u64), + completion_date: None, + delegation_id: "delegation".into(), + validator_id: "validator".into(), + }, + validator: DelegationValidator::stake(Chain::HyperCore, "0x66be52ec79f829cc88e5778a255e2cb9492798fd".into(), "Validator".into(), true, 0.0, 0.0), + price: None, + }; + let input = TransactionLoadInput { + value: "60000000".into(), + sender_address: "0xsender".into(), + destination_address: "".into(), + ..TransactionLoadInput::mock_with_input_type(TransactionInputType::Stake(asset, StakeType::Unstake(delegation))) + }; + let input = SignerInput::new(input, TransactionFee::default()); + let private_key = [1u8; 32]; + + let responses = signer.sign_stake_action(&input, &private_key).expect("should sign"); + assert_eq!(responses.len(), 2); + + let undelegate: serde_json::Value = serde_json::from_str(&responses[0]).expect("json"); + let withdraw: serde_json::Value = serde_json::from_str(&responses[1]).expect("json"); + + assert_eq!(undelegate["action"]["type"], "tokenDelegate"); + assert_eq!(undelegate["action"]["isUndelegate"], true); + assert_eq!(withdraw["action"]["type"], "cWithdraw"); + + assert_eq!(undelegate["action"]["wei"].as_u64().expect("undelegate wei"), 60000000); + assert_eq!(withdraw["action"]["wei"].as_u64().expect("withdraw wei"), 60000000); + + let undelegate_nonce = undelegate["action"]["nonce"].as_u64().expect("nonce"); + let withdraw_nonce = withdraw["action"]["nonce"].as_u64().expect("nonce"); + assert!(undelegate_nonce < withdraw_nonce, "unstake actions should advance nonce"); + } + + #[test] + fn market_order_open_long() { + let data = PerpetualConfirmData::mock(PerpetualDirection::Long, 11, None, None); + let builder = Builder { + builder_address: "0xdeadbeef".to_string(), + fee: 25, + }; + let order = HyperCoreSigner::market_order_from_confirm_data(&data, true, Some(&builder)); + let market_order = &order.orders[0]; + + assert_eq!(order.orders.len(), 1); + assert_eq!(order.grouping, Grouping::Na); + assert!(market_order.is_buy); + assert!(!market_order.reduce_only); + assert_eq!(market_order.asset, data.asset_index as u32); + assert_eq!(market_order.size, data.size); + + let order_builder = order.builder.expect("builder"); + assert_eq!(order_builder.builder_address, builder.builder_address); + assert_eq!(order_builder.fee, builder.fee); + } + + #[test] + fn market_order_close_short() { + let data = PerpetualConfirmData::mock(PerpetualDirection::Short, 5, None, None); + let order = HyperCoreSigner::market_order_from_confirm_data(&data, false, None); + let market_order = &order.orders[0]; + + assert!(market_order.is_buy); + assert!(market_order.reduce_only); + } + + #[test] + fn market_order_open_short() { + let data = PerpetualConfirmData::mock(PerpetualDirection::Short, 9, None, None); + let order = HyperCoreSigner::market_order_from_confirm_data(&data, true, None); + let market_order = &order.orders[0]; + + assert!(!market_order.is_buy); + assert!(!market_order.reduce_only); + } +} diff --git a/core/crates/gem_hypercore/src/signer/mod.rs b/core/crates/gem_hypercore/src/signer/mod.rs new file mode 100644 index 0000000000..9b32a70495 --- /dev/null +++ b/core/crates/gem_hypercore/src/signer/mod.rs @@ -0,0 +1,3 @@ +mod core_signer; + +pub use core_signer::HyperCoreSigner; diff --git a/core/crates/gem_hypercore/src/testkit.rs b/core/crates/gem_hypercore/src/testkit.rs new file mode 100644 index 0000000000..bddd157a9a --- /dev/null +++ b/core/crates/gem_hypercore/src/testkit.rs @@ -0,0 +1,100 @@ +pub use crate::models::metadata::{AssetMetadata, UniverseAsset}; +pub use crate::models::order::OpenOrder; +pub use crate::models::portfolio::{HypercoreDataPoint, HypercorePortfolioResponse, HypercorePortfolioTimeframeData}; +pub use crate::models::position::{AssetPositions, MarginSummary}; +#[cfg(test)] +use crate::rpc::client::HyperCoreClient; +#[cfg(test)] +use gem_client::testkit::MockClient; +#[cfg(test)] +use primitives::InMemoryPreferences; +#[cfg(test)] +use std::sync::Arc; + +impl AssetPositions { + pub fn mock() -> Self { + Self { + asset_positions: vec![], + margin_summary: MarginSummary { + account_value: "10000".to_string(), + total_ntl_pos: "5000".to_string(), + total_raw_usd: "5000".to_string(), + total_margin_used: "2000".to_string(), + }, + cross_margin_summary: MarginSummary { + account_value: "10000".to_string(), + total_ntl_pos: "5000".to_string(), + total_raw_usd: "5000".to_string(), + total_margin_used: "2000".to_string(), + }, + cross_maintenance_margin_used: "1000".to_string(), + withdrawable: "8000".to_string(), + } + } +} + +impl OpenOrder { + pub fn mock(coin: &str, oid: u64, order_type: &str, trigger_px: f64, limit_px: Option) -> Self { + Self { + coin: coin.to_string(), + oid, + trigger_px: Some(trigger_px), + limit_px, + is_position_tpsl: true, + order_type: order_type.to_string(), + } + } +} + +impl HypercorePortfolioTimeframeData { + pub fn mock(vlm: &str) -> Self { + Self { + account_value_history: vec![HypercoreDataPoint { + timestamp_ms: 1640995200000, + value: 1000.0, + }], + pnl_history: vec![HypercoreDataPoint { + timestamp_ms: 1640995200000, + value: 50.0, + }], + vlm: vlm.to_string(), + } + } +} + +impl UniverseAsset { + pub fn mock() -> Self { + Self { + name: "ETH".to_string(), + sz_decimals: 4, + max_leverage: 50, + only_isolated: None, + } + } +} + +impl AssetMetadata { + pub fn mock() -> Self { + Self { + funding: "0.0005".to_string(), + open_interest: "2500.5".to_string(), + prev_day_px: "2000".to_string(), + day_ntl_vlm: "500000".to_string(), + premium: None, + oracle_px: "2100".to_string(), + mark_px: "2105.25".to_string(), + mid_px: Some("2102.5".to_string()), + impact_pxs: None, + day_base_vlm: "250000".to_string(), + } + } +} + +#[cfg(test)] +impl HyperCoreClient { + pub fn mock() -> Self { + let preferences = Arc::new(InMemoryPreferences::new()); + let secure_preferences = Arc::new(InMemoryPreferences::new()); + Self::new_with_preferences(MockClient::new(), preferences, secure_preferences) + } +} diff --git a/core/crates/gem_hypercore/testdata/delegator_history_staking_actions.json b/core/crates/gem_hypercore/testdata/delegator_history_staking_actions.json new file mode 100644 index 0000000000..05417bd66f --- /dev/null +++ b/core/crates/gem_hypercore/testdata/delegator_history_staking_actions.json @@ -0,0 +1,43 @@ +[ + { + "time": 1780081714469, + "hash": "0x945b910697cd885a95d5043c857c0d0201b300ec32c0a72c38243c5956c16245", + "delta": { + "cDeposit": { + "amount": "0.01" + } + } + }, + { + "time": 1780081715281, + "hash": "0x0cfde0fb239ef8630e77043c857c1502025000e0be921735b0c68c4de292d24d", + "delta": { + "delegate": { + "validator": "0x3e5b2598a32ebf003ad5a7254faa3d04ff41d9fe", + "amount": "0.01", + "isUndelegate": false + } + } + }, + { + "time": 1780078270596, + "hash": "0x7b435a1210afafef7cbd043c84b8d402064e00f7aba2cec11f0c0564cfa389da", + "delta": { + "withdrawal": { + "amount": "0.03001423", + "phase": "initiated" + } + } + }, + { + "time": 1780078269817, + "hash": "0xc24f99bd90d6d68ac3c9043c84b8c90201c000a32bd9f55c661845104fdab075", + "delta": { + "delegate": { + "validator": "0x000000000056f99d36b6f2e0c51fd41496bbacb8", + "amount": "0.03001423", + "isUndelegate": true + } + } + } +] diff --git a/core/crates/gem_hypercore/testdata/hl_action_c_withdraw.json b/core/crates/gem_hypercore/testdata/hl_action_c_withdraw.json new file mode 100644 index 0000000000..8250841a28 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_c_withdraw.json @@ -0,0 +1,16 @@ +{ + "action": { + "hyperliquidChain": "Mainnet", + "nonce": 1758983015647, + "signatureChainId": "0xa4b1", + "type": "cWithdraw", + "wei": 10000000 + }, + "isFrontend": true, + "nonce": 1758983015647, + "signature": { + "r": "0xda78bc92a6eaf69b2bbdffc2ddea23f2830abfbd7feb00512ad07f7f2f5704ea", + "s": "0x06ae965ab100c18ba2987a213b871555ede908680eec570041fc80d5cb00fb0e", + "v": 28 + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_action_cancel_orders.json b/core/crates/gem_hypercore/testdata/hl_action_cancel_orders.json new file mode 100644 index 0000000000..60e5445928 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_cancel_orders.json @@ -0,0 +1,22 @@ +{ + "action": { + "cancels": [ + { + "a": 0, + "o": 133614972850 + }, + { + "a": 7, + "o": 133610221604 + } + ], + "type": "cancel" + }, + "isFrontend": true, + "nonce": 1755132902800, + "signature": { + "r": "0x6d7f8feddf09ac204b786ff82a508134b28ba7d91ed412fef5ae0b8561ea26d3", + "s": "0x1c31d3653a2ef71334e733cc81a541db2982724c785002f82e634c71c64726b0", + "v": 27 + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_action_core_to_evm.json b/core/crates/gem_hypercore/testdata/hl_action_core_to_evm.json new file mode 100644 index 0000000000..33ddc5f04a --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_core_to_evm.json @@ -0,0 +1,18 @@ +{ + "action": { + "amount": "0.1", + "destination": "0x2222222222222222222222222222222222222222", + "hyperliquidChain": "Mainnet", + "signatureChainId": "0xa4b1", + "time": 1754996222238, + "token": "HYPE:0x0d01dc56dcaaca66ad901c959b4011ec", + "type": "spotSend" + }, + "isFrontend": true, + "nonce": 1754996222238, + "signature": { + "r": "0x01df5d20fb1d09eed99ccf2381c1fc00e21538fcfa0babfe523bf094bb292f08", + "s": "0x25b3c888612fc55a9fdbed92828aa3c64a8c25e9b71da81b59e6fd5ddfddf684", + "v": 27 + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_action_market_short_tp_sl.json b/core/crates/gem_hypercore/testdata/hl_action_market_short_tp_sl.json new file mode 100644 index 0000000000..8ea300278f --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_market_short_tp_sl.json @@ -0,0 +1,55 @@ +{ + "action": { + "grouping": "normalTpsl", + "orders": [ + { + "a": 25, + "b": false, + "p": "3.0535", + "r": false, + "s": "5", + "t": { + "limit": { + "tif": "FrontendMarket" + } + } + }, + { + "a": 25, + "b": true, + "p": "3.78", + "r": true, + "s": "5", + "t": { + "trigger": { + "isMarket": true, + "tpsl": "sl", + "triggerPx": "3.5" + } + } + }, + { + "a": 25, + "b": true, + "p": "3.24", + "r": true, + "s": "5", + "t": { + "trigger": { + "isMarket": true, + "tpsl": "tp", + "triggerPx": "3" + } + } + } + ], + "type": "order" + }, + "isFrontend": true, + "nonce": 1755135350327, + "signature": { + "r": "0xd49f4af2a7a7037008a3fffd072914b509a685b8e3fc8c08450ff47e300b14cc", + "s": "0x1716da99ec62e121d97aac2f44c24fcdd4b64bf18ab11afe469c006552317ba6", + "v": 28 + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_action_open_long_order.json b/core/crates/gem_hypercore/testdata/hl_action_open_long_order.json new file mode 100644 index 0000000000..69dc9f549a --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_open_long_order.json @@ -0,0 +1,27 @@ +{ + "action": { + "grouping": "na", + "orders": [ + { + "a": 5, + "b": true, + "p": "200.21", + "r": false, + "s": "0.28", + "t": { + "limit": { + "tif": "FrontendMarket" + } + } + } + ], + "type": "order" + }, + "isFrontend": true, + "nonce": 1753576312346, + "signature": { + "r": "0xf3d38b1bf49efb57622bc054d115be8b8d8440b00e45610412d22ffb5ae798f9", + "s": "0x3785cc770743535a79ead405b776bd5996bd62e680a10d614829bb5a73362209", + "v": 28 + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_action_open_short_order.json b/core/crates/gem_hypercore/testdata/hl_action_open_short_order.json new file mode 100644 index 0000000000..b85c0776b6 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_open_short_order.json @@ -0,0 +1,27 @@ +{ + "action": { + "grouping": "na", + "orders": [ + { + "a": 25, + "b": false, + "p": "3.032", + "r": false, + "s": "1", + "t": { + "limit": { + "tif": "FrontendMarket" + } + } + } + ], + "type": "order" + }, + "isFrontend": true, + "nonce": 1753680603007, + "signature": { + "r": "0x26f8b17b17a8ffaf7f913fe92d69bd691dd7d1e6551385507e09dd20b247a6dd", + "s": "0x3941f5e98e11fdab8f0a68db161eaa58e49d2d582881f3bffbc69b27dce5abb0", + "v": 27 + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_action_perp_to_spot.json b/core/crates/gem_hypercore/testdata/hl_action_perp_to_spot.json new file mode 100644 index 0000000000..942af59510 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_perp_to_spot.json @@ -0,0 +1,17 @@ +{ + "action": { + "amount": "10", + "hyperliquidChain": "Mainnet", + "nonce": 1754986301493, + "signatureChainId": "0xa4b1", + "toPerp": false, + "type": "usdClassTransfer" + }, + "isFrontend": true, + "nonce": 1754986301493, + "signature": { + "r": "0x2a0d2571330681c146a744ce32aa31fff5ff720dff6c6e440a2724f64c99e312", + "s": "0x6c7e4296752d20f79573b3bfea00f95f092105235269e38a4bd3e987e0486b85", + "v": 27 + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_action_set_referrer.json b/core/crates/gem_hypercore/testdata/hl_action_set_referrer.json new file mode 100644 index 0000000000..9ee709286a --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_set_referrer.json @@ -0,0 +1,13 @@ +{ + "action": { + "code": "GEMWALLET", + "type": "setReferrer" + }, + "isFrontend": true, + "nonce": 1753882649539, + "signature": { + "r": "0x750edadc6664badceff6d1cd2a96e0aed1e28b0063d9a665e6a8901983de8366", + "s": "0x7872605712424e287f8d02b888ba826a872b0e89a95a50d49388d74e10c41bb3", + "v": 27 + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_action_spot_send_l1.json b/core/crates/gem_hypercore/testdata/hl_action_spot_send_l1.json new file mode 100644 index 0000000000..0452c95c58 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_spot_send_l1.json @@ -0,0 +1,18 @@ +{ + "action": { + "amount": "0.02", + "destination": "0x1085c5f70f7f7591d97da281a64688385455c2bd", + "hyperliquidChain": "Mainnet", + "signatureChainId": "0xa4b1", + "time": 1755004027201, + "token": "USDC:0x6d1e7cde53ba9467b783cb7c530ce054", + "type": "spotSend" + }, + "isFrontend": true, + "nonce": 1755004027201, + "signature": { + "r": "0x382d6358765ddbefb1ced7fdcd14406b8500a2b2a61332bd67ac0ce3746b9d3e", + "s": "0x5c3156b7ef4ad6d17b0ff7966e7aad1b19a4649eddcd186ad3f46013a013980d", + "v": 28 + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_action_spot_to_perps.json b/core/crates/gem_hypercore/testdata/hl_action_spot_to_perps.json new file mode 100644 index 0000000000..bc9a9fcea6 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_spot_to_perps.json @@ -0,0 +1,17 @@ +{ + "action": { + "amount": "10", + "hyperliquidChain": "Mainnet", + "nonce": 1754986567194, + "signatureChainId": "0xa4b1", + "toPerp": true, + "type": "usdClassTransfer" + }, + "isFrontend": true, + "nonce": 1754986567194, + "signature": { + "r": "0x922ab18d3babc74d86c8bb0c259c121193afc57b156b512914ae81c2faad1fb3", + "s": "0x16fc542cdbae9cca3984646759c9e54645a6a92e8adc597d25f0c59a23922a93", + "v": 27 + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_action_spot_to_stake.json b/core/crates/gem_hypercore/testdata/hl_action_spot_to_stake.json new file mode 100644 index 0000000000..3a16ecba3e --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_spot_to_stake.json @@ -0,0 +1,16 @@ +{ + "action": { + "hyperliquidChain": "Mainnet", + "nonce": 1755231476741, + "signatureChainId": "0xa4b1", + "type": "cDeposit", + "wei": 10000000 + }, + "isFrontend": true, + "nonce": 1755231476741, + "signature": { + "r": "0x8e5d7b14d80a8a5d2334509c1f055be0ea8a78c0632ef43bd17b0f788de3538e", + "s": "0x426730e6231d72d3b6ea892b791bf68351de0c754d073bf6f2174accb4176d75", + "v": 28 + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_action_stake_to_validator.json b/core/crates/gem_hypercore/testdata/hl_action_stake_to_validator.json new file mode 100644 index 0000000000..22abd28f55 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_stake_to_validator.json @@ -0,0 +1,18 @@ +{ + "action": { + "hyperliquidChain": "Mainnet", + "isUndelegate": false, + "nonce": 1755231522831, + "signatureChainId": "0xa4b1", + "type": "tokenDelegate", + "validator": "0x5ac99df645f3414876c816caa18b2d234024b487", + "wei": 10000000 + }, + "isFrontend": true, + "nonce": 1755231522831, + "signature": { + "r": "0x3d16b033812211ff3b0bf7793cc628cd4db7cc273dab2264225386a158db842e", + "s": "0x36175c089b06dc245e273d7d7deedad4bd46fb5ce256a5c8de1d6a55a7258008", + "v": 28 + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_action_update_position_tp_sl.json b/core/crates/gem_hypercore/testdata/hl_action_update_position_tp_sl.json new file mode 100644 index 0000000000..26a31677dd --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_action_update_position_tp_sl.json @@ -0,0 +1,43 @@ +{ + "action": { + "grouping": "positionTpsl", + "orders": [ + { + "a": 7, + "b": false, + "p": "671.6", + "r": true, + "s": "0.197", + "t": { + "trigger": { + "isMarket": true, + "tpsl": "sl", + "triggerPx": "730" + } + } + }, + { + "a": 7, + "b": false, + "p": "782", + "r": true, + "s": "0.197", + "t": { + "trigger": { + "isMarket": true, + "tpsl": "tp", + "triggerPx": "850" + } + } + } + ], + "type": "order" + }, + "isFrontend": true, + "nonce": 1755132472149, + "signature": { + "r": "0xe7573d3fadf28422e2068a7f477bed470bfea5627dcd0282283822250440bff0", + "s": "0x027462c40186ea0d4b50df44cf0f7b176acd21affbe03d4b0417b12cddb139b9", + "v": 27 + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_approve_agent.json b/core/crates/gem_hypercore/testdata/hl_eip712_approve_agent.json new file mode 100644 index 0000000000..87a7bf9093 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_approve_agent.json @@ -0,0 +1,55 @@ +{ + "domain": { + "chainId": 42161, + "name": "HyperliquidSignTransaction", + "verifyingContract": "0x0000000000000000000000000000000000000000", + "version": "1" + }, + "message": { + "agentAddress": "0xbec81216a5edeaed508709d8526078c750e307ad", + "agentName": "", + "hyperliquidChain": "Mainnet", + "nonce": 1753576844319, + "signatureChainId": "0xa4b1", + "type": "approveAgent" + }, + "primaryType": "HyperliquidTransaction:ApproveAgent", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:ApproveAgent": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "agentAddress", + "type": "address" + }, + { + "name": "agentName", + "type": "string" + }, + { + "name": "nonce", + "type": "uint64" + } + ] + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_c_withdraw.json b/core/crates/gem_hypercore/testdata/hl_eip712_c_withdraw.json new file mode 100644 index 0000000000..619045d27d --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_c_withdraw.json @@ -0,0 +1,50 @@ +{ + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "type": "cWithdraw", + "wei": 10000000, + "nonce": 1758983015647, + "signatureChainId": "0xa4b1", + "hyperliquidChain": "Mainnet" + }, + "primaryType": "HyperliquidTransaction:CWithdraw", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:CWithdraw": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "wei", + "type": "uint64" + }, + { + "name": "nonce", + "type": "uint64" + } + ] + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_core_to_evm.json b/core/crates/gem_hypercore/testdata/hl_eip712_core_to_evm.json new file mode 100644 index 0000000000..9e0f6229b4 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_core_to_evm.json @@ -0,0 +1,60 @@ +{ + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "destination": "0x2222222222222222222222222222222222222222", + "token": "HYPE:0x0d01dc56dcaaca66ad901c959b4011ec", + "amount": "0.1", + "time": 1754996222238, + "type": "spotSend", + "signatureChainId": "0xa4b1", + "hyperliquidChain": "Mainnet" + }, + "primaryType": "HyperliquidTransaction:SpotSend", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:SpotSend": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "destination", + "type": "string" + }, + { + "name": "token", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "time", + "type": "uint64" + } + ] + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_perp_send_l1.json b/core/crates/gem_hypercore/testdata/hl_eip712_perp_send_l1.json new file mode 100644 index 0000000000..3ea45b3b51 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_perp_send_l1.json @@ -0,0 +1,55 @@ +{ + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "destination": "0xe51d0862078098c84346b6203b50b996f7dafe28", + "amount": "1", + "time": 1754987223323, + "type": "usdSend", + "signatureChainId": "0xa4b1", + "hyperliquidChain": "Mainnet" + }, + "primaryType": "HyperliquidTransaction:UsdSend", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:UsdSend": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "destination", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "time", + "type": "uint64" + } + ] + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_perp_to_spot.json b/core/crates/gem_hypercore/testdata/hl_eip712_perp_to_spot.json new file mode 100644 index 0000000000..8f7a69ae83 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_perp_to_spot.json @@ -0,0 +1,55 @@ +{ + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "type": "usdClassTransfer", + "amount": "10", + "toPerp": false, + "nonce": 1754986301493, + "signatureChainId": "0xa4b1", + "hyperliquidChain": "Mainnet" + }, + "primaryType": "HyperliquidTransaction:UsdClassTransfer", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:UsdClassTransfer": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "toPerp", + "type": "bool" + }, + { + "name": "nonce", + "type": "uint64" + } + ] + } +} diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_spot_send_l1.json b/core/crates/gem_hypercore/testdata/hl_eip712_spot_send_l1.json new file mode 100644 index 0000000000..862fe0947a --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_spot_send_l1.json @@ -0,0 +1,60 @@ +{ + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "destination": "0x1085c5f70f7f7591d97da281a64688385455c2bd", + "token": "USDC:0x6d1e7cde53ba9467b783cb7c530ce054", + "amount": "0.02", + "time": 1755004027201, + "type": "spotSend", + "signatureChainId": "0xa4b1", + "hyperliquidChain": "Mainnet" + }, + "primaryType": "HyperliquidTransaction:SpotSend", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:SpotSend": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "destination", + "type": "string" + }, + { + "name": "token", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "time", + "type": "uint64" + } + ] + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_spot_to_stake_balance.json b/core/crates/gem_hypercore/testdata/hl_eip712_spot_to_stake_balance.json new file mode 100644 index 0000000000..b909b51b32 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_spot_to_stake_balance.json @@ -0,0 +1,50 @@ +{ + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "type": "cDeposit", + "wei": 10000000, + "nonce": 1755231476741, + "signatureChainId": "0xa4b1", + "hyperliquidChain": "Mainnet" + }, + "primaryType": "HyperliquidTransaction:CDeposit", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:CDeposit": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "wei", + "type": "uint64" + }, + { + "name": "nonce", + "type": "uint64" + } + ] + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_stake_to_validator.json b/core/crates/gem_hypercore/testdata/hl_eip712_stake_to_validator.json new file mode 100644 index 0000000000..f43fc2eb65 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_stake_to_validator.json @@ -0,0 +1,60 @@ +{ + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "type": "tokenDelegate", + "validator": "0x5ac99df645f3414876c816caa18b2d234024b487", + "wei": 10000000, + "isUndelegate": false, + "nonce": 1755231522831, + "signatureChainId": "0xa4b1", + "hyperliquidChain": "Mainnet" + }, + "primaryType": "HyperliquidTransaction:TokenDelegate", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:TokenDelegate": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "validator", + "type": "address" + }, + { + "name": "wei", + "type": "uint64" + }, + { + "name": "isUndelegate", + "type": "bool" + }, + { + "name": "nonce", + "type": "uint64" + } + ] + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/hl_eip712_withdraw.json b/core/crates/gem_hypercore/testdata/hl_eip712_withdraw.json new file mode 100644 index 0000000000..b2f87d1868 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/hl_eip712_withdraw.json @@ -0,0 +1,55 @@ +{ + "domain": { + "chainId": 42161, + "name": "HyperliquidSignTransaction", + "verifyingContract": "0x0000000000000000000000000000000000000000", + "version": "1" + }, + "message": { + "amount": "2", + "destination": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "hyperliquidChain": "Mainnet", + "signatureChainId": "0xa4b1", + "time": 1753577591421, + "type": "withdraw3" + }, + "primaryType": "HyperliquidTransaction:Withdraw", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:Withdraw": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "destination", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "time", + "type": "uint64" + } + ] + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/order_broadcast_error.json b/core/crates/gem_hypercore/testdata/order_broadcast_error.json new file mode 100644 index 0000000000..47d01022bb --- /dev/null +++ b/core/crates/gem_hypercore/testdata/order_broadcast_error.json @@ -0,0 +1,13 @@ +{ + "status": "ok", + "response": { + "type": "order", + "data": { + "statuses": [ + { + "error": "Reduce only order would increase position. asset=159" + } + ] + } + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/order_broadcast_filled.json b/core/crates/gem_hypercore/testdata/order_broadcast_filled.json new file mode 100644 index 0000000000..f9b88bab95 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/order_broadcast_filled.json @@ -0,0 +1,17 @@ +{ + "status": "ok", + "response": { + "type": "order", + "data": { + "statuses": [ + { + "filled": { + "totalSz": "126.0", + "avgPx": "0.95002", + "oid": 134896397196 + } + } + ] + } + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/order_broadcast_resting.json b/core/crates/gem_hypercore/testdata/order_broadcast_resting.json new file mode 100644 index 0000000000..3dc995af18 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/order_broadcast_resting.json @@ -0,0 +1,14 @@ +{ + "status": "ok", + "response": { + "data": { + "statuses": [ + { + "resting": { + "oid": 789012 + } + } + ] + } + } +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/order_broadcast_simple_error.json b/core/crates/gem_hypercore/testdata/order_broadcast_simple_error.json new file mode 100644 index 0000000000..e69a5b6c73 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/order_broadcast_simple_error.json @@ -0,0 +1,3 @@ +{ + "status": "error" +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state.json b/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state.json new file mode 100644 index 0000000000..24fd36dd60 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state.json @@ -0,0 +1,4 @@ +{ + "type": "clearinghouseState", + "user": "0x123" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state_dex1.json b/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state_dex1.json new file mode 100644 index 0000000000..a19303f07c --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state_dex1.json @@ -0,0 +1,5 @@ +{ + "type": "clearinghouseState", + "user": "0x123", + "dex": "dex1" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state_dex2.json b/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state_dex2.json new file mode 100644 index 0000000000..e09e456ad4 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_request_clearinghouse_state_dex2.json @@ -0,0 +1,5 @@ +{ + "type": "clearinghouseState", + "user": "0x123", + "dex": "dex2" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders.json b/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders.json new file mode 100644 index 0000000000..56371e3d16 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders.json @@ -0,0 +1,4 @@ +{ + "type": "frontendOpenOrders", + "user": "0x123" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders_dex1.json b/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders_dex1.json new file mode 100644 index 0000000000..99531084d1 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders_dex1.json @@ -0,0 +1,5 @@ +{ + "type": "frontendOpenOrders", + "user": "0x123", + "dex": "dex1" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders_dex2.json b/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders_dex2.json new file mode 100644 index 0000000000..b76ac1ce58 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_request_open_orders_dex2.json @@ -0,0 +1,5 @@ +{ + "type": "frontendOpenOrders", + "user": "0x123", + "dex": "dex2" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_request_perp_dexs.json b/core/crates/gem_hypercore/testdata/perpetual_positions_request_perp_dexs.json new file mode 100644 index 0000000000..06ca157993 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_request_perp_dexs.json @@ -0,0 +1,3 @@ +{ + "type": "perpDexs" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state.json b/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state.json new file mode 100644 index 0000000000..12c87a662c --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state.json @@ -0,0 +1,40 @@ +{ + "assetPositions": [ + { + "type": "oneWay", + "position": { + "coin": "BTC", + "szi": "1.0", + "leverage": { + "type": "cross", + "value": 10 + }, + "entryPx": "100.0", + "positionValue": "100.0", + "unrealizedPnl": "0", + "returnOnEquity": "0", + "liquidationPx": null, + "marginUsed": "10.0", + "maxLeverage": 25, + "cumFunding": { + "allTime": "0", + "sinceOpen": "0" + } + } + } + ], + "marginSummary": { + "accountValue": "10000", + "totalNtlPos": "5000", + "totalRawUsd": "5000", + "totalMarginUsed": "2000" + }, + "crossMarginSummary": { + "accountValue": "10000", + "totalNtlPos": "5000", + "totalRawUsd": "5000", + "totalMarginUsed": "2000" + }, + "crossMaintenanceMarginUsed": "1000", + "withdrawable": "8000" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state_dex1.json b/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state_dex1.json new file mode 100644 index 0000000000..6ac1ec6d48 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state_dex1.json @@ -0,0 +1,40 @@ +{ + "assetPositions": [ + { + "type": "oneWay", + "position": { + "coin": "ETH", + "szi": "1.0", + "leverage": { + "type": "cross", + "value": 10 + }, + "entryPx": "100.0", + "positionValue": "100.0", + "unrealizedPnl": "0", + "returnOnEquity": "0", + "liquidationPx": null, + "marginUsed": "10.0", + "maxLeverage": 25, + "cumFunding": { + "allTime": "0", + "sinceOpen": "0" + } + } + } + ], + "marginSummary": { + "accountValue": "10000", + "totalNtlPos": "5000", + "totalRawUsd": "5000", + "totalMarginUsed": "2000" + }, + "crossMarginSummary": { + "accountValue": "10000", + "totalNtlPos": "5000", + "totalRawUsd": "5000", + "totalMarginUsed": "2000" + }, + "crossMaintenanceMarginUsed": "1000", + "withdrawable": "8000" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state_dex2.json b/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state_dex2.json new file mode 100644 index 0000000000..47969d4ff9 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_response_clearinghouse_state_dex2.json @@ -0,0 +1,17 @@ +{ + "assetPositions": [], + "marginSummary": { + "accountValue": "10000", + "totalNtlPos": "5000", + "totalRawUsd": "5000", + "totalMarginUsed": "2000" + }, + "crossMarginSummary": { + "accountValue": "10000", + "totalNtlPos": "5000", + "totalRawUsd": "5000", + "totalMarginUsed": "2000" + }, + "crossMaintenanceMarginUsed": "1000", + "withdrawable": "8000" +} diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_response_open_orders.json b/core/crates/gem_hypercore/testdata/perpetual_positions_response_open_orders.json new file mode 100644 index 0000000000..a3ccc01086 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_response_open_orders.json @@ -0,0 +1,10 @@ +[ + { + "coin": "BTC", + "oid": 1, + "triggerPx": "110.0", + "limitPx": null, + "isPositionTpsl": true, + "orderType": "Take Profit Market" + } +] diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_response_open_orders_dex1.json b/core/crates/gem_hypercore/testdata/perpetual_positions_response_open_orders_dex1.json new file mode 100644 index 0000000000..4c88397149 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_response_open_orders_dex1.json @@ -0,0 +1,10 @@ +[ + { + "coin": "ETH", + "oid": 2, + "triggerPx": "90.0", + "limitPx": null, + "isPositionTpsl": true, + "orderType": "Stop Market" + } +] diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_response_perp_dexs.json b/core/crates/gem_hypercore/testdata/perpetual_positions_response_perp_dexs.json new file mode 100644 index 0000000000..0e0e9152ea --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_response_perp_dexs.json @@ -0,0 +1,11 @@ +[ + null, + { + "name": "dex1", + "isActive": true + }, + { + "name": "dex2", + "isActive": true + } +] diff --git a/core/crates/gem_hypercore/testdata/perpetual_positions_response_user_abstraction_default.json b/core/crates/gem_hypercore/testdata/perpetual_positions_response_user_abstraction_default.json new file mode 100644 index 0000000000..bfa9269af9 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/perpetual_positions_response_user_abstraction_default.json @@ -0,0 +1 @@ +"default" diff --git a/core/crates/gem_hypercore/testdata/referral_need_to_create_code.json b/core/crates/gem_hypercore/testdata/referral_need_to_create_code.json new file mode 100644 index 0000000000..a21f2427d0 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/referral_need_to_create_code.json @@ -0,0 +1,14 @@ +{ + "referredBy": { + "code": "GEMWALLET" + }, + "cumVlm": "46615331.93", + "unclaimedRewards": "0.0", + "claimedRewards": "0.0", + "builderRewards": "0.0", + "referrerState": { + "stage": "needToCreateCode" + }, + "rewardHistory": [], + "tokenToState": [] +} diff --git a/core/crates/gem_hypercore/testdata/referral_need_to_trade.json b/core/crates/gem_hypercore/testdata/referral_need_to_trade.json new file mode 100644 index 0000000000..50e1db10f6 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/referral_need_to_trade.json @@ -0,0 +1,10 @@ +{ + "referredBy": null, + "cumVlm": "0.0", + "referrerState": { + "stage": "needToTrade", + "data": { + "required": "10000.0" + } + } +} diff --git a/core/crates/gem_hypercore/testdata/spot_meta_spot_swap.json b/core/crates/gem_hypercore/testdata/spot_meta_spot_swap.json new file mode 100644 index 0000000000..23c3e2f5b8 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/spot_meta_spot_swap.json @@ -0,0 +1,29 @@ +{ + "tokens": [ + { + "name": "USDC", + "szDecimals": 8, + "weiDecimals": 8, + "index": 0, + "tokenId": "0x6d1e7cde53ba9467b783cb7c530ce054" + }, + { + "name": "HYPE", + "szDecimals": 2, + "weiDecimals": 8, + "index": 150, + "tokenId": "0x0d01dc56dcaaca66ad901c959b4011ec" + } + ], + "universe": [ + { + "tokens": [ + 150, + 0 + ], + "name": "@107", + "index": 107, + "isCanonical": false + } + ] +} diff --git a/core/crates/gem_hypercore/testdata/staking_delegations.json b/core/crates/gem_hypercore/testdata/staking_delegations.json new file mode 100644 index 0000000000..174e50eb59 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/staking_delegations.json @@ -0,0 +1,12 @@ +[ + { + "validator": "0x5ac99df645f3414876c816caa18b2d234024b487", + "amount": "2719.36493373", + "lockedUntilTimestamp": 1756512053068 + }, + { + "validator": "0xabcdeff4b3727b83a23697500eef089020df2cd2", + "amount": "18.14578086", + "lockedUntilTimestamp": 1755290095791 + } +] \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/transaction_broadcast_error_extra_agent.json b/core/crates/gem_hypercore/testdata/transaction_broadcast_error_extra_agent.json new file mode 100644 index 0000000000..8c36555cb9 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/transaction_broadcast_error_extra_agent.json @@ -0,0 +1,4 @@ +{ + "status": "err", + "response": "Extra agent already used." +} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/user_fills_liquidation.json b/core/crates/gem_hypercore/testdata/user_fills_liquidation.json new file mode 100644 index 0000000000..608ef787a0 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/user_fills_liquidation.json @@ -0,0 +1,24 @@ +[ + { + "coin": "HYPE", + "px": "27.283", + "sz": "7.27", + "side": "A", + "time": 1771816452802, + "startPosition": "7.27", + "dir": "Close Long", + "closedPnl": "-101.585891", + "hash": "0xbdd673d955e66365bf500435d435fc0201fa00bef0e98237619f1f2c14ea3d50", + "oid": 327271591087, + "crossed": true, + "fee": "0.085686", + "tid": 794561431105279, + "liquidation": { + "liquidatedUser": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "markPx": "27.286", + "method": "market" + }, + "feeToken": "USDC", + "twapId": null + } +] diff --git a/core/crates/gem_hypercore/testdata/user_fills_multiple.json b/core/crates/gem_hypercore/testdata/user_fills_multiple.json new file mode 100644 index 0000000000..5d984c0ef7 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/user_fills_multiple.json @@ -0,0 +1,56 @@ +[ + { + "coin": "HYPE", + "px": "47.9", + "sz": "0.5", + "side": "B", + "time": 1759700579491, + "startPosition": "0.0", + "dir": "Open Long", + "closedPnl": "12.5", + "hash": "0x9b4d63110c57f2e19cc7042ce90e300202f500f6a75b11b33f160e63cb5bcccc", + "oid": 187530505765, + "crossed": true, + "fee": "0.021122", + "builderFee": "0.010777", + "tid": 79888867456149, + "feeToken": "USDC", + "twapId": null + }, + { + "coin": "HYPE", + "px": "47.903", + "sz": "1.04", + "side": "B", + "time": 1759700579491, + "startPosition": "0.5", + "dir": "Open Long", + "closedPnl": "8.75", + "hash": "0x9b4d63110c57f2e19cc7042ce90e300202f500f6a75b11b33f160e63cb5bcccc", + "oid": 187530505765, + "crossed": true, + "fee": "0.043939", + "builderFee": "0.022418", + "tid": 362039288350649, + "feeToken": "USDC", + "twapId": null + }, + { + "coin": "HYPE", + "px": "47.904", + "sz": "8.91", + "side": "B", + "time": 1759700579491, + "startPosition": "1.54", + "dir": "Open Long", + "closedPnl": "15.25", + "hash": "0x9b4d63110c57f2e19cc7042ce90e300202f500f6a75b11b33f160e63cb5bcccc", + "oid": 187530505765, + "crossed": true, + "fee": "0.376459", + "builderFee": "0.192071", + "tid": 760303911712819, + "feeToken": "USDC", + "twapId": null + } +] diff --git a/core/crates/gem_hypercore/testdata/user_fills_shared_hash.json b/core/crates/gem_hypercore/testdata/user_fills_shared_hash.json new file mode 100644 index 0000000000..8b83707962 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/user_fills_shared_hash.json @@ -0,0 +1,36 @@ +[ + { + "coin": "SOL", + "px": "77.922", + "sz": "0.72", + "side": "A", + "time": 1771816452802, + "startPosition": "0.72", + "dir": "Close Long", + "closedPnl": "-43.76736", + "hash": "0xshared", + "oid": 327271591086, + "crossed": true, + "fee": "0.024236", + "tid": 286942367586253, + "feeToken": "USDC", + "twapId": null + }, + { + "coin": "HYPE", + "px": "27.283", + "sz": "7.27", + "side": "A", + "time": 1771816452802, + "startPosition": "7.27", + "dir": "Close Long", + "closedPnl": "-101.585891", + "hash": "0xshared", + "oid": 327271591087, + "crossed": true, + "fee": "0.085686", + "tid": 794561431105279, + "feeToken": "USDC", + "twapId": null + } +] diff --git a/core/crates/gem_hypercore/testdata/user_fills_spot_swap.json b/core/crates/gem_hypercore/testdata/user_fills_spot_swap.json new file mode 100644 index 0000000000..460a05c7d0 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/user_fills_spot_swap.json @@ -0,0 +1,20 @@ +[ + { + "coin": "@107", + "px": "39.415", + "sz": "0.3", + "side": "A", + "time": 1773977221812, + "startPosition": "0.49426753", + "dir": "Sell", + "closedPnl": "-1.8255637", + "hash": "0xd16518b18533f577d2de043763f8ad020482009720371449752dc4044437cf62", + "oid": 355101232455, + "crossed": true, + "fee": "0.01326708", + "builderFee": "0.00532102", + "tid": 610219818487575, + "feeToken": "USDC", + "twapId": null + } +] diff --git a/core/crates/gem_hypercore/testdata/user_fills_spot_swap_buy.json b/core/crates/gem_hypercore/testdata/user_fills_spot_swap_buy.json new file mode 100644 index 0000000000..54be865332 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/user_fills_spot_swap_buy.json @@ -0,0 +1,19 @@ +[ + { + "coin": "@107", + "px": "39.93", + "sz": "0.3", + "side": "B", + "time": 1773980617879, + "startPosition": "0.19426753", + "dir": "Buy", + "closedPnl": "0.0", + "hash": "0xbf8b52bd13095a59c105043764964e02028200a2ae0c792b6353fe0fd20d3444", + "oid": 355138434755, + "crossed": true, + "fee": "0.00020159", + "tid": 514401432714589, + "feeToken": "HYPE", + "twapId": null + } +] diff --git a/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_action_hash.json b/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_action_hash.json new file mode 100644 index 0000000000..d5adeb43f7 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_action_hash.json @@ -0,0 +1,10 @@ +[ + { + "time": 1777960894000, + "hash": "0xba3bce0950157157bbb5043aaee1060201e300eeeb1890295e04795c0f194b42", + "delta": { + "type": "send", + "nonce": 1777960893092 + } + } +] diff --git a/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_c_staking_transfer.json b/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_c_staking_transfer.json new file mode 100644 index 0000000000..6236472629 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_c_staking_transfer.json @@ -0,0 +1,12 @@ +[ + { + "time": 1779376556819, + "hash": "0xf0515f4aee4cd625f1cb043be9536a0203ca0030894ff4f8941a0a9dad40b010", + "delta": { + "type": "cStakingTransfer", + "token": "HYPE", + "amount": "0.01", + "isDeposit": true + } + } +] diff --git a/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_spot_transfer.json b/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_spot_transfer.json new file mode 100644 index 0000000000..5ed9fe0a9a --- /dev/null +++ b/core/crates/gem_hypercore/testdata/user_non_funding_ledger_updates_spot_transfer.json @@ -0,0 +1,18 @@ +[ + { + "time": 1761611680099, + "hash": "0x1210f05525bce189138a042e558d8002126b003ac0b0005bb5d99ba7e4b0bb73", + "delta": { + "type": "spotTransfer", + "token": "USDC", + "amount": "1.0", + "usdcValue": "1.0", + "user": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "destination": "0x9edcf9ff72088db8130c2512e5b4d3b5f34ceaf4", + "fee": "0.0", + "nativeTokenFee": "0.0", + "nonce": 1761611679622, + "feeToken": "" + } + } +] diff --git a/core/crates/gem_hypercore/testdata/ws_active_asset_ctx.json b/core/crates/gem_hypercore/testdata/ws_active_asset_ctx.json new file mode 100644 index 0000000000..d6a56727d3 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/ws_active_asset_ctx.json @@ -0,0 +1,15 @@ +{ + "channel": "activeAssetCtx", + "data": { + "coin": "ETH", + "ctx": { + "dayNtlVlm": "1169046.29406", + "prevDayPx": "2000", + "markPx": "2236.40", + "midPx": "2236.45", + "funding": "0.0000125", + "openInterest": "500", + "oraclePx": "2236.20" + } + } +} diff --git a/core/crates/gem_hypercore/testdata/ws_all_mids.json b/core/crates/gem_hypercore/testdata/ws_all_mids.json new file mode 100644 index 0000000000..825f5a74b8 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/ws_all_mids.json @@ -0,0 +1 @@ +{"channel":"allMids","data":{"mids":{"BTC":"104633.0","ETH":"3321.1","SOL":"260.48","DOGE":"0.40381","HYPE":"26.65"}}} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/ws_candle.json b/core/crates/gem_hypercore/testdata/ws_candle.json new file mode 100644 index 0000000000..cdeb972edf --- /dev/null +++ b/core/crates/gem_hypercore/testdata/ws_candle.json @@ -0,0 +1 @@ +{"channel":"candle","data":{"t":1738022400000,"T":1738026000000,"s":"ETH","i":"1h","o":"3300.5","c":"3321.1","h":"3345.0","l":"3290.2","v":"12450.8","n":1842}} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/ws_clearinghouse_state.json b/core/crates/gem_hypercore/testdata/ws_clearinghouse_state.json new file mode 100644 index 0000000000..9ffee851aa --- /dev/null +++ b/core/crates/gem_hypercore/testdata/ws_clearinghouse_state.json @@ -0,0 +1 @@ +{"channel":"clearinghouseState","data":{"user":"0xc64cc00b46150e2681a6c0e57b4b12fd2b68fbc4","clearinghouseState":{"assetPositions":[{"type":"oneWay","position":{"coin":"ETH","szi":"2.5","leverage":{"type":"cross","value":10},"entryPx":"3200.0","positionValue":"8305.0","unrealizedPnl":"305.0","returnOnEquity":"0.0366","liquidationPx":"2850.5","marginUsed":"830.5","maxLeverage":50,"cumFunding":{"allTime":"12.35","sinceOpen":"1.82"}}}],"marginSummary":{"accountValue":"15230.5","totalNtlPos":"8305.0","totalRawUsd":"6925.5","totalMarginUsed":"830.5"},"crossMarginSummary":{"accountValue":"15230.5","totalNtlPos":"8305.0","totalRawUsd":"6925.5","totalMarginUsed":"830.5"},"crossMaintenanceMarginUsed":"415.25","withdrawable":"14400.0"}}} \ No newline at end of file diff --git a/core/crates/gem_hypercore/testdata/ws_open_orders.json b/core/crates/gem_hypercore/testdata/ws_open_orders.json new file mode 100644 index 0000000000..ade19652b4 --- /dev/null +++ b/core/crates/gem_hypercore/testdata/ws_open_orders.json @@ -0,0 +1 @@ +{"channel":"openOrders","data":{"user":"0xc64cc00b46150e2681a6c0e57b4b12fd2b68fbc4","orders":[{"coin":"BTC","oid":8804521338,"triggerPx":"110000.0","limitPx":"110000.0","isPositionTpsl":true,"orderType":"Take Profit Market"},{"coin":"BTC","oid":8804521339,"triggerPx":"95000.0","limitPx":"95000.0","isPositionTpsl":true,"orderType":"Stop Market"}]}} \ No newline at end of file diff --git a/core/crates/gem_jsonrpc/Cargo.toml b/core/crates/gem_jsonrpc/Cargo.toml new file mode 100644 index 0000000000..efec3a5d49 --- /dev/null +++ b/core/crates/gem_jsonrpc/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "gem_jsonrpc" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = ["types"] +types = [] +client = ["dep:gem_client", "dep:async-trait", "dep:primitives", "dep:hex"] +reqwest = ["client", "dep:reqwest", "gem_client/reqwest"] +testkit = ["client", "gem_client/testkit"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +gem_client = { path = "../gem_client", optional = true } +async-trait = { workspace = true, optional = true } +primitives = { path = "../primitives", optional = true } +hex = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/core/crates/gem_jsonrpc/src/alien.rs b/core/crates/gem_jsonrpc/src/alien.rs new file mode 100644 index 0000000000..9cb2a012c2 --- /dev/null +++ b/core/crates/gem_jsonrpc/src/alien.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use gem_client::ClientError; +use primitives::Chain; + +use crate::client::JsonRpcClient; +use crate::rpc::{RpcClient as GenericRpcClient, RpcClientError, RpcProvider as GenericRpcProvider}; + +#[derive(Debug, Clone)] +pub enum AlienError { + RequestError { msg: String }, + ResponseError { msg: String }, + Http { status: u16, len: u32 }, +} + +impl AlienError { + pub fn request_error(msg: impl Into) -> Self { + Self::RequestError { msg: msg.into() } + } + + pub fn response_error(msg: impl Into) -> Self { + Self::ResponseError { msg: msg.into() } + } + + pub fn http_error(status: u16, len: usize) -> Self { + Self::Http { + status, + len: len.min(u32::MAX as usize) as u32, + } + } +} + +impl std::fmt::Display for AlienError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::RequestError { msg } => write!(f, "Request error: {msg}"), + Self::ResponseError { msg } => write!(f, "Response error: {msg}"), + Self::Http { status, .. } => write!(f, "HTTP error: status {status}"), + } + } +} + +impl std::error::Error for AlienError {} + +impl RpcClientError for AlienError { + fn into_client_error(self) -> ClientError { + match self { + Self::RequestError { msg } | Self::ResponseError { msg } => ClientError::Network(msg), + Self::Http { status, .. } => ClientError::Http { status, body: Vec::new() }, + } + } +} + +pub type RpcClient = GenericRpcClient; + +pub trait RpcProvider: GenericRpcProvider {} + +impl RpcProvider for T where T: GenericRpcProvider {} + +pub fn create_client(provider: Arc, chain: Chain) -> Result, AlienError> { + let endpoint = provider.get_endpoint(chain)?; + let client = RpcClient::new(endpoint, provider); + Ok(JsonRpcClient::new(client)) +} diff --git a/core/crates/gem_jsonrpc/src/client.rs b/core/crates/gem_jsonrpc/src/client.rs new file mode 100644 index 0000000000..9b81f32645 --- /dev/null +++ b/core/crates/gem_jsonrpc/src/client.rs @@ -0,0 +1,145 @@ +use crate::types::{ERROR_CLIENT_ERROR, ERROR_INTERNAL_ERROR, JsonRpcError, JsonRpcRequest, JsonRpcRequestConvert, JsonRpcResult, JsonRpcResults}; +use gem_client::{Client, ClientError, ClientExt}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; +use std::collections::HashMap; +#[cfg(feature = "reqwest")] +use std::error::Error; +use std::time::SystemTime; + +pub type CallTuple = (String, Value); + +#[derive(Clone, Debug)] +pub struct JsonRpcClient { + client: C, +} + +impl From for JsonRpcError { + fn from(value: ClientError) -> Self { + JsonRpcError { + code: ERROR_CLIENT_ERROR, + message: value.to_string(), + } + } +} + +impl JsonRpcClient { + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn request(&self, request: T) -> Result { + let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + let req = request.to_req(timestamp); + let result = self._request(req, None).await?; + match result { + JsonRpcResult::Value(value) => Ok(value.result), + JsonRpcResult::Error(error) => Err(error.error), + } + } + + pub async fn call(&self, method: &str, params: impl Into) -> Result { + let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + let req = JsonRpcRequest::new(timestamp, method, params.into()); + let result = self._request(req, None).await?; + match result { + JsonRpcResult::Value(value) => Ok(value.result), + JsonRpcResult::Error(error) => Err(error.error), + } + } + + pub async fn call_with_cache(&self, call: &T, ttl: Option) -> Result, JsonRpcError> { + let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + let req = call.to_req(timestamp); + self._request(req, ttl).await + } + + pub async fn call_method_with_param(&self, method: &str, params: T, ttl: Option) -> Result, JsonRpcError> + where + T: Serialize, + U: DeserializeOwned + Send, + { + let params_value = serde_json::to_value(params).map_err(|e| JsonRpcError { + code: ERROR_INTERNAL_ERROR, + message: format!("Failed to serialize RPC params: {e}"), + })?; + + // Wrap single object/value in an array if it's not already an array + let params_array = match params_value { + serde_json::Value::Array(arr) => arr, + _ => vec![params_value], + }; + + let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + let request = JsonRpcRequest::new(timestamp, method, params_array.into()); + self._request(request, ttl).await + } + + pub async fn batch_call(&self, calls: Vec) -> Result, JsonRpcError> { + if calls.is_empty() { + return Ok(Default::default()); + } + let requests: Vec = calls + .iter() + .enumerate() + .map(|(index, (method, params))| JsonRpcRequest::new(index as u64 + 1, method, params.clone())) + .collect(); + + self.batch_request(requests).await + } + + pub async fn batch_call_requests(&self, calls: Vec) -> Result, JsonRpcError> { + let requests: Vec = calls.iter().enumerate().map(|(index, request)| request.to_req(index as u64 + 1)).collect(); + self.batch_request(requests).await + } + + pub async fn batch_request(&self, requests: Vec) -> Result, JsonRpcError> { + if requests.is_empty() { + return Ok(Default::default()); + } + + let results: Vec> = self.client.post("", &requests).await?; + if results.len() != requests.len() { + return Err(JsonRpcError { + message: "Batch call response length mismatch".into(), + code: ERROR_INTERNAL_ERROR, + }); + } + + Ok(JsonRpcResults(results)) + } + + async fn _request(&self, req: JsonRpcRequest, ttl: Option) -> Result, JsonRpcError> { + let mut headers = HashMap::new(); + if let Some(ttl_seconds) = ttl { + headers.insert("Cache-Control".to_string(), format!("max-age={}", ttl_seconds)); + } + + let result: JsonRpcResult = self.client.post_with_headers("", &req, headers).await?; + Ok(result) + } +} + +#[cfg(feature = "reqwest")] +impl JsonRpcClient { + pub fn new_reqwest(url: String) -> Self { + use gem_client::ReqwestClient; + let reqwest_client = gem_client::builder().build().expect("Failed to build reqwest client"); + let client = ReqwestClient::new(url, reqwest_client); + Self { client } + } +} + +// Convenience functions for creating JsonRpcClient +#[cfg(feature = "reqwest")] +impl JsonRpcClient { + pub fn new_default(url: String) -> Result> { + Ok(Self::new_reqwest(url)) + } +} + +// Module-level convenience function +#[cfg(feature = "reqwest")] +pub fn new_client(url: String) -> Result, Box> { + Ok(JsonRpcClient::new_reqwest(url)) +} diff --git a/core/crates/gem_jsonrpc/src/grpc.rs b/core/crates/gem_jsonrpc/src/grpc.rs new file mode 100644 index 0000000000..28a3209e02 --- /dev/null +++ b/core/crates/gem_jsonrpc/src/grpc.rs @@ -0,0 +1,108 @@ +use async_trait::async_trait; +use std::{collections::HashMap, error::Error, fmt, sync::Arc}; + +use crate::{ + alien, + rpc::{HttpMethod, Target}, +}; + +#[async_trait] +pub trait GrpcTransport: Send + Sync + fmt::Debug { + async fn unary(&self, endpoint: &str, path: &str, body: Vec) -> Result, Box>; +} + +fn unary_target(endpoint: &str, path: &str, body: Vec) -> Target { + Target { + url: format!("{}{}", endpoint.trim_end_matches('/'), path), + method: HttpMethod::Post, + headers: Some(grpc_headers()), + body: Some(body), + } +} + +fn ensure_success_status(status: Option) -> Result<(), Box> { + if let Some(status) = status + && !(200..300).contains(&status) + { + return Err(format!("gRPC HTTP error: status {status}").into()); + } + Ok(()) +} + +fn grpc_headers() -> HashMap { + HashMap::from([ + ("Content-Type".into(), "application/grpc+proto".into()), + ("Accept".into(), "application/grpc+proto".into()), + ("TE".into(), "trailers".into()), + ]) +} + +#[derive(Clone)] +pub struct AlienGrpcTransport { + provider: Arc, +} + +impl AlienGrpcTransport { + pub fn new(provider: Arc) -> Self { + Self { provider } + } +} + +impl fmt::Debug for AlienGrpcTransport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AlienGrpcTransport").finish_non_exhaustive() + } +} + +#[async_trait] +impl GrpcTransport for AlienGrpcTransport { + async fn unary(&self, endpoint: &str, path: &str, body: Vec) -> Result, Box> { + let response = self.provider.request(unary_target(endpoint, path, body)).await?; + ensure_success_status(response.status)?; + Ok(response.data) + } +} + +#[cfg(feature = "reqwest")] +#[derive(Clone, Debug)] +pub struct ReqwestGrpcTransport { + client: reqwest::Client, +} + +#[cfg(feature = "reqwest")] +impl ReqwestGrpcTransport { + pub fn new() -> Self { + Self { client: reqwest::Client::new() } + } + + pub fn new_with_client(client: reqwest::Client) -> Self { + Self { client } + } +} + +#[cfg(feature = "reqwest")] +impl Default for ReqwestGrpcTransport { + fn default() -> Self { + Self::new() + } +} + +#[cfg(feature = "reqwest")] +#[async_trait] +impl GrpcTransport for ReqwestGrpcTransport { + async fn unary(&self, endpoint: &str, path: &str, body: Vec) -> Result, Box> { + let response = self + .client + .post(format!("{}{}", endpoint.trim_end_matches('/'), path)) + .header("Content-Type", "application/grpc+proto") + .header("Accept", "application/grpc+proto") + .header("TE", "trailers") + .body(body) + .send() + .await?; + let status = response.status().as_u16(); + let bytes = response.bytes().await?.to_vec(); + ensure_success_status(Some(status))?; + Ok(bytes) + } +} diff --git a/core/crates/gem_jsonrpc/src/lib.rs b/core/crates/gem_jsonrpc/src/lib.rs new file mode 100644 index 0000000000..55f5275862 --- /dev/null +++ b/core/crates/gem_jsonrpc/src/lib.rs @@ -0,0 +1,17 @@ +pub mod types; + +#[cfg(feature = "client")] +pub mod alien; +#[cfg(feature = "client")] +pub mod client; +#[cfg(feature = "client")] +pub mod grpc; +#[cfg(feature = "client")] +pub use client::*; +#[cfg(feature = "testkit")] +pub mod testkit; + +#[cfg(feature = "client")] +pub mod rpc; +#[cfg(feature = "client")] +pub use rpc::{HttpMethod, RpcClient, RpcClientError, RpcProvider, RpcResponse, Target}; diff --git a/core/crates/gem_jsonrpc/src/rpc.rs b/core/crates/gem_jsonrpc/src/rpc.rs new file mode 100644 index 0000000000..afbee256d0 --- /dev/null +++ b/core/crates/gem_jsonrpc/src/rpc.rs @@ -0,0 +1,200 @@ +use async_trait::async_trait; +use gem_client::{Client, ClientError, ContentType, Response, X_CACHE_TTL, decode_json_byte_array, deserialize_response}; +use primitives::Chain; +use serde::{Serialize, de::DeserializeOwned}; +use std::{ + collections::HashMap, + error::Error, + fmt::{Debug, Display}, + str::FromStr, + sync::Arc, +}; + +pub type RpcResponse = Response; + +pub trait RpcClientError: Error + Send + Sync + 'static + Display + Sized { + fn into_client_error(self) -> ClientError { + ClientError::Network(format!("RPC provider error: {}", self)) + } +} + +#[derive(Debug, Clone)] +pub struct Target { + pub url: String, + pub method: HttpMethod, + pub headers: Option>, + pub body: Option>, +} + +impl Target { + pub fn get(url: &str) -> Self { + Self { + url: url.into(), + method: HttpMethod::Get, + headers: None, + body: None, + } + } + + pub fn post_json(url: &str, body: &T) -> Self { + Self { + url: url.into(), + method: HttpMethod::Post, + headers: Some(HashMap::from([("Content-Type".into(), "application/json".into())])), + body: Some(serde_json::to_vec(body).expect("Failed to serialize JSON body")), + } + } + + pub fn set_cache_ttl(mut self, ttl: u64) -> Self { + self.headers.get_or_insert_with(HashMap::new).insert(X_CACHE_TTL.into(), ttl.to_string()); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum HttpMethod { + Get, + Post, + Put, + Delete, + Head, + Options, + Patch, +} + +impl From for String { + fn from(value: HttpMethod) -> Self { + match value { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Delete => "DELETE", + HttpMethod::Head => "HEAD", + HttpMethod::Options => "OPTIONS", + HttpMethod::Patch => "PATCH", + } + .into() + } +} + +#[async_trait] +pub trait RpcProvider: Send + Sync + Debug { + type Error: std::error::Error + Send + Sync + 'static; + + async fn request(&self, target: Target) -> Result; + fn get_endpoint(&self, chain: Chain) -> Result; +} + +#[derive(Debug, Clone)] +pub struct RpcClient { + base_url: String, + provider: Arc>, +} + +impl RpcClient +where + E: RpcClientError, +{ + pub fn new(base_url: String, provider: Arc>) -> Self { + Self { base_url, provider } + } + + fn build_url(&self, path: &str) -> String { + format!("{}{}", self.base_url.trim_end_matches('/'), path) + } +} + +#[async_trait] +impl Client for RpcClient +where + E: RpcClientError, +{ + async fn get_with(&self, path: &str, _query: &[(String, String)], headers: HashMap) -> Result + where + R: DeserializeOwned, + { + let url = self.build_url(path); + let target = Target { + url, + method: HttpMethod::Get, + headers: if headers.is_empty() { None } else { Some(headers) }, + body: None, + }; + + let response = self.provider.request(target).await.map_err(|e| e.into_client_error())?; + deserialize_response(&response) + } + + async fn get_url(&self, url: &str) -> Result + where + R: DeserializeOwned, + { + let target = Target { + url: url.to_string(), + method: HttpMethod::Get, + headers: None, + body: None, + }; + let response = self.provider.request(target).await.map_err(|e| e.into_client_error())?; + deserialize_response(&response) + } + + async fn post_with(&self, path: &str, body: &T, headers: HashMap) -> Result + where + T: Serialize + Send + Sync, + R: DeserializeOwned, + { + let url = self.build_url(path); + + let mut request_headers = HashMap::from([("Content-Type".to_string(), ContentType::ApplicationJson.as_str().to_string())]); + request_headers.extend(headers); + + let content_type = request_headers.get("Content-Type").and_then(|s| ContentType::from_str(s).ok()); + + let data = match content_type { + Some(ContentType::TextPlain) | Some(ContentType::ApplicationFormUrlEncoded) => { + let json_value = serde_json::to_value(body)?; + match json_value { + serde_json::Value::String(s) => s.into_bytes(), + _ => return Err(ClientError::Serialization("Expected string body for text/plain content-type".to_string())), + } + } + Some(ContentType::ApplicationXBinary) | Some(ContentType::ApplicationAptosBcs) => { + let json_value = serde_json::to_value(body)?; + match json_value { + serde_json::Value::String(s) => hex::decode(&s).map_err(|e| ClientError::Serialization(format!("Failed to decode hex string: {e}")))?, + serde_json::Value::Array(values) => decode_json_byte_array(values)?, + _ => return Err(ClientError::Serialization("Expected hex string body for binary content-type".to_string())), + } + } + _ => serde_json::to_vec(body)?, + }; + + let target = Target { + url, + method: HttpMethod::Post, + headers: Some(request_headers), + body: Some(data), + }; + + let response = self.provider.request(target).await.map_err(|e| e.into_client_error())?; + + deserialize_response(&response) + } +} + +#[async_trait] +impl RpcProvider for RpcClient +where + E: RpcClientError, +{ + type Error = E; + + async fn request(&self, target: Target) -> Result { + self.provider.request(target).await + } + + fn get_endpoint(&self, chain: Chain) -> Result { + self.provider.get_endpoint(chain) + } +} diff --git a/core/crates/gem_jsonrpc/src/testkit.rs b/core/crates/gem_jsonrpc/src/testkit.rs new file mode 100644 index 0000000000..8c5943c99e --- /dev/null +++ b/core/crates/gem_jsonrpc/src/testkit.rs @@ -0,0 +1,66 @@ +use gem_client::{ClientError, testkit::MockClient}; +use serde_json::Value; + +use crate::client::JsonRpcClient; + +pub fn mock_jsonrpc_client(handler: F) -> JsonRpcClient +where + F: Fn(&str, &Value) -> Result + Send + Sync + 'static, +{ + JsonRpcClient::new(mock_jsonrpc_transport(handler)) +} + +pub fn mock_jsonrpc_transport(handler: F) -> MockClient +where + F: Fn(&str, &Value) -> Result + Send + Sync + 'static, +{ + MockClient::new().with_post(move |_, body| { + let request: Value = serde_json::from_slice(body).map_err(|error| ClientError::Serialization(error.to_string()))?; + let method = request + .get("method") + .and_then(Value::as_str) + .ok_or_else(|| ClientError::Serialization("missing method".to_string()))?; + let params = request.get("params").cloned().unwrap_or(Value::Null); + let id = request.get("id").cloned().unwrap_or(Value::Null); + let result = handler(method, ¶ms)?; + + serde_json::to_vec(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + })) + .map_err(|error| ClientError::Serialization(error.to_string())) + }) +} + +#[cfg(test)] +mod tests { + use gem_client::ClientExt; + + use super::mock_jsonrpc_transport; + + #[tokio::test] + async fn mock_jsonrpc_transport_wraps_result() { + let client = mock_jsonrpc_transport(|method, params| { + assert_eq!(method, "echo"); + assert_eq!(params, &serde_json::json!(["hello"])); + Ok(serde_json::json!({ "value": "ok" })) + }); + + let response: serde_json::Value = client + .post( + "", + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "echo", + "params": ["hello"], + }), + ) + .await + .unwrap(); + + assert_eq!(response["id"], 7); + assert_eq!(response["result"]["value"], "ok"); + } +} diff --git a/core/crates/gem_jsonrpc/src/types.rs b/core/crates/gem_jsonrpc/src/types.rs new file mode 100644 index 0000000000..2a4445414a --- /dev/null +++ b/core/crates/gem_jsonrpc/src/types.rs @@ -0,0 +1,258 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::fmt::{Debug, Display}; + +pub const JSONRPC_VERSION: &str = "2.0"; + +pub const ERROR_INVALID_REQUEST: i32 = -32600; +pub const ERROR_METHOD_NOT_FOUND: i32 = -32601; +pub const ERROR_INVALID_PARAMS: i32 = -32602; +pub const ERROR_INTERNAL_ERROR: i32 = -32603; + +pub const ERROR_CLIENT_ERROR: i32 = -32900; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JsonRpcRequest { + pub jsonrpc: &'static str, + pub id: u64, + pub method: String, + pub params: Value, +} + +pub trait JsonRpcRequestConvert { + fn to_req(&self, id: u64) -> JsonRpcRequest; +} + +impl JsonRpcRequest { + pub fn new(id: u64, method: &str, params: Value) -> Self { + Self { + jsonrpc: JSONRPC_VERSION, + id, + method: method.into(), + params, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, +} + +impl Display for JsonRpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let original = self.message.trim(); + let message = if original.is_empty() && self.code == ERROR_CLIENT_ERROR { + "Client error" + } else { + original + }; + + write!(f, "{} ({})", message, self.code) + } +} + +impl std::error::Error for JsonRpcError {} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JsonRpcResponse { + pub id: Option, + pub result: T, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JsonRpcErrorResponse { + pub id: Option, + pub error: JsonRpcError, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum JsonRpcResult { + Value(JsonRpcResponse), + Error(JsonRpcErrorResponse), +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for JsonRpcResult { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = Value::deserialize(deserializer)?; + let id = raw.get("id").and_then(|v| v.as_u64()); + + if let Some(error) = raw.get("error") { + let error: JsonRpcError = serde_json::from_value(error.clone()).map_err(serde::de::Error::custom)?; + return Ok(JsonRpcResult::Error(JsonRpcErrorResponse { id, error })); + } + + let Some(result) = raw.get("result") else { + return Err(serde::de::Error::custom(format!("missing result and error fields, raw: {raw}"))); + }; + + let result = T::deserialize(result.clone()).map_err(|e| serde::de::Error::custom(format!("failed to deserialize result: {e}, raw: {result}")))?; + Ok(JsonRpcResult::Value(JsonRpcResponse { id, result })) + } +} + +impl JsonRpcResult { + pub fn take(self) -> Result { + match self { + JsonRpcResult::Value(value) => Ok(value.result), + JsonRpcResult::Error(error) => Err(error.error), + } + } +} + +pub struct JsonRpcResults(pub Vec>); + +impl JsonRpcResults { + pub fn take_all(self) -> Result, JsonRpcError> { + self.0 + .into_iter() + .enumerate() + .map(|(i, r)| { + r.take().map_err(|e| JsonRpcError { + code: e.code, + message: format!("batch request [{}]: {}", i, e.message), + }) + }) + .collect() + } +} + +impl Default for JsonRpcResults { + fn default() -> Self { + JsonRpcResults(Vec::new()) + } +} + +impl From>> for JsonRpcResults { + fn from(vec: Vec>) -> Self { + JsonRpcResults(vec) + } +} + +impl IntoIterator for JsonRpcResults { + type Item = JsonRpcResult; + type IntoIter = std::vec::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_jsonrpc_error_display_with_client_error_code() { + let error = JsonRpcError { + code: ERROR_CLIENT_ERROR, + message: "".into(), + }; + + assert_eq!(format!("{error}"), "Client error (-32900)"); + } + + #[test] + fn test_jsonrpc_error_display_with_method_not_found_code() { + let error = JsonRpcError { + code: ERROR_METHOD_NOT_FOUND, + message: "Method not found".into(), + }; + + assert_eq!(format!("{error}"), "Method not found (-32601)"); + } + + #[derive(Debug, Deserialize, PartialEq)] + struct Block { + number: String, + } + + #[test] + fn test_deserialize_success() { + let json = r#"{"id": 1, "result": {"number": "0x10"}}"#; + let result: JsonRpcResult = serde_json::from_str(json).unwrap(); + assert!(matches!(result, JsonRpcResult::Value(r) if r.result.number == "0x10")); + } + + #[test] + fn test_deserialize_error_response() { + let json = r#"{"id": 1, "error": {"code": -32601, "message": "Method not found"}}"#; + let result: JsonRpcResult = serde_json::from_str(json).unwrap(); + assert!(matches!(result, JsonRpcResult::Error(e) if e.error.code == -32601)); + } + + #[test] + fn test_deserialize_null_result_fails_with_detail() { + let json = r#"{"id": 1, "result": null}"#; + let err = serde_json::from_str::>(json).unwrap_err(); + assert!( + err.to_string() + .contains("failed to deserialize result: invalid type: null, expected struct Block, raw: null") + ); + } + + #[test] + fn test_deserialize_null_result_ok_for_option() { + let json = r#"{"id": 1, "result": null}"#; + let result: JsonRpcResult> = serde_json::from_str(json).unwrap(); + assert!(matches!(result, JsonRpcResult::Value(r) if r.result.is_none())); + } + + #[test] + fn test_deserialize_batch_with_mixed_results() { + let json = r#"[ + {"id": 1, "result": {"number": "0x10"}}, + {"id": 2, "error": {"code": -32600, "message": "Invalid"}} + ]"#; + let results: Vec> = serde_json::from_str(json).unwrap(); + assert!(matches!(&results[0], JsonRpcResult::Value(_))); + assert!(matches!(&results[1], JsonRpcResult::Error(_))); + } + + #[test] + fn test_take_all_success() { + let results: JsonRpcResults = vec![ + JsonRpcResult::Value(JsonRpcResponse { + id: Some(1), + result: Block { number: "0x10".into() }, + }), + JsonRpcResult::Value(JsonRpcResponse { + id: Some(2), + result: Block { number: "0x20".into() }, + }), + ] + .into(); + + let values = results.take_all().unwrap(); + assert_eq!(values.len(), 2); + assert_eq!(values[0].number, "0x10"); + assert_eq!(values[1].number, "0x20"); + } + + #[test] + fn test_take_all_error_includes_index() { + let results: JsonRpcResults = vec![ + JsonRpcResult::Value(JsonRpcResponse { + id: Some(1), + result: Block { number: "0x10".into() }, + }), + JsonRpcResult::Error(JsonRpcErrorResponse { + id: Some(2), + error: JsonRpcError { + code: ERROR_INVALID_REQUEST, + message: "Invalid".into(), + }, + }), + ] + .into(); + + let err = results.take_all().unwrap_err(); + assert_eq!(err.code, ERROR_INVALID_REQUEST); + assert_eq!(err.message, "batch request [1]: Invalid"); + } +} diff --git a/core/crates/gem_near/Cargo.toml b/core/crates/gem_near/Cargo.toml new file mode 100644 index 0000000000..f3529fbcfb --- /dev/null +++ b/core/crates/gem_near/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "gem_near" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] + +primitives = { path = "../primitives" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +num-bigint = { workspace = true } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } + +# RPC specific dependencies +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["client"], optional = true } +gem_client = { path = "../gem_client", optional = true } +chain_traits = { path = "../chain_traits", optional = true } +async-trait = { workspace = true, optional = true } +chrono = { workspace = true, features = ["serde"], optional = true } +bs58 = { workspace = true, optional = true } +hex = { workspace = true } +futures = { workspace = true, optional = true } +gem_encoding = { path = "../gem_encoding", optional = true } +gem_hash = { path = "../gem_hash", optional = true } +signer = { path = "../signer", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } +hex = { workspace = true } + +[features] +default = [] +rpc = ["dep:gem_jsonrpc", "dep:gem_client", "dep:chain_traits", "dep:async-trait", "dep:chrono", "dep:bs58", "dep:futures"] +signer = ["dep:gem_encoding", "dep:bs58", "dep:gem_hash", "dep:signer"] +reqwest = ["gem_client/reqwest", "gem_jsonrpc/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] diff --git a/core/crates/gem_near/src/address.rs b/core/crates/gem_near/src/address.rs new file mode 100644 index 0000000000..e0214228db --- /dev/null +++ b/core/crates/gem_near/src/address.rs @@ -0,0 +1,38 @@ +use primitives::Address as AddressTrait; + +pub struct NearAddress([u8; 32]); + +impl AddressTrait for NearAddress { + fn try_parse(address: &str) -> Option { + hex::decode(address).ok()?.try_into().ok().map(Self) + } + + fn as_bytes(&self) -> &[u8] { + &self.0 + } + + fn encode(&self) -> String { + hex::encode(self.0) + } +} + +pub fn validate_address(address: &str) -> bool { + NearAddress::is_valid(address) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_near_address() { + let address = "e3ac115fd911eb985ffd884ee60302c84dc94df52127ccde8d6fb97ad6d22945"; + let parsed = NearAddress::try_parse(address).unwrap(); + + assert!(validate_address(address)); + assert_eq!(parsed.as_bytes().len(), 32); + assert_eq!(parsed.encode(), address); + assert!(!validate_address("invalid")); + assert!(!validate_address("e3ac115fd911eb985ffd884ee60302c84dc94df52127ccde8d6fb97ad6d229")); + } +} diff --git a/core/crates/gem_near/src/constants.rs b/core/crates/gem_near/src/constants.rs new file mode 100644 index 0000000000..476435bf68 --- /dev/null +++ b/core/crates/gem_near/src/constants.rs @@ -0,0 +1,3 @@ +pub const TRANSACTION_STATUS_FINAL: &str = "FINAL"; +pub const TRANSACTION_STATUS_EXECUTED: &str = "EXECUTED"; +pub const TRANSACTION_STATUS_EXECUTED_OPTIMISTIC: &str = "EXECUTED_OPTIMISTIC"; diff --git a/core/crates/gem_near/src/lib.rs b/core/crates/gem_near/src/lib.rs new file mode 100644 index 0000000000..ea535f4d8c --- /dev/null +++ b/core/crates/gem_near/src/lib.rs @@ -0,0 +1,17 @@ +pub mod address; +pub use address::validate_address; +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +pub mod constants; +pub mod models; +#[cfg(feature = "signer")] +pub mod signer; + +#[cfg(feature = "rpc")] +pub use rpc::*; +#[cfg(feature = "signer")] +pub use signer::*; diff --git a/core/crates/gem_near/src/models/account.rs b/core/crates/gem_near/src/models/account.rs new file mode 100644 index 0000000000..81fbea530a --- /dev/null +++ b/core/crates/gem_near/src/models/account.rs @@ -0,0 +1,14 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_biguint_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub amount: BigUint, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountAccessKey { + pub nonce: i64, +} diff --git a/core/crates/gem_near/src/models/block.rs b/core/crates/gem_near/src/models/block.rs new file mode 100644 index 0000000000..134aa7ea2b --- /dev/null +++ b/core/crates/gem_near/src/models/block.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + pub header: BlockHeader, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockHeader { + pub hash: String, + pub height: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeStatus { + pub chain_id: String, + pub sync_info: NodeSyncInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeSyncInfo { + pub latest_block_height: u64, + pub syncing: bool, +} diff --git a/core/crates/gem_near/src/models/fee.rs b/core/crates/gem_near/src/models/fee.rs new file mode 100644 index 0000000000..6e21763934 --- /dev/null +++ b/core/crates/gem_near/src/models/fee.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GasPrice { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub gas_price: u64, +} diff --git a/core/crates/gem_near/src/models/mod.rs b/core/crates/gem_near/src/models/mod.rs new file mode 100644 index 0000000000..0684e74401 --- /dev/null +++ b/core/crates/gem_near/src/models/mod.rs @@ -0,0 +1,10 @@ +pub mod account; +pub mod block; +pub mod fee; +pub mod rpc; +pub mod transaction; + +pub use account::*; +pub use block::*; +pub use fee::*; +pub use transaction::*; diff --git a/core/crates/gem_near/src/models/rpc.rs b/core/crates/gem_near/src/models/rpc.rs new file mode 100644 index 0000000000..d30cbacd6a --- /dev/null +++ b/core/crates/gem_near/src/models/rpc.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Block { + pub header: BlockHeader, + pub chunks: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BlockHeader { + pub height: i64, + pub timestamp: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ChunkHeader { + pub shard_id: i64, + pub gas_used: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Chunk { + pub transactions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Transaction { + pub hash: String, + pub signer_id: String, + pub receiver_id: String, + pub actions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum Action { + CreateAccount, + Transfer { + deposit: String, + }, + #[serde(untagged)] + Other(serde_json::Value), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AccessKey { + pub nonce: i64, + pub permission: String, +} diff --git a/core/crates/gem_near/src/models/transaction.rs b/core/crates/gem_near/src/models/transaction.rs new file mode 100644 index 0000000000..9b4335417e --- /dev/null +++ b/core/crates/gem_near/src/models/transaction.rs @@ -0,0 +1,40 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_biguint_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BroadcastResult { + pub final_execution_status: String, + pub transaction: BroadcastTransaction, + pub transaction_outcome: TransactionOutcome, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BroadcastTransaction { + pub hash: String, + pub signer_id: String, + pub receiver_id: String, + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionAction { + #[serde(rename = "Transfer")] + pub transfer: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransferAction { + pub deposit: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionOutcome { + pub outcome: Outcome, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Outcome { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub tokens_burnt: BigUint, +} diff --git a/core/crates/gem_near/src/provider/balances.rs b/core/crates/gem_near/src/provider/balances.rs new file mode 100644 index 0000000000..0018dedef6 --- /dev/null +++ b/core/crates/gem_near/src/provider/balances.rs @@ -0,0 +1,66 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use std::error::Error; + +use gem_client::Client; +use gem_jsonrpc::types::JsonRpcError; +use primitives::{AssetBalance, Chain}; + +use super::balances_mapper; +use crate::rpc::client::NearClient; + +const ACCOUNT_NOT_FOUND_ERROR_CODE: i32 = -32000; + +#[async_trait] +impl ChainBalances for NearClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let account = match self.get_account(&address).await { + Ok(account) => account, + Err(error) if is_account_missing(&error) => return Ok(AssetBalance::new_zero_balance(Chain::Near.as_asset_id())), + Err(error) => return Err(error.into()), + }; + balances_mapper::map_native_balance(&account) + } + + async fn get_balance_tokens(&self, _address: String, _token_ids: Vec) -> Result, Box> { + Ok(vec![]) + } + + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Ok(None) + } + + async fn get_balance_assets(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} + +fn is_account_missing(error: &JsonRpcError) -> bool { + error.code == ACCOUNT_NOT_FOUND_ERROR_CODE +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::{TEST_ADDRESS, create_near_test_client}; + use chain_traits::ChainBalances; + + #[tokio::test] + async fn test_near_get_balance_coin() -> Result<(), Box> { + let client = create_near_test_client()?; + let address = TEST_ADDRESS.to_string(); + let balance = client.get_balance_coin(address).await?; + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + println!("Balance: {} {}", balance.balance.available, balance.asset_id); + Ok(()) + } + + #[tokio::test] + async fn test_near_get_balance_assets() -> Result<(), Box> { + let client = create_near_test_client()?; + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + assert_eq!(assets.len(), 0); + Ok(()) + } +} diff --git a/core/crates/gem_near/src/provider/balances_mapper.rs b/core/crates/gem_near/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..f409f36813 --- /dev/null +++ b/core/crates/gem_near/src/provider/balances_mapper.rs @@ -0,0 +1,26 @@ +use crate::models::account::Account; +use primitives::{AssetBalance, Chain}; +use std::error::Error; + +pub fn map_native_balance(account: &Account) -> Result> { + Ok(AssetBalance::new(Chain::Near.as_asset_id(), account.amount.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::account::Account; + use num_bigint::BigUint; + + #[test] + fn test_map_native_balance() { + let account = Account { + amount: BigUint::from(1000000000000000000000000_u128), + }; + + let result = map_native_balance(&account).unwrap(); + + assert_eq!(result.asset_id, Chain::Near.as_asset_id()); + assert_eq!(result.balance.available, BigUint::from(1000000000000000000000000_u128)); + } +} diff --git a/core/crates/gem_near/src/provider/mod.rs b/core/crates/gem_near/src/provider/mod.rs new file mode 100644 index 0000000000..e8ce23b582 --- /dev/null +++ b/core/crates/gem_near/src/provider/mod.rs @@ -0,0 +1,22 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod state; +pub mod state_mapper; +pub mod testkit; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; + +pub use balances_mapper::*; +pub use preload_mapper::*; +pub use state_mapper::*; +pub use transaction_state_mapper::*; +pub use transactions_mapper::*; diff --git a/core/crates/gem_near/src/provider/preload.rs b/core/crates/gem_near/src/provider/preload.rs new file mode 100644 index 0000000000..72ad55c219 --- /dev/null +++ b/core/crates/gem_near/src/provider/preload.rs @@ -0,0 +1,37 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use futures::try_join; +use num_bigint::BigInt; +use std::{error::Error, str::FromStr}; + +use gem_client::Client; +use primitives::{FeeRate, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput}; + +use crate::{ + provider::{ + preload_mapper::{address_to_public_key, map_transaction_preload}, + state_mapper::map_gas_price_to_priorities, + }, + rpc::client::NearClient, +}; + +#[async_trait] +impl ChainTransactionLoad for NearClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let public_key = address_to_public_key(&input.sender_address)?; + let (access_key, block) = try_join!(self.get_account_access_key(&input.sender_address, &public_key), self.get_latest_block(),)?; + Ok(map_transaction_preload(&access_key, &block)) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + Ok(TransactionLoadData { + fee: TransactionFee::new_from_gas_price_and_limit(input.gas_price.gas_price(), BigInt::from_str("9000000000000")?), // "4174947687500" * 2 + metadata: input.metadata, + }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + let gas_price = self.get_gas_price().await?; + map_gas_price_to_priorities(&gas_price) + } +} diff --git a/core/crates/gem_near/src/provider/preload_mapper.rs b/core/crates/gem_near/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..819f4d14fa --- /dev/null +++ b/core/crates/gem_near/src/provider/preload_mapper.rs @@ -0,0 +1,51 @@ +use crate::models::{AccountAccessKey, Block}; +use primitives::TransactionLoadMetadata; +use std::error::Error; + +pub fn address_to_public_key(address: &str) -> Result> { + let address_bytes = hex::decode(address)?; + let encoded = bs58::encode(address_bytes).into_string(); + Ok(format!("ed25519:{}", encoded)) +} + +pub fn map_transaction_preload(access_key: &AccountAccessKey, block: &Block) -> TransactionLoadMetadata { + TransactionLoadMetadata::Near { + sequence: (access_key.nonce + 1) as u64, + block_hash: block.header.hash.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{AccountAccessKey, Block, BlockHeader}; + + #[test] + fn test_address_to_public_key() { + let address = "051d30e6c78c4cf858389d62af5f703275450d318b85ff52a4ac963948cfdf95"; + let result = address_to_public_key(address).unwrap(); + assert!(result.starts_with("ed25519:")); + } + + #[test] + fn test_map_transaction_preload() { + let access_key = AccountAccessKey { nonce: 116479371000026 }; + + let block = Block { + header: BlockHeader { + hash: "F45xbjXiyHn5noj1692RVqeuNC6X232qhKpvvPrv92iz".to_string(), + height: 12345, + }, + }; + + let result = map_transaction_preload(&access_key, &block); + + match result { + TransactionLoadMetadata::Near { sequence, block_hash } => { + assert_eq!(sequence, 116479371000027); + assert_eq!(block_hash, "F45xbjXiyHn5noj1692RVqeuNC6X232qhKpvvPrv92iz"); + } + _ => panic!("Expected Near metadata"), + } + } +} diff --git a/core/crates/gem_near/src/provider/request_classifier.rs b/core/crates/gem_near/src/provider/request_classifier.rs new file mode 100644 index 0000000000..a178677b1f --- /dev/null +++ b/core/crates/gem_near/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_json_rpc_method("send_tx") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_near/src/provider/state.rs b/core/crates/gem_near/src/provider/state.rs new file mode 100644 index 0000000000..be48a03305 --- /dev/null +++ b/core/crates/gem_near/src/provider/state.rs @@ -0,0 +1,66 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; +use primitives::NodeSyncStatus; + +use crate::provider::state_mapper; +use crate::rpc::client::NearClient; + +#[async_trait] +impl ChainState for NearClient { + async fn get_chain_id(&self) -> Result> { + Ok(self.get_status().await?.chain_id) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_latest_block().await?.header.height) + } + + async fn get_node_status(&self) -> Result> { + let block = self.get_latest_block().await?; + state_mapper::map_node_status(&block) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::rpc::client::NearClient; + use chain_traits::{ChainProvider, ChainState}; + use gem_client::ReqwestClient; + use gem_jsonrpc::{client::JsonRpcClient, new_client}; + + #[tokio::test] + async fn test_near_client_generic_interface() { + let reqwest_client = ReqwestClient::new("https://example.com".to_string(), reqwest::Client::new()); + let jsonrpc_client = JsonRpcClient::new(reqwest_client); + let near_client: NearClient = NearClient::new(jsonrpc_client); + + assert_eq!(near_client.get_chain().to_string(), "near"); + } + + #[tokio::test] + async fn test_get_chain_id() -> Result<(), Box> { + let jsonrpc_client = new_client("https://rpc.mainnet.near.org".to_string())?; + let near_client: NearClient = NearClient::new(jsonrpc_client); + + let chain_id = near_client.get_chain_id().await?; + assert_eq!(chain_id, "mainnet"); + + Ok(()) + } + + #[tokio::test] + async fn test_get_node_status() -> Result<(), Box> { + let jsonrpc_client = new_client("https://rpc.mainnet.near.org".to_string())?; + let near_client: NearClient = NearClient::new(jsonrpc_client); + let node_status = near_client.get_node_status().await?; + + assert!(node_status.in_sync); + assert!(node_status.latest_block_number.is_some()); + assert!(node_status.latest_block_number.unwrap_or(0) > 0); + + Ok(()) + } +} diff --git a/core/crates/gem_near/src/provider/state_mapper.rs b/core/crates/gem_near/src/provider/state_mapper.rs new file mode 100644 index 0000000000..44906fd289 --- /dev/null +++ b/core/crates/gem_near/src/provider/state_mapper.rs @@ -0,0 +1,63 @@ +use crate::models::Block; +use crate::models::fee::GasPrice; +use primitives::{FeePriority, FeeRate, GasPriceType, NodeSyncStatus}; +use std::error::Error; + +pub fn map_gas_price_to_priorities(gas_price: &GasPrice) -> Result, Box> { + let base_price = gas_price.gas_price; + + Ok(vec![ + FeeRate::new(FeePriority::Slow, GasPriceType::regular(base_price)), + FeeRate::new(FeePriority::Normal, GasPriceType::regular(base_price)), + FeeRate::new(FeePriority::Fast, GasPriceType::regular(base_price * 2)), + ]) +} + +pub fn map_node_status(block: &Block) -> Result> { + Ok(NodeSyncStatus::synced(block.header.height)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::fee::GasPrice; + use num_bigint::BigInt; + use primitives::GasPriceType; + + #[test] + fn test_map_gas_price_to_priorities() { + let gas_price = GasPrice { gas_price: 1000000000 }; + + let result = map_gas_price_to_priorities(&gas_price).unwrap(); + assert_eq!(result.len(), 3); + match &result[0].gas_price_type { + GasPriceType::Regular { gas_price } => assert_eq!(gas_price, &BigInt::from(1000000000u64)), + _ => panic!("Expected Regular gas price"), + } + match &result[1].gas_price_type { + GasPriceType::Regular { gas_price } => assert_eq!(gas_price, &BigInt::from(1000000000u64)), + _ => panic!("Expected Regular gas price"), + } + match &result[2].gas_price_type { + GasPriceType::Regular { gas_price } => assert_eq!(gas_price, &BigInt::from(2000000000u64)), + _ => panic!("Expected Regular gas price"), + } + } + + #[test] + fn test_map_node_status() { + use crate::models::{Block, BlockHeader}; + + let block = Block { + header: BlockHeader { + hash: String::new(), + height: 123456789, + }, + }; + let mapped = map_node_status(&block).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(123456789)); + assert_eq!(mapped.current_block_number, Some(123456789)); + } +} diff --git a/core/crates/gem_near/src/provider/testkit.rs b/core/crates/gem_near/src/provider/testkit.rs new file mode 100644 index 0000000000..06cee48a21 --- /dev/null +++ b/core/crates/gem_near/src/provider/testkit.rs @@ -0,0 +1,18 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::NearClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_jsonrpc::new_client; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "75b4f90dc729b28ce1a3d44b2c96b3943136f1d7ced0b5df1fc23662439e3e3c"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_near_test_client() -> Result, Box> { + let settings = get_test_settings(); + let jsonrpc_client = new_client(settings.chains.near.url)?; + Ok(NearClient::new(jsonrpc_client)) +} diff --git a/core/crates/gem_near/src/provider/transaction_broadcast.rs b/core/crates/gem_near/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..514a3a2e84 --- /dev/null +++ b/core/crates/gem_near/src/provider/transaction_broadcast.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{BroadcastProvider, transaction_broadcast_mapper::map_transaction_broadcast_response_from_str, transactions_mapper::map_transaction_broadcast}, + rpc::client::NearClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for NearClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(&data).await?; + map_transaction_broadcast(&response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_near/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_near/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..3ce07e8401 --- /dev/null +++ b/core/crates/gem_near/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,11 @@ +use std::error::Error; + +use gem_jsonrpc::types::JsonRpcResult; + +use crate::models::transaction::BroadcastResult; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::>(response)?.take()?; + map_transaction_broadcast(&response) +} diff --git a/core/crates/gem_near/src/provider/transaction_state.rs b/core/crates/gem_near/src/provider/transaction_state.rs new file mode 100644 index 0000000000..cc7a7588f9 --- /dev/null +++ b/core/crates/gem_near/src/provider/transaction_state.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::NearClient}; + +#[async_trait] +impl ChainTransactionState for NearClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let result = self.get_transaction_status(&request.id, &request.sender_address).await?; + Ok(map_transaction_status(&result)) + } +} diff --git a/core/crates/gem_near/src/provider/transaction_state_mapper.rs b/core/crates/gem_near/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..0694a20e2e --- /dev/null +++ b/core/crates/gem_near/src/provider/transaction_state_mapper.rs @@ -0,0 +1,78 @@ +use crate::models::transaction::BroadcastResult; +use num_bigint::{BigInt, BigUint}; +use primitives::{TransactionChange, TransactionState, TransactionUpdate}; + +pub fn map_transaction_status(response: &BroadcastResult) -> TransactionUpdate { + let state = match response.final_execution_status.as_str() { + crate::constants::TRANSACTION_STATUS_FINAL | crate::constants::TRANSACTION_STATUS_EXECUTED | crate::constants::TRANSACTION_STATUS_EXECUTED_OPTIMISTIC => { + TransactionState::Confirmed + } + _ => TransactionState::Failed, + }; + + let changes = vec![TransactionChange::NetworkFee(BigInt::from( + &response.transaction_outcome.outcome.tokens_burnt * BigUint::from(2u64), + ))]; + + TransactionUpdate { state, changes } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::transaction::{BroadcastResult, BroadcastTransaction, Outcome, TransactionOutcome}; + + fn create_test_transaction() -> BroadcastTransaction { + BroadcastTransaction { + hash: "5qSP5dRVr5KQ37Dd9CV2gi7KDuvtU4eFaRK7cDKREVL2".to_string(), + signer_id: "test.near".to_string(), + receiver_id: "receiver.near".to_string(), + actions: vec![], + } + } + + fn create_test_outcome(tokens_burnt: &str) -> TransactionOutcome { + TransactionOutcome { + outcome: Outcome { + tokens_burnt: tokens_burnt.parse().unwrap(), + }, + } + } + + #[test] + fn test_map_transaction_status_confirmed() { + let response = BroadcastResult { + final_execution_status: crate::constants::TRANSACTION_STATUS_FINAL.to_string(), + transaction: create_test_transaction(), + transaction_outcome: create_test_outcome("417494768750000000000"), + }; + + let result = map_transaction_status(&response); + assert_eq!(result.state, TransactionState::Confirmed); + assert_eq!(result.changes.len(), 1); + if let TransactionChange::NetworkFee(fee) = &result.changes[0] { + assert_eq!(fee, &"834989537500000000000".parse::().unwrap()); + } + } + + #[test] + fn test_map_transaction_status_failed() { + let response = BroadcastResult { + final_execution_status: "EXECUTION_FAILURE".to_string(), + transaction: create_test_transaction(), + transaction_outcome: create_test_outcome("0"), + }; + + let result = map_transaction_status(&response); + assert_eq!(result.state, TransactionState::Failed); + } + + #[test] + fn test_map_real_transaction_response() { + let response: primitives::JsonRpcResult = serde_json::from_str(include_str!("../../testdata/successful_transaction.json")).unwrap(); + + let status_update = map_transaction_status(&response.result); + assert_eq!(status_update.state, TransactionState::Confirmed); + assert_eq!(status_update.changes.len(), 1); + } +} diff --git a/core/crates/gem_near/src/provider/transactions.rs b/core/crates/gem_near/src/provider/transactions.rs new file mode 100644 index 0000000000..aced5fb6be --- /dev/null +++ b/core/crates/gem_near/src/provider/transactions.rs @@ -0,0 +1,19 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::rpc::client::NearClient; + +#[async_trait] +impl ChainTransactions for NearClient { + async fn get_transactions_by_block(&self, _block: u64) -> Result, Box> { + Ok(vec![]) + } + + async fn get_transactions_by_address(&self, _request: TransactionsRequest) -> Result, Box> { + Ok(vec![]) + } +} diff --git a/core/crates/gem_near/src/provider/transactions_mapper.rs b/core/crates/gem_near/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..faa02ffa21 --- /dev/null +++ b/core/crates/gem_near/src/provider/transactions_mapper.rs @@ -0,0 +1,100 @@ +use crate::constants::{TRANSACTION_STATUS_EXECUTED, TRANSACTION_STATUS_EXECUTED_OPTIMISTIC, TRANSACTION_STATUS_FINAL}; +use crate::models::{rpc, transaction::BroadcastResult}; +use chrono::DateTime; +use primitives::{Transaction, TransactionState, TransactionType, chain::Chain}; +use std::error::Error; + +pub fn map_transaction_broadcast(response: &BroadcastResult) -> Result> { + match response.final_execution_status.as_str() { + TRANSACTION_STATUS_FINAL | TRANSACTION_STATUS_EXECUTED | TRANSACTION_STATUS_EXECUTED_OPTIMISTIC => Ok(response.transaction.hash.clone()), + _ => Err(format!("Broadcast failed with status: {}", response.final_execution_status).into()), + } +} + +pub fn map_transaction(chain: Chain, header: rpc::BlockHeader, transaction: rpc::Transaction) -> Option { + if transaction.actions.len() == 1 || transaction.actions.len() == 2 { + let created_at = DateTime::from_timestamp_nanos(header.timestamp as i64); + + match &transaction.actions.last()? { + rpc::Action::Transfer { deposit } => { + let asset_id = chain.as_asset_id(); + return Some(Transaction::new( + transaction.hash, + asset_id.clone(), + transaction.signer_id, + transaction.receiver_id, + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "830000000000000000000".to_string(), + asset_id, + deposit.clone(), + None, + None, + created_at, + )); + } + rpc::Action::CreateAccount | rpc::Action::Other(_) => return None, + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::transaction::{BroadcastResult, BroadcastTransaction, Outcome, TransactionOutcome}; + use primitives::JsonRpcResult; + + fn create_test_transaction() -> BroadcastTransaction { + BroadcastTransaction { + hash: "5qSP5dRVr5KQ37Dd9CV2gi7KDuvtU4eFaRK7cDKREVL2".to_string(), + signer_id: "test.near".to_string(), + receiver_id: "receiver.near".to_string(), + actions: vec![], + } + } + + fn create_test_outcome(tokens_burnt: &str) -> TransactionOutcome { + TransactionOutcome { + outcome: Outcome { + tokens_burnt: tokens_burnt.parse().unwrap(), + }, + } + } + + #[test] + fn test_map_transaction_broadcast_success() { + for status in [TRANSACTION_STATUS_FINAL, TRANSACTION_STATUS_EXECUTED, TRANSACTION_STATUS_EXECUTED_OPTIMISTIC] { + let response = BroadcastResult { + final_execution_status: status.to_string(), + transaction: create_test_transaction(), + transaction_outcome: create_test_outcome("417494768750000000000"), + }; + + let result = map_transaction_broadcast(&response).unwrap(); + assert_eq!(result, "5qSP5dRVr5KQ37Dd9CV2gi7KDuvtU4eFaRK7cDKREVL2"); + } + } + + #[test] + fn test_map_transaction_broadcast_failure() { + let response = BroadcastResult { + final_execution_status: "EXECUTION_FAILURE".to_string(), + transaction: create_test_transaction(), + transaction_outcome: create_test_outcome("0"), + }; + + let result = map_transaction_broadcast(&response); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("EXECUTION_FAILURE")); + } + + #[test] + fn test_map_real_transaction_response() { + let response: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/successful_transaction.json")).unwrap(); + + let hash = map_transaction_broadcast(&response.result).unwrap(); + assert_eq!(hash, "5qSP5dRVr5KQ37Dd9CV2gi7KDuvtU4eFaRK7cDKREVL2"); + } +} diff --git a/core/crates/gem_near/src/rpc/client.rs b/core/crates/gem_near/src/rpc/client.rs new file mode 100644 index 0000000000..a55f969d81 --- /dev/null +++ b/core/crates/gem_near/src/rpc/client.rs @@ -0,0 +1,79 @@ +use crate::models::{Account, AccountAccessKey, Block, BroadcastResult, GasPrice, NodeStatus}; +use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual, ChainProvider, ChainStaking, ChainToken, ChainTraits}; +use gem_client::Client; +use gem_jsonrpc::{client::JsonRpcClient, types::JsonRpcError}; +use primitives::Chain; +use serde_json::json; + +#[derive(Debug)] +pub struct NearClient { + client: JsonRpcClient, + pub chain: Chain, +} + +impl NearClient { + pub fn new(client: JsonRpcClient) -> Self { + Self { client, chain: Chain::Near } + } + + pub async fn get_account(&self, address: &str) -> Result { + let params = json!({ + "request_type": "view_account", + "finality": "final", + "account_id": address + }); + self.client.call("query", params).await + } + + pub async fn get_account_access_key(&self, address: &str, public_key: &str) -> Result { + let params = json!({ + "request_type": "view_access_key", + "finality": "final", + "account_id": address, + "public_key": public_key + }); + self.client.call("query", params).await + } + + pub async fn get_latest_block(&self) -> Result { + let params = json!({"finality": "final"}); + self.client.call("block", params).await + } + + pub async fn get_gas_price(&self) -> Result { + let params = json!([null]); + self.client.call("gas_price", params).await + } + + pub async fn get_status(&self) -> Result { + let params = json!([]); + self.client.call("status", params).await + } + + pub async fn broadcast_transaction(&self, signed_tx_base64: &str) -> Result { + let params = json!({"signed_tx_base64": signed_tx_base64}); + self.client.call("send_tx", params).await + } + + pub async fn get_transaction_status(&self, tx_hash: &str, sender_account_id: &str) -> Result { + let params = json!({ + "tx_hash": tx_hash, + "sender_account_id": sender_account_id, + "wait_until": "EXECUTED" + }); + self.client.call("tx", params).await + } +} + +impl ChainProvider for NearClient { + fn get_chain(&self) -> Chain { + self.chain + } +} + +impl ChainStaking for NearClient {} +impl ChainPerpetual for NearClient {} +impl ChainAddressStatus for NearClient {} +impl ChainAccount for NearClient {} +impl ChainToken for NearClient {} +impl ChainTraits for NearClient {} diff --git a/core/crates/gem_near/src/rpc/mod.rs b/core/crates/gem_near/src/rpc/mod.rs new file mode 100644 index 0000000000..29c62dad8e --- /dev/null +++ b/core/crates/gem_near/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::NearClient; diff --git a/core/crates/gem_near/src/signer/chain_signer.rs b/core/crates/gem_near/src/signer/chain_signer.rs new file mode 100644 index 0000000000..981fb0595c --- /dev/null +++ b/core/crates/gem_near/src/signer/chain_signer.rs @@ -0,0 +1,39 @@ +use crate::signer::models::NearTransfer; +use crate::signer::signing; +use primitives::{ChainSigner, SignerError, SignerInput}; + +#[derive(Default)] +pub struct NearChainSigner; + +impl ChainSigner for NearChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let transaction = NearTransfer::from_input(input)?; + signing::sign_transfer(&transaction, private_key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{TransactionFee, TransactionLoadInput}; + + // Tests taken from https://github.com/trustwallet/wallet-core/blob/master/tests/chains/NEAR/SignerTests.cpp + #[test] + fn test_sign_near_transfer() { + let private_key = bs58::decode("3hoMW1HvnRLSFCLZnvPzWeoGwtdHzke34B2cTHM8rhcbG3TbuLKtShTv3DvyejnXKXKBiV7YPkLeqUHN1ghnqpFv") + .into_vec() + .unwrap(); + + let input = SignerInput::new( + TransactionLoadInput::mock_near("test.near", "whatever.near", "1", 1, "244ZQ9cgj3CQ6bWBdytfrJMuMQ1jdXLFGnr4HhvtCTnM"), + TransactionFee::new_from_fee(0.into()), + ); + + let signed = NearChainSigner.sign_transfer(&input, &private_key[..32]).unwrap(); + + assert_eq!( + signed, + "CQAAAHRlc3QubmVhcgCRez0mjUtY9/7BsVC9aNab4+5dTMOYVeNBU4Rlu3eGDQEAAAAAAAAADQAAAHdoYXRldmVyLm5lYXIPpHP9JpAd8pa+atxMxN800EDvokNSJLaYaRDmMML+9gEAAAADAQAAAAAAAAAAAAAAAAAAAACWmoMzIYbul1Xkg5MlUlgG4Ymj0tK7S0dg6URD6X4cTyLe7vAFmo6XExAO2m4ZFE2n6KDvflObIHCLodjQIb0B" + ); + } +} diff --git a/core/crates/gem_near/src/signer/mod.rs b/core/crates/gem_near/src/signer/mod.rs new file mode 100644 index 0000000000..606114e516 --- /dev/null +++ b/core/crates/gem_near/src/signer/mod.rs @@ -0,0 +1,6 @@ +mod chain_signer; +mod models; +mod serialization; +mod signing; + +pub use chain_signer::NearChainSigner; diff --git a/core/crates/gem_near/src/signer/models.rs b/core/crates/gem_near/src/signer/models.rs new file mode 100644 index 0000000000..dbc72d3617 --- /dev/null +++ b/core/crates/gem_near/src/signer/models.rs @@ -0,0 +1,27 @@ +use primitives::{SignerError, SignerInput}; + +pub struct NearTransfer { + pub signer_id: String, + pub receiver_id: String, + pub nonce: u64, + pub block_hash: [u8; 32], + pub deposit: [u8; 16], +} + +impl NearTransfer { + pub fn from_input(input: &SignerInput) -> Result { + let block_hash: [u8; 32] = bs58::decode(input.metadata.get_block_hash()?) + .into_vec() + .map_err(|e| SignerError::invalid_input(format!("invalid NEAR block hash: {e}")))? + .try_into() + .map_err(|_| SignerError::invalid_input("NEAR block hash must be 32 bytes"))?; + + Ok(Self { + signer_id: input.sender_address.clone(), + receiver_id: input.destination_address.clone(), + nonce: input.metadata.get_sequence()?, + block_hash, + deposit: input.value.parse::().map_err(|_| SignerError::invalid_input("invalid NEAR amount"))?.to_le_bytes(), + }) + } +} diff --git a/core/crates/gem_near/src/signer/serialization.rs b/core/crates/gem_near/src/signer/serialization.rs new file mode 100644 index 0000000000..40ef3bdcce --- /dev/null +++ b/core/crates/gem_near/src/signer/serialization.rs @@ -0,0 +1,24 @@ +use super::models::NearTransfer; +use signer::ED25519_KEY_TYPE; + +const TRANSFER_ACTION: u8 = 3; + +pub fn encode_transfer(transfer: &NearTransfer, public_key: &[u8; 32]) -> Vec { + let mut buf = Vec::with_capacity(128); + write_string(&mut buf, &transfer.signer_id); + buf.push(ED25519_KEY_TYPE); + buf.extend_from_slice(public_key); + buf.extend_from_slice(&transfer.nonce.to_le_bytes()); + write_string(&mut buf, &transfer.receiver_id); + buf.extend_from_slice(&transfer.block_hash); + // 1 action + buf.extend_from_slice(&1u32.to_le_bytes()); + buf.push(TRANSFER_ACTION); + buf.extend_from_slice(&transfer.deposit); + buf +} + +fn write_string(buf: &mut Vec, value: &str) { + buf.extend_from_slice(&(value.len() as u32).to_le_bytes()); + buf.extend_from_slice(value.as_bytes()); +} diff --git a/core/crates/gem_near/src/signer/signing.rs b/core/crates/gem_near/src/signer/signing.rs new file mode 100644 index 0000000000..699b6c084c --- /dev/null +++ b/core/crates/gem_near/src/signer/signing.rs @@ -0,0 +1,18 @@ +use super::models::NearTransfer; +use super::serialization::encode_transfer; +use gem_encoding::encode_base64; +use gem_hash::sha2::sha256; +use primitives::SignerError; +use signer::{ED25519_KEY_TYPE, Ed25519KeyPair}; + +pub fn sign_transfer(transfer: &NearTransfer, private_key: &[u8]) -> Result { + let key_pair = Ed25519KeyPair::from_private_key(private_key)?; + let encoded = encode_transfer(transfer, &key_pair.public_key_bytes); + let digest = sha256(&encoded); + let signature = key_pair.sign(&digest); + + let mut signed = encoded; + signed.push(ED25519_KEY_TYPE); + signed.extend_from_slice(&signature); + Ok(encode_base64(&signed)) +} diff --git a/core/crates/gem_near/testdata/balance_coin.json b/core/crates/gem_near/testdata/balance_coin.json new file mode 100644 index 0000000000..00cff0155d --- /dev/null +++ b/core/crates/gem_near/testdata/balance_coin.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 1755821595, + "result": { + "amount": "3229630020925000000000000", + "block_hash": "FU5nK8BtjaiY4EtTzJ5S9cGouJ43xuRh7mdfmipbrurS", + "block_height": 160650185, + "code_hash": "11111111111111111111111111111111", + "locked": "0", + "storage_paid_at": 0, + "storage_usage": 182 + } + } \ No newline at end of file diff --git a/core/crates/gem_near/testdata/successful_transaction.json b/core/crates/gem_near/testdata/successful_transaction.json new file mode 100644 index 0000000000..14b7ad2c46 --- /dev/null +++ b/core/crates/gem_near/testdata/successful_transaction.json @@ -0,0 +1,164 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "final_execution_status": "FINAL", + "receipts_outcome": [ + { + "block_hash": "5CyzCwkvs3ZKPwBiSDSvYVmZPJrvEet6ZNXv8xdgxUT6", + "id": "GL8AT7dmdpMJs1QmQNrPvghWaZgxMUuapWPshAVgQsPu", + "outcome": { + "executor_id": "75b4f90dc729b28ce1a3d44b2c96b3943136f1d7ced0b5df1fc23662439e3e3c", + "gas_burnt": 4174947687500, + "logs": [], + "metadata": { + "gas_profile": [], + "version": 3 + }, + "receipt_ids": [ + "9dRumLFVfSHG6a8DAnfEcFPU2JQ4Rb4wUdSq1NfAea6Z" + ], + "status": { + "SuccessValue": "" + }, + "tokens_burnt": "417494768750000000000" + }, + "proof": [ + { + "direction": "Left", + "hash": "HJ9UjUZxKnquL7PoE2ughaWRre83AzkBVaXoRP5LqtzH" + }, + { + "direction": "Left", + "hash": "5LakmGMgrBQcikJg6vPLfz6ieny8cDkZqRAtNSTAGbaM" + }, + { + "direction": "Left", + "hash": "5zR4NFB4DXv61XYutedESA85Pw1drUG5xMJUd7KREHBg" + }, + { + "direction": "Left", + "hash": "DUdSJjvEmJdSavx2QbWvLLjAU8Gr6wn7LLRdfsM8htPt" + }, + { + "direction": "Right", + "hash": "EsDt2h9XVG2cjmhHDWY97psrb3Yk9JJdQDQe9WdPiiaC" + }, + { + "direction": "Right", + "hash": "H3AVfkSnSRjaij4PjVQBWZSRUtGZaPX25qFLDhurMuNs" + }, + { + "direction": "Right", + "hash": "DfFijsahNwUvhYD5pmwddw2v89JkQsRp94jxYhNfPy2z" + } + ] + }, + { + "block_hash": "5pRDEsTmAH49fne5iHxGtk9MNHC7mJBpBCQGfbf6TySG", + "id": "9dRumLFVfSHG6a8DAnfEcFPU2JQ4Rb4wUdSq1NfAea6Z", + "outcome": { + "executor_id": "051d30e6c78c4cf858389d62af5f703275450d318b85ff52a4ac963948cfdf95", + "gas_burnt": 4174947687500, + "logs": [], + "metadata": { + "gas_profile": [], + "version": 3 + }, + "receipt_ids": [], + "status": { + "SuccessValue": "" + }, + "tokens_burnt": "0" + }, + "proof": [ + { + "direction": "Left", + "hash": "4jCmuqqLA75qbR6Exop8A2ehA6TdwAFZV8U9UNHfzdA8" + }, + { + "direction": "Right", + "hash": "ZCKfCqrixB8wcCmufgmTYbtt9xJNkXEhY1sYWkqKnEy" + }, + { + "direction": "Right", + "hash": "GcPFWXM9VfpLaukZEapCLJUNVuuDWJa186g4M6RpWDLL" + }, + { + "direction": "Right", + "hash": "7YeFvFEW18yuYtkjqSwXGQHjEh6nzcErvoBSpNMTykuK" + }, + { + "direction": "Left", + "hash": "GgQ69FVY4118JSMCJVoaB7tiLLMr1CofDth18MhG8KXX" + } + ] + } + ], + "status": { + "SuccessValue": "" + }, + "transaction": { + "actions": [ + { + "Transfer": { + "deposit": "1000000000000000000000000" + } + } + ], + "hash": "5qSP5dRVr5KQ37Dd9CV2gi7KDuvtU4eFaRK7cDKREVL2", + "nonce": 116479371000027, + "priority_fee": 0, + "public_key": "ed25519:Lxrbp8DSKRxCUH5EbuAh5M2JQ6apUQhKD4PVMHrpKMW", + "receiver_id": "75b4f90dc729b28ce1a3d44b2c96b3943136f1d7ced0b5df1fc23662439e3e3c", + "signature": "ed25519:3RFpywQE4BpRS35fgLcy7zU8thKhLWf7WWr1MgZQm74QJYfK5ALckoMD4tN7TY4tuQGxRijhRJB5Mr3GvcKJdDEt", + "signer_id": "051d30e6c78c4cf858389d62af5f703275450d318b85ff52a4ac963948cfdf95" + }, + "transaction_outcome": { + "block_hash": "F45xbjXiyHn5noj1692RVqeuNC6X232qhKpvvPrv92iz", + "id": "5qSP5dRVr5KQ37Dd9CV2gi7KDuvtU4eFaRK7cDKREVL2", + "outcome": { + "executor_id": "051d30e6c78c4cf858389d62af5f703275450d318b85ff52a4ac963948cfdf95", + "gas_burnt": 4174947687500, + "logs": [], + "metadata": { + "gas_profile": null, + "version": 1 + }, + "receipt_ids": [ + "GL8AT7dmdpMJs1QmQNrPvghWaZgxMUuapWPshAVgQsPu" + ], + "status": { + "SuccessReceiptId": "GL8AT7dmdpMJs1QmQNrPvghWaZgxMUuapWPshAVgQsPu" + }, + "tokens_burnt": "417494768750000000000" + }, + "proof": [ + { + "direction": "Right", + "hash": "GwiunTKNssSwDy1oGd5ZSaPTNqAutgLdEomL7CHEM642" + }, + { + "direction": "Right", + "hash": "BVqgj3rgYSe73iuELMNCRJeoLYGorYTv2CbhVSdQJoFy" + }, + { + "direction": "Left", + "hash": "3LeAMZM15iqhryavYX5pqq65j8imdAzNqCwBugcDNRUx" + }, + { + "direction": "Right", + "hash": "FdDqzP7UyZYJiPRFWQhEH5u1rWDshCE8WM318Z3gwmbL" + }, + { + "direction": "Right", + "hash": "JE6cBQbR8K5Hv1Rsvy1FLePsxP8f8nGkewysrauSqqkJ" + }, + { + "direction": "Right", + "hash": "Cqd84RKLjuCogCeCHj66RBrSYizeJe3ANXT6D4w6GxBc" + } + ] + } + } +} \ No newline at end of file diff --git a/core/crates/gem_near/testdata/transaction_transfer_error.json b/core/crates/gem_near/testdata/transaction_transfer_error.json new file mode 100644 index 0000000000..7becd59036 --- /dev/null +++ b/core/crates/gem_near/testdata/transaction_transfer_error.json @@ -0,0 +1,18 @@ +{ + "jsonrpc": "2.0", + "id": 1755821717, + "error": { + "code": -32000, + "message": "Server error", + "data": { + "TxExecutionError": { + "InvalidTxError": { + "InvalidNonce": { + "ak_nonce": 116479371000028, + "tx_nonce": 116479371000028 + } + } + } + } + } + } \ No newline at end of file diff --git a/core/crates/gem_near/testdata/transaction_transfer_success.json b/core/crates/gem_near/testdata/transaction_transfer_success.json new file mode 100644 index 0000000000..3a4e39df5e --- /dev/null +++ b/core/crates/gem_near/testdata/transaction_transfer_success.json @@ -0,0 +1,102 @@ +{ + "jsonrpc": "2.0", + "id": 1755821647, + "result": { + "final_execution_status": "EXECUTED_OPTIMISTIC", + "receipts_outcome": [ + { + "block_hash": "121qtkuXQms5sAhdvgy18gLCq81v6gDD5wKvgBtrfRyP", + "id": "GHGqmxbKS6Jv9yUPufd41Su1cX712Qub7t49nJV9RgH2", + "outcome": { + "executor_id": "75b4f90dc729b28ce1a3d44b2c96b3943136f1d7ced0b5df1fc23662439e3e3c", + "gas_burnt": 4174947687500, + "logs": [], + "metadata": { + "gas_profile": [], + "version": 3 + }, + "receipt_ids": [], + "status": { + "SuccessValue": "" + }, + "tokens_burnt": "417494768750000000000" + }, + "proof": [ + { + "direction": "Left", + "hash": "89gHguXNMntfxPtAaYFz4MnpgTrwtjd3D6YWAp3C7aUL" + }, + { + "direction": "Left", + "hash": "3YDUwYJTmMA2MUbQFU2CzuWaPTmdPDuFaahjb5Hobdtm" + }, + { + "direction": "Left", + "hash": "BDa7WYPeG5mMYqN91rCnvJgTBrYCQEC7QyV4U6bNy6Yi" + }, + { + "direction": "Right", + "hash": "FEnRRsu2HV5dhKGXbrQDYpQADp23ZfTmLMfuVkyXkSov" + } + ] + } + ], + "status": { + "SuccessValue": "" + }, + "transaction": { + "actions": [ + { + "Transfer": { + "deposit": "10000000000000000000000" + } + } + ], + "hash": "E36PnGQHhq4ahUt1W2b9nv7Mu1qaGq1aEp63GAsSka1z", + "nonce": 116479371000028, + "priority_fee": 0, + "public_key": "ed25519:Lxrbp8DSKRxCUH5EbuAh5M2JQ6apUQhKD4PVMHrpKMW", + "receiver_id": "75b4f90dc729b28ce1a3d44b2c96b3943136f1d7ced0b5df1fc23662439e3e3c", + "signature": "ed25519:sv6gFM3ttyYzQofjh7g7UiU2CNRvwL97dPLPdbdUYAvRYJ3RygVmWxtaxohWf5VE84mJCRjwAZA7jcmfZN6NtKF", + "signer_id": "051d30e6c78c4cf858389d62af5f703275450d318b85ff52a4ac963948cfdf95" + }, + "transaction_outcome": { + "block_hash": "GGAinqESffEe4h6TxyCDMc6VfQPVqxR6pvKcrozwKxQn", + "id": "E36PnGQHhq4ahUt1W2b9nv7Mu1qaGq1aEp63GAsSka1z", + "outcome": { + "executor_id": "051d30e6c78c4cf858389d62af5f703275450d318b85ff52a4ac963948cfdf95", + "gas_burnt": 4174947687500, + "logs": [], + "metadata": { + "gas_profile": null, + "version": 1 + }, + "receipt_ids": [ + "GHGqmxbKS6Jv9yUPufd41Su1cX712Qub7t49nJV9RgH2" + ], + "status": { + "SuccessReceiptId": "GHGqmxbKS6Jv9yUPufd41Su1cX712Qub7t49nJV9RgH2" + }, + "tokens_burnt": "417494768750000000000" + }, + "proof": [ + { + "direction": "Left", + "hash": "BkX95t2JfrPqgEh2q2qfsp24dcFVZuAGT39zMedJEVwG" + }, + { + "direction": "Right", + "hash": "6MJnR1FRdc5eTFh4y28QMjLDL3Hmn1gU91f6a1VFawNP" + }, + { + "direction": "Right", + "hash": "HLMmq2vYaYTfwG6nPZJMbh1zzkdWd1ftTY8ZP4vS9eCh" + }, + { + "direction": "Right", + "hash": "5N7KTvLoZBBuoFPXavnn76hKrGitg2DsAyC6U4zhExYG" + } + ] + } + } + } \ No newline at end of file diff --git a/core/crates/gem_polkadot/Cargo.toml b/core/crates/gem_polkadot/Cargo.toml new file mode 100644 index 0000000000..c67a7cf16e --- /dev/null +++ b/core/crates/gem_polkadot/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "gem_polkadot" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = ["rpc"] +signer = [] +rpc = [ + "dep:serde_json", + "dep:serde_serializers", + "dep:async-trait", + "dep:gem_client", + "dep:chain_traits", +] +reqwest = ["gem_client/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "primitives/testkit", "settings/testkit"] + +[dependencies] +primitives = { path = "../primitives" } +serde = { workspace = true, features = ["derive"] } +num-bigint = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +bs58 = { workspace = true } +gem_hash = { path = "../gem_hash" } +hex = { workspace = true } +signer = { path = "../signer" } +rand = { workspace = true } + +# Optional RPC dependencies +serde_json = { workspace = true, optional = true } +serde_serializers = { path = "../serde_serializers", features = ["bigint"], optional = true } +async-trait = { workspace = true, optional = true } +gem_client = { path = "../gem_client", optional = true } +chain_traits = { path = "../chain_traits", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_polkadot/src/address.rs b/core/crates/gem_polkadot/src/address.rs new file mode 100644 index 0000000000..f3451fca19 --- /dev/null +++ b/core/crates/gem_polkadot/src/address.rs @@ -0,0 +1,98 @@ +use std::fmt; + +use primitives::{Address, SignerError}; + +const POLKADOT_PREFIX: u8 = 0; +const ADDRESS_DATA_LENGTH: usize = 32; +const ADDRESS_CHECKSUM_LENGTH: usize = 2; +const ADDRESS_LENGTH: usize = 1 + ADDRESS_DATA_LENGTH + ADDRESS_CHECKSUM_LENGTH; +const ADDRESS_MAX_STRING_LENGTH: usize = 60; +const SS58_CHECKSUM_PREFIX: &[u8] = b"SS58PRE"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PolkadotAddress([u8; ADDRESS_DATA_LENGTH]); + +impl Address for PolkadotAddress { + fn try_parse(value: &str) -> Option { + if value.len() > ADDRESS_MAX_STRING_LENGTH { + return None; + } + + let decoded = bs58::decode(value).into_vec().ok()?; + if decoded.len() != ADDRESS_LENGTH || decoded[0] != POLKADOT_PREFIX { + return None; + } + + let checksum = Self::checksum(&decoded[..1 + ADDRESS_DATA_LENGTH]); + if decoded[1 + ADDRESS_DATA_LENGTH..] != checksum { + return None; + } + + Some(Self(decoded[1..1 + ADDRESS_DATA_LENGTH].try_into().ok()?)) + } + + fn as_bytes(&self) -> &[u8] { + &self.0 + } + + fn encode(&self) -> String { + let mut raw = Vec::with_capacity(ADDRESS_LENGTH); + raw.push(POLKADOT_PREFIX); + raw.extend_from_slice(&self.0); + raw.extend_from_slice(&Self::checksum(&raw)); + bs58::encode(raw).into_string() + } +} + +impl PolkadotAddress { + pub(crate) fn parse(value: &str) -> Result { + Self::try_parse(value).ok_or_else(|| SignerError::invalid_input("invalid Polkadot address")) + } + + pub(crate) fn account_id(&self) -> &[u8; ADDRESS_DATA_LENGTH] { + &self.0 + } + + fn checksum(data: &[u8]) -> [u8; ADDRESS_CHECKSUM_LENGTH] { + let mut prefixed = Vec::with_capacity(SS58_CHECKSUM_PREFIX.len() + data.len()); + prefixed.extend_from_slice(SS58_CHECKSUM_PREFIX); + prefixed.extend_from_slice(data); + + let hash = gem_hash::blake2::blake2b_512(&prefixed); + let mut checksum = [0u8; ADDRESS_CHECKSUM_LENGTH]; + checksum.copy_from_slice(&hash[..ADDRESS_CHECKSUM_LENGTH]); + checksum + } +} + +pub fn validate_address(address: &str) -> bool { + PolkadotAddress::is_valid(address) +} + +impl fmt::Display for PolkadotAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_ADDRESS: &str = "15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXo"; + const PUBLIC_KEY: &str = "cd3cfbbaa8f217c2a29ceae4b4063b597b629861916bad98f9826e03d1ab120e"; + + #[test] + fn test_polkadot_address() { + let parsed = PolkadotAddress::from_str(VALID_ADDRESS).unwrap(); + + assert!(validate_address(VALID_ADDRESS)); + assert_eq!(hex::encode(parsed.as_bytes()), PUBLIC_KEY); + assert_eq!(parsed.to_string(), VALID_ADDRESS); + assert_eq!(PolkadotAddress::try_parse(&parsed.encode()), Some(parsed)); + + assert!(PolkadotAddress::from_str("invalid").is_err()); + assert!(!validate_address(&"1".repeat(ADDRESS_MAX_STRING_LENGTH + 1))); + assert!(!validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXj")); + } +} diff --git a/core/crates/gem_polkadot/src/constants.rs b/core/crates/gem_polkadot/src/constants.rs new file mode 100644 index 0000000000..81d856b21d --- /dev/null +++ b/core/crates/gem_polkadot/src/constants.rs @@ -0,0 +1,2 @@ +pub const TRANSACTION_TYPE_TRANSFER_KEEP_ALIVE: &str = "transferKeepAlive"; +pub const TRANSACTION_TYPE_TRANSFER_ALLOW_DEATH: &str = "transferAllowDeath"; diff --git a/core/crates/gem_polkadot/src/lib.rs b/core/crates/gem_polkadot/src/lib.rs new file mode 100644 index 0000000000..568eaef3fd --- /dev/null +++ b/core/crates/gem_polkadot/src/lib.rs @@ -0,0 +1,16 @@ +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +pub mod address; +pub mod constants; +pub mod models; +#[cfg(feature = "signer")] +pub mod signer; +mod transfer; + +pub use address::{PolkadotAddress, validate_address}; +#[cfg(feature = "rpc")] +pub use rpc::client::PolkadotClient; diff --git a/core/crates/gem_polkadot/src/models/account.rs b/core/crates/gem_polkadot/src/models/account.rs new file mode 100644 index 0000000000..1f59cf670f --- /dev/null +++ b/core/crates/gem_polkadot/src/models/account.rs @@ -0,0 +1,13 @@ +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_bigint_from_str, deserialize_u64_from_str, serialize_bigint}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolkadotAccountBalance { + #[serde(serialize_with = "serialize_bigint", deserialize_with = "deserialize_bigint_from_str")] + pub free: BigInt, + #[serde(serialize_with = "serialize_bigint", deserialize_with = "deserialize_bigint_from_str")] + pub reserved: BigInt, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub nonce: u64, +} diff --git a/core/crates/gem_polkadot/src/models/block.rs b/core/crates/gem_polkadot/src/models/block.rs new file mode 100644 index 0000000000..942ccca8d4 --- /dev/null +++ b/core/crates/gem_polkadot/src/models/block.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolkadotBlock { + pub number: String, + pub extrinsics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolkadotExtrinsic { + pub hash: String, + pub success: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolkadotNodeVersion { + pub chain: String, +} diff --git a/core/crates/gem_polkadot/src/models/fee.rs b/core/crates/gem_polkadot/src/models/fee.rs new file mode 100644 index 0000000000..198d765383 --- /dev/null +++ b/core/crates/gem_polkadot/src/models/fee.rs @@ -0,0 +1,22 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_biguint_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolkadotEstimateFee { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub partial_fee: BigUint, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_estimate_fee_deserializes_base_units_without_narrowing() { + let fee: PolkadotEstimateFee = serde_json::from_str(r#"{"partialFee":"18446744073709551616"}"#).unwrap(); + + assert_eq!(fee.partial_fee, BigUint::parse_bytes(b"18446744073709551616", 10).unwrap()); + } +} diff --git a/core/crates/gem_polkadot/src/models/mod.rs b/core/crates/gem_polkadot/src/models/mod.rs new file mode 100644 index 0000000000..4375737448 --- /dev/null +++ b/core/crates/gem_polkadot/src/models/mod.rs @@ -0,0 +1,11 @@ +pub mod account; +pub mod block; +pub mod fee; +pub mod rpc; +pub mod transaction; + +pub use account::*; +pub use block::*; +pub use fee::*; +pub use rpc::*; +pub use transaction::*; diff --git a/core/crates/gem_polkadot/src/models/rpc.rs b/core/crates/gem_polkadot/src/models/rpc.rs new file mode 100644 index 0000000000..a27c7c64cf --- /dev/null +++ b/core/crates/gem_polkadot/src/models/rpc.rs @@ -0,0 +1,92 @@ +use core::str; + +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TransactionBroadcast { + pub hash: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockHeader { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub number: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Block { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub number: u64, + pub extrinsics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Extrinsic { + pub hash: String, + pub method: ExtrinsicMethod, + pub info: ExtrinsicInfo, + pub success: bool, + pub args: ExtrinsicArguments, + pub signature: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtrinsicMethod { + pub pallet: String, + pub method: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtrinsicInfo { + pub partial_fee: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ExtrinsicArguments { + Transfer(ExtrinsicTransfer), + Transfers(ExtrinsicCalls), + Timestamp(ExtrinsicTimestamp), + Other(serde_json::Value), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtrinsicTimestamp { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub now: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtrinsicTransfer { + pub value: String, + pub dest: AddressId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtrinsicCalls { + pub calls: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Call { + pub method: ExtrinsicMethod, + pub args: CallArgs, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallArgs { + pub dest: AddressId, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddressId { + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Signature { + pub signer: AddressId, +} diff --git a/core/crates/gem_polkadot/src/models/transaction.rs b/core/crates/gem_polkadot/src/models/transaction.rs new file mode 100644 index 0000000000..7846b02297 --- /dev/null +++ b/core/crates/gem_polkadot/src/models/transaction.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolkadotTransactionMaterial { + pub at: PolkadotTransactionMaterialBlock, + pub genesis_hash: String, + pub chain_name: String, + pub spec_name: String, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub spec_version: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub tx_version: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolkadotTransactionMaterialBlock { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub height: u64, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolkadotTransactionPayload { + pub tx: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolkadotTransactionBroadcastResponse { + pub hash: Option, + pub error: Option, + pub cause: Option, +} diff --git a/core/crates/gem_polkadot/src/provider/balances.rs b/core/crates/gem_polkadot/src/provider/balances.rs new file mode 100644 index 0000000000..e2138fb918 --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/balances.rs @@ -0,0 +1,55 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use std::error::Error; + +use gem_client::Client; +use primitives::AssetBalance; + +use crate::provider::balances_mapper; +use crate::rpc::client::PolkadotClient; + +#[async_trait] +impl ChainBalances for PolkadotClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let balance = self.get_balance(address).await?; + Ok(balances_mapper::map_coin_balance(balance)) + } + + async fn get_balance_tokens(&self, _address: String, _token_ids: Vec) -> Result, Box> { + Ok(vec![]) + } + + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Ok(None) + } + + async fn get_balance_assets(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, create_polkadot_test_client}; + + #[tokio::test] + async fn test_polkadot_get_balance_coin() -> Result<(), Box> { + let client = create_polkadot_test_client(); + let address = TEST_ADDRESS.to_string(); + let balance = client.get_balance_coin(address).await?; + assert_eq!(balance.asset_id.chain.to_string(), "polkadot"); + println!("Balance: {:?} {}", balance.balance, balance.asset_id); + Ok(()) + } + + #[tokio::test] + async fn test_polkadot_get_balance_assets() -> Result<(), Box> { + let client = create_polkadot_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + assert_eq!(assets.len(), 0); + Ok(()) + } +} diff --git a/core/crates/gem_polkadot/src/provider/balances_mapper.rs b/core/crates/gem_polkadot/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..4a1eab98ca --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/balances_mapper.rs @@ -0,0 +1,32 @@ +use crate::models::account::PolkadotAccountBalance; +use num_bigint::BigUint; +use primitives::{AssetBalance, Balance, Chain}; + +pub fn map_coin_balance(balance: PolkadotAccountBalance) -> AssetBalance { + let available = std::cmp::max(&balance.free - &balance.reserved, num_bigint::BigInt::from(0)); + + AssetBalance::new_balance( + Chain::Polkadot.as_asset_id(), + Balance::with_reserved(BigUint::try_from(available).unwrap_or_default(), BigUint::try_from(balance.reserved).unwrap_or_default()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_coin_balance() { + let balance = PolkadotAccountBalance { + free: num_bigint::BigInt::from(1000000000000_u64), + reserved: num_bigint::BigInt::from(100000000000_u64), + nonce: 1, + }; + + let result = map_coin_balance(balance); + + assert_eq!(result.asset_id, Chain::Polkadot.as_asset_id()); + assert_eq!(result.balance.available, BigUint::from(900000000000_u64)); + assert_eq!(result.balance.reserved, BigUint::from(100000000000_u64)); + } +} diff --git a/core/crates/gem_polkadot/src/provider/mod.rs b/core/crates/gem_polkadot/src/provider/mod.rs new file mode 100644 index 0000000000..507cd32907 --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/mod.rs @@ -0,0 +1,21 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod request_classifier; +pub mod staking; +pub mod state; +pub mod testkit; +pub mod token; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; + +// Re-export mappers for convenience +pub use balances_mapper::*; +pub use transaction_state_mapper::*; +pub use transactions_mapper::*; diff --git a/core/crates/gem_polkadot/src/provider/preload.rs b/core/crates/gem_polkadot/src/provider/preload.rs new file mode 100644 index 0000000000..c807a37b05 --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/preload.rs @@ -0,0 +1,45 @@ +use std::error::Error; + +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use num_bigint::BigInt; + +use gem_client::Client; +use primitives::{ + FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput, +}; + +use crate::rpc::client::PolkadotClient; + +#[async_trait] +impl ChainTransactionLoad for PolkadotClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let material = self.get_transaction_material().await?; + let sender_balance = self.get_balance(input.sender_address).await?; + + Ok(TransactionLoadMetadata::Polkadot { + sequence: sender_balance.nonce, + genesis_hash: material.genesis_hash, + block_hash: material.at.hash, + block_number: material.at.height, + spec_version: material.spec_version, + transaction_version: material.tx_version, + period: 64, + }) + } + + async fn get_transaction_fee_from_data(&self, transaction: String) -> Result> { + let fee = self.estimate_fee(&transaction).await?; + Ok(TransactionFee::new_from_fee(BigInt::from(fee.partial_fee))) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let fee_estimation_transaction = crate::transfer::fee_estimation_transaction(&input)?; + let fee = self.get_transaction_fee_from_data(fee_estimation_transaction).await?; + Ok(TransactionLoadData { fee, metadata: input.metadata }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1)))]) + } +} diff --git a/core/crates/gem_polkadot/src/provider/request_classifier.rs b/core/crates/gem_polkadot/src/provider/request_classifier.rs new file mode 100644 index 0000000000..761b686656 --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/transaction") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_polkadot/src/provider/staking.rs b/core/crates/gem_polkadot/src/provider/staking.rs new file mode 100644 index 0000000000..f9938832ba --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/staking.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; +use chain_traits::ChainStaking; +use std::error::Error; + +use gem_client::Client; +use primitives::{DelegationBase, DelegationValidator}; + +use crate::rpc::client::PolkadotClient; + +#[async_trait] +impl ChainStaking for PolkadotClient { + async fn get_staking_apy(&self) -> Result, Box> { + Ok(Some(10.0)) // Default APY for Polkadot + } + + async fn get_staking_validators(&self, _apy: Option) -> Result, Box> { + Ok(vec![]) + } + + async fn get_staking_delegations(&self, _address: String) -> Result, Box> { + Ok(vec![]) + } +} diff --git a/core/crates/gem_polkadot/src/provider/state.rs b/core/crates/gem_polkadot/src/provider/state.rs new file mode 100644 index 0000000000..8a0ed738f2 --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/state.rs @@ -0,0 +1,42 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; + +use crate::rpc::client::PolkadotClient; + +#[async_trait] +impl ChainState for PolkadotClient { + async fn get_chain_id(&self) -> Result> { + Ok(self.get_node_version().await?.chain) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_block_header("head").await?.number) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::create_polkadot_test_client; + + #[tokio::test] + async fn test_get_chain_id() -> Result<(), Box> { + let client = create_polkadot_test_client(); + let chain_id = client.get_chain_id().await?; + assert!(!chain_id.is_empty()); + println!("Chain ID: {}", chain_id); + Ok(()) + } + + #[tokio::test] + async fn test_get_block_latest_number() -> Result<(), Box> { + let client = create_polkadot_test_client(); + let block_number = client.get_block_latest_number().await?; + assert!(block_number > 0); + println!("Latest block: {}", block_number); + Ok(()) + } +} diff --git a/core/crates/gem_polkadot/src/provider/testkit.rs b/core/crates/gem_polkadot/src/provider/testkit.rs new file mode 100644 index 0000000000..ad07cb2292 --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/testkit.rs @@ -0,0 +1,16 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::PolkadotClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_polkadot_test_client() -> PolkadotClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.polkadot.url, reqwest::Client::new()); + PolkadotClient::new(reqwest_client) +} diff --git a/core/crates/gem_polkadot/src/provider/token.rs b/core/crates/gem_polkadot/src/provider/token.rs new file mode 100644 index 0000000000..83a05f0c61 --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/token.rs @@ -0,0 +1,19 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::Asset; + +use crate::rpc::client::PolkadotClient; + +#[async_trait] +impl ChainToken for PolkadotClient { + async fn get_token_data(&self, _token_id: String) -> Result> { + Err("Chain does not support tokens".into()) + } + + fn get_is_token_address(&self, _token_id: &str) -> bool { + false + } +} diff --git a/core/crates/gem_polkadot/src/provider/transaction_broadcast.rs b/core/crates/gem_polkadot/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..b6b6859f2a --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/transaction_broadcast.rs @@ -0,0 +1,28 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{ + BroadcastProvider, + transaction_broadcast_mapper::{map_transaction_broadcast_response, map_transaction_broadcast_response_from_str}, + }, + rpc::client::PolkadotClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for PolkadotClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(data).await?; + map_transaction_broadcast_response(response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_polkadot/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_polkadot/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..9f0219420d --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,17 @@ +use std::error::Error; + +use crate::models::transaction::PolkadotTransactionBroadcastResponse; + +pub(crate) fn map_transaction_broadcast_response(response: PolkadotTransactionBroadcastResponse) -> Result> { + if let Some(hash) = response.hash { + Ok(hash) + } else if let Some(error) = response.error { + Err(format!("{}: {}", error, response.cause.unwrap_or_default()).into()) + } else { + Err("Invalid broadcast response".into()) + } +} + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + map_transaction_broadcast_response(serde_json::from_str::(response)?) +} diff --git a/core/crates/gem_polkadot/src/provider/transaction_state.rs b/core/crates/gem_polkadot/src/provider/transaction_state.rs new file mode 100644 index 0000000000..29ca7831ef --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/transaction_state.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use primitives::{TransactionStateRequest, TransactionUpdate}; +use std::error::Error; + +use gem_client::Client; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::PolkadotClient}; + +#[async_trait] +impl ChainTransactionState for PolkadotClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let block_number = request.block_number; + if block_number == 0 { + return Err("Invalid block number".into()); + } + + let block_head = self.get_block_head().await?; + let from_block = block_number; + let to_block = calculate_to_block(block_head.number, from_block); + + let blocks = self.get_blocks(&from_block.to_string(), &to_block.to_string()).await?; + Ok(map_transaction_status(blocks, &request.id, block_number)) + } +} + +fn calculate_to_block(block_head_number: u64, from_block: u64) -> u64 { + let to_block = std::cmp::min(block_head_number, from_block + 64); + std::cmp::max(to_block, from_block + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_to_block() { + assert_eq!(calculate_to_block(100, 100), 101); + assert_eq!(calculate_to_block(200, 100), 164); + assert_eq!(calculate_to_block(105, 100), 105); + assert_eq!(calculate_to_block(50, 100), 101); + } +} diff --git a/core/crates/gem_polkadot/src/provider/transaction_state_mapper.rs b/core/crates/gem_polkadot/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..c55e168fbe --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/transaction_state_mapper.rs @@ -0,0 +1,71 @@ +use primitives::{TransactionChange, TransactionState, TransactionUpdate}; + +use crate::models::rpc::Block; + +pub fn map_transaction_status(blocks: Vec, transaction_id: &str, block_number: u64) -> TransactionUpdate { + for block in blocks { + for extrinsic in block.extrinsics { + if extrinsic.hash == transaction_id { + let state = if extrinsic.success { TransactionState::Confirmed } else { TransactionState::Failed }; + return TransactionUpdate::new_state(state); + } + } + } + + TransactionUpdate::new(TransactionState::Pending, vec![TransactionChange::BlockNumber(block_number.to_string())]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::rpc::{Block, Extrinsic, ExtrinsicArguments, ExtrinsicInfo, ExtrinsicMethod}; + + fn create_test_extrinsic(hash: &str, success: bool) -> Extrinsic { + Extrinsic { + hash: hash.to_string(), + method: ExtrinsicMethod { + pallet: "test".to_string(), + method: "test".to_string(), + }, + info: ExtrinsicInfo { + partial_fee: Some("0".to_string()), + }, + success, + args: ExtrinsicArguments::Other(serde_json::json!({})), + signature: None, + } + } + + #[test] + fn test_map_transaction_status_confirmed() { + let blocks = vec![Block { + number: 100, + extrinsics: vec![create_test_extrinsic("hash123", true)], + }]; + + let result = map_transaction_status(blocks, "hash123", 100); + assert_eq!(result.state, TransactionState::Confirmed); + } + + #[test] + fn test_map_transaction_status_failed() { + let blocks = vec![Block { + number: 100, + extrinsics: vec![create_test_extrinsic("hash123", false)], + }]; + + let result = map_transaction_status(blocks, "hash123", 100); + assert_eq!(result.state, TransactionState::Failed); + } + + #[test] + fn test_map_transaction_status_pending() { + let blocks = vec![Block { + number: 100, + extrinsics: vec![create_test_extrinsic("other_hash", true)], + }]; + + let result = map_transaction_status(blocks, "hash123", 100); + assert_eq!(result.state, TransactionState::Pending); + } +} diff --git a/core/crates/gem_polkadot/src/provider/transactions.rs b/core/crates/gem_polkadot/src/provider/transactions.rs new file mode 100644 index 0000000000..f5c5349ae3 --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/transactions.rs @@ -0,0 +1,41 @@ +use async_trait::async_trait; +use chain_traits::{ChainProvider, ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use super::transactions_mapper; +use crate::rpc::client::PolkadotClient; + +#[async_trait] +impl ChainTransactions for PolkadotClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let block_data = self.get_block(block as i64).await?; + Ok(transactions_mapper::map_transactions(self.get_chain(), block_data)) + } + + async fn get_transactions_by_address(&self, _request: TransactionsRequest) -> Result, Box> { + Ok(vec![]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::create_polkadot_test_client; + use chain_traits::ChainTransactionState; + use primitives::{TransactionState, TransactionStateRequest}; + + #[tokio::test] + async fn test_polkadot_get_transaction_status_failed() -> Result<(), Box> { + let client = create_polkadot_test_client(); + let request = TransactionStateRequest::mock_with_id("0x3a9dda661cbdfe12e15c623cd14abf3da64d4bcbe11c0c776def748713c2248b").with_block_number(27_830_222); + + let result = client.get_transaction_status(request).await?; + + assert_eq!(result.state, TransactionState::Failed); + assert!(result.changes.is_empty()); + + Ok(()) + } +} diff --git a/core/crates/gem_polkadot/src/provider/transactions_mapper.rs b/core/crates/gem_polkadot/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..a9a346520f --- /dev/null +++ b/core/crates/gem_polkadot/src/provider/transactions_mapper.rs @@ -0,0 +1,119 @@ +use chrono::{DateTime, Utc}; +use primitives::{Transaction, TransactionState, TransactionType, chain::Chain}; + +use crate::constants::{TRANSACTION_TYPE_TRANSFER_ALLOW_DEATH, TRANSACTION_TYPE_TRANSFER_KEEP_ALIVE}; +use crate::models::rpc::{Block, Extrinsic, ExtrinsicArguments}; + +pub fn map_transactions(chain: Chain, block: Block) -> Vec { + let created_at = block.extrinsics.iter().find_map(|ext| match &ext.args { + ExtrinsicArguments::Timestamp(timestamp) => DateTime::from_timestamp_millis(timestamp.now as i64), + _ => None, + }); + + let Some(created_at) = created_at else { + return vec![]; + }; + + block.extrinsics.iter().flat_map(|x| map_transaction(chain, x.clone(), created_at)).flatten().collect() +} + +pub fn map_transaction(chain: Chain, transaction: Extrinsic, created_at: DateTime) -> Vec> { + match &transaction.args.clone() { + ExtrinsicArguments::Transfer(transfer) => { + vec![map_transfer( + chain, + transaction.clone(), + transaction.method.method.clone(), + transfer.dest.id.clone(), + transfer.value.clone(), + created_at, + )] + } + ExtrinsicArguments::Transfers(transfers) => transfers + .calls + .iter() + .map(|x| { + map_transfer( + chain, + transaction.clone(), + x.method.method.clone(), + x.args.dest.id.clone(), + x.args.value.clone(), + created_at, + ) + }) + .collect(), + _ => vec![], + } +} + +fn map_transfer(chain: Chain, transaction: Extrinsic, method: String, to_address: String, value: String, created_at: DateTime) -> Option { + if method != TRANSACTION_TYPE_TRANSFER_ALLOW_DEATH && method != TRANSACTION_TYPE_TRANSFER_KEEP_ALIVE { + return None; + } + + let from_address = transaction.signature?.signer.id.clone(); + let state = if transaction.success { TransactionState::Confirmed } else { TransactionState::Failed }; + + Some(Transaction::new( + transaction.hash.clone(), + chain.as_asset_id(), + from_address, + to_address, + None, + TransactionType::Transfer, + state, + transaction.info.partial_fee.unwrap_or("0".to_string()), + chain.as_asset_id(), + value, + None, + None, + created_at, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::rpc::{ExtrinsicInfo, ExtrinsicMethod, ExtrinsicTimestamp}; + use primitives::Chain; + + fn make_extrinsic(args: ExtrinsicArguments) -> Extrinsic { + Extrinsic { + hash: String::new(), + method: ExtrinsicMethod { + pallet: String::new(), + method: String::new(), + }, + info: ExtrinsicInfo { partial_fee: None }, + success: true, + args, + signature: None, + } + } + + #[test] + fn map_transactions_finds_timestamp_at_any_index() { + let block = Block { + number: 12147467, + extrinsics: vec![ + make_extrinsic(ExtrinsicArguments::Other(serde_json::json!({"data": {}}))), + make_extrinsic(ExtrinsicArguments::Timestamp(ExtrinsicTimestamp { now: 1771126812000 })), + ], + }; + + let result = map_transactions(Chain::Polkadot, block); + assert!(result.is_empty()); + } + + #[test] + fn map_transactions_returns_empty_without_timestamp() { + let block = Block { + number: 1, + extrinsics: vec![make_extrinsic(ExtrinsicArguments::Other(serde_json::json!({"data": {}})))], + }; + + let result = map_transactions(Chain::Polkadot, block); + assert!(result.is_empty()); + } +} diff --git a/core/crates/gem_polkadot/src/rpc/client.rs b/core/crates/gem_polkadot/src/rpc/client.rs new file mode 100644 index 0000000000..c1e855ae87 --- /dev/null +++ b/core/crates/gem_polkadot/src/rpc/client.rs @@ -0,0 +1,70 @@ +use std::error::Error; + +use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual, ChainProvider, ChainTraits}; +use gem_client::{Client, ClientExt}; +use primitives::Chain; + +use crate::models::account::PolkadotAccountBalance; +use crate::models::block::PolkadotNodeVersion; +use crate::models::fee::PolkadotEstimateFee; +use crate::models::rpc::{Block, BlockHeader}; +use crate::models::transaction::{PolkadotTransactionBroadcastResponse, PolkadotTransactionMaterial, PolkadotTransactionPayload}; + +pub struct PolkadotClient { + pub client: C, +} + +impl PolkadotClient { + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_balance(&self, address: String) -> Result> { + Ok(self.client.get(&format!("/accounts/{}/balance-info", address)).await?) + } + + pub async fn get_transaction_material(&self) -> Result> { + Ok(self.client.get("/transaction/material").await?) + } + + pub async fn estimate_fee(&self, transaction: &str) -> Result> { + let payload = PolkadotTransactionPayload { tx: transaction.to_string() }; + Ok(self.client.post("/transaction/fee-estimate", &payload).await?) + } + + pub async fn get_node_version(&self) -> Result> { + Ok(self.client.get("/node/version").await?) + } + + pub async fn get_block_head(&self) -> Result> { + Ok(self.client.get("/blocks/head").await?) + } + + pub async fn get_blocks(&self, from: &str, to: &str) -> Result, Box> { + Ok(self.client.get(&format!("/blocks?range={}-{}&noFees=true", from, to)).await?) + } + + pub async fn broadcast_transaction(&self, transaction: String) -> Result> { + let payload = PolkadotTransactionPayload { tx: transaction }; + Ok(self.client.post("/transaction", &payload).await?) + } + + pub async fn get_block_header(&self, block: &str) -> Result> { + Ok(self.client.get(&format!("/blocks/{}/header", block)).await?) + } + + pub async fn get_block(&self, block_number: i64) -> Result> { + Ok(self.client.get(&format!("/blocks/{}", block_number)).await?) + } +} + +impl ChainProvider for PolkadotClient { + fn get_chain(&self) -> Chain { + Chain::Polkadot + } +} + +impl ChainTraits for PolkadotClient {} +impl ChainAccount for PolkadotClient {} +impl ChainPerpetual for PolkadotClient {} +impl ChainAddressStatus for PolkadotClient {} diff --git a/core/crates/gem_polkadot/src/rpc/mod.rs b/core/crates/gem_polkadot/src/rpc/mod.rs new file mode 100644 index 0000000000..bb5e0465fe --- /dev/null +++ b/core/crates/gem_polkadot/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::PolkadotClient; diff --git a/core/crates/gem_polkadot/src/signer/chain_signer.rs b/core/crates/gem_polkadot/src/signer/chain_signer.rs new file mode 100644 index 0000000000..0bbced92f2 --- /dev/null +++ b/core/crates/gem_polkadot/src/signer/chain_signer.rs @@ -0,0 +1,93 @@ +use primitives::{ChainSigner, SignerError, SignerInput, TransactionLoadInput}; +use signer::Ed25519KeyPair; + +use crate::address::PolkadotAddress; +use crate::transfer::NativeTransferTransaction; + +#[derive(Default)] +pub struct PolkadotChainSigner; + +impl ChainSigner for PolkadotChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + Self::sign_transaction(&input.input, private_key) + } +} + +impl PolkadotChainSigner { + fn sign_transaction(input: &TransactionLoadInput, private_key: &[u8]) -> Result { + let key_pair = Ed25519KeyPair::from_private_key(private_key)?; + let sender = PolkadotAddress::parse(&input.sender_address)?; + if sender.account_id() != &key_pair.public_key_bytes { + return SignerError::invalid_input_err("Polkadot sender address does not match private key"); + } + + let transaction = NativeTransferTransaction::from_input(input, key_pair.public_key_bytes)?; + let signing_payload = transaction.signing_payload(); + let signature = key_pair.sign(signing_payload.as_ref()); + Ok(transaction.encode_hex(&signature)) + } +} + +#[cfg(test)] +mod tests { + use primitives::{Asset, Chain, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadMetadata}; + + use super::*; + + const ADDRESS: &str = "15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXo"; + + fn metadata() -> TransactionLoadMetadata { + TransactionLoadMetadata::Polkadot { + sequence: 0, + genesis_hash: "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3".to_string(), + block_hash: "0x6e3ffeaa3be9d19bd110e5b6e7cbbc92cceed0d2ec557276c296bf7970ace2e5".to_string(), + block_number: 24_666_537, + spec_version: 1_003_004, + transaction_version: 26, + period: 64, + } + } + + fn input() -> SignerInput { + let fee = TransactionFee::new_from_fee(10.into()); + SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Polkadot)), + sender_address: ADDRESS.to_string(), + destination_address: ADDRESS.to_string(), + value: "10000".to_string(), + gas_price: GasPriceType::regular(10), + memo: None, + is_max_value: false, + metadata: metadata(), + }, + fee, + ) + } + + #[test] + fn test_sign_transfer_matches_mobile_vector() { + let private_key = hex::decode("f4c1daf4543e155b0e5e97351726d8891eae98014ed3f9a9ee1d842753c070ff").unwrap(); + + assert_eq!( + PolkadotChainSigner.sign_transfer(&input(), &private_key).unwrap(), + "0x39028400cd3cfbbaa8f217c2a29ceae4b4063b597b629861916bad98f9826e03d1ab120\ + e00b2276e04c8adcd667512ec0440dd208f8ada56a4aec7572e4742ca2c0f8e5752d4d4f29d7\ + 2a17c5d7e6bbfe2dfc9f081e567fdb9111be12ca04dec40cd2be0079502000000000a0000cd3\ + cfbbaa8f217c2a29ceae4b4063b597b629861916bad98f9826e03d1ab120e419c" + .replace(char::is_whitespace, "") + ); + } + + #[test] + fn test_sign_transfer_rejects_sender_private_key_mismatch() { + let private_key = hex::decode("f4c1daf4543e155b0e5e97351726d8891eae98014ed3f9a9ee1d842753c070ff").unwrap(); + let mut input = input(); + input.input.sender_address = "15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5".to_string(); + + assert_eq!( + PolkadotChainSigner.sign_transfer(&input, &private_key).unwrap_err().to_string(), + "Invalid input: Polkadot sender address does not match private key" + ); + } +} diff --git a/core/crates/gem_polkadot/src/signer/mod.rs b/core/crates/gem_polkadot/src/signer/mod.rs new file mode 100644 index 0000000000..ff3daa8af0 --- /dev/null +++ b/core/crates/gem_polkadot/src/signer/mod.rs @@ -0,0 +1,3 @@ +mod chain_signer; + +pub use chain_signer::PolkadotChainSigner; diff --git a/core/crates/gem_polkadot/src/testdata/balance_coin.json b/core/crates/gem_polkadot/src/testdata/balance_coin.json new file mode 100644 index 0000000000..f0f472ca68 --- /dev/null +++ b/core/crates/gem_polkadot/src/testdata/balance_coin.json @@ -0,0 +1,15 @@ +{ + "at": { + "hash": "0xd4888cc2c727b6956930dcf05fea0ed7fdb399d01e083bcf535de23737cda502", + "height": "27398407" + }, + "nonce": "3", + "tokenSymbol": "DOT", + "free": "180800283679", + "reserved": "0", + "miscFrozen": "miscFrozen does not exist for this runtime", + "feeFrozen": "feeFrozen does not exist for this runtime", + "frozen": "0", + "transferable": "180800283679", + "locks": [] + } \ No newline at end of file diff --git a/core/crates/gem_polkadot/src/transfer.rs b/core/crates/gem_polkadot/src/transfer.rs new file mode 100644 index 0000000000..bad36feefa --- /dev/null +++ b/core/crates/gem_polkadot/src/transfer.rs @@ -0,0 +1,330 @@ +use primitives::{SignerError, TransactionLoadInput, TransactionLoadMetadata, hex::decode_hex_array}; +use signer::Ed25519KeyPair; + +use crate::address::PolkadotAddress; + +const BALANCES_MODULE_INDEX: u8 = 0x0a; +const TRANSFER_ALLOW_DEATH_METHOD_INDEX: u8 = 0x00; +const SIGNATURE_TYPE_ED25519: u8 = 0x00; +const EXTRINSIC_VERSION_SIGNED: u8 = 0x84; +const MULTI_ADDRESS_ID: u8 = 0x00; +const CHARGE_ASSET_TRANSACTION_PAYMENT_NONE: u8 = 0x00; +const CHECK_METADATA_MIN_SPEC_VERSION: u32 = 1_002_005; +const MULTI_ADDRESS_MIN_SPEC_VERSION: u32 = 28; +const MORTAL_ERA_MIN_PERIOD: u64 = 4; +const MORTAL_ERA_MAX_PERIOD: u64 = 1 << 16; +const MAX_UNHASHED_PAYLOAD_SIZE: usize = 256; + +pub(crate) fn fee_estimation_transaction(input: &TransactionLoadInput) -> Result { + let fee_estimation_private_key = rand::random::<[u8; 32]>(); + fee_estimation_transaction_with_private_key(input, &fee_estimation_private_key) +} + +fn fee_estimation_transaction_with_private_key(input: &TransactionLoadInput, private_key: &[u8; 32]) -> Result { + let key_pair = Ed25519KeyPair::from_private_key(private_key)?; + let transaction = NativeTransferTransaction::from_input(input, key_pair.public_key_bytes)?; + let signing_payload = transaction.signing_payload(); + let signature = key_pair.sign(signing_payload.as_ref()); + Ok(transaction.encode_hex(&signature)) +} + +pub(crate) struct NativeTransferTransaction { + call: Vec, + extra: Vec, + signer_account_id: [u8; 32], + uses_multi_address: bool, + signing_payload: NativeTransferSigningPayload, +} + +impl NativeTransferTransaction { + pub(crate) fn from_input(input: &TransactionLoadInput, signer_account_id: [u8; 32]) -> Result { + let parameters = NativeTransferParameters::from_input(input)?; + let destination = PolkadotAddress::parse(&input.destination_address)?; + let value = input.value.parse::().map_err(SignerError::from_display)?; + let uses_multi_address = parameters.uses_multi_address(); + + Ok(Self { + call: Self::encode_call(destination.account_id(), value, uses_multi_address), + extra: parameters.encode_extra(), + signer_account_id, + uses_multi_address, + signing_payload: parameters.signing_payload, + }) + } + + pub(crate) fn encode_hex(&self, signature: &[u8; 64]) -> String { + format!("0x{}", hex::encode(self.encode(signature))) + } + + pub(crate) fn signing_payload(&self) -> SigningPayload { + self.signing_payload.encode(self) + } + + fn encode(&self, signature: &[u8; 64]) -> Vec { + let mut body = Vec::new(); + body.push(EXTRINSIC_VERSION_SIGNED); + encode_multi_address(&self.signer_account_id, self.uses_multi_address, &mut body); + body.push(SIGNATURE_TYPE_ED25519); + body.extend_from_slice(signature); + body.extend_from_slice(&self.extra); + body.extend_from_slice(&self.call); + + let mut encoded = Vec::new(); + encode_compact_integer(body.len() as u128, &mut encoded); + encoded.extend_from_slice(&body); + encoded + } + + fn encode_call(destination: &[u8], value: u128, uses_multi_address: bool) -> Vec { + let mut call = Vec::new(); + call.push(BALANCES_MODULE_INDEX); + call.push(TRANSFER_ALLOW_DEATH_METHOD_INDEX); + encode_multi_address(destination, uses_multi_address, &mut call); + encode_compact_integer(value, &mut call); + call + } +} + +struct NativeTransferParameters { + sequence: u64, + spec_version: u32, + era: MortalEra, + signing_payload: NativeTransferSigningPayload, +} + +impl NativeTransferParameters { + fn from_input(input: &TransactionLoadInput) -> Result { + let sequence = input.metadata.get_sequence()?; + let TransactionLoadMetadata::Polkadot { + genesis_hash, + block_hash, + block_number, + spec_version, + transaction_version, + period, + .. + } = &input.metadata + else { + return SignerError::invalid_input_err("missing Polkadot metadata"); + }; + + let spec_version = u32::try_from(*spec_version).map_err(SignerError::from_display)?; + + let parameters = Self { + sequence, + spec_version, + era: MortalEra::new(*period, *block_number)?, + signing_payload: NativeTransferSigningPayload::new(genesis_hash, block_hash, spec_version, *transaction_version)?, + }; + + Ok(parameters) + } + + fn uses_multi_address(&self) -> bool { + self.spec_version >= MULTI_ADDRESS_MIN_SPEC_VERSION + } + + fn encode_extra(&self) -> Vec { + let mut extra = Vec::new(); + self.era.encode(&mut extra); + encode_compact_integer(self.sequence.into(), &mut extra); + encode_compact_integer(0, &mut extra); + extra.push(CHARGE_ASSET_TRANSACTION_PAYMENT_NONE); + if checks_metadata(self.spec_version) { + extra.push(0); + } + extra + } +} + +fn checks_metadata(spec_version: u32) -> bool { + spec_version >= CHECK_METADATA_MIN_SPEC_VERSION +} + +pub(crate) enum SigningPayload { + Raw(Vec), + Blake2b256([u8; 32]), +} + +impl SigningPayload { + fn new(payload: Vec) -> Self { + if payload.len() > MAX_UNHASHED_PAYLOAD_SIZE { + Self::Blake2b256(gem_hash::blake2::blake2b_256(&payload)) + } else { + Self::Raw(payload) + } + } +} + +impl AsRef<[u8]> for SigningPayload { + fn as_ref(&self) -> &[u8] { + match self { + Self::Raw(payload) => payload, + Self::Blake2b256(payload_hash) => payload_hash, + } + } +} + +struct NativeTransferSigningPayload { + spec_version: u32, + transaction_version: u32, + genesis_hash: [u8; 32], + block_hash: [u8; 32], +} + +impl NativeTransferSigningPayload { + fn new(genesis_hash: &str, block_hash: &str, spec_version: u32, transaction_version: u64) -> Result { + Ok(Self { + spec_version, + transaction_version: u32::try_from(transaction_version).map_err(SignerError::from_display)?, + genesis_hash: decode_hex_array(genesis_hash)?, + block_hash: decode_hex_array(block_hash)?, + }) + } + + fn encode(&self, transaction: &NativeTransferTransaction) -> SigningPayload { + let include_metadata_hash = checks_metadata(self.spec_version); + let mut payload = Vec::with_capacity(transaction.call.len() + transaction.extra.len() + 72 + usize::from(include_metadata_hash)); + payload.extend_from_slice(&transaction.call); + payload.extend_from_slice(&transaction.extra); + payload.extend_from_slice(&self.spec_version.to_le_bytes()); + payload.extend_from_slice(&self.transaction_version.to_le_bytes()); + payload.extend_from_slice(&self.genesis_hash); + payload.extend_from_slice(&self.block_hash); + if include_metadata_hash { + payload.push(0); + } + + SigningPayload::new(payload) + } +} + +#[derive(Clone, Copy)] +struct MortalEra { + period: u64, + phase: u64, +} + +impl MortalEra { + fn new(period: u64, block_number: u64) -> Result { + if period < MORTAL_ERA_MIN_PERIOD { + return SignerError::invalid_input_err("Polkadot mortal era period must be at least 4"); + } + + let period = period + .checked_next_power_of_two() + .unwrap_or(MORTAL_ERA_MAX_PERIOD) + .clamp(MORTAL_ERA_MIN_PERIOD, MORTAL_ERA_MAX_PERIOD); + let quantize_factor = (period >> 12).max(1); + let phase = block_number % period / quantize_factor * quantize_factor; + Ok(Self { period, phase }) + } + + fn encode(&self, output: &mut Vec) { + let quantize_factor = (self.period >> 12).max(1); + let encoded = (self.period.trailing_zeros() - 1).clamp(1, 15) as u16 | ((self.phase / quantize_factor) << 4) as u16; + output.extend_from_slice(&encoded.to_le_bytes()); + } +} + +fn encode_multi_address(address: &[u8], uses_multi_address: bool, output: &mut Vec) { + if uses_multi_address { + output.push(MULTI_ADDRESS_ID); + } + output.extend_from_slice(address); +} + +fn encode_compact_integer(value: u128, output: &mut Vec) { + if value <= 0b0011_1111 { + output.push((value as u8) << 2); + } else if value <= 0b0011_1111_1111_1111 { + output.extend_from_slice(&(((value as u16) << 2) | 0b01).to_le_bytes()); + } else if value <= 0b0011_1111_1111_1111_1111_1111_1111_1111 { + output.extend_from_slice(&(((value as u32) << 2) | 0b10).to_le_bytes()); + } else { + let bytes = value.to_le_bytes(); + let bytes_needed = bytes.iter().rposition(|byte| *byte != 0).map(|index| index + 1).unwrap_or(1); + output.push(0b11 | (((bytes_needed - 4) as u8) << 2)); + output.extend_from_slice(&bytes[..bytes_needed]); + } +} + +#[cfg(test)] +mod tests { + use primitives::{Asset, Chain, GasPriceType, TransactionInputType}; + + use super::*; + + const ADDRESS: &str = "15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXo"; + + fn input() -> TransactionLoadInput { + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Polkadot)), + sender_address: ADDRESS.to_string(), + destination_address: ADDRESS.to_string(), + value: "10000".to_string(), + gas_price: GasPriceType::regular(10), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Polkadot { + sequence: 0, + genesis_hash: "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3".to_string(), + block_hash: "0x6e3ffeaa3be9d19bd110e5b6e7cbbc92cceed0d2ec557276c296bf7970ace2e5".to_string(), + block_number: 24_666_537, + spec_version: 1_003_004, + transaction_version: 26, + period: 64, + }, + } + } + + #[test] + fn test_encode_compact_integer() { + let mut encoded = Vec::new(); + encode_compact_integer(10_000, &mut encoded); + assert_eq!(hex::encode(encoded), "419c"); + } + + #[test] + fn test_mortal_era() { + let mut encoded = Vec::new(); + MortalEra::new(64, 24_666_537).unwrap().encode(&mut encoded); + assert_eq!(hex::encode(encoded), "9502"); + } + + #[test] + fn test_mortal_era_rejects_short_period() { + let error = match MortalEra::new(0, 24_666_537) { + Ok(_) => panic!("expected short mortal era period to fail"), + Err(error) => error, + }; + + assert_eq!(error.to_string(), "Invalid input: Polkadot mortal era period must be at least 4"); + } + + #[test] + fn test_signing_payload_hashes_large_payload() { + let payload = vec![7u8; MAX_UNHASHED_PAYLOAD_SIZE + 1]; + let expected_hash = gem_hash::blake2::blake2b_256(&payload); + + assert_eq!(SigningPayload::new(payload).as_ref(), expected_hash); + } + + #[test] + fn test_fee_estimation_transaction() { + assert_eq!( + fee_estimation_transaction_with_private_key(&input(), &[1; 32]).unwrap(), + concat!( + "0x39028400", + "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "00", + "a98ce781ec36fa2a7c83204c8c113f6a5fe482a49f35c4457a4132bc25cd06220", + "9ed9de9f6eb4523b1b00105c75e4f75f3c1b43490d22bef7bebb633c1a5ce0b", + "950200000000", + "0a0000", + "cd3cfbbaa8f217c2a29ceae4b4063b597b629861916bad98f9826e03d1ab120e", + "419c" + ) + ); + } +} diff --git a/core/crates/gem_rewards/Cargo.toml b/core/crates/gem_rewards/Cargo.toml new file mode 100644 index 0000000000..14b49e03b0 --- /dev/null +++ b/core/crates/gem_rewards/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "gem_rewards" +version = "0.1.0" +edition = "2021" + +[dependencies] +primitives = { path = "../primitives" } +storage = { path = "../storage" } +cacher = { path = "../cacher" } +localizer = { path = "../localizer" } +gem_evm = { path = "../gem_evm", features = ["rpc", "reqwest", "signer"] } +gem_client = { path = "../gem_client", features = ["reqwest"] } +chain_traits = { path = "../chain_traits" } + +serde = { workspace = true } +serde_json = { workspace = true } +alloy-primitives = { workspace = true } +num-traits = { workspace = true } +gem_hash = { path = "../gem_hash" } +hex = { workspace = true } +chrono = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +regex = { workspace = true } +async-trait = { workspace = true } diff --git a/core/crates/gem_rewards/src/abuseipdb/client.rs b/core/crates/gem_rewards/src/abuseipdb/client.rs new file mode 100644 index 0000000000..f661e43bec --- /dev/null +++ b/core/crates/gem_rewards/src/abuseipdb/client.rs @@ -0,0 +1,47 @@ +use std::error::Error; + +use async_trait::async_trait; + +use super::model::AbuseIPDBResponse; +use crate::ip_check_provider::IpCheckProvider; +use crate::model::IpCheckResult; + +#[derive(Clone)] +pub struct AbuseIPDBClient { + client: reqwest::Client, + url: String, + api_key: String, +} + +impl AbuseIPDBClient { + pub fn new(url: String, api_key: String) -> Self { + Self { + client: reqwest::Client::new(), + url, + api_key, + } + } +} + +#[async_trait] +impl IpCheckProvider for AbuseIPDBClient { + fn name(&self) -> &'static str { + "abuseipdb" + } + + async fn check_ip(&self, ip_address: &str) -> Result> { + let url = format!("{}/api/v2/check", self.url); + let response = self + .client + .get(&url) + .header("Key", &self.api_key) + .header("Accept", "application/json") + .query(&[("ipAddress", ip_address)]) + .send() + .await? + .json::() + .await?; + + Ok(response.data.as_ip_check_result()) + } +} diff --git a/core/crates/gem_rewards/src/abuseipdb/mod.rs b/core/crates/gem_rewards/src/abuseipdb/mod.rs new file mode 100644 index 0000000000..fdd30bc754 --- /dev/null +++ b/core/crates/gem_rewards/src/abuseipdb/mod.rs @@ -0,0 +1,4 @@ +mod client; +mod model; + +pub use client::AbuseIPDBClient; diff --git a/core/crates/gem_rewards/src/abuseipdb/model.rs b/core/crates/gem_rewards/src/abuseipdb/model.rs new file mode 100644 index 0000000000..cd6715e727 --- /dev/null +++ b/core/crates/gem_rewards/src/abuseipdb/model.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +use crate::model::IpCheckResult; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AbuseIPDBResponse { + pub data: AbuseIPDBData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AbuseIPDBData { + pub ip_address: String, + pub is_public: bool, + pub ip_version: i64, + pub is_whitelisted: Option, + pub abuse_confidence_score: i64, + pub country_code: String, + pub usage_type: Option, + pub isp: Option, + pub domain: Option, + pub is_tor: bool, + pub total_reports: i64, +} + +impl AbuseIPDBData { + pub fn as_ip_check_result(&self) -> IpCheckResult { + IpCheckResult { + ip_address: self.ip_address.clone(), + country_code: self.country_code.clone(), + confidence_score: self.abuse_confidence_score, + is_tor: self.is_tor, + is_vpn: false, + usage_type: self.usage_type.as_deref().and_then(|s| s.parse().ok()).unwrap_or_default(), + isp: self.isp.clone().unwrap_or_default(), + } + } +} diff --git a/core/crates/gem_rewards/src/error.rs b/core/crates/gem_rewards/src/error.rs new file mode 100644 index 0000000000..1947bd1935 --- /dev/null +++ b/core/crates/gem_rewards/src/error.rs @@ -0,0 +1,154 @@ +use std::error::Error; +use std::fmt; + +use localizer::LanguageLocalizer; +use primitives::{ConfigKey, Localize}; +use storage::{DatabaseError, ReferralValidationError, UsernameValidationError}; + +#[derive(Debug)] +pub enum RewardsError { + Username(String), + Referral(String), +} + +impl fmt::Display for RewardsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RewardsError::Username(msg) => write!(f, "{}", msg), + RewardsError::Referral(msg) => write!(f, "{}", msg), + } + } +} + +impl Error for RewardsError {} + +#[derive(Debug)] +pub enum ReferralError { + Validation(ReferralValidationError), + ReferrerLimitReached(ConfigKey), + RiskScoreExceeded { score: i64, max_allowed: i64 }, + DuplicateAttempt, + IpTorNotAllowed, + IpCountryIneligible(String), + LimitReached(ConfigKey), + InvalidDeviceToken(String), + Database(DatabaseError), +} + +impl fmt::Display for ReferralError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReferralError::Validation(e) => write!(f, "{}", e), + ReferralError::ReferrerLimitReached(key) => write!(f, "referrer_limit_reached: {}", key.as_ref()), + ReferralError::RiskScoreExceeded { score, max_allowed } => write!(f, "risk_score: {} (max allowed: {})", score, max_allowed), + ReferralError::DuplicateAttempt => write!(f, "duplicate_attempt"), + ReferralError::IpTorNotAllowed => write!(f, "ip_tor_not_allowed"), + ReferralError::IpCountryIneligible(country) => write!(f, "ip_country_ineligible: {}", country), + ReferralError::LimitReached(key) => write!(f, "limit_reached: {}", key.as_ref()), + ReferralError::InvalidDeviceToken(reason) => write!(f, "invalid_device_token: {}", reason), + ReferralError::Database(e) => write!(f, "{}", e), + } + } +} + +impl Error for ReferralError {} + +impl Localize for ReferralError { + fn localize(&self, locale: &str) -> String { + let localizer = LanguageLocalizer::new_with_language(locale); + match self { + Self::Validation(ReferralValidationError::CodeDoesNotExist) => localizer.rewards_error_referral_code_not_exist(), + Self::Validation(ReferralValidationError::DeviceAlreadyUsed) => localizer.rewards_error_referral_device_already_used(), + Self::Validation(ReferralValidationError::CannotReferSelf) => localizer.rewards_error_referral_cannot_refer_self(), + Self::Validation(ReferralValidationError::EligibilityExpired(days)) => localizer.rewards_error_referral_eligibility_expired(*days), + Self::Validation(ReferralValidationError::RewardsNotEnabled(_)) => localizer.rewards_error_referral_rewards_not_enabled(), + Self::Validation(ReferralValidationError::Database(_)) => localizer.errors_generic(), + Self::ReferrerLimitReached(_) => localizer.rewards_error_referral_referrer_limit_reached(), + Self::IpCountryIneligible(country) => localizer.rewards_error_referral_country_ineligible(country), + Self::RiskScoreExceeded { .. } | Self::DuplicateAttempt | Self::IpTorNotAllowed | Self::LimitReached(_) | Self::InvalidDeviceToken(_) => { + localizer.rewards_error_referral_limit_reached() + } + Self::Database(_) => localizer.errors_generic(), + } + } +} + +impl From for ReferralError { + fn from(error: ReferralValidationError) -> Self { + ReferralError::Validation(error) + } +} + +impl From for ReferralError { + fn from(error: DatabaseError) -> Self { + ReferralError::Database(error) + } +} + +impl From> for ReferralError { + fn from(error: Box) -> Self { + ReferralError::Database(DatabaseError::Error(error.to_string())) + } +} + +#[derive(Debug)] +pub enum RewardsRedemptionError { + NotEligible(String), + DailyLimitReached, + WeeklyLimitReached, + AccountTooNew, + CooldownNotElapsed, + NotEnoughPoints, + OptionNotAvailable, + NoUsername, +} + +impl fmt::Display for RewardsRedemptionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RewardsRedemptionError::NotEligible(msg) => write!(f, "{}", msg), + RewardsRedemptionError::DailyLimitReached => write!(f, "Daily redemption limit reached"), + RewardsRedemptionError::WeeklyLimitReached => write!(f, "Weekly redemption limit reached"), + RewardsRedemptionError::AccountTooNew => write!(f, "Account too new for redemption"), + RewardsRedemptionError::CooldownNotElapsed => write!(f, "Must wait after recent referral activity"), + RewardsRedemptionError::NotEnoughPoints => write!(f, "Not enough points"), + RewardsRedemptionError::OptionNotAvailable => write!(f, "Redemption option is no longer available"), + RewardsRedemptionError::NoUsername => write!(f, "No username found for address"), + } + } +} + +impl Error for RewardsRedemptionError {} + +#[derive(Debug)] +pub enum UsernameError { + LimitReached(ConfigKey), + Validation(UsernameValidationError), +} + +impl fmt::Display for UsernameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UsernameError::LimitReached(key) => write!(f, "Username creation limit reached: {}", key.as_ref()), + UsernameError::Validation(e) => write!(f, "{}", e), + } + } +} + +impl Error for UsernameError {} + +impl Localize for UsernameError { + fn localize(&self, locale: &str) -> String { + let localizer = LanguageLocalizer::new_with_language(locale); + match self { + Self::LimitReached(_) => localizer.rewards_error_username_daily_limit_reached(), + Self::Validation(e) => e.to_string(), + } + } +} + +impl From for UsernameError { + fn from(error: UsernameValidationError) -> Self { + UsernameError::Validation(error) + } +} diff --git a/core/crates/gem_rewards/src/ip_check_provider.rs b/core/crates/gem_rewards/src/ip_check_provider.rs new file mode 100644 index 0000000000..44f7d78b87 --- /dev/null +++ b/core/crates/gem_rewards/src/ip_check_provider.rs @@ -0,0 +1,11 @@ +use std::error::Error; + +use async_trait::async_trait; + +use crate::model::IpCheckResult; + +#[async_trait] +pub trait IpCheckProvider: Send + Sync { + fn name(&self) -> &'static str; + async fn check_ip(&self, ip_address: &str) -> Result>; +} diff --git a/core/crates/gem_rewards/src/ip_security_client.rs b/core/crates/gem_rewards/src/ip_security_client.rs new file mode 100644 index 0000000000..15e17afa49 --- /dev/null +++ b/core/crates/gem_rewards/src/ip_security_client.rs @@ -0,0 +1,85 @@ +use std::error::Error; +use std::sync::Arc; + +use cacher::{CacheKey, CacherClient}; +use primitives::ConfigKey; + +use crate::UsernameError; +use crate::ip_check_provider::IpCheckProvider; +use crate::model::IpCheckResult; + +#[derive(Clone)] +pub struct IpSecurityClient { + providers: Vec>, + cacher: CacherClient, +} + +impl IpSecurityClient { + pub fn new(providers: Vec>, cacher: CacherClient) -> Self { + Self { providers, cacher } + } + + pub async fn check_ip(&self, ip_address: &str) -> Result> { + self.cacher + .get_or_set_cached(CacheKey::ReferralIpCheck(ip_address), || async { self.check_ip_with_fallback(ip_address).await }) + .await + } + + async fn check_ip_with_fallback(&self, ip_address: &str) -> Result> { + let mut last_error: Option> = None; + + for provider in &self.providers { + match provider.check_ip(ip_address).await { + Ok(result) => return Ok(result), + Err(e) => { + last_error = Some(e); + } + } + } + + Err(last_error.unwrap_or_else(|| "No IP check providers configured".into())) + } + + pub async fn check_username_creation_limits( + &self, + ip_address: &str, + device_id: i32, + global_daily_limit: i64, + ip_limit: i64, + device_limit: i64, + ) -> Result<(), Box> { + let global_count = self.cacher.get_cached_counter(CacheKey::UsernameCreationGlobalDaily).await?; + if global_count >= global_daily_limit { + return Err(UsernameError::LimitReached(ConfigKey::UsernameCreationGlobalDailyLimit).into()); + } + + let ip_count = self.cacher.get_cached_counter(CacheKey::UsernameCreationPerIp(ip_address)).await?; + if ip_count >= ip_limit { + return Err(UsernameError::LimitReached(ConfigKey::UsernameCreationPerIp).into()); + } + + let device_count = self.cacher.get_cached_counter(CacheKey::UsernameCreationPerDevice(device_id)).await?; + if device_count >= device_limit { + return Err(UsernameError::LimitReached(ConfigKey::UsernameCreationPerDevice).into()); + } + + Ok(()) + } + + pub async fn check_username_creation_country_limit(&self, country_code: &str, country_daily_limit: i64) -> Result<(), Box> { + let country_count = self.cacher.get_cached_counter(CacheKey::UsernameCreationPerCountryDaily(country_code)).await?; + if country_count >= country_daily_limit { + return Err(UsernameError::LimitReached(ConfigKey::UsernameCreationPerCountryDailyLimit).into()); + } + + Ok(()) + } + + pub async fn record_username_creation(&self, country_code: &str, ip_address: &str, device_id: i32) -> Result<(), Box> { + self.cacher.increment_cached(CacheKey::UsernameCreationGlobalDaily).await?; + self.cacher.increment_cached(CacheKey::UsernameCreationPerCountryDaily(country_code)).await?; + self.cacher.increment_cached(CacheKey::UsernameCreationPerIp(ip_address)).await?; + self.cacher.increment_cached(CacheKey::UsernameCreationPerDevice(device_id)).await?; + Ok(()) + } +} diff --git a/core/crates/gem_rewards/src/ipapi/client.rs b/core/crates/gem_rewards/src/ipapi/client.rs new file mode 100644 index 0000000000..e175c903e4 --- /dev/null +++ b/core/crates/gem_rewards/src/ipapi/client.rs @@ -0,0 +1,45 @@ +use std::error::Error; + +use async_trait::async_trait; + +use super::model::IpApiResponse; +use crate::ip_check_provider::IpCheckProvider; +use crate::model::IpCheckResult; + +#[derive(Clone)] +pub struct IpApiClient { + client: reqwest::Client, + url: String, + api_key: String, +} + +impl IpApiClient { + pub fn new(url: String, api_key: String) -> Self { + Self { + client: reqwest::Client::new(), + url, + api_key, + } + } +} + +#[async_trait] +impl IpCheckProvider for IpApiClient { + fn name(&self) -> &'static str { + "ipapi" + } + + async fn check_ip(&self, ip_address: &str) -> Result> { + let url = format!("{}/", self.url); + let response = self + .client + .get(&url) + .query(&[("q", ip_address), ("key", &self.api_key)]) + .send() + .await? + .json::() + .await?; + + Ok(response.as_ip_check_result()) + } +} diff --git a/core/crates/gem_rewards/src/ipapi/mod.rs b/core/crates/gem_rewards/src/ipapi/mod.rs new file mode 100644 index 0000000000..8aedab1f5c --- /dev/null +++ b/core/crates/gem_rewards/src/ipapi/mod.rs @@ -0,0 +1,4 @@ +mod client; +mod model; + +pub use client::IpApiClient; diff --git a/core/crates/gem_rewards/src/ipapi/model.rs b/core/crates/gem_rewards/src/ipapi/model.rs new file mode 100644 index 0000000000..b64cedb3af --- /dev/null +++ b/core/crates/gem_rewards/src/ipapi/model.rs @@ -0,0 +1,181 @@ +use primitives::IpUsageType; +use serde::Deserialize; + +use crate::model::IpCheckResult; + +#[derive(Debug, Clone, Deserialize)] +pub struct IpApiResponse { + pub ip: String, + pub is_tor: Option, + pub is_proxy: Option, + pub is_vpn: Option, + pub is_abuser: Option, + pub company: Option, + pub asn: Option, + pub location: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct IpApiCompany { + pub name: Option, + pub abuser_score: Option, + #[serde(rename = "type")] + pub company_type: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct IpApiAsn { + pub abuser_score: Option, + pub org: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct IpApiLocation { + pub country_code: Option, +} + +impl IpApiResponse { + pub fn as_ip_check_result(&self) -> IpCheckResult { + IpCheckResult { + ip_address: self.ip.clone(), + country_code: self.location.as_ref().and_then(|l| l.country_code.clone()).unwrap_or_default(), + confidence_score: self.calculate_confidence_score(), + is_tor: self.is_tor.unwrap_or(false), + is_vpn: self.is_vpn.unwrap_or(false), + usage_type: self.determine_usage_type(), + isp: self + .company + .as_ref() + .and_then(|c| c.name.clone()) + .or_else(|| self.asn.as_ref().and_then(|a| a.org.clone())) + .unwrap_or_default(), + } + } + + fn determine_usage_type(&self) -> IpUsageType { + self.company + .as_ref() + .and_then(|c| c.company_type.as_deref()) + .and_then(|s| s.parse().ok()) + .unwrap_or_default() + } + + fn calculate_confidence_score(&self) -> i64 { + let abuser_score = self.parse_abuser_score(); + let mut score = (abuser_score * 100.0).round() as i64; + + if self.is_abuser.unwrap_or(false) { + score = score.max(50); + } + + if self.is_proxy.unwrap_or(false) || self.is_vpn.unwrap_or(false) { + score = score.max(25); + } + + if self.is_tor.unwrap_or(false) { + score = score.max(75); + } + + score.clamp(0, 100) + } + + fn parse_abuser_score(&self) -> f64 { + let score_str = self + .company + .as_ref() + .and_then(|c| c.abuser_score.clone()) + .or_else(|| self.asn.as_ref().and_then(|a| a.abuser_score.clone())); + + if let Some(s) = score_str { + if let Some(num_str) = s.split_whitespace().next() { + if let Ok(val) = num_str.parse::() { + return val; + } + } + } + + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_abuser_score() { + let response = IpApiResponse { + ip: "1.2.3.4".to_string(), + is_tor: None, + is_proxy: None, + is_vpn: None, + is_abuser: None, + company: Some(IpApiCompany { + name: None, + abuser_score: Some("0.0044 (Low)".to_string()), + company_type: None, + }), + asn: None, + location: None, + }; + + let score = response.parse_abuser_score(); + assert!((score - 0.0044).abs() < 0.0001); + } + + #[test] + fn test_calculate_confidence_score_low() { + let response = IpApiResponse { + ip: "1.2.3.4".to_string(), + is_tor: None, + is_proxy: None, + is_vpn: None, + is_abuser: None, + company: Some(IpApiCompany { + name: None, + abuser_score: Some("0.0044 (Low)".to_string()), + company_type: None, + }), + asn: None, + location: None, + }; + + assert_eq!(response.calculate_confidence_score(), 0); + } + + #[test] + fn test_calculate_confidence_score_tor() { + let response = IpApiResponse { + ip: "1.2.3.4".to_string(), + is_tor: Some(true), + is_proxy: None, + is_vpn: None, + is_abuser: None, + company: None, + asn: None, + location: None, + }; + + assert_eq!(response.calculate_confidence_score(), 75); + } + + #[test] + fn test_determine_usage_type_hosting() { + let response = IpApiResponse { + ip: "1.2.3.4".to_string(), + is_tor: None, + is_proxy: None, + is_vpn: None, + is_abuser: None, + company: Some(IpApiCompany { + name: None, + abuser_score: None, + company_type: Some("hosting".to_string()), + }), + asn: None, + location: None, + }; + + assert_eq!(response.determine_usage_type(), IpUsageType::Hosting); + } +} diff --git a/core/crates/gem_rewards/src/lib.rs b/core/crates/gem_rewards/src/lib.rs new file mode 100644 index 0000000000..1963a96bb5 --- /dev/null +++ b/core/crates/gem_rewards/src/lib.rs @@ -0,0 +1,24 @@ +mod model; +mod risk_scoring; + +mod abuseipdb; +mod error; +mod ip_check_provider; +mod ip_security_client; +mod ipapi; +mod redemption; +mod redemption_service; +mod transfer_provider; +mod transfer_redemption_service; + +pub use abuseipdb::AbuseIPDBClient; +pub use error::{ReferralError, RewardsError, RewardsRedemptionError, UsernameError}; +pub use ip_check_provider::IpCheckProvider; +pub use ip_security_client::IpSecurityClient; +pub use ipapi::IpApiClient; +pub use model::IpCheckResult; +pub use redemption::redeem_points; +pub use redemption_service::{RedemptionAsset, RedemptionRequest, RedemptionResult, RedemptionService}; +pub use risk_scoring::{RiskResult, RiskScoreConfig, RiskScoringInput, RiskSignalInput, evaluate_risk}; +pub use transfer_provider::{EvmClientProvider, WalletConfig}; +pub use transfer_redemption_service::TransferRedemptionService; diff --git a/core/crates/gem_rewards/src/model.rs b/core/crates/gem_rewards/src/model.rs new file mode 100644 index 0000000000..749bf492d2 --- /dev/null +++ b/core/crates/gem_rewards/src/model.rs @@ -0,0 +1,13 @@ +use primitives::IpUsageType; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IpCheckResult { + pub ip_address: String, + pub country_code: String, + pub confidence_score: i64, + pub is_tor: bool, + pub is_vpn: bool, + pub usage_type: IpUsageType, + pub isp: String, +} diff --git a/core/crates/gem_rewards/src/redemption.rs b/core/crates/gem_rewards/src/redemption.rs new file mode 100644 index 0000000000..d4d8228fc8 --- /dev/null +++ b/core/crates/gem_rewards/src/redemption.rs @@ -0,0 +1,11 @@ +use primitives::rewards::{RedemptionResponse, RedemptionResult}; +use storage::{DatabaseClient, DatabaseError, RewardsRedemptionsRepository}; + +pub fn redeem_points(database: &mut DatabaseClient, username: &str, option_id: &str, device_id: i32, wallet_id: i32) -> Result { + let redemption = RewardsRedemptionsRepository::add_redemption(database, username, option_id, device_id, wallet_id)?; + + Ok(RedemptionResponse { + result: RedemptionResult { redemption: redemption.clone() }, + redemption_id: redemption.id, + }) +} diff --git a/core/crates/gem_rewards/src/redemption_service.rs b/core/crates/gem_rewards/src/redemption_service.rs new file mode 100644 index 0000000000..0f8d9eb81a --- /dev/null +++ b/core/crates/gem_rewards/src/redemption_service.rs @@ -0,0 +1,22 @@ +use primitives::Asset; +use std::error::Error; + +#[derive(Clone)] +pub struct RedemptionAsset { + pub asset: Asset, + pub value: String, +} + +#[derive(Clone)] +pub struct RedemptionRequest { + pub recipient_address: String, + pub asset: Option, +} + +pub struct RedemptionResult { + pub transaction_id: String, +} + +pub trait RedemptionService: Send + Sync { + fn process_redemption(&self, request: RedemptionRequest) -> impl std::future::Future>> + Send; +} diff --git a/core/crates/gem_rewards/src/risk_scoring/client.rs b/core/crates/gem_rewards/src/risk_scoring/client.rs new file mode 100644 index 0000000000..36f0807ee0 --- /dev/null +++ b/core/crates/gem_rewards/src/risk_scoring/client.rs @@ -0,0 +1,163 @@ +use crate::model::IpCheckResult; +use primitives::rewards::RewardStatus; +use primitives::{Platform, PlatformStore}; +use storage::models::{NewRiskSignalRow, RiskSignalRow}; + +use super::model::{RiskScore, RiskScoreConfig, RiskSignalInput}; +use super::scoring::calculate_risk_score; + +#[derive(Debug, Clone)] +pub struct RiskScoringInput { + pub username: String, + pub device_id: i32, + pub device_platform: Platform, + pub device_platform_store: PlatformStore, + pub device_os: String, + pub device_model: String, + pub device_locale: String, + pub device_currency: String, + pub ip_result: IpCheckResult, + pub referrer_status: RewardStatus, + pub referrer_referral_count: i64, + pub user_agent: String, +} + +impl RiskScoringInput { + pub fn to_signal_input(&self) -> RiskSignalInput { + RiskSignalInput { + username: self.username.clone(), + device_id: self.device_id, + device_platform: self.device_platform, + device_platform_store: self.device_platform_store, + device_os: self.device_os.clone(), + device_model: self.device_model.clone(), + device_locale: self.device_locale.clone(), + device_currency: self.device_currency.clone(), + ip_address: self.ip_result.ip_address.clone(), + ip_country_code: self.ip_result.country_code.clone(), + ip_usage_type: self.ip_result.usage_type, + ip_isp: self.ip_result.isp.clone(), + ip_abuse_score: self.ip_result.confidence_score, + referrer_status: self.referrer_status, + referrer_referral_count: self.referrer_referral_count, + user_agent: self.user_agent.clone(), + } + } +} + +pub struct RiskResult { + pub score: RiskScore, + pub signal: NewRiskSignalRow, +} + +pub fn evaluate_risk( + input: &RiskScoringInput, + existing_signals: &[RiskSignalRow], + device_model_ring_count: i64, + ip_abuser_count: i64, + cross_referrer_fingerprint_count: i64, + referrer_country_count: i64, + referrer_device_count: i64, + config: &RiskScoreConfig, +) -> RiskResult { + let signal_input = input.to_signal_input(); + let score = calculate_risk_score( + &signal_input, + existing_signals, + device_model_ring_count, + ip_abuser_count, + cross_referrer_fingerprint_count, + referrer_country_count, + referrer_device_count, + config, + ); + + let signal = NewRiskSignalRow { + fingerprint: score.fingerprint.clone(), + referrer_username: signal_input.username, + device_id: signal_input.device_id, + device_platform: signal_input.device_platform.into(), + device_platform_store: signal_input.device_platform_store.into(), + device_os: signal_input.device_os, + device_model: signal_input.device_model, + device_locale: signal_input.device_locale, + device_currency: signal_input.device_currency, + ip_address: signal_input.ip_address, + ip_country_code: signal_input.ip_country_code, + ip_usage_type: signal_input.ip_usage_type.into(), + ip_isp: signal_input.ip_isp, + ip_abuse_score: signal_input.ip_abuse_score as i32, + risk_score: score.score as i32, + user_agent: signal_input.user_agent, + metadata: Some(score.breakdown.to_metadata_json()), + }; + + RiskResult { score, signal } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::IpCheckResult; + use primitives::IpUsageType; + + fn create_test_input() -> RiskScoringInput { + RiskScoringInput { + username: "user1".to_string(), + device_id: 1, + device_platform: Platform::IOS, + device_platform_store: PlatformStore::AppStore, + device_os: "18.0".to_string(), + device_model: "iPhone15,2".to_string(), + device_locale: "en-US".to_string(), + device_currency: "USD".to_string(), + ip_result: IpCheckResult { + ip_address: "192.168.1.1".to_string(), + country_code: "US".to_string(), + confidence_score: 0, + is_tor: false, + is_vpn: false, + usage_type: IpUsageType::Isp, + isp: "Comcast".to_string(), + }, + referrer_status: RewardStatus::Unverified, + referrer_referral_count: 2, + user_agent: String::new(), + } + } + + #[test] + fn evaluate_clean_user() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let result = evaluate_risk(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score.score, 0); + assert!(result.score.is_allowed); + assert_eq!(result.signal.referrer_username, "user1"); + assert_eq!(result.signal.device_model, "iPhone15,2"); + } + + #[test] + fn evaluate_high_abuse_score() { + let mut input = create_test_input(); + input.ip_result.confidence_score = 60; + let config = RiskScoreConfig::default(); + let result = evaluate_risk(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score.score, 60); + assert!(!result.score.is_allowed); + } + + #[test] + fn signal_populated_correctly() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let result = evaluate_risk(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.signal.ip_address, "192.168.1.1"); + assert_eq!(result.signal.ip_isp, "Comcast"); + assert_eq!(*result.signal.device_platform, Platform::IOS); + assert!(!result.signal.fingerprint.is_empty()); + } +} diff --git a/core/crates/gem_rewards/src/risk_scoring/mod.rs b/core/crates/gem_rewards/src/risk_scoring/mod.rs new file mode 100644 index 0000000000..8247d58a52 --- /dev/null +++ b/core/crates/gem_rewards/src/risk_scoring/mod.rs @@ -0,0 +1,6 @@ +mod client; +mod model; +mod scoring; + +pub use client::{RiskResult, RiskScoringInput, evaluate_risk}; +pub use model::{RiskScoreConfig, RiskSignalInput}; diff --git a/core/crates/gem_rewards/src/risk_scoring/model.rs b/core/crates/gem_rewards/src/risk_scoring/model.rs new file mode 100644 index 0000000000..e7f3db6029 --- /dev/null +++ b/core/crates/gem_rewards/src/risk_scoring/model.rs @@ -0,0 +1,258 @@ +use gem_hash::sha2::sha256; +use primitives::rewards::RewardStatus; +use primitives::{IpUsageType, Platform, PlatformStore}; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct RiskScoreConfig { + pub fingerprint_match_penalty_per_referrer: i64, + pub fingerprint_match_max_penalty: i64, + pub ip_reuse_score: i64, + pub isp_model_match_score: i64, + pub device_id_reuse_penalty_per_referrer: i64, + pub device_id_reuse_max_penalty: i64, + pub ineligible_ip_type_score: i64, + pub blocked_ip_types: Vec, + pub blocked_ip_type_penalty: i64, + pub max_abuse_score: i64, + pub penalty_isps: Vec, + pub isp_penalty_score: i64, + pub verified_user_reduction: i64, + pub early_referral_reduction_initial: i64, + pub early_referral_reduction_step: i64, + pub max_allowed_score: i64, + pub same_referrer_pattern_threshold: i64, + pub same_referrer_pattern_penalty: i64, + pub same_referrer_fingerprint_threshold: i64, + pub same_referrer_fingerprint_penalty: i64, + pub same_referrer_device_model_threshold: i64, + pub same_referrer_device_model_penalty: i64, + pub device_model_ring_threshold: i64, + pub device_model_ring_penalty_per_member: i64, + pub lookback: Duration, + pub high_risk_platform_stores: Vec, + pub high_risk_platform_store_penalty: i64, + pub high_risk_countries: Vec, + pub high_risk_country_penalty: i64, + pub high_risk_locales: Vec, + pub high_risk_locale_penalty: i64, + pub high_risk_device_models: Vec, + pub high_risk_device_model_penalty: i64, + pub high_risk_user_agents: Vec, + pub high_risk_user_agent_penalty: i64, + pub velocity_window: Duration, + pub velocity_divisor: i64, + pub velocity_penalty: i64, + pub referral_per_user_daily: i64, + pub verified_multiplier: i64, + pub trusted_multiplier: i64, + pub ip_history_penalty_per_abuser: i64, + pub ip_history_max_penalty: i64, + pub cross_referrer_device_penalty: i64, + pub cross_referrer_fingerprint_threshold: i64, + pub cross_referrer_fingerprint_penalty: i64, + pub country_diversity_threshold: i64, + pub country_diversity_penalty_per_country: i64, + pub device_farming_threshold: i64, + pub device_farming_penalty_per_device: i64, +} + +impl Default for RiskScoreConfig { + fn default() -> Self { + Self { + fingerprint_match_penalty_per_referrer: 50, + fingerprint_match_max_penalty: 200, + ip_reuse_score: 50, + isp_model_match_score: 30, + device_id_reuse_penalty_per_referrer: 50, + device_id_reuse_max_penalty: 200, + ineligible_ip_type_score: 100, + blocked_ip_types: vec![IpUsageType::DataCenter, IpUsageType::Hosting], + blocked_ip_type_penalty: 100, + max_abuse_score: 60, + penalty_isps: vec![], + isp_penalty_score: 30, + verified_user_reduction: 30, + early_referral_reduction_initial: 20, + early_referral_reduction_step: 10, + max_allowed_score: 60, + same_referrer_pattern_threshold: 3, + same_referrer_pattern_penalty: 40, + same_referrer_fingerprint_threshold: 2, + same_referrer_fingerprint_penalty: 60, + same_referrer_device_model_threshold: 3, + same_referrer_device_model_penalty: 50, + device_model_ring_threshold: 2, + device_model_ring_penalty_per_member: 40, + lookback: Duration::from_secs(30 * 86400), + high_risk_platform_stores: vec![], + high_risk_platform_store_penalty: 20, + high_risk_countries: vec![], + high_risk_country_penalty: 15, + high_risk_locales: vec![], + high_risk_locale_penalty: 10, + high_risk_device_models: vec!["sdk_gphone".to_string(), "(?i)emulator".to_string(), "(?i)simulator".to_string()], + high_risk_device_model_penalty: 50, + high_risk_user_agents: vec![ + "(?i)python".to_string(), + "(?i)curl".to_string(), + "(?i)httpie".to_string(), + "(?i)postman".to_string(), + "(?i)insomnia".to_string(), + ], + high_risk_user_agent_penalty: 100, + velocity_window: Duration::from_secs(300), + velocity_divisor: 2, + velocity_penalty: 100, + referral_per_user_daily: 5, + verified_multiplier: 2, + trusted_multiplier: 3, + ip_history_penalty_per_abuser: 30, + ip_history_max_penalty: 150, + cross_referrer_device_penalty: 500, + cross_referrer_fingerprint_threshold: 2, + cross_referrer_fingerprint_penalty: 100, + country_diversity_threshold: 5, + country_diversity_penalty_per_country: 5, + device_farming_threshold: 10, + device_farming_penalty_per_device: 3, + } + } +} + +#[derive(Debug, Clone)] +pub struct RiskSignalInput { + pub username: String, + pub device_id: i32, + pub device_platform: Platform, + pub device_platform_store: PlatformStore, + pub device_os: String, + pub device_model: String, + pub device_locale: String, + pub device_currency: String, + pub ip_address: String, + pub ip_country_code: String, + pub ip_usage_type: IpUsageType, + pub ip_isp: String, + pub ip_abuse_score: i64, + pub referrer_status: RewardStatus, + pub referrer_referral_count: i64, + pub user_agent: String, +} + +impl RiskSignalInput { + pub fn generate_fingerprint(&self) -> String { + let data = [ + self.device_model.as_bytes(), + self.device_locale.as_bytes(), + self.ip_isp.as_bytes(), + self.ip_country_code.as_bytes(), + ] + .concat(); + hex::encode(sha256(&data)) + } + + pub fn early_referral_reduction(&self, config: &RiskScoreConfig) -> i64 { + let initial = config.early_referral_reduction_initial.max(0); + let step = config.early_referral_reduction_step.max(0); + let referral_count = self.referrer_referral_count.max(0); + let reduction = (initial - referral_count.saturating_mul(step)).max(0); + -reduction + } +} + +#[derive(Debug, Clone)] +pub struct RiskScore { + pub score: i64, + pub is_allowed: bool, + pub fingerprint: String, + pub breakdown: RiskScoreBreakdown, +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct RiskScoreBreakdown { + #[serde(skip_serializing_if = "is_zero")] + pub abuse_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub fingerprint_match_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub ip_reuse_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub isp_model_match_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub device_id_reuse_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub ineligible_ip_type_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub verified_user_reduction: i64, + #[serde(skip_serializing_if = "is_zero")] + pub early_referral_reduction: i64, + #[serde(skip_serializing_if = "is_zero")] + pub same_referrer_pattern_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub same_referrer_fingerprint_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub same_referrer_device_model_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub device_model_ring_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub platform_store_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub country_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub locale_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub high_risk_device_model_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub user_agent_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub velocity_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub ip_history_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub cross_referrer_device_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub cross_referrer_fingerprint_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub country_diversity_score: i64, + #[serde(skip_serializing_if = "is_zero")] + pub device_farming_score: i64, +} + +fn is_zero(value: &i64) -> bool { + *value == 0 +} + +impl RiskScoreBreakdown { + pub fn to_metadata_json(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap_or(serde_json::Value::Null) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fingerprint_generation() { + let input = RiskSignalInput { + username: "user1".to_string(), + device_id: 1, + device_platform: Platform::IOS, + device_platform_store: PlatformStore::AppStore, + device_os: "18.0".to_string(), + device_model: "iPhone15,2".to_string(), + device_locale: "en-US".to_string(), + device_currency: "USD".to_string(), + ip_address: "192.168.1.1".to_string(), + ip_country_code: "US".to_string(), + ip_usage_type: IpUsageType::Isp, + ip_isp: "Comcast".to_string(), + ip_abuse_score: 0, + referrer_status: RewardStatus::Unverified, + referrer_referral_count: 0, + user_agent: String::new(), + }; + assert_eq!(input.generate_fingerprint().len(), 64); + } +} diff --git a/core/crates/gem_rewards/src/risk_scoring/scoring.rs b/core/crates/gem_rewards/src/risk_scoring/scoring.rs new file mode 100644 index 0000000000..95baa6dea9 --- /dev/null +++ b/core/crates/gem_rewards/src/risk_scoring/scoring.rs @@ -0,0 +1,1110 @@ +use std::collections::HashSet; +use std::time::Duration; + +use primitives::rewards::RewardStatus; +use regex::Regex; +use storage::models::RiskSignalRow; + +use super::model::{RiskScore, RiskScoreBreakdown, RiskScoreConfig, RiskSignalInput}; + +pub fn calculate_risk_score( + input: &RiskSignalInput, + existing_signals: &[RiskSignalRow], + device_model_ring_count: i64, + ip_abuser_count: i64, + cross_referrer_fingerprint_count: i64, + referrer_country_count: i64, + referrer_device_count: i64, + config: &RiskScoreConfig, +) -> RiskScore { + let fingerprint = input.generate_fingerprint(); + + let is_penalty_isp = config.penalty_isps.iter().any(|isp| input.ip_isp.contains(isp)); + let is_blocked_type = config.blocked_ip_types.contains(&input.ip_usage_type); + let is_high_risk_platform_store = config.high_risk_platform_stores.iter().any(|s| s == input.device_platform_store.as_ref()); + let is_high_risk_country = config.high_risk_countries.iter().any(|c| c == &input.ip_country_code); + let is_high_risk_locale = config.high_risk_locales.iter().any(|l| l == &input.device_locale); + let is_high_risk_device_model = config + .high_risk_device_models + .iter() + .any(|pattern| Regex::new(pattern).map(|re| re.is_match(&input.device_model)).unwrap_or(false)); + let is_high_risk_user_agent = !input.user_agent.is_empty() + && config + .high_risk_user_agents + .iter() + .any(|pattern| Regex::new(pattern).map(|re| re.is_match(&input.user_agent)).unwrap_or(false)); + + let mut breakdown = RiskScoreBreakdown { + abuse_score: if is_blocked_type { + input.ip_abuse_score + } else { + input.ip_abuse_score.min(config.max_abuse_score) + }, + ineligible_ip_type_score: if is_blocked_type { + config.blocked_ip_type_penalty + } else if is_penalty_isp { + config.isp_penalty_score + } else { + 0 + }, + verified_user_reduction: if input.referrer_status.is_verified() { -config.verified_user_reduction } else { 0 }, + early_referral_reduction: input.early_referral_reduction(config), + platform_store_score: if is_high_risk_platform_store { config.high_risk_platform_store_penalty } else { 0 }, + country_score: if is_high_risk_country { config.high_risk_country_penalty } else { 0 }, + locale_score: if is_high_risk_locale { config.high_risk_locale_penalty } else { 0 }, + high_risk_device_model_score: if is_high_risk_device_model { config.high_risk_device_model_penalty } else { 0 }, + user_agent_score: if is_high_risk_user_agent { config.high_risk_user_agent_penalty } else { 0 }, + ..Default::default() + }; + + let mut fingerprint_referrers: HashSet<&str> = HashSet::new(); + let mut device_id_referrers: HashSet<&str> = HashSet::new(); + let mut ip_matched = false; + let mut isp_model_matched = false; + let mut cross_referrer_device_matched = false; + + let mut same_referrer_pattern_count = 0; + let mut same_referrer_fingerprint_count = 0; + let mut same_referrer_device_model_count = 0; + + for signal in existing_signals { + if signal.referrer_username == input.username { + if signal.fingerprint == fingerprint { + same_referrer_fingerprint_count += 1; + } + + if signal.ip_isp == input.ip_isp && signal.device_model == input.device_model && *signal.device_platform == input.device_platform { + same_referrer_pattern_count += 1; + } + + if signal.device_model == input.device_model && *signal.device_platform == input.device_platform { + same_referrer_device_model_count += 1; + } + continue; + } + + // Cross-referrer device sharing: same fingerprint + same IP = definite fraud + if !cross_referrer_device_matched && signal.fingerprint == fingerprint && signal.ip_address == input.ip_address { + cross_referrer_device_matched = true; + } + + // Scaled penalties (count unique referrers) + if signal.fingerprint == fingerprint { + fingerprint_referrers.insert(&signal.referrer_username); + } + + if signal.device_id == input.device_id { + device_id_referrers.insert(&signal.referrer_username); + } + + // Binary penalties (first match triggers full penalty) + if !ip_matched && signal.ip_address == input.ip_address { + ip_matched = true; + } + + if !isp_model_matched && signal.ip_isp == input.ip_isp && signal.device_model == input.device_model { + isp_model_matched = true; + } + } + + // Scaled penalties with caps + let fingerprint_penalty = fingerprint_referrers.len() as i64 * config.fingerprint_match_penalty_per_referrer; + breakdown.fingerprint_match_score = fingerprint_penalty.min(config.fingerprint_match_max_penalty); + + let device_id_penalty = device_id_referrers.len() as i64 * config.device_id_reuse_penalty_per_referrer; + breakdown.device_id_reuse_score = device_id_penalty.min(config.device_id_reuse_max_penalty); + + // Binary penalties + if cross_referrer_device_matched { + breakdown.cross_referrer_device_score = config.cross_referrer_device_penalty; + } + if ip_matched { + breakdown.ip_reuse_score = config.ip_reuse_score; + } + if isp_model_matched && fingerprint_referrers.is_empty() { + breakdown.isp_model_match_score = config.isp_model_match_score; + } + + if same_referrer_fingerprint_count >= config.same_referrer_fingerprint_threshold { + breakdown.same_referrer_fingerprint_score = config.same_referrer_fingerprint_penalty; + } + + if same_referrer_pattern_count >= config.same_referrer_pattern_threshold { + breakdown.same_referrer_pattern_score = config.same_referrer_pattern_penalty; + } + + if same_referrer_device_model_count >= config.same_referrer_device_model_threshold { + breakdown.same_referrer_device_model_score = config.same_referrer_device_model_penalty; + } + + if device_model_ring_count >= config.device_model_ring_threshold { + breakdown.device_model_ring_score = (device_model_ring_count - 1) * config.device_model_ring_penalty_per_member; + } + + if ip_abuser_count > 0 { + let ip_history_penalty = ip_abuser_count * config.ip_history_penalty_per_abuser; + breakdown.ip_history_score = ip_history_penalty.min(config.ip_history_max_penalty); + } + + if cross_referrer_fingerprint_count >= config.cross_referrer_fingerprint_threshold { + breakdown.cross_referrer_fingerprint_score = config.cross_referrer_fingerprint_penalty; + } + + if referrer_country_count >= config.country_diversity_threshold { + breakdown.country_diversity_score = referrer_country_count * config.country_diversity_penalty_per_country; + } + + if referrer_device_count >= config.device_farming_threshold { + breakdown.device_farming_score = referrer_device_count * config.device_farming_penalty_per_device; + } + + let same_referrer_signals: Vec<_> = existing_signals.iter().filter(|s| s.referrer_username == input.username).collect(); + let multiplier = if input.referrer_status == RewardStatus::Trusted { + config.trusted_multiplier + } else if input.referrer_status.is_verified() { + config.verified_multiplier + } else { + 1 + }; + let daily_limit = config.referral_per_user_daily * multiplier; + let velocity_threshold = daily_limit / config.velocity_divisor.max(1); + let (signals_in_window, speed_multiplier) = count_signals_in_recent_window(&same_referrer_signals, config.velocity_window); + if signals_in_window >= velocity_threshold { + let over_threshold = signals_in_window - velocity_threshold + 1; + breakdown.velocity_score = ((over_threshold * config.velocity_penalty) as f64 * speed_multiplier) as i64; + } + + let score = (breakdown.abuse_score + + breakdown.fingerprint_match_score + + breakdown.ip_reuse_score + + breakdown.isp_model_match_score + + breakdown.device_id_reuse_score + + breakdown.ineligible_ip_type_score + + breakdown.same_referrer_pattern_score + + breakdown.same_referrer_fingerprint_score + + breakdown.same_referrer_device_model_score + + breakdown.device_model_ring_score + + breakdown.platform_store_score + + breakdown.country_score + + breakdown.locale_score + + breakdown.high_risk_device_model_score + + breakdown.user_agent_score + + breakdown.velocity_score + + breakdown.ip_history_score + + breakdown.cross_referrer_device_score + + breakdown.cross_referrer_fingerprint_score + + breakdown.country_diversity_score + + breakdown.device_farming_score + + breakdown.verified_user_reduction + + breakdown.early_referral_reduction) + .max(0); + + RiskScore { + score, + is_allowed: score < config.max_allowed_score, + fingerprint, + breakdown, + } +} + +fn count_signals_in_recent_window(signals: &[&RiskSignalRow], window: Duration) -> (i64, f64) { + let now = chrono::Utc::now().naive_utc(); + let window_secs = window.as_secs() as i64; + let recent: Vec<_> = signals.iter().filter(|s| now.signed_duration_since(s.created_at).num_seconds() <= window_secs).collect(); + let count = recent.len() as i64; + if count <= 1 { + return (count, 1.0); + } + let timestamps: Vec<_> = recent.iter().map(|s| s.created_at).collect(); + let (min, max) = (timestamps.iter().min().unwrap(), timestamps.iter().max().unwrap()); + let span_secs = max.signed_duration_since(*min).num_seconds().max(1); + let speed_multiplier = 1.0 + (window_secs - span_secs) as f64 / window_secs as f64; + (count, speed_multiplier) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::rewards::RewardStatus; + use primitives::{IpUsageType, Platform, PlatformStore}; + + fn create_test_input() -> RiskSignalInput { + RiskSignalInput { + username: "user1".to_string(), + device_id: 1, + device_platform: Platform::IOS, + device_platform_store: PlatformStore::AppStore, + device_os: "18.0".to_string(), + device_model: "iPhone15,2".to_string(), + device_locale: "en-US".to_string(), + device_currency: "USD".to_string(), + ip_address: "192.168.1.1".to_string(), + ip_country_code: "US".to_string(), + ip_usage_type: IpUsageType::Isp, + ip_isp: "Comcast".to_string(), + ip_abuse_score: 0, + referrer_status: RewardStatus::Unverified, + referrer_referral_count: 2, + user_agent: String::new(), + } + } + + fn create_signal(referrer_username: &str, fingerprint: &str, ip: &str, isp: &str, model: &str, device_id: i32) -> RiskSignalRow { + RiskSignalRow { + id: 1, + fingerprint: fingerprint.to_string(), + referrer_username: referrer_username.to_string(), + device_id, + device_platform: Platform::IOS.into(), + device_platform_store: PlatformStore::AppStore.into(), + device_os: "18.0".to_string(), + device_model: model.to_string(), + device_locale: "en-US".to_string(), + device_currency: "USD".to_string(), + ip_address: ip.to_string(), + ip_country_code: "US".to_string(), + ip_usage_type: IpUsageType::Isp.into(), + ip_isp: isp.to_string(), + ip_abuse_score: 0, + risk_score: 0, + user_agent: String::new(), + metadata: None, + created_at: chrono::Utc::now().naive_utc() - chrono::TimeDelta::hours(1), + } + } + + #[test] + fn clean_user() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score, 0); + assert!(result.is_allowed); + } + + #[test] + fn high_abuse_score() { + let mut input = create_test_input(); + input.ip_usage_type = IpUsageType::DataCenter; + input.ip_abuse_score = 70; + let config = RiskScoreConfig::default(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score, 170); + assert!(!result.is_allowed); + } + + #[test] + fn fingerprint_match() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let fingerprint = input.generate_fingerprint(); + + // 1 referrer * 50 per referrer = 50 + let existing = create_signal("other_user", &fingerprint, "10.0.0.1", "Comcast", "iPhone15,2", 2); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score, 50); + assert!(result.is_allowed); + } + + #[test] + fn fingerprint_match_scales_with_referrers() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let fingerprint = input.generate_fingerprint(); + + // 2 referrers * 50 = 100 + let signals = vec![ + create_signal("referrer_a", &fingerprint, "10.0.0.1", "Comcast", "iPhone15,2", 2), + create_signal("referrer_b", &fingerprint, "10.0.0.2", "Comcast", "iPhone15,2", 3), + ]; + let result = calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.fingerprint_match_score, 100); + assert!(!result.is_allowed); + } + + #[test] + fn fingerprint_match_capped() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let fingerprint = input.generate_fingerprint(); + + // 5 referrers * 50 = 250, but capped at 200 + let signals: Vec<_> = (0..5) + .map(|i| create_signal(&format!("referrer_{}", i), &fingerprint, &format!("10.0.0.{}", i), "Comcast", "iPhone15,2", 10 + i)) + .collect(); + let result = calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.fingerprint_match_score, 200); + } + + #[test] + fn ip_reuse() { + let input = create_test_input(); + let config = RiskScoreConfig { + max_allowed_score: 40, + ..Default::default() + }; + + let existing = create_signal("other_user", "different", "192.168.1.1", "Verizon", "Pixel 8", 2); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score, 50); + assert!(!result.is_allowed); + } + + #[test] + fn isp_model_match() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + let existing = create_signal("other_user", "different", "10.0.0.1", "Comcast", "iPhone15,2", 2); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score, 30); + assert!(result.is_allowed); + } + + #[test] + fn device_id_reuse() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 1 referrer * 50 per referrer = 50 + let existing = create_signal("other_user", "different", "10.0.0.1", "Verizon", "Pixel 8", 1); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score, 50); + assert!(result.is_allowed); + } + + #[test] + fn device_id_reuse_scales_with_referrers() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 2 referrers * 50 = 100 + let signals = vec![ + create_signal("referrer_a", "fp1", "10.0.0.1", "Verizon", "Pixel 8", 1), + create_signal("referrer_b", "fp2", "10.0.0.2", "AT&T", "Galaxy S23", 1), + ]; + let result = calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.device_id_reuse_score, 100); + assert!(!result.is_allowed); + } + + #[test] + fn device_id_reuse_capped() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 5 referrers * 50 = 250, but capped at 200 + let signals: Vec<_> = (0..5) + .map(|i| create_signal(&format!("referrer_{}", i), &format!("fp{}", i), &format!("10.0.0.{}", i), "ISP", "Model", 1)) + .collect(); + let result = calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.device_id_reuse_score, 200); + } + + #[test] + fn same_user_ignored() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let fingerprint = input.generate_fingerprint(); + + let existing = create_signal("user1", &fingerprint, "192.168.1.1", "Comcast", "iPhone15,2", 1); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.score, 0); + assert!(result.is_allowed); + } + + #[test] + fn no_double_counting_fingerprint_and_isp_model() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let fingerprint = input.generate_fingerprint(); + + // When fingerprint matches, isp_model is not counted (fingerprint is more specific) + let existing = create_signal("other_user", &fingerprint, "10.0.0.1", "Comcast", "iPhone15,2", 2); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.fingerprint_match_score, 50); + assert_eq!(result.breakdown.isp_model_match_score, 0); + assert_eq!(result.score, 50); + } + + #[test] + fn default_ip_type_limits_abuse_score() { + let mut input = create_test_input(); + input.ip_usage_type = IpUsageType::Isp; + input.ip_abuse_score = 80; + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.abuse_score, 60); + assert!(!result.is_allowed); + } + + #[test] + fn blocked_ip_type_gets_full_abuse_score() { + let mut input = create_test_input(); + input.ip_usage_type = IpUsageType::DataCenter; + input.ip_abuse_score = 60; + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.abuse_score, 60); + assert_eq!(result.breakdown.ineligible_ip_type_score, 100); + } + + #[test] + fn penalty_isp_adds_points() { + let mut input = create_test_input(); + input.ip_isp = "SuspiciousISP Inc".to_string(); + input.ip_abuse_score = 25; + let config = RiskScoreConfig { + penalty_isps: vec!["SuspiciousISP".to_string()], + max_allowed_score: 50, + ..Default::default() + }; + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.abuse_score, 25); + assert_eq!(result.breakdown.ineligible_ip_type_score, 30); + assert_eq!(result.score, 55); + assert!(!result.is_allowed); + } + + #[test] + fn verified_user_reduces_score() { + let mut input = create_test_input(); + input.ip_abuse_score = 60; + input.referrer_status = RewardStatus::Verified; + let config = RiskScoreConfig::default(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.abuse_score, 60); + assert_eq!(result.breakdown.verified_user_reduction, -30); + assert_eq!(result.score, 30); + assert!(result.is_allowed); + } + + #[test] + fn verified_user_score_cannot_go_negative() { + let mut input = create_test_input(); + input.referrer_status = RewardStatus::Verified; + let config = RiskScoreConfig::default(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.verified_user_reduction, -30); + assert_eq!(result.score, 0); + assert!(result.is_allowed); + } + + #[test] + fn early_referral_reduction_uses_generic_initial_and_step() { + let config = RiskScoreConfig { + early_referral_reduction_initial: 20, + early_referral_reduction_step: 7, + ..RiskScoreConfig::default() + }; + + let mut first = create_test_input(); + first.ip_abuse_score = 60; + first.referrer_referral_count = 0; + + let mut second = create_test_input(); + second.ip_abuse_score = 60; + second.referrer_referral_count = 1; + + let mut third = create_test_input(); + third.ip_abuse_score = 60; + third.referrer_referral_count = 2; + + let mut fourth = create_test_input(); + fourth.ip_abuse_score = 60; + fourth.referrer_referral_count = 3; + + let first_result = calculate_risk_score(&first, &[], 0, 0, 0, 0, 0, &config); + let second_result = calculate_risk_score(&second, &[], 0, 0, 0, 0, 0, &config); + let third_result = calculate_risk_score(&third, &[], 0, 0, 0, 0, 0, &config); + let fourth_result = calculate_risk_score(&fourth, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(first_result.breakdown.early_referral_reduction, -20); + assert_eq!(second_result.breakdown.early_referral_reduction, -13); + assert_eq!(third_result.breakdown.early_referral_reduction, -6); + assert_eq!(fourth_result.breakdown.early_referral_reduction, 0); + + assert_eq!(first_result.score, 40); + assert_eq!(second_result.score, 47); + assert_eq!(third_result.score, 54); + assert_eq!(fourth_result.score, 60); + + assert!(first_result.is_allowed); + assert!(second_result.is_allowed); + assert!(third_result.is_allowed); + assert!(!fourth_result.is_allowed); + } + + #[test] + fn same_referrer_pattern_below_threshold() { + let signals = [ + create_signal("user1", "fp1", "10.0.0.1", "Comcast", "iPhone15,2", 2), + create_signal("user1", "fp2", "10.0.0.2", "Comcast", "iPhone15,2", 3), + ]; + let result = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_pattern_score, 0); + assert!(result.is_allowed); + } + + #[test] + fn same_referrer_pattern_at_threshold() { + let signals = [ + create_signal("user1", "fp1", "10.0.0.1", "Comcast", "iPhone15,2", 2), + create_signal("user1", "fp2", "10.0.0.2", "Comcast", "iPhone15,2", 3), + create_signal("user1", "fp3", "10.0.0.3", "Comcast", "iPhone15,2", 4), + ]; + let result = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_pattern_score, 40); + assert_eq!(result.breakdown.same_referrer_device_model_score, 50); + assert_eq!(result.score, 90); + } + + #[test] + fn same_referrer_fingerprint_at_threshold() { + let input = create_test_input(); + let fingerprint = input.generate_fingerprint(); + let signals = [ + create_signal("user1", &fingerprint, "10.0.0.1", "Comcast", "iPhone15,2", 2), + create_signal("user1", &fingerprint, "10.0.0.2", "Comcast", "iPhone15,2", 3), + ]; + + let result = calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_fingerprint_score, 60); + assert!(!result.is_allowed); + } + + #[test] + fn same_referrer_both_patterns() { + let input = create_test_input(); + let fingerprint = input.generate_fingerprint(); + let signals = [ + create_signal("user1", &fingerprint, "10.0.0.1", "Comcast", "iPhone15,2", 2), + create_signal("user1", &fingerprint, "10.0.0.2", "Comcast", "iPhone15,2", 3), + create_signal("user1", &fingerprint, "10.0.0.3", "Comcast", "iPhone15,2", 4), + ]; + + let result = calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_pattern_score, 40); + assert_eq!(result.breakdown.same_referrer_fingerprint_score, 60); + assert_eq!(result.breakdown.same_referrer_device_model_score, 50); + assert_eq!(result.score, 150); + assert!(!result.is_allowed); + } + + #[test] + fn same_referrer_different_platform_ignored() { + let mut signal = create_signal("user1", "fp1", "10.0.0.1", "Comcast", "iPhone15,2", 2); + signal.device_platform = Platform::Android.into(); + signal.device_platform_store = PlatformStore::GooglePlay.into(); + let signals = [ + signal, + create_signal("user1", "fp2", "10.0.0.2", "Comcast", "iPhone15,2", 3), + create_signal("user1", "fp3", "10.0.0.3", "Comcast", "iPhone15,2", 4), + ]; + + let result = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_pattern_score, 0); + } + + #[test] + fn fraud_multiple_devices_same_fingerprint() { + let input = RiskSignalInput { + username: "referrer1".to_string(), + device_model: "TestDevice X".to_string(), + device_platform: Platform::Android, + device_platform_store: PlatformStore::GooglePlay, + ip_isp: "Test Mobile ISP".to_string(), + ip_country_code: "XX".to_string(), + device_locale: "en".to_string(), + ..create_test_input() + }; + + let fingerprint = input.generate_fingerprint(); + let signals: Vec<_> = (0..3) + .map(|i| { + let mut s = create_signal("referrer1", &fingerprint, "10.20.30.40", "Test Mobile ISP", "TestDevice X", 100 + i); + s.device_platform = Platform::Android.into(); + s.device_platform_store = PlatformStore::GooglePlay.into(); + s + }) + .collect(); + + let result = calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_pattern_score, 40); + assert_eq!(result.breakdown.same_referrer_fingerprint_score, 60); + assert_eq!(result.breakdown.same_referrer_device_model_score, 50); + assert_eq!(result.score, 150); + assert!(!result.is_allowed); + } + + #[test] + fn same_referrer_device_model_below_threshold() { + let signals = [ + create_signal("user1", "fp1", "10.0.0.1", "ISP_A", "iPhone15,2", 2), + create_signal("user1", "fp2", "10.0.0.2", "ISP_B", "iPhone15,2", 3), + ]; + let result = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_device_model_score, 0); + assert!(result.is_allowed); + } + + #[test] + fn same_referrer_device_model_at_threshold() { + let signals = [ + create_signal("user1", "fp1", "10.0.0.1", "ISP_A", "iPhone15,2", 2), + create_signal("user1", "fp2", "10.0.0.2", "ISP_B", "iPhone15,2", 3), + create_signal("user1", "fp3", "10.0.0.3", "ISP_C", "iPhone15,2", 4), + ]; + let result = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_device_model_score, 50); + assert_eq!(result.breakdown.same_referrer_pattern_score, 0); + assert_eq!(result.score, 50); + } + + #[test] + fn same_referrer_device_model_vpn_rotation_detected() { + let input = RiskSignalInput { + username: "abuser".to_string(), + device_model: "INFINIX X6525".to_string(), + device_platform: Platform::Android, + device_platform_store: PlatformStore::GooglePlay, + device_locale: "in".to_string(), + ..create_test_input() + }; + + let signals: Vec<_> = [("Comcast", "US"), ("AT&T", "US"), ("Sky Broadband", "GB"), ("BT", "GB"), ("Charter", "US")] + .iter() + .enumerate() + .map(|(i, (isp, _country))| { + let mut s = create_signal("abuser", &format!("fp{}", i), &format!("10.0.0.{}", i), isp, "INFINIX X6525", 100 + i as i32); + s.device_platform = Platform::Android.into(); + s.device_platform_store = PlatformStore::GooglePlay.into(); + s + }) + .collect(); + + let result = calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.same_referrer_device_model_score, 50); + assert_eq!(result.breakdown.same_referrer_pattern_score, 0); + assert_eq!(result.score, 50); + } + + #[test] + fn device_model_ring_detected() { + let input = create_test_input(); + let result = calculate_risk_score(&input, &[], 2, 0, 0, 0, 0, &RiskScoreConfig::default()); + + // count=2: (2-1) * 40 = 40 + assert_eq!(result.breakdown.device_model_ring_score, 40); + assert_eq!(result.score, 40); + assert!(result.is_allowed); + } + + #[test] + fn device_model_ring_scales_with_count() { + let input = create_test_input(); + + // count=3: (3-1) * 40 = 80 + let result = calculate_risk_score(&input, &[], 3, 0, 0, 0, 0, &RiskScoreConfig::default()); + assert_eq!(result.breakdown.device_model_ring_score, 80); + assert!(!result.is_allowed); + + // count=5: (5-1) * 40 = 160 + let result = calculate_risk_score(&input, &[], 5, 0, 0, 0, 0, &RiskScoreConfig::default()); + assert_eq!(result.breakdown.device_model_ring_score, 160); + assert!(!result.is_allowed); + } + + #[test] + fn device_model_ring_below_threshold() { + let input = create_test_input(); + let result = calculate_risk_score(&input, &[], 1, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.device_model_ring_score, 0); + assert_eq!(result.score, 0); + assert!(result.is_allowed); + } + + fn create_recent_signal(referrer_username: &str, seconds_ago: i64) -> RiskSignalRow { + let mut s = create_signal(referrer_username, "fp", "10.0.0.1", "ISP", "Model", 2); + s.created_at = chrono::Utc::now().naive_utc() - chrono::TimeDelta::seconds(seconds_ago); + s + } + + #[test] + fn velocity_no_burst() { + // Signals from different referrer don't trigger velocity for user1 + let result = calculate_risk_score(&create_test_input(), &[create_recent_signal("other", 60)], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + assert_eq!(result.breakdown.velocity_score, 0); + } + + #[test] + fn velocity_burst() { + // Normal user threshold=2 (5/2), 1 signal - no penalty + let result = calculate_risk_score(&create_test_input(), &[create_recent_signal("user1", 60)], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + assert_eq!(result.breakdown.velocity_score, 0); + // 2 signals triggers penalty + let signals = vec![create_recent_signal("user1", 60), create_recent_signal("user1", 120)]; + let result = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + assert!(result.breakdown.velocity_score > 0); + } + + #[test] + fn velocity_scales_with_count_and_speed() { + // More signals and tighter span = higher penalty + let signals = vec![create_recent_signal("user1", 60), create_recent_signal("user1", 120)]; + let score2 = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()) + .breakdown + .velocity_score; + let signals = vec![create_recent_signal("user1", 60), create_recent_signal("user1", 120), create_recent_signal("user1", 180)]; + let score3 = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()) + .breakdown + .velocity_score; + assert!(score3 > score2); + assert!(score2 > 0); + } + + #[test] + fn velocity_faster_spam_higher_penalty() { + // Same count but tighter time = higher penalty + // 3 signals in 120s span: multiplier=1.6, penalty=300*1.6=480 + let signals = vec![create_recent_signal("user1", 60), create_recent_signal("user1", 120), create_recent_signal("user1", 180)]; + let slow = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()) + .breakdown + .velocity_score; + // 3 signals in 20s span: multiplier=1+(300-20)/300=1.93, penalty=300*1.93=579 + let signals = vec![create_recent_signal("user1", 60), create_recent_signal("user1", 70), create_recent_signal("user1", 80)]; + let fast = calculate_risk_score(&create_test_input(), &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()) + .breakdown + .velocity_score; + assert!(fast > slow); + } + + #[test] + fn velocity_verified_user() { + let mut input = create_test_input(); + input.referrer_status = RewardStatus::Verified; + // Verified user threshold=5 (10/2), 4 signals - no penalty + let signals: Vec<_> = (0..4).map(|i| create_recent_signal("user1", 60 + i * 30)).collect(); + assert_eq!( + calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()).breakdown.velocity_score, + 0 + ); + // 5 signals triggers penalty + let signals: Vec<_> = (0..5).map(|i| create_recent_signal("user1", 60 + i * 30)).collect(); + assert!(calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()).breakdown.velocity_score > 0); + } + + #[test] + fn velocity_trusted_user() { + let mut input = create_test_input(); + input.referrer_status = RewardStatus::Trusted; + // Trusted user threshold=7 (15/2), 6 signals - no penalty + let signals: Vec<_> = (0..6).map(|i| create_recent_signal("user1", 60 + i * 30)).collect(); + assert_eq!( + calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()).breakdown.velocity_score, + 0 + ); + // 7 signals triggers penalty + let signals: Vec<_> = (0..7).map(|i| create_recent_signal("user1", 60 + i * 30)).collect(); + assert!(calculate_risk_score(&input, &signals, 0, 0, 0, 0, 0, &RiskScoreConfig::default()).breakdown.velocity_score > 0); + } + + #[test] + fn high_risk_device_model_emulator() { + let mut input = create_test_input(); + input.device_model = "Google sdk_gphone64_arm64".to_string(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.high_risk_device_model_score, 50); + assert_eq!(result.score, 50); + } + + #[test] + fn high_risk_device_model_no_match() { + let input = create_test_input(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.high_risk_device_model_score, 0); + } + + #[test] + fn high_risk_device_model_custom_pattern() { + let mut input = create_test_input(); + input.device_model = "INFINIX X6525".to_string(); + let config = RiskScoreConfig { + high_risk_device_models: vec!["INFINIX".to_string()], + ..Default::default() + }; + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.high_risk_device_model_score, 50); + } + + #[test] + fn high_risk_device_model_regex_pattern() { + let mut input = create_test_input(); + input.device_model = "Redmi 2201117TY".to_string(); + let config = RiskScoreConfig { + high_risk_device_models: vec![r"\d{7}[A-Z]{2}".to_string()], + ..Default::default() + }; + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.high_risk_device_model_score, 50); + } + + #[test] + fn high_risk_user_agent_python() { + let mut input = create_test_input(); + input.user_agent = "python-requests/2.32.5".to_string(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.user_agent_score, 100); + assert!(!result.is_allowed); + } + + #[test] + fn high_risk_user_agent_curl() { + let mut input = create_test_input(); + input.user_agent = "curl/8.1.2".to_string(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.user_agent_score, 100); + } + + #[test] + fn high_risk_user_agent_no_match() { + let mut input = create_test_input(); + input.user_agent = "Gem/3.0 (iOS 18.0; iPhone15,2)".to_string(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.user_agent_score, 0); + } + + #[test] + fn high_risk_user_agent_empty() { + let input = create_test_input(); + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 0, &RiskScoreConfig::default()); + + assert_eq!(result.breakdown.user_agent_score, 0); + } + + #[test] + fn ip_history() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + assert_eq!(calculate_risk_score(&input, &[], 0, 3, 0, 0, 0, &config).breakdown.ip_history_score, 90); + assert_eq!(calculate_risk_score(&input, &[], 0, 10, 0, 0, 0, &config).breakdown.ip_history_score, 150); + } + + #[test] + fn cross_referrer_device_same_fingerprint_same_ip() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let fingerprint = input.generate_fingerprint(); + + // Same fingerprint + same IP + different referrer = fraud (500 penalty) + let existing = create_signal("other_referrer", &fingerprint, "192.168.1.1", "Comcast", "iPhone15,2", 2); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.cross_referrer_device_score, 500); + assert!(!result.is_allowed); + } + + #[test] + fn cross_referrer_device_same_fingerprint_different_ip() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let fingerprint = input.generate_fingerprint(); + + // Same fingerprint but different IP = only fingerprint penalty (50), not cross-referrer + let existing = create_signal("other_referrer", &fingerprint, "10.0.0.1", "Comcast", "iPhone15,2", 2); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.cross_referrer_device_score, 0); + assert_eq!(result.breakdown.fingerprint_match_score, 50); + } + + #[test] + fn cross_referrer_device_same_ip_different_fingerprint() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // Same IP but different fingerprint = only IP reuse penalty (50), not cross-referrer + let existing = create_signal("other_referrer", "different_fingerprint", "192.168.1.1", "Verizon", "Pixel 8", 2); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.cross_referrer_device_score, 0); + assert_eq!(result.breakdown.ip_reuse_score, 50); + } + + #[test] + fn cross_referrer_device_same_referrer_ignored() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + let fingerprint = input.generate_fingerprint(); + + // Same referrer should not trigger cross-referrer penalty + let existing = create_signal("user1", &fingerprint, "192.168.1.1", "Comcast", "iPhone15,2", 2); + let result = calculate_risk_score(&input, &[existing], 0, 0, 0, 0, 0, &config); + + assert_eq!(result.breakdown.cross_referrer_device_score, 0); + } + + #[test] + fn cross_referrer_fingerprint_below_threshold() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 1 referrer is below threshold (2) + let result = calculate_risk_score(&input, &[], 0, 0, 1, 0, 0, &config); + + assert_eq!(result.breakdown.cross_referrer_fingerprint_score, 0); + } + + #[test] + fn cross_referrer_fingerprint_at_threshold() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 2 referrers triggers penalty (threshold is 2) + let result = calculate_risk_score(&input, &[], 0, 0, 2, 0, 0, &config); + + assert_eq!(result.breakdown.cross_referrer_fingerprint_score, 100); + assert!(!result.is_allowed); + } + + #[test] + fn cross_referrer_fingerprint_above_threshold() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 6 referrers (VPN fraud ring) triggers penalty + let result = calculate_risk_score(&input, &[], 0, 0, 6, 0, 0, &config); + + assert_eq!(result.breakdown.cross_referrer_fingerprint_score, 100); + assert!(!result.is_allowed); + } + + #[test] + fn country_diversity_below_threshold() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 4 countries is below threshold (5) + let result = calculate_risk_score(&input, &[], 0, 0, 0, 4, 0, &config); + + assert_eq!(result.breakdown.country_diversity_score, 0); + } + + #[test] + fn country_diversity_at_threshold() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 5 countries = 5 * 5 = 25 penalty + let result = calculate_risk_score(&input, &[], 0, 0, 0, 5, 0, &config); + + assert_eq!(result.breakdown.country_diversity_score, 25); + assert!(result.is_allowed); + } + + #[test] + fn country_diversity_high_count() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 10 countries = 10 * 5 = 50 penalty + let result = calculate_risk_score(&input, &[], 0, 0, 0, 10, 0, &config); + + assert_eq!(result.breakdown.country_diversity_score, 50); + assert!(result.is_allowed); + + // 13 countries = 13 * 5 = 65 penalty -> blocked + let result = calculate_risk_score(&input, &[], 0, 0, 0, 13, 0, &config); + + assert_eq!(result.breakdown.country_diversity_score, 65); + assert!(!result.is_allowed); + } + + #[test] + fn device_farming_below_threshold() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 9 devices is below threshold (10) + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 9, &config); + + assert_eq!(result.breakdown.device_farming_score, 0); + } + + #[test] + fn device_farming_at_threshold() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 10 devices = 10 * 3 = 30 penalty + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 10, &config); + + assert_eq!(result.breakdown.device_farming_score, 30); + assert!(result.is_allowed); + } + + #[test] + fn device_farming_high_count() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 20 devices = 20 * 3 = 60 penalty -> blocked + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 20, &config); + + assert_eq!(result.breakdown.device_farming_score, 60); + assert!(!result.is_allowed); + + // 41 devices = 41 * 3 = 123 penalty + let result = calculate_risk_score(&input, &[], 0, 0, 0, 0, 41, &config); + + assert_eq!(result.breakdown.device_farming_score, 123); + assert!(!result.is_allowed); + } + + #[test] + fn combined_country_and_device_farming() { + let input = create_test_input(); + let config = RiskScoreConfig::default(); + + // 10 countries + 41 devices: 10 * 5 = 50 + 41 * 3 = 123 = 173 total + let result = calculate_risk_score(&input, &[], 0, 0, 0, 10, 41, &config); + + assert_eq!(result.breakdown.country_diversity_score, 50); + assert_eq!(result.breakdown.device_farming_score, 123); + assert_eq!(result.score, 173); + assert!(!result.is_allowed); + } +} diff --git a/core/crates/gem_rewards/src/transfer_provider/evm/mod.rs b/core/crates/gem_rewards/src/transfer_provider/evm/mod.rs new file mode 100644 index 0000000000..80ed7bdac8 --- /dev/null +++ b/core/crates/gem_rewards/src/transfer_provider/evm/mod.rs @@ -0,0 +1,3 @@ +mod provider; + +pub use provider::{EvmClientProvider, EvmTransferProvider, WalletConfig}; diff --git a/core/crates/gem_rewards/src/transfer_provider/evm/provider.rs b/core/crates/gem_rewards/src/transfer_provider/evm/provider.rs new file mode 100644 index 0000000000..281bc77310 --- /dev/null +++ b/core/crates/gem_rewards/src/transfer_provider/evm/provider.rs @@ -0,0 +1,114 @@ +use crate::transfer_provider::RedemptionProvider; +use crate::{RedemptionRequest, RedemptionResult}; +use alloy_primitives::hex; +use chain_traits::ChainTransactionLoad; +use gem_client::ReqwestClient; +use gem_evm::rpc::EthereumClient; +use gem_evm::signer::{create_transfer_tx, sign_eip1559_tx}; +use num_traits::ToPrimitive; +use primitives::{ChainType, EVMChain, FeePriority, FeeRate, TransactionInputType, TransactionLoadInput, TransactionPreloadInput}; +use std::collections::HashMap; +use std::error::Error; +use std::sync::Arc; + +pub struct WalletConfig { + pub key: String, + pub address: String, +} + +pub type EvmClientProvider = Arc Option> + Send + Sync>; + +pub struct EvmTransferProvider { + wallets: HashMap, + client_provider: EvmClientProvider, +} + +impl EvmTransferProvider { + pub fn new(wallets: HashMap, client_provider: EvmClientProvider) -> Self { + Self { wallets, client_provider } + } + + fn get_wallet(&self, chain_type: ChainType) -> Result<&WalletConfig, Box> { + self.wallets + .get(&chain_type) + .ok_or_else(|| format!("No wallet configured for chain type {:?}", chain_type).into()) + } + + fn get_private_key(&self, wallet: &WalletConfig) -> Result, Box> { + let key = hex::decode(wallet.key.trim_start_matches("0x"))?; + if key.len() != 32 { + return Err("Private key must be 32 bytes".into()); + } + Ok(key) + } + + fn get_client(&self, chain: EVMChain) -> Result, Box> { + (self.client_provider)(chain).ok_or_else(|| format!("No client configured for chain {:?}", chain).into()) + } + + async fn execute_transfer(&self, request: RedemptionRequest) -> Result> { + let redemption_asset = request.asset.ok_or("Asset is required")?; + let value = redemption_asset.value; + let asset = redemption_asset.asset; + let chain = asset.id.chain; + let evm_chain = EVMChain::from_chain(chain).ok_or_else(|| format!("Chain {} is not an EVM chain", chain))?; + + let wallet = self.get_wallet(chain.chain_type())?; + let private_key = self.get_private_key(wallet)?; + let client = self.get_client(evm_chain)?; + + let fee_rates = client.get_transaction_fee_rates(TransactionInputType::Transfer(asset.clone())).await?; + let fee_rate = FeeRate::find(&fee_rates, FeePriority::Fast).ok_or("No fast fee rate")?; + + let preload_input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(asset.clone()), + sender_address: wallet.address.clone(), + destination_address: request.recipient_address.clone(), + }; + let metadata = client.get_transaction_preload(preload_input).await?; + + let load_input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(asset.clone()), + sender_address: wallet.address.clone(), + destination_address: request.recipient_address.clone(), + value: value.clone(), + gas_price: fee_rate.gas_price_type.clone(), + memo: None, + is_max_value: false, + metadata: metadata.clone(), + }; + + let load_data = client.get_transaction_load(load_input).await?; + + let nonce = metadata.get_sequence()?; + let chain_id: u64 = metadata.get_chain_id()?.parse()?; + let gas_limit = load_data.fee.gas_limit.to_u64().ok_or("Gas limit overflow")?; + let base_fee = fee_rate.gas_price_type.gas_price().to_u128().ok_or("Base fee overflow")?; + let priority_fee = fee_rate.gas_price_type.priority_fee().to_u128().ok_or("Priority fee overflow")?; + let max_priority_fee_per_gas = priority_fee; + let max_fee_per_gas = base_fee + priority_fee; + + let tx = create_transfer_tx( + &asset.id, + &request.recipient_address, + &value, + nonce, + chain_id, + max_fee_per_gas, + max_priority_fee_per_gas, + gas_limit, + )?; + + let signed_tx = sign_eip1559_tx(&tx, &private_key)?; + let signed_tx_hex = format!("0x{}", hex::encode(&signed_tx)); + let transaction_id = client.send_raw_transaction(&signed_tx_hex).await?; + + Ok(RedemptionResult { transaction_id }) + } +} + +impl RedemptionProvider for EvmTransferProvider { + async fn process_redemption(&self, request: RedemptionRequest) -> Result> { + self.execute_transfer(request).await + } +} diff --git a/core/crates/gem_rewards/src/transfer_provider/mod.rs b/core/crates/gem_rewards/src/transfer_provider/mod.rs new file mode 100644 index 0000000000..44c0511897 --- /dev/null +++ b/core/crates/gem_rewards/src/transfer_provider/mod.rs @@ -0,0 +1,10 @@ +mod evm; + +use crate::{RedemptionRequest, RedemptionResult}; +use std::error::Error; + +pub use evm::{EvmClientProvider, EvmTransferProvider, WalletConfig}; + +pub trait RedemptionProvider: Send + Sync { + fn process_redemption(&self, request: RedemptionRequest) -> impl std::future::Future>> + Send; +} diff --git a/core/crates/gem_rewards/src/transfer_redemption_service.rs b/core/crates/gem_rewards/src/transfer_redemption_service.rs new file mode 100644 index 0000000000..729a64ef67 --- /dev/null +++ b/core/crates/gem_rewards/src/transfer_redemption_service.rs @@ -0,0 +1,22 @@ +use crate::transfer_provider::{EvmClientProvider, EvmTransferProvider, RedemptionProvider, WalletConfig}; +use crate::{RedemptionRequest, RedemptionResult, RedemptionService}; +use primitives::ChainType; +use std::collections::HashMap; +use std::error::Error; + +pub struct TransferRedemptionService { + evm_provider: EvmTransferProvider, +} + +impl TransferRedemptionService { + pub fn new(wallets: HashMap, client_provider: EvmClientProvider) -> Self { + let evm_provider = EvmTransferProvider::new(wallets, client_provider); + Self { evm_provider } + } +} + +impl RedemptionService for TransferRedemptionService { + async fn process_redemption(&self, request: RedemptionRequest) -> Result> { + self.evm_provider.process_redemption(request).await + } +} diff --git a/core/crates/gem_solana/Cargo.toml b/core/crates/gem_solana/Cargo.toml new file mode 100644 index 0000000000..ee6951ed65 --- /dev/null +++ b/core/crates/gem_solana/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "gem_solana" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = ["rpc"] +rpc = [ + "gem_jsonrpc/client", + "dep:async-trait", + "dep:gem_client", + "dep:chain_traits", + "dep:futures", +] +signer = ["dep:num-traits"] +reqwest = ["gem_jsonrpc/reqwest"] +chain_integration_tests = [ + "rpc", + "reqwest", + "primitives/testkit", + "dep:settings", +] +integration = [] + +[dependencies] +sha2 = { workspace = true } +bs58 = { workspace = true } +borsh = { workspace = true } +gem_encoding = { path = "../gem_encoding" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +num-bigint = { workspace = true } +chrono = { workspace = true } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } +primitives = { path = "../primitives" } +gem_jsonrpc = { path = "../gem_jsonrpc" } +async-trait = { workspace = true, optional = true } +gem_client = { path = "../gem_client", optional = true } +chain_traits = { path = "../chain_traits", optional = true } +futures = { workspace = true, optional = true } +settings = { path = "../settings", features = ["testkit"], optional = true } +solana-primitives = "0.2.5" +num-traits = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } +primitives = { path = "../primitives", features = ["testkit"], default-features = false } diff --git a/core/crates/gem_solana/src/address.rs b/core/crates/gem_solana/src/address.rs new file mode 100644 index 0000000000..4e592df2db --- /dev/null +++ b/core/crates/gem_solana/src/address.rs @@ -0,0 +1,52 @@ +use primitives::Address as AddressTrait; +use solana_primitives::{Pubkey, SolanaError}; + +pub struct SolanaAddress(Pubkey); + +impl From for Pubkey { + fn from(value: SolanaAddress) -> Self { + value.0 + } +} + +impl SolanaAddress { + pub fn parse(address: &str) -> Result { + Pubkey::from_base58(address).map(Self) + } +} + +impl AddressTrait for SolanaAddress { + fn try_parse(address: &str) -> Option { + Pubkey::from_base58(address).ok().map(Self) + } + + fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + fn encode(&self) -> String { + self.0.to_base58() + } +} + +pub fn validate_address(address: &str) -> bool { + SolanaAddress::is_valid(address) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_solana_address() { + let string = "GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"; + let address = SolanaAddress::try_parse(string).unwrap(); + + assert!(validate_address(string)); + assert_eq!(address.as_bytes().len(), 32); + assert_eq!(address.encode(), string); + assert_eq!(SolanaAddress::parse(string).unwrap().encode(), string); + assert!(SolanaAddress::parse("invalid").is_err()); + assert!(!validate_address("invalid")); + } +} diff --git a/core/crates/gem_solana/src/constants.rs b/core/crates/gem_solana/src/constants.rs new file mode 100644 index 0000000000..614061acf0 --- /dev/null +++ b/core/crates/gem_solana/src/constants.rs @@ -0,0 +1 @@ +pub const STATIC_BASE_FEE: u64 = 5000; diff --git a/core/crates/gem_solana/src/hash.rs b/core/crates/gem_solana/src/hash.rs new file mode 100644 index 0000000000..4c51fbe3c5 --- /dev/null +++ b/core/crates/gem_solana/src/hash.rs @@ -0,0 +1,125 @@ +// Taken from https://github.com/solana-labs/solana/blob/master/sdk/program/src/hash.rs + +use { + sha2::{Digest, Sha256}, + std::{convert::TryFrom, fmt, mem, str::FromStr}, +}; + +/// Size of a hash in bytes. +pub const HASH_BYTES: usize = 32; +/// Maximum string length of a base58 encoded hash. +const MAX_BASE58_LEN: usize = 44; + +#[derive(Clone, Copy, Default, Eq, PartialEq)] +pub struct Hash(pub(crate) [u8; HASH_BYTES]); + +#[derive(Clone, Default)] +pub struct Hasher { + hasher: Sha256, +} + +impl Hasher { + pub fn hash(&mut self, val: &[u8]) { + self.hasher.update(val); + } + pub fn hashv(&mut self, vals: &[&[u8]]) { + for val in vals { + self.hash(val); + } + } + pub fn result(self) -> Hash { + Hash(self.hasher.finalize().into()) + } +} + +impl From<[u8; HASH_BYTES]> for Hash { + fn from(from: [u8; 32]) -> Self { + Self(from) + } +} + +impl AsRef<[u8]> for Hash { + fn as_ref(&self) -> &[u8] { + &self.0[..] + } +} + +impl fmt::Debug for Hash { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", bs58::encode(self.0).into_string()) + } +} + +impl fmt::Display for Hash { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", bs58::encode(self.0).into_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseHashError { + WrongSize, + Invalid, +} + +impl fmt::Display for ParseHashError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongSize => write!(f, "string decoded to wrong size for hash"), + Self::Invalid => write!(f, "failed to decoded string to hash"), + } + } +} + +impl std::error::Error for ParseHashError {} + +impl FromStr for Hash { + type Err = ParseHashError; + + fn from_str(s: &str) -> Result { + if s.len() > MAX_BASE58_LEN { + return Err(ParseHashError::WrongSize); + } + let bytes = bs58::decode(s).into_vec().map_err(|_| ParseHashError::Invalid)?; + if bytes.len() != mem::size_of::() { + Err(ParseHashError::WrongSize) + } else { + Ok(Hash::new(&bytes)) + } + } +} + +impl Hash { + pub fn new(hash_slice: &[u8]) -> Self { + Hash(<[u8; HASH_BYTES]>::try_from(hash_slice).unwrap()) + } + + pub const fn new_from_array(hash_array: [u8; HASH_BYTES]) -> Self { + Self(hash_array) + } + + pub fn to_bytes(self) -> [u8; HASH_BYTES] { + self.0 + } +} + +/// Return a Sha256 hash for the given data. +pub fn hashv(vals: &[&[u8]]) -> Hash { + // Perform the calculation inline, calling this from within a program is + // not supported + let mut hasher = Hasher::default(); + hasher.hashv(vals); + hasher.result() +} + +/// Return a Sha256 hash for the given data. +pub fn hash(val: &[u8]) -> Hash { + hashv(&[val]) +} + +/// Return the hash of the given hash extended with the given value. +pub fn extend_and_hash(id: &Hash, val: &[u8]) -> Hash { + let mut hash_data = id.as_ref().to_vec(); + hash_data.extend_from_slice(val); + hash(&hash_data) +} diff --git a/core/crates/gem_solana/src/jsonrpc.rs b/core/crates/gem_solana/src/jsonrpc.rs new file mode 100644 index 0000000000..1b1e2a651c --- /dev/null +++ b/core/crates/gem_solana/src/jsonrpc.rs @@ -0,0 +1,48 @@ +use std::fmt::Display; + +use crate::models::{Configuration, Filter}; +use gem_jsonrpc::types::{JsonRpcRequest, JsonRpcRequestConvert}; +use serde_json::Value; + +pub const ENCODING_BASE64: &str = "base64"; +pub const ENCODING_BASE58: &str = "base58"; + +pub enum SolanaRpc { + GetProgramAccounts(String, Vec), + GetAccountInfo(String), + GetMultipleAccounts(Vec), + GetEpochInfo, + GetLatestBlockhash, +} + +impl Display for SolanaRpc { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SolanaRpc::GetProgramAccounts(_, _) => write!(f, "getProgramAccounts"), + SolanaRpc::GetAccountInfo(_) => write!(f, "getAccountInfo"), + SolanaRpc::GetMultipleAccounts(_) => write!(f, "getMultipleAccounts"), + SolanaRpc::GetEpochInfo => write!(f, "getEpochInfo"), + SolanaRpc::GetLatestBlockhash => write!(f, "getLatestBlockhash"), + } + } +} + +impl JsonRpcRequestConvert for SolanaRpc { + fn to_req(&self, id: u64) -> JsonRpcRequest { + let val = self; + let method = val.to_string(); + let default_config = Configuration::default(); + + let params: Vec = match val { + SolanaRpc::GetProgramAccounts(program, filters) => vec![Value::String(program.into()), serde_json::to_value(Configuration::new(filters.to_vec())).unwrap()], + SolanaRpc::GetAccountInfo(program) => vec![Value::String(program.into()), serde_json::to_value(default_config).unwrap()], + SolanaRpc::GetMultipleAccounts(accounts) => vec![ + Value::Array(accounts.iter().map(|x| serde_json::to_value(x).unwrap()).collect()), + serde_json::to_value(default_config).unwrap(), + ], + SolanaRpc::GetEpochInfo | SolanaRpc::GetLatestBlockhash => vec![], + }; + + JsonRpcRequest::new(id, &method, params.into()) + } +} diff --git a/core/crates/gem_solana/src/lib.rs b/core/crates/gem_solana/src/lib.rs new file mode 100644 index 0000000000..3c31131627 --- /dev/null +++ b/core/crates/gem_solana/src/lib.rs @@ -0,0 +1,91 @@ +pub mod address; +pub mod constants; +pub mod hash; +pub mod jsonrpc; +pub mod metaplex; +pub mod metaplex_core; +pub mod token_account; + +#[cfg(any(feature = "rpc", feature = "reqwest"))] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +pub mod models; +pub mod transaction; + +#[cfg(feature = "signer")] +pub mod signer; + +pub use address::{SolanaAddress, validate_address}; +pub use jsonrpc::SolanaRpc; +pub use solana_primitives::{Pubkey, SolanaError, find_program_address}; +pub use transaction::{decode_transaction, encode_v0_transaction, instruction_from_primitive, instructions_from_primitives, try_decode_blockhash, try_decode_transaction}; + +#[cfg(all(feature = "reqwest", not(feature = "rpc")))] +pub use rpc::client::SolanaClient; +#[cfg(feature = "rpc")] +pub use rpc::client::SolanaClient; + +// Constants +pub use primitives::asset_constants::{ + SOLANA_PYUSD_TOKEN_ID as PYUSD_TOKEN_MINT, SOLANA_USDC_TOKEN_ID as USDC_TOKEN_MINT, SOLANA_USDS_TOKEN_ID as USDS_TOKEN_MINT, SOLANA_USDT_TOKEN_ID as USDT_TOKEN_MINT, +}; +pub use primitives::contract_constants::{ + SOLANA_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID as ASSOCIATED_TOKEN_ACCOUNT_PROGRAM, SOLANA_BPF_LOADER_PROGRAM_ID as BPF_LOADER_PROGRAM_ID, + SOLANA_COMPUTE_BUDGET_PROGRAM_ID as COMPUTE_BUDGET_PROGRAM_ID, SOLANA_JITO_TIP_PROGRAM_ID as JITO_TIP_PROGRAM_ID, SOLANA_JUPITER_PROGRAM_ID as JUPITER_PROGRAM_ID, + SOLANA_MEMO_PROGRAM_ID as MEMO_PROGRAM_ID, SOLANA_METAPLEX_CORE_PROGRAM_ID as METAPLEX_CORE_PROGRAM, SOLANA_METAPLEX_PROGRAM_ID as METAPLEX_PROGRAM, + SOLANA_OKX_DEX_V2_PROGRAM_ID as OKX_DEX_V2_PROGRAM_ID, SOLANA_STAKE_PROGRAM_ID as STAKE_PROGRAM_ID, SOLANA_SYSTEM_PROGRAM_ID as SYSTEM_PROGRAM_ID, + SOLANA_SYSVAR_CLOCK_ID as SYSVAR_CLOCK_ID, SOLANA_SYSVAR_INSTRUCTIONS_ID as SYSVAR_INSTRUCTIONS_ID, SOLANA_SYSVAR_RENT_ID as SYSVAR_RENT_ID, + SOLANA_TOKEN_2022_PROGRAM_ID as TOKEN_PROGRAM_2022, SOLANA_TOKEN_PROGRAM_ID as TOKEN_PROGRAM, SOLANA_VOTE_PROGRAM_ID as VOTE_PROGRAM_ID, + SOLANA_WRAPPED_SOL_TOKEN_ADDRESS as WSOL_TOKEN_ADDRESS, +}; +pub const COMPUTE_UNIT_LIMIT_DISCRIMINANT: u8 = 2; +pub const COMPUTE_UNIT_PRICE_DISCRIMINANT: u8 = 3; +pub const DEFAULT_SWAP_GAS_LIMIT: u32 = 420_000; +pub const SYSTEM_PROGRAMS: &[&str] = &[ + SYSTEM_PROGRAM_ID, + COMPUTE_BUDGET_PROGRAM_ID, + TOKEN_PROGRAM, + TOKEN_PROGRAM_2022, + ASSOCIATED_TOKEN_ACCOUNT_PROGRAM, + VOTE_PROGRAM_ID, + STAKE_PROGRAM_ID, + SYSVAR_CLOCK_ID, + SYSVAR_RENT_ID, + SYSVAR_INSTRUCTIONS_ID, + BPF_LOADER_PROGRAM_ID, + MEMO_PROGRAM_ID, + JITO_TIP_PROGRAM_ID, +]; +pub const COMMITMENT_CONFIRMED: &str = "confirmed"; + +use primitives::{AssetId, SolanaTokenProgramId}; + +pub fn get_token_program_by_id(id: SolanaTokenProgramId) -> &'static str { + match id { + SolanaTokenProgramId::Token => TOKEN_PROGRAM, + SolanaTokenProgramId::Token2022 => TOKEN_PROGRAM_2022, + } +} + +pub fn get_token_program_id_by_address(address: &str) -> Option { + match address { + TOKEN_PROGRAM => Some(SolanaTokenProgramId::Token), + TOKEN_PROGRAM_2022 => Some(SolanaTokenProgramId::Token2022), + _ => None, + } +} + +pub fn get_pubkey_by_asset(asset_id: &AssetId) -> Option { + match &asset_id.token_id { + Some(token_id) => Pubkey::from_base58(token_id).ok(), + None => Pubkey::from_base58(WSOL_TOKEN_ADDRESS).ok(), + } +} + +pub fn get_pubkey_by_str(asset_id: &str) -> Option { + let asset_id = AssetId::new(asset_id)?; + get_pubkey_by_asset(&asset_id) +} diff --git a/core/crates/gem_solana/src/metaplex/collection.rs b/core/crates/gem_solana/src/metaplex/collection.rs new file mode 100644 index 0000000000..6458ac9394 --- /dev/null +++ b/core/crates/gem_solana/src/metaplex/collection.rs @@ -0,0 +1,14 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_primitives::Pubkey; + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub struct Collection { + pub verified: bool, + pub key: Pubkey, +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub enum CollectionDetails { + V1 { size: u64 }, + V2 { padding: [u8; 8] }, +} diff --git a/core/crates/gem_solana/src/metaplex/data.rs b/core/crates/gem_solana/src/metaplex/data.rs new file mode 100644 index 0000000000..afa34985f0 --- /dev/null +++ b/core/crates/gem_solana/src/metaplex/data.rs @@ -0,0 +1,23 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_primitives::Pubkey; + +#[derive(BorshSerialize, BorshDeserialize, Default, PartialEq, Eq, Debug, Clone)] +pub struct Data { + /// The name of the asset + pub name: String, + /// The symbol for the asset + pub symbol: String, + /// URI pointing to JSON representing the asset + pub uri: String, + /// Royalty basis points that goes to creators in secondary sales (0-10000) + pub seller_fee_basis_points: u16, + /// Array of creators, optional + pub creators: Option>, +} + +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] +pub struct Creator { + pub address: Pubkey, + pub verified: bool, + pub share: u8, +} diff --git a/core/crates/gem_solana/src/metaplex/metadata.rs b/core/crates/gem_solana/src/metaplex/metadata.rs new file mode 100644 index 0000000000..12268bb60e --- /dev/null +++ b/core/crates/gem_solana/src/metaplex/metadata.rs @@ -0,0 +1,89 @@ +use std::str::FromStr; + +use crate::metaplex::{ + Key, TokenStandard, + collection::{Collection, CollectionDetails}, + data::Data, + uses::Uses, +}; +use crate::{METAPLEX_PROGRAM, Pubkey, find_program_address}; +use borsh::{BorshDeserialize, BorshSerialize}; + +#[derive(Clone, BorshDeserialize, BorshSerialize, Debug, PartialEq, Eq)] +pub struct Metadata { + /// Account discriminator. + pub key: Key, + /// Address of the update authority. + pub update_authority: Pubkey, + /// Address of the mint. + pub mint: Pubkey, + /// Asset data. + pub data: Data, + // Immutable, once flipped, all sales of this metadata are considered secondary. + pub primary_sale_happened: bool, + // Whether or not the data struct is mutable, default is not + pub is_mutable: bool, + /// nonce for easy calculation of editions, if present + pub edition_nonce: Option, + /// Since we cannot easily change Metadata, we add the new DataV2 fields here at the end. + pub token_standard: Option, + /// Collection + pub collection: Option, + /// Uses + pub uses: Option, + /// Collection Details + pub collection_details: Option, + /// Programmable Config + pub programmable_config: Option, +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub enum ProgrammableConfig { + V1 { rule_set: Option }, +} + +impl Metadata { + pub fn find_pda(mint: Pubkey) -> Option<(Pubkey, u8)> { + let mpl_id = Pubkey::from_str(METAPLEX_PROGRAM).ok()?; + find_program_address(&mpl_id, &["metadata".as_bytes(), mpl_id.as_bytes().as_ref(), mint.as_bytes().as_ref()]).ok() + } + + pub fn find_master_edition_pda(mint: Pubkey) -> Option<(Pubkey, u8)> { + let mpl_id = Pubkey::from_str(METAPLEX_PROGRAM).ok()?; + find_program_address( + &mpl_id, + &["metadata".as_bytes(), mpl_id.as_bytes().as_ref(), mint.as_bytes().as_ref(), "edition".as_bytes()], + ) + .ok() + } + + pub fn find_token_record_pda(mint: Pubkey, token_account: Pubkey) -> Option<(Pubkey, u8)> { + let mpl_id = Pubkey::from_str(METAPLEX_PROGRAM).ok()?; + find_program_address( + &mpl_id, + &[ + "metadata".as_bytes(), + mpl_id.as_bytes().as_ref(), + mint.as_bytes().as_ref(), + "token_record".as_bytes(), + token_account.as_bytes().as_ref(), + ], + ) + .ok() + } + + pub fn is_programmable(&self) -> bool { + #[allow(clippy::match_like_matches_macro)] + match self.token_standard { + Some(TokenStandard::ProgrammableNonFungible | TokenStandard::ProgrammableNonFungibleEdition) => true, + _ => false, + } + } + + pub fn rule_set(&self) -> Option { + match self.programmable_config { + Some(ProgrammableConfig::V1 { rule_set: Some(pubkey) }) => Some(pubkey), + _ => None, + } + } +} diff --git a/core/crates/gem_solana/src/metaplex/mod.rs b/core/crates/gem_solana/src/metaplex/mod.rs new file mode 100644 index 0000000000..1fad36ab84 --- /dev/null +++ b/core/crates/gem_solana/src/metaplex/mod.rs @@ -0,0 +1,79 @@ +// Taken from https://github.com/metaplex-foundation/mpl-token-metadata/blob/main/programs/token-metadata/program/src/state/metadata.rs +mod collection; +mod data; +mod uses; + +pub mod metadata; +use crate::metaplex::metadata::Metadata; +use borsh::{BorshDeserialize, BorshSerialize}; +use gem_encoding::decode_base64; + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone, Copy)] +pub enum Key { + Uninitialized, + EditionV1, + MasterEditionV1, + ReservationListV1, + MetadataV1, + ReservationListV2, + MasterEditionV2, + EditionMarker, + UseAuthorityRecord, + CollectionAuthorityRecord, + TokenOwnedEscrow, + TokenRecord, + MetadataDelegate, + EditionMarkerV2, + HolderDelegate, +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone, Copy)] +pub enum TokenStandard { + NonFungible, // This is a master edition + FungibleAsset, // A token with metadata that can also have attributes + Fungible, // A token with simple metadata + NonFungibleEdition, // This is a limited edition + ProgrammableNonFungible, // NonFungible with programmable configuration + ProgrammableNonFungibleEdition, // NonFungible with programmable configuration +} + +pub fn decode_metadata(base64_str: &str) -> Result> { + let data = decode_base64(base64_str)?; + let metadata = Metadata::deserialize(&mut data.as_slice())?; + Ok(metadata) +} + +#[cfg(test)] +mod tests { + use crate::{ + Pubkey, USDC_TOKEN_MINT, + metaplex::{Key, decode_metadata, metadata::Metadata}, + }; + use std::str::FromStr; + + #[test] + fn test_metadata_data() { + let string = "BBzjWe1aAS4E+hQrnHUaHF6Hz9CgFhuchf/TG3jN/Nj2xvp6877brTo9ZfNqq8l0MbG75MLS9uDkfKYCA0UvXWEgAAAAVVNEIENvaW4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAVVNEQwAAAAAAAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + let metadata = decode_metadata(string).unwrap(); + + assert_eq!(metadata.key, Key::MetadataV1); + assert_eq!(metadata.mint.to_string(), USDC_TOKEN_MINT); + + assert_eq!(metadata.data.uri.trim_matches(char::from(0)), ""); + assert_eq!(metadata.data.symbol.trim_matches(char::from(0)), "USDC"); + assert_eq!(metadata.data.name.trim_matches(char::from(0)), "USD Coin"); + } + + #[test] + fn test_find_pda() { + let mut mint = Pubkey::from_str(USDC_TOKEN_MINT).unwrap(); + let (pda, _) = Metadata::find_pda(mint).unwrap(); + + assert_eq!(pda.to_string(), "5x38Kp4hvdomTCnCrAny4UtMUt5rQBdB6px2K1Ui45Wq"); + + mint = Pubkey::from_str("MEW1gQWJ3nEXg2qgERiKu7FAFj79PHvQVREQUzScPP5").unwrap(); + let (pda, _) = Metadata::find_pda(mint).unwrap(); + + assert_eq!(pda.to_string(), "5G95zJ9w6ESv7AFWqLKNfbZAoKBADjpVm9MT1cQm8Dpw"); + } +} diff --git a/core/crates/gem_solana/src/metaplex/uses.rs b/core/crates/gem_solana/src/metaplex/uses.rs new file mode 100644 index 0000000000..521d9fd353 --- /dev/null +++ b/core/crates/gem_solana/src/metaplex/uses.rs @@ -0,0 +1,16 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub enum UseMethod { + Burn, + Multiple, + Single, +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub struct Uses { + // 17 bytes + Option byte + pub use_method: UseMethod, //1 + pub remaining: u64, //8 + pub total: u64, //8 +} diff --git a/core/crates/gem_solana/src/metaplex_core.rs b/core/crates/gem_solana/src/metaplex_core.rs new file mode 100644 index 0000000000..3ddfa5f30d --- /dev/null +++ b/core/crates/gem_solana/src/metaplex_core.rs @@ -0,0 +1,69 @@ +// Taken from https://github.com/metaplex-foundation/mpl-core/blob/main/programs/mpl-core/src/state/asset.rs +use crate::Pubkey; +use borsh::{BorshDeserialize, BorshSerialize}; +use gem_encoding::decode_base64; + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone, Copy)] +pub enum Key { + Uninitialized, + AssetV1, + HashedAssetV1, + PluginHeaderV1, + PluginRegistryV1, + CollectionV1, +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub enum UpdateAuthority { + None, + Address(Pubkey), + Collection(Pubkey), +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub struct AssetV1 { + pub key: Key, + pub owner: Pubkey, + pub update_authority: UpdateAuthority, + pub name: String, + pub uri: String, + pub seq: Option, +} + +impl AssetV1 { + pub fn collection(&self) -> Option { + match self.update_authority { + UpdateAuthority::Collection(pubkey) => Some(pubkey), + UpdateAuthority::Address(_) | UpdateAuthority::None => None, + } + } +} + +pub fn decode_asset(base64_data: &str) -> Result> { + let data = decode_base64(base64_data).map_err(|e| format!("decode core asset base64: {e}"))?; + let asset = AssetV1::deserialize(&mut data.as_slice()).map_err(|e| format!("decode core asset: {e}"))?; + if asset.key != Key::AssetV1 { + return Err(format!("unexpected Metaplex Core asset key: {:?}", asset.key).into()); + } + Ok(asset) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BWED_1545_ASSET: &str = "AdQCoT0whdkMOHS+JayL1er4PFXv7D/dY+IJ+r2UN6wYAkeTzcRWuwoVceHlkHr4rEWE2hX58AjYjrPZJGJKtkYICgAAAEJXRUQgIzE1NDVbAAAAaHR0cHM6Ly9iYWZ5YmVpZm91dzRjdnF6a21pNzN3ZWQzM2lsM3l5cTdnam9xbW91emFvZGI3Z2J4YmNiZm94cXd1cS5pcGZzLnczcy5saW5rLzE1NDUuanNvbgA="; + + #[test] + fn test_decode_asset() { + let asset = decode_asset(BWED_1545_ASSET).unwrap(); + assert_eq!(asset.key, Key::AssetV1); + assert_eq!(asset.owner.to_base58(), "FGbkx8rYTPJubjyScReeps6GA83D1nSmFr3BrN7buokb"); + assert_eq!(asset.collection().unwrap().to_base58(), "5pQfZttNUtaj8sySRY9RsdtB81aEAQDh2vnacpxiwTpT"); + assert_eq!(asset.name, "BWED #1545"); + assert!(asset.uri.contains("1545.json")); + assert_eq!(asset.seq, None); + + assert!(decode_asset("Y29ycnVwdA==").is_err()); + } +} diff --git a/core/crates/gem_solana/src/models/balances.rs b/core/crates/gem_solana/src/models/balances.rs new file mode 100644 index 0000000000..f3b02dad19 --- /dev/null +++ b/core/crates/gem_solana/src/models/balances.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +use super::UInt64; + +#[derive(Serialize, Deserialize)] +pub struct SolanaBalance { + pub value: UInt64, +} + +pub struct SolanaBalanceValue { + pub amount: String, +} diff --git a/core/crates/gem_solana/src/models/block.rs b/core/crates/gem_solana/src/models/block.rs new file mode 100644 index 0000000000..f29a954eb8 --- /dev/null +++ b/core/crates/gem_solana/src/models/block.rs @@ -0,0 +1,74 @@ +use crate::models::rpc::{Info, Parsed, ValueResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoteAccounts { + pub current: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoteAccount { + pub vote_pubkey: String, + pub node_pubkey: String, + pub commission: u8, + pub activated_stake: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Block { + pub blockhash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Blockhash { + pub blockhash: String, +} + +pub type LatestBlockhash = ValueResult; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EpochInfo { + pub epoch: u64, + pub slots_in_epoch: u64, + pub slot_index: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidatorConfig { + pub pubkey: String, + pub account: ValidatorConfigAccount, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidatorConfigAccount { + pub data: Parsed>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidatorConfigInfo { + pub name: String, + pub config_data: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InflationRate { + pub validator: f64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SupplyResult { + pub value: SupplyValue, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SupplyValue { + pub total: u64, +} diff --git a/core/crates/gem_solana/src/models/blockhash.rs b/core/crates/gem_solana/src/models/blockhash.rs new file mode 100644 index 0000000000..d6a809d194 --- /dev/null +++ b/core/crates/gem_solana/src/models/blockhash.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaBlockhashResult { + pub value: SolanaBlockhash, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaBlockhash { + pub blockhash: String, +} diff --git a/core/crates/gem_solana/src/models/jito.rs b/core/crates/gem_solana/src/models/jito.rs new file mode 100644 index 0000000000..811eaf1244 --- /dev/null +++ b/core/crates/gem_solana/src/models/jito.rs @@ -0,0 +1,36 @@ +#[derive(Debug, Clone, Default)] +pub struct FeeStats { + pub median: i64, + pub p75: i64, + pub p90: i64, + pub avg: i64, + pub count: usize, +} + +fn percentile(sorted_values: &[i64], p: usize) -> i64 { + if sorted_values.is_empty() { + return 0; + } + let idx = (p * sorted_values.len() / 100).min(sorted_values.len() - 1); + sorted_values[idx] +} + +pub fn calculate_fee_stats(fees: &[i64]) -> FeeStats { + if fees.is_empty() { + return FeeStats::default(); + } + + let mut values = fees.to_vec(); + values.sort(); + + let count = values.len(); + let sum: i64 = values.iter().sum(); + + FeeStats { + median: percentile(&values, 50), + p75: percentile(&values, 75), + p90: percentile(&values, 90), + avg: sum / count as i64, + count, + } +} diff --git a/core/crates/gem_solana/src/models/mod.rs b/core/crates/gem_solana/src/models/mod.rs new file mode 100644 index 0000000000..10cd644121 --- /dev/null +++ b/core/crates/gem_solana/src/models/mod.rs @@ -0,0 +1,20 @@ +pub mod balances; +pub mod block; +pub mod blockhash; +pub mod jito; +pub mod prioritization_fee; +pub mod rpc; +pub mod stake; +pub mod token; +pub mod token_account; +pub mod transaction; +pub mod value; + +type UInt64 = u64; +type Int = i64; + +// Re-export commonly used types for backward compatibility +pub use block::*; +pub use rpc::*; +pub use token::*; +pub use transaction::*; diff --git a/core/crates/gem_solana/src/models/prioritization_fee.rs b/core/crates/gem_solana/src/models/prioritization_fee.rs new file mode 100644 index 0000000000..a6c7728be7 --- /dev/null +++ b/core/crates/gem_solana/src/models/prioritization_fee.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +use super::Int; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaPrioritizationFee { + pub prioritization_fee: Int, +} diff --git a/core/crates/gem_solana/src/models/rpc.rs b/core/crates/gem_solana/src/models/rpc.rs new file mode 100644 index 0000000000..fd51de5844 --- /dev/null +++ b/core/crates/gem_solana/src/models/rpc.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; + +use crate::COMMITMENT_CONFIRMED; + +pub const ENCODING_BASE64: &str = "base64"; +pub const ENCODING_BASE58: &str = "base58"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Configuration { + pub commitment: &'static str, + pub encoding: &'static str, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub filters: Vec, +} + +impl Configuration { + pub fn new(filters: Vec) -> Self { + Self { + commitment: COMMITMENT_CONFIRMED, + encoding: ENCODING_BASE64, + filters, + } + } +} + +impl Default for Configuration { + fn default() -> Self { + Self { + commitment: COMMITMENT_CONFIRMED, + encoding: ENCODING_BASE64, + filters: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Filter { + pub memcmp: Memcmp, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Memcmp { + pub offset: u8, + pub bytes: String, + pub encoding: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValueResult { + pub value: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValueData { + pub data: T, + pub owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Parsed { + pub parsed: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Info { + pub info: T, +} + +pub type AccountData = ValueData>; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + pub ok: Option, +} diff --git a/core/crates/gem_solana/src/models/stake.rs b/core/crates/gem_solana/src/models/stake.rs new file mode 100644 index 0000000000..71c7dc1db4 --- /dev/null +++ b/core/crates/gem_solana/src/models/stake.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +use super::UInt64; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaValidators { + pub current: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaValidator { + pub vote_pubkey: String, + pub commission: i32, + pub epoch_vote_account: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaEpoch { + pub epoch: UInt64, + pub slot_index: UInt64, + pub slots_in_epoch: UInt64, +} diff --git a/core/crates/gem_solana/src/models/token.rs b/core/crates/gem_solana/src/models/token.rs new file mode 100644 index 0000000000..165abf1aef --- /dev/null +++ b/core/crates/gem_solana/src/models/token.rs @@ -0,0 +1,127 @@ +use num_bigint::BigUint; +use primitives::AssetId; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_biguint_from_str, deserialize_u64_from_str}; + +use crate::models::rpc::{Info, Parsed, ValueData, ValueResult}; +pub use num_bigint::BigInt; + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TokenBalance { + pub account_index: i64, + pub mint: String, + pub owner: String, + pub ui_token_amount: TokenAmount, +} + +#[derive(Debug, Clone)] +pub struct TokenBalanceChange { + pub asset_id: AssetId, + pub amount: BigInt, +} + +impl TokenBalance { + pub fn new(account_index: i64, mint: String, owner: String, ui_token_amount: TokenAmount) -> Self { + Self { + account_index, + mint, + owner, + ui_token_amount, + } + } + + pub fn get_amount(&self) -> BigUint { + self.ui_token_amount.amount.clone() + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TokenAccountInfo { + pub pubkey: String, + pub account: TokenAccountData, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TokenAccountData { + pub data: Parsed>, + pub owner: String, + pub lamports: u64, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TokenAccountInfoData { + pub mint: Option, + pub token_amount: Option, + pub stake: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StakeInfo { + pub delegation: StakeDelegation, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StakeDelegation { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub activation_epoch: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub deactivation_epoch: u64, + pub stake: String, + pub voter: String, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TokenAmount { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub amount: BigUint, +} + +impl Default for TokenAmount { + fn default() -> Self { + Self { amount: BigUint::from(0u64) } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenInfo { + pub decimals: i32, + pub supply: String, + pub extensions: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenMetadata { + pub name: String, + pub symbol: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionBase { + #[serde(rename = "extension")] + pub extension_type: String, + pub state: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Extension { + TokenMetadata(ExtensionBase), + Other(ExtensionBase), +} + +pub type ResultTokenInfo = ValueResult>>>; + +impl ResultTokenInfo { + pub fn info(&self) -> TokenInfo { + self.value.data.parsed.info.clone() + } +} diff --git a/core/crates/gem_solana/src/models/token_account.rs b/core/crates/gem_solana/src/models/token_account.rs new file mode 100644 index 0000000000..4376799d09 --- /dev/null +++ b/core/crates/gem_solana/src/models/token_account.rs @@ -0,0 +1,79 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_biguint_from_str, deserialize_u64_from_str}; + +use super::UInt64; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaTokenAccountPubkey { + pub pubkey: String, +} + +// accounts +pub struct SolanaStakeAccount { + pub account: SolanaAccount>>, + pub pubkey: String, +} + +pub struct SolanaTokenAccount { + pub account: SolanaAccount>>, + pub pubkey: String, +} + +// parsed data + +pub struct SolanaAccount { + pub lamports: UInt64, + pub space: UInt64, + pub owner: String, + pub data: T, +} + +pub struct SolanaAccountParsed { + pub parsed: T, +} + +pub struct SolanaAccountParsedInfo { + pub info: T, +} + +// parsed data: stake +pub struct SolanaStakeInfo { + pub stake: SolanaStake, + pub meta: SolanaRentExemptReserve, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaRentExemptReserve { + pub rent_exempt_reserve: String, +} + +pub struct SolanaStake { + pub delegation: SolanaStakeDelegation, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaStakeDelegation { + pub voter: String, + pub stake: String, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub activation_epoch: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub deactivation_epoch: u64, +} + +// parsed data: token +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaTokenInfo { + pub token_amount: SolanaTokenAmount, +} + +#[derive(Serialize, Deserialize)] +pub struct SolanaTokenAmount { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub amount: BigUint, +} diff --git a/core/crates/gem_solana/src/models/transaction.rs b/core/crates/gem_solana/src/models/transaction.rs new file mode 100644 index 0000000000..d412b5fd47 --- /dev/null +++ b/core/crates/gem_solana/src/models/transaction.rs @@ -0,0 +1,259 @@ +use num_bigint::BigUint; +use primitives::{AssetId, Chain}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +use super::UInt64; +use crate::models::token::{BigInt, TokenBalance, TokenBalanceChange}; + +#[derive(Deserialize)] +pub struct SolanaTransaction { + pub meta: SolanaTransactionMeta, + pub slot: UInt64, +} + +#[derive(Deserialize)] +pub struct SolanaTransactionMeta { + err: Option, +} + +impl SolanaTransactionMeta { + pub fn has_error(&self) -> bool { + self.err.is_some() + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Meta { + pub err: Option, + pub fee: u64, + pub pre_balances: Vec, + pub post_balances: Vec, + pub pre_token_balances: Vec, + pub post_token_balances: Vec, +} + +impl Meta { + pub fn has_error(&self) -> bool { + self.err.is_some() + } + + pub fn get_pre_token_balance(&self, account_index: i64) -> Option { + self.pre_token_balances.iter().find(|b| b.account_index == account_index).cloned() + } + + pub fn get_post_token_balance(&self, account_index: i64) -> Option { + self.post_token_balances.iter().find(|b| b.account_index == account_index).cloned() + } + + pub fn get_pre_token_balance_by_owner(&self, owner: &str) -> Vec { + self.pre_token_balances.iter().filter(|b| b.owner == owner).cloned().collect() + } + + pub fn get_post_token_balance_by_owner(&self, owner: &str) -> Vec { + self.post_token_balances.iter().filter(|b| b.owner == owner).cloned().collect() + } + + pub fn get_token_balance_changes_by_owner(&self, owner: &str) -> Vec { + let pre_balances: HashMap<_, _> = self + .pre_token_balances + .iter() + .filter(|b| b.owner == owner) + .map(|b| (b.mint.clone(), b.get_amount())) + .collect(); + + let post_balances: HashMap<_, _> = self + .post_token_balances + .iter() + .filter(|b| b.owner == owner) + .map(|b| (b.mint.clone(), b.get_amount())) + .collect(); + let all_mints: HashSet<_> = pre_balances.keys().chain(post_balances.keys()).cloned().collect(); + + all_mints + .into_iter() + .filter_map(|mint| { + let asset_id = AssetId::from_token(Chain::Solana, &mint); + let pre_amount = pre_balances.get(&mint).cloned().unwrap_or_else(|| BigUint::from(0u64)); + let post_amount = post_balances.get(&mint).cloned().unwrap_or_else(|| BigUint::from(0u64)); + + if post_amount > pre_amount { + let diff = &post_amount - &pre_amount; + Some(TokenBalanceChange { + asset_id, + amount: BigInt::from_biguint(num_bigint::Sign::Plus, diff), + }) + } else if pre_amount > post_amount { + let diff = &pre_amount - &post_amount; + Some(TokenBalanceChange { + asset_id, + amount: BigInt::from_biguint(num_bigint::Sign::Minus, diff), + }) + } else { + None + } + }) + .collect() + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AccountKey { + pub pubkey: String, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + pub message: TransactionMessage, + pub signatures: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Signature { + pub block_time: i64, + pub signature: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Instruction { + pub program_id_index: usize, + #[serde(default)] + pub accounts: Vec, + #[serde(default)] + pub data: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TransactionMessage { + pub account_keys: Vec, + #[serde(default)] + pub instructions: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BlockTransaction { + pub meta: Meta, + pub transaction: Transaction, +} + +impl BlockTransaction { + pub fn fee(&self) -> BigUint { + BigUint::from(self.meta.fee) + } + + pub fn get_balance_change(&self, address: &str) -> u64 { + let index = self.transaction.message.account_keys.iter().position(|k| k == address); + match index { + Some(i) => { + let pre = self.meta.pre_balances.get(i).copied().unwrap_or(0); + let post = self.meta.post_balances.get(i).copied().unwrap_or(0); + pre.saturating_sub(post).saturating_sub(self.meta.fee) + } + None => 0, + } + } + + pub fn get_balance_changes_by_owner(&self, owner: &str) -> TokenBalanceChange { + // Find all account indices that belong to the owner + let account_indices: Vec = self + .transaction + .message + .account_keys + .iter() + .enumerate() + .filter_map(|(i, k)| if k == owner { Some(i) } else { None }) + .collect(); + + let (total_pre, total_post) = account_indices.into_iter().fold((0u64, 0u64), |(pre_acc, post_acc), idx| { + let pre = *self.meta.pre_balances.get(idx).unwrap_or(&0); + let post = *self.meta.post_balances.get(idx).unwrap_or(&0); + (pre_acc.wrapping_add(pre), post_acc.wrapping_add(post)) + }); + + let (sign, diff) = if total_post > total_pre { + let diff = total_post - total_pre; + (num_bigint::Sign::Plus, BigUint::from(diff)) + } else { + let diff = total_pre - total_post; + (num_bigint::Sign::Minus, BigUint::from(diff)) + }; + let fee = self.fee(); + let data = if fee > diff { BigUint::from(0u64) } else { diff - fee }; + + TokenBalanceChange { + asset_id: Chain::Solana.as_asset_id(), + amount: BigInt::from_biguint(sign, data), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockTransactions { + pub block_time: i64, + pub transactions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SingleTransaction { + pub block_time: i64, + pub meta: Meta, + pub transaction: Transaction, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block_transaction(fee: u64, keys: Vec<&str>, pre: Vec, post: Vec) -> BlockTransaction { + BlockTransaction { + meta: Meta { + err: None, + fee, + pre_balances: pre, + post_balances: post, + pre_token_balances: vec![], + post_token_balances: vec![], + }, + transaction: Transaction { + message: TransactionMessage { + account_keys: keys.into_iter().map(String::from).collect(), + instructions: vec![], + }, + signatures: vec![], + }, + } + } + + #[test] + fn test_balance_change() { + let tx = block_transaction(5000, vec!["sender", "recipient"], vec![100_000, 0], vec![85_000, 10_000]); + assert_eq!(tx.get_balance_change("sender"), 10_000); + } + + #[test] + fn test_balance_change_no_change() { + let tx = block_transaction(5000, vec!["sender"], vec![100_000], vec![95_000]); + assert_eq!(tx.get_balance_change("sender"), 0); + } + + #[test] + fn test_balance_change_received() { + let tx = block_transaction(5000, vec!["sender"], vec![100_000], vec![200_000]); + assert_eq!(tx.get_balance_change("sender"), 0); + } + + #[test] + fn test_balance_change_unknown_address() { + let tx = block_transaction(5000, vec!["sender"], vec![100_000], vec![85_000]); + assert_eq!(tx.get_balance_change("unknown"), 0); + } +} diff --git a/core/crates/gem_solana/src/models/value.rs b/core/crates/gem_solana/src/models/value.rs new file mode 100644 index 0000000000..89fc38e039 --- /dev/null +++ b/core/crates/gem_solana/src/models/value.rs @@ -0,0 +1,3 @@ +pub struct SolanaValue { + pub value: T, +} diff --git a/core/crates/gem_solana/src/provider/balances.rs b/core/crates/gem_solana/src/provider/balances.rs new file mode 100644 index 0000000000..af5379381b --- /dev/null +++ b/core/crates/gem_solana/src/provider/balances.rs @@ -0,0 +1,119 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use std::error::Error; + +use crate::provider::balances_mapper::{map_balance_staking, map_coin_balance, map_token_accounts}; +use crate::rpc::client::SolanaClient; +use gem_client::Client; +use primitives::AssetBalance; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainBalances for SolanaClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let balance = self.get_balance(&address).await?; + Ok(map_coin_balance(&balance)) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let results = self.get_token_accounts(&address, &token_ids).await?; + let balances: Vec = results + .iter() + .zip(&token_ids) + .flat_map(|(token_accounts, token_id)| map_token_accounts(token_accounts, token_id)) + .collect(); + + Ok(balances) + } + + async fn get_balance_staking(&self, address: String) -> Result, Box> { + let accounts = self.get_staking_balance(&address).await?; + Ok(map_balance_staking(accounts)) + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + let token_accounts_result = self.get_token_accounts_by_owner(&address, crate::TOKEN_PROGRAM).await?; + let balances: Vec = token_accounts_result + .value + .into_iter() + .filter_map(|account| { + let token_info = &account.account.data.parsed.info; + if let (Some(token_amount), Some(mint)) = (&token_info.token_amount, &token_info.mint) + && token_amount.amount > num_bigint::BigUint::from(0u32) + { + let asset_id = primitives::AssetId { + chain: primitives::Chain::Solana, + token_id: Some(mint.clone()), + }; + return Some(primitives::AssetBalance::new(asset_id, token_amount.amount.clone())); + } + None + }) + .collect(); + + Ok(balances) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::{PYUSD_TOKEN_MINT, USDC_TOKEN_MINT, USDT_TOKEN_MINT, provider::testkit::create_solana_test_client}; + use primitives::{Chain, testkit::signer_mock::TEST_SOLANA_SENDER}; + + #[tokio::test] + async fn test_solana_get_balance_coin() -> Result<(), Box> { + let client = create_solana_test_client(); + let balance = client.get_balance_coin(TEST_SOLANA_SENDER.to_string()).await?; + + assert_eq!(balance.asset_id.chain, Chain::Solana); + assert_eq!(balance.asset_id.token_id, None); + assert!(balance.balance.available >= num_bigint::BigUint::from(0u32)); + + Ok(()) + } + + #[tokio::test] + async fn test_solana_get_balance_tokens() -> Result<(), Box> { + let client = create_solana_test_client(); + let token_ids = vec![USDC_TOKEN_MINT.to_string(), USDT_TOKEN_MINT.to_string(), PYUSD_TOKEN_MINT.to_string()]; + + let balances = client.get_balance_tokens(TEST_SOLANA_SENDER.to_string(), token_ids.clone()).await?; + + assert_eq!(balances.len(), token_ids.len()); + for (i, balance) in balances.iter().enumerate() { + assert_eq!(balance.asset_id.chain, Chain::Solana); + assert_eq!(balance.asset_id.token_id, Some(token_ids[i].clone())); + assert!(balance.balance.available >= num_bigint::BigUint::from(0u32)); + } + + Ok(()) + } + + #[tokio::test] + async fn test_solana_get_balance_staking() -> Result<(), Box> { + let client = create_solana_test_client(); + let staking_balance = client.get_balance_staking(TEST_SOLANA_SENDER.to_string()).await?; + + if let Some(balance) = staking_balance { + assert_eq!(balance.asset_id.chain, Chain::Solana); + assert_eq!(balance.asset_id.token_id, None); + assert!(balance.balance.staked > num_bigint::BigUint::from(0u32)); + } + + Ok(()) + } + + #[tokio::test] + async fn test_solana_get_balance_assets() -> Result<(), Box> { + let client = create_solana_test_client(); + let address = TEST_SOLANA_SENDER.to_string(); + let assets = client.get_balance_assets(address).await?; + + for asset in assets { + assert_eq!(asset.asset_id.chain, Chain::Solana); + assert!(asset.balance.available > num_bigint::BigUint::from(0u32)); + } + Ok(()) + } +} diff --git a/core/crates/gem_solana/src/provider/balances_mapper.rs b/core/crates/gem_solana/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..58afcd96f1 --- /dev/null +++ b/core/crates/gem_solana/src/provider/balances_mapper.rs @@ -0,0 +1,112 @@ +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Chain}; + +use crate::models::balances::SolanaBalance; +use crate::models::{TokenAccountInfo, ValueResult}; + +pub fn map_coin_balance(balance: &SolanaBalance) -> AssetBalance { + let asset_id = AssetId::from_chain(Chain::Solana); + AssetBalance::new(asset_id, BigUint::from(balance.value)) +} + +pub fn map_token_balances(accounts: &ValueResult>, token_ids: &[String]) -> Vec { + accounts + .value + .iter() + .zip(token_ids.iter()) + .map(|(account, token_id)| { + let balance_amount = account + .account + .data + .parsed + .info + .token_amount + .as_ref() + .map(|ta| ta.amount.clone()) + .unwrap_or_else(|| BigUint::from(0u32)); + AssetBalance::new(AssetId::from_token(Chain::Solana, token_id), balance_amount) + }) + .collect() +} + +pub fn map_single_token_balance(account: &TokenAccountInfo, token_id: &str) -> AssetBalance { + let balance_amount = account + .account + .data + .parsed + .info + .token_amount + .as_ref() + .map(|ta| ta.amount.clone()) + .unwrap_or_else(|| BigUint::from(0u32)); + AssetBalance::new(AssetId::from_token(Chain::Solana, token_id), balance_amount) +} + +pub fn map_token_accounts(accounts: &ValueResult>, token_id: &str) -> Vec { + if let Some(account) = accounts.value.first() { + vec![map_single_token_balance(account, token_id)] + } else { + vec![AssetBalance::new(AssetId::from_token(Chain::Solana, token_id), BigUint::from(0u32))] + } +} + +pub fn map_balance_staking(stake_accounts: Vec) -> Option { + let total_staked: u64 = stake_accounts.iter().map(|x| x.account.lamports).sum(); + + Some(AssetBalance::new_staking( + AssetId::from_chain(Chain::Solana), + BigUint::from(total_staked), + BigUint::from(0u32), + BigUint::from(0u32), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::JsonRpcResult; + + #[test] + fn test_map_coin_balance() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/balance_coin.json")).unwrap(); + + let balance_result = map_coin_balance(&result.result); + + assert_eq!(balance_result.asset_id.chain, Chain::Solana); + assert_eq!(balance_result.balance.available, BigUint::from(1366309311_u64)); + } + + #[test] + fn test_map_single_token_balance() { + let result: JsonRpcResult>> = serde_json::from_str(include_str!("../../testdata/balance_spl_token.json")).unwrap(); + + let token_account = &result.result.value[0]; + let token_id = "2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv"; + let balance_result = map_single_token_balance(token_account, token_id); + + assert_eq!(balance_result.asset_id.chain, Chain::Solana); + assert_eq!(balance_result.balance.available, BigUint::from(75071408_u64)); + } + + #[test] + fn test_map_token_balances() { + let result: JsonRpcResult>> = serde_json::from_str(include_str!("../../testdata/balance_spl_token.json")).unwrap(); + + let token_ids = vec!["2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv".to_string()]; + let balances = map_token_balances(&result.result, &token_ids); + + assert_eq!(balances.len(), 1); + assert_eq!(balances[0].asset_id.chain, Chain::Solana); + assert_eq!(balances[0].balance.available, BigUint::from(75071408_u64)); + } + + #[test] + fn test_map_staking_balance() { + let result: JsonRpcResult> = serde_json::from_str(include_str!("../../testdata/balance_staking.json")).unwrap(); + let staking_balance = map_balance_staking(result.result).unwrap(); + + assert_eq!(staking_balance.asset_id.chain, Chain::Solana); + assert_eq!(staking_balance.balance.available, BigUint::from(0u32)); + assert_eq!(staking_balance.balance.staked, BigUint::from(363542610_u64)); + } +} diff --git a/core/crates/gem_solana/src/provider/mod.rs b/core/crates/gem_solana/src/provider/mod.rs new file mode 100644 index 0000000000..e0d5134745 --- /dev/null +++ b/core/crates/gem_solana/src/provider/mod.rs @@ -0,0 +1,33 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod testkit; + +#[cfg(feature = "rpc")] +pub mod preload_mapper; +#[cfg(feature = "rpc")] +pub mod request_classifier; +#[cfg(feature = "rpc")] +pub mod staking; +#[cfg(feature = "rpc")] +pub mod staking_mapper; +#[cfg(feature = "rpc")] +pub mod state; +#[cfg(feature = "rpc")] +pub mod state_mapper; +#[cfg(feature = "rpc")] +pub mod token; +#[cfg(feature = "rpc")] +pub mod token_mapper; +#[cfg(feature = "rpc")] +pub mod transaction_broadcast; +#[cfg(feature = "rpc")] +pub mod transaction_broadcast_mapper; +#[cfg(feature = "rpc")] +pub mod transaction_mapper; +#[cfg(feature = "rpc")] +pub mod transaction_state; +#[cfg(feature = "rpc")] +pub mod transactions; + +pub struct BroadcastProvider; diff --git a/core/crates/gem_solana/src/provider/preload.rs b/core/crates/gem_solana/src/provider/preload.rs new file mode 100644 index 0000000000..b2fdbfe227 --- /dev/null +++ b/core/crates/gem_solana/src/provider/preload.rs @@ -0,0 +1,222 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use std::error::Error; + +use crate::provider::preload_mapper::{calculate_fee_rates, calculate_transaction_fee}; +use gem_client::Client; +use primitives::{ + Chain, FeeRate, SolanaNftStandard, SolanaTokenProgramId, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput, +}; + +use crate::{METAPLEX_CORE_PROGRAM, get_token_program_id_by_address, metaplex_core, rpc::client::SolanaClient}; + +struct SolanaNftPreload { + token_program: Option, + standard: SolanaNftStandard, +} + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainTransactionLoad for SolanaClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let TransactionPreloadInput { + input_type, + sender_address, + destination_address, + } = input; + + let (sender_lookup, recipient_lookup) = match input_type { + TransactionInputType::Swap(_, _, _) => (sender_address.as_str(), sender_address.as_str()), + _ => (sender_address.as_str(), destination_address.as_str()), + }; + + let (sender_mint, recipient_mint, token_program, nft) = match &input_type { + TransactionInputType::TransferNft(_, nft_asset) => { + let SolanaNftPreload { token_program, standard } = self.detect_solana_nft(&nft_asset.token_id).await?; + let mint = token_program.is_some().then_some(nft_asset.token_id.as_str()); + (mint, mint, token_program, Some(standard)) + } + _ => { + let source = input_type.get_asset(); + let recipient = input_type.get_recipient_asset(); + let sender_mint = source.id.token_id.as_deref(); + let recipient_mint = match recipient.chain() { + Chain::Solana => recipient.id.token_id.as_deref(), + _ => None, + }; + (sender_mint, recipient_mint, SolanaTokenProgramId::from_asset_type(&source.asset_type), None) + } + }; + + let sender_token_future = async { + match sender_mint { + Some(mint) => self.find_token_account(sender_lookup, mint).await, + None => Ok(None), + } + }; + let recipient_token_future = async { + match recipient_mint { + Some(mint) => self.find_token_account(recipient_lookup, mint).await, + None => Ok(None), + } + }; + + let (block_hash, sender_token_address, recipient_token_address) = futures::try_join!(self.get_latest_blockhash(), sender_token_future, recipient_token_future)?; + + if let TransactionInputType::TransferNft(_, _) = input_type + && sender_mint.is_some() + && sender_token_address.is_none() + { + return Err("sender does not own Solana NFT token account".into()); + } + + Ok(TransactionLoadMetadata::Solana { + sender_token_address, + recipient_token_address, + token_program, + nft, + block_hash: block_hash.value.blockhash, + }) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let fee = calculate_transaction_fee(&input.input_type, &input.gas_price, input.metadata.get_recipient_token_address()?); + Ok(TransactionLoadData { fee, metadata: input.metadata }) + } + + async fn get_transaction_fee_rates(&self, input_type: TransactionInputType) -> Result, Box> { + let prioritization_fees = self.get_recent_prioritization_fees().await?; + Ok(calculate_fee_rates(&input_type, &prioritization_fees)) + } +} + +#[cfg(feature = "rpc")] +impl SolanaClient { + async fn detect_solana_nft(&self, mint: &str) -> Result> { + let account = self.get_account_info_base64(mint).await?.value.ok_or("Solana NFT account not found")?; + if account.owner == METAPLEX_CORE_PROGRAM { + let data = account.data.first().ok_or("missing Metaplex Core asset data")?; + let collection = metaplex_core::decode_asset(data)?.collection().map(|pubkey| pubkey.to_base58()); + return Ok(SolanaNftPreload { + token_program: None, + standard: SolanaNftStandard::Core { collection }, + }); + } + let token_program = get_token_program_id_by_address(&account.owner).ok_or_else(|| format!("unsupported Solana NFT owner program: {}", account.owner))?; + let metadata = self.get_metaplex_metadata(mint).await.ok(); + let standard = match metadata.filter(|m| m.is_programmable()) { + Some(metadata) => SolanaNftStandard::ProgrammableNonFungible { + rule_set: metadata.rule_set().map(|pubkey| pubkey.to_base58()), + }, + None => SolanaNftStandard::NonFungible, + }; + Ok(SolanaNftPreload { + token_program: Some(token_program), + standard, + }) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_EMPTY_ADDRESS, create_solana_test_client}; + use primitives::swap::SwapData; + use primitives::testkit::signer_mock::TEST_SOLANA_SENDER; + use primitives::{Asset, SwapProvider}; + + #[tokio::test] + async fn test_solana_get_transaction_fee_rates() -> Result<(), Box> { + let client = create_solana_test_client(); + let rates = client.get_transaction_fee_rates(TransactionInputType::Transfer(Asset::mock_sol())).await?; + assert!(rates.len() == 3); + Ok(()) + } + + #[tokio::test] + async fn test_get_solana_transaction_preload_transfer_sol() -> Result<(), Box> { + let client = create_solana_test_client(); + let input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::mock_sol()), + sender_address: TEST_SOLANA_SENDER.to_string(), + destination_address: TEST_SOLANA_SENDER.to_string(), + }; + let result = client.get_transaction_preload(input).await?; + + println!("Tranasction load metadata: {:?}", result); + + assert!(result.get_block_hash()?.len() == 44); + assert!(result.get_sender_token_address()?.is_none()); + assert!(result.get_recipient_token_address()?.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_solana_transaction_preload_transfer_spl_token() -> Result<(), Box> { + let client = create_solana_test_client(); + let input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::mock_spl_token()), + sender_address: TEST_SOLANA_SENDER.to_string(), + destination_address: "4BgapREafMMprtU6CehRmH8LUY26PRFmGf7K4S44oSMW".to_string(), + }; + + let result = client.get_transaction_preload(input).await?; + + println!("Tranasction load metadata: {:?}", result); + + assert!(result.get_block_hash()?.len() == 44); + assert!(result.get_sender_token_address()? == Some("HEeranxp3y7kVQKVSLdZW1rUmnbs7bAtUTMu8o88Jash".to_string())); + assert!(result.get_recipient_token_address()?.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_solana_transaction_preload_swap_spl_to_erc20() -> Result<(), Box> { + let client = create_solana_test_client(); + let swap_data = SwapData::mock_with_provider(SwapProvider::Jupiter); + let input = TransactionPreloadInput { + input_type: TransactionInputType::Swap(Asset::mock_spl_token().clone(), Asset::mock_ethereum_usdc().clone(), swap_data), + sender_address: TEST_SOLANA_SENDER.to_string(), + destination_address: TEST_SOLANA_SENDER.to_string(), + }; + + let result = client.get_transaction_preload(input).await?; + + assert!(result.get_block_hash()?.len() == 44); + assert!(result.get_recipient_token_address()?.is_none()); + assert_eq!(result.get_recipient_token_address()?, None); + assert_eq!(result.get_sender_token_address()?, Some("HEeranxp3y7kVQKVSLdZW1rUmnbs7bAtUTMu8o88Jash".to_string())); + + if let TransactionLoadMetadata::Solana { token_program, .. } = &result { + assert_eq!(token_program.as_ref(), Some(&SolanaTokenProgramId::Token)); + } else { + panic!("expected solana metadata"); + } + + Ok(()) + } + + #[tokio::test] + async fn test_get_solana_transaction_preload_swap_spl_to_spl_with_empty_destination() -> Result<(), Box> { + let client = create_solana_test_client(); + let swap_data = SwapData::mock_with_provider(SwapProvider::Jupiter); + let input = TransactionPreloadInput { + input_type: TransactionInputType::Swap(Asset::mock_spl_token(), Asset::mock_spl_token(), swap_data), + sender_address: TEST_SOLANA_SENDER.to_string(), + destination_address: TEST_EMPTY_ADDRESS.to_string(), + }; + + let result = client.get_transaction_preload(input).await?; + + assert!(result.get_block_hash()?.len() == 44); + let sender_token_address = result.get_sender_token_address()?; + let recipient_token_address = result.get_recipient_token_address()?; + + assert_eq!(sender_token_address, Some("HEeranxp3y7kVQKVSLdZW1rUmnbs7bAtUTMu8o88Jash".to_string())); + assert_eq!(recipient_token_address, sender_token_address); + + Ok(()) + } +} diff --git a/core/crates/gem_solana/src/provider/preload_mapper.rs b/core/crates/gem_solana/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..c3a318f217 --- /dev/null +++ b/core/crates/gem_solana/src/provider/preload_mapper.rs @@ -0,0 +1,378 @@ +use num_bigint::BigInt; +use primitives::{AssetSubtype, Chain, FeeOption, FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType}; +use std::collections::HashMap; + +use crate::{DEFAULT_SWAP_GAS_LIMIT, constants::STATIC_BASE_FEE, models::prioritization_fee::SolanaPrioritizationFee}; + +pub fn calculate_transaction_fee(input_type: &TransactionInputType, gas_price_type: &GasPriceType, recipient_token_address: Option) -> TransactionFee { + let mut options = HashMap::new(); + let recipient_asset = input_type.get_recipient_asset(); + if recipient_asset.chain() == Chain::Solana && recipient_asset.id.token_subtype() == AssetSubtype::TOKEN && recipient_token_address.is_none() { + options.insert( + FeeOption::TokenAccountCreation, + BigInt::from(input_type.get_asset().id.chain.token_activation_fee().unwrap_or(0)), + ); + } + TransactionFee::new_gas_price_type(gas_price_type.clone(), gas_price_type.total_fee(), get_gas_limit(input_type), options) +} + +pub fn calculate_priority_fee(input_type: &TransactionInputType, prioritization_fees: &[SolanaPrioritizationFee]) -> BigInt { + let mut fees: Vec = prioritization_fees.iter().map(|f| f.prioritization_fee).collect(); + fees.sort_by(|a, b| b.cmp(a)); + fees.truncate(5); + + let multiple_of = get_multiple_of(input_type); + + if fees.is_empty() { + BigInt::from(multiple_of) + } else { + let average = fees.iter().sum::() / fees.len() as i64; + let rounded = round_to_nearest(average, multiple_of, true); + BigInt::from(std::cmp::max(rounded, multiple_of)) + } +} + +fn get_gas_limit(input_type: &TransactionInputType) -> BigInt { + match input_type { + TransactionInputType::Transfer(_) + | TransactionInputType::Deposit(_) + | TransactionInputType::TransferNft(_, _) + | TransactionInputType::Account(_, _) + | TransactionInputType::TokenApprove(_, _) + | TransactionInputType::Generic(_, _, _) + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Earn(_, _, _) => BigInt::from(100_000), + TransactionInputType::Swap(_, _, swap_data) => swap_data + .data + .gas_limit + .as_ref() + .and_then(|x| x.parse::().ok()) + .map(BigInt::from) + .unwrap_or(BigInt::from(DEFAULT_SWAP_GAS_LIMIT)), + TransactionInputType::Stake(_, _) => BigInt::from(100_000), + } +} + +fn get_multiple_of(input_type: &TransactionInputType) -> i64 { + match input_type { + TransactionInputType::Transfer(asset) + | TransactionInputType::Deposit(asset) + | TransactionInputType::TransferNft(asset, _) + | TransactionInputType::Account(asset, _) + | TransactionInputType::TokenApprove(asset, _) + | TransactionInputType::Generic(asset, _, _) + | TransactionInputType::Perpetual(asset, _) + | TransactionInputType::Earn(asset, _, _) => match &asset.id.token_subtype() { + AssetSubtype::NATIVE => 25_000, + AssetSubtype::TOKEN => 50_000, + }, + TransactionInputType::Stake(_, _) => 25_000, + TransactionInputType::Swap(_, _, _) => 100_000, + } +} + +fn round_to_nearest(value: i64, multiple: i64, round_up: bool) -> i64 { + if round_up { + ((value + multiple - 1) / multiple) * multiple + } else { + (value / multiple) * multiple + } +} + +pub fn calculate_fee_rates(input_type: &TransactionInputType, prioritization_fees: &[SolanaPrioritizationFee]) -> Vec { + let mut fees: Vec = prioritization_fees.iter().map(|f| f.prioritization_fee).collect(); + fees.sort_by(|a, b| b.cmp(a)); + fees.truncate(5); + + let multiple_of = get_multiple_of(input_type); + let static_base_fee = BigInt::from(STATIC_BASE_FEE); + + let total_priority_base = if fees.is_empty() { + BigInt::from(multiple_of) + } else { + let average = fees.iter().sum::() / fees.len() as i64; + let rounded = round_to_nearest(average, multiple_of, true); + BigInt::from(std::cmp::max(rounded, multiple_of)) + }; + + let gas_limit = get_gas_limit(input_type); + + [FeePriority::Slow, FeePriority::Normal, FeePriority::Fast] + .iter() + .map(|priority| { + let total_priority = match priority { + FeePriority::Slow => &total_priority_base / 2, + FeePriority::Normal => total_priority_base.clone(), + FeePriority::Fast => &total_priority_base * 3, + }; + + let priority_fee = (total_priority.clone() * gas_limit.clone()) / BigInt::from(1_000_000); + let unit_price = total_priority; + + FeeRate::new(*priority, GasPriceType::solana(static_base_fee.clone(), priority_fee, unit_price)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::USDC_TOKEN_MINT; + use primitives::swap::SwapData; + use primitives::{Asset, AssetId, AssetType, Chain, SwapProvider, asset_constants::SOLANA_USDC_ASSET_ID}; + + fn mock_swap_data_with_gas_limit(provider: SwapProvider, gas_limit: Option<&str>) -> SwapData { + let mut data = SwapData::mock_with_provider(provider); + data.data.gas_limit = gas_limit.map(|s| s.to_string()); + data + } + + #[test] + fn test_calculate_transaction_fee() { + let gas_price_type = GasPriceType::eip1559(BigInt::from(5000u64), BigInt::from(15000u64)); + let input_type = TransactionInputType::Transfer(Asset { + id: AssetId::from_chain(Chain::Solana), + chain: Chain::Solana, + token_id: None, + name: "SOL".to_string(), + symbol: "SOL".to_string(), + decimals: 9, + asset_type: AssetType::NATIVE, + }); + + let fee = calculate_transaction_fee(&input_type, &gas_price_type, None); + + assert_eq!(fee.fee, BigInt::from(20_000u64)); + assert_eq!(fee.gas_price_type.gas_price(), BigInt::from(5000u64)); + assert_eq!(fee.gas_price_type.priority_fee(), BigInt::from(15000u64)); + assert_eq!(fee.gas_limit, BigInt::from(100_000u64)); + assert!(fee.options.is_empty()); + } + + #[test] + fn test_calculate_transaction_fee_swap() { + let gas_price_type = GasPriceType::solana(5000u64, 30000u64, 100u64); + let input_type = TransactionInputType::Swap(Asset::mock_sol(), Asset::mock_spl_token(), mock_swap_data_with_gas_limit(SwapProvider::Jupiter, None)); + + let fee = calculate_transaction_fee(&input_type, &gas_price_type, Some("recipient_token_address".to_string())); + + assert_eq!(fee.fee, BigInt::from(35_000u64)); + assert_eq!(fee.gas_limit, BigInt::from(DEFAULT_SWAP_GAS_LIMIT)); + } + + #[test] + fn test_calculate_transaction_fee_swap_with_provider_gas_limit() { + let gas_price_type = GasPriceType::solana(5000u64, 30000u64, 100u64); + let input_type = TransactionInputType::Swap(Asset::mock_sol(), Asset::mock_spl_token(), mock_swap_data_with_gas_limit(SwapProvider::Okx, Some("550000"))); + + let fee = calculate_transaction_fee(&input_type, &gas_price_type, Some("recipient_token_address".to_string())); + + assert_eq!(fee.gas_limit, BigInt::from(550_000u64)); + } + + #[test] + fn test_calculate_transaction_fee_cross_chain_swap_without_token_creation() { + let gas_price_type = GasPriceType::eip1559(BigInt::from(5000u64), BigInt::from(15000u64)); + let input_type = TransactionInputType::Swap(Asset::mock_spl_token(), Asset::mock_ethereum_usdc(), SwapData::mock_with_provider(SwapProvider::Jupiter)); + + let fee = calculate_transaction_fee(&input_type, &gas_price_type, None); + + assert!(!fee.options.contains_key(&FeeOption::TokenAccountCreation)); + } + + #[test] + fn test_calculate_priority_fee() { + let fees = vec![SolanaPrioritizationFee { prioritization_fee: 150_000 }]; + let input_type = TransactionInputType::Transfer(Asset { + id: AssetId::from_chain(Chain::Solana), + chain: Chain::Solana, + token_id: None, + name: "SOL".to_string(), + symbol: "SOL".to_string(), + decimals: 9, + asset_type: AssetType::NATIVE, + }); + + let priority_fee = calculate_priority_fee(&input_type, &fees); + assert_eq!(priority_fee, BigInt::from(150_000)); + } + + #[test] + fn test_calculate_fee_rates() { + let fees = vec![SolanaPrioritizationFee { prioritization_fee: 25_000 }]; + let input_type = TransactionInputType::Transfer(Asset { + id: AssetId::from_chain(Chain::Solana), + chain: Chain::Solana, + token_id: None, + name: "SOL".to_string(), + symbol: "SOL".to_string(), + decimals: 9, + asset_type: AssetType::NATIVE, + }); + + let rates = calculate_fee_rates(&input_type, &fees); + + assert_eq!(rates.len(), 3); + + for rate in &rates { + assert_eq!(rate.gas_price_type.gas_price(), BigInt::from(5000u64)); + } + + assert_eq!(rates[0].priority, FeePriority::Slow); + assert_eq!(rates[0].gas_price_type.priority_fee(), BigInt::from(1_250)); + assert_eq!(rates[0].gas_price_type.unit_price(), BigInt::from(12_500)); + + assert_eq!(rates[1].priority, FeePriority::Normal); + assert_eq!(rates[1].gas_price_type.priority_fee(), BigInt::from(2_500)); + assert_eq!(rates[1].gas_price_type.unit_price(), BigInt::from(25_000)); + + assert_eq!(rates[2].priority, FeePriority::Fast); + assert_eq!(rates[2].gas_price_type.priority_fee(), BigInt::from(7_500)); + assert_eq!(rates[2].gas_price_type.unit_price(), BigInt::from(75_000)); + } + + #[test] + fn test_calculate_fee_rates_empty_fees() { + let fees = vec![]; + let input_type = TransactionInputType::Transfer(Asset { + id: AssetId::from_chain(Chain::Solana), + chain: Chain::Solana, + token_id: None, + name: "SOL".to_string(), + symbol: "SOL".to_string(), + decimals: 9, + asset_type: AssetType::NATIVE, + }); + + let rates = calculate_fee_rates(&input_type, &fees); + + assert_eq!(rates.len(), 3); + assert_eq!(rates[0].gas_price_type.priority_fee(), BigInt::from(1_250u64)); + assert_eq!(rates[1].gas_price_type.priority_fee(), BigInt::from(2_500u64)); + assert_eq!(rates[2].gas_price_type.priority_fee(), BigInt::from(7_500u64)); + } + + #[test] + fn test_calculate_fee_rates_spl_token() { + let fees = vec![SolanaPrioritizationFee { prioritization_fee: 80_000 }]; + let input_type = TransactionInputType::Transfer(Asset { + id: AssetId::from_chain(Chain::Solana), + chain: Chain::Solana, + token_id: Some(USDC_TOKEN_MINT.to_string()), + name: "USDC".to_string(), + symbol: "USDC".to_string(), + decimals: 6, + asset_type: AssetType::SPL, + }); + + let rates = calculate_fee_rates(&input_type, &fees); + assert_eq!(rates.len(), 3); + + assert_eq!(rates[0].gas_price_type.priority_fee(), BigInt::from(5_000u64)); + assert_eq!(rates[1].gas_price_type.priority_fee(), BigInt::from(10_000u64)); + assert_eq!(rates[2].gas_price_type.priority_fee(), BigInt::from(30_000u64)); + } + + #[test] + fn test_calculate_fee_rates_swap() { + let fees = vec![SolanaPrioritizationFee { prioritization_fee: 150_000 }]; + let input_type = TransactionInputType::Swap(Asset::mock_sol(), Asset::mock_spl_token(), mock_swap_data_with_gas_limit(SwapProvider::Jupiter, None)); + + let rates = calculate_fee_rates(&input_type, &fees); + assert_eq!(rates.len(), 3); + + assert_eq!(rates[0].gas_price_type.priority_fee(), BigInt::from(42_000u64)); + assert_eq!(rates[1].gas_price_type.priority_fee(), BigInt::from(84_000u64)); + assert_eq!(rates[2].gas_price_type.priority_fee(), BigInt::from(252_000u64)); + } + + #[test] + fn test_calculate_fee_rates_multiple_fees() { + let fees = vec![ + SolanaPrioritizationFee { prioritization_fee: 200_000 }, + SolanaPrioritizationFee { prioritization_fee: 150_000 }, + SolanaPrioritizationFee { prioritization_fee: 175_000 }, + SolanaPrioritizationFee { prioritization_fee: 125_000 }, + SolanaPrioritizationFee { prioritization_fee: 225_000 }, + SolanaPrioritizationFee { prioritization_fee: 100_000 }, // Should be truncated (6th fee) + ]; + let input_type = TransactionInputType::Transfer(Asset { + id: AssetId::from_chain(Chain::Solana), + chain: Chain::Solana, + token_id: None, + name: "SOL".to_string(), + symbol: "SOL".to_string(), + decimals: 9, + asset_type: AssetType::NATIVE, + }); + + let rates = calculate_fee_rates(&input_type, &fees); + assert_eq!(rates.len(), 3); + + assert_eq!(rates[0].gas_price_type.priority_fee(), BigInt::from(8_750u64)); + assert_eq!(rates[1].gas_price_type.priority_fee(), BigInt::from(17_500u64)); + assert_eq!(rates[2].gas_price_type.priority_fee(), BigInt::from(52_500u64)); + } + + #[test] + fn test_fee_calculation_matches_swift() { + let fees = vec![SolanaPrioritizationFee { prioritization_fee: 150_000 }]; + let input_type = TransactionInputType::Transfer(Asset { + id: AssetId::from_chain(Chain::Solana), + chain: Chain::Solana, + token_id: None, + name: "SOL".to_string(), + symbol: "SOL".to_string(), + decimals: 9, + asset_type: AssetType::NATIVE, + }); + + let rates = calculate_fee_rates(&input_type, &fees); + + assert_eq!(rates[0].gas_price_type.priority_fee(), BigInt::from(7_500)); + assert_eq!(rates[1].gas_price_type.priority_fee(), BigInt::from(15_000)); + assert_eq!(rates[2].gas_price_type.priority_fee(), BigInt::from(45_000)); + } + + #[test] + fn test_calculate_transaction_fee_token_recipient_exists() { + let gas_price_type = GasPriceType::eip1559(BigInt::from(5000u64), BigInt::from(15000u64)); + let asset = Asset { + id: SOLANA_USDC_ASSET_ID.clone(), + chain: Chain::Solana, + token_id: Some(USDC_TOKEN_MINT.to_string()), + name: "USDC".to_string(), + symbol: "USDC".to_string(), + decimals: 6, + asset_type: AssetType::SPL, + }; + let input_type = TransactionInputType::Transfer(asset); + + let fee = calculate_transaction_fee(&input_type, &gas_price_type, Some("recipient_token_address".to_string())); + + assert_eq!(fee.fee, BigInt::from(20_000u64)); + assert!(fee.options.is_empty()); + } + + #[test] + fn test_calculate_transaction_fee_token_recipient_new() { + let gas_price_type = GasPriceType::eip1559(BigInt::from(5000u64), BigInt::from(15000u64)); + let asset = Asset { + id: SOLANA_USDC_ASSET_ID.clone(), + chain: Chain::Solana, + token_id: Some(USDC_TOKEN_MINT.to_string()), + name: "USDC".to_string(), + symbol: "USDC".to_string(), + decimals: 6, + asset_type: AssetType::SPL, + }; + let input_type = TransactionInputType::Transfer(asset); + + let fee = calculate_transaction_fee(&input_type, &gas_price_type, None); + + assert_eq!(fee.fee, BigInt::from(2_059_280u64)); // 20_000 gas + 2_039_280 token account creation + assert_eq!(fee.options.len(), 1); + assert!(fee.options.contains_key(&FeeOption::TokenAccountCreation)); + assert_eq!(fee.options[&FeeOption::TokenAccountCreation], BigInt::from(2_039_280u64)); + } +} diff --git a/core/crates/gem_solana/src/provider/request_classifier.rs b/core/crates/gem_solana/src/provider/request_classifier.rs new file mode 100644 index 0000000000..7007c9fd74 --- /dev/null +++ b/core/crates/gem_solana/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_json_rpc_method("sendTransaction") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_solana/src/provider/staking.rs b/core/crates/gem_solana/src/provider/staking.rs new file mode 100644 index 0000000000..c6c2fdf5e4 --- /dev/null +++ b/core/crates/gem_solana/src/provider/staking.rs @@ -0,0 +1,70 @@ +use async_trait::async_trait; +use chain_traits::ChainStaking; +use std::error::Error; + +use gem_client::Client; +use primitives::{DelegationBase, DelegationValidator}; + +use crate::{ + provider::staking_mapper::{calculate_network_apy, map_staking_delegations, map_staking_validators}, + rpc::client::SolanaClient, +}; + +#[async_trait] +impl ChainStaking for SolanaClient { + async fn get_staking_apy(&self) -> Result, Box> { + let (inflation_rate, supply, accounts) = futures::try_join!(self.get_inflation_rate(), self.get_supply(), self.get_vote_accounts(false))?; + let total_active_stake = accounts.current.iter().map(|validator| validator.activated_stake).sum(); + Ok(Some(calculate_network_apy(inflation_rate.validator, supply.value.total, total_active_stake))) + } + + async fn get_staking_validators(&self, apy: Option) -> Result, Box> { + let accounts = self.get_vote_accounts(false).await?; + let network_apy = match apy { + Some(apy) => apy, + None => { + let (inflation_rate, supply) = futures::try_join!(self.get_inflation_rate(), self.get_supply())?; + let total_active_stake = accounts.current.iter().map(|validator| validator.activated_stake).sum(); + calculate_network_apy(inflation_rate.validator, supply.value.total, total_active_stake) + } + }; + Ok(map_staking_validators(accounts.current, self.get_chain(), network_apy)) + } + + async fn get_staking_delegations(&self, address: String) -> Result, Box> { + let (epoch, accounts) = futures::try_join!(self.get_epoch_info(), self.get_staking_balance(&address))?; + Ok(map_staking_delegations(accounts, epoch, self.get_chain().as_asset_id())) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::create_solana_test_client; + use primitives::testkit::signer_mock::TEST_SOLANA_SENDER; + + #[tokio::test] + async fn test_solana_get_staking_apy() -> Result<(), Box> { + let client = create_solana_test_client(); + let apy = client.get_staking_apy().await?.unwrap(); + + assert!(apy > 0.0); + Ok(()) + } + + #[tokio::test] + async fn test_solana_get_staking_validators() -> Result<(), Box> { + let client = create_solana_test_client(); + let validators = client.get_staking_validators(None).await?; + assert!(!validators.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_solana_get_staking_delegations() -> Result<(), Box> { + let client = create_solana_test_client(); + let delegations = client.get_staking_delegations(TEST_SOLANA_SENDER.to_string()).await?; + assert!(delegations.len() <= 100); + Ok(()) + } +} diff --git a/core/crates/gem_solana/src/provider/staking_mapper.rs b/core/crates/gem_solana/src/provider/staking_mapper.rs new file mode 100644 index 0000000000..076404fb19 --- /dev/null +++ b/core/crates/gem_solana/src/provider/staking_mapper.rs @@ -0,0 +1,153 @@ +use crate::models::{EpochInfo, TokenAccountInfo, VoteAccount}; +use chrono::Utc; +use num_bigint::BigUint; +use primitives::{AssetId, Chain, DelegationBase, DelegationState, DelegationValidator}; + +pub fn calculate_network_apy(inflation_rate: f64, total_supply: u64, total_active_stake: u64) -> f64 { + if total_active_stake == 0 { + return 0.0; + } + + inflation_rate * (total_supply as f64 / total_active_stake as f64) * 100.0 +} + +pub fn map_staking_validators(vote_accounts: Vec, chain: Chain, network_apy: f64) -> Vec { + vote_accounts + .into_iter() + .map(|validator| { + let commission_rate = validator.commission as f64 / 100.0; + let is_active = true; + let validator_apr = if is_active { network_apy - (network_apy * commission_rate) } else { 0.0 }; + + DelegationValidator::stake(chain, validator.vote_pubkey, String::new(), is_active, validator.commission as f64, validator_apr) + }) + .collect() +} + +pub fn map_staking_delegations(stake_accounts: Vec, epoch: EpochInfo, asset_id: AssetId) -> Vec { + stake_accounts + .into_iter() + .filter_map(|account| { + if let Some(stake_info) = &account.account.data.parsed.info.stake { + let balance = BigUint::from(account.account.lamports); + let validator_id = stake_info.delegation.voter.clone(); + + let activation_epoch = stake_info.delegation.activation_epoch; + let deactivation_epoch = stake_info.delegation.deactivation_epoch; + + let is_active = deactivation_epoch == u64::MAX; + + let state = if !is_active { + if deactivation_epoch == epoch.epoch { + DelegationState::Deactivating + } else if deactivation_epoch < epoch.epoch { + DelegationState::AwaitingWithdrawal + } else { + DelegationState::Active + } + } else if activation_epoch == epoch.epoch { + DelegationState::Activating + } else if activation_epoch <= epoch.epoch { + DelegationState::Active + } else { + DelegationState::Pending + }; + + let completion_date = match state { + DelegationState::Activating | DelegationState::Deactivating => { + let remaining_slots = epoch.slots_in_epoch - (epoch.epoch % epoch.slots_in_epoch); + let completion_seconds = remaining_slots as f64 * 0.420; + let completion_time = Utc::now() + chrono::Duration::milliseconds(completion_seconds as i64 * 1000); + Some(completion_time) + } + _ => None, + }; + + let rewards = BigUint::from(0u32); + + return Some(DelegationBase { + asset_id: asset_id.clone(), + state, + balance, + shares: BigUint::from(0u32), + rewards, + completion_date, + delegation_id: account.pubkey.clone(), + validator_id, + }); + } + None + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{EpochInfo, TokenAccountData, TokenAccountInfo, VoteAccount}; + use primitives::{AssetId, Chain, DelegationState}; + + #[test] + fn test_map_staking_validators() { + let vote_accounts = vec![VoteAccount { + vote_pubkey: "validator1".to_string(), + node_pubkey: "node1".to_string(), + commission: 5, + activated_stake: 1_000_000, + }]; + + let result = map_staking_validators(vote_accounts, Chain::Solana, 8.0); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, "validator1"); + assert_eq!(result[0].commission, 5.0); + assert_eq!(result[0].apr, 7.6); + } + + #[test] + fn test_calculate_network_apy() { + let apy = calculate_network_apy(0.125, 400, 100); + + assert_eq!(apy, 50.0); + } + + #[test] + fn test_map_staking_delegations() { + let stake_accounts = vec![TokenAccountInfo { + pubkey: "stake1".to_string(), + account: TokenAccountData { + data: crate::models::Parsed { + parsed: crate::models::Info { + info: crate::models::TokenAccountInfoData { + mint: None, + token_amount: None, + stake: Some(crate::models::StakeInfo { + delegation: crate::models::StakeDelegation { + activation_epoch: 100, + deactivation_epoch: 18446744073709551615, + stake: "1000000".to_string(), + voter: "validator1".to_string(), + }, + }), + }, + }, + }, + owner: "owner1".to_string(), + lamports: 1000000, + }, + }]; + + let epoch = EpochInfo { + epoch: 200, + slot_index: 0, + slots_in_epoch: 432000, + }; + + let result = map_staking_delegations(stake_accounts, epoch, AssetId::from_chain(Chain::Solana)); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].validator_id, "validator1"); + assert_eq!(result[0].balance.to_string(), "1000000"); + assert!(matches!(result[0].state, DelegationState::Active)); + } +} diff --git a/core/crates/gem_solana/src/provider/state.rs b/core/crates/gem_solana/src/provider/state.rs new file mode 100644 index 0000000000..f1af44dc46 --- /dev/null +++ b/core/crates/gem_solana/src/provider/state.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; +use primitives::NodeSyncStatus; + +use crate::provider::state_mapper; +use crate::rpc::client::SolanaClient; + +#[async_trait] +impl ChainState for SolanaClient { + async fn get_chain_id(&self) -> Result> { + Ok(self.get_genesis_hash().await?) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_slot().await?) + } + + async fn get_node_status(&self) -> Result> { + let slot = self.get_slot().await?; + state_mapper::map_node_status(slot) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::create_solana_test_client; + + #[tokio::test] + async fn test_solana_get_chain_id() -> Result<(), Box> { + let client = create_solana_test_client(); + let chain_id = client.get_chain_id().await?; + + println!("Solana chain ID: {}", chain_id); + + assert!(chain_id == "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"); + Ok(()) + } + + #[tokio::test] + async fn test_solana_get_block_latest_number() -> Result<(), Box> { + let client = create_solana_test_client(); + let latest_block = client.get_block_latest_number().await?; + + assert!(latest_block > 0); + println!("Latest block number: {}", latest_block); + + Ok(()) + } + + #[tokio::test] + async fn test_get_node_status() -> Result<(), Box> { + let client = create_solana_test_client(); + let node_status = client.get_node_status().await?; + + assert!(node_status.in_sync); + assert!(node_status.latest_block_number.is_some()); + assert!(node_status.latest_block_number.unwrap_or(0) > 0); + + Ok(()) + } +} diff --git a/core/crates/gem_solana/src/provider/state_mapper.rs b/core/crates/gem_solana/src/provider/state_mapper.rs new file mode 100644 index 0000000000..c66c12e470 --- /dev/null +++ b/core/crates/gem_solana/src/provider/state_mapper.rs @@ -0,0 +1,21 @@ +use primitives::NodeSyncStatus; +use std::error::Error; + +pub fn map_node_status(slot: u64) -> Result> { + Ok(NodeSyncStatus::synced(slot)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_node_status() { + let slot = 287654321u64; + let mapped = map_node_status(slot).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(287654321)); + assert_eq!(mapped.current_block_number, Some(287654321)); + } +} diff --git a/core/crates/gem_solana/src/provider/testkit.rs b/core/crates/gem_solana/src/provider/testkit.rs new file mode 100644 index 0000000000..e8a02c4423 --- /dev/null +++ b/core/crates/gem_solana/src/provider/testkit.rs @@ -0,0 +1,21 @@ +#[cfg(feature = "chain_integration_tests")] +use crate::rpc::client::SolanaClient; +#[cfg(feature = "chain_integration_tests")] +use gem_client::ReqwestClient; +#[cfg(feature = "chain_integration_tests")] +use gem_jsonrpc::JsonRpcClient; +#[cfg(feature = "chain_integration_tests")] +use settings::testkit::get_test_settings; + +#[cfg(feature = "chain_integration_tests")] +pub const TEST_EMPTY_ADDRESS: &str = "EniLGJRPvjbD51z5r59HRN4XoeMmRC4zMtHNHBKi1sFA"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "4dHnggcXjvmMJY2J6iGqse12PeCYQzuTySgwJa36K8MuntmwNrCNztvYRX5ZGpQXzKjaf7g5vaZM7LTuXLNbi2Zx"; + +#[cfg(feature = "chain_integration_tests")] +pub fn create_solana_test_client() -> SolanaClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new_test_client(settings.chains.solana.url); + let rpc_client = JsonRpcClient::new(reqwest_client); + SolanaClient::new(rpc_client) +} diff --git a/core/crates/gem_solana/src/provider/token.rs b/core/crates/gem_solana/src/provider/token.rs new file mode 100644 index 0000000000..25949b2068 --- /dev/null +++ b/core/crates/gem_solana/src/provider/token.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::Asset; + +use crate::{ + models::Extension, + provider::token_mapper::{map_token_data_metaplex, map_token_data_spl_token_2022}, + rpc::client::SolanaClient, +}; + +#[async_trait] +impl ChainToken for SolanaClient { + async fn get_token_data(&self, token_id: String) -> Result> { + let token_info_result = self.get_token_mint_info(&token_id).await?; + let token_info = token_info_result.info(); + + if let Some(extensions) = &token_info.extensions { + for ext in extensions { + if let Extension::TokenMetadata(_token_metadata) = ext { + return map_token_data_spl_token_2022(self.get_chain(), token_id, &token_info); + } + } + } + + let metadata = self.get_metaplex_metadata(&token_id).await?; + map_token_data_metaplex(self.get_chain(), token_id, &token_info, &metadata) + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + token_id.len() >= 40 && token_id.len() <= 60 && bs58::decode(token_id).into_vec().is_ok() + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::{PYUSD_TOKEN_MINT, USDC_TOKEN_MINT, provider::testkit::create_solana_test_client}; + use primitives::{AssetType, Chain}; + + #[tokio::test] + async fn test_solana_get_token_data_usdc_spl_token() -> Result<(), Box> { + let client = create_solana_test_client(); + let usdc_mint = USDC_TOKEN_MINT.to_string(); + + let asset = client.get_token_data(usdc_mint.clone()).await?; + + assert_eq!(asset.chain, Chain::Solana); + assert_eq!(asset.token_id, Some(usdc_mint)); + assert_eq!(asset.symbol, "USDC"); + assert_eq!(asset.name, "USD Coin"); + assert_eq!(asset.decimals, 6); + assert_eq!(asset.asset_type, AssetType::SPL); + + Ok(()) + } + + #[tokio::test] + async fn test_solana_get_token_data_spl_token_2022() -> Result<(), Box> { + let client = create_solana_test_client(); + let spl2022_mint = PYUSD_TOKEN_MINT.to_string(); + + let asset = client.get_token_data(spl2022_mint.clone()).await?; + + assert_eq!(asset.chain, Chain::Solana); + assert_eq!(asset.token_id, Some(spl2022_mint)); + assert_eq!(asset.symbol, "PYUSD"); + assert_eq!(asset.name, "PayPal USD"); + assert_eq!(asset.decimals, 6); + assert_eq!(asset.asset_type, AssetType::SPL2022); + + Ok(()) + } +} diff --git a/core/crates/gem_solana/src/provider/token_mapper.rs b/core/crates/gem_solana/src/provider/token_mapper.rs new file mode 100644 index 0000000000..7ecf40d73f --- /dev/null +++ b/core/crates/gem_solana/src/provider/token_mapper.rs @@ -0,0 +1,62 @@ +use crate::{ + metaplex::metadata::Metadata, + models::{Extension, TokenInfo}, +}; +use primitives::{Asset, AssetId, AssetType, Chain}; + +pub fn map_token_data_metaplex(chain: Chain, token_id: String, token_info: &TokenInfo, meta: &Metadata) -> Result> { + let name = meta.data.name.trim_matches(char::from(0)).to_string(); + let symbol = meta.data.symbol.trim_matches(char::from(0)).to_string(); + let decimals: i32 = token_info.decimals; + let asset_type = if token_info.extensions.is_some() { AssetType::SPL2022 } else { AssetType::SPL }; + + Ok(Asset::new(AssetId::from_token(chain, &token_id), name, symbol, decimals, asset_type)) +} + +pub fn map_token_data_spl_token_2022(chain: Chain, token_id: String, token_info: &TokenInfo) -> Result> { + let token_metadata = token_info + .extensions + .as_ref() + .and_then(|extensions| { + extensions.iter().find_map(|ext| { + if let Extension::TokenMetadata(token_metadata) = ext { + Some(token_metadata.state.clone()) + } else { + None + } + }) + }) + .ok_or("no token metadata found")?; + Ok(Asset::new( + AssetId::from_token(chain, &token_id), + token_metadata.name, + token_metadata.symbol, + token_info.decimals, + AssetType::SPL2022, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{PYUSD_TOKEN_MINT, models::ResultTokenInfo}; + use primitives::{AssetType, JsonRpcResult}; + + #[test] + fn test_map_token_spl_token_2022() { + let result = serde_json::from_str::>(include_str!("../../testdata/pyusd_mint.json")) + .unwrap() + .result + .value + .data + .parsed + .info; + + let token_data = map_token_data_spl_token_2022(Chain::Solana, PYUSD_TOKEN_MINT.to_string(), &result).unwrap(); + + assert_eq!(token_data.name, "PayPal USD"); + assert_eq!(token_data.symbol, "PYUSD"); + assert_eq!(token_data.decimals, 6); + assert_eq!(token_data.asset_type, AssetType::SPL2022); + } +} diff --git a/core/crates/gem_solana/src/provider/transaction_broadcast.rs b/core/crates/gem_solana/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..682537ceae --- /dev/null +++ b/core/crates/gem_solana/src/provider/transaction_broadcast.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{BroadcastProvider, transaction_broadcast_mapper::map_transaction_broadcast_response_from_str}, + rpc::client::SolanaClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for SolanaClient { + async fn transaction_broadcast(&self, data: String, options: BroadcastOptions) -> Result> { + let response = self.send_transaction(data, Some(options.skip_preflight)).await?; + Ok(response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_solana/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_solana/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..e385d2ff4f --- /dev/null +++ b/core/crates/gem_solana/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,7 @@ +use std::error::Error; + +use gem_jsonrpc::types::JsonRpcResult; + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + Ok(serde_json::from_str::>(response)?.take()?) +} diff --git a/core/crates/gem_solana/src/provider/transaction_mapper.rs b/core/crates/gem_solana/src/provider/transaction_mapper.rs new file mode 100644 index 0000000000..4df04c0184 --- /dev/null +++ b/core/crates/gem_solana/src/provider/transaction_mapper.rs @@ -0,0 +1,548 @@ +use chrono::DateTime; +use num_bigint::Sign; + +use crate::{ + COMPUTE_BUDGET_PROGRAM_ID, JUPITER_PROGRAM_ID, METAPLEX_CORE_PROGRAM, METAPLEX_PROGRAM, OKX_DEX_V2_PROGRAM_ID, SYSTEM_PROGRAM_ID, SYSTEM_PROGRAMS, TOKEN_PROGRAM, + models::{BlockTransaction, BlockTransactions, Signature}, +}; +use primitives::{AssetId, Chain, NFTAssetId, SwapProvider, Transaction, TransactionNFTTransferMetadata, TransactionState, TransactionSwapMetadata, TransactionType}; + +const CHAIN: Chain = Chain::Solana; +const SWAP_PROGRAMS: &[(SwapProvider, &str)] = &[(SwapProvider::Jupiter, JUPITER_PROGRAM_ID), (SwapProvider::Okx, OKX_DEX_V2_PROGRAM_ID)]; +const MPL_CORE_TRANSFER_V1: u8 = 14; + +struct MetaplexCoreNftTransfer { + sender: String, + new_owner: String, + asset: String, + collection: String, +} + +fn map_metaplex_core_nft_transfer(transaction: &BlockTransaction, account_keys: &[String]) -> Option { + if !account_keys.iter().any(|key| key == METAPLEX_CORE_PROGRAM) { + return None; + } + let instruction = transaction + .transaction + .message + .instructions + .iter() + .find(|ix| account_keys.get(ix.program_id_index).map(String::as_str) == Some(METAPLEX_CORE_PROGRAM))?; + let data = bs58::decode(&instruction.data).into_vec().ok()?; + if data.first().copied() != Some(MPL_CORE_TRANSFER_V1) { + return None; + } + let resolve = |position: usize| account_keys.get(*instruction.accounts.get(position)? as usize).cloned(); + Some(MetaplexCoreNftTransfer { + asset: resolve(0)?, + collection: resolve(1)?, + sender: resolve(2)?, + new_owner: resolve(4)?, + }) +} + +fn get_swap_provider(account_keys: &[String]) -> Option<(SwapProvider, &'static str)> { + SWAP_PROGRAMS.iter().copied().find(|(_, program_id)| account_keys.iter().any(|key| key == program_id)) +} + +fn map_swap_metadata(transaction: &BlockTransaction, owner: &str, provider: SwapProvider) -> Option { + let balance_changes = transaction.get_balance_changes_by_owner(owner); + let token_balance_changes = transaction.meta.get_token_balance_changes_by_owner(owner); + + let (from_asset, from_value, to_asset, to_value) = match token_balance_changes.as_slice() { + [change] => { + let (from, to) = match change.amount.sign() { + Sign::Plus => (&balance_changes, change), + Sign::Minus => (change, &balance_changes), + Sign::NoSign => return None, + }; + (from.asset_id.clone(), from.amount.magnitude().clone(), to.asset_id.clone(), to.amount.magnitude().clone()) + } + [a, b] => { + let (from, to) = match a.amount.sign() { + Sign::Plus => (b, a), + Sign::Minus => (a, b), + Sign::NoSign => return None, + }; + (from.asset_id.clone(), from.amount.magnitude().clone(), to.asset_id.clone(), to.amount.magnitude().clone()) + } + _ => return None, + }; + + Some(TransactionSwapMetadata { + from_asset, + from_value: from_value.to_string(), + to_asset, + to_value: to_value.to_string(), + provider: Some(provider.id().to_owned()), + }) +} + +pub fn map_block_transactions(transactions: &BlockTransactions) -> Vec { + transactions + .transactions + .iter() + .filter_map(|transaction| map_transaction(transaction, transactions.block_time)) + .collect() +} + +pub fn map_signatures_transactions(transactions: Vec, signatures: Vec) -> Vec { + transactions + .iter() + .zip(signatures.iter()) + .filter_map(|(transaction, signature)| map_transaction(transaction, signature.block_time)) + .collect() +} + +pub fn map_transaction(transaction: &BlockTransaction, block_time: i64) -> Option { + // reject multi-sig transactions (3+), but allow fee-payer pattern (2 signatures) + if transaction.transaction.signatures.len() > 2 { + return None; + } + + let chain = CHAIN; + let account_keys = transaction.transaction.message.account_keys.clone(); + let hash = transaction.transaction.signatures.first()?.to_string(); + let fee = transaction.meta.fee; + let state = if transaction.meta.has_error() { + TransactionState::Reverted + } else { + TransactionState::Confirmed + }; + let fee_asset_id = chain.as_asset_id(); + let created_at = DateTime::from_timestamp(block_time, 0)?; + + // system transfer + if (account_keys.len() == 3) && account_keys.last()? == SYSTEM_PROGRAM_ID + || (account_keys.len() == 4 && account_keys.last()? == SYSTEM_PROGRAM_ID && account_keys.contains(&COMPUTE_BUDGET_PROGRAM_ID.to_string())) + { + let from = account_keys.first()?.clone(); + let to = account_keys[1].clone(); + + let value = transaction.get_balance_change(&from); + + let transaction = Transaction::new( + hash, + chain.as_asset_id(), + from, + to, + None, + TransactionType::Transfer, + state, + fee.to_string(), + fee_asset_id, + value.to_string(), + None, + None, + created_at, + ); + return Some(transaction); + } + + let pre_token_balances = transaction.meta.pre_token_balances.clone(); + let post_token_balances = transaction.meta.post_token_balances.clone(); + + // SPL token transfer (regular tokens or NFTs that go through the SPL Token program). + if let Some(first_balance) = pre_token_balances.first() { + let token_id = &first_balance.mint; + if account_keys.contains(&TOKEN_PROGRAM.to_string()) + && (pre_token_balances.len() == 1 || pre_token_balances.len() == 2) + && post_token_balances.len() == 2 + && pre_token_balances.iter().all(|b| &b.mint == token_id) + && post_token_balances.iter().all(|b| &b.mint == token_id) + { + let sender_account_index: i64 = if transaction.meta.pre_token_balances.len() == 1 { + transaction.meta.pre_token_balances.first()?.account_index + } else if pre_token_balances.first()?.get_amount() >= post_token_balances.first()?.get_amount() { + pre_token_balances.first()?.account_index + } else { + post_token_balances.last()?.account_index + }; + let recipient_account_index = post_token_balances.iter().find(|b| b.account_index != sender_account_index)?.account_index; + + let sender = transaction.meta.get_post_token_balance(sender_account_index)?; + let recipient = transaction.meta.get_post_token_balance(recipient_account_index)?; + let from_value = transaction.meta.get_pre_token_balance(sender_account_index)?.get_amount(); + let to_value = transaction.meta.get_post_token_balance(sender_account_index)?.get_amount(); + + if to_value > from_value { + return None; + } + let value = from_value - to_value; + let from = sender.owner.clone(); + let to = recipient.owner.clone(); + + let is_nft = account_keys.iter().any(|key| key == METAPLEX_PROGRAM); + let (transaction_type, asset_id, metadata) = if is_nft { + let metadata = TransactionNFTTransferMetadata::from_asset_id(NFTAssetId::new(chain, token_id, token_id)); + (TransactionType::TransferNFT, chain.as_asset_id(), serde_json::to_value(metadata).ok()) + } else { + ( + TransactionType::Transfer, + AssetId { + chain, + token_id: Some(token_id.clone()), + }, + None, + ) + }; + + let transaction = Transaction::new( + hash, + asset_id, + from, + to, + None, + transaction_type, + state, + fee.to_string(), + fee_asset_id, + value.to_string(), + None, + metadata, + created_at, + ); + return Some(transaction); + } + } + + // Metaplex Core NFT transfer (single instruction, no SPL token balances). + if let Some(nft) = map_metaplex_core_nft_transfer(transaction, &account_keys) { + let metadata = TransactionNFTTransferMetadata::from_asset_id(NFTAssetId::new(chain, &nft.collection, &nft.asset)); + return Some(Transaction::new( + hash, + chain.as_asset_id(), + nft.sender, + nft.new_owner, + None, + TransactionType::TransferNFT, + state, + fee.to_string(), + fee_asset_id, + "0".to_string(), + None, + serde_json::to_value(metadata).ok(), + created_at, + )); + } + + if let Some((provider, program_id)) = get_swap_provider(&account_keys) { + let sender = account_keys.first()?.clone(); + let swap = map_swap_metadata(transaction, &sender, provider)?; + + let transaction = Transaction::new( + hash.clone(), + swap.from_asset.clone(), + sender.clone(), + sender.clone(), + Some(program_id.to_string()), + TransactionType::Swap, + state, + fee.to_string(), + chain.as_asset_id(), + swap.from_value.clone(), + None, + serde_json::to_value(swap).ok(), + created_at, + ); + return Some(transaction); + } + + // smart contract call + let contract = transaction + .transaction + .message + .instructions + .iter() + .map(|ix| &account_keys[ix.program_id_index]) + .find(|key| !SYSTEM_PROGRAMS.contains(&key.as_str()))?; + let sender = account_keys.first()?.clone(); + let value = transaction.get_balance_change(&sender); + + Some(Transaction::new( + hash, + chain.as_asset_id(), + sender.clone(), + sender, + Some(contract.to_string()), + TransactionType::SmartContractCall, + state, + fee.to_string(), + fee_asset_id, + value.to_string(), + None, + None, + created_at, + )) +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::provider::testkit::TEST_TRANSACTION_ID; + use crate::{ + PYUSD_TOKEN_MINT, USDT_TOKEN_MINT, + models::{SingleTransaction, SolanaTransaction}, + }; + use gem_jsonrpc::types::JsonRpcErrorResponse; + use primitives::{JsonRpcResult, asset_constants::SOLANA_USDC_ASSET_ID}; + + const PNFT_MINT: &str = "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy"; + const CORE_ASSET: &str = "JATWmjADckr2M7TX5xMfo1HNfYS66DKot15fJ4hVLrVE"; + const CORE_COLLECTION: &str = "5pQfZttNUtaj8sySRY9RsdtB81aEAQDh2vnacpxiwTpT"; + + #[test] + fn test_transaction_nft_token_program_transfer() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/nft_token_program_transfer.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1).unwrap(); + + assert_eq!(transaction.transaction_type, TransactionType::TransferNFT); + assert_eq!(transaction.asset_id, Chain::Solana.as_asset_id()); + assert_eq!(transaction.from, "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H"); + + let metadata: TransactionNFTTransferMetadata = serde_json::from_value(transaction.metadata.unwrap()).unwrap(); + assert_eq!(metadata.asset_id, NFTAssetId::new(Chain::Solana, PNFT_MINT, PNFT_MINT)); + } + + #[test] + fn test_transaction_nft_mplcore_transfer() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/nft_mplcore_transfer.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1).unwrap(); + + assert_eq!(transaction.transaction_type, TransactionType::TransferNFT); + assert_eq!(transaction.asset_id, Chain::Solana.as_asset_id()); + assert_eq!(transaction.from, "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H"); + assert_eq!(transaction.to, "G7B17AigRCGvwnxFc5U8zY5T3NBGduLzT7KYApNU2VdR"); + + let metadata: TransactionNFTTransferMetadata = serde_json::from_value(transaction.metadata.unwrap()).unwrap(); + assert_eq!(metadata.asset_id, NFTAssetId::new(Chain::Solana, CORE_COLLECTION, CORE_ASSET)); + } + + #[test] + fn test_transaction_swap_token_to_sol() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/swap_token_to_sol.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1).unwrap(); + let expected = TransactionSwapMetadata { + from_asset: AssetId::from_token(Chain::Solana, "BKpSnSdNdANUxKPsn4AQ8mf4b9BoeVs9JD1Q8cVkpump"), + from_value: "393647577456".to_string(), + to_asset: Chain::Solana.as_asset_id(), + to_value: "139512057".to_string(), + provider: Some(SwapProvider::Jupiter.id().to_owned()), + }; + + assert_eq!(transaction.metadata, Some(serde_json::to_value(expected).unwrap())); + } + + #[test] + fn test_transaction_swap_token_to_token() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/swap_token_to_token.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1).unwrap(); + let expected = TransactionSwapMetadata { + from_asset: AssetId::from_token(Chain::Solana, PYUSD_TOKEN_MINT), + from_value: "1000000".to_string(), + to_asset: AssetId::from_token(Chain::Solana, USDT_TOKEN_MINT), + to_value: "999932".to_string(), + provider: Some(SwapProvider::Jupiter.id().to_owned()), + }; + + assert_eq!(transaction.metadata, Some(serde_json::to_value(expected).unwrap())); + } + + #[test] + fn test_transaction_swap_sol_to_token() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/swap_sol_to_token.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1).unwrap(); + let expected = TransactionSwapMetadata { + from_asset: Chain::Solana.as_asset_id(), + from_value: "10000000".to_string(), + to_asset: AssetId::from_token(Chain::Solana, USDT_TOKEN_MINT), + to_value: "1678930".to_string(), + provider: Some(SwapProvider::Jupiter.id().to_owned()), + }; + + assert_eq!(transaction.metadata, Some(serde_json::to_value(expected).unwrap())); + } + + #[test] + fn test_transaction_swap_okx_token_to_token() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/swap_okx_token_to_token.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1).unwrap(); + let expected = TransactionSwapMetadata { + from_asset: SOLANA_USDC_ASSET_ID.clone(), + from_value: "56061275".to_string(), + to_asset: AssetId::from_token(Chain::Solana, "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u"), + to_value: "2190151370200".to_string(), + provider: Some(SwapProvider::Okx.id().to_owned()), + }; + + assert_eq!(transaction.transaction_type, TransactionType::Swap); + assert_eq!(transaction.asset_id, SOLANA_USDC_ASSET_ID.clone()); + assert_eq!(transaction.contract, Some(OKX_DEX_V2_PROGRAM_ID.to_string())); + assert_eq!(transaction.value, "56061275"); + assert_eq!(transaction.metadata, Some(serde_json::to_value(expected).unwrap())); + } + + #[test] + fn test_transaction_transfer_sol() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/transfer_sol.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1751394455).unwrap(); + let expected = Transaction::new( + "t6DpS6U7G2UG4QwDq4mPM7F45Rnttxp2pHGRwTYsF7frxAs7KmSWWDcpMneMUULbKndkZy8iUvSU1AZUsqzDCPN".to_string(), + Chain::Solana.as_asset_id(), + "DyB4TbDBqPUsCfsJMuoqjktEAod7D3KMNULSo7R1Rb61".to_string(), + "DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "5000".to_string(), + Chain::Solana.as_asset_id(), + "2173".to_string(), + None, + None, + DateTime::from_timestamp(1751394455, 0).unwrap(), + ); + + assert_eq!(transaction, expected); + } + + #[test] + fn test_transaction_transfer_sol_with_compute() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/transfer_sol_with_compute.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1750884182).unwrap(); + let expected = Transaction::new( + "2QeBm7G7qLmVTCVKAkbSUuZvcFjg6mBRVqaVSKWSZsTqJxHVTzMUwxDtu1Myfu8RzpUv5YMEBFFpGbwVM9ZQY8DL".to_string(), + Chain::Solana.as_asset_id(), + "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H".to_string(), + "7nVDzZUjrBA3gHs3gNcHidhmR96CH7KpKsU8pyBZGHUr".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "7500".to_string(), + Chain::Solana.as_asset_id(), + "69000000".to_string(), + None, + None, + DateTime::from_timestamp(1750884182, 0).unwrap(), + ); + + assert_eq!(transaction, expected); + } + + #[test] + fn test_transaction_error_maps_to_reverted() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/transaction_reverted_program_account_not_found.json")).unwrap(); + + let transaction = map_transaction(&result.result, 1).unwrap(); + + assert_eq!(transaction.state, TransactionState::Reverted); + } + + #[test] + fn test_map_transaction_by_hash() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/usdc_transfer.json")).unwrap(); + + let block_transaction = BlockTransaction { + meta: result.result.meta, + transaction: result.result.transaction, + }; + + let transaction = map_transaction(&block_transaction, result.result.block_time).unwrap(); + let expected = Transaction::new( + TEST_TRANSACTION_ID.to_string(), + SOLANA_USDC_ASSET_ID.clone(), + "37BenMAXFJMo3GaXKb2XLsNQXmd6VbbdShZWnwDj9D6k".to_string(), + "3UJQqKq8Xyx4aVRmHgEwpQZiW7toYRQCTy6Bgp1RdKnK".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "5500".to_string(), + Chain::Solana.as_asset_id(), + "100000".to_string(), + None, + None, + DateTime::from_timestamp(1753346616, 0).unwrap(), + ); + + assert_eq!(transaction, expected); + } + + #[test] + fn test_transaction_transfer_usdc_fee_payer() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/usdc_transfer_fee_payer.json")).unwrap(); + + let block_transaction = BlockTransaction { + meta: result.result.meta, + transaction: result.result.transaction, + }; + + let transaction = map_transaction(&block_transaction, result.result.block_time).unwrap(); + let expected = Transaction::new( + "65MevEhHuXZzwQ8VftyQUmbgbs41bj2KMhf6zDM5hqsXN6jxsHYniHbi7MMWQ4kitcgfRdsuYe1s7zAqtzPYXZVG".to_string(), + SOLANA_USDC_ASSET_ID.clone(), + "5QtyKPHtWUf45bNb5buQ6UxpL2ekSJxBCK9g8xMCsc9U".to_string(), + "B1nzrk99FEDAYB2M82yepdvEv1YBRJKcx5Y5R6MSDW1Q".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "10000".to_string(), + Chain::Solana.as_asset_id(), + "24737625".to_string(), + None, + None, + DateTime::from_timestamp(1774244726, 0).unwrap(), + ); + + assert_eq!(transaction, expected); + } + + #[test] + fn test_get_transaction_status() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/transaction_state_transfer_sol.json")).unwrap(); + let transaction = result.result; + + let state = if transaction.slot > 0 { + if transaction.meta.has_error() { + TransactionState::Reverted + } else { + TransactionState::Confirmed + } + } else { + TransactionState::Pending + }; + + assert_eq!(state, TransactionState::Confirmed); + assert_eq!(transaction.slot, 361169359); + } + + #[test] + fn test_transaction_chainflip_vault_swap() { + let result: JsonRpcResult = serde_json::from_str(include_str!("../../testdata/chainflip_vault_swap.json")).unwrap(); + let transaction = map_transaction(&result.result, 1772283531).unwrap(); + + assert_eq!(transaction.transaction_type, TransactionType::SmartContractCall); + assert_eq!(transaction.from, "CabroWmzUzcqqGvprUoC7RnJznuwX6qf5W1tSSaomri7"); + assert_eq!(transaction.contract, Some("J88B7gmadHzTNGiy54c9Ms8BsEXNdB2fntFyhKpk3qoT".to_string())); + assert_eq!(transaction.value, "152686560"); + } + + #[test] + fn test_transaction_broadcast_error() { + let error_response: JsonRpcErrorResponse = serde_json::from_str(include_str!("../../testdata/transaction_broadcast_swap_error.json")).unwrap(); + + assert_eq!(error_response.error.code, -32002); + assert_eq!( + error_response.error.message, + "Transaction simulation failed: Error processing Instruction 3: custom program error: 0x1771" + ); + assert_eq!(error_response.id, Some(1755839259)); + } +} diff --git a/core/crates/gem_solana/src/provider/transaction_state.rs b/core/crates/gem_solana/src/provider/transaction_state.rs new file mode 100644 index 0000000000..f7d953590b --- /dev/null +++ b/core/crates/gem_solana/src/provider/transaction_state.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionState, TransactionStateRequest, TransactionUpdate}; + +use crate::models::SolanaTransaction; +use crate::rpc::client::SolanaClient; + +#[async_trait] +impl ChainTransactionState for SolanaClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let transaction = self.get_transaction(&request.id).await?; + + Ok(map_transaction_update(transaction.as_ref())) + } +} + +fn map_transaction_update(transaction: Option<&SolanaTransaction>) -> TransactionUpdate { + let Some(transaction) = transaction else { + return TransactionUpdate::new_state(TransactionState::Pending); + }; + if transaction.meta.has_error() { + TransactionUpdate::new_state(TransactionState::Reverted) + } else { + TransactionUpdate::new_state(TransactionState::Confirmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::JsonRpcResult; + + #[test] + fn test_map_transaction_update_confirmed() { + let confirmed = serde_json::from_str::>>(include_str!("../../testdata/transaction_state_transfer_sol.json")) + .unwrap() + .result; + + assert_eq!(map_transaction_update(confirmed.as_ref()), TransactionUpdate::new_state(TransactionState::Confirmed)); + } + + #[test] + fn test_map_transaction_update_reverted() { + let reverted = serde_json::from_str::>>(include_str!("../../testdata/transaction_state_reverted_program_account_not_found.json")) + .unwrap() + .result; + + assert_eq!(map_transaction_update(reverted.as_ref()), TransactionUpdate::new_state(TransactionState::Reverted)); + } + + #[test] + fn test_map_transaction_update_pending_when_transaction_not_found() { + let pending = serde_json::from_str::>>(include_str!("../../testdata/transaction_state_pending_not_found.json")) + .unwrap() + .result; + + assert_eq!(map_transaction_update(pending.as_ref()), TransactionUpdate::new_state(TransactionState::Pending)); + } +} diff --git a/core/crates/gem_solana/src/provider/transactions.rs b/core/crates/gem_solana/src/provider/transactions.rs new file mode 100644 index 0000000000..cbd376178f --- /dev/null +++ b/core/crates/gem_solana/src/provider/transactions.rs @@ -0,0 +1,86 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::{ + models::{BlockTransaction, SingleTransaction}, + provider::transaction_mapper::{map_block_transactions, map_signatures_transactions, map_transaction}, + rpc::{client::SolanaClient, constants::MISSING_BLOCKS_ERRORS}, +}; + +#[async_trait] +impl ChainTransactions for SolanaClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + match self.get_block_transactions(block).await { + Ok(block_transactions) => Ok(map_block_transactions(&block_transactions)), + Err(error) => { + if MISSING_BLOCKS_ERRORS.contains(&error.code) { + return Ok(vec![]); + } + Err(Box::new(error)) + } + } + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + let transaction = self + .rpc_call::("getTransaction", serde_json::json!([hash, { "encoding": "json", "maxSupportedTransactionVersion": 0 }])) + .await?; + let block_transaction = BlockTransaction { + meta: transaction.meta, + transaction: transaction.transaction, + }; + Ok(map_transaction(&block_transaction, transaction.block_time)) + } + + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, limit, .. } = request; + let limit = limit.unwrap_or(10); + let signatures = self.get_signatures_for_address(&address, limit).await?; + if signatures.is_empty() { + return Ok(vec![]); + } + let signatures_ids = signatures.clone().iter().map(|x| x.signature.clone()).collect(); + let transactions = self.get_transactions(signatures_ids).await?; + Ok(map_signatures_transactions(transactions, signatures)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_TRANSACTION_ID, create_solana_test_client}; + use chain_traits::ChainState; + use primitives::testkit::signer_mock::TEST_SOLANA_SENDER; + + #[tokio::test] + async fn test_solana_get_transactions_by_block() { + let client = create_solana_test_client(); + + let latest_block = client.get_block_latest_number().await.unwrap(); + let transactions = client.get_transactions_by_block(latest_block).await.unwrap(); + + println!("Latest block: {}, transactions count: {}", latest_block, transactions.len()); + assert!(latest_block > 0); + assert!(!transactions.is_empty()); + } + + #[tokio::test] + async fn test_solana_get_transactions_by_address() { + let client = create_solana_test_client(); + let transactions = client.get_transactions_by_address(TransactionsRequest::new(TEST_SOLANA_SENDER.to_string())).await.unwrap(); + + println!("Address: {}, transactions count: {}", TEST_SOLANA_SENDER, transactions.len()); + } + + #[tokio::test] + async fn test_solana_get_transaction_by_hash() { + let client = create_solana_test_client(); + let transaction = client.get_transaction_by_hash(TEST_TRANSACTION_ID.to_string()).await.unwrap().unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + } +} diff --git a/core/crates/gem_solana/src/rpc/client.rs b/core/crates/gem_solana/src/rpc/client.rs new file mode 100644 index 0000000000..b54ac6036a --- /dev/null +++ b/core/crates/gem_solana/src/rpc/client.rs @@ -0,0 +1,282 @@ +use crate::models::{ + AccountData, EpochInfo, InflationRate, ResultTokenInfo, Signature, SupplyResult, TokenAccountInfo, ValueResult, VoteAccounts, + balances::SolanaBalance, + blockhash::SolanaBlockhashResult, + prioritization_fee::SolanaPrioritizationFee, + transaction::{BlockTransactions, SolanaTransaction}, +}; +use crate::{ + COMMITMENT_CONFIRMED, STAKE_PROGRAM_ID, SolanaRpc, + metaplex::{decode_metadata, metadata::Metadata}, +}; +use chain_traits::ChainProvider; +#[cfg(feature = "rpc")] +use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual, ChainTraits}; +#[cfg(feature = "rpc")] +use gem_client::Client; +use gem_encoding::decode_base64; +#[cfg(feature = "rpc")] +use gem_jsonrpc::{client::JsonRpcClient as GenericJsonRpcClient, types::JsonRpcError}; +use primitives::Chain; +use solana_primitives::{AddressLookupTableAccount, Pubkey}; +use std::{error::Error, str::FromStr}; + +#[cfg(feature = "rpc")] +pub struct SolanaClient { + client: GenericJsonRpcClient, + pub chain: Chain, +} + +pub fn confirmed_config(mut extras: serde_json::Value) -> serde_json::Value { + if let Some(obj) = extras.as_object_mut() { + obj.insert("commitment".to_string(), COMMITMENT_CONFIRMED.into()); + } + extras +} + +fn send_transaction_params(data: String, skip_preflight: Option) -> serde_json::Value { + let mut config = serde_json::json!({ + "encoding": "base64", + "preflightCommitment": COMMITMENT_CONFIRMED, + }); + + if let Some(skip) = skip_preflight + && let Some(obj) = config.as_object_mut() + { + obj.insert("skipPreflight".to_string(), skip.into()); + } + + serde_json::json!([data, config]) +} + +pub fn token_accounts_by_owner_params(owner: &str, program_id: &str) -> serde_json::Value { + serde_json::json!([owner, { "programId": program_id }, confirmed_config(serde_json::json!({ "encoding": "jsonParsed" }))]) +} + +pub fn token_accounts_by_mint_params(owner: &str, mint: &str) -> serde_json::Value { + serde_json::json!([owner, { "mint": mint }, confirmed_config(serde_json::json!({ "encoding": "jsonParsed" }))]) +} + +#[cfg(feature = "rpc")] +impl SolanaClient { + pub fn new(client: GenericJsonRpcClient) -> Self { + Self { client, chain: Chain::Solana } + } + + pub fn get_client(&self) -> &GenericJsonRpcClient { + &self.client + } + + pub fn get_chain(&self) -> Chain { + self.chain + } + + pub async fn rpc_call(&self, method: &str, params: serde_json::Value) -> Result + where + T: serde::de::DeserializeOwned + Send, + { + self.client.call(method, params).await + } + + pub async fn get_balance(&self, address: &str) -> Result { + self.rpc_call("getBalance", serde_json::json!([address, confirmed_config(serde_json::json!({}))])).await + } + + pub async fn get_token_accounts_by_owner(&self, owner: &str, program_id: &str) -> Result>, JsonRpcError> { + let params = token_accounts_by_owner_params(owner, program_id); + self.rpc_call("getTokenAccountsByOwner", params).await + } + + pub async fn get_epoch_info(&self) -> Result { + self.rpc_call("getEpochInfo", serde_json::json!([confirmed_config(serde_json::json!({}))])).await + } + + pub async fn get_token_accounts_by_mint(&self, owner: &str, mint: &str) -> Result>, JsonRpcError> { + let params = token_accounts_by_mint_params(owner, mint); + self.rpc_call("getTokenAccountsByOwner", params).await + } + + pub async fn get_transaction(&self, signature: &str) -> Result, JsonRpcError> { + let params = serde_json::json!([signature, confirmed_config(serde_json::json!({ "maxSupportedTransactionVersion": 0 }))]); + self.rpc_call("getTransaction", params).await + } + + pub async fn get_genesis_hash(&self) -> Result { + self.rpc_call("getGenesisHash", serde_json::json!([])).await + } + + pub async fn get_slot(&self) -> Result { + self.rpc_call("getSlot", serde_json::json!([confirmed_config(serde_json::json!({}))])).await + } + + pub async fn get_latest_blockhash(&self) -> Result { + self.rpc_call("getLatestBlockhash", serde_json::json!([confirmed_config(serde_json::json!({}))])).await + } + + pub async fn get_address_lookup_tables(&self, addresses: Vec) -> Result, Box> { + if addresses.is_empty() { + return Ok(Vec::new()); + } + + let result: ValueResult>> = self.client.request(SolanaRpc::GetMultipleAccounts(addresses.clone())).await?; + result + .value + .into_iter() + .enumerate() + .filter_map(|(index, account)| account.map(|account| (index, account))) + .map(|(index, account)| { + let data = account + .data + .first() + .ok_or_else(|| -> Box { "Missing Solana account data".into() })?; + let bytes = decode_base64(data)?; + let address = Pubkey::from_str(&addresses[index])?; + AddressLookupTableAccount::from_account_data(address, &bytes) + .map_err(|err| -> Box { format!("Invalid Solana address lookup table: {err}").into() }) + }) + .collect() + } + + pub async fn get_staking_balance(&self, address: &str) -> Result, JsonRpcError> { + let config = confirmed_config(serde_json::json!({ + "encoding": "jsonParsed", + "filters": [ + { "memcmp": { "offset": 12, "bytes": address } } + ] + })); + self.rpc_call("getProgramAccounts", serde_json::json!([STAKE_PROGRAM_ID, config])).await + } + + pub async fn get_vote_accounts(&self, keep_unstaked_delinquents: bool) -> Result { + let params = serde_json::json!([confirmed_config(serde_json::json!({ "keepUnstakedDelinquents": keep_unstaked_delinquents }))]); + self.rpc_call("getVoteAccounts", params).await + } + + pub async fn get_inflation_rate(&self) -> Result { + self.rpc_call("getInflationRate", serde_json::json!([])).await + } + + pub async fn get_supply(&self) -> Result { + self.rpc_call("getSupply", serde_json::json!([confirmed_config(serde_json::json!({}))])).await + } + + pub async fn send_transaction(&self, data: String, skip_preflight: Option) -> Result { + self.rpc_call("sendTransaction", send_transaction_params(data, skip_preflight)).await + } + + pub async fn get_recent_prioritization_fees(&self) -> Result, JsonRpcError> { + self.rpc_call("getRecentPrioritizationFees", serde_json::json!([])).await + } + + pub async fn get_token_mint_info(&self, token_mint: &str) -> Result { + let params = serde_json::json!([token_mint, confirmed_config(serde_json::json!({ "encoding": "jsonParsed" }))]); + self.rpc_call("getAccountInfo", params).await + } + + pub(crate) async fn get_account_info_base64(&self, address: &str) -> Result>, JsonRpcError> { + self.rpc_call( + "getAccountInfo", + serde_json::json!([address, confirmed_config(serde_json::json!({ "encoding": "base64" }))]), + ) + .await + } + + pub(crate) async fn find_token_account(&self, owner: &str, mint: &str) -> Result, JsonRpcError> { + let accounts = self.get_token_accounts_by_mint(owner, mint).await?; + Ok(accounts.value.first().map(|account| account.pubkey.clone())) + } + + pub async fn get_metaplex_metadata(&self, token_mint: &str) -> Result> { + let pubkey = Pubkey::from_str(token_mint)?; + let metadata_key = Metadata::find_pda(pubkey) + .ok_or::>("metadata program account not found".into())? + .0 + .to_string(); + let value = self.get_account_info_base64(&metadata_key).await?.value.ok_or("Failed to get metadata")?; + let data = value.data.first().ok_or("Missing metadata account data")?; + decode_metadata(data).map_err(|_| "Failed to decode metadata".into()) + } + + pub async fn get_block_transactions(&self, slot: u64) -> Result { + let config = confirmed_config(serde_json::json!({ + "encoding": "json", + "transactionDetails": "full", + "rewards": false, + "maxSupportedTransactionVersion": 0, + })); + self.rpc_call("getBlock", serde_json::json!([slot, config])).await + } + + pub async fn get_signatures_for_address(&self, address: &str, limit: usize) -> Result, JsonRpcError> { + let params = serde_json::json!([address, confirmed_config(serde_json::json!({ "limit": limit }))]); + self.rpc_call("getSignaturesForAddress", params).await + } + + pub async fn get_transactions(&self, signatures: Vec) -> Result, JsonRpcError> { + let mut transactions = Vec::new(); + + for signature in signatures { + let config = confirmed_config(serde_json::json!({ + "encoding": "json", + "maxSupportedTransactionVersion": 0, + })); + let params = serde_json::json!([signature, config]); + + if let Ok(tx) = self.rpc_call::("getTransaction", params).await { + transactions.push(tx); + } + } + + Ok(transactions) + } + + pub async fn get_token_accounts(&self, address: &str, token_mints: &[String]) -> Result>>, Box> { + let calls: Vec<(String, serde_json::Value)> = token_mints + .iter() + .map(|mint| ("getTokenAccountsByOwner".to_string(), token_accounts_by_mint_params(address, mint))) + .collect(); + Ok(self.get_client().batch_call(calls).await?.take_all()?) + } +} + +#[cfg(feature = "rpc")] +#[async_trait::async_trait] +impl ChainAccount for SolanaClient {} + +#[cfg(feature = "rpc")] +#[async_trait::async_trait] +impl ChainPerpetual for SolanaClient {} + +#[cfg(feature = "rpc")] +#[async_trait::async_trait] +impl ChainAddressStatus for SolanaClient {} + +#[cfg(feature = "rpc")] +impl ChainTraits for SolanaClient {} +impl ChainProvider for SolanaClient { + fn get_chain(&self) -> primitives::Chain { + Chain::Solana + } +} + +#[cfg(test)] +mod tests { + use crate::models::ResultTokenInfo; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Serialize, Deserialize)] + struct JsonRpcResult { + result: T, + } + + #[test] + fn test_decode_token_data() { + let json: serde_json::Value = serde_json::from_str(include_str!("../../testdata/pyusd_mint.json")).expect("file should be proper JSON"); + let result: JsonRpcResult = serde_json::from_value(json).expect("Decoded into ParsedTokenInfo"); + assert_eq!(result.result.value.data.parsed.info.decimals, 6); + + let json: serde_json::Value = serde_json::from_str(include_str!("../../testdata/usdc_mint.json")).expect("file should be proper JSON"); + let result: JsonRpcResult = serde_json::from_value(json).expect("Decoded into ParsedTokenInfo"); + assert_eq!(result.result.value.data.parsed.info.decimals, 6); + } +} diff --git a/core/crates/gem_solana/src/rpc/constants.rs b/core/crates/gem_solana/src/rpc/constants.rs new file mode 100644 index 0000000000..8f8594e08f --- /dev/null +++ b/core/crates/gem_solana/src/rpc/constants.rs @@ -0,0 +1,6 @@ +pub const CLEANUP_BLOCK_ERROR: i32 = -32001; +pub const MISSING_SLOT_ERROR: i32 = -32007; +pub const MISSING_OR_SKIPPED_SLOT_ERROR: i32 = -32009; +pub const NOT_AVAILABLE_SLOT_ERROR: i32 = -32004; + +pub const MISSING_BLOCKS_ERRORS: [i32; 4] = [MISSING_SLOT_ERROR, MISSING_OR_SKIPPED_SLOT_ERROR, NOT_AVAILABLE_SLOT_ERROR, CLEANUP_BLOCK_ERROR]; diff --git a/core/crates/gem_solana/src/rpc/mod.rs b/core/crates/gem_solana/src/rpc/mod.rs new file mode 100644 index 0000000000..75076798fb --- /dev/null +++ b/core/crates/gem_solana/src/rpc/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod constants; + +pub use client::SolanaClient; +pub use constants::*; diff --git a/core/crates/gem_solana/src/signer/chain_signer.rs b/core/crates/gem_solana/src/signer/chain_signer.rs new file mode 100644 index 0000000000..bafd200f7b --- /dev/null +++ b/core/crates/gem_solana/src/signer/chain_signer.rs @@ -0,0 +1,156 @@ +use super::{instructions, swap, transaction}; +use crate::{decode_transaction, transaction::is_transaction_bytes}; +use gem_encoding::encode_base64; +use primitives::{ChainSigner, SignerError, SignerInput, TransferDataOutputType}; +use solana_primitives::{Pubkey, sign_message as sign_solana_message}; + +#[derive(Default)] +pub struct SolanaChainSigner; + +const SIGN_MESSAGE_PAYLOAD_REJECTION: &str = "Serialized Solana transaction or transaction message received in signMessage request; use signTransaction instead"; + +impl ChainSigner for SolanaChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let sender = Pubkey::from_base58(&input.sender_address).map_err(SignerError::from_display)?; + transaction::sign_single_signer_instructions(input, private_key, sender, instructions::native_transfer(input, sender)?) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let sender = Pubkey::from_base58(&input.sender_address).map_err(SignerError::from_display)?; + transaction::sign_single_signer_instructions(input, private_key, sender, instructions::token_transfer(input, sender)?) + } + + fn sign_nft_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let sender = Pubkey::from_base58(&input.sender_address).map_err(SignerError::from_display)?; + transaction::sign_single_signer_instructions(input, private_key, sender, instructions::nft_transfer(input, sender)?) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + swap::sign(input, private_key) + } + + fn sign_stake(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let sender = Pubkey::from_base58(&input.sender_address).map_err(SignerError::from_display)?; + Ok(vec![transaction::sign_single_signer_instructions( + input, + private_key, + sender, + instructions::stake(input, sender)?, + )?]) + } + + fn sign_message(&self, message: &[u8], private_key: &[u8]) -> Result { + if is_transaction_bytes(message) { + return Err(SignerError::invalid_input(SIGN_MESSAGE_PAYLOAD_REJECTION)); + } + let signature = sign_solana_message(private_key, message).map_err(|e| SignerError::signing_error(format!("sign: {e}")))?; + Ok(bs58::encode(signature.as_bytes()).into_string()) + } + + fn sign_data(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let extra = input.input_type.get_generic_data().map_err(SignerError::invalid_input)?; + let data = extra.data_as_str().map_err(SignerError::invalid_input)?; + let mut transaction = decode_transaction(data).map_err(SignerError::invalid_input)?; + + let signatures = transaction.signatures(); + if signatures.is_empty() || signatures[0].as_bytes() != &[0u8; 64] { + return Err(SignerError::invalid_input("user signature should be first")); + } + + let message_bytes = transaction.serialize_message().map_err(|e| SignerError::signing_error(format!("serialize message: {e}")))?; + let signature = sign_solana_message(private_key, &message_bytes).map_err(|e| SignerError::signing_error(format!("sign: {e}")))?; + + match extra.output_type { + TransferDataOutputType::Signature => Ok(bs58::encode(signature.as_bytes()).into_string()), + TransferDataOutputType::EncodedTransaction => { + transaction.signatures_mut()[0] = signature; + let bytes = transaction.serialize().map_err(|e| SignerError::signing_error(format!("serialize transaction: {e}")))?; + Ok(encode_base64(&bytes)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signer::testkit::{DOUBLE_SIG_TX, EXPECTED_MESSAGE_HEX, SINGLE_SIG_TX, mock_legacy_transaction}; + use gem_encoding::decode_base64; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use primitives::{Chain, ChainSigner, SignerInput, TransactionLoadInput, TransferDataOutputType}; + use solana_primitives::VersionedTransaction; + + #[test] + fn test_deserialize_single_signature_transaction() { + let bytes = decode_base64(SINGLE_SIG_TX).unwrap(); + let transaction = VersionedTransaction::deserialize_with_version(&bytes).unwrap(); + + assert_eq!(transaction.signatures().len(), 1); + + let message_bytes = transaction.serialize_message().unwrap(); + let message_hex: String = message_bytes.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(message_hex, EXPECTED_MESSAGE_HEX); + } + + #[test] + fn test_deserialize_double_signature_transaction() { + let bytes = decode_base64(DOUBLE_SIG_TX).unwrap(); + let transaction = VersionedTransaction::deserialize_with_version(&bytes).unwrap(); + + assert_eq!(transaction.signatures().len(), 2); + } + + #[test] + fn test_sign_data_encoded_transaction() { + let signer = SolanaChainSigner; + let input = TransactionLoadInput::mock_sign_data(Chain::Solana, SINGLE_SIG_TX, TransferDataOutputType::EncodedTransaction); + let fee = input.default_fee(); + let input = SignerInput::new(input, fee); + + let result = signer.sign_data(&input, &TEST_PRIVATE_KEY).unwrap(); + + let signed_bytes = decode_base64(&result).unwrap(); + let signed_transaction = VersionedTransaction::deserialize_with_version(&signed_bytes).unwrap(); + assert_eq!(signed_transaction.signatures().len(), 1); + assert_ne!(signed_transaction.signatures()[0].as_bytes(), &[0u8; 64]); + } + + #[test] + fn test_sign_data_signature_output() { + let signer = SolanaChainSigner; + let input = TransactionLoadInput::mock_sign_data(Chain::Solana, SINGLE_SIG_TX, TransferDataOutputType::Signature); + let fee = input.default_fee(); + let input = SignerInput::new(input, fee); + + let result = signer.sign_data(&input, &TEST_PRIVATE_KEY).unwrap(); + + let sig_bytes = bs58::decode(&result).into_vec().unwrap(); + assert_eq!(sig_bytes.len(), 64); + } + + #[test] + fn test_sign_message() { + let result = SolanaChainSigner.sign_message(b"hello", &TEST_PRIVATE_KEY).unwrap(); + + assert_eq!(bs58::decode(result).into_vec().unwrap().len(), 64); + } + + #[test] + fn test_sign_message_rejects_transaction_payloads() { + let bytes = decode_base64(SINGLE_SIG_TX).unwrap(); + let result = SolanaChainSigner.sign_message(&bytes, &TEST_PRIVATE_KEY); + + assert_eq!(result.unwrap_err().to_string(), format!("Invalid input: {SIGN_MESSAGE_PAYLOAD_REJECTION}")); + + let transaction = VersionedTransaction::deserialize_with_version(&bytes).unwrap(); + let message = transaction.serialize_message().unwrap(); + let result = SolanaChainSigner.sign_message(&message, &TEST_PRIVATE_KEY); + + assert_eq!(result.unwrap_err().to_string(), format!("Invalid input: {SIGN_MESSAGE_PAYLOAD_REJECTION}")); + + let message = mock_legacy_transaction().serialize_message().unwrap(); + let result = SolanaChainSigner.sign_message(&message, &TEST_PRIVATE_KEY); + + assert_eq!(result.unwrap_err().to_string(), format!("Invalid input: {SIGN_MESSAGE_PAYLOAD_REJECTION}")); + } +} diff --git a/core/crates/gem_solana/src/signer/instructions/mod.rs b/core/crates/gem_solana/src/signer/instructions/mod.rs new file mode 100644 index 0000000000..56c40b2a32 --- /dev/null +++ b/core/crates/gem_solana/src/signer/instructions/mod.rs @@ -0,0 +1,10 @@ +mod nft_transfer; +mod stake; +mod stake_account; +mod token_transfer; +mod transfer; + +pub(super) use nft_transfer::nft_transfer; +pub(super) use stake::stake; +pub(super) use token_transfer::token_transfer; +pub(super) use transfer::native_transfer; diff --git a/core/crates/gem_solana/src/signer/instructions/nft_transfer.rs b/core/crates/gem_solana/src/signer/instructions/nft_transfer.rs new file mode 100644 index 0000000000..171acb8fac --- /dev/null +++ b/core/crates/gem_solana/src/signer/instructions/nft_transfer.rs @@ -0,0 +1,332 @@ +use crate::{METAPLEX_CORE_PROGRAM, METAPLEX_PROGRAM, SYSTEM_PROGRAM_ID, SYSVAR_INSTRUCTIONS_ID, get_token_program_by_id, metaplex::metadata::Metadata, signer::transaction}; +use primitives::{NFTAsset, SignerError, SignerInput, SolanaNftStandard, SolanaTokenProgramId, TransactionLoadMetadata, contract_constants::SOLANA_METAPLEX_AUTH_RULES_PROGRAM_ID}; +use solana_primitives::{ + AccountMeta, Instruction, Pubkey, + instructions::{ + associated_token::get_associated_token_address_with_program_id, + memo::memo, + program_ids::{ASSOCIATED_TOKEN_PROGRAM_ID, system_program}, + }, +}; + +use super::token_transfer::spl_transfer_checked; + +const MPL_CORE_TRANSFER_V1: u8 = 14; +const MPL_TOKEN_METADATA_TRANSFER_V1: u8 = 49; + +pub(in crate::signer) fn nft_transfer(input: &SignerInput, sender: Pubkey) -> Result, SignerError> { + let nft_asset = input.input_type.get_nft_asset().map_err(SignerError::invalid_input)?; + let TransactionLoadMetadata::Solana { token_program, nft, .. } = &input.metadata else { + return Err(SignerError::invalid_input("expected Solana metadata")); + }; + let standard = nft.as_ref().ok_or_else(|| SignerError::invalid_input("missing Solana NFT standard"))?; + + match standard { + SolanaNftStandard::Core { collection } => metaplex_core_transfer(input, sender, nft_asset, collection.as_deref()), + SolanaNftStandard::NonFungible => spl_nft_transfer(input, sender, nft_asset, spl_program(token_program.as_ref())?), + SolanaNftStandard::ProgrammableNonFungible { rule_set } => { + metaplex_token_metadata_transfer(input, sender, nft_asset, spl_program(token_program.as_ref())?, rule_set.as_deref()) + } + } +} + +fn spl_program(token_program: Option<&SolanaTokenProgramId>) -> Result<&SolanaTokenProgramId, SignerError> { + token_program.ok_or_else(|| SignerError::invalid_input("missing SPL token program for NFT")) +} + +fn spl_nft_transfer(input: &SignerInput, sender: Pubkey, nft_asset: &NFTAsset, token_program: &SolanaTokenProgramId) -> Result, SignerError> { + let token_program_id = Pubkey::from_base58(get_token_program_by_id(token_program.clone())).map_err(SignerError::from_display)?; + let mint = Pubkey::from_base58(&nft_asset.token_id).map_err(SignerError::from_display)?; + spl_transfer_checked(input, sender, mint, 1, 0, token_program_id) +} + +fn metaplex_token_metadata_transfer( + input: &SignerInput, + sender: Pubkey, + nft_asset: &NFTAsset, + spl_token_program: &SolanaTokenProgramId, + rule_set: Option<&str>, +) -> Result, SignerError> { + let mpl_program = Pubkey::from_base58(METAPLEX_PROGRAM).map_err(SignerError::from_display)?; + let token_program = Pubkey::from_base58(get_token_program_by_id(spl_token_program.clone())).map_err(SignerError::from_display)?; + let ata_program = Pubkey::from_base58(ASSOCIATED_TOKEN_PROGRAM_ID).map_err(SignerError::from_display)?; + let sysvar_instructions = Pubkey::from_base58(SYSVAR_INSTRUCTIONS_ID).map_err(SignerError::from_display)?; + let mint = Pubkey::from_base58(&nft_asset.token_id).map_err(SignerError::from_display)?; + let recipient = Pubkey::from_base58(&input.destination_address).map_err(SignerError::from_display)?; + + let sender_token_address = input + .metadata + .get_sender_token_address() + .map_err(SignerError::from_display)? + .ok_or_else(|| SignerError::invalid_input("missing sender token address"))?; + let sender_token_address = Pubkey::from_base58(&sender_token_address).map_err(SignerError::from_display)?; + let recipient_token_address = match input.metadata.get_recipient_token_address().map_err(SignerError::from_display)? { + Some(address) => Pubkey::from_base58(&address).map_err(SignerError::from_display)?, + None => get_associated_token_address_with_program_id(&recipient, &mint, &token_program), + }; + + let metadata_pda = Metadata::find_pda(mint).ok_or_else(|| SignerError::invalid_input("failed to derive metadata PDA"))?.0; + let master_edition = Metadata::find_master_edition_pda(mint) + .ok_or_else(|| SignerError::invalid_input("failed to derive master edition PDA"))? + .0; + let source_token_record = Metadata::find_token_record_pda(mint, sender_token_address) + .ok_or_else(|| SignerError::invalid_input("failed to derive source token record PDA"))? + .0; + let destination_token_record = Metadata::find_token_record_pda(mint, recipient_token_address) + .ok_or_else(|| SignerError::invalid_input("failed to derive destination token record PDA"))? + .0; + + let (auth_rules_program, auth_rules) = match rule_set { + Some(rule_set) => { + let program = Pubkey::from_base58(SOLANA_METAPLEX_AUTH_RULES_PROGRAM_ID).map_err(SignerError::from_display)?; + let rules = Pubkey::from_base58(rule_set).map_err(|_| SignerError::invalid_input(format!("invalid Solana pNFT rule set: {rule_set}")))?; + (program, rules) + } + None => (mpl_program, mpl_program), + }; + + let mut data = Vec::with_capacity(11); + data.push(MPL_TOKEN_METADATA_TRANSFER_V1); + data.push(0); + data.extend_from_slice(&1u64.to_le_bytes()); + data.push(0); + + let mut instructions = transaction::compute_budget_instructions(&input.fee)?; + if let Some(memo_text) = input.get_memo() { + instructions.push(memo(memo_text, &[])); + } + instructions.push(Instruction { + program_id: mpl_program, + accounts: vec![ + AccountMeta::new_writable(sender_token_address), + AccountMeta::new_readonly(sender), + AccountMeta::new_writable(recipient_token_address), + AccountMeta::new_readonly(recipient), + AccountMeta::new_readonly(mint), + AccountMeta::new_writable(metadata_pda), + AccountMeta::new_readonly(master_edition), + AccountMeta::new_writable(source_token_record), + AccountMeta::new_writable(destination_token_record), + AccountMeta::new_signer(sender), + AccountMeta::new_signer_writable(sender), + AccountMeta::new_readonly(system_program()), + AccountMeta::new_readonly(sysvar_instructions), + AccountMeta::new_readonly(token_program), + AccountMeta::new_readonly(ata_program), + AccountMeta::new_readonly(auth_rules_program), + AccountMeta::new_readonly(auth_rules), + ], + data, + }); + Ok(instructions) +} + +fn metaplex_core_transfer(input: &SignerInput, sender: Pubkey, nft_asset: &NFTAsset, collection: Option<&str>) -> Result, SignerError> { + let core_program = Pubkey::from_base58(METAPLEX_CORE_PROGRAM).map_err(SignerError::from_display)?; + let system_program_id = Pubkey::from_base58(SYSTEM_PROGRAM_ID).map_err(SignerError::from_display)?; + let asset = Pubkey::from_base58(&nft_asset.token_id).map_err(SignerError::from_display)?; + let new_owner = Pubkey::from_base58(&input.destination_address).map_err(SignerError::from_display)?; + let collection_account = match collection { + Some(address) => Pubkey::from_base58(address).map_err(|_| SignerError::invalid_input(format!("invalid Solana Core NFT collection: {address}")))?, + None => core_program, + }; + + let mut instructions = transaction::compute_budget_instructions(&input.fee)?; + if let Some(memo_text) = input.get_memo() { + instructions.push(memo(memo_text, &[])); + } + instructions.push(Instruction { + program_id: core_program, + accounts: vec![ + AccountMeta::new_writable(asset), + AccountMeta::new_readonly(collection_account), + AccountMeta::new_signer_writable(sender), + AccountMeta::new_signer(sender), + AccountMeta::new_readonly(new_owner), + AccountMeta::new_readonly(system_program_id), + AccountMeta::new_readonly(core_program), + ], + data: vec![MPL_CORE_TRANSFER_V1, 0], + }); + Ok(instructions) +} + +#[cfg(test)] +mod tests { + use crate::{ + METAPLEX_CORE_PROGRAM, METAPLEX_PROGRAM, SYSTEM_PROGRAM_ID, SYSVAR_INSTRUCTIONS_ID, TOKEN_PROGRAM, + signer::{SolanaChainSigner, testkit::*}, + }; + use primitives::contract_constants::SOLANA_METAPLEX_AUTH_RULES_PROGRAM_ID; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use primitives::{ + Asset, Chain, ChainSigner, GasPriceType, NFTAsset, NFTAssetId, NFTImages, NFTResource, NFTType, SignerInput, SolanaNftStandard, SolanaTokenProgramId, TransactionFee, + TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, + }; + use solana_primitives::{ + Pubkey, + instructions::{ + associated_token::get_associated_token_address_with_program_id, + program_ids::{ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, token_program}, + }, + }; + + const NFT_MINT: &str = "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy"; + const PNFT_RULE_SET: &str = "Brq4ESPuwPNwBhzvEcY2uM1fXTB171yWuem6U8jEiHiY"; + const CORE_ASSET: &str = "HpYF5mAtjshGy93ce4FWjKg4XkFUocyAKm5BMdQ18d1K"; + const CORE_COLLECTION: &str = "5pQfZttNUtaj8sySRY9RsdtB81aEAQDh2vnacpxiwTpT"; + + fn transfer_checked_data() -> Vec { + let mut data = vec![12]; + data.extend_from_slice(&1u64.to_le_bytes()); + data.push(0); + data + } + + fn nft_asset(token_id: &str, collection: &str) -> NFTAsset { + let id = NFTAssetId::new(Chain::Solana, collection, token_id); + NFTAsset { + id: id.clone(), + collection_id: id.get_collection_id(), + contract_address: Some(token_id.to_string()), + token_id: token_id.to_string(), + token_type: NFTType::SPL, + name: "Solana NFT".to_string(), + description: None, + chain: Chain::Solana, + resource: NFTResource::new(String::new(), String::new()), + images: NFTImages { + preview: NFTResource::new(String::new(), String::new()), + }, + attributes: vec![], + } + } + + fn signer_input(nft_asset: NFTAsset, metadata: TransactionLoadMetadata) -> SignerInput { + let input = TransactionLoadInput { + input_type: TransactionInputType::TransferNft(Asset::from_chain(Chain::Solana), nft_asset), + sender_address: sender_address(), + destination_address: TEST_RECIPIENT.to_string(), + value: "1".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata, + }; + SignerInput::new(input, TransactionFee::default()) + } + + #[test] + fn test_sign_spl_nft_transfer() { + let signer = SolanaChainSigner; + let input = signer_input( + nft_asset(NFT_MINT, NFT_MINT), + TransactionLoadMetadata::mock_solana_nft(TEST_SENDER_TOKEN_ADDRESS, SolanaTokenProgramId::Token, SolanaNftStandard::NonFungible), + ); + + let result = signer.sign_nft_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + + let transaction = crate::decode_transaction(&result).unwrap(); + let mint = Pubkey::from_base58(NFT_MINT).unwrap(); + let recipient = Pubkey::from_base58(TEST_RECIPIENT).unwrap(); + let recipient_token_address = get_associated_token_address_with_program_id(&recipient, &mint, &token_program()); + assert_eq!( + (0..transaction.instructions().len()).map(|index| program_id(&transaction, index)).collect::>(), + vec![ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID] + ); + assert_eq!(transaction.instructions()[0].data, vec![1]); + assert_eq!(account_key(&transaction, 0, 1), recipient_token_address); + assert_eq!(account_key(&transaction, 1, 0).to_string(), TEST_SENDER_TOKEN_ADDRESS); + assert_eq!(account_key(&transaction, 1, 1), mint); + assert_eq!(account_key(&transaction, 1, 2), recipient_token_address); + assert_eq!(transaction.instructions()[1].data, transfer_checked_data()); + } + + #[test] + fn test_sign_p_nft_transfer() { + let signer = SolanaChainSigner; + let input = signer_input( + nft_asset(NFT_MINT, NFT_MINT), + TransactionLoadMetadata::mock_solana_nft( + TEST_SENDER_TOKEN_ADDRESS, + SolanaTokenProgramId::Token, + SolanaNftStandard::ProgrammableNonFungible { + rule_set: Some(PNFT_RULE_SET.to_string()), + }, + ), + ); + + let result = signer.sign_nft_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + + let transaction = crate::decode_transaction(&result).unwrap(); + assert_eq!(program_id(&transaction, 0), METAPLEX_PROGRAM); + let inst_data = &transaction.instructions()[0].data; + let mut expected = vec![49, 0]; + expected.extend_from_slice(&1u64.to_le_bytes()); + expected.push(0); + assert_eq!(inst_data, &expected); + + let mint = Pubkey::from_base58(NFT_MINT).unwrap(); + let source = Pubkey::from_base58(TEST_SENDER_TOKEN_ADDRESS).unwrap(); + let recipient = Pubkey::from_base58(TEST_RECIPIENT).unwrap(); + let recipient_ata = get_associated_token_address_with_program_id(&recipient, &mint, &Pubkey::from_base58(TOKEN_PROGRAM).unwrap()); + let metadata_pda = crate::metaplex::metadata::Metadata::find_pda(mint).unwrap().0; + let master_edition = crate::metaplex::metadata::Metadata::find_master_edition_pda(mint).unwrap().0; + let source_record = crate::metaplex::metadata::Metadata::find_token_record_pda(mint, source).unwrap().0; + let dest_record = crate::metaplex::metadata::Metadata::find_token_record_pda(mint, recipient_ata).unwrap().0; + let auth_rules = Pubkey::from_base58(PNFT_RULE_SET).unwrap(); + let auth_rules_program = Pubkey::from_base58(SOLANA_METAPLEX_AUTH_RULES_PROGRAM_ID).unwrap(); + let sysvar_instructions = Pubkey::from_base58(SYSVAR_INSTRUCTIONS_ID).unwrap(); + let system_program_pk = Pubkey::from_base58(SYSTEM_PROGRAM_ID).unwrap(); + let sender = Pubkey::from_base58(&sender_address()).unwrap(); + + assert_eq!(account_key(&transaction, 0, 0), source); + assert_eq!(account_key(&transaction, 0, 1), sender); + assert_eq!(account_key(&transaction, 0, 2), recipient_ata); + assert_eq!(account_key(&transaction, 0, 3), recipient); + assert_eq!(account_key(&transaction, 0, 4), mint); + assert_eq!(account_key(&transaction, 0, 5), metadata_pda); + assert_eq!(account_key(&transaction, 0, 6), master_edition); + assert_eq!(account_key(&transaction, 0, 7), source_record); + assert_eq!(account_key(&transaction, 0, 8), dest_record); + assert_eq!(account_key(&transaction, 0, 9), sender); + assert_eq!(account_key(&transaction, 0, 10), sender); + assert_eq!(account_key(&transaction, 0, 11), system_program_pk); + assert_eq!(account_key(&transaction, 0, 12), sysvar_instructions); + assert_eq!(account_key(&transaction, 0, 13).to_string(), TOKEN_PROGRAM); + assert_eq!(account_key(&transaction, 0, 14).to_string(), ASSOCIATED_TOKEN_PROGRAM_ID); + assert_eq!(account_key(&transaction, 0, 15), auth_rules_program); + assert_eq!(account_key(&transaction, 0, 16), auth_rules); + } + + #[test] + fn test_sign_core_nft_transfer() { + let signer = SolanaChainSigner; + let input = signer_input(nft_asset(CORE_ASSET, CORE_COLLECTION), TransactionLoadMetadata::mock_solana_core_nft(Some(CORE_COLLECTION))); + + let result = signer.sign_nft_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + + let transaction = crate::decode_transaction(&result).unwrap(); + let core_program = Pubkey::from_base58(METAPLEX_CORE_PROGRAM).unwrap(); + let system_program_pk = Pubkey::from_base58(SYSTEM_PROGRAM_ID).unwrap(); + let asset = Pubkey::from_base58(CORE_ASSET).unwrap(); + let collection = Pubkey::from_base58(CORE_COLLECTION).unwrap(); + let recipient = Pubkey::from_base58(TEST_RECIPIENT).unwrap(); + let sender = Pubkey::from_base58(&sender_address()).unwrap(); + assert_eq!(transaction.instructions().len(), 1); + assert_eq!(program_id(&transaction, 0), METAPLEX_CORE_PROGRAM); + assert_eq!(transaction.instructions()[0].data, vec![14, 0]); + assert_eq!(account_key(&transaction, 0, 0), asset); + assert_eq!(account_key(&transaction, 0, 1), collection); + assert_eq!(account_key(&transaction, 0, 2), sender); + assert_eq!(account_key(&transaction, 0, 3), sender); + assert_eq!(account_key(&transaction, 0, 4), recipient); + assert_eq!(account_key(&transaction, 0, 5), system_program_pk); + assert_eq!(account_key(&transaction, 0, 6), core_program); + + let input = signer_input(nft_asset(CORE_ASSET, CORE_ASSET), TransactionLoadMetadata::mock_solana_core_nft(None)); + let result = signer.sign_nft_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + let transaction = crate::decode_transaction(&result).unwrap(); + assert_eq!(account_key(&transaction, 0, 1), core_program); + } +} diff --git a/core/crates/gem_solana/src/signer/instructions/stake.rs b/core/crates/gem_solana/src/signer/instructions/stake.rs new file mode 100644 index 0000000000..91e9363b81 --- /dev/null +++ b/core/crates/gem_solana/src/signer/instructions/stake.rs @@ -0,0 +1,180 @@ +use super::stake_account; +use crate::signer::transaction; +use primitives::{SignerError, SignerInput, StakeType}; +use solana_primitives::{Instruction, Pubkey, instructions::memo::memo}; + +pub(in crate::signer) fn stake(input: &SignerInput, sender: Pubkey) -> Result, SignerError> { + let stake_type = input.input_type.get_stake_type().map_err(SignerError::invalid_input)?; + let mut instructions = transaction::compute_budget_instructions(&input.fee)?; + match stake_type { + StakeType::Stake(validator) => { + let validator = Pubkey::from_base58(&validator.id).map_err(SignerError::from_display)?; + let stake_account = stake_account::from_blockhash(&sender, input)?; + let seed = stake_account::seed_from_blockhash(input)?; + instructions.extend(stake_account::delegate_instructions(sender, validator, stake_account, seed, input.value_as_u64()?)?); + if let Some(memo_text) = input.get_memo() { + instructions.push(memo(memo_text, &[])); + } + } + StakeType::Unstake(delegation) => { + let stake_account = Pubkey::from_base58(&delegation.base.delegation_id).map_err(SignerError::from_display)?; + instructions.push(stake_account::deactivate_instruction(stake_account, sender)?); + } + StakeType::Withdraw(delegation) => { + let stake_account = Pubkey::from_base58(&delegation.base.delegation_id).map_err(SignerError::from_display)?; + instructions.push(stake_account::withdraw_instruction(stake_account, sender, sender, input.value_as_u64()?)?); + } + StakeType::Redelegate(_) | StakeType::Rewards(_) => { + return Err(SignerError::invalid_input("unsupported Solana stake action")); + } + StakeType::Freeze(_) | StakeType::Unfreeze(_) => { + return Err(SignerError::invalid_input("Solana does not support freeze operations")); + } + } + Ok(instructions) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signer::{SolanaChainSigner, testkit::*}; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use primitives::{ + Asset, Chain, ChainSigner, Delegation, DelegationValidator, GasPriceType, SignerInput, StakeType, TransactionFee, TransactionInputType, TransactionLoadInput, + }; + use solana_primitives::{ + Pubkey, + instructions::program_ids::{MEMO_PROGRAM_ID, SYSTEM_PROGRAM_ID as SYSTEM_PROGRAM_ID_STRING}, + }; + + // https://github.com/trustwallet/wallet-core/blob/master/rust/tw_tests/tests/chains/solana/solana_sign.rs + const REFERENCE_STAKE_PRIVATE_KEY: &str = "AevJ4EWcvQ6dptBDvF2Ri5pU6QSBjkzSGHMfbLFKa746"; + const REFERENCE_STAKE_ACCOUNT: &str = "6XMLCn47d5kPi3g4YcjqFvDuxWnpVADpN2tXpeRc4XUB"; + const REFERENCE_VALIDATOR: &str = "4jpwTqt1qZoR7u6u639z2AngYFGN3nakvKhowcnRZDEC"; + const REFERENCE_DELEGATE_STAKE_TX: &str = concat!( + "j24mVM9Zgu5vDZhPLGGuCRXQnP9djNtxdHh4txN3S7dwJsNNL5fbhzGpPgSUAcLGoMVCfF9TuqTYfpfJnb4sJFe1ahM8yPL5HwuKL6py5AZJFi8SWx9fvaVB699dCPo1GT3JoEBLPCZ9o2jQtnwzLkzTYJnKv2axqhKWFE2sz6TBA5J39eZcjMFUYgyxz6Q5S4MWqYQCb8UET2NAEZoKcfy7j8N25WXL6Gj4j3hBZjpHQQNaGaNEprEqyma3ZuVhpGiCALSsuzVLX3wZVo4icXwe952deMFA4tH3BK1jcSQCgfmcKDJ9nd7bdrnUUs4BoMdF1uDZB5LxE2UH8QiqtYvaUcorF4SJ3gPxM5ykbyPsNK1cSYZF9NMpW2GofyC17eELwnHQTQB2kqphxJZu7BahvkwiDPPeeydiXAkBspJ3nc3PCBujv6WJw22ZHw5j6zAP8ZGnCW44pqtWD5qifF9tTKhySKdANNiWifs3tSCCPQqjfJXu14drNinR6VG8rJxS1qgmRYiRQUa7m1vtoaZFRN5qKUeAfoFKkAVaNnMdwgsNqNH4dqBodTCJFs1LkYwhgRZdZGbwXTn1j7vpR3DSnv4g72i2H556srzK53jdUmdv6yfxt516XDSshqZtHnKZ1tudxKjBXwsqT3imDiZFVka9wKWUAYMCi4XZ79CY6Xpsd9c18U2e9TCngQmgkTATFgrqysfraokNffgqWxvsPMugksbvbPjJs3iCzByvphkC9p7hCf6LwbeF8XnVB91EAgRDA4VLE1f9wkcq5zjy879YWJ4r516h3PQszTz1EaJXNAXdbk5Em7eyuuabGP1Q3nijFTL2yhMDsXpgrjAuEAABNxFMd4J1JRMaic615mHrhwociksrsfQK" + ); + const REFERENCE_DEACTIVATE_STAKE_TX: &str = "6x3fSstNz4GpPxmT5jHXwyD62uyJMKaPWeBDNNcwXZA9NJ3E7KavCXPNUd8ZYTX5VpkfHKGszkwzM6AdAp4giLD29jvWdNYjkV1Nvb42xFwGD6ryMPZzXkJijaRTrA7SvPTDSRU2haGVmorqkywAXLQUCw47NmBUfLTb5gDcKoBeaAsahckv1eCE746thJVTg2dQNvUTULKF6xckUg7kwFkcUuRe4HCcRgrKcNAUKLR2rEM3brVQkUyAaAtMMtc3gVDXxxpbtW5Fa9wGaEnh31FdRo4z5YBzAUaz7vcrvzF2j81KCPTVnYyTmeJzCzJafzCVCtw"; + const REFERENCE_WITHDRAW_STAKE_TX: &str = "gxr4o1trVP8DGG8UC21AA964YqAPFA3rBCF9MwmBQpn5fDtcujM9wp1gzT466MxWGR8wMciS6dSL771q29eURrEEuvhJzRaFDGPLgVB3UL4gd4T2amPQkR4Dzq5drKEtPJRBR86KVVc2kjDsbWNpdL8S7pZqW3VUijAbm9TS8ezG8NExSCkhxExKhUjXWWguEL4qXra7s2JZfhtmvuJneWnEY3isUVfC9knWtGNwpNFvRvzbH2sgHzwtSsD7mkYrBJoazLCwT8r9yypxycHL41XcGtH425MA16kVSunvvBfzG9PzBTS65YJBs64tzttasCU9uEphkwgmfrmoEC8iKt8xD47Ra79RyXd95yURsaxvpb1tVAH8kMNtj8iV1Pfm"; + + fn stake_signer_input(private_key: &[u8], stake_type: StakeType, value: &str) -> SignerInput { + let input = TransactionLoadInput { + input_type: TransactionInputType::Stake(Asset::mock_sol(), stake_type), + sender_address: sender_address_for_key(private_key), + destination_address: String::new(), + value: value.to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: solana_metadata(None, None, None), + }; + SignerInput::new(input, TransactionFee::default()) + } + + fn stake_data(instruction: u32) -> Vec { + instruction.to_le_bytes().to_vec() + } + + fn withdraw_stake_data(lamports: u64) -> Vec { + let mut data = stake_data(4); + data.extend_from_slice(&lamports.to_le_bytes()); + data + } + + #[test] + fn test_sign_stake() { + let signer = SolanaChainSigner; + let validator = DelegationValidator::stake(Chain::Solana, TEST_RECIPIENT.to_string(), "validator".to_string(), true, 0.0, 0.0); + let input = TransactionLoadInput { + input_type: TransactionInputType::Stake(Asset::mock_sol(), StakeType::Stake(validator)), + sender_address: sender_address(), + destination_address: String::new(), + value: "42".to_string(), + gas_price: GasPriceType::regular(0), + memo: Some("stake memo".to_string()), + is_max_value: false, + metadata: solana_metadata(None, None, None), + }; + let input = SignerInput::new(input, TransactionFee::default()); + + let result = signer.sign_stake(&input, &TEST_PRIVATE_KEY).unwrap(); + + let transaction = crate::decode_transaction(&result[0]).unwrap(); + let stake_account = stake_account::from_blockhash(&Pubkey::from_base58(&sender_address()).unwrap(), &input).unwrap(); + assert_eq!(transaction.signatures().len(), 1); + assert_eq!( + (0..transaction.instructions().len()).map(|index| program_id(&transaction, index)).collect::>(), + vec![SYSTEM_PROGRAM_ID_STRING, crate::STAKE_PROGRAM_ID, crate::STAKE_PROGRAM_ID, MEMO_PROGRAM_ID] + ); + assert_eq!(account_key(&transaction, 0, 1), stake_account); + assert_eq!(transaction.instructions()[0].data[0..4], 3u32.to_le_bytes()); + assert_eq!(transaction.instructions()[1].data, { + let mut data = stake_data(0); + let authority = Pubkey::from_base58(&sender_address()).unwrap(); + data.extend_from_slice(authority.as_bytes()); + data.extend_from_slice(authority.as_bytes()); + data.extend_from_slice(&0i64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(Pubkey::new([0u8; 32]).as_bytes()); + data + }); + assert_eq!(transaction.instructions()[2].data, stake_data(2)); + assert_eq!(transaction.instructions()[3].accounts, Vec::::new()); + assert_eq!(transaction.instructions()[3].data, b"stake memo"); + + let delegation = Delegation::mock_with_id(TEST_RECIPIENT.to_string()); + let input = TransactionLoadInput { + input_type: TransactionInputType::Stake(Asset::mock_sol(), StakeType::Unstake(delegation.clone())), + sender_address: sender_address(), + destination_address: String::new(), + value: "0".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: solana_metadata(None, None, None), + }; + let input = SignerInput::new(input, TransactionFee::default()); + let result = signer.sign_stake(&input, &TEST_PRIVATE_KEY).unwrap(); + let transaction = crate::decode_transaction(&result[0]).unwrap(); + assert_eq!(program_id(&transaction, 0), crate::STAKE_PROGRAM_ID); + assert_eq!(transaction.instructions()[0].data, stake_data(5)); + assert_eq!(account_key(&transaction, 0, 0), Pubkey::from_base58(TEST_RECIPIENT).unwrap()); + + let input = TransactionLoadInput { + input_type: TransactionInputType::Stake(Asset::mock_sol(), StakeType::Withdraw(delegation)), + sender_address: sender_address(), + destination_address: String::new(), + value: "55".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: solana_metadata(None, None, None), + }; + let input = SignerInput::new(input, TransactionFee::default()); + let result = signer.sign_stake(&input, &TEST_PRIVATE_KEY).unwrap(); + let transaction = crate::decode_transaction(&result[0]).unwrap(); + assert_eq!(program_id(&transaction, 0), crate::STAKE_PROGRAM_ID); + assert_eq!(transaction.instructions()[0].data, withdraw_stake_data(55)); + assert_eq!(account_key(&transaction, 0, 0), Pubkey::from_base58(TEST_RECIPIENT).unwrap()); + assert_eq!(account_key(&transaction, 0, 1), Pubkey::from_base58(&sender_address()).unwrap()); + } + + #[test] + fn test_sign_reference_stake() { + let signer = SolanaChainSigner; + let private_key = private_key_base58(REFERENCE_STAKE_PRIVATE_KEY); + let validator = DelegationValidator::stake(Chain::Solana, REFERENCE_VALIDATOR.to_string(), "validator".to_string(), true, 0.0, 0.0); + let stake = stake_signer_input(&private_key, StakeType::Stake(validator), "42"); + let result = signer.sign_stake(&stake, &private_key).unwrap(); + assert_eq!(base58_transaction(&result[0]), REFERENCE_DELEGATE_STAKE_TX); + + let delegation = Delegation::mock_with_id(REFERENCE_STAKE_ACCOUNT.to_string()); + let unstake = stake_signer_input(&private_key, StakeType::Unstake(delegation.clone()), "0"); + let result = signer.sign_stake(&unstake, &private_key).unwrap(); + assert_eq!(base58_transaction(&result[0]), REFERENCE_DEACTIVATE_STAKE_TX); + + let withdraw = stake_signer_input(&private_key, StakeType::Withdraw(delegation), "42"); + let result = signer.sign_stake(&withdraw, &private_key).unwrap(); + assert_eq!(base58_transaction(&result[0]), REFERENCE_WITHDRAW_STAKE_TX); + } +} diff --git a/core/crates/gem_solana/src/signer/instructions/stake_account.rs b/core/crates/gem_solana/src/signer/instructions/stake_account.rs new file mode 100644 index 0000000000..8d8c52db85 --- /dev/null +++ b/core/crates/gem_solana/src/signer/instructions/stake_account.rs @@ -0,0 +1,224 @@ +use crate::{STAKE_PROGRAM_ID, SYSTEM_PROGRAM_ID, SYSVAR_CLOCK_ID, SYSVAR_RENT_ID}; +use primitives::{SignerError, SignerInput}; +use sha2::{Digest, Sha256}; +use solana_primitives::{AccountMeta, Instruction, Pubkey}; + +const SYSVAR_STAKE_HISTORY_ID: &str = "SysvarStakeHistory1111111111111111111111111"; +const STAKE_CONFIG_ID: &str = "StakeConfig11111111111111111111111111111111"; +const DEFAULT_STAKE_ACCOUNT_SPACE: u64 = 200; +const SYSTEM_CREATE_ACCOUNT_WITH_SEED: u32 = 3; +const STAKE_INITIALIZE: u32 = 0; +const STAKE_DELEGATE: u32 = 2; +const STAKE_WITHDRAW: u32 = 4; +const STAKE_DEACTIVATE: u32 = 5; + +pub(super) fn delegate_instructions(sender: Pubkey, validator: Pubkey, stake_account: Pubkey, seed: String, lamports: u64) -> Result, SignerError> { + Ok(vec![ + create_with_seed_instruction(sender, stake_account, seed, lamports)?, + initialize_instruction(stake_account, sender)?, + delegate_instruction(stake_account, validator, sender)?, + ]) +} + +pub(super) fn deactivate_instruction(stake_account: Pubkey, authority: Pubkey) -> Result { + Ok(Instruction { + program_id: program()?, + accounts: vec![ + AccountMeta { + pubkey: stake_account, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: Pubkey::from_base58(SYSVAR_CLOCK_ID).map_err(SignerError::from_display)?, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: authority, + is_signer: true, + is_writable: false, + }, + ], + data: STAKE_DEACTIVATE.to_le_bytes().to_vec(), + }) +} + +pub(super) fn withdraw_instruction(stake_account: Pubkey, recipient: Pubkey, authority: Pubkey, lamports: u64) -> Result { + let mut data = Vec::new(); + data.extend_from_slice(&STAKE_WITHDRAW.to_le_bytes()); + data.extend_from_slice(&lamports.to_le_bytes()); + + Ok(Instruction { + program_id: program()?, + accounts: vec![ + AccountMeta { + pubkey: stake_account, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: recipient, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: Pubkey::from_base58(SYSVAR_CLOCK_ID).map_err(SignerError::from_display)?, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: Pubkey::from_base58(SYSVAR_STAKE_HISTORY_ID).map_err(SignerError::from_display)?, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: authority, + is_signer: true, + is_writable: false, + }, + ], + data, + }) +} + +pub(super) fn from_blockhash(sender: &Pubkey, input: &SignerInput) -> Result { + let seed = seed_from_blockhash(input)?; + let stake_program = program()?; + let mut hasher = Sha256::new(); + hasher.update(sender.as_bytes()); + hasher.update(seed.as_bytes()); + hasher.update(stake_program.as_bytes()); + Ok(Pubkey::new(hasher.finalize().into())) +} + +pub(super) fn seed_from_blockhash(input: &SignerInput) -> Result { + let block_hash = input.metadata.get_block_hash()?; + block_hash + .get(..block_hash.len().min(32)) + .map(String::from) + .ok_or_else(|| SignerError::invalid_input("invalid Solana block hash")) +} + +fn create_with_seed_instruction(sender: Pubkey, stake_account: Pubkey, seed: String, lamports: u64) -> Result { + let stake_program = program()?; + let mut data = Vec::new(); + data.extend_from_slice(&SYSTEM_CREATE_ACCOUNT_WITH_SEED.to_le_bytes()); + data.extend_from_slice(sender.as_bytes()); + // Solana system instructions use bincode string encoding for seeds. + data.extend_from_slice(&(seed.len() as u64).to_le_bytes()); + data.extend_from_slice(seed.as_bytes()); + data.extend_from_slice(&lamports.to_le_bytes()); + data.extend_from_slice(&DEFAULT_STAKE_ACCOUNT_SPACE.to_le_bytes()); + data.extend_from_slice(stake_program.as_bytes()); + + Ok(Instruction { + program_id: Pubkey::from_base58(SYSTEM_PROGRAM_ID).map_err(SignerError::from_display)?, + accounts: vec![ + AccountMeta { + pubkey: sender, + is_signer: true, + is_writable: true, + }, + AccountMeta { + pubkey: stake_account, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: sender, + is_signer: true, + is_writable: false, + }, + ], + data, + }) +} + +fn initialize_instruction(stake_account: Pubkey, authority: Pubkey) -> Result { + let mut data = Vec::new(); + data.extend_from_slice(&STAKE_INITIALIZE.to_le_bytes()); + data.extend_from_slice(authority.as_bytes()); + data.extend_from_slice(authority.as_bytes()); + data.extend_from_slice(&0i64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(Pubkey::new([0u8; 32]).as_bytes()); + + Ok(Instruction { + program_id: program()?, + accounts: vec![ + AccountMeta { + pubkey: stake_account, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: Pubkey::from_base58(SYSVAR_RENT_ID).map_err(SignerError::from_display)?, + is_signer: false, + is_writable: false, + }, + ], + data, + }) +} + +fn delegate_instruction(stake_account: Pubkey, validator: Pubkey, authority: Pubkey) -> Result { + Ok(Instruction { + program_id: program()?, + accounts: vec![ + AccountMeta { + pubkey: stake_account, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: validator, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: Pubkey::from_base58(SYSVAR_CLOCK_ID).map_err(SignerError::from_display)?, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: Pubkey::from_base58(SYSVAR_STAKE_HISTORY_ID).map_err(SignerError::from_display)?, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: Pubkey::from_base58(STAKE_CONFIG_ID).map_err(SignerError::from_display)?, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: authority, + is_signer: true, + is_writable: false, + }, + ], + data: STAKE_DELEGATE.to_le_bytes().to_vec(), + }) +} + +fn program() -> Result { + Pubkey::from_base58(STAKE_PROGRAM_ID).map_err(SignerError::from_display) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::SignerInput; + + #[test] + fn test_seed_from_blockhash() { + let valid_block_hash = "1".repeat(44); + assert_eq!(seed_from_blockhash(&SignerInput::mock_solana(&valid_block_hash)).unwrap(), "1".repeat(32)); + + let invalid_block_hash = format!("{}é", "1".repeat(31)); + assert_eq!( + seed_from_blockhash(&SignerInput::mock_solana(&invalid_block_hash)).unwrap_err().to_string(), + "Invalid input: invalid Solana block hash" + ); + } +} diff --git a/core/crates/gem_solana/src/signer/instructions/token_transfer.rs b/core/crates/gem_solana/src/signer/instructions/token_transfer.rs new file mode 100644 index 0000000000..60530f2ced --- /dev/null +++ b/core/crates/gem_solana/src/signer/instructions/token_transfer.rs @@ -0,0 +1,191 @@ +use crate::{get_token_program_by_id, signer::transaction}; +use primitives::{Asset, SignerError, SignerInput, SolanaTokenProgramId}; +use solana_primitives::{ + Instruction, Pubkey, + instructions::{ + associated_token::{create_associated_token_account_idempotent, get_associated_token_address_with_program_id}, + memo::memo, + token::transfer_checked_with_program_id, + }, +}; + +pub(in crate::signer) fn token_transfer(input: &SignerInput, sender: Pubkey) -> Result, SignerError> { + let asset = input.input_type.get_asset(); + let token_program_id = token_program_id(input)?; + let mint = Pubkey::from_base58(asset.id.get_token_id()?).map_err(SignerError::from_display)?; + let decimals = token_decimals(asset)?; + let amount = input.value_as_u64()?; + + spl_transfer_checked(input, sender, mint, amount, decimals, token_program_id) +} + +pub(in crate::signer::instructions) fn spl_transfer_checked( + input: &SignerInput, + sender: Pubkey, + mint: Pubkey, + amount: u64, + decimals: u8, + token_program_id: Pubkey, +) -> Result, SignerError> { + let sender_token_address = input + .metadata + .get_sender_token_address()? + .ok_or_else(|| SignerError::invalid_input("missing sender token address"))?; + let sender_token_address = Pubkey::from_base58(&sender_token_address).map_err(SignerError::from_display)?; + + let mut instructions = transaction::compute_budget_instructions(&input.fee)?; + let recipient_token_address = match input.metadata.get_recipient_token_address()? { + Some(recipient_token_address) => Pubkey::from_base58(&recipient_token_address).map_err(SignerError::from_display)?, + None => { + let recipient = Pubkey::from_base58(&input.destination_address).map_err(SignerError::from_display)?; + let recipient_token_address = get_associated_token_address_with_program_id(&recipient, &mint, &token_program_id); + instructions.push(create_associated_token_account_idempotent(&sender, &recipient, &mint, &token_program_id)); + recipient_token_address + } + }; + + if let Some(memo_text) = input.get_memo() { + instructions.push(memo(memo_text, &[])); + } + instructions.push(transfer_checked_with_program_id( + &sender_token_address, + &mint, + &recipient_token_address, + &sender, + amount, + decimals, + &token_program_id, + )); + Ok(instructions) +} + +fn token_program_id(input: &SignerInput) -> Result { + let asset = input.input_type.get_asset(); + let asset_program = + SolanaTokenProgramId::from_asset_type(&asset.asset_type).ok_or_else(|| SignerError::invalid_input(format!("unsupported Solana token type: {:?}", asset.asset_type)))?; + if let Some(metadata_program) = input.metadata.get_solana_token_program_id().map_err(SignerError::from_display)? + && metadata_program != asset_program + { + return Err(SignerError::invalid_input("Solana token program metadata does not match asset type")); + } + Pubkey::from_base58(get_token_program_by_id(asset_program)).map_err(SignerError::from_display) +} + +fn token_decimals(asset: &Asset) -> Result { + u8::try_from(asset.decimals).map_err(|_| SignerError::invalid_input("invalid Solana token decimals")) +} + +#[cfg(test)] +mod tests { + use crate::signer::{SolanaChainSigner, testkit::*}; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use primitives::{Asset, AssetId, AssetType, Chain, ChainSigner, GasPriceType, SignerInput, SolanaTokenProgramId, TransactionFee, TransactionInputType, TransactionLoadInput}; + use solana_primitives::{ + Pubkey, + instructions::{ + associated_token::get_associated_token_address_with_program_id, + program_ids::{ASSOCIATED_TOKEN_PROGRAM_ID, MEMO_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, token_program}, + }, + }; + + fn transfer_checked_data(amount: u64, decimals: u8) -> Vec { + let mut data = vec![12]; + data.extend_from_slice(&amount.to_le_bytes()); + data.push(decimals); + data + } + + #[test] + fn test_sign_token_transfer() { + let signer = SolanaChainSigner; + let input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::mock_spl_token()), + sender_address: sender_address(), + destination_address: TEST_RECIPIENT.to_string(), + value: "123456".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: solana_metadata(Some(TEST_SENDER_TOKEN_ADDRESS), None, Some(SolanaTokenProgramId::Token)), + }; + let input = SignerInput::new(input, TransactionFee::default()); + + let result = signer.sign_token_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + + let transaction = crate::decode_transaction(&result).unwrap(); + let mint = Pubkey::from_base58(Asset::mock_spl_token().id.get_token_id().unwrap()).unwrap(); + let recipient = Pubkey::from_base58(TEST_RECIPIENT).unwrap(); + let recipient_token_address = get_associated_token_address_with_program_id(&recipient, &mint, &token_program()); + assert_eq!( + (0..transaction.instructions().len()).map(|index| program_id(&transaction, index)).collect::>(), + vec![ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID] + ); + assert_eq!(transaction.instructions()[0].data, vec![1]); + assert_eq!(account_key(&transaction, 0, 1), recipient_token_address); + assert_eq!(account_key(&transaction, 1, 2), recipient_token_address); + assert_eq!(transaction.instructions()[1].data, transfer_checked_data(123456, 6)); + + let spl2022_asset = Asset::new( + AssetId::from_token(Chain::Solana, Asset::mock_spl_token().id.get_token_id().unwrap()), + "Token 2022".to_string(), + "T22".to_string(), + 6, + AssetType::SPL2022, + ); + let input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(spl2022_asset), + sender_address: sender_address(), + destination_address: TEST_RECIPIENT.to_string(), + value: "7".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: solana_metadata(Some(TEST_SENDER_TOKEN_ADDRESS), Some(TEST_SENDER_TOKEN_ADDRESS), Some(SolanaTokenProgramId::Token2022)), + }; + let input = SignerInput::new(input, TransactionFee::default()); + + let result = signer.sign_token_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + + let transaction = crate::decode_transaction(&result).unwrap(); + assert_eq!(transaction.instructions().len(), 1); + assert_eq!(program_id(&transaction, 0), TOKEN_2022_PROGRAM_ID); + assert_eq!(transaction.instructions()[0].data, transfer_checked_data(7, 6)); + + let mismatched_asset = Asset::mock_spl_token(); + let input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(mismatched_asset), + sender_address: sender_address(), + destination_address: TEST_RECIPIENT.to_string(), + value: "7".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: solana_metadata(Some(TEST_SENDER_TOKEN_ADDRESS), Some(TEST_SENDER_TOKEN_ADDRESS), Some(SolanaTokenProgramId::Token2022)), + }; + let input = SignerInput::new(input, TransactionFee::default()); + assert_eq!( + signer.sign_token_transfer(&input, &TEST_PRIVATE_KEY).unwrap_err().to_string(), + "Invalid input: Solana token program metadata does not match asset type" + ); + + let input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::mock_spl_token()), + sender_address: sender_address(), + destination_address: TEST_RECIPIENT.to_string(), + value: "123456".to_string(), + gas_price: GasPriceType::regular(0), + memo: Some("token memo".to_string()), + is_max_value: false, + metadata: solana_metadata(Some(TEST_SENDER_TOKEN_ADDRESS), Some(TEST_SENDER_TOKEN_ADDRESS), Some(SolanaTokenProgramId::Token)), + }; + let input = SignerInput::new(input, TransactionFee::default()); + let result = signer.sign_token_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + let transaction = crate::decode_transaction(&result).unwrap(); + assert_eq!( + (0..transaction.instructions().len()).map(|index| program_id(&transaction, index)).collect::>(), + vec![MEMO_PROGRAM_ID, TOKEN_PROGRAM_ID] + ); + assert_eq!(transaction.instructions()[0].accounts, Vec::::new()); + assert_eq!(transaction.instructions()[0].data, b"token memo"); + } +} diff --git a/core/crates/gem_solana/src/signer/instructions/transfer.rs b/core/crates/gem_solana/src/signer/instructions/transfer.rs new file mode 100644 index 0000000000..37de04176b --- /dev/null +++ b/core/crates/gem_solana/src/signer/instructions/transfer.rs @@ -0,0 +1,95 @@ +use crate::signer::transaction; +use primitives::{SignerError, SignerInput}; +use solana_primitives::{ + Instruction, Pubkey, + instructions::{memo::memo, system::transfer}, +}; + +pub(in crate::signer) fn native_transfer(input: &SignerInput, sender: Pubkey) -> Result, SignerError> { + let recipient = Pubkey::from_base58(&input.destination_address).map_err(SignerError::from_display)?; + let mut instructions = transaction::compute_budget_instructions(&input.fee)?; + if let Some(memo_text) = input.get_memo() { + instructions.push(memo(memo_text, &[])); + } + instructions.push(transfer(&sender, &recipient, input.value_as_u64()?)); + Ok(instructions) +} + +#[cfg(test)] +mod tests { + use crate::signer::{SolanaChainSigner, testkit::*}; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use primitives::{Asset, ChainSigner, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput}; + use solana_primitives::instructions::program_ids::{COMPUTE_BUDGET_PROGRAM_ID, MEMO_PROGRAM_ID, SYSTEM_PROGRAM_ID}; + + // https://github.com/trustwallet/wallet-core/blob/master/rust/tw_tests/tests/chains/solana/solana_sign.rs + const REFERENCE_TRANSFER_PRIVATE_KEY: &str = "A7psj2GW7ZMdY4E5hJq14KMeYg7HFjULSsWSrTXZLvYr"; + const REFERENCE_TRANSFER_TX: &str = "3p2kzZ1DvquqC6LApPuxpTg5CCDVPqJFokGSnGhnBHrta4uq7S2EyehV1XNUVXp51D69GxGzQZUjikfDzbWBG2aFtG3gHT1QfLzyFKHM4HQtMQMNXqay1NAeiiYZjNhx9UvMX4uAQZ4Q6rx6m2AYfQ7aoMUrejq298q1wBFdtS9XVB5QTiStnzC7zs97FUEK2T4XapjF1519EyFBViTfHpGpnf5bfizDzsW9kYUtRDW1UC2LgHr7npgq5W9TBmHf9hSmRgM9XXucjXLqubNWE7HUMhbKjuBqkirRM"; + + fn transfer_data(lamports: u64) -> Vec { + let mut data = vec![2, 0, 0, 0]; + data.extend_from_slice(&lamports.to_le_bytes()); + data + } + + #[test] + fn test_sign_transfer() { + let signer = SolanaChainSigner; + let input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::mock_sol()), + sender_address: sender_address(), + destination_address: TEST_RECIPIENT.to_string(), + value: "42".to_string(), + gas_price: GasPriceType::solana(5_000u64, 0u64, 2u64), + memo: Some("HelloSolanaMemo".to_string()), + is_max_value: false, + metadata: solana_metadata(None, None, None), + }; + let fee = TransactionFee::new_gas_price_type(GasPriceType::solana(5_000u64, 0u64, 2u64), 5_000u64.into(), 2_000u64.into(), Default::default()); + let input = SignerInput::new(input, fee); + + let result = signer.sign_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + + let transaction = crate::decode_transaction(&result).unwrap(); + assert_eq!(transaction.signatures().len(), 1); + assert_ne!(transaction.signatures()[0].as_bytes(), &[0u8; 64]); + assert_eq!( + (0..transaction.instructions().len()).map(|index| program_id(&transaction, index)).collect::>(), + vec![COMPUTE_BUDGET_PROGRAM_ID, COMPUTE_BUDGET_PROGRAM_ID, MEMO_PROGRAM_ID, SYSTEM_PROGRAM_ID] + ); + assert_eq!(transaction.instructions()[0].data, { + let mut data = vec![3]; + data.extend_from_slice(&2u64.to_le_bytes()); + data + }); + assert_eq!(transaction.instructions()[1].data, { + let mut data = vec![2]; + data.extend_from_slice(&2_000u32.to_le_bytes()); + data + }); + assert_eq!(transaction.instructions()[2].accounts, Vec::::new()); + assert_eq!(transaction.instructions()[2].data, b"HelloSolanaMemo"); + assert_eq!(transaction.instructions()[3].data, transfer_data(42)); + } + + #[test] + fn test_sign_reference_transfer() { + let signer = SolanaChainSigner; + let private_key = private_key_base58(REFERENCE_TRANSFER_PRIVATE_KEY); + let transfer = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::mock_sol()), + sender_address: sender_address_for_key(&private_key), + destination_address: TEST_RECIPIENT.to_string(), + value: "42".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: solana_metadata(None, None, None), + }; + let transfer = SignerInput::new(transfer, TransactionFee::default()); + + let result = signer.sign_transfer(&transfer, &private_key).unwrap(); + + assert_eq!(base58_transaction(&result), REFERENCE_TRANSFER_TX); + } +} diff --git a/core/crates/gem_solana/src/signer/mod.rs b/core/crates/gem_solana/src/signer/mod.rs new file mode 100644 index 0000000000..4cdbfc4172 --- /dev/null +++ b/core/crates/gem_solana/src/signer/mod.rs @@ -0,0 +1,8 @@ +mod chain_signer; +mod instructions; +mod swap; +#[cfg(test)] +pub mod testkit; +mod transaction; + +pub use chain_signer::SolanaChainSigner; diff --git a/core/crates/gem_solana/src/signer/swap.rs b/core/crates/gem_solana/src/signer/swap.rs new file mode 100644 index 0000000000..f6aa74fa9c --- /dev/null +++ b/core/crates/gem_solana/src/signer/swap.rs @@ -0,0 +1,99 @@ +use crate::decode_transaction; +use gem_encoding::encode_base64; +use num_traits::ToPrimitive; +use primitives::{SignerError, SignerInput, TransactionFee}; +use solana_primitives::sign_message as sign_solana_message; + +pub(crate) fn sign(input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let swap_data = input.input_type.get_swap_data().map_err(SignerError::invalid_input)?; + let transaction_base64 = &swap_data.data.data; + + let unit_price = input.fee.unit_price_u64()?; + let quote_gas_limit = swap_data + .data + .gas_limit + .as_ref() + .map(|_| swap_data.data.gas_limit_as_u32()) + .transpose() + .map_err(SignerError::invalid_input)?; + + Ok(vec![sign_transaction(transaction_base64, private_key, unit_price, quote_gas_limit, &input.fee)?]) +} + +fn sign_transaction(transaction_base64: &str, private_key: &[u8], unit_price: u64, quote_gas_limit: Option, fee: &TransactionFee) -> Result { + let mut transaction = decode_transaction(transaction_base64).map_err(SignerError::invalid_input)?; + + if transaction.signatures().len() <= 1 { + let gas_limit = match quote_gas_limit.or(transaction.get_compute_unit_limit()) { + Some(gas_limit) => Some(gas_limit), + None => { + let gas_limit = fee.gas_limit.to_u32().ok_or_else(|| SignerError::invalid_input("invalid gas limit"))?; + (gas_limit > 0).then_some(gas_limit) + } + }; + if unit_price > 0 { + transaction + .set_compute_unit_price(unit_price) + .map_err(|e| SignerError::invalid_input(format!("set compute unit price: {e}")))?; + } + if let Some(gas_limit) = gas_limit.filter(|gas_limit| *gas_limit > 0) { + transaction + .set_compute_unit_limit(gas_limit) + .map_err(|e| SignerError::invalid_input(format!("set compute unit limit: {e}")))?; + } + } + + let message_bytes = transaction.serialize_message().map_err(|e| SignerError::signing_error(format!("serialize message: {e}")))?; + let sig = sign_solana_message(private_key, &message_bytes).map_err(|e| SignerError::signing_error(format!("sign: {e}")))?; + + let sigs = transaction.signatures_mut(); + if sigs.is_empty() { + sigs.push(sig); + } else { + sigs[0] = sig; + } + + let bytes = transaction.serialize().map_err(|e| SignerError::signing_error(format!("serialize transaction: {e}")))?; + Ok(encode_base64(&bytes)) +} + +#[cfg(test)] +mod tests { + use crate::signer::{SolanaChainSigner, testkit::SINGLE_SIG_TX}; + use primitives::swap::SwapData; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use primitives::{Asset, ChainSigner, GasPriceType, SignerInput, SwapProvider, TransactionFee, TransactionInputType, TransactionLoadInput}; + + #[test] + fn test_sign_swap_without_quote_gas_limit_uses_embedded_limit() { + let signer = SolanaChainSigner; + let original_limit = crate::decode_transaction(SINGLE_SIG_TX).unwrap().get_compute_unit_limit(); + let swap_data = SwapData::mock_with_provider_data(SwapProvider::Jupiter, SINGLE_SIG_TX, None); + let input_type = TransactionInputType::Swap(Asset::mock_sol(), Asset::mock_spl_token(), swap_data); + let input = TransactionLoadInput::mock_with_input_type(input_type); + let fee = TransactionFee::new_gas_price_type(GasPriceType::solana(5_000u64, 0u64, 0u64), 5_000u64.into(), 1u64.into(), Default::default()); + let input = SignerInput::new(input, fee); + + let result = signer.sign_swap(&input, &TEST_PRIVATE_KEY).unwrap(); + + let signed_transaction = crate::decode_transaction(&result[0]).unwrap(); + assert_eq!(signed_transaction.get_compute_unit_limit(), original_limit); + assert_ne!(signed_transaction.signatures()[0].as_bytes(), &[0u8; 64]); + } + + #[test] + fn test_sign_swap_prefers_quote_gas_limit() { + let signer = SolanaChainSigner; + let gas_limit = crate::DEFAULT_SWAP_GAS_LIMIT.to_string(); + let swap_data = SwapData::mock_with_provider_data(SwapProvider::Jupiter, SINGLE_SIG_TX, Some(&gas_limit)); + let input_type = TransactionInputType::Swap(Asset::mock_sol(), Asset::mock_spl_token(), swap_data); + let input = TransactionLoadInput::mock_with_input_type(input_type); + let fee = TransactionFee::new_gas_price_type(GasPriceType::solana(5_000u64, 0u64, 0u64), 5_000u64.into(), 1u64.into(), Default::default()); + let input = SignerInput::new(input, fee); + + let result = signer.sign_swap(&input, &TEST_PRIVATE_KEY).unwrap(); + + let signed_transaction = crate::decode_transaction(&result[0]).unwrap(); + assert_eq!(signed_transaction.get_compute_unit_limit(), Some(crate::DEFAULT_SWAP_GAS_LIMIT)); + } +} diff --git a/core/crates/gem_solana/src/signer/testkit.rs b/core/crates/gem_solana/src/signer/testkit.rs new file mode 100644 index 0000000000..87742f178a --- /dev/null +++ b/core/crates/gem_solana/src/signer/testkit.rs @@ -0,0 +1,61 @@ +use gem_encoding::decode_base64; +use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; +use primitives::{SolanaTokenProgramId, TransactionLoadMetadata}; +use solana_primitives::{CompiledInstruction, LegacyMessage, MessageHeader, Pubkey, VersionedTransaction, get_address}; + +pub const TEST_RECIPIENT: &str = "EN2sCsJ1WDV8UFqsiTXHcUPUxQ4juE71eCknHYYMifkd"; +pub const TEST_SENDER_TOKEN_ADDRESS: &str = "HEeranxp3y7kVQKVSLdZW1rUmnbs7bAtUTMu8o88Jash"; + +pub fn sender_address() -> String { + sender_address_for_key(&TEST_PRIVATE_KEY) +} + +pub fn sender_address_for_key(private_key: &[u8]) -> String { + get_address(private_key).unwrap() +} + +pub fn solana_metadata(sender_token_address: Option<&str>, recipient_token_address: Option<&str>, token_program: Option) -> TransactionLoadMetadata { + TransactionLoadMetadata::mock_solana_token(sender_token_address, recipient_token_address, token_program) +} + +pub fn private_key_base58(value: &str) -> Vec { + bs58::decode(value).into_vec().unwrap() +} + +pub fn base58_transaction(encoded_base64: &str) -> String { + bs58::encode(decode_base64(encoded_base64).unwrap()).into_string() +} + +pub fn mock_legacy_transaction() -> VersionedTransaction { + VersionedTransaction::Legacy { + signatures: vec![], + message: LegacyMessage { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![Pubkey::new([1; 32]), Pubkey::new([2; 32])], + recent_blockhash: [3; 32], + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }], + }, + } +} + +pub fn program_id(transaction: &VersionedTransaction, index: usize) -> String { + let instruction = &transaction.instructions()[index]; + transaction.account_keys()[instruction.program_id_index as usize].to_base58() +} + +pub fn account_key(transaction: &VersionedTransaction, instruction_index: usize, account_index: usize) -> Pubkey { + let instruction = &transaction.instructions()[instruction_index]; + transaction.account_keys()[instruction.accounts[account_index] as usize] +} + +pub const SINGLE_SIG_TX: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIE4X7qT7inGBPqFijUWiMASkIQer7GcY6cKR108e8O++fF6bw89YHC7acs3m1YUIhVlGt8Lsh5HSIZozEPbcak9lEkZ+TiEMZdELfvcljcnBtv3Cf2z6oSYOBlVK0qYEJiVPvd6ryg3kqPlsEMcw3Fwx5sgoudurpYDKruhuxfayEdR478uf/smdZykpYhZIZWgIVX3IpDQW2WlWxw1AX3C53BHo4HDkVOPejukK6/oQdRT8m1S5xpmRD9q8e3XSK/Xu3TqNZNjLp6OSRg8r3sOv1e+QztSj31QG3tKRlT5zLqo0WQ1mJ0Hxkrw69L1dQmgMqgZd20xmPPvg53n23dsfNG6CPj/KUTsrekiJQc2Hvabji48RBleABXKq2lBKpj89u0FRUiC4li6CDgDgrZl6r6UV4hTXUW6fWb5yNguwg6dRIiwf+OZsakVXlghtpfUMBbAo8Tzu8oq+0HQFjMFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E48G3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqYyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZrBrj0IfykjcGJUj3DEwErsKplWlJhufLtGdSBiHThjC0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6Mb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hhfF5aGC8uN+dIBDrxV3vHwZYs2RmFKm87C1HtO3e+NIHDAAFAsBcFQAPBgAJACwLDgEBCwIACQwCAAAAgJaYAAAAAAAOAQkBEQ8GAAYAEgsOAQENQg4QAAkFCgYsEgcNEQ0qKRYZCgUXGBArDg0nDi0uEAUhIgQfICYOHiUdGhsoHB4eHh4eHgQDECMOEBQDFQoTCAIBJDLBIJszQdacgQUEAAAAEgA8AAQDKAACB2QCAxEBZAMEgJaYAAAAAACBqQkAAAAAADIAMg4DCQAAAQkEB4tUBJUod+xdJHClaAbwfY0KcsPyS6puvinwAKI7S1cD9e7vBh7xJzZ/XBUMq/0+5x74cLXUfrm4RcW7GMg1vW5lr144gPYI6KKjBMO8u74DwL/CbfplXQ5y4uTDJAQIjShVPQLz8v+me8U9jJd68IPKjLkFvL08uLoAzNqZz4glI8qy5jvrpHYo8Vzh6SmqRgs5a0H2AAOFaZwE6uzp5wNl7es="; +pub const DOUBLE_SIG_TX: &str = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg8M2FRx269K+zS8zLnv1jrOc5UgDry1oYxecVoCE+FxaIlIE3LTCK5GF5CzCCSkyPQPR14YZsIa38Vu8zmewBgAIAAwuF+6k+4pxgT6hYo1FojAEpCEHq+xnGOnCkddPHvDvvnwoEJW7RK+RvTyYjTaEmmeJJGx7FDytUlV3phwhnLk/r+o7AIiuGV76I/RQwJmovrxVIVynZIDhgTTNAHNxKXKQMOlp0lJiwd9U+29PnQtf+c3R43jQldu6Ve4l4MJzRLHu3TqNZNjLp6OSRg8r3sOv1e+QztSj31QG3tKRlT5zLCWp06QMZwgS1uI/TJ/gwLpboVWOHBIESfT2odVamEF8olyekhrnZjIZzm9FeP8AkdfjUBFdj1PeKOYjOQQ5yIVmPLxjpjiM7L1AxMxqh2a/yjPk5ti2H8FK09PE9u6wRAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCp0P5e0Steyi4OtRMALbRonjhrWQUnM3/sbCIGRwipaicFCAAJA4AaBgAAAAAACQcAAgMLDQoOAQEKAwQCAAkDgJaYAAAAAAAFBQADAgsNvAGP6/zC01qGTam9BJP5vR95KkrtwfmdVFNadaRsOP1WqPLGt8jXWBehgJaYAAAAAAAAAAAAAAAAAJF5EQAAAAAAAAAAAAAAAAAVAIk/g7omh1PrQHSTjRmxQaSVbGwIsUkg7qwrN0tYmJrlA5JYGB9c6sjb/7cDCJAkPK7WmpWZ0ohtlXqct2Vq872zKDwhDQAAAAAhh39oAAAAAJ1rmLGP0mte/uxo0CDc8b56lMLDFTU3ebxrOu1EGI3fMgUTAwIABQsGDxAREgcTDAEUFRYKDQiNNiXP7dL61wHZww37lhGNrgM1pfXVtOEebA/y8LN1Fi1I9ekUZZPYqwIREAopJw4LGgwPAwQg"; +pub const EXPECTED_MESSAGE_HEX: &str = "800100081385fba93ee29c604fa858a351688c01290841eafb19c63a70a475d3c7bc3bef9f17a6f0f3d6070bb69cb379b56142215651adf0bb21e47488668cc43db71a93d944919f938843197442dfbdc96372706dbf709fdb3ea84983819552b4a981098953ef77aaf283792a3e5b0431cc37170c79b20a2e76eae96032abba1bb17dac84751e3bf2e7ffb26759ca4a588592195a02155f72290d05b65a55b1c35017dc2e77047a381c391538f7a3ba42bafe841d453f26d52e71a66443f6af1edd748afd7bb74ea3593632e9e8e49183caf7b0ebf57be433b528f7d501b7b4a4654f9ccbaa8d16435989d07c64af0ebd2f57509a032a819776d3198f3ef839de7db776c7cd1ba08f8ff2944ecade9222507361ef69b8e2e3c44195e0015caab69412a98fcf6ed05454882e258ba08380382b665eabe945788535d45ba7d66f9c8d82ec20e9d4488b07fe399b1a9155e5821b697d43016c0a3c4f3bbca2afb41d0163305700000000000000000000000000000000000000000000000000000000000000000306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a400000000479d55bf231c06eee74c56ece681507fdb1b2dea3f48e5102b1cda256bc138f06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a98c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859ac1ae3d087f29237062548f70c4c04aec2a995694986e7cbb467520621d38630b43ffa27f5d7f64a74c09b1f295879de4b09ab36dfc9dd514b321aa7b38ce5e8c6fa7af3bedbad3a3d65f36aabc97431b1bbe4c2d2f6e0e47ca60203452f5d6185f1796860bcb8df9d2010ebc55def1f0658b3646614a9bcec2d47b4eddef8d2070c000502c05c15000f060009002c0b0e01010b0200090c0200000080969800000000000e010901110f06000600120b0e01010d420e100009050a062c12070d110d2a2916190a051718102b0e0d270e2d2e10052122041f20260e1e251d1a1b281c1e1e1e1e1e1e040310230e101403150a130802012432c1209b3341d69c81050400000012003c000403280002076402031101640304809698000000000081a90900000000003200320e03090000010904078b5404952877ec5d2470a56806f07d8d0a72c3f24baa6ebe29f000a23b4b5703f5eeef061ef127367f5c150cabfd3ee71ef870b5d47eb9b845c5bb18c835bd6e65af5e3880f608e8a2a304c3bcbbbe03c0bfc26dfa655d0e72e2e4c32404088d28553d02f3f2ffa67bc53d8c977af083ca8cb905bcbd3cb8ba00ccda99cf882523cab2e63beba47628f15ce1e929aa460b396b41f6000385699c04eaece9e70365edeb"; diff --git a/core/crates/gem_solana/src/signer/transaction.rs b/core/crates/gem_solana/src/signer/transaction.rs new file mode 100644 index 0000000000..2080dc3152 --- /dev/null +++ b/core/crates/gem_solana/src/signer/transaction.rs @@ -0,0 +1,233 @@ +use gem_encoding::encode_base64; +use num_traits::ToPrimitive; +use primitives::{SignerError, SignerInput, TransactionFee}; +use solana_primitives::{ + CompiledInstruction, Instruction, Message, MessageHeader, Pubkey, SignatureBytes, Transaction, + instructions::compute_budget::{set_compute_unit_limit, set_compute_unit_price}, +}; +use std::collections::HashMap; + +#[derive(Clone, Copy)] +struct AccountFlags { + is_signer: bool, + is_writable: bool, +} + +pub(crate) fn compute_budget_instructions(fee: &TransactionFee) -> Result, SignerError> { + let unit_price = fee.unit_price_u64()?; + let gas_limit = fee.gas_limit.to_u32().ok_or_else(|| SignerError::invalid_input("invalid gas limit"))?; + let mut instructions = Vec::new(); + if unit_price > 0 { + instructions.push(set_compute_unit_price(unit_price)); + } + if gas_limit > 0 { + instructions.push(set_compute_unit_limit(gas_limit)); + } + Ok(instructions) +} + +pub(crate) fn sign_single_signer_instructions(input: &SignerInput, private_key: &[u8], fee_payer: Pubkey, instructions: Vec) -> Result { + let mut transaction = build_legacy_transaction(fee_payer, block_hash(input)?, instructions)?; + if transaction.num_required_signatures() != 1 { + return Err(SignerError::invalid_input("Solana transaction requires more than one signer")); + } + transaction.sign(&[private_key]).map_err(|e| SignerError::signing_error(format!("sign: {e}")))?; + let bytes = transaction + .serialize_legacy() + .map_err(|e| SignerError::signing_error(format!("serialize transaction: {e}")))?; + Ok(encode_base64(&bytes)) +} + +fn build_legacy_transaction(fee_payer: Pubkey, recent_blockhash: [u8; 32], instructions: Vec) -> Result { + let mut flags = HashMap::new(); + let mut account_order = Vec::new(); + let mut program_order = Vec::new(); + + merge_account(&mut flags, &mut account_order, fee_payer, true, true); + for instruction in &instructions { + for account in &instruction.accounts { + merge_account(&mut flags, &mut account_order, account.pubkey, account.is_signer, account.is_writable); + } + merge_account(&mut flags, &mut program_order, instruction.program_id, false, false); + } + + let mut buckets: [Vec; 4] = Default::default(); + for pubkey in account_order.iter().chain(program_order.iter()) { + let flags = flags.get(pubkey).ok_or_else(|| SignerError::invalid_input("missing Solana account flags"))?; + let bucket = match (flags.is_signer, flags.is_writable) { + (true, true) => 0, + (true, false) => 1, + (false, true) => 2, + (false, false) => 3, + }; + buckets[bucket].push(*pubkey); + } + + let num_required_signatures = buckets[0].len() + buckets[1].len(); + let account_keys = account_keys(fee_payer, &buckets); + if account_keys.len() > u8::MAX as usize || num_required_signatures > u8::MAX as usize { + return Err(SignerError::invalid_input("Solana transaction has too many account keys")); + } + + let key_to_index = account_keys.iter().enumerate().map(|(index, pubkey)| (*pubkey, index as u8)).collect::>(); + let compiled_instructions = instructions + .iter() + .map(|instruction| { + let program_id_index = account_index(&key_to_index, instruction.program_id)?; + let accounts = instruction + .accounts + .iter() + .map(|account| account_index(&key_to_index, account.pubkey)) + .collect::, SignerError>>()?; + Ok(CompiledInstruction { + program_id_index, + accounts, + data: instruction.data.clone(), + }) + }) + .collect::, SignerError>>()?; + + let header = MessageHeader { + num_required_signatures: num_required_signatures as u8, + num_readonly_signed_accounts: buckets[1].len() as u8, + num_readonly_unsigned_accounts: buckets[3].len() as u8, + }; + Ok(Transaction { + signatures: vec![SignatureBytes::new([0u8; 64]); num_required_signatures], + message: Message::new(header, account_keys, recent_blockhash, compiled_instructions), + }) +} + +fn merge_account(flags: &mut HashMap, order: &mut Vec, pubkey: Pubkey, is_signer: bool, is_writable: bool) { + flags + .entry(pubkey) + .and_modify(|flags| { + flags.is_signer |= is_signer; + flags.is_writable |= is_writable; + }) + .or_insert_with(|| { + order.push(pubkey); + AccountFlags { is_signer, is_writable } + }); +} + +fn account_keys(fee_payer: Pubkey, buckets: &[Vec; 4]) -> Vec { + let mut account_keys = Vec::with_capacity(buckets.iter().map(Vec::len).sum()); + account_keys.push(fee_payer); + account_keys.extend(buckets[0].iter().copied().filter(|pubkey| *pubkey != fee_payer)); + for bucket in &buckets[1..] { + account_keys.extend(bucket.iter().copied()); + } + account_keys +} + +fn account_index(key_to_index: &HashMap, pubkey: Pubkey) -> Result { + key_to_index.get(&pubkey).copied().ok_or_else(|| SignerError::invalid_input("missing Solana account key")) +} + +fn block_hash(input: &SignerInput) -> Result<[u8; 32], SignerError> { + let block_hash = input.metadata.get_block_hash()?; + let bytes = bs58::decode(&block_hash).into_vec().map_err(|_| SignerError::invalid_input("invalid Solana block hash"))?; + bytes.try_into().map_err(|_| SignerError::invalid_input("Solana block hash must be 32 bytes")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{decode_transaction, signer::testkit::SINGLE_SIG_TX}; + use solana_primitives::AccountMeta; + + fn pubkey(value: u8) -> Pubkey { + Pubkey::new([value; 32]) + } + + #[test] + fn test_decode_transaction_compute_unit_limit() { + let transaction = decode_transaction(SINGLE_SIG_TX).unwrap(); + + assert_eq!(transaction.get_compute_unit_limit(), Some(1_400_000)); + } + + #[test] + fn test_build_legacy_transaction_preserves_account_order_by_bucket() { + let fee_payer = pubkey(1); + let writable = pubkey(2); + let readonly_first = pubkey(3); + let readonly_second = pubkey(4); + let program_first = pubkey(5); + let program_second = pubkey(6); + let instructions = vec![ + Instruction { + program_id: program_first, + accounts: vec![ + AccountMeta { + pubkey: fee_payer, + is_signer: true, + is_writable: false, + }, + AccountMeta { + pubkey: readonly_first, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: writable, + is_signer: false, + is_writable: true, + }, + ], + data: vec![1], + }, + Instruction { + program_id: program_second, + accounts: vec![AccountMeta { + pubkey: readonly_second, + is_signer: false, + is_writable: false, + }], + data: vec![2], + }, + ]; + + let transaction = build_legacy_transaction(fee_payer, [0; 32], instructions).unwrap(); + + assert_eq!( + transaction.account_keys(), + &[fee_payer, writable, readonly_first, readonly_second, program_first, program_second] + ); + assert_eq!(transaction.num_required_signatures(), 1); + assert_eq!(transaction.num_readonly_unsigned_accounts(), 4); + } + + #[test] + fn test_build_legacy_transaction_upgrades_duplicate_account_flags() { + let fee_payer = pubkey(1); + let upgraded = pubkey(2); + let program = pubkey(3); + let instructions = vec![ + Instruction { + program_id: program, + accounts: vec![AccountMeta { + pubkey: upgraded, + is_signer: false, + is_writable: false, + }], + data: vec![1], + }, + Instruction { + program_id: program, + accounts: vec![AccountMeta { + pubkey: upgraded, + is_signer: false, + is_writable: true, + }], + data: vec![2], + }, + ]; + + let transaction = build_legacy_transaction(fee_payer, [0; 32], instructions).unwrap(); + + assert_eq!(transaction.account_keys(), &[fee_payer, upgraded, program]); + assert_eq!(transaction.num_readonly_unsigned_accounts(), 1); + } +} diff --git a/core/crates/gem_solana/src/token_account.rs b/core/crates/gem_solana/src/token_account.rs new file mode 100644 index 0000000000..2e29df7561 --- /dev/null +++ b/core/crates/gem_solana/src/token_account.rs @@ -0,0 +1,65 @@ +use std::sync::LazyLock; + +use crate::{ASSOCIATED_TOKEN_ACCOUNT_PROGRAM, Pubkey, SolanaError, find_program_address}; + +static ASSOCIATED_TOKEN_PROGRAM: LazyLock = LazyLock::new(|| Pubkey::from_base58(ASSOCIATED_TOKEN_ACCOUNT_PROGRAM).unwrap()); + +pub fn get_token_account(wallet: &str, token_mint: &str, token_program: &str) -> Result { + let owner = Pubkey::from_base58(wallet)?; + let token_program = Pubkey::from_base58(token_program)?; + let mint = Pubkey::from_base58(token_mint)?; + let seeds = [owner.as_bytes().as_ref(), token_program.as_bytes().as_ref(), mint.as_bytes().as_ref()]; + + Ok(find_program_address(&ASSOCIATED_TOKEN_PROGRAM, &seeds)?.0.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{PYUSD_TOKEN_MINT, TOKEN_PROGRAM, TOKEN_PROGRAM_2022, USDC_TOKEN_MINT, USDS_TOKEN_MINT, USDT_TOKEN_MINT, WSOL_TOKEN_ADDRESS}; + + #[test] + fn test_get_token_account() -> Result<(), Box> { + let test_cases = [ + ( + "CzVqG98YbFNiMREwgTswSML59CNrfobsNX4N9j6K8fbC", + USDT_TOKEN_MINT, + TOKEN_PROGRAM, + "3gV5dwdpdBTQU3hQsXJqBKdJF5fD3Fv3RZSFZgKWcjfh", + ), + ( + "AzDByJsGm9gAVQPX8v8WS3iAs3PPdTwZZDDUNP2u5nVj", + WSOL_TOKEN_ADDRESS, + TOKEN_PROGRAM, + "GkEZoxULxLSXo267QkdM1nLg87zTcpMJwUbxgkRxmLnV", + ), + ( + "9CXNmcRzZenixwtzAEVA3Jdo3yC6Hscxcpa4R7tQ6tTV", + USDC_TOKEN_MINT, + TOKEN_PROGRAM, + "8vErkepS3arxRAQk66XiJuKLGi8jajRw2DXoNooHPejf", + ), + ( + "FC2QFuyPj5cRBkfi83f2EA9gJvFp8EEq2TKVbhqod1vz", + USDS_TOKEN_MINT, + TOKEN_PROGRAM, + "APL8v7ptdmteuJqbByfQ64LmNYrCJPrLPbV5VEnMegwC", + ), + ( + "fr6yQkDmWy6R6pecbUsxXaw6EvRJznZ2HsK5frQgud8", + PYUSD_TOKEN_MINT, + TOKEN_PROGRAM_2022, + "Ffeie177PRngys3SwNH44AYdT9yExm63GAmTXHdmL1k1", + ), + ]; + + for (wallet, token_mint, token_program, expected_token_account) in test_cases.iter() { + let fee_token_account = get_token_account(wallet, token_mint, token_program)?; + assert_eq!(fee_token_account, *expected_token_account); + } + + assert!(get_token_account("invalid", USDC_TOKEN_MINT, TOKEN_PROGRAM).is_err()); + + Ok(()) + } +} diff --git a/core/crates/gem_solana/src/transaction.rs b/core/crates/gem_solana/src/transaction.rs new file mode 100644 index 0000000000..c6f624ffc1 --- /dev/null +++ b/core/crates/gem_solana/src/transaction.rs @@ -0,0 +1,98 @@ +use gem_encoding::{decode_base64, encode_base64}; +use primitives::SolanaInstruction; +use solana_primitives::{AccountMeta, AddressLookupTableAccount, Instruction, Pubkey, TransactionBuilder, VersionedTransaction}; + +pub fn try_decode_transaction(transaction_base64: &str) -> Option { + let data = decode_base64(transaction_base64).ok()?; + try_decode_transaction_bytes(&data) +} + +pub(crate) fn try_decode_transaction_bytes(transaction: &[u8]) -> Option { + let decoded = VersionedTransaction::deserialize_with_version(transaction).ok()?; + (decoded.serialize().ok()? == transaction).then_some(decoded) +} + +pub(crate) fn is_transaction_bytes(transaction: &[u8]) -> bool { + try_decode_transaction_bytes(transaction).is_some() || try_decode_transaction_message(transaction).is_some() +} + +fn try_decode_transaction_message(message: &[u8]) -> Option { + let mut transaction = Vec::with_capacity(message.len() + 1); + transaction.push(0); + transaction.extend_from_slice(message); + + let decoded = VersionedTransaction::deserialize_with_version(&transaction).ok()?; + (decoded.serialize_message().ok()? == message).then_some(decoded) +} + +pub fn decode_transaction(transaction_base64: &str) -> Result { + try_decode_transaction(transaction_base64).ok_or_else(|| "failed to decode transaction".to_string()) +} + +pub fn try_decode_blockhash(blockhash: &str) -> Option<[u8; 32]> { + bs58::decode(blockhash).into_vec().ok()?.try_into().ok() +} + +pub fn encode_v0_transaction(payer: Pubkey, recent_blockhash: &str, instructions: &[Instruction], lookup_tables: &[AddressLookupTableAccount]) -> Result { + let recent_blockhash = try_decode_blockhash(recent_blockhash).ok_or_else(|| "Invalid Solana blockhash".to_string())?; + let transaction = TransactionBuilder::build_v0_transaction(payer, recent_blockhash, instructions, lookup_tables).map_err(|err| format!("Solana transaction error: {err}"))?; + let bytes = transaction.serialize().map_err(|err| format!("Solana transaction error: {err}"))?; + Ok(encode_base64(&bytes)) +} + +pub fn instruction_from_primitive(instruction: SolanaInstruction) -> Result { + let program_id = Pubkey::from_base58(&instruction.program_id).map_err(|err| format!("Invalid Solana address {}: {err}", instruction.program_id))?; + let accounts = instruction + .accounts + .into_iter() + .map(|account| { + Ok(AccountMeta { + pubkey: Pubkey::from_base58(&account.pubkey).map_err(|err| format!("Invalid Solana address {}: {err}", account.pubkey))?, + is_signer: account.is_signer, + is_writable: account.is_writable, + }) + }) + .collect::, String>>()?; + Ok(Instruction { + program_id, + accounts, + data: decode_base64(&instruction.data).map_err(|err| err.to_string())?, + }) +} + +pub fn instructions_from_primitives(instructions: Vec) -> Result, String> { + instructions.into_iter().map(instruction_from_primitive).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(feature = "signer")] + use crate::signer::testkit::{SINGLE_SIG_TX, mock_legacy_transaction}; + + #[test] + fn test_try_decode_blockhash() { + assert!(try_decode_blockhash("BZcyEKqjBNG5bEY6i5ev6PfPTgDSB9LwovJE1hJfJoHF").is_some()); + assert!(try_decode_blockhash("invalid blockhash").is_none()); + assert!(try_decode_blockhash("1111111111111111111111111111111").is_none()); + } + + #[cfg(feature = "signer")] + #[test] + fn test_is_transaction_bytes() { + let full_transaction = gem_encoding::decode_base64(SINGLE_SIG_TX).unwrap(); + let transaction = VersionedTransaction::deserialize_with_version(&full_transaction).unwrap(); + let mut v0_message = transaction.serialize_message().unwrap(); + let mut transaction_with_trailing_byte = full_transaction.clone(); + + assert!(is_transaction_bytes(&full_transaction)); + assert!(is_transaction_bytes(&v0_message)); + assert!(is_transaction_bytes(&mock_legacy_transaction().serialize_message().unwrap())); + + transaction_with_trailing_byte.push(0); + v0_message.push(0); + assert!(!is_transaction_bytes(&transaction_with_trailing_byte)); + assert!(!is_transaction_bytes(&v0_message)); + assert!(!is_transaction_bytes(b"hello")); + } +} diff --git a/core/crates/gem_solana/testdata/balance_coin.json b/core/crates/gem_solana/testdata/balance_coin.json new file mode 100644 index 0000000000..23e6ee667a --- /dev/null +++ b/core/crates/gem_solana/testdata/balance_coin.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "2.3.7", + "slot": 361156151 + }, + "value": 1366309311 + }, + "id": 1 + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/balance_spl_token.json b/core/crates/gem_solana/testdata/balance_spl_token.json new file mode 100644 index 0000000000..4b54cef054 --- /dev/null +++ b/core/crates/gem_solana/testdata/balance_spl_token.json @@ -0,0 +1,41 @@ +{ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "2.3.7", + "slot": 361156097 + }, + "value": [ + { + "account": { + "data": { + "parsed": { + "info": { + "isNative": false, + "mint": "2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "state": "initialized", + "tokenAmount": { + "amount": "75071408", + "decimals": 6, + "uiAmount": 75.071408, + "uiAmountString": "75.071408" + } + }, + "type": "account" + }, + "program": "spl-token", + "space": 165 + }, + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 18446744073709551615, + "space": 165 + }, + "pubkey": "CULQToo7CNmMmuPcprsDNSth9HiApG224hTGisMWPLZs" + } + ] + }, + "id": 1 + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/balance_staking.json b/core/crates/gem_solana/testdata/balance_staking.json new file mode 100644 index 0000000000..67e4ee7727 --- /dev/null +++ b/core/crates/gem_solana/testdata/balance_staking.json @@ -0,0 +1,293 @@ +{ + "jsonrpc": "2.0", + "id": 1755631916, + "result": [ + { + "account": { + "data": { + "parsed": { + "info": { + "meta": { + "authorized": { + "staker": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "withdrawer": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H" + }, + "lockup": { + "custodian": "11111111111111111111111111111111", + "epoch": 0, + "unixTimestamp": 0 + }, + "rentExemptReserve": "2282880" + }, + "stake": { + "creditsObserved": 1173366768, + "delegation": { + "activationEpoch": "831", + "deactivationEpoch": "18446744073709551615", + "stake": "97847072", + "voter": "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF", + "warmupCooldownRate": 0.25 + } + } + }, + "type": "delegated" + }, + "program": "stake", + "space": 200 + }, + "executable": false, + "lamports": 100129952, + "owner": "Stake11111111111111111111111111111111111111", + "rentEpoch": 18446744073709552000, + "space": 200 + }, + "pubkey": "DYPqEPfyCFJ7LxENB1SkKvEczmgybjoWyqTR5aZDo98W" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "meta": { + "authorized": { + "staker": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "withdrawer": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H" + }, + "lockup": { + "custodian": "11111111111111111111111111111111", + "epoch": 0, + "unixTimestamp": 0 + }, + "rentExemptReserve": "2282880" + }, + "stake": { + "creditsObserved": 922400411, + "delegation": { + "activationEpoch": "700", + "deactivationEpoch": "830", + "stake": "8116159", + "voter": "CatzoSMUkTRidT5DwBxAC2pEtnwMBTpkCepHkFgZDiqb", + "warmupCooldownRate": 0.25 + } + } + }, + "type": "delegated" + }, + "program": "stake", + "space": 200 + }, + "executable": false, + "lamports": 10487253, + "owner": "Stake11111111111111111111111111111111111111", + "rentEpoch": 18446744073709552000, + "space": 200 + }, + "pubkey": "AMYzorwT5BH1kThunfzYDXuDcDcixUsaG93u8V5WNYy5" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "meta": { + "authorized": { + "staker": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "withdrawer": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H" + }, + "lockup": { + "custodian": "11111111111111111111111111111111", + "epoch": 0, + "unixTimestamp": 0 + }, + "rentExemptReserve": "2282880" + }, + "stake": { + "creditsObserved": 1170563414, + "delegation": { + "activationEpoch": "783", + "deactivationEpoch": "18446744073709551615", + "stake": "99513224", + "voter": "4PsiLMyoUQ7QRn1FFiFCvej4hsUTFzfvJnyN4bj1tmSN", + "warmupCooldownRate": 0.25 + } + } + }, + "type": "delegated" + }, + "program": "stake", + "space": 200 + }, + "executable": false, + "lamports": 101997972, + "owner": "Stake11111111111111111111111111111111111111", + "rentEpoch": 18446744073709552000, + "space": 200 + }, + "pubkey": "8A5N6VrL8N6hbLY5ZSB7hUmFgS4ZMxBKLxonf7adskNc" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "meta": { + "authorized": { + "staker": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "withdrawer": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H" + }, + "lockup": { + "custodian": "11111111111111111111111111111111", + "epoch": 0, + "unixTimestamp": 0 + }, + "rentExemptReserve": "2282880" + }, + "stake": { + "creditsObserved": 920428096, + "delegation": { + "activationEpoch": "700", + "deactivationEpoch": "830", + "stake": "8116378", + "voter": "he1iusunGwqrNtafDtLdhsUQDFvo13z9sUa36PauBtk", + "warmupCooldownRate": 0.25 + } + } + }, + "type": "delegated" + }, + "program": "stake", + "space": 200 + }, + "executable": false, + "lamports": 10483145, + "owner": "Stake11111111111111111111111111111111111111", + "rentEpoch": 18446744073709552000, + "space": 200 + }, + "pubkey": "ASQQcxX1n6J1wtWEM14SoWsFmCtY2bvpV8dcKFJPZgWd" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "meta": { + "authorized": { + "staker": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "withdrawer": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H" + }, + "lockup": { + "custodian": "11111111111111111111111111111111", + "epoch": 0, + "unixTimestamp": 0 + }, + "rentExemptReserve": "2282880" + }, + "stake": { + "creditsObserved": 1174324462, + "delegation": { + "activationEpoch": "616", + "deactivationEpoch": "18446744073709551615", + "stake": "106677423", + "voter": "GvZEwtCHZ7YtCkQCaLRVEXsyVvQkRDhJhQgB6akPme1e", + "warmupCooldownRate": 0.25 + } + } + }, + "type": "delegated" + }, + "program": "stake", + "space": 200 + }, + "executable": false, + "lamports": 110209966, + "owner": "Stake11111111111111111111111111111111111111", + "rentEpoch": 18446744073709552000, + "space": 200 + }, + "pubkey": "FNFrX8bPfG1PFkmiq7v3hYaxUx4kwZpNZgEwt5j8aFiJ" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "meta": { + "authorized": { + "staker": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "withdrawer": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H" + }, + "lockup": { + "custodian": "11111111111111111111111111111111", + "epoch": 0, + "unixTimestamp": 0 + }, + "rentExemptReserve": "2282880" + }, + "stake": { + "creditsObserved": 1173366768, + "delegation": { + "activationEpoch": "819", + "deactivationEpoch": "18446744073709551615", + "stake": "17812386", + "voter": "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF", + "warmupCooldownRate": 0.25 + } + } + }, + "type": "delegated" + }, + "program": "stake", + "space": 200 + }, + "executable": false, + "lamports": 20095266, + "owner": "Stake11111111111111111111111111111111111111", + "rentEpoch": 18446744073709552000, + "space": 200 + }, + "pubkey": "HfZ5HhXsW7DyBM6ZwZUAtrFGVPzWw5oZBgzHwcyxN4jE" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "meta": { + "authorized": { + "staker": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "withdrawer": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H" + }, + "lockup": { + "custodian": "11111111111111111111111111111111", + "epoch": 0, + "unixTimestamp": 0 + }, + "rentExemptReserve": "2282880" + }, + "stake": { + "creditsObserved": 1173366768, + "delegation": { + "activationEpoch": "783", + "deactivationEpoch": "18446744073709551615", + "stake": "7856176", + "voter": "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF", + "warmupCooldownRate": 0.25 + } + } + }, + "type": "delegated" + }, + "program": "stake", + "space": 200 + }, + "executable": false, + "lamports": 10139056, + "owner": "Stake11111111111111111111111111111111111111", + "rentEpoch": 18446744073709552000, + "space": 200 + }, + "pubkey": "9Ss9EnZwPGSofkG24KoW9BsDraLwPf6JVdMJcEwFT5q1" + } + ] + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/chainflip_vault_swap.json b/core/crates/gem_solana/testdata/chainflip_vault_swap.json new file mode 100644 index 0000000000..db279dc559 --- /dev/null +++ b/core/crates/gem_solana/testdata/chainflip_vault_swap.json @@ -0,0 +1,145 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1772283531, + "meta": { + "computeUnitsConsumed": 21038, + "costUnits": 23110, + "err": null, + "fee": 22640, + "innerInstructions": [ + { + "index": 1, + "instructions": [ + { + "accounts": [ + 0, + 2 + ], + "data": "11114YWjDYGXu2RxurXut24B9xaWA81zHpjWeoK2fq2xF3e3M2StnGJiEy615V4b5kUbE5", + "programIdIndex": 4, + "stackHeight": 2 + }, + { + "accounts": [ + 0, + 3 + ], + "data": "3Bxs3zvXTyz3jM4s", + "programIdIndex": 4, + "stackHeight": 2 + }, + { + "accounts": [ + 0, + 1 + ], + "data": "3Bxs4NQNnDSisSzK", + "programIdIndex": 4, + "stackHeight": 2 + } + ] + } + ], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program J88B7gmadHzTNGiy54c9Ms8BsEXNdB2fntFyhKpk3qoT invoke [1]", + "Program log: Instruction: XSwapNative", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program J88B7gmadHzTNGiy54c9Ms8BsEXNdB2fntFyhKpk3qoT consumed 20738 of 419850 compute units", + "Program J88B7gmadHzTNGiy54c9Ms8BsEXNdB2fntFyhKpk3qoT success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success" + ], + "postBalances": [ + 118248276, + 150000000, + 2463840, + 1308480, + 1, + 2025360, + 1141440, + 1 + ], + "postTokenBalances": [], + "preBalances": [ + 270957476, + 0, + 0, + 1085760, + 1, + 2025360, + 1141440, + 1 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 403286793, + "transaction": { + "message": { + "accountKeys": [ + "CabroWmzUzcqqGvprUoC7RnJznuwX6qf5W1tSSaomri7", + "3tJ67qa2GDfvv2wcMYNUfN5QBZrFpTwcU8ASZKMvCTVU", + "CVxvaZ5CpzfWkYmFFS4g6xk4ArLxGE4bVqaKBuDDnuTG", + "FmAcjWaRFUxGWBfGT7G3CzcFeJFsewQ4KPJVG4f6fcob", + "11111111111111111111111111111111", + "ACLMuTFvDAb3oecQQGkTVqpUbhCKHG3EZ9uNXHK1W9ka", + "J88B7gmadHzTNGiy54c9Ms8BsEXNdB2fntFyhKpk3qoT", + "ComputeBudget111111111111111111111111111111" + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 4, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "JBscv3", + "programIdIndex": 7, + "stackHeight": 1 + }, + { + "accounts": [ + 5, + 1, + 0, + 2, + 3, + 4 + ], + "data": "6Fm3u1Eh4g6eLqD6G773qHcD1oZh3CF2GCR2Eq7EnsRAxoFYhefz4xts7Rc9JvV17vuXRPE9MyadXifGb68h1DsteiBBTokh5ncU6L3bbWo9k261nkkE6wEuwuFGH5qk2aT7JujD5Ko2jQeHxrGoCeziFp5A51yqe8YjkeaRnZ1cKQPtBtQWJsA6yQsFi789hixaYYvN7rAmqoLMRgQPcDjkazmYJEKb9aUrUjciz86CGcngZyzg4Ex5ooXoQV9gyMT73uboDq8yrQ4r3ZEQBNxFGMKtCQbVpM", + "programIdIndex": 6, + "stackHeight": 1 + }, + { + "accounts": [], + "data": "3GEzpibfC1uZ", + "programIdIndex": 7, + "stackHeight": 1 + } + ], + "recentBlockhash": "GigH1a61iesGEeEs9z8acpgc2thgVykeBoMw95fJ6cJC" + }, + "signatures": [ + "3qp65NuE5SEp1c4Av3ofhzNh5pFxeBtp3Tm2hCPuSRUC5nTjQpdeYaJ7xHRGnmKr2wxLjU3PVHGu6yPBs2aDAFPH" + ] + }, + "version": "legacy" + }, + "id": 1 +} diff --git a/core/crates/gem_solana/testdata/nft_mplcore_transfer.json b/core/crates/gem_solana/testdata/nft_mplcore_transfer.json new file mode 100644 index 0000000000..6e3851cf44 --- /dev/null +++ b/core/crates/gem_solana/testdata/nft_mplcore_transfer.json @@ -0,0 +1,102 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1752111467, + "meta": { + "computeUnitsConsumed": 13556, + "err": null, + "fee": 79998, + "innerInstructions": [], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d invoke [1]", + "Program log: Instruction: Transfer", + "Program log: programs/mpl-core/src/state/asset.rs:293:Approve", + "Program CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d consumed 13256 of 16457 compute units", + "Program CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d success" + ], + "postBalances": [ + 591346883, + 3615840, + 3514800, + 1, + 1141440, + 548723727 + ], + "postTokenBalances": [], + "preBalances": [ + 591426881, + 3615840, + 3514800, + 1, + 1141440, + 548723727 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 352273001, + "transaction": { + "message": { + "accountKeys": [ + "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "JATWmjADckr2M7TX5xMfo1HNfYS66DKot15fJ4hVLrVE", + "5pQfZttNUtaj8sySRY9RsdtB81aEAQDh2vnacpxiwTpT", + "ComputeBudget111111111111111111111111111111", + "CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d", + "G7B17AigRCGvwnxFc5U8zY5T3NBGduLzT7KYApNU2VdR" + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 4, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "3qdr87i4kbWX", + "programIdIndex": 3, + "stackHeight": 1 + }, + { + "accounts": [], + "data": "H5u3ZR", + "programIdIndex": 3, + "stackHeight": 1 + }, + { + "accounts": [ + 1, + 2, + 0, + 4, + 5, + 4, + 4 + ], + "data": "24o", + "programIdIndex": 4, + "stackHeight": 1 + } + ], + "recentBlockhash": "GP8PcK3Jj9kYdt9uUBfo1d5MvuoRdVj2ipNGhPo5stQA" + }, + "signatures": [ + "3LnToWvKmnssh2B6v2o7LbMVdA6didfcUkFEfyQ4h5usJkPCV8RCRLY1okxGuVq2wfgQJLhsoqpxDMVgPnbfa7Ph" + ] + }, + "transactionIndex": 977, + "version": "legacy" + }, + "id": 1 +} diff --git a/core/crates/gem_solana/testdata/nft_token_program_transfer.json b/core/crates/gem_solana/testdata/nft_token_program_transfer.json new file mode 100644 index 0000000000..8d01bd004b --- /dev/null +++ b/core/crates/gem_solana/testdata/nft_token_program_transfer.json @@ -0,0 +1,275 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1779221552, + "meta": { + "computeUnitsConsumed": 77938, + "costUnits": 81016, + "err": null, + "fee": 7500, + "innerInstructions": [ + { + "index": 2, + "instructions": [ + { + "accounts": [ + 1, + 7, + 8 + ], + "data": "C", + "programIdIndex": 11, + "stackHeight": 2 + }, + { + "accounts": [ + 1, + 7, + 2, + 0, + 0 + ], + "data": "g7gn3dpVguJSb", + "programIdIndex": 11, + "stackHeight": 2 + }, + { + "accounts": [ + 2, + 7, + 8 + ], + "data": "B", + "programIdIndex": 11, + "stackHeight": 2 + }, + { + "accounts": [ + 0, + 5 + ], + "data": "3Bxs3zsXzUzSwHyq", + "programIdIndex": 9, + "stackHeight": 2 + }, + { + "accounts": [ + 5 + ], + "data": "9krTDDojp1psPDkb", + "programIdIndex": 9, + "stackHeight": 2 + }, + { + "accounts": [ + 5 + ], + "data": "SYXsBkG6yKW2wWDcW8EDHR6D3P82bKxJGPpM65DD8nHqBfMP", + "programIdIndex": 9, + "stackHeight": 2 + } + ] + } + ], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s invoke [1]", + "Program log: IX: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 134 of 51162 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 182 of 46135 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 137 of 40235 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program log: Transfer 1447680 lamports to the new account", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program log: Allocate space for the account", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program log: Assign the account to the owning program", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s consumed 77638 of 99700 compute units", + "Program metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s success" + ], + "postBalances": [ + 489554096, + 2039280, + 2039280, + 5115600, + 0, + 1447680, + 238439993, + 1461600, + 1030080, + 1, + 0, + 2191440, + 3388104256, + 1141441, + 4496160, + 1, + 5556234 + ], + "postTokenBalances": [ + { + "accountIndex": 1, + "mint": "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "0", + "decimals": 0, + "uiAmount": null, + "uiAmountString": "0" + } + }, + { + "accountIndex": 2, + "mint": "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "owner": "FGbkx8rYTPJubjyScReeps6GA83D1nSmFr3BrN7buokb", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "1", + "decimals": 0, + "uiAmount": 1.0, + "uiAmountString": "1" + } + } + ], + "preBalances": [ + 489561596, + 2039280, + 2039280, + 5115600, + 1447680, + 0, + 238439993, + 1461600, + 1030080, + 1, + 0, + 2191440, + 3388104256, + 1141441, + 4496160, + 1, + 5556234 + ], + "preTokenBalances": [ + { + "accountIndex": 1, + "mint": "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "1", + "decimals": 0, + "uiAmount": 1.0, + "uiAmountString": "1" + } + }, + { + "accountIndex": 2, + "mint": "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "owner": "FGbkx8rYTPJubjyScReeps6GA83D1nSmFr3BrN7buokb", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "0", + "decimals": 0, + "uiAmount": null, + "uiAmountString": "0" + } + } + ], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 420839152, + "transaction": { + "message": { + "accountKeys": [ + "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "85LP75NvG35CuMoiiMsS6gmDgVw5sLg7598bqNMLjTbL", + "ENjN6nim3qATMUt7mrFT5FEeJWM7TFJ6ut14HywqRjkb", + "FuysEvrb25xEAVt8sxSyxrHdPPSFnxvqpAuUDEzW1gkr", + "7rtD4HYpSXLcenUzSju7Tpm4NAkhVVx3VbybddDFw4Xu", + "BuCrV9nvSGqkR8UmH6BbiSoYJyjA74gtcB6yZuxBT36L", + "FGbkx8rYTPJubjyScReeps6GA83D1nSmFr3BrN7buokb", + "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "3Ln3rVKiyrN2d8fJXfxGEJYU3gWY9TmJHCRyPhjMJmmV", + "11111111111111111111111111111111", + "Sysvar1nstructions1111111111111111111111111", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "auth9SigNpDKz4sJJ1DfCTuZrZNSAgh9sFD3rboVmgg", + "Brq4ESPuwPNwBhzvEcY2uM1fXTB171yWuem6U8jEiHiY", + "ComputeBudget111111111111111111111111111111", + "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 11, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "3hd3odyyp3J7", + "programIdIndex": 15, + "stackHeight": 1 + }, + { + "accounts": [], + "data": "JC3gyu", + "programIdIndex": 15, + "stackHeight": 1 + }, + { + "accounts": [ + 1, + 0, + 2, + 6, + 7, + 3, + 8, + 4, + 5, + 0, + 0, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "data": "D9kCuD4PTuQuyCK", + "programIdIndex": 16, + "stackHeight": 1 + } + ], + "recentBlockhash": "BWoJd9czeY8GmRjo57gujnSEjBzuZgp9hwGzLGRYgPie" + }, + "signatures": [ + "2dTUAtjDtviXPAHqmPqQpWGxGcHdyJX8o92Kcak3vEGDzKTEhsnTLux786ezmBdiaxwvQQ8DKUT2oR9vHkR9CEUU" + ] + }, + "version": "legacy" + }, + "id": 1 +} diff --git a/core/crates/gem_solana/testdata/pyusd_mint.json b/core/crates/gem_solana/testdata/pyusd_mint.json new file mode 100644 index 0000000000..5e0f33936e --- /dev/null +++ b/core/crates/gem_solana/testdata/pyusd_mint.json @@ -0,0 +1,105 @@ +{ + "id": "bd8e71f2-2100-41a9-9458-dc438c7af5ff", + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "2.0.15", + "slot": 306340458 + }, + "value": { + "data": { + "parsed": { + "info": { + "decimals": 6, + "extensions": [ + { + "extension": "mintCloseAuthority", + "state": { + "closeAuthority": "2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk" + } + }, + { + "extension": "permanentDelegate", + "state": { + "delegate": "2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk" + } + }, + { + "extension": "transferFeeConfig", + "state": { + "newerTransferFee": { + "epoch": 605, + "maximumFee": 0, + "transferFeeBasisPoints": 0 + }, + "olderTransferFee": { + "epoch": 605, + "maximumFee": 0, + "transferFeeBasisPoints": 0 + }, + "transferFeeConfigAuthority": "2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk", + "withdrawWithheldAuthority": "2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk", + "withheldAmount": 0 + } + }, + { + "extension": "confidentialTransferMint", + "state": { + "auditorElgamalPubkey": null, + "authority": "2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk", + "autoApproveNewAccounts": false + } + }, + { + "extension": "confidentialTransferFeeConfig", + "state": { + "authority": "2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk", + "harvestToMintEnabled": true, + "withdrawWithheldAuthorityElgamalPubkey": "HDfmQztzBN2Cc3rkDZuL88SfWw5sSajVMyiz5QaQHFc=", + "withheldAmount": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + } + }, + { + "extension": "transferHook", + "state": { + "authority": "2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk", + "programId": null + } + }, + { + "extension": "metadataPointer", + "state": { + "authority": "9nEfZqzTP3dfVWmzQy54TzsZqSQqDFVW4PhXdG9vYCVD", + "metadataAddress": "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" + } + }, + { + "extension": "tokenMetadata", + "state": { + "additionalMetadata": [], + "mint": "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + "name": "PayPal USD", + "symbol": "PYUSD", + "updateAuthority": "9nEfZqzTP3dfVWmzQy54TzsZqSQqDFVW4PhXdG9vYCVD", + "uri": "https://token-metadata.paxos.com/pyusd_metadata/prod/solana/pyusd_metadata.json" + } + } + ], + "freezeAuthority": "2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk", + "isInitialized": true, + "mintAuthority": "22mKJkKjGEQ3rampp5YKaSsaYZ52BUkcnUN6evXGsXzz", + "supply": "148331253437589" + }, + "type": "mint" + }, + "program": "spl-token-2022", + "space": 866 + }, + "executable": false, + "lamports": 222905340, + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "rentEpoch": 18446744073709552000, + "space": 866 + } + } +} \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/swap_okx_token_to_token.json b/core/crates/gem_solana/testdata/swap_okx_token_to_token.json new file mode 100644 index 0000000000..6e9c3da2da --- /dev/null +++ b/core/crates/gem_solana/testdata/swap_okx_token_to_token.json @@ -0,0 +1,619 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1773866864, + "meta": { + "computeUnitsConsumed": 198672, + "costUnits": 205364, + "err": null, + "fee": 26854, + "innerInstructions": [ + { + "index": 5, + "instructions": [ + { + "accounts": [ + 4, + 27, + 18, + 0 + ], + "data": "hGSoe9JEV5WdB", + "programIdIndex": 23, + "stackHeight": 2 + }, + { + "accounts": [ + 18, + 27, + 2, + 17 + ], + "data": "jBowmZNpdpTrD", + "programIdIndex": 23, + "stackHeight": 2 + }, + { + "accounts": [ + 17, + 16, + 11, + 18, + 5, + 13, + 15, + 14, + 23, + 22, + 26, + 27, + 20, + 12, + 10 + ], + "data": "ASCsAbe1UnEJvFzhALDBq1pjXrULTp6hcwhNPCzjtg9iMjTZjCptRRMA", + "programIdIndex": 19, + "stackHeight": 2 + }, + { + "accounts": [ + 18, + 27, + 13, + 17 + ], + "data": "hSaPzPGXBXxsf", + "programIdIndex": 23, + "stackHeight": 3 + }, + { + "accounts": [ + 15, + 20, + 5, + 11 + ], + "data": "iryv2me446DZ2", + "programIdIndex": 23, + "stackHeight": 3 + }, + { + "accounts": [ + 21 + ], + "data": "2Ap35ipDGK3kmeQhjR9PbtMdvBrswnD54mRpLp4AeZD9mh", + "programIdIndex": 7, + "stackHeight": 2 + }, + { + "accounts": [ + 5, + 20, + 1, + 17 + ], + "data": "iryv2me446DZ2", + "programIdIndex": 23, + "stackHeight": 2 + }, + { + "accounts": [ + 21 + ], + "data": "2i5U4oDAL1TsPpnegHVhp7hXYFFnZrcMXYkWEoDAqQZsap4rxiUDSTccUunPeWo3Q1KAxnmDf8UMGyv1rySnZefDLU99Wzb7GmzCTys2bg9QKXUFWPBbhxMZYpPK7hzYQ9CC9yCbVCXN2a6YaKtq1rwhZStXVqnNLHuaJp9GNYcWEvfmf5APR9FMWLGPBnmzS1LsGMYUfTy33fgHnKxi1RRAUNV4SSgQs8DQ6MXZ8iHg4tBuvALLXwjJTGqFeYzNZN5bk4onPvnDTs1L4PNUFuHy5FcrRy9PVwqmiyxYboG7MPdYmVkcbwrfsJiqfYuVBJAB8nB7SL6eh85gKRwA97jqBbRCa3VjAWywmwLmsCo6dMxpzan7Pev6SxidbedaUiF61aRHerxAGt4AbMUF5ZDQtkwG1Jnp", + "programIdIndex": 7, + "stackHeight": 2 + } + ] + } + ], + "loadedAddresses": { + "readonly": [ + "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK", + "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "Ag3hiK9svNixH9Vu5sD2CmK5fyDWrx9a1iVSbZW22bUS", + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "11111111111111111111111111111111", + "D9GYt4W7VvteKCSvTgyjzuiBJyTy94Kmr6YiC9fbxGjW", + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + ], + "writable": [ + "J5gdoukUCXrZH1GZGqkggrP3tApC9yQdgPdJPHs1LNp5", + "2Gkkt4JdzatJUhGDH8YdgG7pc5RRtdNRpnZSAGoZHM2f", + "8xtf9Tw99YsfYHZWQTTa3St2rYHv6ggSsbYMjpTm9Lo6", + "GLwtx9us2e34Ugdc2SNo171XfL5mhkuEPLwHmJh5BVHz", + "4tvVpgzk62BhnG4kLgbRmPjythEvLqdgqjXjFZ3nky38", + "8EgEfhQjSEFn7iB9keLxmvzzJGDJH8Hn491BVj2cewpZ", + "9iFER3bpjf1PTTCQCfTRu17EJgvsxo9pVyA9QWwEuX4x", + "ARu4n5mFdZogZAravu7CcizaojWnS6oqka37gdLT5SZn", + "8XrtGP8RG33AnrN2yJ6H3gnXPNQ3dYXKqM72bpzhcsd6" + ] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", + "Program log: CreateIdempotent", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 5838 of 218240 compute units", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", + "Program log: CreateIdempotent", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 7437 of 212402 compute units", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", + "Program log: CreateIdempotent", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 5937 of 204965 compute units", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u invoke [1]", + "Program log: Instruction: SwapTob", + "Program log: commission_rate: 5000000, platform_fee_rate: 0, trim_rate: 100, commission_direction: true, acc_close_flag: false", + "Program log: order_id: 1002210890", + "Program log: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "Program log: HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "Program log: H4WCM3nCeWc6auqFGNfZTjt3ZMC5cWcLsD9Ri6WNx37m", + "Program log: H4WCM3nCeWc6auqFGNfZTjt3ZMC5cWcLsD9Ri6WNx37m", + "Program log: before_source_balance: 56061276, before_destination_balance: 0, amount_in: 55780969, expect_amount_out: 2190151370200, slippage: 100", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 174539 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 163709 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program log: commission_direction: true, commission_amount: 280306, commission_adjust_amount: 0", + "Program log: 5a4wEUC6f5pBnuZEF6ym8cR1DPw3tWawGgbPkjnzhx9J", + "Program log: Dex::RaydiumClmmSwapV2 amount_in: 55780969, offset: 0", + "Program log: 2Gkkt4JdzatJUhGDH8YdgG7pc5RRtdNRpnZSAGoZHM2f", + "Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK invoke [2]", + "Program log: Instruction: SwapV2", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 78577 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6147 of 69780 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program data: QMbN6CYIceIS5N/wM8ticsSK9nmUevqSzhHDn6Buh7GwkL1q5f1vsIwZ/fJnU/k3TRL1wVo7FYnIAN7xOUt5dDIszpdd1jeRb+mi+wTRK4xVbo3/RBaSz9xPOZvdM6FH3Uevp/9N340Fd0wMqSxaKEcaYi++PrjiJLD4HLoxqk8EtRj2/vDWW2kmUwMAAAAAAAAAAAAAAADYxTHv/QEAAAAAAAAAAAAAAWRGdH+K5VUnxgAAAAAAAACTalMCqJ8AAAAAAAAAAAAAOp0BAA==", + "Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK consumed 79242 of 138846 compute units", + "Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK success", + "Program log: SwapEvent { dex: RaydiumClmmSwapV2, amount_in: 55780969, amount_out: 2190151370200 }", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u invoke [2]", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u consumed 5048 of 51307 compute units", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6147 of 40544 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program log: after_source_balance: 1, after_destination_balance: 2190151370200, source_token_change: 56061275, destination_token_change: 2190151370200", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u invoke [2]", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u consumed 5048 of 25668 compute units", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u success", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u consumed 179160 of 199028 compute units", + "Program proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u success" + ], + "postBalances": [ + 926146, + 2039280, + 2039283, + 2039280, + 2039281, + 2039280, + 2899447380, + 53592440, + 3316551056, + 1, + 13641600, + 11637121, + 72161280, + 2039281, + 32092560, + 2039280, + 1709413, + 3973802750, + 2039291, + 1854540726, + 1461719, + 0, + 58559539, + 5595651284, + 1, + 3271981826, + 523000134, + 494078749912 + ], + "postTokenBalances": [ + { + "accountIndex": 1, + "mint": "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "owner": "H4WCM3nCeWc6auqFGNfZTjt3ZMC5cWcLsD9Ri6WNx37m", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "2190151370200", + "decimals": 9, + "uiAmount": 2190.1513702, + "uiAmountString": "2190.1513702" + } + }, + { + "accountIndex": 2, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "982967309", + "decimals": 6, + "uiAmount": 982.967309, + "uiAmountString": "982.967309" + } + }, + { + "accountIndex": 3, + "mint": "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "owner": "D9GYt4W7VvteKCSvTgyjzuiBJyTy94Kmr6YiC9fbxGjW", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "359687602795", + "decimals": 9, + "uiAmount": 359.687602795, + "uiAmountString": "359.687602795" + } + }, + { + "accountIndex": 4, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "H4WCM3nCeWc6auqFGNfZTjt3ZMC5cWcLsD9Ri6WNx37m", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "1", + "decimals": 6, + "uiAmount": 0.000001, + "uiAmountString": "0.000001" + } + }, + { + "accountIndex": 5, + "mint": "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "owner": "ARu4n5mFdZogZAravu7CcizaojWnS6oqka37gdLT5SZn", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "0", + "decimals": 9, + "uiAmount": null, + "uiAmountString": "0" + } + }, + { + "accountIndex": 13, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "2Gkkt4JdzatJUhGDH8YdgG7pc5RRtdNRpnZSAGoZHM2f", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "797803181899", + "decimals": 6, + "uiAmount": 797803.181899, + "uiAmountString": "797803.181899" + } + }, + { + "accountIndex": 15, + "mint": "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "owner": "2Gkkt4JdzatJUhGDH8YdgG7pc5RRtdNRpnZSAGoZHM2f", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "3578051914880269", + "decimals": 9, + "uiAmount": 3578051.914880269, + "uiAmountString": "3578051.914880269" + } + }, + { + "accountIndex": 18, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "ARu4n5mFdZogZAravu7CcizaojWnS6oqka37gdLT5SZn", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "586828325", + "decimals": 6, + "uiAmount": 586.828325, + "uiAmountString": "586.828325" + } + } + ], + "preBalances": [ + 953000, + 2039280, + 2039283, + 2039280, + 2039281, + 2039280, + 2899447380, + 53592440, + 3316551056, + 1, + 13641600, + 11637121, + 72161280, + 2039281, + 32092560, + 2039280, + 1709413, + 3973802750, + 2039291, + 1854540726, + 1461719, + 0, + 58559539, + 5595651284, + 1, + 3271981826, + 523000134, + 494078749912 + ], + "preTokenBalances": [ + { + "accountIndex": 1, + "mint": "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "owner": "H4WCM3nCeWc6auqFGNfZTjt3ZMC5cWcLsD9Ri6WNx37m", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "0", + "decimals": 9, + "uiAmount": null, + "uiAmountString": "0" + } + }, + { + "accountIndex": 2, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "982687003", + "decimals": 6, + "uiAmount": 982.687003, + "uiAmountString": "982.687003" + } + }, + { + "accountIndex": 3, + "mint": "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "owner": "D9GYt4W7VvteKCSvTgyjzuiBJyTy94Kmr6YiC9fbxGjW", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "359687602795", + "decimals": 9, + "uiAmount": 359.687602795, + "uiAmountString": "359.687602795" + } + }, + { + "accountIndex": 4, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "H4WCM3nCeWc6auqFGNfZTjt3ZMC5cWcLsD9Ri6WNx37m", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "56061276", + "decimals": 6, + "uiAmount": 56.061276, + "uiAmountString": "56.061276" + } + }, + { + "accountIndex": 5, + "mint": "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "owner": "ARu4n5mFdZogZAravu7CcizaojWnS6oqka37gdLT5SZn", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "0", + "decimals": 9, + "uiAmount": null, + "uiAmountString": "0" + } + }, + { + "accountIndex": 13, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "2Gkkt4JdzatJUhGDH8YdgG7pc5RRtdNRpnZSAGoZHM2f", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "797747400930", + "decimals": 6, + "uiAmount": 797747.40093, + "uiAmountString": "797747.40093" + } + }, + { + "accountIndex": 15, + "mint": "HmMubgKx91Tpq3jmfcKQwsv5HrErqnCTTRJMB6afFR2u", + "owner": "2Gkkt4JdzatJUhGDH8YdgG7pc5RRtdNRpnZSAGoZHM2f", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "3580242066250469", + "decimals": 9, + "uiAmount": 3580242.066250469, + "uiAmountString": "3580242.066250469" + } + }, + { + "accountIndex": 18, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "ARu4n5mFdZogZAravu7CcizaojWnS6oqka37gdLT5SZn", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "586828325", + "decimals": 6, + "uiAmount": 586.828325, + "uiAmountString": "586.828325" + } + } + ], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 407308454, + "transaction": { + "message": { + "accountKeys": [ + "H4WCM3nCeWc6auqFGNfZTjt3ZMC5cWcLsD9Ri6WNx37m", + "A9r4C5SJsA59xKdZUMCRA4XiDDgC7FgfqVETXjxxvmMd", + "5a4wEUC6f5pBnuZEF6ym8cR1DPw3tWawGgbPkjnzhx9J", + "AE2ysLGTBcpCxhWTjbUverpwTFELuH53vGsr6Abi13kY", + "CppmHztt696Xem9GEmY5eDkPxfjcfF1i4yTaU9WcRjQE", + "NLYdo7tGWtJzefZkXZtf9dQ111K6xbVgox5J8X8GkxJ", + "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "ComputeBudget111111111111111111111111111111" + ], + "addressTableLookups": [ + { + "accountKey": "2g4dVisVRev4Rr8zmKh4was2zyFTmdEuc8DnXgqkzMHc", + "readonlyIndexes": [ + 8, + 12 + ], + "writableIndexes": [ + 1, + 2, + 3, + 4, + 5, + 7, + 10 + ] + }, + { + "accountKey": "Ga7MuV4c198RzhFvvpEHFVLUHaEDAM1VqW2rr2sJqfxe", + "readonlyIndexes": [ + 113, + 65, + 66, + 68, + 106, + 108, + 62 + ], + "writableIndexes": [ + 1, + 3 + ] + } + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 4, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "JVZ5vF", + "programIdIndex": 9, + "stackHeight": 1 + }, + { + "accounts": [], + "data": "3gJqkocMWaMm", + "programIdIndex": 9, + "stackHeight": 1 + }, + { + "accounts": [ + 0, + 1, + 0, + 20, + 24, + 23 + ], + "data": "2", + "programIdIndex": 8, + "stackHeight": 1 + }, + { + "accounts": [ + 0, + 2, + 6, + 27, + 24, + 23 + ], + "data": "2", + "programIdIndex": 8, + "stackHeight": 1 + }, + { + "accounts": [ + 0, + 3, + 25, + 20, + 24, + 23 + ], + "data": "2", + "programIdIndex": 8, + "stackHeight": 1 + }, + { + "accounts": [ + 0, + 4, + 1, + 27, + 20, + 2, + 7, + 17, + 18, + 5, + 23, + 23, + 8, + 24, + 21, + 7, + 19, + 17, + 18, + 5, + 16, + 11, + 13, + 15, + 14, + 23, + 22, + 26, + 27, + 20, + 12, + 10, + 24, + 24, + 3 + ], + "data": "UYwQwy5pgDfMEHchRuxu5FA8LC1MxnevT3kc69d9hSQgb6PkzknHDNaAAM1kAPKGEW7", + "programIdIndex": 7, + "stackHeight": 1 + } + ], + "recentBlockhash": "EDEGmBHvcnvXBZBADhResxyc7PSDfNhCuWT5rqkWYt81" + }, + "signatures": [ + "4o4hLvZb9MBNiQhGng3b5BUBwJNYNroHdBFF78iFJ8KnkUKxZXQGW7qUnRFCKr2qpGB74swQbLk2UeAyVhLyM2nn" + ] + }, + "version": 0 + }, + "id": 1 +} diff --git a/core/crates/gem_solana/testdata/swap_sol_to_token.json b/core/crates/gem_solana/testdata/swap_sol_to_token.json new file mode 100644 index 0000000000..9cf1232c5a --- /dev/null +++ b/core/crates/gem_solana/testdata/swap_sol_to_token.json @@ -0,0 +1,769 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1748539010, + "meta": { + "computeUnitsConsumed": 211069, + "err": null, + "fee": 57500, + "innerInstructions": [ + { + "index": 2, + "instructions": [ + { + "accounts": [ + 25 + ], + "data": "84eT", + "programIdIndex": 13, + "stackHeight": 2 + }, + { + "accounts": [ + 0, + 7 + ], + "data": "11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL", + "programIdIndex": 10, + "stackHeight": 2 + }, + { + "accounts": [ + 7 + ], + "data": "P", + "programIdIndex": 13, + "stackHeight": 2 + }, + { + "accounts": [ + 7, + 25 + ], + "data": "6V5gZwAiypU4xFKnNRNuz6ngGYxcAreeD8ZHpMvMyTfSF", + "programIdIndex": 13, + "stackHeight": 2 + } + ] + }, + { + "index": 5, + "instructions": [ + { + "accounts": [ + 7, + 1, + 0 + ], + "data": "3ay2hEw4e3yH", + "programIdIndex": 13, + "stackHeight": 2 + }, + { + "accounts": [ + 17, + 24, + 18, + 20, + 1, + 6, + 25, + 27, + 19, + 24, + 16, + 13, + 13, + 26, + 24, + 3, + 4, + 5 + ], + "data": "PgQWtn8ozix6fcXzmaH33cABwM3RwpN95", + "programIdIndex": 24, + "stackHeight": 2 + }, + { + "accounts": [ + 1, + 25, + 18, + 16 + ], + "data": "hjt27wSFrm67A", + "programIdIndex": 13, + "stackHeight": 3 + }, + { + "accounts": [ + 20, + 27, + 6, + 17 + ], + "data": "gATXu1CzbZZPB", + "programIdIndex": 13, + "stackHeight": 3 + }, + { + "accounts": [ + 26 + ], + "data": "yCGxBopjnVNQkNP5usq1PnuX63UpLUMAPbtb9tJ3pXKjaHfWnNzzWHRPvjXskAMby9LzT62RtrCThsDVb67RUC6MB1J8frfHTucXQheSKRCjNqZo2xsT25YeqNtGaF7pXNyQGUxVEWsF8q2ugVDVLkePqo761SQ4MnKsHxp8CP7piK76L5yvzSNUVhPpyZeU4jSRcB", + "programIdIndex": 24, + "stackHeight": 3 + }, + { + "accounts": [ + 15 + ], + "data": "QMqFu4fYGGeUEysFnenhAvBobXTzswhLdvQq6s8axxcbKUPRksm2543pJNNNHVd1VJ58FCg7NVh9cMuPYiMKNyfUpUXSDci9arMkqVwgC1zp93MuSNXXyYH52XjWsAGRZU9dzNMD2rbkFy2UahB1pynfQp4YNgfs6xhCEyeRiJ4gMgs", + "programIdIndex": 12, + "stackHeight": 2 + }, + { + "accounts": [ + 16, + 22, + 23, + 21, + 8, + 6, + 13, + 29 + ], + "data": "HtHwpRcqFtP7vVywcunoxeCx", + "programIdIndex": 28, + "stackHeight": 2 + }, + { + "accounts": [ + 6, + 21, + 16 + ], + "data": "3EFghwD6AbLs", + "programIdIndex": 13, + "stackHeight": 3 + }, + { + "accounts": [ + 23, + 8, + 22 + ], + "data": "3RHthtkB4thD", + "programIdIndex": 13, + "stackHeight": 3 + }, + { + "accounts": [ + 15 + ], + "data": "QMqFu4fYGGeUEysFnenhAvC84LqwVcxGQ4jSwmA9PGzJR1g2ER75NWZc21f6bKJsroAigqt4u1HzRjCGPEKxBiajE3Bo4dKU7Bjdu1ncQd14WjJc6SFhVqQvxvpJRmNtb7jZ6djVjdSh3b2T1H8ukPG3XQdX97KRHUygkEbRxKYzGA3", + "programIdIndex": 12, + "stackHeight": 2 + }, + { + "accounts": [ + 8, + 2, + 16 + ], + "data": "3vHt4Tm2bgf9", + "programIdIndex": 13, + "stackHeight": 2 + }, + { + "accounts": [ + 15 + ], + "data": "2qWhKzSZDTHhTkHUC1NYnTggN52WCf8MDXT6mxxQvwS1K8zKJbDsc3emFRSvSPCoMnKAJKz3RmPW1EC7pQMrLLoBxKGKYdhcG9RXATW6Nc1vfuVS7tBcPW5p3", + "programIdIndex": 12, + "stackHeight": 2 + }, + { + "accounts": [ + 8, + 9, + 16 + ], + "data": "3TH4FCiAiwqq", + "programIdIndex": 13, + "stackHeight": 2 + } + ] + } + ], + "loadedAddresses": { + "readonly": [ + "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo", + "So11111111111111111111111111111111111111112", + "D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "SoLFiHG9TfgtdUXUjWAxi3LtvYuFyDLVhBWxdMZxyCe", + "Sysvar1nstructions1111111111111111111111111", + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" + ], + "writable": [ + "3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL", + "8kR2HTHzPtTJuzpFZ8jtGCQ9TpahPaWbZfTNRs2GJdxq", + "8mLREQqtxf9yashib1PQTKFqQYaHWVUnLHkaNJxKYEkk", + "EeThDNkUuNhJFHYqR3yTB6wzcj1hrubgVQuvSSGjNt4W", + "9dWWzz1eLTKX5tuHBQT8qexq3tskdnsqaDudoNrEt7TJ", + "AxHocY4moH8roYQXMQWqoehtW5piMtTJQYmfL4wQ83D8", + "FfcnazsC12gejkhp4gY96Jb9RYRMsnCDqsbeuQYknUKi" + ] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", + "Program log: CreateIdempotent", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: GetAccountDataSize", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1569 of 409795 compute units", + "Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program log: Initialize the associated token account", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: InitializeImmutableOwner", + "Program log: Please upgrade to SPL Token 2022 for immutable owner support", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 403208 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: InitializeAccount3", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3158 of 399326 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 23815 of 419700 compute units", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", + "Program 11111111111111111111111111111111 invoke [1]", + "Program 11111111111111111111111111111111 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]", + "Program log: Instruction: SyncNative", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3045 of 395735 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]", + "Program log: Instruction: SharedAccountsRoute", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4735 of 388808 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo invoke [2]", + "Program log: Instruction: Swap", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6238 of 341231 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 331451 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo invoke [3]", + "Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo consumed 2135 of 321996 compute units", + "Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo success", + "Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo consumed 60252 of 378715 compute units", + "Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 316731 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program SoLFiHG9TfgtdUXUjWAxi3LtvYuFyDLVhBWxdMZxyCe invoke [2]", + "Program log: @@@:k+MgdirGGcOAcmC2LXdPvtYOh8EmELB0FlGrDG/pTI+alpYAAAAAALwjdhQAAAAAAAAAAAAAAAA=", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 241093 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 234308 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program SoLFiHG9TfgtdUXUjWAxi3LtvYuFyDLVhBWxdMZxyCe consumed 84427 of 313850 compute units", + "Program SoLFiHG9TfgtdUXUjWAxi3LtvYuFyDLVhBWxdMZxyCe success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 227687 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 224969 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 218816 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 216623 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 180844 of 392690 compute units", + "Program return: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 Up4ZAAAAAAA=", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]", + "Program log: Instruction: CloseAccount", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2915 of 211846 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success" + ], + "postBalances": [ + 432970193, + 104224250, + 2039280, + 71437440, + 71437440, + 71437440, + 2039980, + 0, + 2039780, + 2039280, + 1, + 1, + 1141440, + 934087680, + 731913600, + 1017968, + 6373815819, + 10882836, + 84605662469, + 23385600, + 2039280, + 2039280, + 20378880, + 2039280, + 1141440, + 1045539216193, + 4000100, + 391278817123, + 1141440, + 0, + 117267510278 + ], + "postTokenBalances": [ + { + "accountIndex": 1, + "mint": "So11111111111111111111111111111111111111112", + "owner": "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "102184579", + "decimals": 9, + "uiAmount": 0.102184579, + "uiAmountString": "0.102184579" + } + }, + { + "accountIndex": 2, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "115649294", + "decimals": 6, + "uiAmount": 115.649294, + "uiAmountString": "115.649294" + } + }, + { + "accountIndex": 6, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "327817", + "decimals": 6, + "uiAmount": 0.327817, + "uiAmountString": "0.327817" + } + }, + { + "accountIndex": 8, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "23587739", + "decimals": 6, + "uiAmount": 23.587739, + "uiAmountString": "23.587739" + } + }, + { + "accountIndex": 9, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "19566515", + "decimals": 6, + "uiAmount": 19.566515, + "uiAmountString": "19.566515" + } + }, + { + "accountIndex": 18, + "mint": "So11111111111111111111111111111111111111112", + "owner": "3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "84603623189", + "decimals": 9, + "uiAmount": 84.603623189, + "uiAmountString": "84.603623189" + } + }, + { + "accountIndex": 20, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "9731933016", + "decimals": 6, + "uiAmount": 9731.933016, + "uiAmountString": "9731.933016" + } + }, + { + "accountIndex": 21, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "AxHocY4moH8roYQXMQWqoehtW5piMtTJQYmfL4wQ83D8", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "566300714605", + "decimals": 6, + "uiAmount": 566300.714605, + "uiAmountString": "566300.714605" + } + }, + { + "accountIndex": 23, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "AxHocY4moH8roYQXMQWqoehtW5piMtTJQYmfL4wQ83D8", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "363277610112", + "decimals": 6, + "uiAmount": 363277.610112, + "uiAmountString": "363277.610112" + } + } + ], + "preBalances": [ + 443027693, + 104224250, + 2039280, + 71437440, + 71437440, + 71437440, + 2039980, + 0, + 2039780, + 2039280, + 1, + 1, + 1141440, + 934087680, + 731913600, + 1017968, + 6373815819, + 10882836, + 84595662469, + 23385600, + 2039280, + 2039280, + 20378880, + 2039280, + 1141440, + 1045539216193, + 4000100, + 391278817123, + 1141440, + 0, + 117267510278 + ], + "preTokenBalances": [ + { + "accountIndex": 1, + "mint": "So11111111111111111111111111111111111111112", + "owner": "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "102184579", + "decimals": 9, + "uiAmount": 0.102184579, + "uiAmountString": "0.102184579" + } + }, + { + "accountIndex": 2, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "115640858", + "decimals": 6, + "uiAmount": 115.640858, + "uiAmountString": "115.640858" + } + }, + { + "accountIndex": 6, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "327817", + "decimals": 6, + "uiAmount": 0.327817, + "uiAmountString": "0.327817" + } + }, + { + "accountIndex": 8, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "23587739", + "decimals": 6, + "uiAmount": 23.587739, + "uiAmountString": "23.587739" + } + }, + { + "accountIndex": 9, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "17887585", + "decimals": 6, + "uiAmount": 17.887585, + "uiAmountString": "17.887585" + } + }, + { + "accountIndex": 18, + "mint": "So11111111111111111111111111111111111111112", + "owner": "3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "84593623189", + "decimals": 9, + "uiAmount": 84.593623189, + "uiAmountString": "84.593623189" + } + }, + { + "accountIndex": 20, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "9733620828", + "decimals": 6, + "uiAmount": 9733.620828, + "uiAmountString": "9733.620828" + } + }, + { + "accountIndex": 21, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "AxHocY4moH8roYQXMQWqoehtW5piMtTJQYmfL4wQ83D8", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "566299026793", + "decimals": 6, + "uiAmount": 566299.026793, + "uiAmountString": "566299.026793" + } + }, + { + "accountIndex": 23, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "AxHocY4moH8roYQXMQWqoehtW5piMtTJQYmfL4wQ83D8", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "363279297478", + "decimals": 6, + "uiAmount": 363279.297478, + "uiAmountString": "363279.297478" + } + } + ], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 343286716, + "transaction": { + "message": { + "accountKeys": [ + "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "g7dD1FHSemkUQrX1Eak37wzvDjscgBW2pFCENwjLdMX", + "4PcMeWoiBFXLEGqnbmrwCbkbgZe1BoQ1MSCA5kgzgptC", + "4jHAKp54K5ibNHdc4cNuvA6UPJXbkZtri8V2Eo4dyDd2", + "9KzwXTfyieS7V1D2jvPa1F9cVTkvZP5F6VDUc7qy7stN", + "CzfDWVUJ68SuwSztnUb59TuNBAG2wNnyzRwW16xNtVpN", + "DVCeozFGbe6ew3eWTnZByjHeYqTq1cvbrB7JJhkLxaRJ", + "GzZi7Akqm4qktaRcKCj96UieXD3873a8vZ68drUvk7M7", + "HkphEpUqnFBxBuCPEq5j1HA9L8EwmsmRT6UcFKziptM1", + "Hyeu2rx6LaVyzVErAsW8BbDBk3MZ7JREx1pCXDurMm92", + "11111111111111111111111111111111", + "ComputeBudget111111111111111111111111111111", + "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf", + "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ" + ], + "addressTableLookups": [ + { + "accountKey": "2kaUMDr1jnWG1XfetBrWE8KnCmcwAV9LpZiZvTxc9jej", + "readonlyIndexes": [ + 118, + 7, + 126, + 237 + ], + "writableIndexes": [ + 236, + 244, + 242, + 231 + ] + }, + { + "accountKey": "58G1beNP7YZwA1sHD3MTUaY3YxbYmZxntqk8Xb9tC9e4", + "readonlyIndexes": [ + 201, + 199, + 215 + ], + "writableIndexes": [ + 204, + 203, + 200 + ] + } + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 7, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "JBscv3", + "programIdIndex": 11, + "stackHeight": null + }, + { + "accounts": [], + "data": "3ReqxfsK4sr7", + "programIdIndex": 11, + "stackHeight": null + }, + { + "accounts": [ + 0, + 7, + 0, + 25, + 10, + 13 + ], + "data": "2", + "programIdIndex": 14, + "stackHeight": null + }, + { + "accounts": [ + 0, + 7 + ], + "data": "3Bxs4NN8M2Yn4TLb", + "programIdIndex": 10, + "stackHeight": null + }, + { + "accounts": [ + 7 + ], + "data": "J", + "programIdIndex": 13, + "stackHeight": null + }, + { + "accounts": [ + 13, + 16, + 0, + 7, + 1, + 8, + 9, + 25, + 30, + 2, + 12, + 15, + 12, + 24, + 17, + 24, + 18, + 20, + 1, + 6, + 25, + 27, + 19, + 24, + 16, + 13, + 13, + 26, + 24, + 3, + 4, + 5, + 12, + 28, + 16, + 22, + 23, + 21, + 8, + 6, + 13, + 29 + ], + "data": "jMabf4iAML85vf3SfhiHggfkMq1R81y9xFmaY176eMoUQMmfRijGaW7j", + "programIdIndex": 12, + "stackHeight": null + }, + { + "accounts": [ + 7, + 0, + 0 + ], + "data": "A", + "programIdIndex": 13, + "stackHeight": null + } + ], + "recentBlockhash": "DYPemZqvzMfTXab7TzyNcNBFDjnNqEakXq9mQwnmUHvM" + }, + "signatures": [ + "4f3RA1nY71qvM8PsFL5XGKebwRvqFyQSRc8ZgEgifoVygNfbvuHksJnZyWMfZFzDA3NNgfpLRfbJ2QYmjTnRN9eP" + ] + }, + "version": 0 + }, + "id": 1 + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/swap_token_to_sol.json b/core/crates/gem_solana/testdata/swap_token_to_sol.json new file mode 100644 index 0000000000..34bfad3cdd --- /dev/null +++ b/core/crates/gem_solana/testdata/swap_token_to_sol.json @@ -0,0 +1,525 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1748473941, + "meta": { + "computeUnitsConsumed": 111292, + "err": null, + "fee": 707891, + "innerInstructions": [ + { + "index": 2, + "instructions": [ + { + "accounts": [ + 0, + 1 + ], + "data": "11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL", + "programIdIndex": 14, + "stackHeight": 2 + }, + { + "accounts": [ + 1, + 12 + ], + "data": "6cDwd8nZewYShmzPCNfdDPoiuJm2TQLo2ymSPhdtHDKr5", + "programIdIndex": 13, + "stackHeight": 2 + } + ] + }, + { + "index": 3, + "instructions": [ + { + "accounts": [ + 4, + 0, + 17, + 18, + 12, + 2, + 1, + 5, + 6, + 19, + 7, + 13, + 13, + 14, + 20, + 21, + 16, + 8, + 9 + ], + "data": "5jRcjdixRUDTwceAeeooXa2rgX3SdyNud", + "programIdIndex": 16, + "stackHeight": 2 + }, + { + "accounts": [ + 2, + 18, + 5, + 0 + ], + "data": "hXteSMGZpAzXX", + "programIdIndex": 13, + "stackHeight": 3 + }, + { + "accounts": [ + 6, + 12, + 1, + 4 + ], + "data": "hULmxFNEG8oUC", + "programIdIndex": 13, + "stackHeight": 3 + }, + { + "accounts": [ + 6, + 12, + 7, + 4 + ], + "data": "hGBryEEtpu73E", + "programIdIndex": 13, + "stackHeight": 3 + }, + { + "accounts": [ + 6, + 12, + 8, + 4 + ], + "data": "hGBryEEtpu73E", + "programIdIndex": 13, + "stackHeight": 3 + }, + { + "accounts": [ + 21 + ], + "data": "9k6unfwB8yYie7YGjfXzMuX6gMsUrj1gPYzJJ2AS23N1HajfGXTbvRgxfJYo5WNmCQKwSekPhNJrEuzGxhePheLYNnviGvD1uQ1E8mN9QHKmyAPijmgod2hrRPb3uJeUeUHXxuaDeRiR2fumknN89swbzBr9s314R2Qe6iGNs8gKYfeTNdGTxAiQrRha53yi57jGhDNKkJfMaQpMfR34zMUuWQnAr3x63a5szAMeCEjMasSVfwSUcBc1MJhmbWad1wjWEqnjJjxUw8zTaFyJDdbQVgYYWgogt1sNE7tKha5Y9QBEytxTsaPyAKavJwgaD4HZTDrPQHSNtfQkAngZL4v6pNjjBjqpsBPrz9HNzG3kKuUxJwQZCuqjk5NBkAyVocaVW7yZ3kVvvMCYxTfv2t2TpCro74woD8vA3xzf5m6D2RMvUQq7snxXhG2YAmURWZgTN7mZ1wtWojnPJmbmBnpFTrNdNYxJdvrEYAC3WAS6t2ydmtY1QqM", + "programIdIndex": 16, + "stackHeight": 3 + }, + { + "accounts": [ + 15 + ], + "data": "QMqFu4fYGGeUEysFnenhAvD9ejyXHFDBV5ALHaSWH79rCnwQxmC7PdnTewrGxr9NtytNUCjGPL9BmrGrkbuKdhGq1CLRvDs4aWqahQLKfvMCFWkNjfhFNy3rfxkit6eyMGDxoUaCE3u9GDSswCRtt6of3FZMtTr2JecEcs3KDJtiB2f", + "programIdIndex": 11, + "stackHeight": 2 + }, + { + "accounts": [ + 1, + 3, + 0 + ], + "data": "3FVTQbLm71if", + "programIdIndex": 13, + "stackHeight": 2 + }, + { + "accounts": [ + 15 + ], + "data": "2qWhKzSZDTHhTkHUC1NYnTg5pjHBCLXu8Qyq1EoegRbYJawLHBGxowDaKmMmTgJN1C9n44yYAqMRGB5bWGNevUcbb5AJwoddmMGcRXtda58QFoesJaL7eHdPd", + "programIdIndex": 11, + "stackHeight": 2 + } + ] + } + ], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]", + "Program log: Instruction: CreateTokenAccount", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: InitializeAccount3", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3158 of 124205 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 6712 of 127732 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]", + "Program log: Instruction: Route", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA invoke [2]", + "Program log: Instruction: Sell", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6147 of 73631 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6238 of 64610 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6238 of 55483 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6238 of 46354 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program data: Pi83CqUD3CpVmDdoAAAAAHBZOadbAAAAAAAAAAAAAABwWTmnWwAAAAAAAAAAAAAAwF8Ig/DLAABSZ4LMEgAAAIwEbwgAAAAAFAAAAAAAAABrUQQAAAAAAAUAAAAAAAAAWxQBAAAAAAAhs2oIAAAAAGuKaAgAAAAAyYzU3QZT8eN7kuzVoFoqHfqVbaKn81H2b/+zsazQk3DgNvuzD00+VDonLzWAJQnPELr+fDZn26HW+AiRryv2ZpH4qygUA96v3Dv6m0b76s/nhj3ZvIh+3/CPwzlkloZGjfZw/fogxh9ueLrzJLJyGWTMVB/pTT9KbjnMmHRqGfODhHQpLmdalLQ27LCpmIlCMoqD3cYjOAKWEmfFzWEXy6JjF6U7oP1oxMlT7DDw4JuOc2h1HLKBVIbK4+mdCvnd2H28C2Mc2saQntR+99ZiiMLL5wO34Dzcp8lXMkDBiZkFAAAAAAAAAFsUAQAAAAAA", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA invoke [3]", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA consumed 2009 of 33631 compute units", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA success", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA consumed 84266 of 115352 compute units", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 29349 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4735 of 26872 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 20636 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 101365 of 121020 compute units", + "Program return: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 X2NmCAAAAAA=", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]", + "Program log: Instruction: CloseAccount", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2915 of 19655 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success" + ], + "postBalances": [ + 279195760, + 0, + 2039280, + 736613819, + 2978880, + 2039280, + 80601338401, + 3432509135045, + 467065541, + 0, + 1, + 1141440, + 1045539215943, + 934087680, + 1, + 1017918, + 1141440, + 4454404, + 1461600, + 13575736257763, + 731913600, + 0, + 0 + ], + "postTokenBalances": [ + { + "accountIndex": 2, + "mint": "BKpSnSdNdANUxKPsn4AQ8mf4b9BoeVs9JD1Q8cVkpump", + "owner": "G6ExCb2rehAsWWHgHWRsuqHWosSdzbXLGrGYD4CTvBR7", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "0", + "decimals": 6, + "uiAmount": null, + "uiAmountString": "0" + } + }, + { + "accountIndex": 3, + "mint": "So11111111111111111111111111111111111111112", + "owner": "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "734574148", + "decimals": 9, + "uiAmount": 0.734574148, + "uiAmountString": "0.734574148" + } + }, + { + "accountIndex": 5, + "mint": "BKpSnSdNdANUxKPsn4AQ8mf4b9BoeVs9JD1Q8cVkpump", + "owner": "EZmVxnJXMwZ8Szjfy7bnG7hC5LynzZDT6KCcGYNqE57V", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "224627498531120", + "decimals": 6, + "uiAmount": 224627498.53112, + "uiAmountString": "224627498.53112" + } + }, + { + "accountIndex": 6, + "mint": "So11111111111111111111111111111111111111112", + "owner": "EZmVxnJXMwZ8Szjfy7bnG7hC5LynzZDT6KCcGYNqE57V", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "80599299121", + "decimals": 9, + "uiAmount": 80.599299121, + "uiAmountString": "80.599299121" + } + }, + { + "accountIndex": 7, + "mint": "So11111111111111111111111111111111111111112", + "owner": "9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "3432507095765", + "decimals": 9, + "uiAmount": 3432.507095765, + "uiAmountString": "3432.507095765" + } + }, + { + "accountIndex": 8, + "mint": "So11111111111111111111111111111111111111112", + "owner": "7QbwHRcpdQsK6g3VpPcR41UKTvqR8mJ5Jgody7zHLNWh", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "465026261", + "decimals": 9, + "uiAmount": 0.465026261, + "uiAmountString": "0.465026261" + } + } + ], + "preBalances": [ + 138975812, + 0, + 2039280, + 736472751, + 2978880, + 2039280, + 80742548802, + 3432509064298, + 466994794, + 0, + 1, + 1141440, + 1045539215943, + 934087680, + 1, + 1017918, + 1141440, + 4454404, + 1461600, + 13575736257763, + 731913600, + 0, + 0 + ], + "preTokenBalances": [ + { + "accountIndex": 2, + "mint": "BKpSnSdNdANUxKPsn4AQ8mf4b9BoeVs9JD1Q8cVkpump", + "owner": "G6ExCb2rehAsWWHgHWRsuqHWosSdzbXLGrGYD4CTvBR7", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "393647577456", + "decimals": 6, + "uiAmount": 393647.577456, + "uiAmountString": "393647.577456" + } + }, + { + "accountIndex": 3, + "mint": "So11111111111111111111111111111111111111112", + "owner": "GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "734433080", + "decimals": 9, + "uiAmount": 0.73443308, + "uiAmountString": "0.73443308" + } + }, + { + "accountIndex": 5, + "mint": "BKpSnSdNdANUxKPsn4AQ8mf4b9BoeVs9JD1Q8cVkpump", + "owner": "EZmVxnJXMwZ8Szjfy7bnG7hC5LynzZDT6KCcGYNqE57V", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "224233850953664", + "decimals": 6, + "uiAmount": 224233850.953664, + "uiAmountString": "224233850.953664" + } + }, + { + "accountIndex": 6, + "mint": "So11111111111111111111111111111111111111112", + "owner": "EZmVxnJXMwZ8Szjfy7bnG7hC5LynzZDT6KCcGYNqE57V", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "80740509522", + "decimals": 9, + "uiAmount": 80.740509522, + "uiAmountString": "80.740509522" + } + }, + { + "accountIndex": 7, + "mint": "So11111111111111111111111111111111111111112", + "owner": "9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "3432507025018", + "decimals": 9, + "uiAmount": 3432.507025018, + "uiAmountString": "3432.507025018" + } + }, + { + "accountIndex": 8, + "mint": "So11111111111111111111111111111111111111112", + "owner": "7QbwHRcpdQsK6g3VpPcR41UKTvqR8mJ5Jgody7zHLNWh", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "464955514", + "decimals": 9, + "uiAmount": 0.464955514, + "uiAmountString": "0.464955514" + } + } + ], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 343121592, + "transaction": { + "message": { + "accountKeys": [ + "G6ExCb2rehAsWWHgHWRsuqHWosSdzbXLGrGYD4CTvBR7", + "AZASV6VF1TskkSR6PfmduLh1ffCsBdimCzzBU7pWUSqg", + "App3KKWTMUerp7i36ua2WcDjXHyNZneUPQpGdLdnM3fs", + "g7dD1FHSemkUQrX1Eak37wzvDjscgBW2pFCENwjLdMX", + "EZmVxnJXMwZ8Szjfy7bnG7hC5LynzZDT6KCcGYNqE57V", + "C3WFhDuvTMLKd1wo6BLt2xWNo15TcULt3t2ncHnV9AFi", + "E8hS2DP8JkMGcjKNsbkKgdMw8Hu6CWTnJZsf1PdYnnGj", + "Bvtgim23rfocUzxVX9j9QFxTbBnH8JZxnaGLCEkXvjKS", + "4ikPAPEn2CVWfQE26TMbTL9oSKnUCDYV87KiNKsv35VD", + "7QbwHRcpdQsK6g3VpPcR41UKTvqR8mJ5Jgody7zHLNWh", + "ComputeBudget111111111111111111111111111111", + "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "So11111111111111111111111111111111111111112", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "11111111111111111111111111111111", + "D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf", + "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA", + "ADyA8hdefvWN2dbGGWFotbzWxrAvLW83WG6QCVXvJKqw", + "BKpSnSdNdANUxKPsn4AQ8mf4b9BoeVs9JD1Q8cVkpump", + "9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "GS4CU59F31iL7aR2Q8zVS8DRrcRnXX1yjQ66TqNVQnaR", + "jitodontfronth6w6b6h2b6rze2h5u95cv3x2pm44o6" + ], + "addressTableLookups": [], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 13, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "EvvDtB", + "programIdIndex": 10, + "stackHeight": null + }, + { + "accounts": [], + "data": "3LH6DEvZigD5", + "programIdIndex": 10, + "stackHeight": null + }, + { + "accounts": [ + 1, + 0, + 12, + 13, + 14 + ], + "data": "2tDqDdUmhLW1t", + "programIdIndex": 11, + "stackHeight": null + }, + { + "accounts": [ + 13, + 0, + 2, + 1, + 11, + 12, + 3, + 15, + 11, + 16, + 4, + 0, + 17, + 18, + 12, + 2, + 1, + 5, + 6, + 19, + 7, + 13, + 13, + 14, + 20, + 21, + 16, + 8, + 9, + 22 + ], + "data": "PrpFmsY4d26dKbdKNXbdAkx4QJuT9oBDmcB5FuFrEptetXsP", + "programIdIndex": 11, + "stackHeight": null + }, + { + "accounts": [ + 1, + 0, + 0 + ], + "data": "A", + "programIdIndex": 13, + "stackHeight": null + } + ], + "recentBlockhash": "FE82Bd8iuySoNnDkzGHuA8jdrrDdWRZQr6svNWb2Uwqq" + }, + "signatures": [ + "421BfVMF4SCkn1k9FAWX2hjfJcgwYaRHauHP5i233ma4ARxLjvfWu7igUZFpaYLdzyuGHFzcvimMPdFh6VXpoAVL" + ] + }, + "version": 0 + }, + "id": 1 +} \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/swap_token_to_token.json b/core/crates/gem_solana/testdata/swap_token_to_token.json new file mode 100644 index 0000000000..e78ab99315 --- /dev/null +++ b/core/crates/gem_solana/testdata/swap_token_to_token.json @@ -0,0 +1,872 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1748473941, + "meta": { + "computeUnitsConsumed": 252119, + "err": null, + "fee": 57500, + "innerInstructions": [ + { + "index": 2, + "instructions": [ + { + "accounts": [ + 0, + 34, + 30, + 1, + 5, + 22, + 20, + 21, + 19, + 36, + 31, + 35, + 33, + 27, + 28 + ], + "data": "JJyz1VxGNKxptzvGGxiWF2VD8eNGZW6TGT", + "programIdIndex": 32, + "stackHeight": 2 + }, + { + "accounts": [ + 1, + 34, + 22, + 0 + ], + "data": "gvPShZQhKrzGM", + "programIdIndex": 28, + "stackHeight": 3 + }, + { + "accounts": [ + 36, + 31, + 35, + 20, + 5, + 21, + 30, + 27 + ], + "data": "P6F8AMLbzxVMaoEpUxq9T6rYCdd2buDeF", + "programIdIndex": 33, + "stackHeight": 3 + }, + { + "accounts": [ + 20, + 30, + 21, + 35 + ], + "data": "g9uEcEWSZiHuw", + "programIdIndex": 27, + "stackHeight": 4 + }, + { + "accounts": [ + 20, + 30, + 5, + 35 + ], + "data": "gxbuGA6eCfyjb", + "programIdIndex": 27, + "stackHeight": 4 + }, + { + "accounts": [ + 13 + ], + "data": "QMqFu4fYGGeUEysFnenhAvDLCKNcZ6RVNL1ETZ4Md2NKwNjTVbTMrb5rrjFMYcRVpGuS1fCfp9hnwxX5bhMZr5W2PYf7TYYNQAQUB1k5kkKRRgBLZ74LeKeysHFPG5xwR2kQoABH4eB6mgfcftnkmUC298LfTWHTT6fvoBzn34RrtHm", + "programIdIndex": 10, + "stackHeight": 2 + }, + { + "accounts": [ + 27, + 12, + 24, + 6, + 25, + 5, + 23, + 8, + 2, + 7, + 39 + ], + "data": "59p8WydnSZtTLE9dXbPuWMaWGP99HbGo5cX1gt4YVZWXRNMBWCimrHemuZ", + "programIdIndex": 38, + "stackHeight": 2 + }, + { + "accounts": [ + 5, + 23, + 12 + ], + "data": "3Qi3roi1YbSP", + "programIdIndex": 27, + "stackHeight": 3 + }, + { + "accounts": [ + 25, + 6, + 24 + ], + "data": "3QV8zJL6Uzfy", + "programIdIndex": 27, + "stackHeight": 3 + }, + { + "accounts": [ + 13 + ], + "data": "QMqFu4fYGGeUEysFnenhAvDWgqp1W7DbrMv3z8JcyrP4Bu3Yyyj7irLW76wEzMiFqkMXcsUXJG1WLwjdCWzNTL6957kdfWSD7SPFG2av5YHKd54e9uPHwkAfuurjuGuoGqhAeo3r6DBHrB93cuZ6pDUy52r9prnKViVHoLV6Q7CpCo1", + "programIdIndex": 10, + "stackHeight": 2 + }, + { + "accounts": [ + 17, + 14, + 11, + 15, + 16, + 6, + 4, + 18, + 18, + 26, + 12, + 27 + ], + "data": "2j6vnwYDURn8zcFftk5xLBk3whRgiUPpQG7", + "programIdIndex": 29, + "stackHeight": 2 + }, + { + "accounts": [ + 6, + 15, + 12 + ], + "data": "3QV8zJL6Uzfy", + "programIdIndex": 27, + "stackHeight": 3 + }, + { + "accounts": [ + 16, + 4, + 17 + ], + "data": "3wek6uB1yAnB", + "programIdIndex": 27, + "stackHeight": 3 + }, + { + "accounts": [ + 13 + ], + "data": "QMqFu4fYGGeUEysFnenhAvD866YwW6jMndC6NeFLmgrgSsQrYzqQkLQZLriiyYAHU47xKZ9Dcp6oHcAMxjdC9NFJecmNYi1Ua1ZLQMxJVTMdjDbS6tuJrCbG62KfdYZTt6LHZj21tSBcPxt4htZEA7KJh14trNvZk4Xnw2EhpL3dMSf", + "programIdIndex": 10, + "stackHeight": 2 + }, + { + "accounts": [ + 4, + 3, + 12 + ], + "data": "3c3xAhTY6VW7", + "programIdIndex": 27, + "stackHeight": 2 + }, + { + "accounts": [ + 13 + ], + "data": "2qWhKzSZDTHhTkHUC1NYnTggN52WCf8MDXT6mxxQvwS1K8zKJbDsc3emFRSvSPCoMnKAJKz3RmPW1EC7pQMrLLoBxKGKYdhcG9RXATW6Nc1vfuBCBzRJtztf1", + "programIdIndex": 10, + "stackHeight": 2 + }, + { + "accounts": [ + 4, + 3, + 12 + ], + "data": "3Z4MXtfMgfkK", + "programIdIndex": 27, + "stackHeight": 2 + } + ] + } + ], + "loadedAddresses": { + "readonly": [ + "Sysvar1nstructions1111111111111111111111111", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "stab1io8dHvK26KoHmTwwHyYmHRbUWbyEJx6CdrGabC", + "swapNyd8XiQwJ6ianp9snpu4brUqFxadzvHebnAXjJZ", + "vo1tWgqZMjG61Z2T9qUaMYKqZ75CYzMuaZ2LZP1n7HV", + "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + "7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME", + "8BSWYgAczR36C7ukr32v7uTepoRhYJYxAVnpBtYniZTm", + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc", + "GwRSc3EPw2fCLJN7zWwcApXgHSrfmj9m4H5sfk1W2SUJ" + ], + "writable": [ + "VLJQYMMHmjiJinxyFXUduQKWiYjfmetNAoMauk2ij8V", + "pQwjKq6a2Y2Lr5B5RsPCEs7nkfFJTLtQv8z6N8eG1ZB", + "D94tFiBfJzdZmcH6GtV39iXexWyVpNfwEH3CxEbqvsvr", + "J4HJYz4p7TRP96WVFky3vh7XryxoFehHjoRySUTeSeXw", + "3YnYpQMUnUFxd9D1GSx6k1sNM9XcYLy2T68ymuu1WutH", + "AioJRQXvcDLRhHMd6DAkTbbMpgVx63qSGQYmRBS2vHYA", + "ArLSJrSstZ3kjeZDyMAgjfjad1qdRZHHYaCQTQeAcTpa", + "EszmvzMNgMPDysbAJBvqPC4DtUcU76KvBVfC11r8sSv7", + "dwxR9YF7WwnJJu7bPC4UNcWFpcSsooH6fxbpoa3fTbJ", + "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", + "D3CDPQLoa9jY1LXCkpUqd3JQDWz8DX1LDE1dhmJt9fq4" + ] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]", + "Program log: Instruction: SharedAccountsRoute", + "Program swapNyd8XiQwJ6ianp9snpu4brUqFxadzvHebnAXjJZ invoke [2]", + "Program log: Instruction: SwapV2", + "Program data: rFJyzxtn0wQl2+GAvr17eBwX2OmVv1f6stLeVKMi50itgO7ixgUm9gMAAAAoxdBN1W4AAIggjgA+hQAAGLlfESoqAAA=", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 3939 of 351014 compute units", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success", + "Program vo1tWgqZMjG61Z2T9qUaMYKqZ75CYzMuaZ2LZP1n7HV invoke [3]", + "Program log: Instruction: WithdrawV2", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [4]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 332915 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [4]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 323971 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program vo1tWgqZMjG61Z2T9qUaMYKqZ75CYzMuaZ2LZP1n7HV consumed 24007 of 341481 compute units", + "Program vo1tWgqZMjG61Z2T9qUaMYKqZ75CYzMuaZ2LZP1n7HV success", + "Program swapNyd8XiQwJ6ianp9snpu4brUqFxadzvHebnAXjJZ consumed 97799 of 413091 compute units", + "Program swapNyd8XiQwJ6ianp9snpu4brUqFxadzvHebnAXjJZ success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 313557 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [2]", + "Program log: Instruction: Swap", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 272586 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 264881 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program data: 4cpJr5MroJZowQw6x63HLj9fsqaHKhx7g1G0uMOlkMFIUUE/4Y2EZgD2LjGKmnsJagAAAAAAAAAARygNIWwWCmoAAAAAAAAAAENCDwAAAAAAQe1YAAAAAAAAAAAAAAAAAAAAAAAAAAAAWAAAAAAAAAANAAAAAAAAAA==", + "Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc consumed 53570 of 310194 compute units", + "Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 254887 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y invoke [2]", + "Program log: Instruction: Swap", + "Program log: price_x: 1715782", + "Program log: price_y: 10000", + "Program log: 1389321720000000000000000000, 1715782, 10000000", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 204362 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 196750 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program log: XY15 197547751827,90971122557,5827905,999932", + "Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y consumed 66192 of 251650 compute units", + "Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 183722 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 181004 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 174851 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 172658 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 251819 of 419700 compute units", + "Program return: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 dS4PAAAAAAA=", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success" + ], + "postBalances": [ + 7634295473, + 2192400, + 70407360, + 2039280, + 2040080, + 2039580, + 847071822, + 70407360, + 70407360, + 1, + 1141440, + 29900160, + 13629037326, + 1017918, + 8741760, + 197555619012, + 2039280, + 5526240, + 2561280, + 3243360, + 2039280, + 2039280, + 2192400, + 3240993, + 5045819694, + 100730981737, + 0, + 934087680, + 1141440, + 1141440, + 391268817123, + 1920960, + 1141440, + 1141440, + 2064718951, + 941572, + 0, + 117242184885, + 1141440, + 0 + ], + "postTokenBalances": [ + { + "accountIndex": 1, + "mint": "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + "owner": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "uiTokenAmount": { + "amount": "41058697", + "decimals": 6, + "uiAmount": 41.058697, + "uiAmountString": "41.058697" + } + }, + { + "accountIndex": 3, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "115640858", + "decimals": 6, + "uiAmount": 115.640858, + "uiAmountString": "115.640858" + } + }, + { + "accountIndex": 4, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "15704584", + "decimals": 6, + "uiAmount": 15.704584, + "uiAmountString": "15.704584" + } + }, + { + "accountIndex": 5, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "69519859", + "decimals": 6, + "uiAmount": 69.519859, + "uiAmountString": "69.519859" + } + }, + { + "accountIndex": 6, + "mint": "So11111111111111111111111111111111111111112", + "owner": "BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "843032041", + "decimals": 9, + "uiAmount": 0.843032041, + "uiAmountString": "0.843032041" + } + }, + { + "accountIndex": 15, + "mint": "So11111111111111111111111111111111111111112", + "owner": "D94tFiBfJzdZmcH6GtV39iXexWyVpNfwEH3CxEbqvsvr", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "197553579732", + "decimals": 9, + "uiAmount": 197.553579732, + "uiAmountString": "197.553579732" + } + }, + { + "accountIndex": 16, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "D94tFiBfJzdZmcH6GtV39iXexWyVpNfwEH3CxEbqvsvr", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "90970122625", + "decimals": 6, + "uiAmount": 90970.122625, + "uiAmountString": "90970.122625" + } + }, + { + "accountIndex": 20, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "2496364478518", + "decimals": 6, + "uiAmount": 2496364.478518, + "uiAmountString": "2496364.478518" + } + }, + { + "accountIndex": 21, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "8UgoPZAR8ZLoEmV6pJ8SZ6JKESP2X8nbnrZSdSgNtg1y", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "122323748", + "decimals": 6, + "uiAmount": 122.323748, + "uiAmountString": "122.323748" + } + }, + { + "accountIndex": 22, + "mint": "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + "owner": "7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "uiTokenAmount": { + "amount": "237641092654", + "decimals": 6, + "uiAmount": 237641.092654, + "uiAmountString": "237641.092654" + } + }, + { + "accountIndex": 23, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "10331831874", + "decimals": 6, + "uiAmount": 10331.831874, + "uiAmountString": "10331.831874" + } + }, + { + "accountIndex": 25, + "mint": "So11111111111111111111111111111111111111112", + "owner": "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "100728832256", + "decimals": 9, + "uiAmount": 100.728832256, + "uiAmountString": "100.728832256" + } + } + ], + "preBalances": [ + 7634352973, + 2192400, + 70407360, + 2039280, + 2040080, + 2039580, + 847071822, + 70407360, + 70407360, + 1, + 1141440, + 29900160, + 13629037326, + 1017918, + 8741760, + 197549791107, + 2039280, + 5526240, + 2561280, + 3243360, + 2039280, + 2039280, + 2192400, + 3240993, + 5045819694, + 100736809642, + 0, + 934087680, + 1141440, + 1141440, + 391268817123, + 1920960, + 1141440, + 1141440, + 2064718951, + 941572, + 0, + 117242184885, + 1141440, + 0 + ], + "preTokenBalances": [ + { + "accountIndex": 1, + "mint": "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + "owner": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "uiTokenAmount": { + "amount": "42058697", + "decimals": 6, + "uiAmount": 42.058697, + "uiAmountString": "42.058697" + } + }, + { + "accountIndex": 3, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "114640926", + "decimals": 6, + "uiAmount": 114.640926, + "uiAmountString": "114.640926" + } + }, + { + "accountIndex": 4, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "15704584", + "decimals": 6, + "uiAmount": 15.704584, + "uiAmountString": "15.704584" + } + }, + { + "accountIndex": 5, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "69519859", + "decimals": 6, + "uiAmount": 69.519859, + "uiAmountString": "69.519859" + } + }, + { + "accountIndex": 6, + "mint": "So11111111111111111111111111111111111111112", + "owner": "BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "843032041", + "decimals": 9, + "uiAmount": 0.843032041, + "uiAmountString": "0.843032041" + } + }, + { + "accountIndex": 15, + "mint": "So11111111111111111111111111111111111111112", + "owner": "D94tFiBfJzdZmcH6GtV39iXexWyVpNfwEH3CxEbqvsvr", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "197547751827", + "decimals": 9, + "uiAmount": 197.547751827, + "uiAmountString": "197.547751827" + } + }, + { + "accountIndex": 16, + "mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "owner": "D94tFiBfJzdZmcH6GtV39iXexWyVpNfwEH3CxEbqvsvr", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "90971122557", + "decimals": 6, + "uiAmount": 90971.122557, + "uiAmountString": "90971.122557" + } + }, + { + "accountIndex": 20, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "2496365478525", + "decimals": 6, + "uiAmount": 2496365.478525, + "uiAmountString": "2496365.478525" + } + }, + { + "accountIndex": 21, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "8UgoPZAR8ZLoEmV6pJ8SZ6JKESP2X8nbnrZSdSgNtg1y", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "122323744", + "decimals": 6, + "uiAmount": 122.323744, + "uiAmountString": "122.323744" + } + }, + { + "accountIndex": 22, + "mint": "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + "owner": "7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "uiTokenAmount": { + "amount": "237640092654", + "decimals": 6, + "uiAmount": 237640.092654, + "uiAmountString": "237640.092654" + } + }, + { + "accountIndex": 23, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "10330831871", + "decimals": 6, + "uiAmount": 10330.831871, + "uiAmountString": "10330.831871" + } + }, + { + "accountIndex": 25, + "mint": "So11111111111111111111111111111111111111112", + "owner": "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "100734660161", + "decimals": 9, + "uiAmount": 100.734660161, + "uiAmountString": "100.734660161" + } + } + ], + "returnData": { + "data": [ + "dS4PAAAAAAA=", + "base64" + ], + "programId": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4" + }, + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 343121592, + "transaction": { + "message": { + "accountKeys": [ + "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "t9EFHGwUuVhWZmeK8qiD1SbtSs45B3miiV5NW1HeHhy", + "249r7UcCSsyaeDzKvX1q5BU5HmpFKGPYFY2oYKRfe2VX", + "4PcMeWoiBFXLEGqnbmrwCbkbgZe1BoQ1MSCA5kgzgptC", + "6pXVFSACE5BND2C3ibGRWMG1fNtV7hfynWrfNKtCXhN3", + "7u7cD7NxcZEuzRCBaYo8uVpotRdqZwez47vvuwzCov43", + "8ctcHN52LY21FEipCjr1MVWtoZa1irJQTPyAaTj72h7S", + "CTeXMF8Pxx2p3ExmSzgn3FGYCFuaUoccGjktwDSLfm4D", + "HZC6ZtJ2sQSuzuJcyjUzHSf3PN6CbEU3ZXMzhjm53c4Z", + "ComputeBudget111111111111111111111111111111", + "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "6YawcNeZ74tRyCv4UfGydYMr7eho7vbUR6ScVffxKAb3", + "BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV", + "D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf", + "GZsNmWKbqhMYtdSkkvMdEyQF9k5mLmP7tTKYWZjcHVPE" + ], + "addressTableLookups": [ + { + "accountKey": "E1iuryq4fcxyqfQZginfahXTyBC3XmDNsVjSDukkyUrc", + "readonlyIndexes": [ + 122, + 8, + 4, + 117, + 83 + ], + "writableIndexes": [ + 119, + 118, + 120, + 121 + ] + }, + { + "accountKey": "5VB2riWiuz9TSxYYrVxtDiy1Wb4upEfEFHtuZ4yRr3mC", + "readonlyIndexes": [ + 180, + 187, + 174, + 177, + 175, + 188, + 189 + ], + "writableIndexes": [ + 186, + 181, + 182, + 178 + ] + }, + { + "accountKey": "9AsimPML6N36BAe8keQRAHpLuKgCKv9QJV9912TXZhBD", + "readonlyIndexes": [ + 0, + 108 + ], + "writableIndexes": [ + 109, + 144, + 105 + ] + } + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 6, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "JBscv3", + "programIdIndex": 9, + "stackHeight": null + }, + { + "accounts": [], + "data": "3ReqxfsK4sr7", + "programIdIndex": 9, + "stackHeight": null + }, + { + "accounts": [ + 27, + 12, + 0, + 1, + 1, + 4, + 3, + 34, + 37, + 3, + 28, + 13, + 10, + 32, + 0, + 34, + 30, + 1, + 5, + 22, + 20, + 21, + 19, + 36, + 31, + 35, + 33, + 27, + 28, + 38, + 27, + 12, + 24, + 6, + 25, + 5, + 23, + 8, + 2, + 7, + 39, + 29, + 17, + 14, + 11, + 15, + 16, + 6, + 4, + 18, + 18, + 26, + 12, + 27 + ], + "data": "N6Jg6trErYYoiAe3D6uttUX5mpJXMkGohnVaDPuAE5wHfjWSpSDwutnpxwC4cU1", + "programIdIndex": 10, + "stackHeight": null + } + ], + "recentBlockhash": "4F9iK78gBaxRk5BZVumZKCaSPpNHfUHVQpkbNDg6iWrA" + }, + "signatures": [ + "SuN6K9f4YL4VRmigUDZo2VYcNjVYbwnY6wdfWRHWgwQqcT3vz9cs7B8hytzN8NQcfNNMhQRj3vGwa8bEpSd1sJ3" + ] + }, + "version": 0 + }, + "id": 1 + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/transaction_broadcast_swap_error.json b/core/crates/gem_solana/testdata/transaction_broadcast_swap_error.json new file mode 100644 index 0000000000..953e834b05 --- /dev/null +++ b/core/crates/gem_solana/testdata/transaction_broadcast_swap_error.json @@ -0,0 +1,81 @@ +{ + "jsonrpc": "2.0", + "error": { + "code": -32002, + "message": "Transaction simulation failed: Error processing Instruction 3: custom program error: 0x1771", + "data": { + "accounts": null, + "err": { + "InstructionError": [ + 3, + { + "Custom": 6001 + } + ] + }, + "innerInstructions": null, + "loadedAccountsDataSize": 247813, + "logs": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", + "Program log: CreateIdempotent", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: GetAccountDataSize", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1569 of 409795 compute units", + "Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program log: Initialize the associated token account", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: InitializeImmutableOwner", + "Program log: Please upgrade to SPL Token 2022 for immutable owner support", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 403208 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: InitializeAccount3", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3158 of 399326 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 23815 of 419700 compute units", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]", + "Program log: Instruction: Route", + "Program 2wT8Yq49kHgDzXuPxZSaeLaH1qbmGXtEyPy64bL7aD3c invoke [2]", + "Program log: Instruction: Swap", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 340420 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: MintTo", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4492 of 333375 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 326497 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program 2wT8Yq49kHgDzXuPxZSaeLaH1qbmGXtEyPy64bL7aD3c consumed 73254 of 391361 compute units", + "Program 2wT8Yq49kHgDzXuPxZSaeLaH1qbmGXtEyPy64bL7aD3c success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 195 of 316608 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: Transfer", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4735 of 314318 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 195 of 308237 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 88552 of 395885 compute units", + "Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 failed: custom program error: 0x1771" + ], + "replacementBlockhash": null, + "returnData": null, + "unitsConsumed": 112667 + } + }, + "id": 1755839259 + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/transaction_reverted_program_account_not_found.json b/core/crates/gem_solana/testdata/transaction_reverted_program_account_not_found.json new file mode 100644 index 0000000000..b63c9c1baa --- /dev/null +++ b/core/crates/gem_solana/testdata/transaction_reverted_program_account_not_found.json @@ -0,0 +1,35 @@ +{ + "jsonrpc": "2.0", + "result": { + "meta": { + "err": "ProgramAccountNotFound", + "fee": 5000, + "preBalances": [ + 100000, + 0, + 1 + ], + "postBalances": [ + 95000, + 0, + 1 + ], + "preTokenBalances": [], + "postTokenBalances": [] + }, + "transaction": { + "message": { + "accountKeys": [ + "sender", + "recipient", + "11111111111111111111111111111111" + ], + "instructions": [] + }, + "signatures": [ + "signature" + ] + } + }, + "id": 1 +} diff --git a/core/crates/gem_solana/testdata/transaction_state_pending_not_found.json b/core/crates/gem_solana/testdata/transaction_state_pending_not_found.json new file mode 100644 index 0000000000..15fbf40838 --- /dev/null +++ b/core/crates/gem_solana/testdata/transaction_state_pending_not_found.json @@ -0,0 +1,5 @@ +{ + "jsonrpc": "2.0", + "result": null, + "id": 1 +} diff --git a/core/crates/gem_solana/testdata/transaction_state_reverted_program_account_not_found.json b/core/crates/gem_solana/testdata/transaction_state_reverted_program_account_not_found.json new file mode 100644 index 0000000000..1a0037d77e --- /dev/null +++ b/core/crates/gem_solana/testdata/transaction_state_reverted_program_account_not_found.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1779324501, + "meta": { + "err": "ProgramAccountNotFound", + "status": { + "Err": "ProgramAccountNotFound" + } + }, + "slot": 421096852 + }, + "id": 1779324510 +} diff --git a/core/crates/gem_solana/testdata/transaction_state_transfer_sol.json b/core/crates/gem_solana/testdata/transaction_state_transfer_sol.json new file mode 100644 index 0000000000..7d88316a02 --- /dev/null +++ b/core/crates/gem_solana/testdata/transaction_state_transfer_sol.json @@ -0,0 +1,91 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1755634669, + "meta": { + "computeUnitsConsumed": 9860, + "costUnits": 11192, + "err": null, + "fee": 5000, + "innerInstructions": [], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program Stake11111111111111111111111111111111111111 invoke [1]", + "Program log: Instruction: Withdraw", + "Program Stake11111111111111111111111111111111111111 consumed 9710 of 99850 compute units", + "Program Stake11111111111111111111111111111111111111 success" + ], + "postBalances": [ + 1387269709, + 0, + 1169280, + 114979200, + 1, + 1141440 + ], + "postTokenBalances": [], + "preBalances": [ + 1376791564, + 10483145, + 1169280, + 114979200, + 1, + 1141440 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 361169359, + "transaction": { + "message": { + "accountKeys": [ + "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "ASQQcxX1n6J1wtWEM14SoWsFmCtY2bvpV8dcKFJPZgWd", + "SysvarC1ock11111111111111111111111111111111", + "SysvarStakeHistory1111111111111111111111111", + "ComputeBudget111111111111111111111111111111", + "Stake11111111111111111111111111111111111111" + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 4, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "JC3gyu", + "programIdIndex": 4, + "stackHeight": 1 + }, + { + "accounts": [ + 1, + 0, + 2, + 3, + 0 + ], + "data": "5Nvj7aVaZetwKjyR", + "programIdIndex": 5, + "stackHeight": 1 + } + ], + "recentBlockhash": "CHDpVwyUzZB2d9FU55wB4EERoqJUsPKCXY7s55K2tMbW" + }, + "signatures": [ + "2BMrwZEGZ6m8hfVz6iMHeDq8JjXRHEcQgPMt6M9QsUSK4ReVvNWdmZsmPiCfH1Ebhi14vvmk1Am3rFZnkBS3LjsQ" + ] + }, + "version": "legacy" + }, + "id": 1755634766 + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/transfer_sol.json b/core/crates/gem_solana/testdata/transfer_sol.json new file mode 100644 index 0000000000..2bf57ea865 --- /dev/null +++ b/core/crates/gem_solana/testdata/transfer_sol.json @@ -0,0 +1,69 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1751394455, + "meta": { + "computeUnitsConsumed": 150, + "err": null, + "fee": 5000, + "innerInstructions": [], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program 11111111111111111111111111111111 invoke [1]", + "Program 11111111111111111111111111111111 success" + ], + "postBalances": [ + 1035001578, + 7874850, + 1 + ], + "postTokenBalances": [], + "preBalances": [ + 1035008751, + 7872677, + 1 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 350464523, + "transaction": { + "message": { + "accountKeys": [ + "DyB4TbDBqPUsCfsJMuoqjktEAod7D3KMNULSo7R1Rb61", + "DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh", + "11111111111111111111111111111111" + ], + "addressTableLookups": [], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 1, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [ + 0, + 1 + ], + "data": "3Bxs4Mmcv4RnVjWP", + "programIdIndex": 2, + "stackHeight": null + } + ], + "recentBlockhash": "39azqUhuc7E8D1iEauT66b8xat8WFcU8VoV95PqkLfkK" + }, + "signatures": [ + "t6DpS6U7G2UG4QwDq4mPM7F45Rnttxp2pHGRwTYsF7frxAs7KmSWWDcpMneMUULbKndkZy8iUvSU1AZUsqzDCPN" + ] + }, + "version": 0 + }, + "id": 1 +} \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/transfer_sol_with_compute.json b/core/crates/gem_solana/testdata/transfer_sol_with_compute.json new file mode 100644 index 0000000000..51faf57180 --- /dev/null +++ b/core/crates/gem_solana/testdata/transfer_sol_with_compute.json @@ -0,0 +1,87 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockTime": 1750884182, + "meta": { + "computeUnitsConsumed": 450, + "err": null, + "fee": 7500, + "innerInstructions": [], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [1]", + "Program 11111111111111111111111111111111 success" + ], + "postBalances": [ + 1631192310, + 304091763, + 1, + 1 + ], + "postTokenBalances": [], + "preBalances": [ + 1700199810, + 235091763, + 1, + 1 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 349179820, + "transaction": { + "message": { + "accountKeys": [ + "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "7nVDzZUjrBA3gHs3gNcHidhmR96CH7KpKsU8pyBZGHUr", + "ComputeBudget111111111111111111111111111111", + "11111111111111111111111111111111" + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 2, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "3hd3odyyp3J7", + "programIdIndex": 2, + "stackHeight": null + }, + { + "accounts": [], + "data": "JC3gyu", + "programIdIndex": 2, + "stackHeight": null + }, + { + "accounts": [ + 0, + 1 + ], + "data": "3Bxs4BhqtYz726uV", + "programIdIndex": 3, + "stackHeight": null + } + ], + "recentBlockhash": "7LPW7gdpDnb4LuqqQC5TjbWCB7kqZoHa99eswHoUQ5Bw" + }, + "signatures": [ + "2QeBm7G7qLmVTCVKAkbSUuZvcFjg6mBRVqaVSKWSZsTqJxHVTzMUwxDtu1Myfu8RzpUv5YMEBFFpGbwVM9ZQY8DL" + ] + }, + "version": "legacy" + } + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/usdc_mint.json b/core/crates/gem_solana/testdata/usdc_mint.json new file mode 100644 index 0000000000..0fe7417b68 --- /dev/null +++ b/core/crates/gem_solana/testdata/usdc_mint.json @@ -0,0 +1,31 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "2.0.15", + "slot": 306306301 + }, + "value": { + "data": { + "parsed": { + "info": { + "decimals": 6, + "freezeAuthority": "7dGbd2QZcCKcTndnHcTL8q7SMVXAkp688NTQYwrRCrar", + "isInitialized": true, + "mintAuthority": "BJE5MMbqXjVwjAF7oxwPYXnTXDyspzZyt4vwenNw5ruG", + "supply": "3986659142956864" + }, + "type": "mint" + }, + "program": "spl-token", + "space": 82 + }, + "executable": false, + "lamports": 321913128472, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 18446744073709552000, + "space": 82 + } + } + } \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/usdc_transfer.json b/core/crates/gem_solana/testdata/usdc_transfer.json new file mode 100644 index 0000000000..2d499355f0 --- /dev/null +++ b/core/crates/gem_solana/testdata/usdc_transfer.json @@ -0,0 +1,222 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1753346616, + "meta": { + "computeUnitsConsumed": 28694, + "err": null, + "fee": 5500, + "innerInstructions": [ + { + "index": 2, + "instructions": [ + { + "accounts": [ + 4 + ], + "data": "84eT", + "programIdIndex": 6, + "stackHeight": 2 + }, + { + "accounts": [ + 0, + 1 + ], + "data": "11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL", + "programIdIndex": 5, + "stackHeight": 2 + }, + { + "accounts": [ + 1 + ], + "data": "P", + "programIdIndex": 6, + "stackHeight": 2 + }, + { + "accounts": [ + 1, + 4 + ], + "data": "6Pc15mXMvpqDtqyXHNqSHJP1711UcxAfuAtG3BPhEvUDH", + "programIdIndex": 6, + "stackHeight": 2 + } + ] + } + ], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", + "Program log: Create", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: GetAccountDataSize", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1622 of 92608 compute units", + "Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program log: Initialize the associated token account", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: InitializeImmutableOwner", + "Program log: Please upgrade to SPL Token 2022 for immutable owner support", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 85968 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: InitializeAccount3", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4241 of 82084 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 22195 of 99700 compute units", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6199 of 77505 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success" + ], + "postBalances": [ + 47771787, + 2039280, + 2039280, + 0, + 407375877148, + 1, + 4522329612, + 1009200, + 1, + 736050881 + ], + "postTokenBalances": [ + { + "accountIndex": 1, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "3UJQqKq8Xyx4aVRmHgEwpQZiW7toYRQCTy6Bgp1RdKnK", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "100000", + "decimals": 6, + "uiAmount": 0.1, + "uiAmountString": "0.1" + } + }, + { + "accountIndex": 2, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "37BenMAXFJMo3GaXKb2XLsNQXmd6VbbdShZWnwDj9D6k", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "5839648", + "decimals": 6, + "uiAmount": 5.839648, + "uiAmountString": "5.839648" + } + } + ], + "preBalances": [ + 49816567, + 0, + 2039280, + 0, + 407375877148, + 1, + 4522329612, + 1009200, + 1, + 736050881 + ], + "preTokenBalances": [ + { + "accountIndex": 2, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "37BenMAXFJMo3GaXKb2XLsNQXmd6VbbdShZWnwDj9D6k", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "5939648", + "decimals": 6, + "uiAmount": 5.939648, + "uiAmountString": "5.939648" + } + } + ], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 355393521, + "transaction": { + "message": { + "accountKeys": [ + "37BenMAXFJMo3GaXKb2XLsNQXmd6VbbdShZWnwDj9D6k", + "8dqL6jKduEJwfAem49yAvXB8YczkDPgVCgU88VPfTnAP", + "6ocrDGfR4vwdQD8HtVCpQvbpADWFSHkbrJUm2X6s7wQT", + "3UJQqKq8Xyx4aVRmHgEwpQZiW7toYRQCTy6Bgp1RdKnK", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "11111111111111111111111111111111", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "SysvarRent111111111111111111111111111111111", + "ComputeBudget111111111111111111111111111111", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + ], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 7, + "numRequiredSignatures": 1 + }, + "instructions": [ + { + "accounts": [], + "data": "3cDeqiGMb6md", + "programIdIndex": 8, + "stackHeight": null + }, + { + "accounts": [], + "data": "JC3gyu", + "programIdIndex": 8, + "stackHeight": null + }, + { + "accounts": [ + 0, + 1, + 3, + 4, + 5, + 6, + 7 + ], + "data": "", + "programIdIndex": 9, + "stackHeight": null + }, + { + "accounts": [ + 2, + 4, + 1, + 0 + ], + "data": "i9TTqffgKmDLh", + "programIdIndex": 6, + "stackHeight": null + } + ], + "recentBlockhash": "Dt2Kh73s1EUbQf1go8VyBqH6v91H6QG5JxBFPhpjFRqz" + }, + "signatures": [ + "4dHnggcXjvmMJY2J6iGqse12PeCYQzuTySgwJa36K8MuntmwNrCNztvYRX5ZGpQXzKjaf7g5vaZM7LTuXLNbi2Zx" + ] + }, + "version": "legacy" + }, + "id": 1 +} \ No newline at end of file diff --git a/core/crates/gem_solana/testdata/usdc_transfer_fee_payer.json b/core/crates/gem_solana/testdata/usdc_transfer_fee_payer.json new file mode 100644 index 0000000000..48768fdba3 --- /dev/null +++ b/core/crates/gem_solana/testdata/usdc_transfer_fee_payer.json @@ -0,0 +1,158 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1774244726, + "meta": { + "computeUnitsConsumed": 10637, + "err": null, + "fee": 10000, + "innerInstructions": [], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "logMessages": [ + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", + "Program log: CreateIdempotent", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 4437 of 400000 compute units", + "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]", + "Program log: Instruction: TransferChecked", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 395563 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success" + ], + "postBalances": [ + 503437144, + 0, + 2039280, + 2039280, + 3316551056, + 4108026820, + 494081894945, + 1, + 5595651284 + ], + "postTokenBalances": [ + { + "accountIndex": 2, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "B1nzrk99FEDAYB2M82yepdvEv1YBRJKcx5Y5R6MSDW1Q", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "24737625", + "decimals": 6, + "uiAmount": 24.737625, + "uiAmountString": "24.737625" + } + }, + { + "accountIndex": 3, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "5QtyKPHtWUf45bNb5buQ6UxpL2ekSJxBCK9g8xMCsc9U", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "20620017822", + "decimals": 6, + "uiAmount": 20620.017822, + "uiAmountString": "20620.017822" + } + } + ], + "preBalances": [ + 503447144, + 0, + 2039280, + 2039280, + 3316551056, + 4108026820, + 494081894945, + 1, + 5595651284 + ], + "preTokenBalances": [ + { + "accountIndex": 2, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "B1nzrk99FEDAYB2M82yepdvEv1YBRJKcx5Y5R6MSDW1Q", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "0", + "decimals": 6, + "uiAmount": null, + "uiAmountString": "0" + } + }, + { + "accountIndex": 3, + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "owner": "5QtyKPHtWUf45bNb5buQ6UxpL2ekSJxBCK9g8xMCsc9U", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "20644755447", + "decimals": 6, + "uiAmount": 20644.755447, + "uiAmountString": "20644.755447" + } + } + ], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 408264979, + "transaction": { + "message": { + "accountKeys": [ + "Cc5M6mecyBKjdrjnvreuANqhMBdLeU7RZNzoSEh2Rnfc", + "5QtyKPHtWUf45bNb5buQ6UxpL2ekSJxBCK9g8xMCsc9U", + "HSSRXb8gHLp5EpBGbkUu1gG4pNhJuDvHXuNG5X39R2bk", + "6VHz6WyCN1GuAQBvPn8AVorzRqwsHh91DDovQ5SUHhT4", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "B1nzrk99FEDAYB2M82yepdvEv1YBRJKcx5Y5R6MSDW1Q", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "11111111111111111111111111111111", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ], + "header": { + "numReadonlySignedAccounts": 1, + "numReadonlyUnsignedAccounts": 5, + "numRequiredSignatures": 2 + }, + "instructions": [ + { + "accounts": [ + 0, + 2, + 5, + 6, + 7, + 8 + ], + "data": "2", + "programIdIndex": 4, + "stackHeight": 1 + }, + { + "accounts": [ + 3, + 6, + 2, + 1 + ], + "data": "hEzruBeqPQSUm", + "programIdIndex": 8, + "stackHeight": 1 + } + ], + "recentBlockhash": "GnFV4frLkvw18jwZjHZMMAYoRcXau7feicYS1kEHte62" + }, + "signatures": [ + "65MevEhHuXZzwQ8VftyQUmbgbs41bj2KMhf6zDM5hqsXN6jxsHYniHbi7MMWQ4kitcgfRdsuYe1s7zAqtzPYXZVG", + "2mXmNEMGetMLxTWaL67vQuSNMwfu1XKAXjyLw6vkS4bp6gMZM9MtHkGNXunXRzKDFmmsR8Z6rGbSzPYrsCRexrBs" + ] + }, + "version": "legacy" + }, + "id": 1774292960 +} diff --git a/core/crates/gem_stellar/Cargo.toml b/core/crates/gem_stellar/Cargo.toml new file mode 100644 index 0000000000..85607e5fbf --- /dev/null +++ b/core/crates/gem_stellar/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "gem_stellar" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +rpc = ["dep:number_formatter", "dep:chrono", "dep:async-trait", "dep:chain_traits", "dep:futures"] +signer = ["dep:gem_encoding", "dep:gem_hash", "dep:num-traits", "dep:signer"] +reqwest = ["gem_client/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] + +[dependencies] +serde = { workspace = true, features = ["derive"] } +primitives = { path = "../primitives" } +num-bigint = { workspace = true } +num-traits = { workspace = true, optional = true } +url = { workspace = true } + +# Optional dependencies for the rpc feature +number_formatter = { path = "../number_formatter", optional = true } +chrono = { workspace = true, features = ["serde"], optional = true } +async-trait = { workspace = true, optional = true } +chain_traits = { path = "../chain_traits", optional = true } +futures = { workspace = true, optional = true } +serde_json = { workspace = true } +gem_client = { path = "../gem_client" } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } +gem_encoding = { path = "../gem_encoding", optional = true } +gem_hash = { path = "../gem_hash", optional = true } +signer = { path = "../signer", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } +primitives = { path = "../primitives", features = ["testkit"] } +hex = { workspace = true } diff --git a/core/crates/gem_stellar/src/address.rs b/core/crates/gem_stellar/src/address.rs new file mode 100644 index 0000000000..afa5d23450 --- /dev/null +++ b/core/crates/gem_stellar/src/address.rs @@ -0,0 +1,88 @@ +use gem_encoding::{decode_base32, encode_base32}; +use primitives::Address; +use signer::Base32Address; +use std::fmt; + +const ED25519_PUBLIC_KEY_VERSION: u8 = 0x30; +const ADDRESS_LENGTH: usize = 56; +const DECODED_LENGTH: usize = 35; +const CRC16_XMODEM_POLY: u16 = 0x1021; + +#[derive(Clone)] +pub struct StellarAddress { + pub(crate) base32: Base32Address, +} + +impl Address for StellarAddress { + fn try_parse(address: &str) -> Option { + if address.len() != ADDRESS_LENGTH || !address.starts_with('G') { + return None; + } + let decoded = decode_base32(address.as_bytes()).ok()?; + if decoded.len() != DECODED_LENGTH || decoded[0] != ED25519_PUBLIC_KEY_VERSION { + return None; + } + let crc = u16::from_le_bytes([decoded[33], decoded[34]]); + if Self::crc16_xmodem(&decoded[..33]) != crc { + return None; + } + Base32Address::from_slice(&decoded[1..33]).ok().map(|base32| Self { base32 }) + } + + fn as_bytes(&self) -> &[u8] { + self.base32.payload() + } + + fn encode(&self) -> String { + let mut raw = Vec::with_capacity(DECODED_LENGTH); + raw.push(ED25519_PUBLIC_KEY_VERSION); + raw.extend_from_slice(self.base32.payload()); + let crc = Self::crc16_xmodem(&raw).to_le_bytes(); + raw.extend_from_slice(&crc); + encode_base32(&raw) + } +} + +pub fn validate_address(address: &str) -> bool { + StellarAddress::is_valid(address) +} + +impl StellarAddress { + fn crc16_xmodem(data: &[u8]) -> u16 { + let mut crc: u16 = 0; + for &byte in data { + crc ^= (byte as u16) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { (crc << 1) ^ CRC16_XMODEM_POLY } else { crc << 1 }; + } + } + crc + } +} + +impl fmt::Display for StellarAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_ADDRESS: &str = "GAE2SZV4VLGBAPRYRFV2VY7YYLYGYIP5I7OU7BSP6DJT7GAZ35OKFDYI"; + + #[test] + fn test_stellar_address() { + let parsed = StellarAddress::from_str(VALID_ADDRESS).unwrap(); + + assert!(validate_address(VALID_ADDRESS)); + assert_eq!(parsed.to_string(), VALID_ADDRESS); + assert_eq!(parsed.as_bytes().len(), 32); + + assert!(!validate_address("")); + assert!(!validate_address("invalid")); + // wrong checksum (last char flipped) + assert!(!validate_address("GAE2SZV4VLGBAPRYRFV2VY7YYLYGYIP5I7OU7BSP6DJT7GAZ35OKFDYZ")); + } +} diff --git a/core/crates/gem_stellar/src/constants.rs b/core/crates/gem_stellar/src/constants.rs new file mode 100644 index 0000000000..ced22122da --- /dev/null +++ b/core/crates/gem_stellar/src/constants.rs @@ -0,0 +1,4 @@ +pub const TRANSACTION_TYPE_PAYMENT: &str = "payment"; +pub const TRANSACTION_TYPE_CREATE_ACCOUNT: &str = "create_account"; +pub const STELLAR_DECIMALS: u32 = 7; +pub const STELLAR_TOKEN_DECIMALS: i32 = 7; diff --git a/core/crates/gem_stellar/src/lib.rs b/core/crates/gem_stellar/src/lib.rs new file mode 100644 index 0000000000..c8e18e8749 --- /dev/null +++ b/core/crates/gem_stellar/src/lib.rs @@ -0,0 +1,17 @@ +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +#[cfg(feature = "signer")] +pub mod address; +pub mod constants; +pub mod models; +#[cfg(feature = "signer")] +pub mod signer; + +#[cfg(feature = "signer")] +pub use address::{StellarAddress, validate_address}; +#[cfg(feature = "signer")] +pub use signer::*; diff --git a/core/crates/gem_stellar/src/models/account.rs b/core/crates/gem_stellar/src/models/account.rs new file mode 100644 index 0000000000..3372747244 --- /dev/null +++ b/core/crates/gem_stellar/src/models/account.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub sequence: u64, + pub balances: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountEmpty { + pub id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Balance { + pub balance: String, + pub asset_type: String, + pub asset_code: Option, + pub asset_issuer: Option, +} diff --git a/core/crates/gem_stellar/src/models/block.rs b/core/crates/gem_stellar/src/models/block.rs new file mode 100644 index 0000000000..8e856abcd9 --- /dev/null +++ b/core/crates/gem_stellar/src/models/block.rs @@ -0,0 +1,10 @@ +#[cfg(feature = "rpc")] +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + pub closed_at: String, + pub sequence: i64, + pub base_fee_in_stroops: i64, +} diff --git a/core/crates/gem_stellar/src/models/common.rs b/core/crates/gem_stellar/src/models/common.rs new file mode 100644 index 0000000000..0cfb8a1334 --- /dev/null +++ b/core/crates/gem_stellar/src/models/common.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarEmbedded { + pub _embedded: StellarRecords, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarRecords { + pub records: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarAsset { + pub asset_code: String, + pub asset_issuer: String, + pub contract_id: Option, +} + +// RPC models +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Embedded { + pub _embedded: Records, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Records { + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AccountResult { + Found(T), + NotFound, +} diff --git a/core/crates/gem_stellar/src/models/fee.rs b/core/crates/gem_stellar/src/models/fee.rs new file mode 100644 index 0000000000..b78312f94f --- /dev/null +++ b/core/crates/gem_stellar/src/models/fee.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarFees { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub last_ledger_base_fee: u64, + pub fee_charged: StellarFeeCharged, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarFeeCharged { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub min: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub p95: u64, +} diff --git a/core/crates/gem_stellar/src/models/mod.rs b/core/crates/gem_stellar/src/models/mod.rs new file mode 100644 index 0000000000..ba3e5ef976 --- /dev/null +++ b/core/crates/gem_stellar/src/models/mod.rs @@ -0,0 +1,17 @@ +pub mod account; +#[cfg(feature = "rpc")] +pub mod block; +pub mod common; +pub mod fee; +pub mod node; +#[cfg(feature = "signer")] +pub mod signing; +pub mod transaction; + +pub use account::*; +#[cfg(feature = "rpc")] +pub use block::*; +pub use common::*; +pub use fee::*; +pub use node::*; +pub use transaction::*; diff --git a/core/crates/gem_stellar/src/models/node.rs b/core/crates/gem_stellar/src/models/node.rs new file mode 100644 index 0000000000..4410138662 --- /dev/null +++ b/core/crates/gem_stellar/src/models/node.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarNodeStatus { + pub ingest_latest_ledger: i32, + pub network_passphrase: String, +} + +// RPC models +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeStatus { + pub ingest_latest_ledger: i32, + pub network_passphrase: String, +} diff --git a/core/crates/gem_stellar/src/models/signing/asset.rs b/core/crates/gem_stellar/src/models/signing/asset.rs new file mode 100644 index 0000000000..3d97e5e548 --- /dev/null +++ b/core/crates/gem_stellar/src/models/signing/asset.rs @@ -0,0 +1,45 @@ +use crate::address::StellarAddress; +use primitives::{Address, SignerError, SignerInput}; +use signer::InvalidInput; + +#[derive(Clone)] +pub enum StellarAssetCode { + Alphanum4([u8; 4]), + Alphanum12([u8; 12]), +} + +#[derive(Clone)] +pub struct StellarAssetData { + pub issuer: StellarAddress, + pub code: StellarAssetCode, +} + +impl StellarAssetData { + pub fn new(issuer: &str, code: &str) -> Result { + if !(code.is_ascii() && code.bytes().all(|byte| byte.is_ascii_alphanumeric())) { + return SignerError::invalid_input_err("Stellar asset code must be ASCII alphanumeric"); + } + + let code = match code.len() { + 1..=4 => { + let mut buf = [0u8; 4]; + buf[..code.len()].copy_from_slice(code.as_bytes()); + StellarAssetCode::Alphanum4(buf) + } + 5..=12 => { + let mut buf = [0u8; 12]; + buf[..code.len()].copy_from_slice(code.as_bytes()); + StellarAssetCode::Alphanum12(buf) + } + _ => return Err(SignerError::invalid_input("Stellar asset code must be 1-12 characters")), + }; + + let issuer = StellarAddress::from_str(issuer).invalid_input("invalid Stellar issuer address")?; + Ok(Self { issuer, code }) + } + + pub(crate) fn from_input(input: &SignerInput) -> Result { + let (issuer, code) = input.input_type.get_asset().id.split_sub_token_parts().invalid_input("invalid Stellar token ID")?; + Self::new(&issuer, &code) + } +} diff --git a/core/crates/gem_stellar/src/models/signing/mod.rs b/core/crates/gem_stellar/src/models/signing/mod.rs new file mode 100644 index 0000000000..a7e086dac5 --- /dev/null +++ b/core/crates/gem_stellar/src/models/signing/mod.rs @@ -0,0 +1,7 @@ +mod asset; +mod operation; +mod transaction; + +pub use asset::{StellarAssetCode, StellarAssetData}; +pub use operation::{Memo, Operation}; +pub use transaction::StellarTransaction; diff --git a/core/crates/gem_stellar/src/models/signing/operation.rs b/core/crates/gem_stellar/src/models/signing/operation.rs new file mode 100644 index 0000000000..f28194f9f2 --- /dev/null +++ b/core/crates/gem_stellar/src/models/signing/operation.rs @@ -0,0 +1,36 @@ +use super::StellarAssetData; +use crate::address::StellarAddress; + +#[derive(Clone)] +pub enum Memo { + None, + Text(String), + #[cfg_attr(not(test), allow(unused))] + Id(u64), +} + +#[derive(Clone)] +pub enum Operation { + CreateAccount { + destination: StellarAddress, + amount: u64, + }, + Payment { + destination: StellarAddress, + asset: Option, + amount: u64, + }, + ChangeTrust { + asset: StellarAssetData, + }, +} + +impl Operation { + pub fn operation_type(&self) -> u32 { + match self { + Self::CreateAccount { .. } => 0, + Self::Payment { .. } => 1, + Self::ChangeTrust { .. } => 6, + } + } +} diff --git a/core/crates/gem_stellar/src/models/signing/transaction.rs b/core/crates/gem_stellar/src/models/signing/transaction.rs new file mode 100644 index 0000000000..6103b1b7fa --- /dev/null +++ b/core/crates/gem_stellar/src/models/signing/transaction.rs @@ -0,0 +1,80 @@ +use super::asset::StellarAssetData; +use super::operation::{Memo, Operation}; +use crate::address::StellarAddress; +use num_traits::ToPrimitive; +use primitives::{Address, SignerError, SignerInput}; +use signer::InvalidInput; + +const MEMO_TEXT_MAX_BYTES: usize = 28; + +#[derive(Clone)] +pub struct StellarTransaction { + pub account: StellarAddress, + pub fee: u32, + pub sequence: u64, + pub memo: Memo, + pub time_bounds: Option, + pub operation: Operation, +} + +impl StellarTransaction { + pub fn transfer(input: &SignerInput) -> Result { + let amount = input.value_as_u64()?; + let destination = StellarAddress::from_str(&input.destination_address).invalid_input("invalid Stellar address")?; + let is_destination_exist = input.metadata.get_is_destination_address_exist()?; + + let operation = if is_destination_exist { + Operation::Payment { destination, asset: None, amount } + } else { + Operation::CreateAccount { destination, amount } + }; + + Self::build(input, fee_u32(input)?, operation) + } + + pub fn token_transfer(input: &SignerInput) -> Result { + if !input.metadata.get_is_destination_address_exist()? { + return SignerError::invalid_input_err("Stellar destination account not found for token transfer"); + } + + let amount = input.value_as_u64()?; + let operation = Operation::Payment { + destination: StellarAddress::from_str(&input.destination_address).invalid_input("invalid Stellar address")?, + asset: Some(StellarAssetData::from_input(input)?), + amount, + }; + + Self::build(input, fee_u32(input)?, operation) + } + + pub fn account_action(input: &SignerInput) -> Result { + let operation = Operation::ChangeTrust { + asset: StellarAssetData::from_input(input)?, + }; + + Self::build(input, fee_u32(input)?, operation) + } + + fn build(input: &SignerInput, fee: u32, operation: Operation) -> Result { + Ok(Self { + account: StellarAddress::from_str(&input.sender_address).invalid_input("invalid Stellar address")?, + fee, + sequence: input.metadata.get_sequence()?, + memo: memo(input.memo.as_deref())?, + time_bounds: None, + operation, + }) + } +} + +fn fee_u32(input: &SignerInput) -> Result { + input.fee.fee.to_u32().invalid_input("invalid transaction fee") +} + +fn memo(value: Option<&str>) -> Result { + match value { + Some(text) if text.len() > MEMO_TEXT_MAX_BYTES => SignerError::invalid_input_err("Stellar memo text must be at most 28 bytes"), + Some(text) => Ok(Memo::Text(text.to_string())), + None => Ok(Memo::None), + } +} diff --git a/core/crates/gem_stellar/src/models/transaction.rs b/core/crates/gem_stellar/src/models/transaction.rs new file mode 100644 index 0000000000..f73f718def --- /dev/null +++ b/core/crates/gem_stellar/src/models/transaction.rs @@ -0,0 +1,100 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarTransactionBroadcast { + pub hash: Option, + #[serde(rename = "title")] + pub error_message: Option, + pub tx_status: String, + pub error_result_xdr: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarTransactionStatus { + pub successful: bool, + #[serde(deserialize_with = "serde_serializers::deserialize_biguint_from_str")] + pub fee_charged: BigUint, + pub hash: String, +} + +// RPC models +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Payment { + pub id: String, + pub transaction_successful: bool, + pub transaction_hash: String, + #[serde(rename = "type")] + pub payment_type: String, + + // payment + pub asset_type: Option, + pub from: Option, + pub to: Option, + pub amount: Option, + + pub created_at: String, + + // create account + pub source_account: Option, + pub funder: Option, + pub account: Option, + pub starting_balance: Option, + + pub transaction: Option, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarPaymentTransaction { + pub memo: Option, +} + +#[cfg(feature = "rpc")] +impl Payment { + fn amount_formatter(value: &str) -> Option { + use number_formatter::BigNumberFormatter; + use primitives::{Asset, Chain}; + BigNumberFormatter::value_from_amount(value, Asset::from_chain(Chain::Stellar).decimals as u32).ok() + } + + pub fn from_address(&self) -> Option { + use crate::constants::{TRANSACTION_TYPE_CREATE_ACCOUNT, TRANSACTION_TYPE_PAYMENT}; + match self.payment_type.as_str() { + TRANSACTION_TYPE_PAYMENT => self.from.clone(), + TRANSACTION_TYPE_CREATE_ACCOUNT => self.funder.clone(), + _ => None, + } + } + + pub fn to_address(&self) -> Option { + use crate::constants::{TRANSACTION_TYPE_CREATE_ACCOUNT, TRANSACTION_TYPE_PAYMENT}; + match self.payment_type.as_str() { + TRANSACTION_TYPE_PAYMENT => self.to.clone(), + TRANSACTION_TYPE_CREATE_ACCOUNT => self.account.clone(), + _ => None, + } + } + + pub fn get_state(&self) -> primitives::TransactionState { + use primitives::TransactionState; + match self.transaction_successful { + true => TransactionState::Confirmed, + false => TransactionState::Failed, + } + } + + pub fn get_value(&self) -> Option { + use crate::constants::{TRANSACTION_TYPE_CREATE_ACCOUNT, TRANSACTION_TYPE_PAYMENT}; + match self.payment_type.as_str() { + TRANSACTION_TYPE_PAYMENT => Self::amount_formatter(self.amount.as_ref()?), + TRANSACTION_TYPE_CREATE_ACCOUNT => Self::amount_formatter(self.starting_balance.as_ref()?), + _ => None, + } + } + + pub fn get_memo(&self) -> Option { + self.transaction.as_ref()?.memo.clone() + } +} diff --git a/core/crates/gem_stellar/src/provider/balances.rs b/core/crates/gem_stellar/src/provider/balances.rs new file mode 100644 index 0000000000..2f36fdeb49 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/balances.rs @@ -0,0 +1,85 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use std::error::Error; + +use gem_client::Client; +use primitives::{AssetBalance, Chain}; + +use super::balances_mapper::{map_all_balances, map_native_balance, map_token_balances}; +use crate::{models::AccountResult, rpc::client::StellarClient}; + +#[async_trait] +impl ChainBalances for StellarClient { + async fn get_balance_coin(&self, address: String) -> Result> { + match self.get_account(address).await? { + AccountResult::Found(account) => map_native_balance(&account), + AccountResult::NotFound => Ok(AssetBalance::new_zero_balance(Chain::Stellar.as_asset_id())), + } + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + match self.get_account(address).await? { + AccountResult::Found(account) => Ok(map_token_balances(&account, token_ids, self.get_chain())), + AccountResult::NotFound => Ok(vec![]), + } + } + + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Ok(None) + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + match self.get_account(address).await? { + AccountResult::Found(account) => Ok(map_all_balances(self.get_chain(), account)), + AccountResult::NotFound => Ok(vec![]), + } + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_EMPTY_ADDRESS, create_test_client}; + use primitives::{Chain, asset_constants::STELLAR_USDC_TOKEN_ID}; + + #[tokio::test] + async fn test_stellar_get_balance_coin() -> Result<(), Box> { + let client = create_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + + assert_eq!(balance.asset_id.chain, Chain::Stellar); + assert_eq!(balance.asset_id.token_id, None); + assert!(balance.balance.available >= num_bigint::BigUint::from(0u32)); + + Ok(()) + } + + #[tokio::test] + async fn test_stellar_get_balance_coin_empty_address() -> Result<(), Box> { + let client = create_test_client(); + let balance = client.get_balance_coin(TEST_EMPTY_ADDRESS.to_string()).await?; + + assert_eq!(balance.asset_id.chain, Chain::Stellar); + assert_eq!(balance.asset_id.token_id, None); + assert!(balance.balance.available == num_bigint::BigUint::from(0u32)); + + Ok(()) + } + + #[tokio::test] + async fn test_get_balance_tokens() -> Result<(), Box> { + let client = create_test_client(); + let token_ids = vec![STELLAR_USDC_TOKEN_ID.to_string()]; + + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids.clone()).await?; + + assert_eq!(balances.len(), token_ids.len()); + for (i, balance) in balances.iter().enumerate() { + assert_eq!(balance.asset_id.chain, Chain::Stellar); + assert_eq!(balance.asset_id.token_id, Some(token_ids[i].clone())); + assert!(balance.balance.available >= num_bigint::BigUint::from(0u32)); + } + + Ok(()) + } +} diff --git a/core/crates/gem_stellar/src/provider/balances_mapper.rs b/core/crates/gem_stellar/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..0c44ee2a9f --- /dev/null +++ b/core/crates/gem_stellar/src/provider/balances_mapper.rs @@ -0,0 +1,112 @@ +use crate::constants::STELLAR_DECIMALS; +use crate::models::account::Account; +use num_bigint::BigUint; +use number_formatter::BigNumberFormatter; +use primitives::{AssetBalance, AssetId, Balance, Chain}; +use std::error::Error; + +pub fn map_native_balance(account: &Account) -> Result> { + let chain = Chain::Stellar; + let reserved_amount = chain.account_activation_fee().unwrap_or(0) as u64; + let native_balance = account.balances.iter().find(|b| b.asset_type == "native").map(|b| b.balance.clone()).unwrap_or_default(); + + let balance_stroops_str = BigNumberFormatter::value_from_amount(&native_balance, STELLAR_DECIMALS)?; + let balance_decimal = BigNumberFormatter::big_decimal_value(&balance_stroops_str, 0).unwrap_or_default(); + let reserved_decimal = BigNumberFormatter::big_decimal_value(&reserved_amount.to_string(), 0).unwrap_or_default(); + let available_decimal = balance_decimal - reserved_decimal; + let available = available_decimal.to_string(); + let reserved_str = reserved_amount.to_string(); + + let available_biguint = available.parse::().unwrap_or_default(); + let reserved_biguint = reserved_str.parse::().unwrap_or_default(); + + Ok(AssetBalance::new_balance(chain.as_asset_id(), Balance::with_reserved(available_biguint, reserved_biguint))) +} + +pub fn map_token_balances(account: &Account, token_ids: Vec, chain: Chain) -> Vec { + token_ids + .into_iter() + .map(|token_id| { + if let Some((issuer, symbol)) = token_id.split_once("::") { + if let Some(balance) = account + .balances + .iter() + .find(|b| b.asset_issuer.as_deref() == Some(issuer) && b.asset_code.as_deref() == Some(symbol) && b.asset_type != "native") + { + let amount = BigNumberFormatter::value_from_amount_biguint(&balance.balance, STELLAR_DECIMALS).unwrap_or_default(); + AssetBalance::new_with_active(AssetId::from_token(chain, &token_id), Balance::coin_balance(amount), true) + } else { + AssetBalance::new_with_active(AssetId::from_token(chain, &token_id), Balance::coin_balance(BigUint::from(0u32)), false) + } + } else { + // Invalid format - only support issuer::symbol + AssetBalance::new_with_active(AssetId::from_token(chain, &token_id), Balance::coin_balance(BigUint::from(0u32)), false) + } + }) + .collect() +} + +pub fn map_all_balances(chain: Chain, account: Account) -> Vec { + let mut balances = Vec::new(); + + for balance in account.balances { + match balance.asset_type.as_str() { + "native" => { + // Native XLM balance + if let Ok(value) = BigNumberFormatter::value_from_amount_biguint(&balance.balance, STELLAR_DECIMALS) { + let balance_obj = Balance::coin_balance(value); + balances.push(AssetBalance::new_with_active(chain.as_asset_id(), balance_obj, true)); + } + } + "credit_alphanum4" | "credit_alphanum12" => { + // Token balances + if let (Some(asset_issuer), Some(asset_code)) = (&balance.asset_issuer, &balance.asset_code) { + let token_id = AssetId::sub_token_id(&[asset_issuer.clone(), asset_code.clone()]); + let asset_id = AssetId::from_token(chain, &token_id); + if let Ok(value) = BigNumberFormatter::value_from_amount_biguint(&balance.balance, STELLAR_DECIMALS) { + let balance_obj = Balance::coin_balance(value); + balances.push(AssetBalance::new_with_active(asset_id, balance_obj, true)); + } + } + } + _ => { + // Ignore other asset types + } + } + } + + balances +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::account::Account; + use serde_json; + + #[test] + fn test_map_native_balance() { + let account: Account = serde_json::from_str(include_str!("../../testdata/balance.json")).unwrap(); + let chain = Chain::Stellar; + let asset_id = AssetId::from_chain(chain); + + let result = map_native_balance(&account).unwrap(); + + assert_eq!(result.asset_id, asset_id); + assert_eq!(result.balance.available, BigUint::from(299999077_u64)); + assert_eq!(result.balance.reserved, BigUint::from(10000000_u64)); + } + + #[test] + fn test_map_native_balance_with_minimal_balance() { + let account: Account = serde_json::from_str(include_str!("../../testdata/balance_coin.json")).unwrap(); + let chain = Chain::Stellar; + let asset_id = AssetId::from_chain(chain); + + let result = map_native_balance(&account).unwrap(); + + assert_eq!(result.asset_id, asset_id); + assert_eq!(result.balance.available, BigUint::from(0u32)); + assert_eq!(result.balance.reserved, BigUint::from(10000000_u64)); + } +} diff --git a/core/crates/gem_stellar/src/provider/mod.rs b/core/crates/gem_stellar/src/provider/mod.rs new file mode 100644 index 0000000000..e23e04be18 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/mod.rs @@ -0,0 +1,18 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod state; +pub mod state_mapper; +pub mod testkit; +pub mod token; +pub mod token_mapper; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; diff --git a/core/crates/gem_stellar/src/provider/preload.rs b/core/crates/gem_stellar/src/provider/preload.rs new file mode 100644 index 0000000000..206c9dd613 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/preload.rs @@ -0,0 +1,142 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use futures; +use std::error::Error; + +use gem_client::Client; +use primitives::{FeePriority, FeeRate, GasPriceType, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput}; + +use crate::{models::AccountResult, provider::preload_mapper::map_transaction_load, rpc::client::StellarClient}; + +#[async_trait] +impl ChainTransactionLoad for StellarClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let destination_address = input.input_type.swap_to_address().unwrap_or(&input.destination_address); + let (sender_account, destination_exists) = futures::join!(self.get_account(input.sender_address.clone()), self.account_exists(destination_address)); + match sender_account? { + AccountResult::Found(account) => Ok(TransactionLoadMetadata::Stellar { + sequence: account.sequence + 1, + is_destination_address_exist: destination_exists?, + }), + AccountResult::NotFound => Err("Sender account not found".into()), + } + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + map_transaction_load(input) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + let fees = self.get_fees().await?; + Ok(vec![ + FeeRate::new(FeePriority::Slow, GasPriceType::regular(fees.fee_charged.min)), + FeeRate::new(FeePriority::Normal, GasPriceType::regular(fees.fee_charged.min)), + FeeRate::new(FeePriority::Fast, GasPriceType::regular(fees.fee_charged.p95 * 2)), + ]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_EMPTY_ADDRESS, create_test_client}; + use primitives::{Asset, Chain, TransactionInputType, TransactionPreloadInput}; + + #[tokio::test] + async fn test_stellar_get_transaction_preload() -> Result<(), Box> { + let client = create_test_client(); + + let input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Stellar)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_ADDRESS.to_string(), + }; + + let metadata = client.get_transaction_preload(input).await?; + + assert!(metadata.get_sequence()? > 0); + assert!(metadata.get_is_destination_address_exist()?); + + Ok(()) + } + + #[tokio::test] + async fn test_stellar_get_transaction_preload_empty_address() -> Result<(), Box> { + let client = create_test_client(); + + let input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Stellar)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_EMPTY_ADDRESS.to_string(), + }; + + let metadata = client.get_transaction_preload(input).await?; + + assert!(metadata.get_sequence()? > 0); + assert!(!metadata.get_is_destination_address_exist()?); + + Ok(()) + } + + #[tokio::test] + async fn test_stellar_get_transaction_load() -> Result<(), Box> { + let client = create_test_client(); + + let preload_input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Stellar)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_ADDRESS.to_string(), + }; + + let metadata = client.get_transaction_preload(preload_input).await?; + + let load_input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Stellar)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_ADDRESS.to_string(), + value: "1000000".to_string(), + gas_price: primitives::GasPriceType::regular(100), + memo: None, + is_max_value: false, + metadata, + }; + + let load_data = client.get_transaction_load(load_input).await?; + + assert!(load_data.fee.fee > num_bigint::BigInt::from(0)); + assert!(load_data.metadata.get_sequence()? > 0); + + Ok(()) + } + + #[tokio::test] + async fn test_stellar_get_transaction_load_empty_destination() -> Result<(), Box> { + let client = create_test_client(); + + let preload_input = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Stellar)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_EMPTY_ADDRESS.to_string(), + }; + + let metadata = client.get_transaction_preload(preload_input).await?; + + let load_input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Stellar)), + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_EMPTY_ADDRESS.to_string(), + value: "15000000".to_string(), + gas_price: primitives::GasPriceType::regular(100), + memo: None, + is_max_value: false, + metadata, + }; + + let load_data = client.get_transaction_load(load_input).await?; + + assert_eq!(load_data.fee.fee, num_bigint::BigInt::from(100)); + assert!(load_data.metadata.get_sequence()? > 0); + + Ok(()) + } +} diff --git a/core/crates/gem_stellar/src/provider/preload_mapper.rs b/core/crates/gem_stellar/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..9e0a5d459c --- /dev/null +++ b/core/crates/gem_stellar/src/provider/preload_mapper.rs @@ -0,0 +1,55 @@ +use num_bigint::BigInt; +use primitives::{FeeOption, TransactionFee, TransactionLoadData, TransactionLoadInput}; +use std::error::Error; + +pub fn map_transaction_load(input: TransactionLoadInput) -> Result> { + let fee = if input.metadata.get_is_destination_address_exist()? { + input.default_fee() + } else { + TransactionFee::new_from_fee_with_option(input.gas_price.gas_price(), FeeOption::TokenAccountCreation, BigInt::ZERO) + }; + + Ok(TransactionLoadData { fee, metadata: input.metadata }) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Asset, Chain, GasPriceType, TransactionInputType, TransactionLoadMetadata}; + + #[test] + fn test_map_transaction_load_destination_exists() { + let input = TransactionLoadInput { + metadata: TransactionLoadMetadata::Stellar { + sequence: 1, + is_destination_address_exist: true, + }, + ..TransactionLoadInput::mock() + }; + + let result = map_transaction_load(input).unwrap(); + + assert_eq!(result.fee.fee, BigInt::from(1000)); + assert!(result.fee.options.is_empty()); + } + + #[test] + fn test_map_transaction_load_destination_not_exist() { + let input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Stellar)), + value: "15000000".to_string(), + gas_price: GasPriceType::regular(BigInt::from(100)), + metadata: TransactionLoadMetadata::Stellar { + sequence: 1, + is_destination_address_exist: false, + }, + ..TransactionLoadInput::mock() + }; + + let result = map_transaction_load(input).unwrap(); + + assert_eq!(result.fee.fee, BigInt::from(100)); + assert!(result.fee.options.contains_key(&FeeOption::TokenAccountCreation)); + assert_eq!(result.fee.options.get(&FeeOption::TokenAccountCreation), Some(&BigInt::ZERO)); + } +} diff --git a/core/crates/gem_stellar/src/provider/request_classifier.rs b/core/crates/gem_stellar/src/provider/request_classifier.rs new file mode 100644 index 0000000000..6131673fba --- /dev/null +++ b/core/crates/gem_stellar/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/transactions_async") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_stellar/src/provider/state.rs b/core/crates/gem_stellar/src/provider/state.rs new file mode 100644 index 0000000000..9823e1cf0a --- /dev/null +++ b/core/crates/gem_stellar/src/provider/state.rs @@ -0,0 +1,18 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; + +use crate::rpc::client::StellarClient; + +#[async_trait] +impl ChainState for StellarClient { + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_node_status().await?.ingest_latest_ledger as u64) + } + + async fn get_chain_id(&self) -> Result> { + Ok(self.get_node_status().await?.network_passphrase) + } +} diff --git a/core/crates/gem_stellar/src/provider/state_mapper.rs b/core/crates/gem_stellar/src/provider/state_mapper.rs new file mode 100644 index 0000000000..ae37f7b84f --- /dev/null +++ b/core/crates/gem_stellar/src/provider/state_mapper.rs @@ -0,0 +1,42 @@ +use crate::models::fee::StellarFees; +use primitives::{FeePriority, FeeRate, GasPriceType}; + +#[cfg(test)] +use num_bigint::BigInt; + +pub fn map_fee_stats_to_priorities(fees: &StellarFees) -> Vec { + let min_fee = std::cmp::max(fees.fee_charged.min, fees.last_ledger_base_fee); + + let fast_fee = fees.fee_charged.p95.max(min_fee) * 2; + + vec![ + FeeRate::new(FeePriority::Slow, GasPriceType::regular(min_fee)), + FeeRate::new(FeePriority::Normal, GasPriceType::regular(min_fee)), + FeeRate::new(FeePriority::Fast, GasPriceType::regular(fast_fee)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::fee::{StellarFeeCharged, StellarFees}; + + #[test] + fn test_map_fee_stats_to_priorities() { + let fees = StellarFees { + fee_charged: StellarFeeCharged { min: 100, p95: 500 }, + last_ledger_base_fee: 150, + }; + + let result = map_fee_stats_to_priorities(&fees); + assert_eq!(result.len(), 3); + match &result[0].gas_price_type { + GasPriceType::Regular { gas_price } => assert_eq!(gas_price, &BigInt::from(150)), // max(100, 150) + _ => panic!("Expected Regular gas price"), + } + match &result[2].gas_price_type { + GasPriceType::Regular { gas_price } => assert_eq!(gas_price, &BigInt::from(1000)), // 500 * 2 + _ => panic!("Expected Regular gas price"), + } + } +} diff --git a/core/crates/gem_stellar/src/provider/testkit.rs b/core/crates/gem_stellar/src/provider/testkit.rs new file mode 100644 index 0000000000..aadb9e71c4 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/testkit.rs @@ -0,0 +1,20 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::StellarClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP"; +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_EMPTY_ADDRESS: &str = "GBUUVZ2XQZGVPQ2IAWDTOJ3Z2UZC23I7MEAC2VRP7VCTNFOZDGCJJMEI"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "356f0ece1eb64da9569b9a2b7a2fe0c3c5a00346a6ea33915c61f19e9ccdf418"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_test_client() -> StellarClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.stellar.url, reqwest::Client::new()); + StellarClient::new(reqwest_client) +} diff --git a/core/crates/gem_stellar/src/provider/token.rs b/core/crates/gem_stellar/src/provider/token.rs new file mode 100644 index 0000000000..4fd4566333 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/token.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use crate::constants::STELLAR_TOKEN_DECIMALS; +use crate::rpc::client::StellarClient; +use gem_client::Client; +use primitives::{Asset, AssetId, AssetType}; + +#[async_trait] +impl ChainToken for StellarClient { + async fn get_token_data(&self, token_id: String) -> Result> { + let (issuer, symbol) = if token_id.contains("::") { + let parts: Vec<&str> = token_id.split("::").collect(); + if parts.len() != 2 { + return Err("Invalid token ID format. Expected: issuer::symbol".into()); + } + (parts[0], Some(parts[1])) + } else { + (token_id.as_str(), None) + }; + + let assets = self.get_assets_by_issuer(issuer).await?; + + let asset = if let Some(sym) = symbol { + assets + ._embedded + .records + .iter() + .find(|a| a.asset_code == sym) + .ok_or_else(|| format!("Asset not found: {}", token_id))? + } else { + assets._embedded.records.first().ok_or_else(|| format!("No assets found for issuer: {}", issuer))? + }; + let symbol = asset.asset_code.clone(); + let token_id = AssetId::sub_token_id(&[issuer.to_string(), symbol.clone()]); + + Ok(Asset { + id: AssetId::from(self.chain, Some(token_id.clone())), + chain: self.chain, + token_id: Some(token_id), + name: symbol.clone(), + symbol, + decimals: STELLAR_TOKEN_DECIMALS, + asset_type: AssetType::TOKEN, + }) + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + token_id.len() >= 56 && token_id.starts_with("G") + } +} diff --git a/core/crates/gem_stellar/src/provider/token_mapper.rs b/core/crates/gem_stellar/src/provider/token_mapper.rs new file mode 100644 index 0000000000..50a51703ef --- /dev/null +++ b/core/crates/gem_stellar/src/provider/token_mapper.rs @@ -0,0 +1,36 @@ +use crate::constants::STELLAR_TOKEN_DECIMALS; +use crate::models::common::StellarAsset; +use primitives::{Asset, AssetId, AssetType, Chain}; + +pub fn map_token_data(asset: &StellarAsset, token_id: String, chain: Chain) -> Asset { + Asset { + id: AssetId::from(chain, Some(token_id.clone())), + chain, + token_id: Some(token_id), + name: asset.asset_code.clone(), + symbol: asset.asset_code.clone(), + decimals: STELLAR_TOKEN_DECIMALS, + asset_type: AssetType::TOKEN, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::asset_constants::STELLAR_USDC_TOKEN_ID; + + #[test] + fn test_map_token_data() { + let stellar_asset = StellarAsset { + asset_code: "USDC".to_string(), + asset_issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN".to_string(), + contract_id: None, + }; + let token_id = STELLAR_USDC_TOKEN_ID.to_string(); + let chain = Chain::Stellar; + + let result = map_token_data(&stellar_asset, token_id, chain); + assert_eq!(result.symbol, "USDC"); + assert_eq!(result.chain, Chain::Stellar); + } +} diff --git a/core/crates/gem_stellar/src/provider/transaction_broadcast.rs b/core/crates/gem_stellar/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..fa730b6796 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/transaction_broadcast.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{BroadcastProvider, transaction_broadcast_mapper::map_transaction_broadcast_response_from_str, transactions_mapper::map_transaction_broadcast}, + rpc::client::StellarClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for StellarClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(&data).await?; + map_transaction_broadcast(&response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_stellar/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_stellar/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..0bca4a9663 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,9 @@ +use std::error::Error; + +use crate::models::transaction::StellarTransactionBroadcast; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::(response)?; + map_transaction_broadcast(&response) +} diff --git a/core/crates/gem_stellar/src/provider/transaction_state.rs b/core/crates/gem_stellar/src/provider/transaction_state.rs new file mode 100644 index 0000000000..4919295eab --- /dev/null +++ b/core/crates/gem_stellar/src/provider/transaction_state.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::StellarClient}; + +#[async_trait] +impl ChainTransactionState for StellarClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let result = self.get_transaction_status(&request.id).await?; + Ok(map_transaction_status(&result)) + } +} diff --git a/core/crates/gem_stellar/src/provider/transaction_state_mapper.rs b/core/crates/gem_stellar/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..7329aaa643 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/transaction_state_mapper.rs @@ -0,0 +1,45 @@ +use num_bigint::BigInt; +use primitives::{TransactionChange, TransactionState, TransactionUpdate}; + +use crate::models::transaction::StellarTransactionStatus; + +pub fn map_transaction_status(tx: &StellarTransactionStatus) -> TransactionUpdate { + let state = if tx.successful { TransactionState::Confirmed } else { TransactionState::Failed }; + + let network_fee = BigInt::from(tx.fee_charged.clone()); + + TransactionUpdate { + state, + changes: vec![TransactionChange::NetworkFee(network_fee)], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use num_bigint::BigUint; + + use crate::models::transaction::StellarTransactionStatus; + + #[test] + fn test_map_transaction_status() { + let status = StellarTransactionStatus { + successful: true, + fee_charged: BigUint::from(100_u32), + hash: "hash".to_string(), + }; + + let result = map_transaction_status(&status); + assert_eq!(result.state, TransactionState::Confirmed); + assert_eq!(result.changes.len(), 1); + } + + #[test] + fn test_map_transaction_status_with_real_data() { + let response: StellarTransactionStatus = serde_json::from_str(include_str!("../../testdata/transaction_state_success.json")).unwrap(); + + let result = map_transaction_status(&response); + assert_eq!(result.state, TransactionState::Confirmed); + assert!(!result.changes.is_empty()); + } +} diff --git a/core/crates/gem_stellar/src/provider/transactions.rs b/core/crates/gem_stellar/src/provider/transactions.rs new file mode 100644 index 0000000000..35ebef789d --- /dev/null +++ b/core/crates/gem_stellar/src/provider/transactions.rs @@ -0,0 +1,88 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::{ + models::AccountResult, + provider::transactions_mapper::{map_transaction_by_hash, map_transactions}, + rpc::client::StellarClient, +}; + +#[async_trait] +impl ChainTransactions for StellarClient { + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, .. } = request; + let payments = self.get_account_payments(address).await?; + match payments { + AccountResult::Found(payments) => Ok(map_transactions(self.get_chain(), payments._embedded.records)), + AccountResult::NotFound => Ok(Vec::new()), + } + } + + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let payments = self.get_block_payments_all(block).await?; + Ok(map_transactions(self.get_chain(), payments)) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + let payments = self.get_transaction_payments(&hash).await?; + match payments { + AccountResult::Found(payments) => Ok(map_transaction_by_hash(self.get_chain(), payments._embedded.records, &hash)), + AccountResult::NotFound => Ok(None), + } + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_EMPTY_ADDRESS, TEST_TRANSACTION_ID, create_test_client}; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_get_transactions_by_block() { + let stellar_client = create_test_client(); + let latest_block = stellar_client.get_block_latest_number().await.unwrap(); + let transactions = stellar_client.get_transactions_by_block(latest_block).await.unwrap(); + + println!("Latest block: {}, transactions count: {}", latest_block, transactions.len()); + assert!(latest_block > 0); + } + + #[tokio::test] + async fn test_get_transactions_by_address() { + let stellar_client = create_test_client(); + let transactions = stellar_client + .get_transactions_by_address(TransactionsRequest::new(TEST_ADDRESS.to_string())) + .await + .unwrap(); + + println!("Address: {}, transactions count: {}", TEST_ADDRESS, transactions.len()); + + assert!(!transactions.is_empty()); + } + + #[tokio::test] + async fn test_get_transactions_by_address_empty() { + let stellar_client = create_test_client(); + let transactions = stellar_client + .get_transactions_by_address(TransactionsRequest::new(TEST_EMPTY_ADDRESS.to_string())) + .await + .unwrap(); + + println!("Address: {}, transactions count: {}", TEST_EMPTY_ADDRESS, transactions.len()); + + assert!(transactions.is_empty()); + } + + #[tokio::test] + async fn test_get_transaction_by_hash() { + let stellar_client = create_test_client(); + let transaction = stellar_client.get_transaction_by_hash(TEST_TRANSACTION_ID.to_string()).await.unwrap().unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + } +} diff --git a/core/crates/gem_stellar/src/provider/transactions_mapper.rs b/core/crates/gem_stellar/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..abcdee9998 --- /dev/null +++ b/core/crates/gem_stellar/src/provider/transactions_mapper.rs @@ -0,0 +1,128 @@ +use crate::constants::{TRANSACTION_TYPE_CREATE_ACCOUNT, TRANSACTION_TYPE_PAYMENT}; +use crate::models::transaction::{Payment, StellarTransactionBroadcast}; +use chrono::DateTime; +use primitives::{Transaction, TransactionType, chain::Chain}; +use std::error::Error; +use url::form_urlencoded; + +pub fn encode_transaction_data(data: &str) -> String { + form_urlencoded::Serializer::new(String::new()).append_pair("tx", data).finish() +} + +pub fn map_transaction_broadcast(response: &StellarTransactionBroadcast) -> Result> { + if let Some(hash) = &response.hash + && response.tx_status == "PENDING" + { + Ok(hash.clone()) + } else if let Some(error) = &response.error_message { + Err(format!("Broadcast error: {}", error).into()) + } else if let Some(error) = &response.error_result_xdr { + Err(format!("Broadcast error: {}", error).into()) + } else { + Err("Unknown broadcast error".into()) + } +} + +pub fn map_transactions(chain: Chain, transactions: Vec) -> Vec { + transactions.into_iter().flat_map(|x| map_transaction(chain, x)).collect() +} + +pub fn map_transaction_by_hash(chain: Chain, transactions: Vec, hash: &str) -> Option { + transactions + .into_iter() + .filter_map(|transaction| map_transaction(chain, transaction)) + .find(|transaction| transaction.hash == hash) +} + +pub fn map_transaction(chain: Chain, transaction: Payment) -> Option { + match transaction.payment_type.as_str() { + TRANSACTION_TYPE_PAYMENT | TRANSACTION_TYPE_CREATE_ACCOUNT => { + if transaction.clone().asset_type.unwrap_or_default() == "native" || transaction.clone().payment_type.as_str() == TRANSACTION_TYPE_CREATE_ACCOUNT { + let created_at = DateTime::parse_from_rfc3339(&transaction.created_at).ok()?.into(); + + return Some(Transaction::new( + transaction.clone().transaction_hash, + chain.as_asset_id(), + transaction.from_address()?, + transaction.to_address()?, + None, + TransactionType::Transfer, + transaction.get_state(), + "1000".to_string(), // TODO: Calculate from block/transaction + chain.as_asset_id(), + transaction.get_value()?, + transaction.clone().get_memo(), + None, + created_at, + )); + } + + None + } + _ => None, + } +} + +pub fn is_token_address(token_id: &str) -> bool { + token_id.len() > 32 && token_id.contains('-') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{ + Embedded, + transaction::{Payment, StellarTransactionBroadcast, StellarTransactionStatus}, + }, + provider::testkit::TEST_TRANSACTION_ID, + }; + use primitives::Chain; + + #[test] + fn test_encode_transaction_data_variants() { + assert_eq!(encode_transaction_data("payload"), "tx=payload"); + + let special = encode_transaction_data("tx=abc/123?&value=42 +more"); + assert_eq!(special, "tx=tx%3Dabc%2F123%3F%26value%3D42+%2Bmore"); + } + + #[test] + fn test_map_transaction_broadcast_success() { + let response = StellarTransactionBroadcast { + hash: Some("test_hash_123".to_string()), + error_message: None, + tx_status: "PENDING".to_string(), + error_result_xdr: None, + }; + + let result = map_transaction_broadcast(&response).unwrap(); + assert_eq!(result, "test_hash_123"); + } + + #[test] + fn test_map_transaction_broadcast_with_real_data() { + let response: StellarTransactionStatus = serde_json::from_str(include_str!("../../testdata/transaction_transfer_broadcast_success.json")).unwrap(); + + let result = map_transaction_broadcast(&StellarTransactionBroadcast { + hash: Some(response.hash.clone()), + error_message: None, + tx_status: "PENDING".to_string(), + error_result_xdr: None, + }); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf"); + } + + #[test] + fn test_map_transaction_by_hash() { + let payments: Embedded = serde_json::from_str(include_str!("../../testdata/transaction_by_hash.json")).unwrap(); + let transaction = map_transaction_by_hash(Chain::Stellar, payments._embedded.records, TEST_TRANSACTION_ID).unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + assert_eq!(transaction.from, "GFROM"); + assert_eq!(transaction.to, "GTO"); + assert_eq!(transaction.memo, Some("49639518".to_string())); + } +} diff --git a/core/crates/gem_stellar/src/rpc/client.rs b/core/crates/gem_stellar/src/rpc/client.rs new file mode 100644 index 0000000000..ef491a754f --- /dev/null +++ b/core/crates/gem_stellar/src/rpc/client.rs @@ -0,0 +1,131 @@ +use std::error::Error; + +use crate::models::account::Account; +use crate::models::common::{Embedded, StellarAsset, StellarEmbedded}; +use crate::models::fee::StellarFees; +use crate::models::node::NodeStatus; +use crate::models::transaction::{Payment, StellarTransactionBroadcast, StellarTransactionStatus}; +use crate::models::{AccountEmpty, AccountResult}; + +use chain_traits::{ChainAddressStatus, ChainPerpetual, ChainProvider, ChainStaking, ChainTraits}; +use gem_client::{Client, ClientError, ClientExt, ContentType}; +use primitives::Chain; +use std::collections::HashMap; + +use crate::provider::transactions_mapper::encode_transaction_data; + +#[derive(Debug)] +pub struct StellarClient { + client: C, + pub chain: Chain, +} + +impl StellarClient { + pub fn new(client: C) -> Self { + Self { client, chain: Chain::Stellar } + } + + pub fn get_chain(&self) -> Chain { + self.chain + } + + pub async fn get_node_status(&self) -> Result> { + Ok(self.client.get("/").await?) + } + + pub async fn get_transaction_status(&self, transaction_id: &str) -> Result> { + Ok(self.client.get(&format!("/transactions/{}", transaction_id)).await?) + } + + pub async fn get_fees(&self) -> Result> { + Ok(self.client.get("/fee_stats").await?) + } + + pub async fn broadcast_transaction(&self, data: &str) -> Result> { + let body = encode_transaction_data(data); + let headers = HashMap::from([("Content-Type".to_string(), ContentType::ApplicationFormUrlEncoded.as_str().to_string())]); + + Ok(self.client.post_with_headers("/transactions_async", &body, headers).await?) + } + + pub async fn get_assets_by_issuer(&self, issuer: &str) -> Result, Box> { + Ok(self.client.get(&format!("/assets?asset_issuer={}&limit=200", issuer)).await?) + } + + pub async fn get_account(&self, account_id: String) -> Result, Box> { + match self.client.get::(&format!("/accounts/{}", account_id)).await { + Ok(account) => Ok(AccountResult::Found(account)), + Err(ClientError::Http { status: 404, .. }) => Ok(AccountResult::NotFound), + Err(e) => Err(Box::new(e)), + } + } + + pub async fn account_exists(&self, address: &str) -> Result> { + match self.client.get::(&format!("/accounts/{}", address)).await { + Ok(account) => Ok(account.id.is_some()), + Err(ClientError::Http { status: 404, .. }) => Ok(false), + Err(e) => Err(Box::new(e)), + } + } + + pub async fn get_account_payments(&self, account_id: String) -> Result>, Box> { + self.get_payments(&format!("/accounts/{account_id}/payments?order=desc&limit=200&include_failed=true&join=transactions")) + .await + } + + pub async fn get_transaction_payments(&self, transaction_id: &str) -> Result>, Box> { + self.get_payments(&format!("/transactions/{transaction_id}/payments?include_failed=true&join=transactions")) + .await + } + + async fn get_payments(&self, path: &str) -> Result>, Box> { + match self.client.get::>(path).await { + Ok(result) => Ok(AccountResult::Found(result)), + Err(ClientError::Http { status: 404, .. }) => Ok(AccountResult::NotFound), + Err(e) => Err(Box::new(e)), + } + } + + pub async fn get_block_payments(&self, block_number: u64, limit: usize, cursor: Option) -> Result, Box> { + let cursor_param = cursor.unwrap_or_default(); + let result: Embedded = self + .client + .get(&format!( + "/ledgers/{}/payments?limit={}&include_failed=true&cursor={}&join=transactions", + block_number, limit, cursor_param + )) + .await?; + Ok(result._embedded.records) + } + + pub async fn get_block_payments_all(&self, block_number: u64) -> Result, Box> { + let mut results: Vec = Vec::new(); + let mut cursor: Option = None; + let limit: usize = 200; + loop { + let payments = self.get_block_payments(block_number, limit, cursor).await?; + results.extend(payments.clone()); + cursor = payments.last().map(|x| x.id.clone()); + + if payments.len() < limit { + return Ok(results); + } + } + } +} + +impl ChainStaking for StellarClient {} + +impl ChainPerpetual for StellarClient {} + +impl ChainAddressStatus for StellarClient {} + +impl chain_traits::ChainAccount for StellarClient {} + +impl ChainTraits for StellarClient {} + +impl ChainProvider for StellarClient { + fn get_chain(&self) -> primitives::Chain { + self.chain + } +} diff --git a/core/crates/gem_stellar/src/rpc/mod.rs b/core/crates/gem_stellar/src/rpc/mod.rs new file mode 100644 index 0000000000..38692a78c1 --- /dev/null +++ b/core/crates/gem_stellar/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::StellarClient; diff --git a/core/crates/gem_stellar/src/signer/chain_signer.rs b/core/crates/gem_stellar/src/signer/chain_signer.rs new file mode 100644 index 0000000000..8daec0a085 --- /dev/null +++ b/core/crates/gem_stellar/src/signer/chain_signer.rs @@ -0,0 +1,149 @@ +use crate::models::signing::StellarTransaction; +use crate::signer::signing::sign_transaction; +use primitives::{ChainSigner, SignerError, SignerInput}; + +#[derive(Default)] +pub struct StellarChainSigner; + +impl ChainSigner for StellarChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&StellarTransaction::transfer(input)?, private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&StellarTransaction::token_transfer(input)?, private_key) + } + + fn sign_account_action(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&StellarTransaction::account_action(input)?, private_key) + } +} + +#[cfg(test)] +mod tests { + // Tests taken from https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Stellar/TWAnySignerTests.cpp + use super::*; + use crate::address::StellarAddress; + use crate::models::signing::{Memo, Operation, StellarAssetData, StellarTransaction}; + use crate::signer::signing::sign_transaction; + use gem_encoding::decode_base64; + use primitives::{Address, Asset, AssetType, Chain, TransactionFee, TransactionLoadInput, TransactionLoadMetadata}; + use signer::Ed25519KeyPair; + + const PRIVATE_KEY: &str = "59a313f46ef1c23a9e4f71cea10fc0c56a2a6bb8a4b9ea3d5348823e5a478722"; + const SENDER: &str = "GAE2SZV4VLGBAPRYRFV2VY7YYLYGYIP5I7OU7BSP6DJT7GAZ35OKFDYI"; + const DESTINATION: &str = "GDCYBNRRPIHLHG7X7TKPUPAZ7WVUXCN3VO7WCCK64RIFV5XM5V5K4A52"; + + fn metadata(sequence: u64, is_destination_address_exist: bool) -> TransactionLoadMetadata { + TransactionLoadMetadata::Stellar { + sequence, + is_destination_address_exist, + } + } + + #[test] + fn test_sign_stellar_transactions() { + let key = hex::decode(PRIVATE_KEY).unwrap(); + + // Native transfer with memo + let input = SignerInput::new( + TransactionLoadInput::mock_transfer( + Asset::from_chain(Chain::Stellar), + SENDER, + DESTINATION, + "10000000", + 1000, + Some("Hello, world!"), + metadata(2, true), + ), + TransactionFee::new_from_fee(1000.into()), + ); + let signed = StellarChainSigner.sign_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "AAAAAAmpZryqzBA+OIlrquP4wvBsIf1H3U+GT/DTP5gZ31yiAAAD6AAAAAAAAAACAAAAAAAAAAEAAAANSGVsbG8sIHdvcmxkIQAAAAAAAAEAAAAAAAAAAQAAAADFgLYxeg6zm/f81Po8Gf2rS4m7q79hCV7kUFr27O16rgAAAAAAAAAAAJiWgAAAAAAAAAABGd9cogAAAEBQQldEkYJ6rMvOHilkwFCYyroGGUvrNeWVqr/sn3iFFqgz91XxgUT0ou7bMSPRgPROfBYDfQCFfFxbcDPrrCwB" + ); + + // Transfer to non-existent destination (creates account) + let input = SignerInput::new( + TransactionLoadInput::mock_transfer(Asset::from_chain(Chain::Stellar), SENDER, DESTINATION, "10000000", 1000, None, metadata(2, false)), + TransactionFee::new_from_fee(1000.into()), + ); + let signed = StellarChainSigner.sign_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "AAAAAAmpZryqzBA+OIlrquP4wvBsIf1H3U+GT/DTP5gZ31yiAAAD6AAAAAAAAAACAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAxYC2MXoOs5v3/NT6PBn9q0uJu6u/YQle5FBa9uzteq4AAAAAAJiWgAAAAAAAAAABGd9cogAAAEA6vrVXe4OUNPKKlGtzJiNzGi1p1yAd6pxoTEcoixXZbWponp6L5XOVweg5tTM36pZVQjQIxEjOgktinR96Wf8O" + ); + + // Token transfer + let mobi = Asset::mock_with_params( + Chain::Stellar, + Some("GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH::MOBI".into()), + "MOBI".into(), + "MOBI".into(), + 7, + AssetType::TOKEN, + ); + let input = SignerInput::new( + TransactionLoadInput::mock_transfer( + mobi, + "GDFEKJIFKUZP26SESUHZONAUJZMBSODVN2XBYN4KAGNHB7LX2OIXLPUL", + "GA3ISGYIE2ZTH3UAKEKBVHBPKUSL3LT4UQ6C5CUGP2IM5F467O267KI7", + "12000000", + 1000, + None, + metadata(144098454883270661, true), + ), + TransactionFee::new_from_fee(1000.into()), + ); + let signed = StellarChainSigner + .sign_token_transfer(&input, &hex::decode("3c0635f8638605aed6e461cf3fa2d508dd895df1a1655ff92c79bfbeaf88d4b9").unwrap()) + .unwrap(); + assert_eq!( + signed, + "AAAAAMpFJQVVMv16RJUPlzQUTlgZOHVurhw3igGacP1305F1AAAD6AH/8MgAAAAFAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAANokbCCazM+6AURQanC9VJL2ufKQ8LoqGfpDOl577te8AAAABTU9CSQAAAAA8cTArnmXa4wEQJxDHOw5SwBaDVjBfAP5lRMNZkRtlZAAAAAAAtxsAAAAAAAAAAAF305F1AAAAQEuWZZvKZuF6SMuSGIyfLqx5sn5O55+Kd489uP4g9jZH4UE7zZ4ME0+74I0BU8YDsYOmmxcfp/vdwTd+n3oGCQw=" + ); + } + + #[test] + fn test_sign_change_trust_with_time_bounds() { + let transaction = StellarTransaction { + account: StellarAddress::from_str("GDFEKJIFKUZP26SESUHZONAUJZMBSODVN2XBYN4KAGNHB7LX2OIXLPUL").unwrap(), + fee: 10000, + sequence: 144098454883270659, + memo: Memo::None, + time_bounds: Some(1613336576), + operation: Operation::ChangeTrust { + asset: StellarAssetData::new("GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH", "MOBI").unwrap(), + }, + }; + + let signed = sign_transaction(&transaction, &hex::decode("3c0635f8638605aed6e461cf3fa2d508dd895df1a1655ff92c79bfbeaf88d4b9").unwrap()).unwrap(); + assert_eq!( + signed, + "AAAAAMpFJQVVMv16RJUPlzQUTlgZOHVurhw3igGacP1305F1AAAnEAH/8MgAAAADAAAAAQAAAAAAAAAAAAAAAGApkAAAAAAAAAAAAQAAAAAAAAAGAAAAAU1PQkkAAAAAPHEwK55l2uMBECcQxzsOUsAWg1YwXwD+ZUTDWZEbZWR//////////wAAAAAAAAABd9ORdQAAAEAnfyXyaNQX5Bq3AEQVBIaYd+cLib+y2sNY7DF/NYVSE51dZ6swGGElz094ObsPefmVmeRrkGsSc/fF5pmth+wJ" + ); + } + + #[test] + fn test_stellar_signing_validation_and_hint() { + let transfer_key = hex::decode("3c0635f8638605aed6e461cf3fa2d508dd895df1a1655ff92c79bfbeaf88d4b9").unwrap(); + + let signed = StellarChainSigner + .sign_transfer( + &SignerInput::new( + TransactionLoadInput::mock_transfer(Asset::from_chain(Chain::Stellar), SENDER, DESTINATION, "10000000", 1000, None, metadata(2, true)), + TransactionFee::new_from_fee(1000.into()), + ), + &transfer_key, + ) + .unwrap(); + let envelope = decode_base64(&signed).unwrap(); + let signer_key = Ed25519KeyPair::from_private_key(&transfer_key).unwrap(); + let sender = StellarAddress::from_str(SENDER).unwrap(); + let hint_offset = envelope.len() - 72; + + assert_eq!(&envelope[hint_offset..hint_offset + 4], &signer_key.public_key_bytes[28..32]); + assert_ne!(&envelope[hint_offset..hint_offset + 4], &sender.as_bytes()[28..32]); + } +} diff --git a/core/crates/gem_stellar/src/signer/mod.rs b/core/crates/gem_stellar/src/signer/mod.rs new file mode 100644 index 0000000000..a2c6ff6410 --- /dev/null +++ b/core/crates/gem_stellar/src/signer/mod.rs @@ -0,0 +1,5 @@ +mod chain_signer; +mod serialization; +mod signing; + +pub use chain_signer::StellarChainSigner; diff --git a/core/crates/gem_stellar/src/signer/serialization.rs b/core/crates/gem_stellar/src/signer/serialization.rs new file mode 100644 index 0000000000..0bc61cac37 --- /dev/null +++ b/core/crates/gem_stellar/src/signer/serialization.rs @@ -0,0 +1,111 @@ +use crate::address::StellarAddress; +use crate::models::signing::{Memo, Operation, StellarAssetCode, StellarAssetData, StellarTransaction}; +use primitives::Address; +use signer::ED25519_KEY_TYPE; + +const ASSET_TYPE_NATIVE: u32 = 0; +const ASSET_TYPE_ALPHANUM4: u32 = 1; +const ASSET_TYPE_ALPHANUM12: u32 = 2; + +/// XDR-encode a Stellar transaction (unsigned envelope body). +pub(crate) fn encode_transaction(tx: &StellarTransaction) -> Vec { + let mut data = Vec::new(); + encode_address(&mut data, &tx.account); + write_u32(&mut data, tx.fee); + write_u64(&mut data, tx.sequence); + encode_time_bounds(&mut data, tx); + encode_memo(&mut data, &tx.memo); + // 1 operation, no source account override + write_u32(&mut data, 1); + write_u32(&mut data, 0); + write_u32(&mut data, tx.operation.operation_type()); + + match &tx.operation { + Operation::CreateAccount { destination, amount } => encode_create_account(&mut data, destination, *amount), + Operation::Payment { destination, asset, amount } => encode_payment(&mut data, destination, asset.as_ref(), *amount), + Operation::ChangeTrust { asset } => encode_change_trust(&mut data, asset), + } + + // ext (union void) + write_u32(&mut data, 0); + data +} + +fn encode_create_account(data: &mut Vec, destination: &StellarAddress, amount: u64) { + encode_address(data, destination); + write_u64(data, amount); +} + +fn encode_payment(data: &mut Vec, destination: &StellarAddress, asset: Option<&StellarAssetData>, amount: u64) { + encode_address(data, destination); + encode_asset(data, asset); + write_u64(data, amount); +} + +fn encode_change_trust(data: &mut Vec, asset: &StellarAssetData) { + encode_asset(data, Some(asset)); + write_u64(data, i64::MAX as u64); +} + +fn encode_time_bounds(data: &mut Vec, tx: &StellarTransaction) { + if let Some(to) = tx.time_bounds.filter(|v| *v > 0) { + write_u32(data, 1); + write_u64(data, 0); + write_u64(data, to); + } else { + write_u32(data, 0); + } +} + +fn encode_memo(data: &mut Vec, memo: &Memo) { + match memo { + Memo::None => write_u32(data, 0), + Memo::Text(text) => { + write_u32(data, 1); + write_u32(data, text.len() as u32); + data.extend_from_slice(text.as_bytes()); + pad4(data); + } + Memo::Id(id) => { + write_u32(data, 2); + write_u64(data, *id); + } + } +} + +fn encode_asset(data: &mut Vec, asset: Option<&StellarAssetData>) { + match asset { + Some(asset) => { + match &asset.code { + StellarAssetCode::Alphanum4(code) => { + write_u32(data, ASSET_TYPE_ALPHANUM4); + data.extend_from_slice(code); + } + StellarAssetCode::Alphanum12(code) => { + write_u32(data, ASSET_TYPE_ALPHANUM12); + data.extend_from_slice(code); + } + } + encode_address(data, &asset.issuer); + } + None => write_u32(data, ASSET_TYPE_NATIVE), + } +} + +fn encode_address(data: &mut Vec, address: &StellarAddress) { + write_u32(data, ED25519_KEY_TYPE as u32); + data.extend_from_slice(address.as_bytes()); +} + +fn write_u32(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn write_u64(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn pad4(data: &mut Vec) { + let padding = (4 - (data.len() % 4)) % 4; + data.extend(std::iter::repeat_n(0, padding)); +} diff --git a/core/crates/gem_stellar/src/signer/signing.rs b/core/crates/gem_stellar/src/signer/signing.rs new file mode 100644 index 0000000000..76a890103f --- /dev/null +++ b/core/crates/gem_stellar/src/signer/signing.rs @@ -0,0 +1,31 @@ +use crate::models::signing::StellarTransaction; +use crate::signer::serialization::encode_transaction; +use gem_encoding::encode_base64; +use gem_hash::sha2::sha256; +use primitives::SignerError; +use signer::Ed25519KeyPair; + +const NETWORK_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015"; +const ENVELOPE_TYPE_TX: [u8; 4] = 2u32.to_be_bytes(); +const SIGNATURE_COUNT: [u8; 4] = 1u32.to_be_bytes(); + +pub(crate) fn sign_transaction(transaction: &StellarTransaction, private_key: &[u8]) -> Result { + let encoded = encode_transaction(transaction); + let network_id = sha256(NETWORK_PASSPHRASE.as_bytes()); + + let mut preimage = Vec::with_capacity(network_id.len() + ENVELOPE_TYPE_TX.len() + encoded.len()); + preimage.extend_from_slice(&network_id); + preimage.extend_from_slice(&ENVELOPE_TYPE_TX); + preimage.extend_from_slice(&encoded); + + let digest = sha256(&preimage); + let key_pair = Ed25519KeyPair::from_private_key(private_key)?; + let signature = key_pair.sign(&digest); + + let mut envelope = encoded; + envelope.extend_from_slice(&SIGNATURE_COUNT); + envelope.extend_from_slice(&key_pair.public_key_bytes[28..32]); + envelope.extend_from_slice(&(signature.len() as u32).to_be_bytes()); + envelope.extend_from_slice(&signature); + Ok(encode_base64(&envelope)) +} diff --git a/core/crates/gem_stellar/testdata/balance.json b/core/crates/gem_stellar/testdata/balance.json new file mode 100644 index 0000000000..9b0bc48788 --- /dev/null +++ b/core/crates/gem_stellar/testdata/balance.json @@ -0,0 +1,85 @@ +{ + "_links": { + "self": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP" + }, + "transactions": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP/transactions{?cursor,limit,order}", + "templated": true + }, + "operations": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP/operations{?cursor,limit,order}", + "templated": true + }, + "payments": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP/payments{?cursor,limit,order}", + "templated": true + }, + "effects": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP/effects{?cursor,limit,order}", + "templated": true + }, + "offers": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP/offers{?cursor,limit,order}", + "templated": true + }, + "trades": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP/trades{?cursor,limit,order}", + "templated": true + }, + "data": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP/data/{key}", + "templated": true + } + }, + "id": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "account_id": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "sequence": "235679166362550285", + "sequence_ledger": 55722220, + "sequence_time": "1739478356", + "subentry_count": 1, + "last_modified_ledger": 58472162, + "last_modified_time": "2025-08-15T02:09:09Z", + "thresholds": { + "low_threshold": 0, + "med_threshold": 0, + "high_threshold": 0 + }, + "flags": { + "auth_required": false, + "auth_revocable": false, + "auth_immutable": false, + "auth_clawback_enabled": false + }, + "balances": [ + { + "balance": "27.6243772", + "limit": "922337203685.4775807", + "buying_liabilities": "0.0000000", + "selling_liabilities": "0.0000000", + "last_modified_ledger": 55220444, + "is_authorized": true, + "is_authorized_to_maintain_liabilities": true, + "asset_type": "credit_alphanum4", + "asset_code": "USDC", + "asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + }, + { + "balance": "30.9999077", + "buying_liabilities": "0.0000000", + "selling_liabilities": "0.0000000", + "asset_type": "native" + } + ], + "signers": [ + { + "weight": 1, + "key": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "type": "ed25519_public_key" + } + ], + "data": {}, + "num_sponsoring": 0, + "num_sponsored": 0, + "paging_token": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP" +} \ No newline at end of file diff --git a/core/crates/gem_stellar/testdata/balance_coin.json b/core/crates/gem_stellar/testdata/balance_coin.json new file mode 100644 index 0000000000..5601d0f4bb --- /dev/null +++ b/core/crates/gem_stellar/testdata/balance_coin.json @@ -0,0 +1,71 @@ +{ + "_links": { + "self": { + "href": "http://rpc.ankr.com/accounts/GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R" + }, + "transactions": { + "href": "http://rpc.ankr.com/accounts/GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R/transactions{?cursor,limit,order}", + "templated": true + }, + "operations": { + "href": "http://rpc.ankr.com/accounts/GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R/operations{?cursor,limit,order}", + "templated": true + }, + "payments": { + "href": "http://rpc.ankr.com/accounts/GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R/payments{?cursor,limit,order}", + "templated": true + }, + "effects": { + "href": "http://rpc.ankr.com/accounts/GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R/effects{?cursor,limit,order}", + "templated": true + }, + "offers": { + "href": "http://rpc.ankr.com/accounts/GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R/offers{?cursor,limit,order}", + "templated": true + }, + "trades": { + "href": "http://rpc.ankr.com/accounts/GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R/trades{?cursor,limit,order}", + "templated": true + }, + "data": { + "href": "http://rpc.ankr.com/accounts/GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R/data/{key}", + "templated": true + } + }, + "id": "GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R", + "account_id": "GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R", + "sequence": "251581592013635584", + "subentry_count": 0, + "last_modified_ledger": 58575904, + "last_modified_time": "2025-08-22T01:39:51Z", + "thresholds": { + "low_threshold": 0, + "med_threshold": 0, + "high_threshold": 0 + }, + "flags": { + "auth_required": false, + "auth_revocable": false, + "auth_immutable": false, + "auth_clawback_enabled": false + }, + "balances": [ + { + "balance": "1.0000000", + "buying_liabilities": "0.0000000", + "selling_liabilities": "0.0000000", + "asset_type": "native" + } + ], + "signers": [ + { + "weight": 1, + "key": "GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R", + "type": "ed25519_public_key" + } + ], + "data": {}, + "num_sponsoring": 0, + "num_sponsored": 0, + "paging_token": "GCUOJRS24KEHSUOU6PSKSU372YMTZMYPCQ74X6R2BKDL5NWIZTJIYV4R" + } \ No newline at end of file diff --git a/core/crates/gem_stellar/testdata/fees.json b/core/crates/gem_stellar/testdata/fees.json new file mode 100644 index 0000000000..8a516bf183 --- /dev/null +++ b/core/crates/gem_stellar/testdata/fees.json @@ -0,0 +1,37 @@ +{ + "last_ledger": "58575391", + "last_ledger_base_fee": "100", + "ledger_capacity_usage": "0.63", + "fee_charged": { + "max": "442075", + "min": "100", + "mode": "100", + "p10": "100", + "p20": "100", + "p30": "100", + "p40": "100", + "p50": "100", + "p60": "100", + "p70": "89860", + "p80": "94224", + "p90": "94224", + "p95": "134319", + "p99": "186885" + }, + "max_fee": { + "max": "29962619", + "min": "100", + "mode": "127641", + "p10": "101", + "p20": "10000", + "p30": "20001", + "p40": "100000", + "p50": "127641", + "p60": "127641", + "p70": "168602", + "p80": "270000", + "p90": "1000000", + "p95": "3341126", + "p99": "25409767" + } + } \ No newline at end of file diff --git a/core/crates/gem_stellar/testdata/transaction_by_hash.json b/core/crates/gem_stellar/testdata/transaction_by_hash.json new file mode 100644 index 0000000000..66c448cfaa --- /dev/null +++ b/core/crates/gem_stellar/testdata/transaction_by_hash.json @@ -0,0 +1,24 @@ +{ + "_embedded": { + "records": [ + { + "id": "1", + "transaction_successful": true, + "transaction_hash": "356f0ece1eb64da9569b9a2b7a2fe0c3c5a00346a6ea33915c61f19e9ccdf418", + "type": "payment", + "asset_type": "native", + "from": "GFROM", + "to": "GTO", + "amount": "0.0001", + "created_at": "2026-01-11T05:10:52Z", + "source_account": null, + "funder": null, + "account": null, + "starting_balance": null, + "transaction": { + "memo": "49639518" + } + } + ] + } +} diff --git a/core/crates/gem_stellar/testdata/transaction_state_error.json b/core/crates/gem_stellar/testdata/transaction_state_error.json new file mode 100644 index 0000000000..e79f39259d --- /dev/null +++ b/core/crates/gem_stellar/testdata/transaction_state_error.json @@ -0,0 +1,50 @@ +{ + "_links": { + "self": { + "href": "http://rpc.ankr.com/transactions/95811f45cbc7bb08ddb60cf096c30d272c9a2d533ff95cd588de3028d8b407f6" + }, + "account": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP" + }, + "ledger": { + "href": "http://rpc.ankr.com/ledgers/58575809" + }, + "operations": { + "href": "http://rpc.ankr.com/transactions/95811f45cbc7bb08ddb60cf096c30d272c9a2d533ff95cd588de3028d8b407f6/operations{?cursor,limit,order}", + "templated": true + }, + "effects": { + "href": "http://rpc.ankr.com/transactions/95811f45cbc7bb08ddb60cf096c30d272c9a2d533ff95cd588de3028d8b407f6/effects{?cursor,limit,order}", + "templated": true + }, + "precedes": { + "href": "http://rpc.ankr.com/transactions?order=asc\u0026cursor=251581183991885824" + }, + "succeeds": { + "href": "http://rpc.ankr.com/transactions?order=desc\u0026cursor=251581183991885824" + }, + "transaction": { + "href": "http://rpc.ankr.com/transactions/95811f45cbc7bb08ddb60cf096c30d272c9a2d533ff95cd588de3028d8b407f6" + } + }, + "id": "95811f45cbc7bb08ddb60cf096c30d272c9a2d533ff95cd588de3028d8b407f6", + "paging_token": "251581183991885824", + "successful": false, + "hash": "95811f45cbc7bb08ddb60cf096c30d272c9a2d533ff95cd588de3028d8b407f6", + "ledger": 58575809, + "created_at": "2025-08-22T01:30:41Z", + "source_account": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "source_account_sequence": "235679166362550290", + "fee_account": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "fee_charged": "100", + "max_fee": "100", + "operation_count": 1, + "envelope_xdr": "AAAAAgAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAGQDRUz0AAAAEgAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAKjkxlriiHlR1PPkqVN/1hk8sw8UP8v6OgqGvrbIzNKMAAAAAAABhqAAAAAAAAAAARP/MbUAAABAalIP4+do7KxrHgYUYyBFy0Dr4StLCbhbgHO8eyz8YC6KSwNI+pr7gxKO4HM/f1SfKhbuImxwvxHRg1GOpmm4AQ==", + "result_xdr": "AAAAAAAAAGT/////AAAAAQAAAAAAAAAA/////QAAAAA=", + "result_meta_xdr": "AAAAAwAAAAAAAAACAAAAAwN9y8EAAAAAAAAAABukzRaqjZQNvY6AXC6V4il1hNtkAbj0gkIZ2NMT/zG1AAAAABHdCZEDRUz0AAAAEQAAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAA33LvgAAAABop8gwAAAAAAAAAAEDfcvBAAAAAAAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAAAR3QmRA0VM9AAAABIAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAN9y8EAAAAAaKfIQQAAAAAAAAAAAAAAAAAAAAA=", + "fee_meta_xdr": "AAAAAgAAAAMDfcu+AAAAAAAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAAAR3Qn1A0VM9AAAABEAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAN9y74AAAAAaKfIMAAAAAAAAAABA33LwQAAAAAAAAAAG6TNFqqNlA29joBcLpXiKXWE22QBuPSCQhnY0xP/MbUAAAAAEd0JkQNFTPQAAAARAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADfcu+AAAAAGinyDAAAAAA", + "memo_type": "none", + "signatures": [ + "alIP4+do7KxrHgYUYyBFy0Dr4StLCbhbgHO8eyz8YC6KSwNI+pr7gxKO4HM/f1SfKhbuImxwvxHRg1GOpmm4AQ==" + ] + } \ No newline at end of file diff --git a/core/crates/gem_stellar/testdata/transaction_state_success.json b/core/crates/gem_stellar/testdata/transaction_state_success.json new file mode 100644 index 0000000000..9c62850b90 --- /dev/null +++ b/core/crates/gem_stellar/testdata/transaction_state_success.json @@ -0,0 +1,50 @@ +{ + "_links": { + "self": { + "href": "http://rpc.ankr.com/transactions/dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf" + }, + "account": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP" + }, + "ledger": { + "href": "http://rpc.ankr.com/ledgers/58575576" + }, + "operations": { + "href": "http://rpc.ankr.com/transactions/dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf/operations{?cursor,limit,order}", + "templated": true + }, + "effects": { + "href": "http://rpc.ankr.com/transactions/dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf/effects{?cursor,limit,order}", + "templated": true + }, + "precedes": { + "href": "http://rpc.ankr.com/transactions?order=asc&cursor=251580183265222656" + }, + "succeeds": { + "href": "http://rpc.ankr.com/transactions?order=desc&cursor=251580183265222656" + }, + "transaction": { + "href": "http://rpc.ankr.com/transactions/dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf" + } + }, + "id": "dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf", + "paging_token": "251580183265222656", + "successful": true, + "hash": "dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf", + "ledger": 58575576, + "created_at": "2025-08-22T01:07:43Z", + "source_account": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "source_account_sequence": "235679166362550288", + "fee_account": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "fee_charged": "100", + "max_fee": "100", + "operation_count": 1, + "envelope_xdr": "AAAAAgAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAGQDRUz0AAAAEAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAANj/7cbBvxLHgDgQUuw9vs6fHf96aRl2R2Ydh8lqHuuKAAAAAAAAAAAAAYagAAAAAAAAAAET/zG1AAAAQDHsU0YLzUWObaAX2QS1JvBfdXZRFzFeC/J4j5pc6yv6XV9mLYk9bpe1EokKZrx5tY1O6mmhLkvEUYP3f5D03wA=", + "result_xdr": "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAA=", + "result_meta_xdr": "AAAAAwAAAAAAAAACAAAAAwN9ytgAAAAAAAAAABukzRaqjZQNvY6AXC6V4il1hNtkAbj0gkIZ2NMT/zG1AAAAABHgF5kDRUz0AAAADwAAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAA32RAwAAAABopnPlAAAAAAAAAAEDfcrYAAAAAAAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAAAR4BeZA0VM9AAAABAAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAN9ytgAAAAAaKfC3wAAAAAAAAABAAAABAAAAAMDfcrYAAAAAAAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAAAR4BeZA0VM9AAAABAAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAN9ytgAAAAAaKfC3wAAAAAAAAABA33K2AAAAAAAAAAAG6TNFqqNlA29joBcLpXiKXWE22QBuPSCQhnY0xP/MbUAAAAAEd6Q+QNFTPQAAAAQAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADfcrYAAAAAGinwt8AAAAAAAAAAwN9kQMAAAAAAAAAANj/7cbBvxLHgDgQUuw9vs6fHf96aRl2R2Ydh8lqHuuKAAAAAFKg19wDRVC7AAAACgAAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAA0keoAAAAABneKWOAAAAAAAAAAEDfcrYAAAAAAAAAADY/+3Gwb8Sx4A4EFLsPb7Onx3/emkZdkdmHYfJah7rigAAAABSol58A0VQuwAAAAoAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAANJHqAAAAAAZ3iljgAAAAAAAAAAAAAAAA==", + "fee_meta_xdr": "AAAAAgAAAAMDfZEDAAAAAAAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAAAR4Bf9A0VM9AAAAA8AAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAN9kQMAAAAAaKZz5QAAAAAAAAABA33K2AAAAAAAAAAAG6TNFqqNlA29joBcLpXiKXWE22QBuPSCQhnY0xP/MbUAAAAAEeAXmQNFTPQAAAAPAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADfZEDAAAAAGimc+UAAAAA", + "memo_type": "none", + "signatures": [ + "MexTRgvNRY5toBfZBLUm8F91dlEXMV4L8niPmlzrK/pdX2YtiT1ul7USiQpmvHm1jU7qaaEuS8RRg/d/kPTfAA==" + ] + } \ No newline at end of file diff --git a/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_error.json b/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_error.json new file mode 100644 index 0000000000..98c191f55a --- /dev/null +++ b/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_error.json @@ -0,0 +1,13 @@ +{ + "type": "https://stellar.org/horizon-errors/transaction_failed", + "title": "Transaction Failed", + "status": 400, + "detail": "The transaction failed when submitted to the stellar network. The `extras.result_codes` field on this response contains further details. Descriptions of each code can be found at: https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/transaction-failed/", + "extras": { + "envelope_xdr": "AAAAABukzRaqjZQNvY6AXC6V4il1hNtkAbj0gkIZ2NMT/zG1AAAAAANFTPQAAAARAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA2P/txsG/EseAOBBS7D2+zp8d/3ppGXZHZh2HyWoe64oAAAAAAAAAAAABhqAAAAAAAAAAARP/MbUAAABAexEpdvvXBE/AP0yXRSSfcWtMfQETpWoha7e/xVq7TGdblHOE5Sa38CDErUfElpvHZU0q0Oau/F2nuh7YtJx0Bw==", + "result_codes": { + "transaction": "tx_insufficient_fee" + }, + "result_xdr": "AAAAAAAAAGT////3AAAAAA==" + } + } \ No newline at end of file diff --git a/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_error_low_reserve.json b/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_error_low_reserve.json new file mode 100644 index 0000000000..110831b72a --- /dev/null +++ b/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_error_low_reserve.json @@ -0,0 +1,16 @@ +{ + "type": "https://stellar.org/horizon-errors/transaction_failed", + "title": "Transaction Failed", + "status": 400, + "detail": "The transaction failed when submitted to the stellar network. The `extras.result_codes` field on this response contains further details. Descriptions of each code can be found at: https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/transaction-failed/", + "extras": { + "envelope_xdr": "AAAAABukzRaqjZQNvY6AXC6V4il1hNtkAbj0gkIZ2NMT/zG1AAAAZANFTPQAAAASAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAqOTGWuKIeVHU8+SpU3/WGTyzDxQ/y/o6Coa+tsjM0owAAAAAAAGGoAAAAAAAAAABE/8xtQAAAEBqUg/j52jsrGseBhRjIEXLQOvhK0sJuFuAc7x7LPxgLopLA0j6mvuDEo7gcz9/VJ8qFu4ibHC/EdGDUY6mabgB", + "result_codes": { + "transaction": "tx_failed", + "operations": [ + "op_low_reserve" + ] + }, + "result_xdr": "AAAAAAAAAGT/////AAAAAQAAAAAAAAAA/////QAAAAA=" + } + } \ No newline at end of file diff --git a/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_success.json b/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_success.json new file mode 100644 index 0000000000..9c62850b90 --- /dev/null +++ b/core/crates/gem_stellar/testdata/transaction_transfer_broadcast_success.json @@ -0,0 +1,50 @@ +{ + "_links": { + "self": { + "href": "http://rpc.ankr.com/transactions/dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf" + }, + "account": { + "href": "http://rpc.ankr.com/accounts/GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP" + }, + "ledger": { + "href": "http://rpc.ankr.com/ledgers/58575576" + }, + "operations": { + "href": "http://rpc.ankr.com/transactions/dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf/operations{?cursor,limit,order}", + "templated": true + }, + "effects": { + "href": "http://rpc.ankr.com/transactions/dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf/effects{?cursor,limit,order}", + "templated": true + }, + "precedes": { + "href": "http://rpc.ankr.com/transactions?order=asc&cursor=251580183265222656" + }, + "succeeds": { + "href": "http://rpc.ankr.com/transactions?order=desc&cursor=251580183265222656" + }, + "transaction": { + "href": "http://rpc.ankr.com/transactions/dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf" + } + }, + "id": "dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf", + "paging_token": "251580183265222656", + "successful": true, + "hash": "dbc69dff72e4ca7ddf47311e12da09ac5952c777d19855f95f13b0ec624f8baf", + "ledger": 58575576, + "created_at": "2025-08-22T01:07:43Z", + "source_account": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "source_account_sequence": "235679166362550288", + "fee_account": "GAN2JTIWVKGZIDN5R2AFYLUV4IUXLBG3MQA3R5ECIIM5RUYT74Y3LDOP", + "fee_charged": "100", + "max_fee": "100", + "operation_count": 1, + "envelope_xdr": "AAAAAgAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAGQDRUz0AAAAEAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAANj/7cbBvxLHgDgQUuw9vs6fHf96aRl2R2Ydh8lqHuuKAAAAAAAAAAAAAYagAAAAAAAAAAET/zG1AAAAQDHsU0YLzUWObaAX2QS1JvBfdXZRFzFeC/J4j5pc6yv6XV9mLYk9bpe1EokKZrx5tY1O6mmhLkvEUYP3f5D03wA=", + "result_xdr": "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAA=", + "result_meta_xdr": "AAAAAwAAAAAAAAACAAAAAwN9ytgAAAAAAAAAABukzRaqjZQNvY6AXC6V4il1hNtkAbj0gkIZ2NMT/zG1AAAAABHgF5kDRUz0AAAADwAAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAA32RAwAAAABopnPlAAAAAAAAAAEDfcrYAAAAAAAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAAAR4BeZA0VM9AAAABAAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAN9ytgAAAAAaKfC3wAAAAAAAAABAAAABAAAAAMDfcrYAAAAAAAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAAAR4BeZA0VM9AAAABAAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAN9ytgAAAAAaKfC3wAAAAAAAAABA33K2AAAAAAAAAAAG6TNFqqNlA29joBcLpXiKXWE22QBuPSCQhnY0xP/MbUAAAAAEd6Q+QNFTPQAAAAQAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADfcrYAAAAAGinwt8AAAAAAAAAAwN9kQMAAAAAAAAAANj/7cbBvxLHgDgQUuw9vs6fHf96aRl2R2Ydh8lqHuuKAAAAAFKg19wDRVC7AAAACgAAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAA0keoAAAAABneKWOAAAAAAAAAAEDfcrYAAAAAAAAAADY/+3Gwb8Sx4A4EFLsPb7Onx3/emkZdkdmHYfJah7rigAAAABSol58A0VQuwAAAAoAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAANJHqAAAAAAZ3iljgAAAAAAAAAAAAAAAA==", + "fee_meta_xdr": "AAAAAgAAAAMDfZEDAAAAAAAAAAAbpM0Wqo2UDb2OgFwuleIpdYTbZAG49IJCGdjTE/8xtQAAAAAR4Bf9A0VM9AAAAA8AAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAN9kQMAAAAAaKZz5QAAAAAAAAABA33K2AAAAAAAAAAAG6TNFqqNlA29joBcLpXiKXWE22QBuPSCQhnY0xP/MbUAAAAAEeAXmQNFTPQAAAAPAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADfZEDAAAAAGimc+UAAAAA", + "memo_type": "none", + "signatures": [ + "MexTRgvNRY5toBfZBLUm8F91dlEXMV4L8niPmlzrK/pdX2YtiT1ul7USiQpmvHm1jU7qaaEuS8RRg/d/kPTfAA==" + ] + } \ No newline at end of file diff --git a/core/crates/gem_sui/Cargo.toml b/core/crates/gem_sui/Cargo.toml new file mode 100644 index 0000000000..dec87e1833 --- /dev/null +++ b/core/crates/gem_sui/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "gem_sui" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +rpc = [ + "dep:async-trait", + "dep:chain_traits", + "gem_encoding/protobuf", + "dep:gem_jsonrpc", +] +reqwest = ["rpc", "gem_jsonrpc/reqwest"] +signer = ["dep:signer"] +chain_integration_tests = ["rpc", "reqwest", "primitives/testkit", "settings/testkit"] + +[dependencies] +primitives = { path = "../primitives" } +chain_primitives = { path = "../chain_primitives" } +sui-types = { workspace = true } +sui-transaction-builder = { package = "sui-transaction-builder", version = "0.3.1", default-features = false } +bcs = { version = "0.2.1" } +hex = { workspace = true } +gem_encoding = { path = "../gem_encoding" } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["client"], optional = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } +chrono = { workspace = true } +num-traits = { workspace = true } + +futures = { workspace = true } +signer = { path = "../signer", optional = true } + +# Optional dependencies for rpc feature +num-bigint = { workspace = true, features = ["serde"] } +async-trait = { workspace = true, optional = true } +chain_traits = { path = "../chain_traits", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } +primitives = { path = "../primitives", features = ["testkit"] } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_sui/src/address.rs b/core/crates/gem_sui/src/address.rs new file mode 100644 index 0000000000..acbb2288ad --- /dev/null +++ b/core/crates/gem_sui/src/address.rs @@ -0,0 +1,57 @@ +use primitives::Address as AddressTrait; +use std::str::FromStr; +use sui_types::Address; + +use crate::SuiError; + +pub struct SuiAddress(Address); + +impl From for Address { + fn from(value: SuiAddress) -> Self { + value.0 + } +} + +impl SuiAddress { + pub fn parse(address: &str) -> Result { + Address::from_str(address) + .map(Self) + .map_err(|err| SuiError::invalid_input(format!("Invalid Sui address {address}: {err}"))) + } +} + +impl AddressTrait for SuiAddress { + fn try_parse(address: &str) -> Option { + Address::from_str(address).ok().map(Self) + } + + fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + fn encode(&self) -> String { + self.0.to_string() + } +} + +pub fn validate_address(address: &str) -> bool { + SuiAddress::is_valid(address) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sui_address() { + let address = "0xada112cfb90b44ba889cc5d39ac2bf46281e4a91f7919c693bcd9b8323e81ed2"; + let parsed = SuiAddress::try_parse(address).unwrap(); + + assert!(validate_address(address)); + assert_eq!(parsed.as_bytes().len(), 32); + assert_eq!(parsed.encode(), address); + assert_eq!(SuiAddress::parse(address).unwrap().encode(), address); + assert!(SuiAddress::parse("invalid").is_err()); + assert!(!validate_address("invalid")); + } +} diff --git a/core/crates/gem_sui/src/coin_type.rs b/core/crates/gem_sui/src/coin_type.rs new file mode 100644 index 0000000000..89c58e0023 --- /dev/null +++ b/core/crates/gem_sui/src/coin_type.rs @@ -0,0 +1,68 @@ +use crate::{SUI_COIN_TYPE, SUI_COIN_TYPE_FULL}; +use primitives::hex::decode_hex; + +const SUI_ADDRESS_LENGTH: usize = 32; + +pub fn full_coin_type(coin_type: &str) -> String { + let Some((prefix, rest)) = coin_type.split_once("::") else { + return coin_type.to_string(); + }; + match decode_hex(prefix) { + Ok(bytes) if bytes.len() <= SUI_ADDRESS_LENGTH => { + let mut padded = [0u8; SUI_ADDRESS_LENGTH]; + padded[SUI_ADDRESS_LENGTH - bytes.len()..].copy_from_slice(&bytes); + format!("0x{}::{rest}", hex::encode(padded)) + } + _ => coin_type.to_string(), + } +} + +pub fn coin_type_matches(a: &str, b: &str) -> bool { + full_coin_type(a) == full_coin_type(b) +} + +pub fn is_sui_coin(coin_type: &str) -> bool { + coin_type == SUI_COIN_TYPE || coin_type == SUI_COIN_TYPE_FULL +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_full_coin_type() { + assert_eq!( + full_coin_type("0x2::sui::SUI"), + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + ); + assert_eq!( + full_coin_type("2::sui::SUI"), + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + ); + assert_eq!( + full_coin_type("0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI"), + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + ); + assert_eq!(full_coin_type("0xabc"), "0xabc"); + assert_eq!(full_coin_type("not-a-type::coin::COIN"), "not-a-type::coin::COIN"); + } + + #[test] + fn test_coin_type_matches() { + assert!(coin_type_matches("0x2::sui::SUI", "0x2::sui::SUI")); + assert!(coin_type_matches("0x2::sui::SUI", "2::sui::SUI")); + assert!(coin_type_matches("2::sui::SUI", "0x2::sui::SUI")); + assert!(coin_type_matches( + "0x2::sui::SUI", + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + )); + assert!(!coin_type_matches("0x2::sui::SUI", "0x3::token::TOKEN")); + } + + #[test] + fn test_is_sui_coin() { + assert!(is_sui_coin(SUI_COIN_TYPE)); + assert!(is_sui_coin(SUI_COIN_TYPE_FULL)); + assert!(!is_sui_coin("0x3::token::TOKEN")); + } +} diff --git a/core/crates/gem_sui/src/error.rs b/core/crates/gem_sui/src/error.rs new file mode 100644 index 0000000000..6981974cc4 --- /dev/null +++ b/core/crates/gem_sui/src/error.rs @@ -0,0 +1,33 @@ +use std::{ + error::Error, + fmt::{Display, Formatter}, +}; + +#[derive(Debug)] +pub enum SuiError { + InvalidInput(String), + InsufficientBalance { coin_type: String }, + NoGasCoins, +} + +impl Display for SuiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidInput(message) => write!(f, "{message}"), + Self::InsufficientBalance { coin_type } => write!(f, "insufficient {coin_type} balance"), + Self::NoGasCoins => write!(f, "No SUI coins available for gas"), + } + } +} + +impl Error for SuiError {} + +impl SuiError { + pub fn invalid_input(message: impl Into) -> Self { + Self::InvalidInput(message.into()) + } + + pub fn from_display(error: impl Display) -> Self { + Self::invalid_input(error.to_string()) + } +} diff --git a/core/crates/gem_sui/src/gas_budget.rs b/core/crates/gem_sui/src/gas_budget.rs new file mode 100644 index 0000000000..d7436d78d0 --- /dev/null +++ b/core/crates/gem_sui/src/gas_budget.rs @@ -0,0 +1,19 @@ +use std::cmp::max; + +pub const GAS_BUDGET_MULTIPLIER: u64 = 120; + +pub fn calculate_gas_budget(computation_cost: u64, storage_cost: u64, storage_rebate: u64) -> u64 { + max(computation_cost, computation_cost.saturating_add(storage_cost).saturating_sub(storage_rebate)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gas_budget() { + let fee = calculate_gas_budget(611_000, 2_424_400, 2_400_156); + assert_eq!(fee, 635_244); + assert_eq!(fee * GAS_BUDGET_MULTIPLIER / 100, 762_292); + } +} diff --git a/core/crates/gem_sui/src/lib.rs b/core/crates/gem_sui/src/lib.rs new file mode 100644 index 0000000000..84e58e0e66 --- /dev/null +++ b/core/crates/gem_sui/src/lib.rs @@ -0,0 +1,83 @@ +pub mod address; +pub use address::validate_address; +pub mod coin_type; +pub use coin_type::{coin_type_matches, full_coin_type, is_sui_coin}; +#[cfg(feature = "rpc")] +pub mod rpc; +#[cfg(feature = "rpc")] +pub use rpc::SuiClient; + +#[cfg(feature = "rpc")] +pub mod provider; + +pub mod models; + +#[cfg(feature = "rpc")] +pub mod transfer_builder; +#[cfg(feature = "rpc")] +pub use transfer_builder::*; + +pub mod error; +pub mod gas_budget; +pub mod tx_builder; + +#[cfg(feature = "signer")] +pub mod signer; + +pub use error::SuiError; +pub use models::ObjectId; +use models::{Coin, OwnedCoins}; +use std::error::Error; +use sui_transaction_builder::ObjectInput; +use sui_types::Address; +pub use tx_builder::{decode_transaction, stake::*, transfer::*, validate_and_hash}; + +pub const SUI_SYSTEM_ID: &str = "sui_system"; + +pub const SUI_FRAMEWORK_PACKAGE_ID: u8 = 0x2; +pub const SUI_SYSTEM_PACKAGE_ID: u8 = 0x3; +pub const SUI_SYSTEM_STATE_OBJECT_ID: u8 = 0x5; +pub const SUI_CLOCK_OBJECT_ID: u8 = 0x6; + +pub const SUI_COIN_TYPE: &str = "0x2::sui::SUI"; +pub const SUI_COIN_TYPE_FULL: &str = "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI"; +pub const EMPTY_ADDRESS: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; +pub const STORAGE_FEE_UNIT: u64 = 76; // https://blog.sui.io/storage-fees-explained +pub const ESTIMATION_GAS_BUDGET: u64 = 50_000_000; +pub const SUI_STAKE_EVENT: &str = "0x3::validator::StakingRequestEvent"; +pub const SUI_UNSTAKE_EVENT: &str = "0x3::validator::UnstakingRequestEvent"; + +pub fn sui_framework_package_address() -> Address { + ObjectId::from(SUI_FRAMEWORK_PACKAGE_ID).into() +} + +pub fn sui_system_package_address() -> Address { + ObjectId::from(SUI_SYSTEM_PACKAGE_ID).into() +} + +pub fn sui_system_state_object_id() -> Address { + ObjectId::from(SUI_SYSTEM_STATE_OBJECT_ID).into() +} + +pub fn sui_clock_object_id() -> Address { + ObjectId::from(SUI_CLOCK_OBJECT_ID).into() +} + +pub fn sui_system_state_object_input() -> ObjectInput { + ObjectInput::shared(sui_system_state_object_id(), 1, true) +} + +pub fn sui_clock_object_input() -> ObjectInput { + ObjectInput::shared(sui_clock_object_id(), 1, false) +} + +pub fn validate_enough_balance(coins: &OwnedCoins, amount: u64) -> Option> { + let total = coins.total(); + if total == 0 { + return Some("no spendable coin objects or address balance".into()); + } + if total < amount { + return Some(format!("total amount ({}) is less than amount to send ({})", total, amount).into()); + } + None +} diff --git a/core/crates/gem_sui/src/models/account.rs b/core/crates/gem_sui/src/models/account.rs new file mode 100644 index 0000000000..b544c9972f --- /dev/null +++ b/core/crates/gem_sui/src/models/account.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Owner { + String(String), + OwnerObject(OwnerObject), +} + +impl Owner { + pub fn get_address_owner(&self) -> Option { + match self { + Owner::String(_) => None, + Owner::OwnerObject(obj) => obj.address_owner.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnerObject { + #[serde(rename = "AddressOwner")] + pub address_owner: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GasObject { + pub owner: Owner, +} diff --git a/core/crates/gem_sui/src/models/coin.rs b/core/crates/gem_sui/src/models/coin.rs new file mode 100644 index 0000000000..2f0e6b7d49 --- /dev/null +++ b/core/crates/gem_sui/src/models/coin.rs @@ -0,0 +1,101 @@ +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "rpc")] +use num_bigint::BigInt; +#[cfg(feature = "rpc")] +use serde_serializers::deserialize_bigint_from_str; + +#[cfg(feature = "rpc")] +use super::account::Owner; +use super::core::Coin; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiCoinMetadata { + pub decimals: i32, + pub name: String, + pub symbol: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiObject { + pub object_id: String, + pub digest: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedCoins { + pub coin_type: String, + pub coins: Vec, + pub address_balance: u64, +} + +impl Default for OwnedCoins { + fn default() -> Self { + Self { + coin_type: String::new(), + coins: Vec::new(), + address_balance: 0, + } + } +} + +impl OwnedCoins { + pub fn new(coin_type: String, coins: Vec, address_balance: u64) -> Self { + Self { + coin_type, + coins, + address_balance, + } + } + + pub fn map(self, f: impl FnMut(T) -> U) -> OwnedCoins { + OwnedCoins { + coin_type: self.coin_type, + coins: self.coins.into_iter().map(f).collect(), + address_balance: self.address_balance, + } + } + + pub fn try_map(self, f: impl FnMut(T) -> Result) -> Result, E> { + Ok(OwnedCoins { + coin_type: self.coin_type, + coins: self.coins.into_iter().map(f).collect::>()?, + address_balance: self.address_balance, + }) + } +} + +impl OwnedCoins { + pub fn coin_total(&self) -> u64 { + self.coins.iter().map(|coin| coin.balance).fold(0, u64::saturating_add) + } + + pub fn total(&self) -> u64 { + self.coin_total().saturating_add(self.address_balance) + } +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Balance { + pub coin_type: String, + #[serde(deserialize_with = "deserialize_bigint_from_str")] + pub total_balance: BigInt, + #[serde(default)] + /// Amount in the per-address balance accumulator. + pub address_balance: u64, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BalanceChange { + pub owner: Owner, + #[serde(rename = "coinType")] + pub coin_type: String, + #[serde(deserialize_with = "deserialize_bigint_from_str")] + pub amount: BigInt, +} diff --git a/core/crates/gem_sui/src/models/core.rs b/core/crates/gem_sui/src/models/core.rs new file mode 100644 index 0000000000..c1f2cfe76d --- /dev/null +++ b/core/crates/gem_sui/src/models/core.rs @@ -0,0 +1,93 @@ +use super::OwnedCoins; +use bcs; +use gem_encoding::encode_base64; +use std::error::Error; +use sui_transaction_builder::ObjectInput; +use sui_types::{Address, Digest, Transaction}; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Coin { + pub coin_type: String, + pub balance: u64, + pub object: Object, +} + +impl Coin { + pub fn to_input(&self) -> ObjectInput { + self.object.to_input() + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct Object { + pub object_id: Address, + pub digest: Digest, + pub version: u64, +} + +impl Object { + pub fn to_input(&self) -> ObjectInput { + ObjectInput::owned(self.object_id, self.version, self.digest) + } +} + +#[derive(Debug, PartialEq, Clone)] +pub struct Gas { + pub budget: u64, + pub price: u64, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct StakeInput { + pub sender: String, + pub validator: String, + pub stake_amount: u64, + pub gas: Gas, + pub coins: OwnedCoins, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct UnstakeInput { + pub sender: String, + pub staked_sui: Object, + pub gas: Gas, + pub gas_coin: Coin, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct TransferInput { + pub sender: String, + pub recipient: String, + pub amount: u64, + pub coins: OwnedCoins, + pub send_max: bool, + pub gas: Gas, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct TokenTransferInput { + pub sender: String, + pub recipient: String, + pub amount: u64, + pub tokens: OwnedCoins, + pub gas: Gas, + pub gas_coin: Coin, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct TxOutput { + pub tx_data: Vec, + pub hash: Vec, +} + +impl TxOutput { + pub fn from_tx(tx_data: &Transaction) -> Result> { + let digest = tx_data.signing_digest(); + let tx_data = bcs::to_bytes(tx_data)?; + Ok(Self { tx_data, hash: digest.to_vec() }) + } + + pub fn base64_encoded(&self) -> String { + encode_base64(&self.tx_data) + } +} diff --git a/core/crates/gem_sui/src/models/inspect.rs b/core/crates/gem_sui/src/models/inspect.rs new file mode 100644 index 0000000000..e1ee7c5c0a --- /dev/null +++ b/core/crates/gem_sui/src/models/inspect.rs @@ -0,0 +1,47 @@ +use serde::Deserialize; +use serde_serializers::deserialize_u64_from_str; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectResult { + pub effects: InspectEffects, + pub events: serde_json::Value, + pub error: Option, + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectCommandResult { + #[serde(default)] + pub return_values: Vec, +} + +pub type InspectReturnValue = (Vec, String); + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectEvent { + pub package_id: String, + pub transaction_module: String, + pub parsed_json: T, + pub r#type: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectEffects { + pub gas_used: InspectGasUsed, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectGasUsed { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub computation_cost: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub storage_cost: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub storage_rebate: u64, +} diff --git a/core/crates/gem_sui/src/models/mod.rs b/core/crates/gem_sui/src/models/mod.rs new file mode 100644 index 0000000000..a07985d89c --- /dev/null +++ b/core/crates/gem_sui/src/models/mod.rs @@ -0,0 +1,26 @@ +pub mod account; +pub mod coin; +pub mod core; +pub mod inspect; +pub mod object_id; +pub mod staking; +#[cfg(test)] +pub mod testkit; +pub mod transaction; + +pub use coin::*; +pub use core::*; +pub use inspect::{InspectCommandResult, InspectEffects, InspectEvent, InspectGasUsed, InspectResult, InspectReturnValue}; +pub use object_id::ObjectId; +pub use staking::*; +pub use transaction::*; + +// RPC models with explicit imports to avoid conflicts +#[cfg(feature = "rpc")] +pub use account::{GasObject, Owner, OwnerObject}; +#[cfg(feature = "rpc")] +pub use coin::{Balance, BalanceChange}; +#[cfg(feature = "rpc")] +pub use staking::{EventStake, EventUnstake}; +#[cfg(feature = "rpc")] +pub use transaction::{Digest, Effect, Event, GasUsed, Status, TransactionBroadcast}; diff --git a/core/crates/gem_sui/src/models/object_id.rs b/core/crates/gem_sui/src/models/object_id.rs new file mode 100644 index 0000000000..359edd862d --- /dev/null +++ b/core/crates/gem_sui/src/models/object_id.rs @@ -0,0 +1,17 @@ +use sui_types::Address; + +pub struct ObjectId(pub Address); + +impl From for ObjectId { + fn from(byte: u8) -> Self { + let mut bytes = [0u8; 32]; + bytes[31] = byte; + Self(Address::new(bytes)) + } +} + +impl From for Address { + fn from(val: ObjectId) -> Self { + val.0 + } +} diff --git a/core/crates/gem_sui/src/models/staking.rs b/core/crates/gem_sui/src/models/staking.rs new file mode 100644 index 0000000000..22bd0149c9 --- /dev/null +++ b/core/crates/gem_sui/src/models/staking.rs @@ -0,0 +1,73 @@ +#[cfg(feature = "rpc")] +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "rpc")] +use serde_serializers::{deserialize_biguint_from_str, deserialize_option_biguint_from_str}; + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiStakeDelegation { + pub validator_address: String, + pub staking_pool: String, + pub stakes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiSystemState { + pub epoch: String, + pub epoch_start_timestamp_ms: String, + pub epoch_duration_ms: String, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum SuiStakeStatus { + Active, + Pending, + Unstaked, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiStake { + pub staked_sui_id: String, + pub status: SuiStakeStatus, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub principal: BigUint, + pub stake_request_epoch: String, + pub stake_active_epoch: String, + #[serde(default, deserialize_with = "deserialize_option_biguint_from_str")] + pub estimated_reward: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiValidators { + pub apys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiValidator { + pub address: String, + pub apy: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventStake { + pub amount: String, + pub staker_address: String, + pub validator_address: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventUnstake { + pub principal_amount: String, + pub reward_amount: String, + pub staker_address: String, + pub validator_address: String, +} diff --git a/core/crates/gem_sui/src/models/testkit.rs b/core/crates/gem_sui/src/models/testkit.rs new file mode 100644 index 0000000000..c4f99eb319 --- /dev/null +++ b/core/crates/gem_sui/src/models/testkit.rs @@ -0,0 +1,28 @@ +use crate::SUI_COIN_TYPE; +use crate::models::{Coin, Object, OwnedCoins}; + +impl Object { + pub fn mock() -> Self { + Self { + object_id: "0xabcdef1234567890abcdef1234567890abcdef12".parse().unwrap(), + digest: "HdfF7hswRuvbXbEXjGjmUCt7gLybhvbPvvK8zZbCqyD8".parse().unwrap(), + version: 100, + } + } +} + +impl Coin { + pub fn mock_sui() -> Self { + Self { + coin_type: SUI_COIN_TYPE.to_string(), + balance: 5_000_000_000, + object: Object::mock(), + } + } +} + +impl OwnedCoins { + pub fn mock_sui() -> Self { + Self::new(SUI_COIN_TYPE.to_string(), vec![Coin::mock_sui()], 0) + } +} diff --git a/core/crates/gem_sui/src/models/transaction.rs b/core/crates/gem_sui/src/models/transaction.rs new file mode 100644 index 0000000000..cea3d17a36 --- /dev/null +++ b/core/crates/gem_sui/src/models/transaction.rs @@ -0,0 +1,123 @@ +use num_bigint::BigUint; +use num_traits::ToPrimitive; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_serializers::deserialize_biguint_from_str; + +use crate::gas_budget::calculate_gas_budget; + +#[cfg(feature = "rpc")] +use serde_serializers::deserialize_u64_from_str; + +#[cfg(feature = "rpc")] +use super::account::GasObject; +#[cfg(feature = "rpc")] +use super::coin::BalanceChange; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiTransaction { + pub effects: SuiEffects, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiStatus { + pub status: String, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiEffects { + pub gas_used: GasUsed, + pub status: SuiStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GasUsed { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub computation_cost: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub storage_cost: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub storage_rebate: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub non_refundable_storage_fee: BigUint, +} + +impl GasUsed { + pub fn calculate_gas_budget(&self) -> Result> { + match (self.computation_cost.to_u64(), self.storage_cost.to_u64(), self.storage_rebate.to_u64()) { + (Some(computation), Some(storage), Some(rebate)) => Ok(calculate_gas_budget(computation, storage, rebate)), + _ => Err("gas cost overflow".into()), + } + } +} + +pub use TransactionBroadcast as SuiBroadcastTransaction; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionBroadcast { + pub digest: String, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionBlocks { + pub data: Vec, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Checkpoint { + pub epoch: String, + pub sequence_number: String, + pub digest: String, + pub network_total_transactions: String, + pub previous_digest: String, + pub timestamp_ms: String, + pub transactions: Vec, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Digest { + pub digest: String, + pub effects: Effect, + #[serde(default)] + pub move_call_packages: Vec, + #[serde(rename = "balanceChanges")] + pub balance_changes: Option>, + pub events: Vec, + #[serde(rename = "timestampMs")] + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub timestamp_ms: u64, +} + +#[cfg(feature = "rpc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Effect { + #[serde(rename = "gasUsed")] + pub gas_used: GasUsed, + pub status: Status, + #[serde(rename = "gasObject")] + pub gas_object: GasObject, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Status { + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + #[serde(rename = "type")] + pub event_type: String, + #[serde(rename = "parsedJson")] + pub parsed_json: Option, + #[serde(rename = "packageId")] + pub package_id: String, +} diff --git a/core/crates/gem_sui/src/provider/accounts.rs b/core/crates/gem_sui/src/provider/accounts.rs new file mode 100644 index 0000000000..357e6d78a1 --- /dev/null +++ b/core/crates/gem_sui/src/provider/accounts.rs @@ -0,0 +1,24 @@ +#[cfg(feature = "rpc")] +use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual, ChainProvider, ChainTraits}; +use primitives::Chain; + +use crate::rpc::client::SuiClient; + +#[cfg(feature = "rpc")] +impl ChainTraits for SuiClient {} + +#[cfg(feature = "rpc")] +impl ChainProvider for SuiClient { + fn get_chain(&self) -> Chain { + Chain::Sui + } +} + +#[cfg(feature = "rpc")] +impl ChainAccount for SuiClient {} + +#[cfg(feature = "rpc")] +impl ChainPerpetual for SuiClient {} + +#[cfg(feature = "rpc")] +impl ChainAddressStatus for SuiClient {} diff --git a/core/crates/gem_sui/src/provider/balances.rs b/core/crates/gem_sui/src/provider/balances.rs new file mode 100644 index 0000000000..c1316bd9e8 --- /dev/null +++ b/core/crates/gem_sui/src/provider/balances.rs @@ -0,0 +1,90 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainBalances; +use primitives::AssetBalance; + +use crate::provider::balances_mapper::{map_assets_balances, map_balance_coin, map_balance_staking, map_balance_tokens}; +use crate::rpc::client::SuiClient; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainBalances for SuiClient { + async fn get_balance_coin(&self, address: String) -> Result> { + Ok(map_balance_coin(self.get_balance(address).await?)) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + Ok(map_balance_tokens(self.get_all_balances(address).await?, token_ids)) + } + + async fn get_balance_staking(&self, address: String) -> Result, Box> { + Ok(Some(map_balance_staking(self.get_stake_delegations(address).await?))) + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + Ok(map_assets_balances(self.get_all_balances(address).await?)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::*; + use primitives::Chain; + + #[tokio::test] + async fn test_sui_get_balance_coin() -> Result<(), Box> { + let client = create_sui_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + assert_eq!(balance.asset_id.chain, Chain::Sui); + println!("Balance: {:?}", balance); + Ok(()) + } + + #[tokio::test] + async fn test_sui_get_balance_tokens() -> Result<(), Box> { + let client = create_sui_test_client(); + let token_ids = vec![ + "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN".to_string(), // USDC + ]; + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids).await?; + + for balance in &balances { + assert_eq!(balance.asset_id.chain, Chain::Sui); + println!("Token balance: {:?}", balance); + } + Ok(()) + } + + #[tokio::test] + async fn test_sui_get_balance_staking() -> Result<(), Box> { + let client = create_sui_test_client(); + + // First check raw RPC response to see if there are any delegations + let delegations = client.get_stake_delegations(TEST_ADDRESS.to_string()).await?; + println!("Found {} delegations for address {}", delegations.len(), TEST_ADDRESS); + + let balance = client.get_balance_staking(TEST_ADDRESS.to_string()).await?; + + let staking_balance = balance.expect("Test address should have staking balance"); + assert_eq!(staking_balance.asset_id.chain, Chain::Sui); + + assert!(staking_balance.balance.staked > num_bigint::BigUint::from(0u32), "Staked amount should be greater than 0"); + + println!("Staking balance: {} SUI", staking_balance.balance.staked); + Ok(()) + } + + #[tokio::test] + async fn test_sui_get_balance_staking_empty_address() -> Result<(), Box> { + let client = create_sui_test_client(); + let balance = client.get_balance_staking(TEST_ADDRESS_EMPTY.to_string()).await?; + + assert!(balance.unwrap().balance.staked == num_bigint::BigUint::from(0u32)); + + Ok(()) + } +} diff --git a/core/crates/gem_sui/src/provider/balances_mapper.rs b/core/crates/gem_sui/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..b928f05244 --- /dev/null +++ b/core/crates/gem_sui/src/provider/balances_mapper.rs @@ -0,0 +1,137 @@ +use crate::models::Balance as SuiBalance; +use crate::models::staking::SuiStakeDelegation; +use crate::{coin_type_matches, is_sui_coin}; +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Balance, Chain}; + +pub fn map_balance_coin(balance: SuiBalance) -> AssetBalance { + AssetBalance::new_balance( + Chain::Sui.as_asset_id(), + Balance::coin_balance(BigUint::try_from(balance.total_balance).unwrap_or_default()), + ) +} + +pub fn map_balance_tokens(balances: Vec, token_ids: Vec) -> Vec { + token_ids + .into_iter() + .map(|token_id| { + let balance = balances + .iter() + .find(|b| coin_type_matches(&b.coin_type, &token_id)) + .map(|b| &b.total_balance) + .cloned() + .unwrap_or_default(); + + AssetBalance::new_balance( + AssetId::from_token(Chain::Sui, &token_id), + Balance::coin_balance(BigUint::try_from(balance).unwrap_or_default()), + ) + }) + .collect() +} + +pub fn map_balance_staking(delegations: Vec) -> AssetBalance { + let staked = delegations + .iter() + .flat_map(|delegation| &delegation.stakes) + .map(|stake| &stake.principal + stake.estimated_reward.as_ref().unwrap_or(&BigUint::from(0u32))) + .sum::(); + + AssetBalance::new_balance(Chain::Sui.as_asset_id(), Balance::stake_balance(staked, BigUint::from(0u32), None)) +} + +fn map_token_asset_balance(balance: SuiBalance) -> Option { + if is_sui_coin(&balance.coin_type) { + return None; + } + + Some(AssetBalance::new_balance( + AssetId::from_token(Chain::Sui, &balance.coin_type), + Balance::coin_balance(BigUint::try_from(balance.total_balance).unwrap_or_default()), + )) +} + +pub fn map_assets_balances(balances: Vec) -> Vec { + balances.into_iter().filter_map(map_token_asset_balance).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::asset_constants::SUI_USDC_TOKEN_ID; + + #[test] + fn test_map_coin_balance() { + let balance: SuiBalance = serde_json::from_str(include_str!("../../testdata/balance_coin.json")).unwrap(); + + let result = map_balance_coin(balance); + assert_eq!(result.balance.available, BigUint::from(52855428706_u64)); + assert_eq!(result.asset_id.chain, Chain::Sui); + } + + #[test] + fn test_map_token_balances() { + let balances: Vec = serde_json::from_str(include_str!("../../testdata/balance_tokens.json")).unwrap(); + + let token_ids = vec![ + SUI_USDC_TOKEN_ID.to_string(), + "0xda1644f58a955833a15abae24f8cc65b5bd8152ce013fde8be0a6a3dcf51fe36::token::TOKEN".to_string(), + ]; + + let result = map_balance_tokens(balances, token_ids); + assert_eq!(result.len(), 2); + assert_eq!(result[0].balance.available, BigUint::from(3685298_u64)); // USDC balance + assert_eq!(result[1].balance.available, BigUint::from(1000_u64)); // TOKEN balance + } + + #[test] + fn test_coin_type_matches() { + assert!(coin_type_matches("0x2::sui::SUI", "0x2::sui::SUI")); + assert!(coin_type_matches("0x2::sui::SUI", "2::sui::SUI")); + assert!(coin_type_matches("2::sui::SUI", "0x2::sui::SUI")); + assert!(!coin_type_matches("0x2::sui::SUI", "0x3::token::TOKEN")); + } + + #[test] + fn test_map_balance_staking() { + let delegations: Vec = serde_json::from_str(include_str!("../../testdata/stakes.json")).unwrap(); + + let balance = map_balance_staking(delegations); + + assert_eq!(balance.asset_id.chain, Chain::Sui); + + assert_eq!(balance.balance.staked, BigUint::from(9113484503_u64)); + assert_eq!(balance.balance.available, BigUint::from(0u32)); + } + + #[test] + fn test_map_balance_staking_empty() { + let delegations: Vec = vec![]; + let balance = map_balance_staking(delegations); + + assert_eq!(balance.asset_id.chain, Chain::Sui); + assert_eq!(balance.balance.staked, BigUint::from(0u32)); + assert_eq!(balance.balance.available, BigUint::from(0u32)); + } + + #[test] + fn test_map_assets_balances() { + let balances: Vec = serde_json::from_str(include_str!("../../testdata/balance_tokens.json")).unwrap(); + + let result = map_assets_balances(balances); + + assert_eq!(result.len(), 7); + assert_eq!( + result[0].asset_id, + AssetId::from_token(Chain::Sui, "0xce7ff77a83ea0cb6fd39bd8748e2ec89a3f41e8efdc3f4eb123e0ca37b184db2::buck::BUCK") + ); + assert_eq!(result[1].asset_id, AssetId::from_token(Chain::Sui, SUI_USDC_TOKEN_ID)); + assert_eq!(result[1].balance.available, BigUint::from(3685298_u64)); + assert_eq!( + result[3].asset_id, + AssetId::from_token(Chain::Sui, "0xda1644f58a955833a15abae24f8cc65b5bd8152ce013fde8be0a6a3dcf51fe36::token::TOKEN") + ); + assert_eq!(result[3].balance.available, BigUint::from(1000_u64)); + assert_eq!(result.iter().filter(|balance| balance.asset_id == Chain::Sui.as_asset_id()).count(), 0); + } +} diff --git a/core/crates/gem_sui/src/provider/mod.rs b/core/crates/gem_sui/src/provider/mod.rs new file mode 100644 index 0000000000..35cd85a93e --- /dev/null +++ b/core/crates/gem_sui/src/provider/mod.rs @@ -0,0 +1,21 @@ +pub mod accounts; +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod staking; +pub mod staking_mapper; +pub mod state; +pub mod state_mapper; +pub mod testkit; +pub mod token; +pub mod token_mapper; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; diff --git a/core/crates/gem_sui/src/provider/preload.rs b/core/crates/gem_sui/src/provider/preload.rs new file mode 100644 index 0000000000..56ed0feab9 --- /dev/null +++ b/core/crates/gem_sui/src/provider/preload.rs @@ -0,0 +1,196 @@ +use std::{collections::HashMap, error::Error}; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainTransactionLoad; +use num_bigint::BigInt; +use primitives::{ + FeeRate, GasPriceType, StakeType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput, +}; + +use crate::{ + ESTIMATION_GAS_BUDGET, SUI_COIN_TYPE, + gas_budget::GAS_BUDGET_MULTIPLIER, + models::{Coin, OwnedCoins, SuiObject}, +}; +use crate::{ + provider::preload_mapper::{map_transaction_data, map_transaction_rate_rates}, + rpc::client::SuiClient, +}; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainTransactionLoad for SuiClient { + async fn get_transaction_preload(&self, _input: TransactionPreloadInput) -> Result> { + Ok(TransactionLoadMetadata::None) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let (sui_coins, token_coins, objects) = self.get_coins_for_input_type(input.sender_address.as_str(), input.input_type.clone()).await?; + + let estimate_bytes = map_transaction_data(input.clone(), sui_coins.clone(), token_coins.clone(), objects.clone(), ESTIMATION_GAS_BUDGET)?; + let fee = self.estimate_fee(&estimate_bytes, &input.gas_price, input.is_max_value).await?; + + let message_bytes = match estimated_gas_budget(&input.input_type, &fee)? { + Some(budget) => map_transaction_data(input, sui_coins, token_coins, objects, budget)?, + None => estimate_bytes, + }; + + Ok(TransactionLoadData { + fee, + metadata: TransactionLoadMetadata::Sui { message_bytes }, + }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + let gas_price = self.get_gas_price().await?; + Ok(map_transaction_rate_rates(gas_price)) + } +} + +fn estimated_gas_budget(input_type: &TransactionInputType, fee: &TransactionFee) -> Result, Box> { + match input_type { + TransactionInputType::Swap(..) | TransactionInputType::Generic(..) => Ok(None), + _ => Ok(Some(fee.gas_limit()?)), + } +} + +impl SuiClient { + async fn estimate_fee(&self, tx_data: &str, gas_price: &GasPriceType, is_max_value: bool) -> Result> { + let tx_data_only = tx_data.split('_').next().unwrap_or(tx_data); + let result = self.dry_run(tx_data_only.to_string()).await?; + let fee = result.effects.gas_used.calculate_gas_budget()?; + let gas_limit = if is_max_value { fee } else { fee * GAS_BUDGET_MULTIPLIER / 100 }; + + Ok(TransactionFee { + fee: BigInt::from(fee), + gas_price_type: gas_price.clone(), + gas_limit: BigInt::from(gas_limit), + options: HashMap::new(), + }) + } + + async fn get_coins_for_input_type( + &self, + address: &str, + input_type: TransactionInputType, + ) -> Result<(OwnedCoins, Option>, Vec), Box> { + match input_type { + TransactionInputType::Transfer(asset) => match asset.id.token_id { + None => Ok((self.get_coins(address, SUI_COIN_TYPE).await?, None, Vec::new())), + Some(token_id) => { + let (gas_coins, token_coins) = futures::try_join!(self.get_coins(address, SUI_COIN_TYPE), self.get_coins(address, &token_id))?; + Ok((gas_coins, Some(token_coins), Vec::new())) + } + }, + TransactionInputType::Stake(_, stake_type) => match stake_type { + StakeType::Stake(_) => Ok((self.get_coins(address, SUI_COIN_TYPE).await?, None, Vec::new())), + StakeType::Unstake(delegation) => { + let (gas_coins, staked_object) = futures::try_join!(self.get_coins(address, SUI_COIN_TYPE), self.get_object(delegation.base.delegation_id.clone()))?; + Ok((gas_coins, None, vec![staked_object])) + } + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => { + Err("Unsupported stake type for Sui".into()) + } + }, + TransactionInputType::Swap(_, _, _) => Ok((OwnedCoins::default(), None, Vec::new())), + TransactionInputType::Generic(_, _, _) => Ok((OwnedCoins::default(), None, Vec::new())), + TransactionInputType::TransferNft(_, _) | TransactionInputType::Account(_, _) => Err("Unsupported transaction type for Sui".into()), + _ => Err("Unsupported transaction type for Sui".into()), + } + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::models::SuiStakeStatus; + use crate::provider::testkit::*; + use chain_traits::ChainTransactionLoad; + use gem_encoding::decode_base64; + use primitives::{Asset, Chain, Delegation, FeePriority, StakeType, TransactionLoadInput}; + + #[tokio::test] + async fn test_sui_get_transaction_fee_rates() -> Result<(), Box> { + let client = create_sui_test_client(); + let rates = client.get_transaction_fee_rates(TransactionInputType::Transfer(Asset::from_chain(Chain::Sui))).await?; + + println!("Sui transaction fee rates: {:?}", rates); + + assert_eq!(rates.len(), 3); + assert_eq!(rates[0].priority, FeePriority::Slow); + assert_eq!(rates[1].priority, FeePriority::Normal); + assert_eq!(rates[2].priority, FeePriority::Fast); + + Ok(()) + } + + #[tokio::test] + async fn test_sui_get_transaction_preload() -> Result<(), Box> { + let client = create_sui_test_client(); + let input = TransactionPreloadInput { + sender_address: TEST_ADDRESS.to_string(), + destination_address: TEST_ADDRESS.to_string(), + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Sui)), + }; + + let _metadata = client.get_transaction_preload(input).await?; + + Ok(()) + } + + #[tokio::test] + async fn test_sui_get_transaction_preload_unstake() -> Result<(), Box> { + let client = create_sui_test_client(); + + let user_address = "0x93f65b8c16c263343bbf66cf9f8eef69cb1dbc92d13f0c331b0dcaeb76b4aab6"; + let delegation_id = client + .get_stake_delegations(user_address.to_string()) + .await? + .into_iter() + .flat_map(|delegation| delegation.stakes.into_iter()) + .find(|stake| stake.status == SuiStakeStatus::Active) + .ok_or("No active Sui stake found for test address")? + .staked_sui_id; + + let delegation = Delegation::mock_with_id(delegation_id); + let stake_type = StakeType::Unstake(delegation); + + let input = TransactionLoadInput { + sender_address: user_address.to_string(), + destination_address: user_address.to_string(), + value: "1000000000".to_string(), + input_type: TransactionInputType::Stake(Asset::from_chain(Chain::Sui), stake_type), + gas_price: primitives::GasPriceType::regular(BigInt::from(1000)), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::None, + }; + + let result = client.get_transaction_load(input).await?; + + match result.metadata { + TransactionLoadMetadata::Sui { message_bytes } => { + assert!(!message_bytes.is_empty()); + println!("Sui unstake transaction data: {} chars", message_bytes.len()); + + assert!(message_bytes.contains('_')); + let parts: Vec<&str> = message_bytes.split('_').collect(); + assert_eq!(parts.len(), 2); + + assert!(decode_base64(parts[0]).is_ok()); + assert!(hex::decode(parts[1]).is_ok()); + } + _ => panic!("Expected Sui metadata for unstake transaction"), + } + + assert!(result.fee.fee > BigInt::from(0)); + assert!(result.fee.gas_limit > BigInt::from(0)); + assert!(result.fee.gas_limit >= result.fee.fee); + + println!("Unstake transaction fee: {}", result.fee.fee); + + Ok(()) + } +} diff --git a/core/crates/gem_sui/src/provider/preload_mapper.rs b/core/crates/gem_sui/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..edf7b40bf2 --- /dev/null +++ b/core/crates/gem_sui/src/provider/preload_mapper.rs @@ -0,0 +1,168 @@ +use std::error::Error; + +use gem_encoding::encode_base64; +use num_bigint::BigInt; +use primitives::{FeePriority, FeeRate, GasPriceType, StakeType, TransactionInputType, TransactionLoadInput}; + +use crate::{encode_split_and_stake, encode_token_transfer, encode_transfer, encode_unstake, models::*, validate_and_hash}; + +pub fn map_transaction_rate_rates(base_gas_price: BigInt) -> Vec { + vec![ + FeeRate::new(FeePriority::Slow, GasPriceType::regular(base_gas_price.clone())), + FeeRate::new(FeePriority::Normal, GasPriceType::regular(&base_gas_price * BigInt::from(110) / BigInt::from(100))), + FeeRate::new(FeePriority::Fast, GasPriceType::regular(&base_gas_price * BigInt::from(2))), + ] +} + +pub fn map_transaction_data( + input: TransactionLoadInput, + sui_coins: OwnedCoins, + token_coins: Option>, + objects: Vec, + gas_budget: u64, +) -> Result> { + let gas_price = input.gas_price.gas_price().to_string().parse().unwrap_or(0); + + match input.input_type { + TransactionInputType::Transfer(asset) | TransactionInputType::Deposit(asset) => match asset.id.token_id.as_ref() { + None => { + let transfer_input = TransferInput { + sender: input.sender_address, + recipient: input.destination_address, + amount: input.value.parse().unwrap_or(0), + coins: sui_coins, + send_max: input.is_max_value, + gas: Gas { + budget: gas_budget, + price: gas_price, + }, + }; + let tx_output = encode_transfer(&transfer_input)?; + let data = encode_base64(&tx_output.tx_data); + let digest = hex::encode(&tx_output.hash); + Ok(format!("{}_{}", data, digest)) + } + Some(_token_id) => { + let gas_coin = sui_coins.coins.first().ok_or("No gas coins available for token transfer")?.clone(); + let tokens = token_coins.ok_or("Missing token coins set for Sui token transfer")?; + let token_transfer_input = TokenTransferInput { + sender: input.sender_address, + recipient: input.destination_address, + amount: input.value.parse().unwrap_or(0), + tokens, + gas: Gas { + budget: gas_budget, + price: gas_price, + }, + gas_coin, + }; + let tx_output = encode_token_transfer(&token_transfer_input)?; + let data = encode_base64(&tx_output.tx_data); + let digest = hex::encode(&tx_output.hash); + Ok(format!("{}_{}", data, digest)) + } + }, + TransactionInputType::Stake(_, stake_type) => match stake_type { + StakeType::Stake(validator) => { + let stake_input = StakeInput { + sender: input.sender_address, + validator: validator.id.clone(), + stake_amount: input.value.parse().unwrap_or(0), + gas: Gas { + budget: gas_budget, + price: gas_price, + }, + coins: sui_coins, + }; + let tx_output = encode_split_and_stake(&stake_input)?; + let data = encode_base64(&tx_output.tx_data); + let digest = hex::encode(&tx_output.hash); + Ok(format!("{}_{}", data, digest)) + } + StakeType::Unstake(delegation) => { + let gas_coin = sui_coins.coins.first().ok_or("No gas coins available for unstake")?.clone(); + let staked_object = objects + .iter() + .find(|obj| obj.object_id == delegation.base.delegation_id) + .ok_or("Staked SUI object not found in provided objects")?; + + let staked_sui = crate::models::Object { + object_id: staked_object.object_id.parse().map_err(|err| format!("invalid staked Sui object id: {err}"))?, + version: staked_object.version.parse().unwrap_or(0), + digest: staked_object.digest.parse().map_err(|err| format!("invalid staked Sui object digest: {err}"))?, + }; + + let unstake_input = UnstakeInput { + sender: input.sender_address, + staked_sui, + gas: Gas { + budget: gas_budget, + price: gas_price, + }, + gas_coin, + }; + let tx_output = encode_unstake(&unstake_input)?; + let data = encode_base64(&tx_output.tx_data); + let digest = hex::encode(&tx_output.hash); + Ok(format!("{}_{}", data, digest)) + } + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => { + Err("Unsupported stake type for Sui".into()) + } + }, + TransactionInputType::Swap(_, _, data) => { + let tx_output = validate_and_hash(&data.data.data)?; + let data = encode_base64(&tx_output.tx_data); + let digest = hex::encode(&tx_output.hash); + Ok(format!("{}_{}", data, digest)) + } + TransactionInputType::Generic(_, _, extra) => { + let raw_data = extra.data.ok_or("Missing transaction data for Sui generic input")?; + let encoded = String::from_utf8(raw_data).map_err(|_| "Invalid UTF-8 data for Sui generic input")?; + let tx_output = validate_and_hash(&encoded)?; + let data = encode_base64(&tx_output.tx_data); + let digest = hex::encode(&tx_output.hash); + Ok(format!("{}_{}", data, digest)) + } + _ => Err("Unsupported transaction type for Sui".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SuiObject; + use primitives::{Asset, Chain, Delegation, StakeType, TransactionInputType, TransactionLoadInput}; + + #[test] + fn test_unstake_uses_object_reference() { + let delegation_id = "0x1234567890abcdef1234567890abcdef12345678"; + let delegation = Delegation::mock_with_id(delegation_id.to_string()); + let input_type = TransactionInputType::Stake(Asset::from_chain(Chain::Sui), StakeType::Unstake(delegation)); + let input = TransactionLoadInput::mock_with_input_type(input_type); + + let gas_coins = OwnedCoins::::mock_sui(); + + let objects = vec![SuiObject { + object_id: delegation_id.to_string(), + version: "12345".to_string(), + digest: "CU86BjXRF1XHFRjKBasCYEuaQxhHuyGBpuoJyqsrYoX5".to_string(), + }]; + + let result = map_transaction_data(input, gas_coins, None, objects, 25_000_000); + assert!(result.is_ok()); + } + + #[test] + fn test_map_transaction_rate_rates() { + let rates = map_transaction_rate_rates(BigInt::from(497)); + + assert_eq!(rates.len(), 3); + assert_eq!(rates[0].priority, FeePriority::Slow); + assert_eq!(rates[0].gas_price_type.gas_price(), BigInt::from(497)); + assert_eq!(rates[1].priority, FeePriority::Normal); + assert_eq!(rates[1].gas_price_type.gas_price(), BigInt::from(546)); + assert_eq!(rates[2].priority, FeePriority::Fast); + assert_eq!(rates[2].gas_price_type.gas_price(), BigInt::from(994)); + } +} diff --git a/core/crates/gem_sui/src/provider/request_classifier.rs b/core/crates/gem_sui/src/provider/request_classifier.rs new file mode 100644 index 0000000000..fa82cb84a2 --- /dev/null +++ b/core/crates/gem_sui/src/provider/request_classifier.rs @@ -0,0 +1,35 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; +use crate::rpc::client::PATH_EXECUTE_TRANSACTION; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path(PATH_EXECUTE_TRANSACTION) { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::ChainRequestProtocol; + + #[test] + fn test_classify_request() { + let provider = BroadcastProvider; + + let broadcast = ChainRequest::new(ChainRequestProtocol::Http, "POST", PATH_EXECUTE_TRANSACTION, &[]); + assert_eq!(provider.classify_request(broadcast), ChainRequestType::Broadcast); + + let wrong_method = ChainRequest::new(ChainRequestProtocol::Http, "GET", PATH_EXECUTE_TRANSACTION, &[]); + assert_eq!(provider.classify_request(wrong_method), ChainRequestType::Unknown); + + let simulation = ChainRequest::new(ChainRequestProtocol::Http, "POST", "/sui.rpc.v2.TransactionExecutionService/SimulateTransaction", &[]); + assert_eq!(provider.classify_request(simulation), ChainRequestType::Unknown); + } +} diff --git a/core/crates/gem_sui/src/provider/staking.rs b/core/crates/gem_sui/src/provider/staking.rs new file mode 100644 index 0000000000..5b3bba1dd7 --- /dev/null +++ b/core/crates/gem_sui/src/provider/staking.rs @@ -0,0 +1,58 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainStaking; +use primitives::{DelegationBase, DelegationValidator}; + +use crate::provider::staking_mapper; +use crate::rpc::client::SuiClient; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainStaking for SuiClient { + async fn get_staking_apy(&self) -> Result, Box> { + let validators = self.get_validator_apys().await?; + let apy = staking_mapper::map_staking_apy(validators)?; + Ok(Some(apy)) + } + + async fn get_staking_validators(&self, apy: Option) -> Result, Box> { + let validators = self.get_validators().await?; + let default_apy = apy.unwrap_or(0.0); + let delegation_validators = staking_mapper::map_validators(validators, default_apy); + Ok(delegation_validators) + } + + async fn get_staking_delegations(&self, address: String) -> Result, Box> { + let delegations = self.get_stake_delegations(address).await?; + let system_state = self.get_system_state().await?; + let delegation_bases = staking_mapper::map_delegations(delegations, system_state); + Ok(delegation_bases) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::*; + + #[tokio::test] + async fn test_get_staking_apy() -> Result<(), Box> { + let client = create_sui_test_client(); + let apy = client.get_staking_apy().await?; + assert!(apy.is_some()); + println!("Staking APY: {:?}", apy); + Ok(()) + } + + #[tokio::test] + async fn test_get_staking_validators() -> Result<(), Box> { + let client = create_sui_test_client(); + let validators = client.get_staking_validators(Some(5.0)).await?; + assert!(!validators.is_empty()); + println!("Found {} validators", validators.len()); + Ok(()) + } +} diff --git a/core/crates/gem_sui/src/provider/staking_mapper.rs b/core/crates/gem_sui/src/provider/staking_mapper.rs new file mode 100644 index 0000000000..d338dac656 --- /dev/null +++ b/core/crates/gem_sui/src/provider/staking_mapper.rs @@ -0,0 +1,54 @@ +use crate::models::staking::{SuiStakeDelegation, SuiStakeStatus, SuiSystemState, SuiValidators}; +use chrono::{DateTime, Utc}; +use num_bigint::BigUint; +use primitives::{Chain, DelegationBase, DelegationState, DelegationValidator}; + +pub fn map_validators(validators: SuiValidators, default_apy: f64) -> Vec { + validators + .apys + .into_iter() + .map(|validator| DelegationValidator::stake(Chain::Sui, validator.address, String::new(), true, 0.0, default_apy)) + .collect() +} + +pub fn map_delegations(delegations: Vec, system_state: SuiSystemState) -> Vec { + let epoch_start_ms = system_state.epoch_start_timestamp_ms.parse::().unwrap_or(0); + let epoch_duration_ms = system_state.epoch_duration_ms.parse::().unwrap_or(0); + + delegations + .into_iter() + .flat_map(|delegation| { + let validator_address = delegation.validator_address.clone(); + delegation.stakes.into_iter().map(move |stake| { + let completion_date = match map_stake_state(&stake.status) { + DelegationState::Activating => Some(DateTime::from_timestamp((epoch_start_ms + epoch_duration_ms) / 1000, 0).unwrap_or_else(Utc::now)), + _ => None, + }; + + DelegationBase { + asset_id: Chain::Sui.as_asset_id(), + state: map_stake_state(&stake.status), + balance: stake.principal, + shares: BigUint::from(0u32), + rewards: stake.estimated_reward.unwrap_or(BigUint::from(0u32)), + completion_date, + delegation_id: stake.staked_sui_id.clone(), + validator_id: validator_address.clone(), + } + }) + }) + .collect() +} + +pub fn map_staking_apy(validators: SuiValidators) -> Result> { + let max_apy = validators.apys.into_iter().map(|v| v.apy).fold(0.0, f64::max); + Ok(max_apy * 100.0) +} + +fn map_stake_state(status: &SuiStakeStatus) -> DelegationState { + match status { + SuiStakeStatus::Active => DelegationState::Active, + SuiStakeStatus::Pending => DelegationState::Activating, + SuiStakeStatus::Unstaked => DelegationState::Deactivating, + } +} diff --git a/core/crates/gem_sui/src/provider/state.rs b/core/crates/gem_sui/src/provider/state.rs new file mode 100644 index 0000000000..616f30f13d --- /dev/null +++ b/core/crates/gem_sui/src/provider/state.rs @@ -0,0 +1,64 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainState; +#[cfg(feature = "rpc")] +use primitives::NodeSyncStatus; + +use crate::provider::state_mapper; +use crate::rpc::client::SuiClient; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainState for SuiClient { + async fn get_chain_id(&self) -> Result> { + self.get_chain_id().await + } + + async fn get_block_latest_number(&self) -> Result> { + self.get_latest_block().await + } + + async fn get_node_status(&self) -> Result> { + let latest_checkpoint = self.get_block_latest_number().await?; + state_mapper::map_node_status(latest_checkpoint) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::*; + + #[tokio::test] + async fn test_get_chain_id() -> Result<(), Box> { + let client = create_sui_test_client(); + let chain_id = client.get_chain_id().await?; + assert!(!chain_id.is_empty()); + println!("Sui chain ID: {}", chain_id); + Ok(()) + } + + #[tokio::test] + async fn test_get_block_latest_number() -> Result<(), Box> { + let client = create_sui_test_client(); + let latest_block = client.get_block_latest_number().await?; + assert!(latest_block > 0); + println!("Latest block: {}", latest_block); + Ok(()) + } + + #[tokio::test] + async fn test_get_node_status() -> Result<(), Box> { + let client = create_sui_test_client(); + let node_status = client.get_node_status().await?; + + assert!(node_status.in_sync); + assert!(node_status.latest_block_number.is_some()); + assert!(node_status.latest_block_number.unwrap_or(0) > 0); + + Ok(()) + } +} diff --git a/core/crates/gem_sui/src/provider/state_mapper.rs b/core/crates/gem_sui/src/provider/state_mapper.rs new file mode 100644 index 0000000000..1430948b0a --- /dev/null +++ b/core/crates/gem_sui/src/provider/state_mapper.rs @@ -0,0 +1,21 @@ +use primitives::NodeSyncStatus; +use std::error::Error; + +pub fn map_node_status(latest_checkpoint: u64) -> Result> { + Ok(NodeSyncStatus::synced(latest_checkpoint)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_node_status() { + let latest_checkpoint = 98765u64; + let mapped = map_node_status(latest_checkpoint).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(98765)); + assert_eq!(mapped.current_block_number, Some(98765)); + } +} diff --git a/core/crates/gem_sui/src/provider/testkit.rs b/core/crates/gem_sui/src/provider/testkit.rs new file mode 100644 index 0000000000..1dee1e6592 --- /dev/null +++ b/core/crates/gem_sui/src/provider/testkit.rs @@ -0,0 +1,23 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::SuiClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use primitives::asset_constants::SUI_USDC_TOKEN_ID; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "0x93f65b8c16c263343bbf66cf9f8eef69cb1dbc92d13f0c331b0dcaeb76b4aab6"; +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS_EMPTY: &str = "0x180c5478e639770c4424bfbcd4208d8d61f4e52518c76c5ea1ed05b418380457"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_TOKEN_ADDRESS: &str = SUI_USDC_TOKEN_ID; + +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "CJ16PEqq49KFp758iEVwxEkd3CwP7zDfqGYLuLuu9Z63"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_sui_test_client() -> SuiClient { + let settings = get_test_settings(); + SuiClient::new(settings.chains.sui.url) +} diff --git a/core/crates/gem_sui/src/provider/token.rs b/core/crates/gem_sui/src/provider/token.rs new file mode 100644 index 0000000000..fc71873681 --- /dev/null +++ b/core/crates/gem_sui/src/provider/token.rs @@ -0,0 +1,39 @@ +use std::error::Error; + +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainToken; +use primitives::{Asset, Chain}; + +use crate::provider::token_mapper::{map_is_token_address, map_token_data}; +use crate::rpc::client::SuiClient; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainToken for SuiClient { + async fn get_token_data(&self, token_id: String) -> Result> { + let metadata = self.get_coin_metadata(token_id.clone()).await?; + Ok(map_token_data(Chain::Sui, &token_id, metadata)) + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + map_is_token_address(token_id) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainToken; + + #[tokio::test] + async fn test_get_token_data() -> Result<(), Box> { + let client = create_sui_test_client(); + let token_data = client.get_token_data(TEST_TOKEN_ADDRESS.to_string()).await?; + assert!(!token_data.name.is_empty()); + assert!(token_data.decimals > 0); + println!("Token data: {:?}", token_data); + Ok(()) + } +} diff --git a/core/crates/gem_sui/src/provider/token_mapper.rs b/core/crates/gem_sui/src/provider/token_mapper.rs new file mode 100644 index 0000000000..f8f4823100 --- /dev/null +++ b/core/crates/gem_sui/src/provider/token_mapper.rs @@ -0,0 +1,40 @@ +use crate::models::SuiCoinMetadata; +use primitives::{Asset, AssetId, AssetType, Chain}; + +pub fn map_is_token_address(token_id: &str) -> bool { + token_id.starts_with("0x") && token_id.len() >= 66 && token_id.len() <= 100 +} + +pub fn map_token_data(chain: Chain, token_id: &str, metadata: SuiCoinMetadata) -> Asset { + Asset::new(AssetId::from_token(chain, token_id), metadata.name, metadata.symbol, metadata.decimals, AssetType::TOKEN) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_is_token_address() { + assert!(map_is_token_address("0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN")); + assert!(!map_is_token_address("invalid")); + assert!(!map_is_token_address("0x123")); + } + + #[test] + fn test_map_token_data() { + let metadata = SuiCoinMetadata { + name: "USD Coin".to_string(), + symbol: "USDC".to_string(), + decimals: 6, + }; + let token_id = "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN"; + + let asset = map_token_data(Chain::Sui, token_id, metadata); + + assert_eq!(asset.name, "USD Coin"); + assert_eq!(asset.symbol, "USDC"); + assert_eq!(asset.decimals, 6); + assert_eq!(asset.chain, Chain::Sui); + assert_eq!(asset.asset_type, AssetType::TOKEN); + } +} diff --git a/core/crates/gem_sui/src/provider/transaction_broadcast.rs b/core/crates/gem_sui/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..f71383f9a7 --- /dev/null +++ b/core/crates/gem_sui/src/provider/transaction_broadcast.rs @@ -0,0 +1,39 @@ +use std::str; + +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; + +use primitives::BroadcastOptions; + +use crate::{ + provider::{ + BroadcastProvider, + transaction_broadcast_mapper::{ + map_transaction_broadcast_request, map_transaction_broadcast_response, map_transaction_broadcast_response_from_grpc, map_transaction_broadcast_response_from_str, + }, + }, + rpc::client::SuiClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for SuiClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let (transaction_data, signature) = map_transaction_broadcast_request(&data)?; + let response = self.broadcast(transaction_data, signature).await?; + map_transaction_broadcast_response(response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } + + fn decode_transaction_broadcast_bytes(&self, response: &[u8]) -> Option { + map_transaction_broadcast_response_from_grpc(response).ok().or_else(|| { + str::from_utf8(response) + .ok() + .and_then(|response| map_transaction_broadcast_response_from_str(response).ok()) + }) + } +} diff --git a/core/crates/gem_sui/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_sui/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..3c25f69046 --- /dev/null +++ b/core/crates/gem_sui/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,52 @@ +use std::error::Error; + +use gem_encoding::protobuf::decode_grpc_message; + +use crate::models::SuiBroadcastTransaction; +use crate::rpc::proto::ExecuteTransactionResponse; + +pub fn map_transaction_broadcast_request(data: &str) -> Result<(String, String), Box> { + let parts = data.split_once('_').ok_or("Invalid transaction data format. Expected format: data_signature")?; + Ok((parts.0.to_string(), parts.1.to_string())) +} + +pub(crate) fn map_transaction_broadcast_response(response: SuiBroadcastTransaction) -> Result> { + Ok(response.digest) +} + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + map_transaction_broadcast_response(serde_json::from_str::(response)?) +} + +pub fn map_transaction_broadcast_response_from_grpc(response: &[u8]) -> Result> { + let response: ExecuteTransactionResponse = decode_grpc_message(response)?; + map_transaction_broadcast_response(SuiBroadcastTransaction { + digest: response + .transaction + .and_then(|transaction| transaction.digest) + .ok_or("missing Sui broadcast transaction digest")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_encoding::protobuf::{encode_message_field, encode_string_field}; + + fn grpc_frame(payload: &[u8]) -> Vec { + let mut frame = Vec::new(); + frame.push(0); + frame.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + frame.extend_from_slice(payload); + frame + } + + #[test] + fn test_map_transaction_broadcast_response_from_grpc() { + let digest = "HgFLiBHYjYKhEh5NPY52Zm9bhwrs4W6AxE46gMU7nwhZ"; + let transaction = encode_string_field(1, digest); + let response = encode_message_field(1, &transaction); + + assert_eq!(map_transaction_broadcast_response_from_grpc(&grpc_frame(&response)).unwrap(), digest); + } +} diff --git a/core/crates/gem_sui/src/provider/transaction_state.rs b/core/crates/gem_sui/src/provider/transaction_state.rs new file mode 100644 index 0000000000..a1a2025b90 --- /dev/null +++ b/core/crates/gem_sui/src/provider/transaction_state.rs @@ -0,0 +1,16 @@ +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::ChainTransactionState; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::SuiClient}; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainTransactionState for SuiClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let transaction = self.get_transaction(request.id).await?; + Ok(map_transaction_status(transaction)) + } +} diff --git a/core/crates/gem_sui/src/provider/transaction_state_mapper.rs b/core/crates/gem_sui/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..cac685fc1b --- /dev/null +++ b/core/crates/gem_sui/src/provider/transaction_state_mapper.rs @@ -0,0 +1,45 @@ +use primitives::{TransactionState, TransactionUpdate}; + +use crate::models::Digest; + +pub fn map_transaction_status(transaction: Digest) -> TransactionUpdate { + let state = match transaction.effects.status.status.as_str() { + "success" => TransactionState::Confirmed, + "failure" => TransactionState::Reverted, + _ => TransactionState::Pending, + }; + TransactionUpdate::new_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Effect, GasObject, GasUsed, Owner, Status}; + use num_bigint::BigUint; + + #[test] + fn test_map_transaction_status() { + let digest = Digest { + digest: "test".to_string(), + effects: Effect { + gas_used: GasUsed { + computation_cost: BigUint::from(1000u32), + storage_cost: BigUint::from(500u32), + storage_rebate: BigUint::from(100u32), + non_refundable_storage_fee: BigUint::from(0u32), + }, + status: Status { status: "success".to_string() }, + gas_object: GasObject { + owner: Owner::String("0x123".to_string()), + }, + }, + move_call_packages: Vec::new(), + balance_changes: None, + events: vec![], + timestamp_ms: 1234567890, + }; + + let update = map_transaction_status(digest); + assert_eq!(update.state, TransactionState::Confirmed); + } +} diff --git a/core/crates/gem_sui/src/provider/transactions.rs b/core/crates/gem_sui/src/provider/transactions.rs new file mode 100644 index 0000000000..3cc1fbe3fc --- /dev/null +++ b/core/crates/gem_sui/src/provider/transactions.rs @@ -0,0 +1,99 @@ +#[cfg(feature = "rpc")] +use async_trait::async_trait; +#[cfg(feature = "rpc")] +use chain_traits::{ChainTransactions, TransactionsRequest}; +use primitives::Transaction; + +use crate::provider::transactions_mapper::{map_transaction, map_transaction_blocks}; +use crate::rpc::client::SuiClient; + +#[cfg(feature = "rpc")] +#[async_trait] +impl ChainTransactions for SuiClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let transaction_blocks = self.get_checkpoint_transactions(block, None).await?; + Ok(map_transaction_blocks(transaction_blocks)) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + Ok(map_transaction(self.get_transaction(hash).await?)) + } + + async fn get_transactions_by_address(&self, _request: TransactionsRequest) -> Result, Box> { + Ok(vec![]) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::{ChainState, ChainTransactionState, ChainTransactions, TransactionsRequest}; + use primitives::{TransactionState, TransactionStateRequest}; + + #[tokio::test] + async fn test_get_transactions_by_block() -> Result<(), Box> { + let client = create_sui_test_client(); + let latest_block = client.get_block_latest_number().await?; + let transactions = ChainTransactions::get_transactions_by_block(&client, latest_block - 1).await?; + + println!("Transactions in block {}: {}", latest_block - 1, transactions.len()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_transaction_status() -> Result<(), Box> { + let client = create_sui_test_client(); + let latest_block = client.get_block_latest_number().await?; + let transactions = client.get_transactions_by_block(latest_block - 1).await?; + let transaction_id = transactions.transactions.first().ok_or("No Sui transaction found in latest checkpoint")?; + let request = TransactionStateRequest::mock_with_id(transaction_id); + let status = client.get_transaction_status(request).await?; + + println!("Transaction status: {:?}", status); + + assert!(status.state == TransactionState::Confirmed); + assert!(status.changes.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_get_transactions_by_address() -> Result<(), Box> { + let client = create_sui_test_client(); + let transactions = ChainTransactions::get_transactions_by_address(&client, TransactionsRequest::new(TEST_ADDRESS.to_string()).with_limit(1)).await?; + assert!(transactions.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_get_transaction_by_hash() -> Result<(), Box> { + let client = create_sui_test_client(); + let latest_block = client.get_block_latest_number().await?; + let mut transaction = None; + let mut transaction_id = None; + let mut transaction_block = None; + + for block in (latest_block.saturating_sub(20)..latest_block).rev() { + let transactions = client.get_transactions_by_block(block).await?; + for digest in transactions.transactions { + if let Some(mapped) = ChainTransactions::get_transaction_by_hash(&client, digest.to_string()).await? { + transaction = Some(mapped); + transaction_id = Some(digest); + transaction_block = Some(block); + break; + } + } + if transaction.is_some() { + break; + } + } + let transaction = transaction.ok_or("No mappable Sui transaction found in recent checkpoints")?; + let transaction_id = transaction_id.ok_or("No Sui transaction hash found")?; + let transaction_block = transaction_block.ok_or("No Sui transaction block found")?; + + println!("Mappable Sui transaction in block {}: {}", transaction_block, transaction_id); + + assert_eq!(transaction.hash, transaction_id); + Ok(()) + } +} diff --git a/core/crates/gem_sui/src/provider/transactions_mapper.rs b/core/crates/gem_sui/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..762f718b0a --- /dev/null +++ b/core/crates/gem_sui/src/provider/transactions_mapper.rs @@ -0,0 +1,432 @@ +use crate::models::{BalanceChange, Digest, Event, EventStake, EventUnstake, GasUsed, TransactionBlocks}; +use crate::{SUI_COIN_TYPE, SUI_STAKE_EVENT, SUI_UNSTAKE_EVENT, full_coin_type, sui_framework_package_address}; +use chain_primitives::{BalanceDiff, SwapMapper}; +use chrono::{TimeZone, Utc}; +use num_bigint::{BigUint, Sign}; +use primitives::{AssetId, SwapProvider, Transaction, TransactionSmartContractMetadata, TransactionState, TransactionSwapMetadata, TransactionType, chain::Chain}; + +const CHAIN: Chain = Chain::Sui; + +pub fn get_fee(gas_used: GasUsed) -> BigUint { + let computation_cost = gas_used.computation_cost; + let storage_cost = gas_used.storage_cost; + let storage_rebate = gas_used.storage_rebate; + + let cost = computation_cost.clone() + storage_cost.clone(); + if storage_rebate >= cost { + return BigUint::from(0u32); + } + computation_cost + storage_cost - storage_rebate +} + +pub fn map_transaction(transaction: Digest) -> Option { + let chain = CHAIN; + let balance_changes = transaction.balance_changes.unwrap_or_default(); + let effects = transaction.effects.clone(); + let hash = transaction.digest.clone(); + let fee = get_fee(effects.gas_used.clone()); + let created_at = Utc.timestamp_millis_opt(transaction.timestamp_ms as i64).unwrap(); + let state = if effects.status.status == "success" { + TransactionState::Confirmed + } else { + TransactionState::Failed + }; + let owner = effects.gas_object.owner.get_address_owner(); + + let (asset_id, from, to, transaction_type, value, metadata) = map_transaction_type(&transaction.events, &transaction.move_call_packages, &balance_changes, &owner, &fee)?; + + Some(Transaction::new( + hash, + asset_id, + from, + to, + None, + transaction_type, + state, + fee.to_string(), + chain.as_asset_id(), + value, + None, + metadata, + created_at, + )) +} + +fn map_transaction_type( + events: &[Event], + move_call_packages: &[String], + balance_changes: &[BalanceChange], + owner: &Option, + fee: &BigUint, +) -> Option<(AssetId, String, String, TransactionType, String, Option)> { + let chain = CHAIN; + + // system & token transfer + if events.is_empty() && (balance_changes.len() == 2 || balance_changes.len() == 3) { + let (from_change, to_change) = map_transfer_balance_changes(balance_changes, fee)?; + + let asset_id = if is_native_sui(&from_change.coin_type) { + chain.as_asset_id() + } else { + AssetId::from_token(chain, &from_change.coin_type) + }; + return Some(( + asset_id, + from_change.owner.get_address_owner()?, + to_change.owner.get_address_owner()?, + TransactionType::Transfer, + to_change.amount.to_string(), + None, + )); + } + + // stake + if let Some(event) = single_event(events, SUI_STAKE_EVENT) { + let event_json = event.parsed_json.clone()?; + let stake = serde_json::from_value::(event_json).ok()?; + return Some(( + chain.as_asset_id(), + stake.staker_address, + stake.validator_address, + TransactionType::StakeDelegate, + stake.amount, + None, + )); + } + + // swap + if events.iter().any(|x| x.event_type.contains("Swap")) { + let owner_balance_changes: Vec<_> = balance_changes.iter().filter(|x| x.owner.get_address_owner() == *owner).cloned().collect(); + let swap = match owner_balance_changes.len() { + 2 => map_swap_from_balance_changes(owner_balance_changes, fee)?, + 3 => { + let filtered: Vec<_> = owner_balance_changes.into_iter().filter(|x| !is_native_sui(&x.coin_type)).collect(); + map_swap_from_balance_changes(filtered, fee)? + } + _ => return None, + }; + let owner = owner.clone()?; + let asset_id = swap.from_asset.clone(); + return Some(( + asset_id, + owner.clone(), + owner, + TransactionType::Swap, + swap.from_value.clone(), + serde_json::to_value(&swap).ok(), + )); + } + + // unstake + if let Some(event) = single_event(events, SUI_UNSTAKE_EVENT) { + let event_json = event.parsed_json.clone()?; + let stake = serde_json::from_value::(event_json).ok()?; + return Some(( + chain.as_asset_id(), + stake.staker_address, + stake.validator_address, + TransactionType::StakeUndelegate, + stake.principal_amount, + None, + )); + } + + // smart contract call + if !events.is_empty() { + let method_name = events.first()?.event_type.rsplit("::").nth(1)?.to_string(); + let metadata = TransactionSmartContractMetadata { method_name }; + let owner = owner.clone()?; + let contract = primary_contract(move_call_packages.iter().map(String::as_str)).or_else(|| primary_contract(events.iter().map(|event| event.package_id.as_str()))); + return Some(( + chain.as_asset_id(), + owner.clone(), + contract.unwrap_or(owner), + TransactionType::SmartContractCall, + "0".to_string(), + serde_json::to_value(metadata).ok(), + )); + } + + None +} + +fn primary_contract<'a>(contracts: impl IntoIterator) -> Option { + let mut first = None; + for contract in contracts { + if contract.is_empty() { + continue; + } + if first.is_none() { + first = Some(contract); + } + if !is_sui_framework_package(contract) { + return Some(contract.to_string()); + } + } + first.map(ToString::to_string) +} + +fn is_sui_framework_package(package: &str) -> bool { + package.parse::().is_ok_and(|address| address == sui_framework_package_address()) +} + +fn map_transfer_balance_changes<'a>(balance_changes: &'a [BalanceChange], fee: &BigUint) -> Option<(&'a BalanceChange, &'a BalanceChange)> { + let to_change = single(balance_changes.iter().filter(|change| change.amount.sign() == Sign::Plus))?; + let from_change = single(outgoing_changes(balance_changes, &to_change.coin_type)).or_else(|| select_native_transfer_source(balance_changes, to_change, fee))?; + Some((from_change, to_change)) +} + +fn single(mut values: impl Iterator) -> Option { + let value = values.next()?; + values.next().is_none().then_some(value) +} + +fn outgoing_changes<'a>(balance_changes: &'a [BalanceChange], coin_type: &'a str) -> impl Iterator + 'a { + balance_changes + .iter() + .filter(move |change| change.amount.sign() == Sign::Minus && type_tag_matches(&change.coin_type, coin_type)) +} + +fn select_native_transfer_source<'a>(balance_changes: &'a [BalanceChange], to_change: &'a BalanceChange, fee: &BigUint) -> Option<&'a BalanceChange> { + if !is_native_sui(&to_change.coin_type) { + return None; + } + + let amount = to_change.amount.magnitude().clone(); + outgoing_changes(balance_changes, &to_change.coin_type) + .find(|change| change.amount.magnitude() == &amount) + .or_else(|| { + let amount_with_fee = amount + fee; + outgoing_changes(balance_changes, &to_change.coin_type).find(|change| change.amount.magnitude() == &amount_with_fee) + }) + .or_else(|| outgoing_changes(balance_changes, &to_change.coin_type).max_by(|left, right| left.amount.magnitude().cmp(right.amount.magnitude()))) +} + +pub fn map_swap_from_balance_changes(balance_changes: Vec, fee: &BigUint) -> Option { + let balance_diffs: Vec = balance_changes + .into_iter() + .map(|change| BalanceDiff { + asset_id: map_asset_id(&change.coin_type), + from_value: None, + to_value: None, + diff: change.amount, + }) + .collect(); + + let native_asset_id = Chain::Sui.as_asset_id(); + SwapMapper::map_swap(&balance_diffs, fee, &native_asset_id, Some(SwapProvider::CetusClmm.id().to_owned())) +} + +pub fn map_asset_id(coin_type: &str) -> AssetId { + if is_native_sui(coin_type) { + Chain::Sui.as_asset_id() + } else { + AssetId::from_token(Chain::Sui, coin_type) + } +} + +fn is_native_sui(coin_type: &str) -> bool { + type_tag_matches(coin_type, SUI_COIN_TYPE) +} + +fn single_event<'a>(events: &'a [Event], event_type: &str) -> Option<&'a Event> { + let [event] = events else { + return None; + }; + type_tag_matches(&event.event_type, event_type).then_some(event) +} + +fn type_tag_matches(value: &str, expected: &str) -> bool { + full_coin_type(value) == full_coin_type(expected) +} + +pub fn map_transaction_blocks(transaction_blocks: TransactionBlocks) -> Vec { + transaction_blocks.data.into_iter().flat_map(map_transaction).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Effect, GasObject, Owner, OwnerObject, Status}; + use crate::provider::testkit::TEST_TRANSACTION_ID; + use crate::{SUI_COIN_TYPE_FULL, SUI_UNSTAKE_EVENT}; + use num_bigint::{BigInt, BigUint}; + use serde_json::json; + + const OWNER_ADDRESS: &str = "0x1930a5e729ad95a48e4d9dc2ca8a001f8ed18b20077c083cd6b1d3355a7972a5"; + const RECIPIENT_ADDRESS: &str = "0x9d6b98b18fd26b5efeec68d020dcf1be7a94c2c315353779bc6b3aed44188ddf"; + const SPONSORED_TRANSFER_SENDER_ADDRESS: &str = "0x00ea18889868519abd2f238966cab9875750bb2859ed3a34debec37781520138"; + const VALIDATOR_ADDRESS: &str = "0xbba318294a51ddeafa50c335c8e77202170e1f272599a2edc40592100863f638"; + const TOKEN_A: &str = "0x00000000000000000000000000000000000000000000000000000000000000aa::coin::AAA"; + const TOKEN_B: &str = "0x00000000000000000000000000000000000000000000000000000000000000bb::coin::BBB"; + + fn owner(address: &str) -> Owner { + Owner::OwnerObject(OwnerObject { + address_owner: Some(address.to_string()), + }) + } + + fn balance_change(address: &str, coin_type: &str, amount: i64) -> BalanceChange { + BalanceChange { + owner: owner(address), + coin_type: coin_type.to_string(), + amount: BigInt::from(amount), + } + } + + fn event(event_type: impl Into, parsed_json: serde_json::Value) -> Event { + Event { + event_type: event_type.into(), + parsed_json: Some(parsed_json), + package_id: String::new(), + } + } + + fn make_digest(events: Vec, balance_changes: Vec) -> Digest { + Digest { + digest: "test".to_string(), + effects: Effect { + gas_used: GasUsed { + computation_cost: BigUint::from(0u32), + storage_cost: BigUint::from(0u32), + storage_rebate: BigUint::from(0u32), + non_refundable_storage_fee: BigUint::from(0u32), + }, + status: Status { status: "success".to_string() }, + gas_object: GasObject { owner: owner(OWNER_ADDRESS) }, + }, + move_call_packages: Vec::new(), + balance_changes: Some(balance_changes), + events, + timestamp_ms: 1778964551487, + } + } + + #[test] + fn test_map_transaction_blocks() { + let transaction_blocks = TransactionBlocks { data: vec![] }; + let transactions = map_transaction_blocks(transaction_blocks); + assert_eq!(transactions.len(), 0); + } + + #[test] + fn test_map_smart_contract_call() { + let digest: Digest = serde_json::from_str(include_str!("../../testdata/transfer_token_contract.json")).unwrap(); + let transaction = map_transaction(digest).unwrap(); + + assert_eq!(transaction.transaction_type, TransactionType::SmartContractCall); + assert_eq!(transaction.value, "0"); + + let metadata: TransactionSmartContractMetadata = serde_json::from_value(transaction.metadata.unwrap()).unwrap(); + assert_eq!(metadata.method_name, "timevy_tipping"); + } + + #[test] + fn test_map_mayan_mctp_smart_contract_call() { + let digest: Digest = serde_json::from_str(include_str!("../../testdata/mayan_mctp_sui_usdc_to_arbitrum_usdc.json")).unwrap(); + let expected_contract = primary_contract(digest.move_call_packages.iter().map(String::as_str)).unwrap(); + let transaction = map_transaction(digest).unwrap(); + + assert_eq!(transaction.hash, "AqXACRuimqMVf4wiVjR3Ch5PBunhQAJY3ZfAMF3MXUsW"); + assert_eq!(transaction.transaction_type, TransactionType::SmartContractCall); + assert_eq!(transaction.from, "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991"); + assert_eq!(transaction.to, expected_contract); + } + + #[test] + fn test_map_transaction_by_hash() { + let digest: Digest = serde_json::from_str(include_str!("../../testdata/transfer_sui.json")).unwrap(); + let transaction = map_transaction(digest).unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + assert_eq!(transaction.transaction_type, TransactionType::Transfer); + } + + #[test] + fn test_map_full_type_tags() { + let digest: Digest = serde_json::from_str(include_str!("../../testdata/stake_grpc.json")).unwrap(); + let transaction = map_transaction(digest).unwrap(); + + assert_eq!(transaction.hash, "DXKezMGJZaxJRC6a6zCr3JdfquYGxgU1zjV4xrNAaCFB"); + assert_eq!(transaction.transaction_type, TransactionType::StakeDelegate); + assert_eq!(transaction.value, "2000000000"); + assert_eq!(transaction.from, OWNER_ADDRESS); + assert_eq!(transaction.to, VALIDATOR_ADDRESS); + assert_eq!(transaction.fee, "10610996"); + assert_eq!(map_asset_id(SUI_COIN_TYPE_FULL), Chain::Sui.as_asset_id()); + + let native_transfer = map_transaction(make_digest( + vec![], + vec![ + balance_change(OWNER_ADDRESS, SUI_COIN_TYPE_FULL, -101744880), + balance_change(RECIPIENT_ADDRESS, SUI_COIN_TYPE_FULL, 100000000), + ], + )) + .unwrap(); + + assert_eq!(native_transfer.transaction_type, TransactionType::Transfer); + assert_eq!(native_transfer.asset_id, Chain::Sui.as_asset_id()); + assert_eq!(native_transfer.value, "100000000"); + + let digest: Digest = serde_json::from_str(include_str!("../../testdata/sponsored_transfer_sui.json")).unwrap(); + let sponsored_transfer = map_transaction(digest).unwrap(); + + assert_eq!(sponsored_transfer.transaction_type, TransactionType::Transfer); + assert_eq!(sponsored_transfer.asset_id, Chain::Sui.as_asset_id()); + assert_eq!(sponsored_transfer.from, SPONSORED_TRANSFER_SENDER_ADDRESS); + assert_eq!(sponsored_transfer.to, OWNER_ADDRESS); + assert_eq!(sponsored_transfer.value, "5996594751"); + + let token_transfer = map_transaction(make_digest( + vec![], + vec![ + balance_change(OWNER_ADDRESS, SUI_COIN_TYPE_FULL, -1000), + balance_change(OWNER_ADDRESS, TOKEN_A, -100), + balance_change(RECIPIENT_ADDRESS, TOKEN_A, 100), + ], + )) + .unwrap(); + + assert_eq!(token_transfer.transaction_type, TransactionType::Transfer); + assert_eq!(token_transfer.asset_id, AssetId::from_token(Chain::Sui, TOKEN_A)); + assert_eq!(token_transfer.value, "100"); + + let swap = map_transaction(make_digest( + vec![event("0x00000000000000000000000000000000000000000000000000000000000000cc::pool::SwapEvent", json!({}))], + vec![ + balance_change(OWNER_ADDRESS, SUI_COIN_TYPE_FULL, -1000), + balance_change(OWNER_ADDRESS, TOKEN_A, -200), + balance_change(OWNER_ADDRESS, TOKEN_B, 150), + ], + )) + .unwrap(); + + assert_eq!(swap.transaction_type, TransactionType::Swap); + assert_eq!(swap.asset_id, AssetId::from_token(Chain::Sui, TOKEN_A)); + assert_eq!(swap.value, "200"); + let metadata: TransactionSwapMetadata = serde_json::from_value(swap.metadata.unwrap()).unwrap(); + assert_eq!(metadata.from_asset, AssetId::from_token(Chain::Sui, TOKEN_A)); + assert_eq!(metadata.from_value, "200"); + assert_eq!(metadata.to_asset, AssetId::from_token(Chain::Sui, TOKEN_B)); + assert_eq!(metadata.to_value, "150"); + + let unstake = map_transaction(make_digest( + vec![event( + full_coin_type(SUI_UNSTAKE_EVENT), + json!({ + "principal_amount": "3000000000", + "reward_amount": "42", + "staker_address": OWNER_ADDRESS, + "validator_address": VALIDATOR_ADDRESS, + }), + )], + vec![balance_change(OWNER_ADDRESS, SUI_COIN_TYPE_FULL, 3000000000)], + )) + .unwrap(); + + assert_eq!(unstake.transaction_type, TransactionType::StakeUndelegate); + assert_eq!(unstake.value, "3000000000"); + assert_eq!(unstake.from, OWNER_ADDRESS); + assert_eq!(unstake.to, VALIDATOR_ADDRESS); + } +} diff --git a/core/crates/gem_sui/src/rpc/client.rs b/core/crates/gem_sui/src/rpc/client.rs new file mode 100644 index 0000000000..55d0f09006 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/client.rs @@ -0,0 +1,424 @@ +use std::{error::Error, str::FromStr, sync::Arc}; + +use gem_encoding::decode_base64; +use gem_encoding::protobuf::{MessageDecode, MessageEncode, decode_grpc_message, encode_grpc_message}; +use gem_jsonrpc::grpc::GrpcTransport; +use num_bigint::BigInt; +use primitives::Chain; +use serde::de::DeserializeOwned; +use sui_types::Address; + +use super::mapper::{map_checkpoint, map_executed_transaction, map_inspect_result, map_sui_effects}; +use super::proto::{ + self as proto, BatchGetObjectsRequest, BatchGetObjectsResponse, BatchGetTransactionsRequest, BatchGetTransactionsResponse, ExecuteTransactionRequest, + ExecuteTransactionResponse, FieldMask, GetBalanceRequest, GetBalanceResponse, GetCheckpointRequest, GetCheckpointResponse, GetCoinInfoRequest, GetCoinInfoResponse, + GetEpochRequest, GetEpochResponse, GetFunctionRequest, GetFunctionResponse, GetObjectRequest, GetObjectResponse, GetServiceInfoRequest, GetServiceInfoResponse, + GetTransactionRequest, GetTransactionResponse, ListBalancesRequest, ListBalancesResponse, ListOwnedObjectsRequest, ListOwnedObjectsResponse, SimulateTransactionRequest, + SimulateTransactionResponse, Transaction as GrpcTransaction, TransactionChecks, UserSignature as GrpcUserSignature, WithMut, +}; +use super::transport::default_transport; +use crate::models::transaction::{SuiBroadcastTransaction, SuiTransaction}; +use crate::models::{Balance, Checkpoint, Coin, Digest, InspectResult, Object, OwnedCoins, SuiCoinMetadata, SuiObject, TransactionBlocks}; +use crate::{SUI_COIN_TYPE, SUI_COIN_TYPE_FULL}; + +const TRANSACTION_READ_MASK: &[&str] = &[ + "digest", + "transaction.kind", + "effects.gas_used", + "effects.status", + "effects.gas_object", + "events.events.package_id", + "events.events.event_type", + "events.events.json", + "balance_changes", + "timestamp", +]; + +pub(super) const PATH_GET_EPOCH: &str = "/sui.rpc.v2.LedgerService/GetEpoch"; +const PATH_GET_SERVICE_INFO: &str = "/sui.rpc.v2.LedgerService/GetServiceInfo"; +const PATH_GET_OBJECT: &str = "/sui.rpc.v2.LedgerService/GetObject"; +const PATH_GET_CHECKPOINT: &str = "/sui.rpc.v2.LedgerService/GetCheckpoint"; +const PATH_GET_TRANSACTION: &str = "/sui.rpc.v2.LedgerService/GetTransaction"; +const PATH_GET_FUNCTION: &str = "/sui.rpc.v2.MovePackageService/GetFunction"; +pub(super) const PATH_LIST_OWNED_OBJECTS: &str = "/sui.rpc.v2.StateService/ListOwnedObjects"; +const PATH_GET_BALANCE: &str = "/sui.rpc.v2.StateService/GetBalance"; +const PATH_LIST_BALANCES: &str = "/sui.rpc.v2.StateService/ListBalances"; +const PATH_GET_COIN_INFO: &str = "/sui.rpc.v2.StateService/GetCoinInfo"; +const PATH_BATCH_GET_OBJECTS: &str = "/sui.rpc.v2.LedgerService/BatchGetObjects"; +const PATH_BATCH_GET_TRANSACTIONS: &str = "/sui.rpc.v2.LedgerService/BatchGetTransactions"; +pub(super) const PATH_SIMULATE_TRANSACTION: &str = "/sui.rpc.v2.TransactionExecutionService/SimulateTransaction"; +pub(crate) const PATH_EXECUTE_TRANSACTION: &str = "/sui.rpc.v2.TransactionExecutionService/ExecuteTransaction"; +const BATCH_GET_TRANSACTIONS_LIMIT: usize = 50; + +#[derive(Clone, Debug)] +pub struct SuiClient { + endpoint: String, + transport: Option>, +} + +impl SuiClient { + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + transport: default_transport(), + } + } + + pub fn new_with_transport(endpoint: impl Into, transport: Arc) -> Self { + Self { + endpoint: endpoint.into(), + transport: Some(transport), + } + } + + pub(super) async fn grpc_unary(&self, path: &str, request: Req) -> Result> + where + Req: MessageEncode, + Resp: MessageDecode, + { + let transport = self.transport.as_ref().ok_or("missing Sui gRPC transport")?; + let body = encode_grpc_message(&request); + let response = transport.unary(&self.endpoint, path, body).await?; + decode_grpc_message(&response).map_err(|error| format!("Sui gRPC decode failed for {path}: {error}").into()) + } + + pub async fn inspect_transaction_block(&self, sender: &str, tx_data: &[u8], _gas_price: Option) -> Result> { + let transaction = decode_inspect_transaction_bytes(sender, tx_data)?; + let request = SimulateTransactionRequest::new(transaction).with(|request| { + request.read_mask = Some(FieldMask::from_paths([ + "transaction.effects.gas_used", + "transaction.effects.status", + "command_outputs.return_values.value", + ])); + request.checks = Some(TransactionChecks::Disabled); + }); + Ok(map_inspect_result(self.grpc_unary(PATH_SIMULATE_TRANSACTION, request).await?)) + } + + pub async fn get_balance(&self, address: String) -> Result> { + self.get_balance_for_coin(&address, SUI_COIN_TYPE_FULL).await + } + + pub async fn get_balance_for_coin(&self, address: &str, coin_type: &str) -> Result> { + let request = GetBalanceRequest { + owner: Some(address.to_string()), + coin_type: Some(coin_type.to_string()), + }; + let response: GetBalanceResponse = self.grpc_unary(PATH_GET_BALANCE, request).await?; + let balance = response.balance.unwrap_or_default(); + Ok(Balance { + coin_type: balance.coin_type.unwrap_or_else(|| coin_type.to_string()), + total_balance: BigInt::from(balance.balance.unwrap_or_default()), + address_balance: balance.address_balance.unwrap_or_default(), + }) + } + + pub async fn get_all_balances(&self, address: String) -> Result, Box> { + let mut request = ListBalancesRequest { + owner: Some(address), + page_size: Some(1000), + ..Default::default() + }; + let mut balances = Vec::new(); + + loop { + let response: ListBalancesResponse = self.grpc_unary(PATH_LIST_BALANCES, request.clone()).await?; + let page = response + .balances + .into_iter() + .map(|balance| { + Ok(Balance { + coin_type: balance.coin_type.ok_or("missing Sui balance coin type")?, + total_balance: BigInt::from(balance.balance.ok_or("missing Sui balance amount")?), + address_balance: balance.address_balance.unwrap_or_default(), + }) + }) + .collect::, Box>>()?; + balances.extend(page); + if response.next_page_token.is_none() { + break; + } + request.page_token = response.next_page_token; + } + + Ok(balances) + } + + pub async fn get_coin_metadata(&self, token_id: String) -> Result> { + let request = GetCoinInfoRequest { coin_type: Some(token_id) }; + let response: GetCoinInfoResponse = self.grpc_unary(PATH_GET_COIN_INFO, request).await?; + let metadata = response.metadata.ok_or("missing Sui coin metadata")?; + Ok(SuiCoinMetadata { + decimals: metadata.decimals.unwrap_or_default() as i32, + name: metadata.name.unwrap_or_default(), + symbol: metadata.symbol.unwrap_or_default(), + }) + } + + pub async fn get_chain_id(&self) -> Result> { + map_service_chain_identifier(self.service_info().await?) + } + + pub async fn get_latest_block(&self) -> Result> { + Ok(self.service_info().await?.checkpoint_height.ok_or("missing Sui checkpoint height")?) + } + + pub async fn get_gas_price(&self) -> Result> { + let request = GetEpochRequest { + epoch: None, + read_mask: Some(FieldMask::from_path_string("reference_gas_price")), + }; + let response: GetEpochResponse = self.grpc_unary(PATH_GET_EPOCH, request).await?; + let epoch = response.epoch.ok_or("missing Sui epoch")?; + Ok(BigInt::from(epoch.reference_gas_price.ok_or("missing Sui reference gas price")?)) + } + + pub async fn get_coins(&self, address: &str, coin_type: &str) -> Result, Box> { + let (objects, balance) = futures::try_join!(self.list_coin_objects(address, coin_type), self.get_balance_for_coin(address, coin_type),)?; + Ok(OwnedCoins::new(coin_type.to_string(), objects, balance.address_balance)) + } + + async fn list_coin_objects(&self, address: &str, coin_type: &str) -> Result, Box> { + let mut request = ListOwnedObjectsRequest { + owner: Some(address.to_string()), + page_size: Some(1000), + object_type: Some(format!("0x2::coin::Coin<{}>", full_sui_coin_type(coin_type))), + read_mask: Some(FieldMask::from_paths(["object_id", "version", "digest", "balance", "object_type"])), + ..Default::default() + }; + let mut coins = Vec::new(); + + loop { + let response: ListOwnedObjectsResponse = self.grpc_unary(PATH_LIST_OWNED_OBJECTS, request.clone()).await?; + let page = response + .objects + .into_iter() + .map(|object| { + let coin_type = object + .object_type + .ok_or("missing Sui coin object type")? + .trim_start_matches("0x2::coin::Coin<") + .trim_end_matches('>') + .to_string(); + let object_id_str = object.object_id.ok_or("missing Sui coin object id")?; + let digest_str = object.digest.ok_or("missing Sui coin digest")?; + Ok(Coin { + coin_type, + balance: object.balance.ok_or("missing Sui coin balance")?, + object: Object { + object_id: Address::from_str(&object_id_str)?, + digest: sui_types::Digest::from_str(&digest_str)?, + version: object.version.ok_or("missing Sui coin version")?, + }, + }) + }) + .collect::, Box>>()?; + coins.extend(page); + if response.next_page_token.is_none() { + break; + } + request.page_token = response.next_page_token; + } + + Ok(coins) + } + + pub async fn get_object(&self, object_id: String) -> Result> { + let object_id = Address::from_str(&object_id)?; + let request = GetObjectRequest::new(&object_id).with(|request| { + request.read_mask = Some(FieldMask::from_paths(["object_id", "version", "digest"])); + }); + let response: GetObjectResponse = self.grpc_unary(PATH_GET_OBJECT, request).await?; + let object = response.object.ok_or("missing Sui object")?; + Ok(SuiObject { + object_id: object.object_id.ok_or("missing Sui object id")?, + digest: object.digest.ok_or("missing Sui object digest")?, + version: object.version.ok_or("missing Sui object version")?.to_string(), + }) + } + + pub async fn get_object_json(&self, object_id: String) -> Result> + where + T: DeserializeOwned, + { + let object_id = Address::from_str(&object_id)?; + let request = GetObjectRequest::new(&object_id).with(|request| { + request.read_mask = Some(FieldMask::from_paths(["json"])); + }); + let response: GetObjectResponse = self.grpc_unary(PATH_GET_OBJECT, request).await?; + let json = response.object.and_then(|object| object.json).ok_or("missing Sui object JSON")?; + Ok(serde_json::from_value(json)?) + } + + pub(crate) async fn get_function(&self, package_id: &str, module: &str, function: &str) -> Result> { + let package_id = Address::from_str(package_id)?; + let request = GetFunctionRequest::new(&package_id, module, function); + let response: GetFunctionResponse = self.grpc_unary(PATH_GET_FUNCTION, request).await?; + Ok(response.function.ok_or("missing Sui function descriptor")?) + } + + pub async fn dry_run(&self, tx_data: String) -> Result> { + let transaction = decode_transaction_base64(&tx_data)?; + let request = SimulateTransactionRequest::new(transaction).with(|request| { + request.read_mask = Some(FieldMask::from_paths(["transaction.effects.gas_used", "transaction.effects.status"])); + request.checks = Some(TransactionChecks::Enabled); + }); + let response: SimulateTransactionResponse = self.grpc_unary(PATH_SIMULATE_TRANSACTION, request).await?; + let executed = response.transaction.ok_or("missing simulated transaction")?; + Ok(SuiTransaction { + effects: map_sui_effects(executed.effects.as_ref()), + }) + } + + pub async fn get_transaction(&self, transaction_id: String) -> Result> { + let request = GetTransactionRequest { + digest: Some(transaction_id), + read_mask: Some(FieldMask::from_paths(TRANSACTION_READ_MASK.iter().copied())), + }; + let response: GetTransactionResponse = self.grpc_unary(PATH_GET_TRANSACTION, request).await?; + map_executed_transaction(response.transaction.ok_or("missing Sui transaction")?) + } + + pub async fn get_transactions_by_block(&self, checkpoint: u64) -> Result> { + let request = GetCheckpointRequest::by_sequence_number(checkpoint).with(|request| { + request.read_mask = Some(FieldMask::from_paths(["sequence_number", "digest", "summary", "contents.transactions.transaction"])); + }); + let response: GetCheckpointResponse = self.grpc_unary(PATH_GET_CHECKPOINT, request).await?; + let checkpoint = response.checkpoint.ok_or("missing Sui checkpoint")?; + map_checkpoint(checkpoint) + } + + pub async fn get_checkpoint_transactions(&self, checkpoint: u64, limit: Option) -> Result> { + let checkpoint = self.get_transactions_by_block(checkpoint).await?; + let digests: Vec<_> = checkpoint.transactions.into_iter().take(limit.unwrap_or(usize::MAX)).collect(); + if digests.is_empty() { + return Ok(TransactionBlocks { data: Vec::new() }); + } + + let mut data = Vec::new(); + for digests in digests.chunks(BATCH_GET_TRANSACTIONS_LIMIT) { + data.extend(self.batch_get_transactions(digests.to_vec()).await?); + } + Ok(TransactionBlocks { data }) + } + + async fn batch_get_transactions(&self, digests: Vec) -> Result, Box> { + let request = BatchGetTransactionsRequest { + digests, + read_mask: Some(FieldMask::from_paths(TRANSACTION_READ_MASK.iter().copied())), + }; + let response: BatchGetTransactionsResponse = self.grpc_unary(PATH_BATCH_GET_TRANSACTIONS, request).await?; + response + .transactions + .into_iter() + .map(|result| match result { + proto::GetTransactionResult::Transaction(transaction) => map_executed_transaction(*transaction), + proto::GetTransactionResult::Error(status) => Err(format!("Sui transaction gRPC error {}: {}", status.code, status.message).into()), + proto::GetTransactionResult::Unknown => Err("missing Sui transaction result".into()), + }) + .collect::, _>>() + } + + pub async fn broadcast(&self, data: String, signature: String) -> Result> { + let transaction = decode_transaction_base64(&data)?; + let signature = sui_types::UserSignature::from_base64(&signature)?; + let request = ExecuteTransactionRequest { + transaction: Some(transaction), + signatures: vec![GrpcUserSignature::from_sdk(signature)], + read_mask: Some(FieldMask::from_paths(["digest", "effects.status"])), + }; + let response: ExecuteTransactionResponse = self.grpc_unary(PATH_EXECUTE_TRANSACTION, request).await?; + Ok(SuiBroadcastTransaction { + digest: response + .transaction + .and_then(|transaction| transaction.digest) + .ok_or("missing Sui broadcast transaction digest")?, + }) + } + + pub(crate) async fn get_multiple_objects(&self, object_ids: Vec) -> Result, Box> { + let requests = object_ids + .into_iter() + .map(|object_id| Ok(GetObjectRequest::new(&Address::from_str(&object_id)?))) + .collect::, Box>>()?; + let request = BatchGetObjectsRequest { + requests, + read_mask: Some(FieldMask::from_paths(["object_id", "version", "digest", "owner"])), + }; + let response: BatchGetObjectsResponse = self.grpc_unary(PATH_BATCH_GET_OBJECTS, request).await?; + response + .objects + .into_iter() + .map(|result| match result { + proto::GetObjectResult::Object(object) => Ok(*object), + proto::GetObjectResult::Error(status) => Err(format!("Sui object gRPC error {}: {}", status.code, status.message).into()), + proto::GetObjectResult::Unknown => Err("missing Sui object result".into()), + }) + .collect() + } + + async fn service_info(&self) -> Result> { + self.grpc_unary(PATH_GET_SERVICE_INFO, GetServiceInfoRequest).await + } + + pub(super) async fn get_epoch(&self, epoch: Option, read_mask: Option) -> Result> { + let request = GetEpochRequest { epoch, read_mask }; + let response: GetEpochResponse = self.grpc_unary(PATH_GET_EPOCH, request).await?; + Ok(response.epoch.ok_or("missing Sui epoch")?) + } +} + +fn decode_transaction_base64(tx_data: &str) -> Result> { + decode_transaction_bytes(&decode_base64(tx_data)?) +} + +fn decode_transaction_bytes(tx_data: &[u8]) -> Result> { + let _: sui_types::Transaction = bcs::from_bytes(tx_data)?; + Ok(GrpcTransaction::from_transaction_bcs(tx_data.to_vec())) +} + +fn decode_inspect_transaction_bytes(sender: &str, tx_data: &[u8]) -> Result> { + if let Ok(transaction) = decode_transaction_bytes(tx_data) { + return Ok(transaction); + } + let kind: sui_types::TransactionKind = bcs::from_bytes(tx_data)?; + Ok(GrpcTransaction::from_kind(proto::TransactionKind::from_sdk(kind)?, sender)) +} + +fn full_sui_coin_type(coin_type: &str) -> String { + match coin_type { + SUI_COIN_TYPE => SUI_COIN_TYPE_FULL.to_string(), + _ => coin_type.to_string(), + } +} + +fn map_service_chain_identifier(service_info: proto::GetServiceInfoResponse) -> Result> { + if service_info.chain.as_deref() == Some("mainnet") { + return Ok(Chain::Sui.network_id().to_string()); + } + + Ok(service_info.chain_id.ok_or("missing Sui chain id")?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_service_chain_identifier() { + let service_info = proto::GetServiceInfoResponse { + chain: Some("mainnet".to_string()), + chain_id: Some("grpc-mainnet-genesis-digest".to_string()), + ..Default::default() + }; + assert_eq!(map_service_chain_identifier(service_info).unwrap(), Chain::Sui.network_id()); + + let testnet = "sui-testnet-chain-id"; + let service_info = proto::GetServiceInfoResponse { + chain: Some("testnet".to_string()), + chain_id: Some(testnet.to_string()), + ..Default::default() + }; + assert_eq!(map_service_chain_identifier(service_info).unwrap(), testnet); + } +} diff --git a/core/crates/gem_sui/src/rpc/mapper.rs b/core/crates/gem_sui/src/rpc/mapper.rs new file mode 100644 index 0000000000..0f92521ec6 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/mapper.rs @@ -0,0 +1,178 @@ +use std::{error::Error, str::FromStr}; + +use num_bigint::{BigInt, BigUint}; + +use super::proto::{self, Command, OwnerKind, Timestamp}; +use crate::models::transaction::SuiStatus; +use crate::models::{ + BalanceChange, Checkpoint, Digest, Effect, Event, GasObject, GasUsed, InspectCommandResult, InspectEffects, InspectGasUsed, InspectResult, Owner, OwnerObject, Status, + SuiEffects, +}; + +pub(super) fn timestamp_millis(timestamp: &Timestamp) -> i64 { + timestamp.millis() +} + +pub(super) fn map_checkpoint(checkpoint: proto::Checkpoint) -> Result> { + let summary = required(checkpoint.summary, "missing Sui checkpoint summary")?; + let contents = required(checkpoint.contents, "missing Sui checkpoint contents")?; + let transactions = contents + .transactions + .into_iter() + .map(|transaction| required(transaction.transaction, "missing Sui checkpoint transaction digest")) + .collect::, _>>()?; + + Ok(Checkpoint { + epoch: required(summary.epoch, "missing Sui checkpoint epoch")?.to_string(), + sequence_number: required(checkpoint.sequence_number, "missing Sui checkpoint sequence number")?.to_string(), + digest: required(checkpoint.digest, "missing Sui checkpoint digest")?, + network_total_transactions: required(summary.total_network_transactions, "missing Sui checkpoint network transaction count")?.to_string(), + previous_digest: summary.previous_digest.unwrap_or_default(), + timestamp_ms: timestamp_millis(&required(summary.timestamp, "missing Sui checkpoint timestamp")?).to_string(), + transactions, + }) +} + +pub(super) fn map_executed_transaction(transaction: proto::ExecutedTransaction) -> Result> { + Ok(Digest { + digest: transaction.digest.ok_or("missing Sui transaction digest")?, + effects: map_effect(transaction.effects.as_ref()), + move_call_packages: map_move_call_packages(transaction.transaction.as_ref()), + balance_changes: Some(transaction.balance_changes.into_iter().map(map_balance_change).collect::, _>>()?), + events: transaction.events.map(map_events).unwrap_or_default(), + timestamp_ms: transaction.timestamp.as_ref().map(timestamp_millis).unwrap_or_default() as u64, + }) +} + +fn map_move_call_packages(transaction: Option<&proto::Transaction>) -> Vec { + transaction + .and_then(|transaction| transaction.kind.as_ref()) + .and_then(|kind| kind.programmable_transaction.as_ref()) + .map(|transaction| { + transaction + .commands + .iter() + .filter_map(|command| match command { + Command::MoveCall(call) => call.package.clone(), + Command::TransferObjects(_) + | Command::SplitCoins(_) + | Command::MergeCoins(_) + | Command::Publish(_) + | Command::MakeMoveVector(_) + | Command::Upgrade(_) + | Command::Unknown => None, + }) + .collect() + }) + .unwrap_or_default() +} + +fn map_effect(effects: Option<&proto::TransactionEffects>) -> Effect { + let gas_object_owner = effects + .and_then(|effects| effects.gas_object.as_ref()) + .and_then(|object| object.output_owner.as_ref().or(object.input_owner.as_ref())) + .map(map_owner) + .unwrap_or_else(|| Owner::String(String::new())); + + Effect { + gas_used: map_gas_used(effects.and_then(|effects| effects.gas_used.as_ref())), + status: Status { + status: if transaction_success(effects) { "success" } else { "failure" }.to_string(), + }, + gas_object: GasObject { owner: gas_object_owner }, + } +} + +pub(super) fn map_sui_effects(effects: Option<&proto::TransactionEffects>) -> SuiEffects { + SuiEffects { + gas_used: map_gas_used(effects.and_then(|effects| effects.gas_used.as_ref())), + status: SuiStatus { + status: if transaction_success(effects) { "success" } else { "failure" }.to_string(), + error: transaction_error(effects), + }, + } +} + +fn transaction_success(effects: Option<&proto::TransactionEffects>) -> bool { + effects.and_then(|effects| effects.status.as_ref()).and_then(|status| status.success).unwrap_or(false) +} + +fn transaction_error(effects: Option<&proto::TransactionEffects>) -> Option { + effects + .and_then(|effects| effects.status.as_ref()) + .and_then(|status| status.error.as_ref()) + .and_then(|error| error.description.clone()) +} + +fn map_gas_used(gas: Option<&proto::GasCostSummary>) -> GasUsed { + GasUsed { + computation_cost: BigUint::from(gas.and_then(|gas| gas.computation_cost).unwrap_or_default()), + storage_cost: BigUint::from(gas.and_then(|gas| gas.storage_cost).unwrap_or_default()), + storage_rebate: BigUint::from(gas.and_then(|gas| gas.storage_rebate).unwrap_or_default()), + non_refundable_storage_fee: BigUint::from(gas.and_then(|gas| gas.non_refundable_storage_fee).unwrap_or_default()), + } +} + +fn map_balance_change(change: proto::BalanceChange) -> Result> { + Ok(BalanceChange { + owner: Owner::OwnerObject(OwnerObject { address_owner: change.address }), + coin_type: change.coin_type.ok_or("missing Sui balance change coin type")?, + amount: BigInt::from_str(&change.amount.ok_or("missing Sui balance change amount")?)?, + }) +} + +fn map_events(events: proto::TransactionEvents) -> Vec { + events + .events + .into_iter() + .map(|event| Event { + event_type: event.event_type.unwrap_or_default(), + parsed_json: event.json, + package_id: event.package_id.unwrap_or_default(), + }) + .collect() +} + +fn map_owner(owner: &proto::Owner) -> Owner { + match owner.kind() { + OwnerKind::Address => Owner::OwnerObject(OwnerObject { + address_owner: owner.address.clone(), + }), + _ => Owner::String(owner.address.clone().unwrap_or_default()), + } +} + +pub(super) fn map_inspect_result(response: proto::SimulateTransactionResponse) -> InspectResult { + let effects = response.transaction.as_ref().and_then(|transaction| transaction.effects.as_ref()); + let gas_used = effects.and_then(|effects| effects.gas_used.as_ref()); + + InspectResult { + effects: InspectEffects { + gas_used: InspectGasUsed { + computation_cost: gas_used.and_then(|gas| gas.computation_cost).unwrap_or_default(), + storage_cost: gas_used.and_then(|gas| gas.storage_cost).unwrap_or_default(), + storage_rebate: gas_used.and_then(|gas| gas.storage_rebate).unwrap_or_default(), + }, + }, + events: serde_json::Value::Null, + error: transaction_error(effects), + results: response + .command_outputs + .into_iter() + .map(|output| InspectCommandResult { + return_values: output + .return_values + .into_iter() + .filter_map(|value| { + let value = value.value?; + Some((value.value.unwrap_or_default().to_vec(), value.name.unwrap_or_default())) + }) + .collect(), + }) + .collect(), + } +} + +fn required(value: Option, message: &'static str) -> Result> { + value.ok_or_else(|| message.into()) +} diff --git a/core/crates/gem_sui/src/rpc/mod.rs b/core/crates/gem_sui/src/rpc/mod.rs new file mode 100644 index 0000000000..b769037a60 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/mod.rs @@ -0,0 +1,7 @@ +pub mod client; +mod mapper; +pub(crate) mod proto; +mod staking; +mod transport; + +pub use client::SuiClient; diff --git a/core/crates/gem_sui/src/rpc/proto/balances.rs b/core/crates/gem_sui/src/rpc/proto/balances.rs new file mode 100644 index 0000000000..b13b6dfa44 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/balances.rs @@ -0,0 +1,92 @@ +use gem_encoding::protobuf::{proto_decode, proto_encode}; + +// Field numbers mirror sui-rpc v0.3.1 state_service.proto: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/state_service.proto + +#[derive(Clone, Debug, Default)] +pub struct GetBalanceRequest { + pub owner: Option, + pub coin_type: Option, +} + +proto_encode!(GetBalanceRequest { + 1 => owner: optional_string, + 2 => coin_type: optional_string, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetBalanceResponse { + pub balance: Option, +} + +proto_decode!(GetBalanceResponse { + 1 => balance: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct ListBalancesRequest { + pub owner: Option, + pub page_size: Option, + pub page_token: Option>, +} + +proto_encode!(ListBalancesRequest { + 1 => owner: optional_string, + 2 => page_size: optional_varint_u32, + 3 => page_token: optional_bytes, +}); + +#[derive(Clone, Debug, Default)] +pub struct ListBalancesResponse { + pub balances: Vec, + pub next_page_token: Option>, +} + +proto_decode!(ListBalancesResponse { + 1 => balances: repeated_message, + 2 => next_page_token: optional_bytes, +}); + +#[derive(Clone, Debug, Default)] +pub struct Balance { + pub coin_type: Option, + pub balance: Option, + pub address_balance: Option, +} + +proto_decode!(Balance { + 1 => coin_type: optional_string, + 3 => balance: optional_varint_u64, + 4 => address_balance: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetCoinInfoRequest { + pub coin_type: Option, +} + +proto_encode!(GetCoinInfoRequest { + 1 => coin_type: optional_string, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetCoinInfoResponse { + pub metadata: Option, +} + +proto_decode!(GetCoinInfoResponse { + 2 => metadata: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct CoinMetadata { + pub decimals: Option, + pub name: Option, + pub symbol: Option, +} + +proto_decode!(CoinMetadata { + 2 => decimals: optional_varint_u32, + 3 => name: optional_string, + 4 => symbol: optional_string, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/bcs.rs b/core/crates/gem_sui/src/rpc/proto/bcs.rs new file mode 100644 index 0000000000..0d6204ef6c --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/bcs.rs @@ -0,0 +1,34 @@ +use gem_encoding::protobuf::{proto_decode, proto_encode}; +use serde::Deserialize; + +// Field numbers mirror sui-rpc v0.3.1 BCS protobuf schema: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/bcs.proto + +#[derive(Clone, Debug, Default)] +pub struct Bcs { + pub name: Option, + pub value: Option>, +} + +impl Bcs { + pub fn new(name: impl Into, value: Vec) -> Self { + Self { + name: Some(name.into()), + value: Some(value), + } + } + + pub fn deserialize<'de, T: Deserialize<'de>>(&'de self) -> Result { + ::bcs::from_bytes(self.value.as_deref().unwrap_or(&[])) + } +} + +proto_encode!(Bcs { + 1 => name: optional_string, + 2 => value: optional_bytes, +}); + +proto_decode!(Bcs { + 1 => name: optional_string, + 2 => value: optional_bytes, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/checkpoints.rs b/core/crates/gem_sui/src/rpc/proto/checkpoints.rs new file mode 100644 index 0000000000..ba0af77e3e --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/checkpoints.rs @@ -0,0 +1,106 @@ +use super::{FieldMask, Timestamp}; +use gem_encoding::protobuf::{proto_decode, proto_encode}; + +// Field numbers mirror sui-rpc v0.3.1 ledger/checkpoint schemas: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/ledger_service.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/checkpoint.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/checkpoint_summary.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/checkpoint_contents.proto + +#[derive(Clone, Debug, Default)] +pub struct GetCheckpointRequest { + pub sequence_number: Option, + pub read_mask: Option, +} + +impl GetCheckpointRequest { + pub fn by_sequence_number(sequence_number: u64) -> Self { + Self { + sequence_number: Some(sequence_number), + read_mask: None, + } + } +} + +proto_encode!(GetCheckpointRequest { + 1 => sequence_number: optional_varint_u64, + 3 => read_mask: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetCheckpointResponse { + pub checkpoint: Option, +} + +proto_decode!(GetCheckpointResponse { + 1 => checkpoint: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct Checkpoint { + pub sequence_number: Option, + pub digest: Option, + pub summary: Option, + pub contents: Option, +} + +proto_decode!(Checkpoint { + 1 => sequence_number: optional_varint_u64, + 2 => digest: optional_string, + 3 => summary: optional_message, + 5 => contents: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct CheckpointSummary { + pub epoch: Option, + pub total_network_transactions: Option, + pub previous_digest: Option, + pub timestamp: Option, +} + +proto_decode!(CheckpointSummary { + 3 => epoch: optional_varint_u64, + 5 => total_network_transactions: optional_varint_u64, + 7 => previous_digest: optional_string, + 9 => timestamp: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct CheckpointContents { + pub transactions: Vec, +} + +proto_decode!(CheckpointContents { + 4 => transactions: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct CheckpointedTransactionInfo { + pub transaction: Option, +} + +proto_decode!(CheckpointedTransactionInfo { + 1 => transaction: optional_string, +}); + +#[cfg(test)] +mod tests { + use super::*; + use gem_encoding::protobuf::MessageDecode; + + #[test] + fn test_checkpoint_response_wire_bytes_decode() { + let response = hex::decode("0a2a082a1211636865636b706f696e742d6469676573741a0a180728633a04707265762a0722050a03747831").unwrap(); + let checkpoint = GetCheckpointResponse::decode(&response).unwrap().checkpoint.unwrap(); + let summary = checkpoint.summary.unwrap(); + let contents = checkpoint.contents.unwrap(); + + assert_eq!(checkpoint.sequence_number, Some(42)); + assert_eq!(checkpoint.digest.as_deref(), Some("checkpoint-digest")); + assert_eq!(summary.epoch, Some(7)); + assert_eq!(summary.total_network_transactions, Some(99)); + assert_eq!(summary.previous_digest.as_deref(), Some("prev")); + assert_eq!(contents.transactions[0].transaction.as_deref(), Some("tx1")); + } +} diff --git a/core/crates/gem_sui/src/rpc/proto/field.rs b/core/crates/gem_sui/src/rpc/proto/field.rs new file mode 100644 index 0000000000..3ef5d56bb5 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/field.rs @@ -0,0 +1,39 @@ +use gem_encoding::protobuf::proto_encode; + +// Field numbers mirror sui-rpc v0.3.1 google.protobuf.FieldMask schema: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/google/protobuf/field_mask.proto + +#[derive(Clone, Debug, Default)] +pub struct FieldMask { + pub paths: Vec, +} + +impl FieldMask { + pub fn from_paths(paths: impl IntoIterator>) -> Self { + Self { + paths: paths.into_iter().map(|path| path.as_ref().to_string()).collect(), + } + } + + pub fn from_path_string(paths: &str) -> Self { + Self::from_paths(paths.split(',').map(str::trim).filter(|path| !path.is_empty())) + } +} + +proto_encode!(FieldMask { + 1 => paths: repeated_string, +}); + +#[cfg(test)] +mod tests { + use super::*; + use gem_encoding::protobuf::MessageEncode; + + #[test] + fn test_field_mask_encode() { + assert_eq!( + FieldMask::from_paths(["digest", "effects.status"]).encode(), + hex::decode("0a066469676573740a0e656666656374732e737461747573").unwrap() + ); + } +} diff --git a/core/crates/gem_sui/src/rpc/proto/json.rs b/core/crates/gem_sui/src/rpc/proto/json.rs new file mode 100644 index 0000000000..46c1c43693 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/json.rs @@ -0,0 +1,120 @@ +use serde_json::{Map, Number, Value}; + +use super::MessageResult; +use gem_encoding::protobuf::{MessageDecode, proto_decode}; + +// Field numbers mirror google.protobuf.Value, Struct, and ListValue: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/google/protobuf/struct.proto + +pub(super) fn decode_json_value(data: &[u8]) -> MessageResult { + Ok(JsonValue::decode(data)?.into_value()) +} + +#[derive(Clone, Debug)] +struct JsonValue { + value: Value, +} + +impl Default for JsonValue { + fn default() -> Self { + Self { value: Value::Null } + } +} + +impl JsonValue { + fn into_value(self) -> Value { + self.value + } +} + +proto_decode!(JsonValue { + 1 => |value, _field| value.value = Value::Null, + 2 => |value, field| value.value = Number::from_f64(f64::from_bits(field.fixed64()?)).map(Value::Number).unwrap_or(Value::Null), + 3 => |value, field| value.value = Value::String(field.string()?), + 4 => |value, field| value.value = Value::Bool(field.varint()? != 0), + 5 => |value, field| value.value = field.message::()?.into_value(), + 6 => |value, field| value.value = field.message::()?.into_value(), +}); + +#[derive(Clone, Debug, Default)] +struct JsonStruct { + fields: Map, +} + +impl JsonStruct { + fn into_value(self) -> Value { + Value::Object(self.fields) + } +} + +proto_decode!(JsonStruct { + 1 => |value, field| { + let entry = field.message::()?; + value.fields.insert(entry.key, entry.value); + }, +}); + +#[derive(Clone, Debug)] +struct JsonStructField { + key: String, + value: Value, +} + +impl Default for JsonStructField { + fn default() -> Self { + Self { + key: String::new(), + value: Value::Null, + } + } +} + +proto_decode!(JsonStructField { + 1 => |value, field| value.key = field.string()?, + 2 => |value, field| value.value = field.message::()?.into_value(), +}); + +#[derive(Clone, Debug, Default)] +struct JsonList { + values: Vec, +} + +impl JsonList { + fn into_value(self) -> Value { + Value::Array(self.values) + } +} + +proto_decode!(JsonList { + 1 => |value, field| value.values.push(field.message::()?.into_value()), +}); + +#[cfg(test)] +mod tests { + use super::*; + use gem_encoding::protobuf::{encode_message_field, encode_raw_varint_field, encode_string_field}; + use serde_json::json; + + #[test] + fn test_decode_json_value() { + let name = [encode_string_field(1, "name"), encode_message_field(2, &encode_string_field(3, "Sui"))].concat(); + let active = [encode_string_field(1, "active"), encode_message_field(2, &encode_raw_varint_field(4, 1))].concat(); + let list = [ + encode_message_field(1, &encode_string_field(3, "rpc")), + encode_message_field(1, &encode_raw_varint_field(4, 1)), + ] + .concat(); + let tags = [encode_string_field(1, "tags"), encode_message_field(2, &encode_message_field(6, &list))].concat(); + let object = [encode_message_field(1, &name), encode_message_field(1, &active), encode_message_field(1, &tags)].concat(); + let value = encode_message_field(5, &object); + + assert_eq!( + decode_json_value(&value).unwrap(), + json!({ + "name": "Sui", + "active": true, + "tags": ["rpc", true], + }) + ); + } +} diff --git a/core/crates/gem_sui/src/rpc/proto/mod.rs b/core/crates/gem_sui/src/rpc/proto/mod.rs new file mode 100644 index 0000000000..9725f79f61 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/mod.rs @@ -0,0 +1,42 @@ +pub(crate) mod balances; +pub(crate) mod bcs; +pub(crate) mod checkpoints; +pub(crate) mod field; +pub(crate) mod json; +pub(crate) mod move_package; +pub(crate) mod objects; +pub(crate) mod service; +pub(crate) mod status; +pub(crate) mod timestamp; +pub(crate) mod transaction_data; +pub(crate) mod transactions; + +pub(crate) use gem_encoding::protobuf::MessageResult; + +pub(crate) use balances::{GetBalanceRequest, GetBalanceResponse, GetCoinInfoRequest, GetCoinInfoResponse, ListBalancesRequest, ListBalancesResponse}; +pub(crate) use bcs::Bcs; +pub(crate) use checkpoints::{Checkpoint, GetCheckpointRequest, GetCheckpointResponse}; +pub(crate) use field::FieldMask; +pub(crate) use move_package::{FunctionDescriptor, GetFunctionRequest, GetFunctionResponse, OpenSignature, open_signature}; +pub(crate) use objects::{ + BatchGetObjectsRequest, BatchGetObjectsResponse, GetObjectRequest, GetObjectResponse, GetObjectResult, ListOwnedObjectsRequest, ListOwnedObjectsResponse, Object, Owner, + OwnerKind, +}; +pub(crate) use service::{Epoch, GetEpochRequest, GetEpochResponse, GetServiceInfoRequest, GetServiceInfoResponse}; +pub(crate) use status::Status; +pub(crate) use timestamp::Timestamp; +pub(crate) use transaction_data::{Argument, Command, Input, MoveCall, ProgrammableTransaction, Transaction, TransactionKind, UserSignature}; +pub(crate) use transactions::{ + BalanceChange, BatchGetTransactionsRequest, BatchGetTransactionsResponse, ExecuteTransactionRequest, ExecuteTransactionResponse, ExecutedTransaction, GasCostSummary, + GetTransactionRequest, GetTransactionResponse, GetTransactionResult, SimulateTransactionRequest, SimulateTransactionResponse, TransactionChecks, TransactionEffects, + TransactionEvents, +}; + +pub(crate) trait WithMut: Sized { + fn with(mut self, update: impl FnOnce(&mut Self)) -> Self { + update(&mut self); + self + } +} + +impl WithMut for T {} diff --git a/core/crates/gem_sui/src/rpc/proto/move_package.rs b/core/crates/gem_sui/src/rpc/proto/move_package.rs new file mode 100644 index 0000000000..4498925a6c --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/move_package.rs @@ -0,0 +1,76 @@ +use sui_types::Address as SdkAddress; + +use gem_encoding::protobuf::{proto_decode, proto_encode}; + +// Mirrors https://github.com/MystenLabs/sui-apis/blob/main/proto/sui/rpc/v2/move_package_service.proto +#[derive(Clone, Debug, Default)] +pub struct GetFunctionRequest { + pub package_id: Option, + pub module_name: Option, + pub name: Option, +} + +impl GetFunctionRequest { + pub fn new(package_id: &SdkAddress, module: &str, name: &str) -> Self { + Self { + package_id: Some(package_id.to_string()), + module_name: Some(module.to_string()), + name: Some(name.to_string()), + } + } +} + +proto_encode!(GetFunctionRequest { + 1 => package_id: optional_string, + 2 => module_name: optional_string, + 3 => name: optional_string, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetFunctionResponse { + pub function: Option, +} + +proto_decode!(GetFunctionResponse { + 1 => function: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct FunctionDescriptor { + pub parameters: Vec, +} + +proto_decode!(FunctionDescriptor { + 8 => parameters: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct OpenSignature { + pub reference: Option, +} + +proto_decode!(OpenSignature { + 1 => reference: optional_varint_i32, +}); + +pub mod open_signature { + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum Reference { + Unknown = 0, + Immutable = 1, + Mutable = 2, + } + + impl TryFrom for Reference { + type Error = i32; + + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(Self::Unknown), + 1 => Ok(Self::Immutable), + 2 => Ok(Self::Mutable), + _ => Err(value), + } + } + } +} diff --git a/core/crates/gem_sui/src/rpc/proto/objects.rs b/core/crates/gem_sui/src/rpc/proto/objects.rs new file mode 100644 index 0000000000..730951df34 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/objects.rs @@ -0,0 +1,188 @@ +use sui_types::Address as SdkAddress; + +use serde_json::Value; + +use super::json::decode_json_value; +use super::{Bcs, FieldMask, Status}; +use gem_encoding::protobuf::{proto_decode, proto_encode}; + +// Field numbers mirror sui-rpc v0.3.1 ledger/object schemas: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/ledger_service.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/object.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/owner.proto + +#[derive(Clone, Debug, Default)] +pub struct ListOwnedObjectsRequest { + pub owner: Option, + pub page_size: Option, + pub page_token: Option>, + pub read_mask: Option, + pub object_type: Option, +} + +proto_encode!(ListOwnedObjectsRequest { + 1 => owner: optional_string, + 2 => page_size: optional_varint_u32, + 3 => page_token: optional_bytes, + 4 => read_mask: optional_message, + 5 => object_type: optional_string, +}); + +#[derive(Clone, Debug, Default)] +pub struct ListOwnedObjectsResponse { + pub objects: Vec, + pub next_page_token: Option>, +} + +proto_decode!(ListOwnedObjectsResponse { + 1 => objects: repeated_message, + 2 => next_page_token: optional_bytes, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetObjectRequest { + pub object_id: Option, + pub read_mask: Option, +} + +impl GetObjectRequest { + pub fn new(object_id: &SdkAddress) -> Self { + Self { + object_id: Some(object_id.to_string()), + read_mask: None, + } + } +} + +proto_encode!(GetObjectRequest { + 1 => object_id: optional_string, + 3 => read_mask: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetObjectResponse { + pub object: Option, +} + +proto_decode!(GetObjectResponse { + 1 => object: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct BatchGetObjectsRequest { + pub requests: Vec, + pub read_mask: Option, +} + +proto_encode!(BatchGetObjectsRequest { + 1 => requests: repeated_message, + 2 => read_mask: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct BatchGetObjectsResponse { + pub objects: Vec, +} + +proto_decode!(BatchGetObjectsResponse { + 1 => objects: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub enum GetObjectResult { + Object(Box), + Error(Status), + #[default] + Unknown, +} + +proto_decode!(GetObjectResult { + 1 => |value, field| *value = Self::Object(Box::new(field.message()?)), + 2 => |value, field| *value = Self::Error(field.message()?), +}); + +#[derive(Clone, Debug, Default)] +pub struct Object { + pub object_id: Option, + pub version: Option, + pub digest: Option, + pub owner: Option, + pub object_type: Option, + pub contents: Option, + pub json: Option, + pub balance: Option, +} + +proto_decode!(Object { + 2 => |value, field| value.object_id = Some(field.string()?), + 3 => |value, field| value.version = Some(field.varint()?), + 4 => |value, field| value.digest = Some(field.string()?), + 5 => |value, field| value.owner = Some(field.message()?), + 6 => |value, field| value.object_type = Some(field.string()?), + 8 => |value, field| value.contents = Some(field.message()?), + 100 => |value, field| value.json = Some(decode_json_value(field.bytes()?)?), + 101 => |value, field| value.balance = Some(field.varint()?), +}); + +#[derive(Clone, Debug, Default)] +pub struct Owner { + pub kind: Option, + pub address: Option, + pub version: Option, +} + +impl Owner { + pub fn kind(&self) -> OwnerKind { + OwnerKind::from_i32(self.kind.unwrap_or_default()) + } +} + +proto_decode!(Owner { + 1 => kind: optional_varint_i32, + 2 => address: optional_string, + 3 => version: optional_varint_u64, +}); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OwnerKind { + Unknown, + Address, + Object, + Shared, + Immutable, + ConsensusAddress, +} + +impl OwnerKind { + fn from_i32(value: i32) -> Self { + match value { + 1 => Self::Address, + 2 => Self::Object, + 3 => Self::Shared, + 4 => Self::Immutable, + 5 => Self::ConsensusAddress, + _ => Self::Unknown, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_encoding::protobuf::MessageDecode; + + #[test] + fn test_object_response_wire_bytes_decode() { + let response = hex::decode("0a2b120530786162631807220a6f626a2d6469676573742a0b0801120730786f776e65723204636f696ea8067b").unwrap(); + let object = GetObjectResponse::decode(&response).unwrap().object.unwrap(); + let owner = object.owner.unwrap(); + + assert_eq!(object.object_id.as_deref(), Some("0xabc")); + assert_eq!(object.version, Some(7)); + assert_eq!(object.digest.as_deref(), Some("obj-digest")); + assert_eq!(owner.kind(), OwnerKind::Address); + assert_eq!(owner.address.as_deref(), Some("0xowner")); + assert_eq!(object.object_type.as_deref(), Some("coin")); + assert_eq!(object.balance, Some(123)); + } +} diff --git a/core/crates/gem_sui/src/rpc/proto/service.rs b/core/crates/gem_sui/src/rpc/proto/service.rs new file mode 100644 index 0000000000..9c303c4775 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/service.rs @@ -0,0 +1,115 @@ +use super::{FieldMask, Timestamp}; +use gem_encoding::protobuf::{proto_decode, proto_encode}; + +// Field numbers mirror sui-rpc v0.3.1 ledger/epoch/system-state schemas: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/ledger_service.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/epoch.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/system_state.proto + +#[derive(Clone, Debug, Default)] +pub struct GetServiceInfoRequest; + +proto_encode!(GetServiceInfoRequest {}); + +#[derive(Clone, Debug, Default)] +pub struct GetServiceInfoResponse { + pub chain_id: Option, + pub chain: Option, + pub checkpoint_height: Option, +} + +proto_decode!(GetServiceInfoResponse { + 1 => chain_id: optional_string, + 2 => chain: optional_string, + 4 => checkpoint_height: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetEpochRequest { + pub epoch: Option, + pub read_mask: Option, +} + +proto_encode!(GetEpochRequest { + 1 => epoch: optional_varint_u64, + 2 => read_mask: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetEpochResponse { + pub epoch: Option, +} + +proto_decode!(GetEpochResponse { + 1 => epoch: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct Epoch { + pub epoch: u64, + pub system_state: Option, + pub start: Option, + pub end: Option, + pub reference_gas_price: Option, +} + +proto_decode!(Epoch { + 1 => epoch: varint_u64, + 3 => system_state: optional_message, + 6 => start: optional_message, + 7 => end: optional_message, + 8 => reference_gas_price: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +pub struct SystemState { + pub validators: Option, + pub parameters: Option, +} + +proto_decode!(SystemState { + 4 => validators: optional_message, + 6 => parameters: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct SystemParameters { + pub stake_subsidy_start_epoch: Option, +} + +proto_decode!(SystemParameters { + 2 => stake_subsidy_start_epoch: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +pub struct ValidatorSet { + pub active_validators: Vec, +} + +proto_decode!(ValidatorSet { + 2 => active_validators: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct Validator { + pub address: Option, + pub staking_pool: Option, +} + +proto_decode!(Validator { + 2 => address: optional_string, + 32 => staking_pool: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct StakingPool { + pub id: Option, + pub sui_balance: Option, + pub pool_token_balance: Option, +} + +proto_decode!(StakingPool { + 1 => id: optional_string, + 4 => sui_balance: optional_varint_u64, + 6 => pool_token_balance: optional_varint_u64, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/status.rs b/core/crates/gem_sui/src/rpc/proto/status.rs new file mode 100644 index 0000000000..04d64468ba --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/status.rs @@ -0,0 +1,15 @@ +use gem_encoding::protobuf::proto_decode; + +// Field numbers mirror google.rpc.Status: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/google/rpc/status.proto + +#[derive(Clone, Debug, Default)] +pub struct Status { + pub code: i32, + pub message: String, +} + +proto_decode!(Status { + 1 => code: varint_i32, + 2 => message: string, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/timestamp.rs b/core/crates/gem_sui/src/rpc/proto/timestamp.rs new file mode 100644 index 0000000000..3512820bcb --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/timestamp.rs @@ -0,0 +1,21 @@ +use gem_encoding::protobuf::proto_decode; + +// Field numbers mirror sui-rpc v0.3.1 google.protobuf.Timestamp schema: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/google/protobuf/timestamp.proto + +#[derive(Clone, Debug, Default)] +pub struct Timestamp { + pub seconds: i64, + pub nanos: i32, +} + +impl Timestamp { + pub fn millis(&self) -> i64 { + self.seconds.saturating_mul(1000) + i64::from(self.nanos / 1_000_000) + } +} + +proto_decode!(Timestamp { + 1 => seconds: varint_i64, + 2 => nanos: varint_i32, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/transaction_data/argument.rs b/core/crates/gem_sui/src/rpc/proto/transaction_data/argument.rs new file mode 100644 index 0000000000..a6a5993463 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/transaction_data/argument.rs @@ -0,0 +1,55 @@ +use gem_encoding::protobuf::proto_encode; +use sui_types as sdk; + +// Field numbers mirror sui-rpc v0.3.1 argument schema: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/argument.proto + +const ARGUMENT_KIND_GAS: i32 = 1; +const ARGUMENT_KIND_INPUT: i32 = 2; +const ARGUMENT_KIND_RESULT: i32 = 3; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Argument { + pub kind: Option, + pub input: Option, + pub result: Option, + pub subresult: Option, +} + +impl Argument { + pub fn new_input(input: u16) -> Self { + Self { + kind: Some(ARGUMENT_KIND_INPUT), + input: Some(input.into()), + ..Default::default() + } + } + + pub(super) fn from_sdk(value: sdk::Argument) -> Self { + match value { + sdk::Argument::Gas => Self { + kind: Some(ARGUMENT_KIND_GAS), + ..Default::default() + }, + sdk::Argument::Input(input) => Self::new_input(input), + sdk::Argument::Result(result) => Self { + kind: Some(ARGUMENT_KIND_RESULT), + result: Some(result.into()), + ..Default::default() + }, + sdk::Argument::NestedResult(result, subresult) => Self { + kind: Some(ARGUMENT_KIND_RESULT), + result: Some(result.into()), + subresult: Some(subresult.into()), + ..Default::default() + }, + } + } +} + +proto_encode!(Argument { + 1 => kind: optional_varint_i32, + 2 => input: optional_varint_u32, + 3 => result: optional_varint_u32, + 4 => subresult: optional_varint_u32, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/transaction_data/command.rs b/core/crates/gem_sui/src/rpc/proto/transaction_data/command.rs new file mode 100644 index 0000000000..cf4575399e --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/transaction_data/command.rs @@ -0,0 +1,228 @@ +use gem_encoding::protobuf::{encode_message_field, proto_decode, proto_encode}; +use sui_types as sdk; + +use super::Argument; +use crate::rpc::proto::MessageResult; + +// Field numbers mirror sui-rpc v0.3.1 transaction command schemas: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/transaction.proto + +#[derive(Clone, Debug, Default)] +pub enum Command { + MoveCall(MoveCall), + TransferObjects(TransferObjects), + SplitCoins(SplitCoins), + MergeCoins(MergeCoins), + Publish(Publish), + MakeMoveVector(MakeMoveVector), + Upgrade(Upgrade), + #[default] + Unknown, +} + +impl Command { + pub(super) fn from_sdk(value: sdk::Command) -> MessageResult { + match value { + sdk::Command::MoveCall(value) => Ok(Self::MoveCall(MoveCall::from_sdk(value))), + sdk::Command::TransferObjects(value) => Ok(Self::TransferObjects(TransferObjects::from_sdk(value))), + sdk::Command::SplitCoins(value) => Ok(Self::SplitCoins(SplitCoins::from_sdk(value))), + sdk::Command::MergeCoins(value) => Ok(Self::MergeCoins(MergeCoins::from_sdk(value))), + sdk::Command::Publish(value) => Ok(Self::Publish(Publish::from_sdk(value))), + sdk::Command::MakeMoveVector(value) => Ok(Self::MakeMoveVector(MakeMoveVector::from_sdk(value))), + sdk::Command::Upgrade(value) => Ok(Self::Upgrade(Upgrade::from_sdk(value))), + _ => Err("unsupported Sui transaction command for protobuf encoding".into()), + } + } +} + +impl From for Command { + fn from(value: MoveCall) -> Self { + Self::MoveCall(value) + } +} + +proto_encode!(Command as value { + match value { + Command::MoveCall(value) => encode_message_field(1, &value.encode()), + Command::TransferObjects(value) => encode_message_field(2, &value.encode()), + Command::SplitCoins(value) => encode_message_field(3, &value.encode()), + Command::MergeCoins(value) => encode_message_field(4, &value.encode()), + Command::Publish(value) => encode_message_field(5, &value.encode()), + Command::MakeMoveVector(value) => encode_message_field(6, &value.encode()), + Command::Upgrade(value) => encode_message_field(7, &value.encode()), + Command::Unknown => Vec::new(), + }, +}); + +proto_decode!(Command { + 1 => |value, field| *value = Self::MoveCall(field.message()?), +}); + +#[derive(Clone, Debug, Default)] +pub struct MoveCall { + pub package: Option, + pub module: Option, + pub function: Option, + pub type_arguments: Vec, + pub arguments: Vec, +} + +impl MoveCall { + pub fn from_parts(package: impl ToString, module: impl Into, function: impl Into, arguments: Vec) -> Self { + Self { + package: Some(package.to_string()), + module: Some(module.into()), + function: Some(function.into()), + arguments, + ..Default::default() + } + } + + fn from_sdk(value: sdk::MoveCall) -> Self { + Self { + package: Some(value.package.to_string()), + module: Some(value.module.to_string()), + function: Some(value.function.to_string()), + type_arguments: value.type_arguments.iter().map(ToString::to_string).collect(), + arguments: value.arguments.into_iter().map(Argument::from_sdk).collect(), + } + } +} + +proto_encode!(MoveCall { + 1 => package: optional_string, + 2 => module: optional_string, + 3 => function: optional_string, + 4 => type_arguments: repeated_string, + 5 => arguments: repeated_message, +}); + +proto_decode!(MoveCall { + 1 => package: optional_string, +}); + +#[derive(Clone, Debug, Default)] +pub struct TransferObjects { + pub objects: Vec, + pub address: Option, +} + +impl TransferObjects { + fn from_sdk(value: sdk::TransferObjects) -> Self { + Self { + objects: value.objects.into_iter().map(Argument::from_sdk).collect(), + address: Some(Argument::from_sdk(value.address)), + } + } +} + +proto_encode!(TransferObjects { + 1 => objects: repeated_message, + 2 => address: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct SplitCoins { + pub coin: Option, + pub amounts: Vec, +} + +impl SplitCoins { + fn from_sdk(value: sdk::SplitCoins) -> Self { + Self { + coin: Some(Argument::from_sdk(value.coin)), + amounts: value.amounts.into_iter().map(Argument::from_sdk).collect(), + } + } +} + +proto_encode!(SplitCoins { + 1 => coin: optional_message, + 2 => amounts: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct MergeCoins { + pub coin: Option, + pub coins_to_merge: Vec, +} + +impl MergeCoins { + fn from_sdk(value: sdk::MergeCoins) -> Self { + Self { + coin: Some(Argument::from_sdk(value.coin)), + coins_to_merge: value.coins_to_merge.into_iter().map(Argument::from_sdk).collect(), + } + } +} + +proto_encode!(MergeCoins { + 1 => coin: optional_message, + 2 => coins_to_merge: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct Publish { + pub modules: Vec>, + pub dependencies: Vec, +} + +impl Publish { + fn from_sdk(value: sdk::Publish) -> Self { + Self { + modules: value.modules, + dependencies: value.dependencies.iter().map(ToString::to_string).collect(), + } + } +} + +proto_encode!(Publish { + 1 => modules: repeated_bytes, + 2 => dependencies: repeated_string, +}); + +#[derive(Clone, Debug, Default)] +pub struct MakeMoveVector { + pub element_type: Option, + pub elements: Vec, +} + +impl MakeMoveVector { + fn from_sdk(value: sdk::MakeMoveVector) -> Self { + Self { + element_type: value.type_.map(|value| value.to_string()), + elements: value.elements.into_iter().map(Argument::from_sdk).collect(), + } + } +} + +proto_encode!(MakeMoveVector { + 1 => element_type: optional_string, + 2 => elements: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct Upgrade { + pub modules: Vec>, + pub dependencies: Vec, + pub package: Option, + pub ticket: Option, +} + +impl Upgrade { + fn from_sdk(value: sdk::Upgrade) -> Self { + Self { + modules: value.modules, + dependencies: value.dependencies.iter().map(ToString::to_string).collect(), + package: Some(value.package.to_string()), + ticket: Some(Argument::from_sdk(value.ticket)), + } + } +} + +proto_encode!(Upgrade { + 1 => modules: repeated_bytes, + 2 => dependencies: repeated_string, + 3 => package: optional_string, + 4 => ticket: optional_message, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/transaction_data/input.rs b/core/crates/gem_sui/src/rpc/proto/transaction_data/input.rs new file mode 100644 index 0000000000..236b322227 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/transaction_data/input.rs @@ -0,0 +1,132 @@ +use gem_encoding::protobuf::proto_encode; +use sui_types as sdk; + +use crate::rpc::proto::MessageResult; + +// Field numbers mirror sui-rpc v0.3.1 input schema: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/input.proto + +const INPUT_KIND_PURE: i32 = 1; +const INPUT_KIND_IMMUTABLE_OR_OWNED: i32 = 2; +const INPUT_KIND_SHARED: i32 = 3; +const INPUT_KIND_RECEIVING: i32 = 4; +const INPUT_KIND_FUNDS_WITHDRAWAL: i32 = 5; + +const MUTABILITY_IMMUTABLE: i32 = 1; +const MUTABILITY_MUTABLE: i32 = 2; +const MUTABILITY_NON_EXCLUSIVE_WRITE: i32 = 3; + +const SOURCE_SENDER: i32 = 1; +const SOURCE_SPONSOR: i32 = 2; + +#[derive(Clone, Debug, Default)] +pub struct Input { + pub kind: Option, + pub pure: Option>, + pub object_id: Option, + pub version: Option, + pub digest: Option, + pub mutable: Option, + pub mutability: Option, + pub funds_withdrawal: Option, +} + +impl Input { + pub fn object_id(object_id: impl ToString) -> Self { + Self { + object_id: Some(object_id.to_string()), + ..Default::default() + } + } + + pub fn pure(value: Vec) -> Self { + Self { + pure: Some(value), + ..Default::default() + } + } + + pub(super) fn from_sdk(value: sdk::Input) -> MessageResult { + match value { + sdk::Input::Pure(value) => Ok(Self { + kind: Some(INPUT_KIND_PURE), + pure: Some(value), + ..Default::default() + }), + sdk::Input::ImmutableOrOwned(reference) => Ok(Self { + kind: Some(INPUT_KIND_IMMUTABLE_OR_OWNED), + object_id: Some(reference.object_id().to_string()), + version: Some(reference.version()), + digest: Some(reference.digest().to_string()), + ..Default::default() + }), + sdk::Input::Shared(shared) => { + let mutability = match shared.mutability() { + sdk::Mutability::Immutable => MUTABILITY_IMMUTABLE, + sdk::Mutability::Mutable => MUTABILITY_MUTABLE, + sdk::Mutability::NonExclusiveWrite => MUTABILITY_NON_EXCLUSIVE_WRITE, + }; + Ok(Self { + kind: Some(INPUT_KIND_SHARED), + object_id: Some(shared.object_id().to_string()), + version: Some(shared.version()), + mutable: Some(shared.mutability().is_mutable()), + mutability: Some(mutability), + ..Default::default() + }) + } + sdk::Input::Receiving(reference) => Ok(Self { + kind: Some(INPUT_KIND_RECEIVING), + object_id: Some(reference.object_id().to_string()), + version: Some(reference.version()), + digest: Some(reference.digest().to_string()), + ..Default::default() + }), + sdk::Input::FundsWithdrawal(withdrawal) => Ok(Self { + kind: Some(INPUT_KIND_FUNDS_WITHDRAWAL), + funds_withdrawal: Some(FundsWithdrawal::from_sdk(withdrawal)), + ..Default::default() + }), + _ => Err("unsupported Sui transaction input for protobuf encoding".into()), + } + } +} + +proto_encode!(Input { + 1 => kind: optional_varint_i32, + 2 => pure: optional_bytes, + 3 => object_id: optional_string, + 4 => version: optional_varint_u64, + 5 => digest: optional_string, + 6 => mutable: optional_bool, + 7 => mutability: optional_varint_i32, + 8 => funds_withdrawal: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct FundsWithdrawal { + pub amount: Option, + pub coin_type: Option, + pub source: Option, +} + +impl FundsWithdrawal { + fn from_sdk(value: sdk::FundsWithdrawal) -> Self { + let source = match value.source() { + sdk::WithdrawFrom::Sender => SOURCE_SENDER, + sdk::WithdrawFrom::Sponsor => SOURCE_SPONSOR, + _ => 0, + }; + Self { + amount: value.amount(), + coin_type: Some(value.coin_type().to_string()), + source: Some(source), + } + } +} + +proto_encode!(FundsWithdrawal { + 1 => amount: optional_varint_u64, + 2 => coin_type: optional_string, + 3 => source: optional_varint_i32, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/transaction_data/mod.rs b/core/crates/gem_sui/src/rpc/proto/transaction_data/mod.rs new file mode 100644 index 0000000000..f4115674c4 --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/transaction_data/mod.rs @@ -0,0 +1,11 @@ +mod argument; +mod command; +mod input; +mod signature; +mod transaction; + +pub use argument::Argument; +pub use command::{Command, MoveCall}; +pub use input::Input; +pub use signature::UserSignature; +pub use transaction::{ProgrammableTransaction, Transaction, TransactionKind}; diff --git a/core/crates/gem_sui/src/rpc/proto/transaction_data/signature.rs b/core/crates/gem_sui/src/rpc/proto/transaction_data/signature.rs new file mode 100644 index 0000000000..6896146d7d --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/transaction_data/signature.rs @@ -0,0 +1,27 @@ +use gem_encoding::protobuf::proto_encode; +use sui_types as sdk; + +use crate::rpc::proto::Bcs; + +// Field numbers mirror sui-rpc v0.3.1 signature schema: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/signature.proto + +#[derive(Clone, Debug, Default)] +pub struct UserSignature { + pub bcs: Option, + pub scheme: Option, +} + +impl UserSignature { + pub fn from_sdk(signature: sdk::UserSignature) -> Self { + Self { + bcs: Some(Bcs::new("UserSignatureBytes", signature.to_bytes())), + scheme: Some(signature.scheme().to_u8().into()), + } + } +} + +proto_encode!(UserSignature { + 1 => bcs: optional_message, + 2 => scheme: optional_varint_i32, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/transaction_data/transaction.rs b/core/crates/gem_sui/src/rpc/proto/transaction_data/transaction.rs new file mode 100644 index 0000000000..f04c39930c --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/transaction_data/transaction.rs @@ -0,0 +1,107 @@ +use gem_encoding::protobuf::{proto_decode, proto_encode}; +use sui_types as sdk; + +use super::{Command, Input}; +use crate::rpc::proto::{Bcs, MessageResult}; + +// Field numbers mirror sui-rpc v0.3.1 transaction data schema: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/transaction.proto + +const KIND_PROGRAMMABLE_TRANSACTION: i32 = 1; +const KIND_PROGRAMMABLE_SYSTEM_TRANSACTION: i32 = 11; + +#[derive(Clone, Debug, Default)] +pub struct Transaction { + pub bcs: Option, + pub kind: Option, + pub sender: Option, +} + +impl Transaction { + pub fn from_transaction_bcs(value: Vec) -> Self { + Self { + bcs: Some(Bcs::new("TransactionData", value)), + ..Default::default() + } + } + + pub fn from_kind(kind: TransactionKind, sender: &str) -> Self { + Self { + kind: Some(kind), + sender: Some(sender.to_string()), + ..Default::default() + } + } +} + +proto_encode!(Transaction { + 1 => bcs: optional_message, + 4 => kind: optional_message, + 5 => sender: optional_string, +}); + +proto_decode!(Transaction { + 4 => kind: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct TransactionKind { + pub kind: Option, + pub programmable_transaction: Option, +} + +impl TransactionKind { + pub fn programmable_transaction(transaction: ProgrammableTransaction) -> Self { + Self { + kind: Some(KIND_PROGRAMMABLE_TRANSACTION), + programmable_transaction: Some(transaction), + } + } + + pub fn from_sdk(value: sdk::TransactionKind) -> MessageResult { + match value { + sdk::TransactionKind::ProgrammableTransaction(transaction) => Ok(Self { + kind: Some(KIND_PROGRAMMABLE_TRANSACTION), + programmable_transaction: Some(ProgrammableTransaction::from_sdk(transaction)?), + }), + sdk::TransactionKind::ProgrammableSystemTransaction(transaction) => Ok(Self { + kind: Some(KIND_PROGRAMMABLE_SYSTEM_TRANSACTION), + programmable_transaction: Some(ProgrammableTransaction::from_sdk(transaction)?), + }), + _ => Err("unsupported Sui transaction kind for protobuf encoding".into()), + } + } +} + +proto_encode!(TransactionKind { + 1 => kind: optional_varint_i32, + 2 => programmable_transaction: optional_message, +}); + +proto_decode!(TransactionKind { + 2 => programmable_transaction: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct ProgrammableTransaction { + pub inputs: Vec, + pub commands: Vec, +} + +impl ProgrammableTransaction { + pub fn from_sdk(value: sdk::ProgrammableTransaction) -> MessageResult { + Ok(Self { + inputs: value.inputs.into_iter().map(Input::from_sdk).collect::>()?, + commands: value.commands.into_iter().map(Command::from_sdk).collect::>()?, + }) + } +} + +proto_encode!(ProgrammableTransaction { + 1 => inputs: repeated_message, + 2 => commands: repeated_message, +}); + +proto_decode!(ProgrammableTransaction { + 2 => commands: repeated_message, +}); diff --git a/core/crates/gem_sui/src/rpc/proto/transactions.rs b/core/crates/gem_sui/src/rpc/proto/transactions.rs new file mode 100644 index 0000000000..2b2928cb8c --- /dev/null +++ b/core/crates/gem_sui/src/rpc/proto/transactions.rs @@ -0,0 +1,308 @@ +use serde_json::Value; + +use super::json::decode_json_value; +use super::{Bcs, FieldMask, Owner, Status, Timestamp, Transaction, UserSignature}; +use gem_encoding::protobuf::{proto_decode, proto_encode}; + +// Field numbers mirror sui-rpc v0.3.1 transaction response schemas: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/ledger_service.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/transaction_execution_service.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/executed_transaction.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/effects.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/event.proto +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/balance_change.proto + +#[derive(Clone, Debug, Default)] +pub struct GetTransactionRequest { + pub digest: Option, + pub read_mask: Option, +} + +proto_encode!(GetTransactionRequest { + 1 => digest: optional_string, + 2 => read_mask: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct GetTransactionResponse { + pub transaction: Option, +} + +proto_decode!(GetTransactionResponse { + 1 => transaction: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct BatchGetTransactionsRequest { + pub digests: Vec, + pub read_mask: Option, +} + +proto_encode!(BatchGetTransactionsRequest { + 1 => digests: repeated_string, + 2 => read_mask: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct BatchGetTransactionsResponse { + pub transactions: Vec, +} + +proto_decode!(BatchGetTransactionsResponse { + 1 => transactions: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub enum GetTransactionResult { + Transaction(Box), + Error(Status), + #[default] + Unknown, +} + +proto_decode!(GetTransactionResult { + 1 => |value, field| *value = Self::Transaction(Box::new(field.message()?)), + 2 => |value, field| *value = Self::Error(field.message()?), +}); + +#[derive(Clone, Debug, Default)] +pub struct ExecuteTransactionRequest { + pub transaction: Option, + pub signatures: Vec, + pub read_mask: Option, +} + +proto_encode!(ExecuteTransactionRequest { + 1 => transaction: optional_message, + 2 => signatures: repeated_message, + 3 => read_mask: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct ExecuteTransactionResponse { + pub transaction: Option, +} + +proto_decode!(ExecuteTransactionResponse { + 1 => transaction: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct SimulateTransactionRequest { + pub transaction: Option, + pub read_mask: Option, + pub checks: Option, +} + +impl SimulateTransactionRequest { + pub fn new(transaction: Transaction) -> Self { + Self { + transaction: Some(transaction), + read_mask: None, + checks: None, + } + } +} + +proto_encode!(SimulateTransactionRequest { + 1 => transaction: optional_message, + 2 => read_mask: optional_message, + 3 => checks: optional_enum_varint, +}); + +#[derive(Clone, Copy, Debug)] +pub enum TransactionChecks { + Enabled = 0, + Disabled = 1, +} + +impl From for u64 { + fn from(value: TransactionChecks) -> Self { + value as u64 + } +} + +#[derive(Clone, Debug, Default)] +pub struct SimulateTransactionResponse { + pub transaction: Option, + pub command_outputs: Vec, +} + +proto_decode!(SimulateTransactionResponse { + 1 => transaction: optional_message, + 2 => command_outputs: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct CommandResult { + pub return_values: Vec, +} + +proto_decode!(CommandResult { + 1 => return_values: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct CommandOutput { + pub value: Option, +} + +proto_decode!(CommandOutput { + 2 => value: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct ExecutedTransaction { + pub digest: Option, + pub transaction: Option, + pub effects: Option, + pub events: Option, + pub timestamp: Option, + pub balance_changes: Vec, +} + +proto_decode!(ExecutedTransaction { + 1 => digest: optional_string, + 2 => transaction: optional_message, + 4 => effects: optional_message, + 5 => events: optional_message, + 7 => timestamp: optional_message, + 8 => balance_changes: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct TransactionEffects { + pub status: Option, + pub gas_used: Option, + pub gas_object: Option, +} + +proto_decode!(TransactionEffects { + 4 => status: optional_message, + 6 => gas_used: optional_message, + 8 => gas_object: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct ExecutionStatus { + pub success: Option, + pub error: Option, +} + +proto_decode!(ExecutionStatus { + 1 => success: optional_bool, + 2 => error: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct ExecutionError { + pub description: Option, +} + +proto_decode!(ExecutionError { + 1 => description: optional_string, +}); + +#[derive(Clone, Debug, Default)] +pub struct GasCostSummary { + pub computation_cost: Option, + pub storage_cost: Option, + pub storage_rebate: Option, + pub non_refundable_storage_fee: Option, +} + +proto_decode!(GasCostSummary { + 1 => computation_cost: optional_varint_u64, + 2 => storage_cost: optional_varint_u64, + 3 => storage_rebate: optional_varint_u64, + 4 => non_refundable_storage_fee: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +pub struct ChangedObject { + pub input_owner: Option, + pub output_owner: Option, +} + +proto_decode!(ChangedObject { + 5 => input_owner: optional_message, + 9 => output_owner: optional_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct BalanceChange { + pub address: Option, + pub coin_type: Option, + pub amount: Option, +} + +proto_decode!(BalanceChange { + 1 => address: optional_string, + 2 => coin_type: optional_string, + 3 => amount: optional_string, +}); + +#[derive(Clone, Debug, Default)] +pub struct TransactionEvents { + pub events: Vec, +} + +proto_decode!(TransactionEvents { + 3 => events: repeated_message, +}); + +#[derive(Clone, Debug, Default)] +pub struct Event { + pub package_id: Option, + pub event_type: Option, + pub json: Option, +} + +proto_decode!(Event { + 1 => |value, field| value.package_id = Some(field.string()?), + 4 => |value, field| value.event_type = Some(field.string()?), + 6 => |value, field| value.json = Some(decode_json_value(field.bytes()?)?), +}); + +#[cfg(test)] +mod tests { + use super::*; + use gem_encoding::protobuf::{MessageDecode, MessageEncode, encode_message_field, encode_string_field}; + + #[test] + fn test_execute_transaction_response_decode() { + let digest = "HgFLiBHYjYKhEh5NPY52Zm9bhwrs4W6AxE46gMU7nwhZ"; + let transaction = encode_string_field(1, digest); + let response = encode_message_field(1, &transaction); + + assert_eq!(ExecuteTransactionResponse::decode(&response).unwrap().transaction.unwrap().digest.unwrap(), digest); + } + + #[test] + fn test_request_wire_bytes() { + let transaction = Transaction::from_transaction_bcs(vec![1, 2, 3]); + let read_mask = FieldMask::from_paths(["digest"]); + let simulate = SimulateTransactionRequest { + transaction: Some(transaction.clone()), + read_mask: Some(read_mask.clone()), + checks: Some(TransactionChecks::Disabled), + }; + let execute = ExecuteTransactionRequest { + transaction: Some(transaction), + signatures: vec![UserSignature { + bcs: Some(Bcs::new("UserSignatureBytes", vec![0xaa, 0xbb])), + scheme: Some(0), + }], + read_mask: Some(read_mask), + }; + + assert_eq!( + hex::encode(simulate.encode()), + "0a180a160a0f5472616e73616374696f6e44617461120301020312080a066469676573741801" + ); + assert_eq!( + hex::encode(execute.encode()), + "0a180a160a0f5472616e73616374696f6e446174611203010203121c0a180a12557365725369676e617475726542797465731202aabb10001a080a06646967657374" + ); + } +} diff --git a/core/crates/gem_sui/src/rpc/staking.rs b/core/crates/gem_sui/src/rpc/staking.rs new file mode 100644 index 0000000000..6f1a0e922a --- /dev/null +++ b/core/crates/gem_sui/src/rpc/staking.rs @@ -0,0 +1,357 @@ +use std::{error::Error, str::FromStr}; + +use futures::future::try_join_all; +use num_bigint::BigUint; +use sui_types::Address; + +use super::client::{PATH_LIST_OWNED_OBJECTS, PATH_SIMULATE_TRANSACTION, SuiClient}; +use super::mapper::timestamp_millis; +use super::proto::{ + self as proto, Argument, FieldMask, Input, ListOwnedObjectsRequest, ListOwnedObjectsResponse, MoveCall, ProgrammableTransaction, SimulateTransactionRequest, + SimulateTransactionResponse, Transaction as GrpcTransaction, TransactionChecks, TransactionKind, WithMut, +}; +use crate::models::staking::{SuiStake, SuiStakeDelegation, SuiStakeStatus, SuiSystemState, SuiValidator, SuiValidators}; +use crate::{SUI_SYSTEM_ID, sui_system_package_address, sui_system_state_object_id}; + +const STAKED_SUI_TYPE: &str = "0x3::staking_pool::StakedSui"; +const STAKING_APY_EPOCH_COUNT: u64 = 5; +const STAKING_APY_READ_MASK: &[&str] = &[ + "epoch", + "system_state.parameters.stake_subsidy_start_epoch", + "system_state.validators.active_validators.address", + "system_state.validators.active_validators.staking_pool.id", + "system_state.validators.active_validators.staking_pool.sui_balance", + "system_state.validators.active_validators.staking_pool.pool_token_balance", +]; + +#[derive(Debug, serde::Deserialize)] +struct StakedSuiObject { + id: Address, + pool_id: Address, + stake_activation_epoch: u64, + principal: u64, +} + +struct DelegatedStake { + staked_sui_id: String, + validator_address: String, + staking_pool: String, + activation_epoch: u64, + principal: u64, + rewards: u64, +} + +impl SuiClient { + pub async fn get_stake_delegations(&self, address: String) -> Result, Box> { + let address = Address::from_str(&address)?; + let delegations = self.list_delegated_stake(&address).await?; + Ok(delegations + .into_iter() + .map(|delegation| { + let rewards = delegation.rewards; + SuiStakeDelegation { + validator_address: delegation.validator_address, + staking_pool: delegation.staking_pool, + stakes: vec![SuiStake { + staked_sui_id: delegation.staked_sui_id, + status: SuiStakeStatus::Active, + principal: BigUint::from(delegation.principal), + stake_request_epoch: delegation.activation_epoch.to_string(), + stake_active_epoch: delegation.activation_epoch.to_string(), + estimated_reward: Some(BigUint::from(rewards)), + }], + } + }) + .collect()) + } + + pub async fn get_validators(&self) -> Result> { + let epoch = self.get_epoch(None, Some(FieldMask::from_paths(["system_state.validators"]))).await?; + let apys = epoch + .system_state + .and_then(|state| state.validators) + .map(|validators| { + validators + .active_validators + .into_iter() + .filter_map(|validator| { + Some(SuiValidator { + address: validator.address?, + apy: 0.0, + }) + }) + .collect() + }) + .unwrap_or_default(); + Ok(SuiValidators { apys }) + } + + pub(crate) async fn get_validator_apys(&self) -> Result> { + let read_mask = FieldMask::from_paths(STAKING_APY_READ_MASK); + let latest_epoch = self.get_epoch(None, Some(read_mask.clone())).await?; + let current_epoch = latest_epoch.epoch; + let stake_subsidy_start_epoch = latest_epoch + .system_state + .as_ref() + .and_then(|state| state.parameters.as_ref()) + .and_then(|parameters| parameters.stake_subsidy_start_epoch) + .unwrap_or_default(); + + let start_epoch = current_epoch.saturating_sub(STAKING_APY_EPOCH_COUNT - 1).max(stake_subsidy_start_epoch); + let previous_epochs = try_join_all((start_epoch..current_epoch).rev().map(|epoch| self.get_epoch(Some(epoch), Some(read_mask.clone())))).await?; + let epochs = std::iter::once(latest_epoch).chain(previous_epochs).collect::>(); + + map_validator_apys(&epochs) + } + + pub async fn get_system_state(&self) -> Result> { + let epoch = self.get_epoch(None, Some(FieldMask::from_paths(["epoch", "start", "end"]))).await?; + let start_ms = epoch.start.as_ref().map(timestamp_millis).unwrap_or_default().to_string(); + let duration_ms = match (epoch.start.as_ref(), epoch.end.as_ref()) { + (Some(start), Some(end)) => (timestamp_millis(end) - timestamp_millis(start)).max(0).to_string(), + _ => "0".to_string(), + }; + Ok(SuiSystemState { + epoch: epoch.epoch.to_string(), + epoch_start_timestamp_ms: start_ms, + epoch_duration_ms: duration_ms, + }) + } + + async fn list_delegated_stake(&self, address: &Address) -> Result, Box> { + let mut request = ListOwnedObjectsRequest { + owner: Some(address.to_string()), + page_size: Some(500), + read_mask: Some(FieldMask::from_path_string("contents")), + object_type: Some(STAKED_SUI_TYPE.to_string()), + ..Default::default() + }; + let mut objects = Vec::new(); + + loop { + let response: ListOwnedObjectsResponse = self.grpc_unary(PATH_LIST_OWNED_OBJECTS, request.clone()).await?; + objects.extend(response.objects); + if response.next_page_token.is_none() { + break; + } + request.page_token = response.next_page_token; + } + + self.create_delegated_stakes(objects).await + } + + async fn create_delegated_stakes(&self, objects: Vec) -> Result, Box> { + let staked_sui = objects + .into_iter() + .map(|object| { + object + .contents + .ok_or("missing Sui staked object contents")? + .deserialize::() + .map_err(|error| error.into()) + }) + .collect::, Box>>()?; + let ids = staked_sui.iter().map(|stake| stake.id).collect::>(); + let pool_ids = staked_sui.iter().map(|stake| stake.pool_id).collect::>(); + let rewards = self.calculate_rewards(&ids).await?; + let validator_addresses = self.get_validator_address_by_pool_id(&pool_ids).await?; + + Ok(staked_sui + .into_iter() + .zip(rewards) + .zip(validator_addresses) + .map(|((stake, (_id, rewards)), (_pool_id, validator_address))| DelegatedStake { + staked_sui_id: stake.id.to_string(), + validator_address: validator_address.to_string(), + staking_pool: stake.pool_id.to_string(), + activation_epoch: stake.stake_activation_epoch, + principal: stake.principal, + rewards, + }) + .collect()) + } + + async fn calculate_rewards(&self, staked_sui_ids: &[Address]) -> Result, Box> { + let mut ptb = ProgrammableTransaction { + inputs: vec![Input::object_id(sui_system_state_object_id())], + ..Default::default() + }; + let system_object = Argument::new_input(0); + + for id in staked_sui_ids { + let staked_sui = Argument::new_input(ptb.inputs.len() as u16); + ptb.inputs.push(Input::object_id(id)); + ptb.commands + .push(MoveCall::from_parts(sui_system_package_address(), SUI_SYSTEM_ID, "calculate_rewards", vec![system_object, staked_sui]).into()); + } + + let response = self.simulate_staking_transaction(ptb).await?; + if staked_sui_ids.len() != response.command_outputs.len() { + return Err("missing Sui rewards command outputs".into()); + } + + staked_sui_ids + .iter() + .zip(response.command_outputs) + .map(|(id, output)| { + let rewards = output.return_values.first().and_then(|value| value.value.as_ref()).ok_or("missing Sui rewards BCS value")?; + if rewards.name.as_deref() != Some("u64") || rewards.value.as_ref().map(|value| value.len()) != Some(size_of::()) { + return Err("invalid Sui rewards BCS value".into()); + } + let value = rewards.value.as_ref().ok_or("missing Sui rewards bytes")?; + let bytes: [u8; size_of::()] = value.as_slice().try_into()?; + Ok((*id, u64::from_le_bytes(bytes))) + }) + .collect() + } + + async fn get_validator_address_by_pool_id(&self, pool_ids: &[Address]) -> Result, Box> { + let mut ptb = ProgrammableTransaction { + inputs: vec![Input::object_id(sui_system_state_object_id())], + ..Default::default() + }; + let system_object = Argument::new_input(0); + + for id in pool_ids { + let pool_id = Argument::new_input(ptb.inputs.len() as u16); + ptb.inputs.push(Input::pure(id.into_inner().to_vec())); + ptb.commands + .push(MoveCall::from_parts(sui_system_package_address(), SUI_SYSTEM_ID, "validator_address_by_pool_id", vec![system_object, pool_id]).into()); + } + + let response = self.simulate_staking_transaction(ptb).await?; + if pool_ids.len() != response.command_outputs.len() { + return Err("missing Sui validator address command outputs".into()); + } + + pool_ids + .iter() + .zip(response.command_outputs) + .map(|(id, output)| { + let address = output + .return_values + .first() + .and_then(|value| value.value.as_ref()) + .ok_or("missing Sui validator address BCS value")?; + if address.name.as_deref() != Some("address") || address.value.as_ref().map(|value| value.len()) != Some(Address::LENGTH) { + return Err("invalid Sui validator address BCS value".into()); + } + let value = address.value.as_ref().ok_or("missing Sui validator address bytes")?; + Ok((*id, Address::from_bytes(value)?)) + }) + .collect() + } + + async fn simulate_staking_transaction(&self, ptb: ProgrammableTransaction) -> Result> { + let transaction = GrpcTransaction::from_kind(TransactionKind::programmable_transaction(ptb), "0x0"); + let request = SimulateTransactionRequest::new(transaction).with(|request| { + request.read_mask = Some(FieldMask::from_paths(["command_outputs.return_values.value", "transaction.effects.status"])); + request.checks = Some(TransactionChecks::Disabled); + }); + let response: SimulateTransactionResponse = self.grpc_unary(PATH_SIMULATE_TRANSACTION, request).await?; + if !response + .transaction + .as_ref() + .and_then(|transaction| transaction.effects.as_ref()) + .and_then(|effects| effects.status.as_ref()) + .and_then(|status| status.success) + .unwrap_or(false) + { + return Err("Sui staking transaction simulation failed".into()); + } + Ok(response) + } +} + +fn map_validator_apys(epochs: &[proto::Epoch]) -> Result> { + let latest_validators = epochs + .first() + .ok_or("missing Sui epoch")? + .system_state + .as_ref() + .and_then(|state| state.validators.as_ref()) + .ok_or("missing Sui validators")?; + + let apys = latest_validators + .active_validators + .iter() + .filter_map(|validator| { + let address = validator.address.clone()?; + let pool_id = validator.staking_pool.as_ref()?.id.as_deref()?; + let (total_apy, count) = epochs + .windows(2) + .filter_map(|window| { + let current_rate = validator_pool_rate(&window[0], pool_id)?; + let previous_rate = validator_pool_rate(&window[1], pool_id)?; + let apy = if current_rate > 0.0 { (previous_rate / current_rate).powf(365.0) - 1.0 } else { 0.0 }; + (apy.is_finite() && apy > 0.0 && apy < 0.1).then_some(apy) + }) + .fold((0.0, 0), |(total, count), apy| (total + apy, count + 1)); + + let apy = if count == 0 { 0.0 } else { total_apy / count as f64 }; + Some(SuiValidator { address, apy }) + }) + .collect(); + + Ok(SuiValidators { apys }) +} + +fn validator_pool_rate(epoch: &proto::Epoch, pool_id: &str) -> Option { + let pool = epoch.system_state.as_ref()?.validators.as_ref()?.active_validators.iter().find_map(|validator| { + let pool = validator.staking_pool.as_ref()?; + (pool.id.as_deref() == Some(pool_id)).then_some(pool) + })?; + + match (pool.sui_balance, pool.pool_token_balance) { + (Some(0), Some(_)) => Some(1.0), + (Some(sui_balance), Some(pool_token_balance)) => Some(pool_token_balance as f64 / sui_balance as f64), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::staking_mapper; + + const APY: f64 = 0.01484661182599185; + const POOL_ID: &str = "pool"; + const SCALE: u64 = 1_000_000_000_000_000; + const VALIDATOR_ADDRESS: &str = "validator"; + + #[test] + fn test_map_validator_apys_from_grpc_epoch_snapshots() { + let validators = map_validator_apys(&[ + proto::Epoch::mock_with_validator_rate(100, 1.0), + proto::Epoch::mock_with_validator_rate(99, (1.0 + APY).powf(1.0 / 365.0)), + ]) + .unwrap(); + + assert_eq!(validators.apys.len(), 1); + assert_eq!(validators.apys[0].address, VALIDATOR_ADDRESS); + assert!((validators.apys[0].apy - APY).abs() < 0.000000000001); + assert!((staking_mapper::map_staking_apy(validators).unwrap() - APY * 100.0).abs() < 0.000000001); + } + + impl proto::Epoch { + fn mock_with_validator_rate(epoch: u64, rate: f64) -> Self { + Self { + epoch, + system_state: Some(proto::service::SystemState { + validators: Some(proto::service::ValidatorSet { + active_validators: vec![proto::service::Validator { + address: Some(VALIDATOR_ADDRESS.to_string()), + staking_pool: Some(proto::service::StakingPool { + id: Some(POOL_ID.to_string()), + sui_balance: Some(SCALE), + pool_token_balance: Some((SCALE as f64 * rate).round() as u64), + }), + }], + }), + parameters: None, + }), + ..Default::default() + } + } + } +} diff --git a/core/crates/gem_sui/src/rpc/transport.rs b/core/crates/gem_sui/src/rpc/transport.rs new file mode 100644 index 0000000000..262b6e67fe --- /dev/null +++ b/core/crates/gem_sui/src/rpc/transport.rs @@ -0,0 +1,15 @@ +use std::sync::Arc; + +use gem_jsonrpc::grpc::GrpcTransport; +#[cfg(feature = "reqwest")] +use gem_jsonrpc::grpc::ReqwestGrpcTransport; + +#[cfg(feature = "reqwest")] +pub(super) fn default_transport() -> Option> { + Some(Arc::new(ReqwestGrpcTransport::new())) +} + +#[cfg(not(feature = "reqwest"))] +pub(super) fn default_transport() -> Option> { + None +} diff --git a/core/crates/gem_sui/src/signer/chain_signer.rs b/core/crates/gem_sui/src/signer/chain_signer.rs new file mode 100644 index 0000000000..6fbdbd9d10 --- /dev/null +++ b/core/crates/gem_sui/src/signer/chain_signer.rs @@ -0,0 +1,57 @@ +use primitives::{ChainSigner, SignerError, SignerInput, TransactionInputType, decode_hex, stake_type::StakeType}; + +use super::signature::{sign_digest, sign_personal_message}; + +#[derive(Default)] +pub struct SuiChainSigner; + +impl ChainSigner for SuiChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_from_metadata(input, private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_from_metadata(input, private_key) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + sign_from_metadata(input, private_key).map(|signature| vec![signature]) + } + + fn sign_stake(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + match &input.input_type { + TransactionInputType::Stake(_, stake_type) => match stake_type { + StakeType::Stake(_) | StakeType::Unstake(_) => {} + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) => { + return Err(SignerError::SigningError("Sui signer does not support this staking operation yet".to_string())); + } + StakeType::Freeze(_) | StakeType::Unfreeze(_) => return Err(SignerError::InvalidInput("Sui does not support freeze operations".to_string())), + }, + _ => return Err(SignerError::InvalidInput("Expected stake transaction".to_string())), + } + sign_from_metadata(input, private_key).map(|signature| vec![signature]) + } + + fn sign_data(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_from_metadata(input, private_key) + } + + fn sign_message(&self, message: &[u8], private_key: &[u8]) -> Result { + sign_personal_message(message, private_key) + } +} + +fn sign_from_metadata(input: &SignerInput, private_key: &[u8]) -> Result { + let message_bytes = input.metadata.get_message_bytes()?; + sign_message_bytes(&message_bytes, private_key) +} + +fn sign_message_bytes(message: &str, private_key: &[u8]) -> Result { + let (prefix, digest_hex) = message.split_once('_').ok_or_else(|| SignerError::InvalidInput("Invalid Sui digest payload".to_string()))?; + + let digest = decode_hex(digest_hex).map_err(|_| SignerError::InvalidInput("Invalid digest hex for Sui transaction".to_string()))?; + + let signature = sign_digest(&digest, private_key)?; + + Ok(format!("{prefix}_{signature}")) +} diff --git a/core/crates/gem_sui/src/signer/mod.rs b/core/crates/gem_sui/src/signer/mod.rs new file mode 100644 index 0000000000..2172242104 --- /dev/null +++ b/core/crates/gem_sui/src/signer/mod.rs @@ -0,0 +1,5 @@ +mod chain_signer; +mod signature; + +pub use chain_signer::SuiChainSigner; +pub use signature::{SUI_PERSONAL_MESSAGE_SIGNATURE_LEN, sign_digest, sign_personal_message}; diff --git a/core/crates/gem_sui/src/signer/signature.rs b/core/crates/gem_sui/src/signer/signature.rs new file mode 100644 index 0000000000..e02a1a2f80 --- /dev/null +++ b/core/crates/gem_sui/src/signer/signature.rs @@ -0,0 +1,62 @@ +use std::borrow::Cow; + +use primitives::SignerError; +use signer::Ed25519KeyPair; +use sui_types::{Ed25519PublicKey, Ed25519Signature, PersonalMessage, SimpleSignature, UserSignature}; + +/// 1-byte flag + 64-byte signature + 32-byte public key. +pub const SUI_PERSONAL_MESSAGE_SIGNATURE_LEN: usize = 1 + Ed25519Signature::LENGTH + Ed25519PublicKey::LENGTH; + +pub fn sign_personal_message(message: &[u8], private_key: &[u8]) -> Result { + let personal_message = PersonalMessage(Cow::Borrowed(message)); + let digest = personal_message.signing_digest(); + sign_digest(digest.as_ref(), private_key) +} + +pub fn sign_digest(digest: &[u8], private_key: &[u8]) -> Result { + let key_pair = Ed25519KeyPair::from_private_key(private_key)?; + assemble_signature(&key_pair.sign(digest), &key_pair.public_key_bytes) +} + +fn assemble_signature(signature: &[u8], public_key: &[u8]) -> Result { + let signature_bytes: [u8; Ed25519Signature::LENGTH] = signature + .try_into() + .map_err(|_| SignerError::InvalidInput(format!("Expected {} byte ed25519 signature, got {}", Ed25519Signature::LENGTH, signature.len())))?; + let public_key_bytes: [u8; Ed25519PublicKey::LENGTH] = public_key + .try_into() + .map_err(|_| SignerError::InvalidInput(format!("Expected {} byte ed25519 public key, got {}", Ed25519PublicKey::LENGTH, public_key.len())))?; + + let sui_signature = SimpleSignature::Ed25519 { + signature: Ed25519Signature::new(signature_bytes), + public_key: Ed25519PublicKey::new(public_key_bytes), + }; + + Ok(UserSignature::Simple(sui_signature).to_base64()) +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_encoding::decode_base64; + + #[test] + fn test_sui_sign_personal_message() { + let private_key = hex::decode("1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34").expect("valid hex"); + let message = b"Hello, world!".to_vec(); + + let signature_base64 = sign_personal_message(&message, &private_key).expect("signing succeeds"); + + let signature_bytes = decode_base64(&signature_base64).unwrap(); + assert_eq!(signature_bytes.len(), SUI_PERSONAL_MESSAGE_SIGNATURE_LEN, "signature layout length"); + assert_eq!(signature_bytes[0], 0x00, "expected Ed25519 flag prefix"); + + let expected_base64 = "ALmKZNcvdmYgYloqKMAq7eSw5neV1mSEKfZProHEh8Ddw+6aJvLpuViFqZCHqwKdCqtzN8a+7jIDQSxbvmt04QDTaUUhl8KlZIHl4tPovwPeI0n2emMVGVaCIgjCM0re4g=="; + assert_eq!(signature_base64, expected_base64); + } + + #[test] + fn test_sign_digest_rejects_invalid_key_length() { + let result = sign_digest(&[0u8; 32], &[0u8; 16]); + assert!(result.is_err()); + } +} diff --git a/core/crates/gem_sui/src/transfer_builder.rs b/core/crates/gem_sui/src/transfer_builder.rs new file mode 100644 index 0000000000..887140dae0 --- /dev/null +++ b/core/crates/gem_sui/src/transfer_builder.rs @@ -0,0 +1,112 @@ +use crate::{ + ESTIMATION_GAS_BUDGET, SUI_COIN_TYPE, SuiClient, + gas_budget::GAS_BUDGET_MULTIPLIER, + models::{Coin, Gas, OwnedCoins, TokenTransferInput, TransferInput}, + tx_builder::{encode_token_transfer, encode_transfer}, +}; +use futures::try_join; +use num_traits::ToPrimitive; +use std::error::Error; + +#[allow(clippy::too_many_arguments)] +pub async fn build_transfer_message_bytes( + client: &SuiClient, + sender: &str, + recipient: &str, + amount: u64, + token_type: Option<&str>, +) -> Result> { + let (gas_price_bigint, sui_coins) = try_join!(client.get_gas_price(), client.get_coins(sender, SUI_COIN_TYPE))?; + + let gas_price = gas_price_bigint + .to_u64() + .ok_or_else(|| format!("Failed to convert Sui gas price to u64: {gas_price_bigint}"))?; + + if sui_coins.coins.is_empty() { + return Err("No SUI coins available for gas budget".into()); + } + + let token_coins = match token_type { + None => None, + Some(token_type) => Some(get_token_coins(client, sender, token_type).await?), + }; + + let estimate_output = build_tx_output(sender, recipient, amount, &sui_coins, token_coins.as_ref(), ESTIMATION_GAS_BUDGET, gas_price)?; + let dry_run_result = client.dry_run(estimate_output.base64_encoded()).await?; + let fee = dry_run_result.effects.gas_used.calculate_gas_budget()?; + let gas_budget = fee * GAS_BUDGET_MULTIPLIER / 100; + + let tx_output = build_tx_output(sender, recipient, amount, &sui_coins, token_coins.as_ref(), gas_budget, gas_price)?; + Ok(tx_output.base64_encoded()) +} + +async fn get_token_coins(client: &SuiClient, sender: &str, token_type: &str) -> Result, Box> { + let owned = client.get_coins(sender, token_type).await?; + if owned.coins.is_empty() && owned.address_balance == 0 { + return Err(format!("No coins or address balance found for token type {token_type}").into()); + } + Ok(owned) +} + +fn build_tx_output( + sender: &str, + recipient: &str, + amount: u64, + sui_coins: &OwnedCoins, + token_coins: Option<&OwnedCoins>, + gas_budget: u64, + gas_price: u64, +) -> Result> { + let gas = Gas { + budget: gas_budget, + price: gas_price, + }; + + match token_coins { + Some(tokens) => { + let token_transfer_input = TokenTransferInput { + sender: sender.to_string(), + recipient: recipient.to_string(), + amount, + tokens: tokens.clone(), + gas, + gas_coin: sui_coins.coins.first().unwrap().clone(), + }; + encode_token_transfer(&token_transfer_input) + } + None => { + let transfer_input = TransferInput { + sender: sender.to_string(), + recipient: recipient.to_string(), + amount, + coins: sui_coins.clone(), + send_max: false, + gas, + }; + encode_transfer(&transfer_input) + } + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_TOKEN_ADDRESS, create_sui_test_client}; + use gem_encoding::decode_base64; + + #[tokio::test] + async fn test_build_transfer_message_bytes_native() -> Result<(), Box> { + let client = create_sui_test_client(); + let message = build_transfer_message_bytes(&client, TEST_ADDRESS, TEST_ADDRESS, 1, None).await?; + decode_base64(&message)?; + Ok(()) + } + + #[tokio::test] + async fn test_build_transfer_message_bytes_token() -> Result<(), Box> { + let client = create_sui_test_client(); + let message = build_transfer_message_bytes(&client, TEST_ADDRESS, TEST_ADDRESS, 1, Some(TEST_TOKEN_ADDRESS)).await?; + decode_base64(&message)?; + Ok(()) + } +} diff --git a/core/crates/gem_sui/src/tx_builder/balance.rs b/core/crates/gem_sui/src/tx_builder/balance.rs new file mode 100644 index 0000000000..f8e3830ef2 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/balance.rs @@ -0,0 +1,38 @@ +use crate::{SuiError, sui_framework_package_address, tx_builder::move_call}; +use sui_transaction_builder::{Argument, TransactionBuilder}; + +const MODULE_COIN: &str = "coin"; +const MODULE_BALANCE: &str = "balance"; +const FUNCTION_INTO_BALANCE: &str = "into_balance"; +const FUNCTION_FROM_BALANCE: &str = "from_balance"; +const FUNCTION_BALANCE_ZERO: &str = "zero"; +const FUNCTION_BALANCE_VALUE: &str = "value"; +const FUNCTION_BALANCE_DESTROY_ZERO: &str = "destroy_zero"; + +pub fn into_balance(txb: &mut TransactionBuilder, coin_type: &str, coin: Argument) -> Result { + move_call(txb, sui_framework_package_address(), MODULE_COIN, FUNCTION_INTO_BALANCE, &[coin_type], vec![coin]) +} + +pub fn from_balance(txb: &mut TransactionBuilder, coin_type: &str, balance: Argument) -> Result { + move_call(txb, sui_framework_package_address(), MODULE_COIN, FUNCTION_FROM_BALANCE, &[coin_type], vec![balance]) +} + +pub fn balance_zero(txb: &mut TransactionBuilder, coin_type: &str) -> Result { + move_call(txb, sui_framework_package_address(), MODULE_BALANCE, FUNCTION_BALANCE_ZERO, &[coin_type], vec![]) +} + +pub fn balance_value(txb: &mut TransactionBuilder, coin_type: &str, balance: Argument) -> Result { + move_call(txb, sui_framework_package_address(), MODULE_BALANCE, FUNCTION_BALANCE_VALUE, &[coin_type], vec![balance]) +} + +pub fn destroy_zero_balance(txb: &mut TransactionBuilder, coin_type: &str, balance: Argument) -> Result<(), SuiError> { + move_call( + txb, + sui_framework_package_address(), + MODULE_BALANCE, + FUNCTION_BALANCE_DESTROY_ZERO, + &[coin_type], + vec![balance], + )?; + Ok(()) +} diff --git a/core/crates/gem_sui/src/tx_builder/input.rs b/core/crates/gem_sui/src/tx_builder/input.rs new file mode 100644 index 0000000000..b19217cd59 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/input.rs @@ -0,0 +1,52 @@ +#[cfg(feature = "rpc")] +use crate::{SUI_COIN_TYPE, SuiClient, SuiError, models::Coin}; +#[cfg(feature = "rpc")] +use futures::try_join; +#[cfg(feature = "rpc")] +use num_traits::ToPrimitive; +use sui_transaction_builder::ObjectInput; + +#[derive(Clone)] +pub struct TransactionBuilderInput { + pub sender: String, + pub gas_price: u64, + pub gas_budget: u64, + pub gas_objects: Vec, +} + +impl TransactionBuilderInput { + pub fn new(sender: impl Into, gas_price: u64, gas_budget: u64, gas_objects: Vec) -> Self { + Self { + sender: sender.into(), + gas_price, + gas_budget, + gas_objects, + } + } + + pub fn with_gas_budget(&self, gas_budget: u64) -> Self { + let mut input = self.clone(); + input.gas_budget = gas_budget; + input + } + + #[cfg(feature = "rpc")] + pub async fn prefetch(client: &SuiClient, sender: &str, gas_budget: u64) -> Result { + let gas_price = async { + client + .get_gas_price() + .await + .map_err(SuiError::from_display)? + .to_u64() + .ok_or_else(|| SuiError::invalid_input("Sui gas price overflow")) + }; + let gas_coins = async { client.get_coins(sender, SUI_COIN_TYPE).await.map(|owned| owned.coins).map_err(SuiError::from_display) }; + let (gas_price, gas_coins) = try_join!(gas_price, gas_coins)?; + if gas_coins.is_empty() { + return Err(SuiError::NoGasCoins); + } + let gas_objects = gas_coins.iter().map(Coin::to_input).collect(); + + Ok(Self::new(sender, gas_price, gas_budget, gas_objects)) + } +} diff --git a/core/crates/gem_sui/src/tx_builder/mod.rs b/core/crates/gem_sui/src/tx_builder/mod.rs new file mode 100644 index 0000000000..2bb45af666 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/mod.rs @@ -0,0 +1,23 @@ +pub mod balance; +mod input; +#[cfg(feature = "rpc")] +pub(crate) mod object_resolver; +#[cfg(feature = "rpc")] +mod prefetch; +pub mod stake; +mod transaction; +pub mod transaction_json; +pub mod transfer; + +pub use balance::{balance_value, balance_zero, destroy_zero_balance, from_balance, into_balance}; +pub use input::TransactionBuilderInput; +#[cfg(feature = "rpc")] +pub use object_resolver::{ObjectResolver, ResolvedObjectInput}; +#[cfg(feature = "rpc")] +pub use prefetch::PrefetchedTransactionData; +pub use stake::*; +pub(crate) use transaction::build_amount_coin; +pub use transaction::{build_input_coin, decode_transaction, finish_transaction, move_call, validate_and_hash, zero_coin}; +#[cfg(feature = "rpc")] +pub use transaction_json::{ReplayedTransaction, TransactionJsonReplay, prepare_transaction_json_replay, replay_transaction_json}; +pub use transfer::*; diff --git a/core/crates/gem_sui/src/tx_builder/object_resolver.rs b/core/crates/gem_sui/src/tx_builder/object_resolver.rs new file mode 100644 index 0000000000..11482d06b3 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/object_resolver.rs @@ -0,0 +1,129 @@ +use crate::rpc::proto::{Object, OwnerKind}; +use crate::{SuiClient, SuiError}; +use std::{ + collections::{BTreeSet, HashMap}, + str::FromStr, +}; +use sui_transaction_builder::{Argument, ObjectInput, TransactionBuilder}; +use sui_types::{Address, Digest}; + +#[derive(Clone, Debug)] +pub struct ResolvedObjectInput { + object_id: Address, + owner: ResolvedObjectOwner, +} + +#[derive(Clone, Debug)] +enum ResolvedObjectOwner { + Shared { initial_shared_version: u64 }, + Owned { version: u64, digest: Digest }, + Immutable { version: u64, digest: Digest }, +} + +impl ResolvedObjectInput { + pub async fn get_multiple(client: &SuiClient, object_ids: Vec) -> Result, SuiError> { + client + .get_multiple_objects(object_ids) + .await + .map_err(SuiError::from_display)? + .into_iter() + .map(Self::from_rpc_object) + .collect() + } + + pub(crate) fn from_rpc_object(object: Object) -> Result { + let object_id = object.object_id.ok_or_else(|| SuiError::invalid_input("missing Sui object id"))?; + let owner = object.owner.ok_or_else(|| SuiError::invalid_input(format!("Sui object owner is missing: {object_id}")))?; + let owner_kind = owner.kind(); + let object_id = Address::from_str(&object_id).map_err(|err| SuiError::invalid_input(format!("Invalid Sui object id {object_id}: {err}")))?; + + let owner = match owner_kind { + OwnerKind::Shared => ResolvedObjectOwner::Shared { + initial_shared_version: owner + .version + .ok_or_else(|| SuiError::invalid_input(format!("Sui shared object version is missing: {object_id}")))?, + }, + OwnerKind::Address | OwnerKind::Object => ResolvedObjectOwner::Owned { + version: object + .version + .ok_or_else(|| SuiError::invalid_input(format!("Sui object version is missing: {object_id}")))?, + digest: digest(object.digest, object_id)?, + }, + OwnerKind::Immutable => ResolvedObjectOwner::Immutable { + version: object + .version + .ok_or_else(|| SuiError::invalid_input(format!("Sui object version is missing: {object_id}")))?, + digest: digest(object.digest, object_id)?, + }, + OwnerKind::Unknown | OwnerKind::ConsensusAddress => { + return Err(SuiError::invalid_input(format!("Unsupported Sui object owner kind for {object_id}: {owner_kind:?}"))); + } + }; + Ok(Self { object_id, owner }) + } + + pub fn input(&self, mutable: bool) -> ObjectInput { + match &self.owner { + ResolvedObjectOwner::Shared { initial_shared_version } => ObjectInput::shared(self.object_id, *initial_shared_version, mutable), + ResolvedObjectOwner::Owned { version, digest } => ObjectInput::owned(self.object_id, *version, *digest), + ResolvedObjectOwner::Immutable { version, digest } => ObjectInput::immutable(self.object_id, *version, *digest), + } + } +} + +fn digest(value: Option, object_id: Address) -> Result { + let value = value.ok_or_else(|| SuiError::invalid_input(format!("Sui object digest is missing: {object_id}")))?; + Digest::from_str(&value).map_err(|err| SuiError::invalid_input(format!("Invalid Sui object digest for {object_id}: {err}"))) +} + +pub struct ObjectResolver { + shared_versions: HashMap, +} + +impl ObjectResolver { + pub async fn prefetch(client: &SuiClient, object_ids: Vec, pinned: &HashMap) -> Result { + let unique_ids: Vec = object_ids.into_iter().collect::>().into_iter().collect(); + let missing: Vec = unique_ids.iter().filter(|id| !pinned.contains_key(*id)).cloned().collect(); + + let fetched = if missing.is_empty() { + Vec::new() + } else { + client.get_multiple_objects(missing).await.map_err(SuiError::from_display)? + }; + + let mut shared_versions: HashMap = fetched + .into_iter() + .filter_map(|object| { + let id = object.object_id?; + object.owner.and_then(|owner| match owner.kind() { + OwnerKind::Shared => owner.version.map(|version| (id, version)), + _ => None, + }) + }) + .collect(); + for id in &unique_ids { + if let Some(&version) = pinned.get(id) { + shared_versions.insert(id.clone(), version); + } + } + Ok(Self { shared_versions }) + } + + pub fn initial_shared_version(&self, object_id: &str) -> Option { + self.shared_versions.get(object_id).copied() + } + + pub fn shared_object_input(&self, object_id: &str, mutable: bool) -> Result { + let version = self + .shared_versions + .get(object_id) + .copied() + .ok_or_else(|| SuiError::invalid_input(format!("Sui shared object was not prefetched: {object_id}")))?; + let address = Address::from_str(object_id).map_err(|err| SuiError::invalid_input(format!("Invalid Sui address {object_id}: {err}")))?; + Ok(ObjectInput::shared(address, version, mutable)) + } + + pub fn shared_object(&self, txb: &mut TransactionBuilder, object_id: &str, mutable: bool) -> Result { + Ok(txb.object(self.shared_object_input(object_id, mutable)?)) + } +} diff --git a/core/crates/gem_sui/src/tx_builder/prefetch.rs b/core/crates/gem_sui/src/tx_builder/prefetch.rs new file mode 100644 index 0000000000..2b9f90cb40 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/prefetch.rs @@ -0,0 +1,54 @@ +use super::{ObjectResolver, TransactionBuilderInput}; +use crate::{ + SuiClient, SuiError, is_sui_coin, + models::{Coin, OwnedCoins}, +}; +use futures::try_join; +use std::collections::HashMap; + +pub struct PrefetchedTransactionData { + pub transaction: TransactionBuilderInput, + pub input_coins: OwnedCoins, + pub output_coin: Option, + pub resolver: ObjectResolver, +} + +impl PrefetchedTransactionData { + pub async fn prefetch( + client: &SuiClient, + sender: &str, + input_coin_type: &str, + output_coin_type: Option<&str>, + object_ids: Vec, + pinned: &HashMap, + gas_budget: u64, + ) -> Result { + let output_coins_fut = async { + match output_coin_type { + Some(coin_type) => get_user_coins(client, sender, coin_type).await, + None => Ok(OwnedCoins::default()), + } + }; + let (transaction, input_coins, output_owned, resolver) = try_join!( + TransactionBuilderInput::prefetch(client, sender, gas_budget), + get_user_coins(client, sender, input_coin_type), + output_coins_fut, + ObjectResolver::prefetch(client, object_ids, pinned), + )?; + + Ok(Self { + transaction, + input_coins, + output_coin: output_owned.coins.into_iter().next(), + resolver, + }) + } +} + +async fn get_user_coins(client: &SuiClient, owner: &str, coin_type: &str) -> Result, SuiError> { + if is_sui_coin(coin_type) { + Ok(OwnedCoins::default()) + } else { + client.get_coins(owner, coin_type).await.map_err(SuiError::from_display) + } +} diff --git a/core/crates/gem_sui/src/tx_builder/stake.rs b/core/crates/gem_sui/src/tx_builder/stake.rs new file mode 100644 index 0000000000..efb6f084cf --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/stake.rs @@ -0,0 +1,173 @@ +use crate::{ + SUI_SYSTEM_ID, SUI_SYSTEM_PACKAGE_ID, + models::{ObjectId, StakeInput, TxOutput, UnstakeInput}, + sui_system_state_object_input, +}; + +use std::{error::Error, str::FromStr}; +use sui_transaction_builder::{Function, ObjectInput, TransactionBuilder}; +use sui_types::{Address, Identifier, TypeTag}; + +use super::{TransactionBuilderInput, finish_transaction, transfer::requires_hybrid_funding}; + +pub const SUI_REQUEST_ADD_STAKE: &str = "request_add_stake"; +pub const SUI_REQUEST_WITHDRAW_STAKE: &str = "request_withdraw_stake"; + +fn build_split_and_stake_ptb(input: &StakeInput) -> Result> { + if let Some(err) = crate::validate_enough_balance(&input.coins, input.stake_amount) { + return Err(err); + } + if input.coins.coins.is_empty() { + return Err("No SUI coins available for gas".into()); + } + if requires_hybrid_funding(&input.coins, input.stake_amount) { + return Err("Sui stake: amount requires combining Address Balance with Coin objects, which is not supported".into()); + } + + let stake_chain = primitives::StakeChain::Sui; + if input.stake_amount < stake_chain.get_min_stake_amount() { + return Err("stake amount is too small".into()); + } + + let validator = Address::from_str(&input.validator)?; + + let mut ptb = TransactionBuilder::new(); + + let stake_coin = if input.coins.address_balance >= input.stake_amount { + let coin_type: TypeTag = input + .coins + .coin_type + .parse() + .map_err(|err| format!("invalid Sui native coin type {}: {err}", input.coins.coin_type))?; + ptb.funds_withdrawal_coin(coin_type, input.stake_amount) + } else { + let stake_amount = ptb.pure(&input.stake_amount); + let gas = ptb.gas(); + let mut split_results = ptb.split_coins(gas, vec![stake_amount]); + split_results.pop().expect("split_coins should return one argument") + }; + + // move call request_add_stake + let function = Function::new( + ObjectId::from(SUI_SYSTEM_PACKAGE_ID).into(), + Identifier::new(SUI_SYSTEM_ID).unwrap(), + Identifier::new(SUI_REQUEST_ADD_STAKE).unwrap(), + ); + + let sys_state = ptb.object(sui_system_state_object_input()); + let validator_argument = ptb.pure(&validator); + + ptb.move_call(function, vec![sys_state, stake_coin, validator_argument]); + + Ok(ptb) +} + +pub fn encode_split_and_stake(input: &StakeInput) -> Result> { + let ptb = build_split_and_stake_ptb(input)?; + let gas_objects = input.coins.coins.iter().map(|x| x.object.to_input()).collect::>(); + finish_transaction(ptb, TransactionBuilderInput::new(input.sender.as_str(), input.gas.price, input.gas.budget, gas_objects)) + .map_err(|err| Box::new(err) as Box) +} + +fn build_unstake_ptb(input: &UnstakeInput) -> Result<(TransactionBuilder, ObjectInput), Box> { + let mut ptb = TransactionBuilder::new(); + + let staked_sui = ptb.object(input.staked_sui.to_input()); + let gas_coin = input.gas_coin.to_input(); + let function = Function::new( + ObjectId::from(SUI_SYSTEM_PACKAGE_ID).into(), + Identifier::new(SUI_SYSTEM_ID).unwrap(), + Identifier::new(SUI_REQUEST_WITHDRAW_STAKE).unwrap(), + ); + + let sys_state = ptb.object(sui_system_state_object_input()); + + ptb.move_call(function, vec![sys_state, staked_sui]); + + Ok((ptb, gas_coin)) +} + +pub fn encode_unstake(input: &UnstakeInput) -> Result> { + let (ptb, gas_coin) = build_unstake_ptb(input)?; + finish_transaction(ptb, TransactionBuilderInput::new(input.sender.as_str(), input.gas.price, input.gas.budget, vec![gas_coin])) + .map_err(|err| Box::new(err) as Box) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + SUI_COIN_TYPE, + models::{Coin, Gas, Object, OwnedCoins}, + tx_builder::decode_transaction, + }; + use gem_encoding::encode_base64; + use sui_types::Transaction; + + #[test] + fn test_encode_split_stake() { + let mut input = StakeInput { + sender: "0xe6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c2".into(), + validator: "0x61953ea72709eed72f4441dd944eec49a11b4acabfc8e04015e89c63be81b6ab".into(), + stake_amount: 1_000_000_000, + gas: Gas { budget: 20_000_000, price: 750 }, + coins: OwnedCoins::new( + SUI_COIN_TYPE.into(), + vec![Coin { + coin_type: SUI_COIN_TYPE.into(), + balance: 10990277896, + object: Object { + object_id: "0x36b8380aa7531d73723657d73a114cfafedf89dc8c76b6752f6daef17e43dda2".parse().unwrap(), + version: 0x3f4d8e5, + digest: "HdfF7hswRuvbXbEXjGjmUCt7gLybhvbPvvK8zZbCqyD8".parse().unwrap(), + }, + }], + 0, + ), + }; + let data = encode_split_and_stake(&input).unwrap(); + let tx: Transaction = bcs::from_bytes(&data.tx_data).unwrap(); + let expected_tx_data = hex::decode("000003000800ca9a3b0000000001010000000000000000000000000000000000000000000000000000000000000005010000000000000001002061953ea72709eed72f4441dd944eec49a11b4acabfc8e04015e89c63be81b6ab020200010100000000000000000000000000000000000000000000000000000000000000000000030a7375695f73797374656d11726571756573745f6164645f7374616b6500030101000300000000010200e6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c20136b8380aa7531d73723657d73a114cfafedf89dc8c76b6752f6daef17e43dda2e5d8f4030000000020f71f24516bc04cbf877d42faf459514448c8de6cff48faa44b3eef3b26782e8fe6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c2ee02000000000000002d31010000000000").unwrap(); + let expected_hash = hex::decode("66be75b0f86ca3a9f24380adc8d8336d8921d5dbdc78f1b3c24c7d6842ce5911").unwrap(); + let expected_tx: Transaction = bcs::from_bytes(&expected_tx_data).unwrap(); + + assert_eq!(tx, expected_tx); + assert_eq!(data.tx_data, expected_tx_data); + assert_eq!(data.hash, expected_hash); + + input.stake_amount = 100_000_000; + let result = encode_split_and_stake(&input); + assert!(result.is_err()); + } + + #[test] + fn test_unstake() { + let input = UnstakeInput { + sender: "0xe6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c2".into(), + staked_sui: Object { + object_id: "0xc8c1666ae68f46b609d40bb51d1ec23dc2e0560f986aae878643b6d215549fcf".parse().unwrap(), + digest: "CU86BjXRF1XHFRjKBasCYEuaQxhHuyGBpuoJyqsrYoX5".parse().unwrap(), + version: 64195796, + }, + gas: Gas { budget: 25_000_000, price: 750 }, + gas_coin: Coin { + coin_type: SUI_COIN_TYPE.into(), + balance: 631668351, + object: Object { + object_id: "0x36b8380aa7531d73723657d73a114cfafedf89dc8c76b6752f6daef17e43dda2".parse().unwrap(), + version: 68755407, + digest: "FHbvG5i7f8o2VrKpXnqGFHNvGxG7BBKREea5avdPN7ke".parse().unwrap(), + }, + }, + }; + let output = encode_unstake(&input).unwrap(); + + let b64_encoded = encode_base64(&output.tx_data); + let expected_tx = "AAACAQDIwWZq5o9GtgnUC7UdHsI9wuBWD5hqroeGQ7bSFVSfz9SM0wMAAAAAIKpjQQjo/YvOPLh6HhyTHqLP/lLvy8vGI66VEJwCDARwAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQEAAAAAAAAAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMKc3VpX3N5c3RlbRZyZXF1ZXN0X3dpdGhkcmF3X3N0YWtlAAIBAQABAADmr4D+GwtC/NlnYuXHD16Nrjn48O4PEYysDVW3TiknwgE2uDgKp1Mdc3I2V9c6EUz6/t+J3Ix2tnUvba7xfkPdos8fGQQAAAAAINREZGL0SD9y5n7te55Ju78nQ/PVWycQpwYPm4+JrWej5q+A/hsLQvzZZ2Llxw9eja45+PDuDxGMrA1Vt04pJ8LuAgAAAAAAAEB4fQEAAAAAAA=="; + let expected_decoded: Transaction = decode_transaction(expected_tx).unwrap(); + let output_tx: Transaction = bcs::from_bytes(&output.tx_data).unwrap(); + + assert_eq!(b64_encoded, expected_tx); + assert_eq!(output_tx, expected_decoded); + } +} diff --git a/core/crates/gem_sui/src/tx_builder/transaction.rs b/core/crates/gem_sui/src/tx_builder/transaction.rs new file mode 100644 index 0000000000..1ef0d79f61 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/transaction.rs @@ -0,0 +1,132 @@ +use super::TransactionBuilderInput; +use crate::{ + SuiError, is_sui_coin, + models::{Coin, OwnedCoins, TxOutput}, + sui_framework_package_address, +}; +use gem_encoding::decode_base64; +use serde::de::DeserializeOwned; +use std::{error::Error, str::FromStr}; +use sui_transaction_builder::{Argument, Function, TransactionBuilder}; +use sui_types::{Address, Identifier, TypeTag}; + +const MODULE_COIN: &str = "coin"; +const FUNCTION_ZERO: &str = "zero"; + +/// Build a `Coin` of exactly `amount`: pure withdrawal if Address Balance covers it, else coin objects topped up by the shortfall. +pub(crate) fn build_amount_coin(txb: &mut TransactionBuilder, coin_type_tag: TypeTag, amount: u64, address_balance: u64, coins: &[Coin]) -> Result { + if address_balance >= amount { + return Ok(txb.funds_withdrawal_coin(coin_type_tag, amount)); + } + + if coins.is_empty() { + return Err(SuiError::invalid_input("no coin sources for Sui amount")); + } + + let coin_total: u64 = coins.iter().map(|c| c.balance).fold(0, u64::saturating_add); + let mut coin_args: Vec = coins.iter().map(|c| txb.object(c.to_input())).collect(); + let primary = coin_args.remove(0); + if !coin_args.is_empty() { + txb.merge_coins(primary, coin_args); + } + if let Some(shortfall) = amount.checked_sub(coin_total).filter(|s| *s > 0) { + let withdrawn = txb.funds_withdrawal_coin(coin_type_tag, shortfall); + txb.merge_coins(primary, vec![withdrawn]); + } + + let amount_arg = txb.pure(&amount); + txb.split_coins(primary, vec![amount_arg]) + .pop() + .ok_or_else(|| SuiError::invalid_input("Sui split coin failed")) +} + +pub fn move_call(txb: &mut TransactionBuilder, package: Address, module: &str, function: &str, type_args: &[&str], arguments: Vec) -> Result { + let type_args = type_args + .iter() + .map(|value| { + value + .parse::() + .map_err(|err| SuiError::invalid_input(format!("Invalid Sui type argument {value}: {err}"))) + }) + .collect::, _>>()?; + let function = Function::new( + package, + Identifier::new(module).map_err(SuiError::from_display)?, + Identifier::new(function).map_err(SuiError::from_display)?, + ) + .with_type_args(type_args); + Ok(txb.move_call(function, arguments)) +} + +pub fn zero_coin(txb: &mut TransactionBuilder, coin_type: &str) -> Result { + move_call(txb, sui_framework_package_address(), MODULE_COIN, FUNCTION_ZERO, &[coin_type], vec![]) +} + +pub fn build_input_coin(txb: &mut TransactionBuilder, coin_type: &str, amount: u64, source: &OwnedCoins) -> Result { + if amount == 0 { + return zero_coin(txb, coin_type); + } + + if is_sui_coin(coin_type) { + let amount_arg = txb.pure(&amount); + let gas = txb.gas(); + return txb.split_coins(gas, vec![amount_arg]).pop().ok_or_else(|| SuiError::invalid_input("Sui split coin failed")); + } + + if source.total() < amount { + return Err(SuiError::InsufficientBalance { coin_type: coin_type.to_string() }); + } + + let type_tag: TypeTag = coin_type + .parse() + .map_err(|err| SuiError::invalid_input(format!("Invalid Sui coin type {coin_type}: {err}")))?; + build_amount_coin(txb, type_tag, amount, source.address_balance, &source.coins) +} + +pub fn finish_transaction(mut txb: TransactionBuilder, input: TransactionBuilderInput) -> Result { + txb.set_sender(Address::from_str(&input.sender).map_err(|err| SuiError::invalid_input(format!("Invalid Sui address {}: {err}", input.sender)))?); + txb.set_gas_price(input.gas_price); + txb.set_gas_budget(input.gas_budget); + txb.add_gas_objects(input.gas_objects); + + let transaction = txb.try_build().map_err(SuiError::from_display)?; + TxOutput::from_tx(&transaction).map_err(SuiError::from_display) +} + +pub fn decode_transaction(encoded: &str) -> Result> { + let bytes = decode_base64(encoded)?; + let transaction = bcs::from_bytes::(&bytes)?; + Ok(transaction) +} + +pub fn validate_and_hash(encoded: &str) -> Result> { + if encoded.trim().is_empty() { + return Err("Missing Sui transaction data".into()); + } + + let transaction = decode_transaction(encoded).map_err(|err| format!("Invalid Sui transaction payload: {err}"))?; + TxOutput::from_tx(&transaction) +} + +#[cfg(test)] +mod tests { + use super::*; + use sui_types::{Transaction, TransactionKind}; + + #[test] + fn test_decode_transaction() { + let encoded = "AAAPAAhkx5NBAAAAAAAIKUO8sgMAAAAAAQAAAQAAAQAACGTHk0EAAAAAAQFexM/GvrUlJRacMqd+FsKIt7/Lm4mCielL8xCFcLPvpBbjZwAAAAAAAQEB2qRikmMsPE2PMfI+oPmzaij/NnfpaEmA5EOEA6Z6PY8uBRgAAAAAAAABAYBJ0AkRYmmsBO4UIGt6/YtktYASefhUAe5LOXefgJE0zicvAAAAAAABAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgEAAAAAAAAAAAEB8ZTZsbytly5Fp91n3Umz7h4zV6AKUIUMUs1Ru0UOE7QXwmUAAAAAAAABASjkmd/16GSi6v5HYmmk9QNfHBbzONp74YsQNJmr8nHO7fIyAAAAAAABAQHwxA1nsHgADhgDIzTDMlxHueyfPZrkEovoINVGY9FOO+/yMgAAAAAAAQEBNdNbDlsXdZPYw6gBRiSFVy/DCGHmzpalWvbcRzBwknju8jIAAAAAAAAAIJP2W4wWwmM0O79mz5+O72nLHbyS0T8MMxsNyut2tKq2BgIAAQEAAADcFXIbqoK6ZIItWFpzSaFQj3bZSugOiZsG5INpwld1Dghzd2FwX2NhcBFvYnRhaW5fcm91dGVyX2NhcAIHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQAH5COc2VH2xT2cQeJScNgNMfklrRZV5bpbVDhD1KZpde4EU1VJUARTVUlQAAUCAAABAQABAgABAwABBAAA3BVyG6qCumSCLVhac0mhUI922UroDombBuSDacJXdQ4Ic3dhcF9jYXANaW5pdGlhdGVfcGF0aAEHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQACAgEAAQUAAB7GqMWsC4uXwofNNLn8apS1OgfJMKhQWVJnncjUs3gKBnJvdXRlchBzd2FwX2JfdG9fYV9ieV9iAwcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgNzdWkDU1VJAAfkI5zZUfbFPZxB4lJw2A0x+SWtFlXlultUOEPUpml17gRTVUlQBFNVSVAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA3N1aQNTVUkABgEGAAIBAAEHAAEIAAICAAEJAADcFXIbqoK6ZIItWFpzSaFQj3bZSugOiZsG5INpwld1Dghzd2FwX2NhcBFyZXR1cm5fcm91dGVyX2NhcAIHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQAH5COc2VH2xT2cQeJScNgNMfklrRZV5bpbVDhD1KZpde4EU1VJUARTVUlQAAYCAQACAwABCgABCwABDAABDQABAQIDAAEOAJP2W4wWwmM0O79mz5+O72nLHbyS0T8MMxsNyut2tKq2AQAX1Cs2B1S8591qpdZjDUOB/CBDy2V8/6tqhBbwbdyxj734BAAAAAAg6yrtiW5R0TC68GDMmZye6U+KDjfZlq21n3bztRGzXjuT9luMFsJjNDu/Zs+fju9pyx28ktE/DDMbDcrrdrSqtu4CAAAAAAAA3P9fAAAAAAAA"; + let transaction: Transaction = decode_transaction(encoded).unwrap(); + + assert_eq!(transaction.sender.to_string(), "0x93f65b8c16c263343bbf66cf9f8eef69cb1dbc92d13f0c331b0dcaeb76b4aab6"); + match transaction.kind { + TransactionKind::ProgrammableTransaction(programmable) => { + assert_eq!(programmable.commands.len(), 6); + } + _ => panic!("wrong kind"), + } + + let output = validate_and_hash(encoded).unwrap(); + assert_eq!(hex::encode(output.hash), "883f6f54145fdaf357e3d404a8353b1f6eda265bc2b28ec8178631e092c24e3b"); + } +} diff --git a/core/crates/gem_sui/src/tx_builder/transaction_json/builder.rs b/core/crates/gem_sui/src/tx_builder/transaction_json/builder.rs new file mode 100644 index 0000000000..48df9d1ee2 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/transaction_json/builder.rs @@ -0,0 +1,154 @@ +use super::model::{TransactionArgument, TransactionCommand, TransactionInput, TransactionObject}; +use crate::{SuiError, address::SuiAddress, tx_builder::move_call as sui_move_call}; +use gem_encoding::decode_base64; +use std::{collections::HashMap, str::FromStr}; +use sui_transaction_builder::{Argument, ObjectInput, TransactionBuilder}; +use sui_types::{Digest, TypeTag}; + +pub(super) enum CommandOutput { + Single(Argument), + Nested(Vec), + Empty, +} + +pub(super) fn replay_input(txb: &mut TransactionBuilder, index: usize, input: &TransactionInput, object_inputs: &HashMap) -> Result { + match input { + TransactionInput::Pure { pure } => { + Ok(txb.pure_bytes_unique(decode_base64(&pure.bytes).map_err(|err| SuiError::invalid_input(format!("Invalid Sui transaction encoding: {err}")))?)) + } + TransactionInput::Object { object } => Ok(txb.object(object_input(object)?)), + TransactionInput::UnresolvedObject { .. } => Ok(txb.object( + object_inputs + .get(&index) + .cloned() + .ok_or_else(|| SuiError::invalid_input("Missing resolved Sui object input"))?, + )), + TransactionInput::UnresolvedPure { pure } => Err(SuiError::invalid_input(format!("Sui transaction contains unresolved pure input: {pure}"))), + } +} + +pub(super) fn replay_command(txb: &mut TransactionBuilder, command: TransactionCommand, inputs: &[Argument], outputs: &[CommandOutput]) -> Result { + match command { + TransactionCommand::MoveCall { move_call } => { + let arguments = move_call + .arguments + .iter() + .map(|argument| input_or_output_argument(txb, argument, inputs, outputs)) + .collect::, _>>()?; + let type_arguments = move_call.type_arguments.iter().map(String::as_str).collect::>(); + let output = sui_move_call( + txb, + SuiAddress::parse(&move_call.package)?.into(), + &move_call.module, + &move_call.function, + &type_arguments, + arguments, + )?; + Ok(CommandOutput::Single(output)) + } + TransactionCommand::TransferObjects { transfer_objects } => { + let objects = transfer_objects + .objects + .iter() + .map(|argument| input_or_output_argument(txb, argument, inputs, outputs)) + .collect::, _>>()?; + let address = input_or_output_argument(txb, &transfer_objects.address, inputs, outputs)?; + txb.transfer_objects(objects, address); + Ok(CommandOutput::Empty) + } + TransactionCommand::SplitCoins { split_coins } => { + let coin = input_or_output_argument(txb, &split_coins.coin, inputs, outputs)?; + let amounts = split_coins + .amounts + .iter() + .map(|argument| input_or_output_argument(txb, argument, inputs, outputs)) + .collect::, _>>()?; + Ok(CommandOutput::Nested(txb.split_coins(coin, amounts))) + } + TransactionCommand::MergeCoins { merge_coins } => { + let destination = input_or_output_argument(txb, &merge_coins.destination, inputs, outputs)?; + let sources = merge_coins + .sources + .iter() + .map(|argument| input_or_output_argument(txb, argument, inputs, outputs)) + .collect::, _>>()?; + txb.merge_coins(destination, sources); + Ok(CommandOutput::Empty) + } + TransactionCommand::MakeMoveVec { make_move_vec } => { + let type_ = make_move_vec + .r#type + .as_deref() + .map(TypeTag::from_str) + .transpose() + .map_err(|err| SuiError::invalid_input(format!("Invalid Sui MakeMoveVec type: {err}")))?; + let elements = make_move_vec + .elements + .iter() + .map(|argument| input_or_output_argument(txb, argument, inputs, outputs)) + .collect::, _>>()?; + Ok(CommandOutput::Single(txb.make_move_vec(type_, elements))) + } + TransactionCommand::Publish { publish } => Err(SuiError::invalid_input(format!("Unsupported Sui Publish command: {publish}"))), + TransactionCommand::Upgrade { upgrade } => Err(SuiError::invalid_input(format!("Unsupported Sui Upgrade command: {upgrade}"))), + } +} + +pub(super) fn output_argument(argument: &TransactionArgument, outputs: &[CommandOutput]) -> Result { + match argument { + TransactionArgument::Result { result } => match outputs.get(*result).ok_or_else(|| SuiError::invalid_input("Missing Sui result argument"))? { + CommandOutput::Single(argument) => Ok(*argument), + CommandOutput::Nested(_) | CommandOutput::Empty => Err(SuiError::invalid_input("Invalid Sui result argument")), + }, + TransactionArgument::NestedResult { nested_result } => { + let output = outputs.get(nested_result[0]).ok_or_else(|| SuiError::invalid_input("Missing Sui nested result argument"))?; + match output { + CommandOutput::Single(argument) => argument + .to_nested(nested_result[1] + 1) + .get(nested_result[1]) + .copied() + .ok_or_else(|| SuiError::invalid_input("Invalid Sui nested result argument")), + CommandOutput::Nested(arguments) => arguments + .get(nested_result[1]) + .copied() + .ok_or_else(|| SuiError::invalid_input("Invalid Sui nested result argument")), + CommandOutput::Empty => Err(SuiError::invalid_input("Invalid Sui nested result argument")), + } + } + TransactionArgument::GasCoin { .. } | TransactionArgument::Input { .. } => Err(SuiError::invalid_input("Invalid Sui output argument")), + } +} + +fn object_input(object: &TransactionObject) -> Result { + match object { + TransactionObject::ImmOrOwnedObject { object } => Ok(ObjectInput::owned(SuiAddress::parse(&object.object_id)?.into(), object.version, digest(&object.digest)?)), + TransactionObject::SharedObject { object } => Ok(ObjectInput::shared( + SuiAddress::parse(&object.object_id)?.into(), + object.initial_shared_version, + object.mutable, + )), + TransactionObject::Receiving { object } => Ok(ObjectInput::receiving( + SuiAddress::parse(&object.object_id)?.into(), + object.version, + digest(&object.digest)?, + )), + } +} + +fn input_or_output_argument(txb: &mut TransactionBuilder, argument: &TransactionArgument, inputs: &[Argument], outputs: &[CommandOutput]) -> Result { + match argument { + TransactionArgument::GasCoin { gas_coin } => { + if *gas_coin { + Ok(txb.gas()) + } else { + Err(SuiError::invalid_input("Invalid Sui gas coin argument")) + } + } + TransactionArgument::Input { input } => inputs.get(*input).copied().ok_or_else(|| SuiError::invalid_input("Missing Sui input argument")), + TransactionArgument::Result { .. } | TransactionArgument::NestedResult { .. } => output_argument(argument, outputs), + } +} + +fn digest(value: &str) -> Result { + Digest::from_str(value).map_err(|err| SuiError::invalid_input(format!("Invalid Sui object digest {value}: {err}"))) +} diff --git a/core/crates/gem_sui/src/tx_builder/transaction_json/mod.rs b/core/crates/gem_sui/src/tx_builder/transaction_json/mod.rs new file mode 100644 index 0000000000..a581d5a4f6 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/transaction_json/mod.rs @@ -0,0 +1,15 @@ +//! JSON emitted by Mysten's TypeScript transaction builder `Transaction.toJSON()`. + +mod model; + +#[cfg(feature = "rpc")] +mod builder; +#[cfg(feature = "rpc")] +mod replay; +#[cfg(feature = "rpc")] +mod resolver; + +pub use model::*; + +#[cfg(feature = "rpc")] +pub use replay::{ReplayedTransaction, TransactionJsonReplay, prepare_transaction_json_replay, replay_transaction_json}; diff --git a/core/crates/gem_sui/src/tx_builder/transaction_json/model.rs b/core/crates/gem_sui/src/tx_builder/transaction_json/model.rs new file mode 100644 index 0000000000..677b00409c --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/transaction_json/model.rs @@ -0,0 +1,182 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_serializers::deserialize_u64_from_str_or_int; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionBuilderJson { + pub version: u8, + pub inputs: Vec, + pub commands: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum TransactionInput { + Pure { + #[serde(rename = "Pure")] + pure: PureInput, + }, + Object { + #[serde(rename = "Object")] + object: TransactionObject, + }, + UnresolvedObject { + #[serde(rename = "UnresolvedObject")] + object: UnresolvedObject, + }, + UnresolvedPure { + #[serde(rename = "UnresolvedPure")] + pure: Value, + }, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PureInput { + pub bytes: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnresolvedObject { + pub object_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum TransactionObject { + ImmOrOwnedObject { + #[serde(rename = "ImmOrOwnedObject")] + object: ObjectRef, + }, + SharedObject { + #[serde(rename = "SharedObject")] + object: SharedObjectRef, + }, + Receiving { + #[serde(rename = "Receiving")] + object: ObjectRef, + }, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ObjectRef { + pub object_id: String, + #[serde(deserialize_with = "deserialize_u64_from_str_or_int")] + pub version: u64, + pub digest: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedObjectRef { + pub object_id: String, + #[serde(deserialize_with = "deserialize_u64_from_str_or_int")] + pub initial_shared_version: u64, + pub mutable: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum TransactionCommand { + MoveCall { + #[serde(rename = "MoveCall")] + move_call: MoveCallCommand, + }, + TransferObjects { + #[serde(rename = "TransferObjects")] + transfer_objects: TransferObjectsCommand, + }, + SplitCoins { + #[serde(rename = "SplitCoins")] + split_coins: SplitCoinsCommand, + }, + MergeCoins { + #[serde(rename = "MergeCoins")] + merge_coins: MergeCoinsCommand, + }, + MakeMoveVec { + #[serde(rename = "MakeMoveVec")] + make_move_vec: MakeMoveVecCommand, + }, + Publish { + #[serde(rename = "Publish")] + publish: Value, + }, + Upgrade { + #[serde(rename = "Upgrade")] + upgrade: Value, + }, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveCallCommand { + pub package: String, + pub module: String, + pub function: String, + #[serde(default)] + pub type_arguments: Vec, + #[serde(default)] + pub arguments: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TransferObjectsCommand { + pub objects: Vec, + pub address: TransactionArgument, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SplitCoinsCommand { + pub coin: TransactionArgument, + pub amounts: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct MergeCoinsCommand { + pub destination: TransactionArgument, + pub sources: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct MakeMoveVecCommand { + pub r#type: Option, + pub elements: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(untagged)] +pub enum TransactionArgument { + GasCoin { + #[serde(rename = "GasCoin")] + gas_coin: bool, + }, + Input { + #[serde(rename = "Input")] + input: usize, + }, + Result { + #[serde(rename = "Result")] + result: usize, + }, + NestedResult { + #[serde(rename = "NestedResult")] + nested_result: [usize; 2], + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_transaction_json() { + let transaction: TransactionBuilderJson = serde_json::from_str(include_str!("../../../testdata/transaction_builder_json.json")).unwrap(); + + assert_eq!(transaction.version, 2); + assert_eq!(transaction.inputs.len(), 2); + assert_eq!(transaction.commands.len(), 2); + } +} diff --git a/core/crates/gem_sui/src/tx_builder/transaction_json/replay.rs b/core/crates/gem_sui/src/tx_builder/transaction_json/replay.rs new file mode 100644 index 0000000000..b5112c156c --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/transaction_json/replay.rs @@ -0,0 +1,60 @@ +use super::{ + builder::{CommandOutput, output_argument, replay_command, replay_input}, + model::{TransactionArgument, TransactionBuilderJson}, + resolver::{input_mutability, object_inputs}, +}; +use crate::{SuiClient, SuiError}; +use std::collections::HashMap; +use sui_transaction_builder::{Argument, ObjectInput, TransactionBuilder}; + +pub struct ReplayedTransaction { + pub txb: TransactionBuilder, + outputs: Vec, +} + +impl ReplayedTransaction { + pub fn argument(&self, argument: &TransactionArgument) -> Result { + output_argument(argument, &self.outputs) + } +} + +pub struct TransactionJsonReplay { + transaction: TransactionBuilderJson, + object_inputs: HashMap, +} + +impl TransactionJsonReplay { + pub fn replay(&self) -> Result { + let mut txb = TransactionBuilder::new(); + let inputs = self + .transaction + .inputs + .iter() + .enumerate() + .map(|(index, input)| replay_input(&mut txb, index, input, &self.object_inputs)) + .collect::, _>>()?; + + let mut outputs = Vec::new(); + for command in self.transaction.commands.iter().cloned() { + let output = replay_command(&mut txb, command, &inputs, &outputs)?; + outputs.push(output); + } + + Ok(ReplayedTransaction { txb, outputs }) + } +} + +pub async fn replay_transaction_json(client: &SuiClient, transaction_json: &str) -> Result { + prepare_transaction_json_replay(client, transaction_json).await?.replay() +} + +pub async fn prepare_transaction_json_replay(client: &SuiClient, transaction_json: &str) -> Result { + let transaction: TransactionBuilderJson = serde_json::from_str(transaction_json).map_err(|err| SuiError::invalid_input(format!("Invalid Sui transaction JSON: {err}")))?; + if transaction.version != 2 { + return Err(SuiError::invalid_input(format!("Unsupported Sui transaction JSON version {}", transaction.version))); + } + + let input_mutability = input_mutability(client, &transaction.commands).await?; + let object_inputs = object_inputs(client, &transaction.inputs, &input_mutability).await?; + Ok(TransactionJsonReplay { transaction, object_inputs }) +} diff --git a/core/crates/gem_sui/src/tx_builder/transaction_json/resolver.rs b/core/crates/gem_sui/src/tx_builder/transaction_json/resolver.rs new file mode 100644 index 0000000000..6650694c33 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/transaction_json/resolver.rs @@ -0,0 +1,131 @@ +use super::model::{TransactionArgument, TransactionCommand, TransactionInput}; +use crate::rpc::proto::{FunctionDescriptor, OpenSignature, open_signature::Reference}; +use crate::{SuiClient, SuiError, tx_builder::ResolvedObjectInput}; +use futures::future::try_join_all; +use std::collections::{HashMap, HashSet}; +use sui_transaction_builder::ObjectInput; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct MoveFunctionKey { + package: String, + module: String, + function: String, +} + +pub(super) async fn input_mutability(client: &SuiClient, commands: &[TransactionCommand]) -> Result, SuiError> { + let keys = commands + .iter() + .filter_map(|command| match command { + TransactionCommand::MoveCall { move_call } => Some(MoveFunctionKey { + package: move_call.package.clone(), + module: move_call.module.clone(), + function: move_call.function.clone(), + }), + _ => None, + }) + .collect::>() + .into_iter() + .collect::>(); + let cache = fetch_functions(client, keys).await?; + let mut mutable_inputs = HashMap::::new(); + + for command in commands { + let TransactionCommand::MoveCall { move_call } = command else { + continue; + }; + let key = MoveFunctionKey { + package: move_call.package.clone(), + module: move_call.module.clone(), + function: move_call.function.clone(), + }; + let function = cache.get(&key).ok_or_else(|| SuiError::invalid_input("Missing cached Sui Move function signature"))?; + for (argument, parameter) in move_call.arguments.iter().zip(&function.parameters) { + if !is_mutable_parameter(parameter) { + continue; + } + if let TransactionArgument::Input { input } = argument { + mutable_inputs.insert(*input, true); + } + } + } + + Ok(mutable_inputs) +} + +async fn fetch_functions(client: &SuiClient, keys: Vec) -> Result, SuiError> { + try_join_all(keys.into_iter().map(|key| async move { + let function = client + .get_function(&key.package, &key.module, &key.function) + .await + .map_err(|err| SuiError::invalid_input(format!("Failed to fetch Sui Move function signature: {err}")))?; + Ok((key, function)) + })) + .await + .map(|functions| functions.into_iter().collect()) +} + +pub(super) async fn object_inputs(client: &SuiClient, inputs: &[TransactionInput], input_mutability: &HashMap) -> Result, SuiError> { + let object_ids = inputs + .iter() + .filter_map(|input| match input { + TransactionInput::UnresolvedObject { object } => Some(object.object_id.clone()), + TransactionInput::Pure { .. } | TransactionInput::Object { .. } | TransactionInput::UnresolvedPure { .. } => None, + }) + .collect::>() + .into_iter() + .collect::>(); + let fetched = if object_ids.is_empty() { + Vec::new() + } else { + client + .get_multiple_objects(object_ids.clone()) + .await + .map_err(|err| SuiError::invalid_input(format!("Failed to fetch Sui transaction objects: {err}")))? + }; + let fetched = object_ids + .into_iter() + .zip(fetched) + .map(|(object_id, object)| Ok((object_id, ResolvedObjectInput::from_rpc_object(object)?))) + .collect::, SuiError>>()?; + + inputs + .iter() + .enumerate() + .filter_map(|(index, input)| match input { + TransactionInput::UnresolvedObject { object } => Some(object_input_from_fetched(index, &object.object_id, &fetched, input_mutability)), + TransactionInput::Pure { .. } | TransactionInput::Object { .. } | TransactionInput::UnresolvedPure { .. } => None, + }) + .collect() +} + +fn is_mutable_parameter(parameter: &OpenSignature) -> bool { + parameter.reference.and_then(|reference| Reference::try_from(reference).ok()) == Some(Reference::Mutable) +} + +fn object_input_from_fetched( + index: usize, + object_id: &str, + fetched: &HashMap, + input_mutability: &HashMap, +) -> Result<(usize, ObjectInput), SuiError> { + let object = fetched + .get(object_id) + .ok_or_else(|| SuiError::invalid_input(format!("Sui object was not returned by RPC: {object_id}")))?; + Ok((index, object.input(input_mutability.get(&index).copied().unwrap_or(false)))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_mutable_parameter() { + let mut parameter = OpenSignature { + reference: Some(Reference::Mutable as i32), + }; + assert!(is_mutable_parameter(¶meter)); + + parameter.reference = Some(Reference::Immutable as i32); + assert!(!is_mutable_parameter(¶meter)); + } +} diff --git a/core/crates/gem_sui/src/tx_builder/transfer.rs b/core/crates/gem_sui/src/tx_builder/transfer.rs new file mode 100644 index 0000000000..125873b417 --- /dev/null +++ b/core/crates/gem_sui/src/tx_builder/transfer.rs @@ -0,0 +1,280 @@ +use crate::models::*; +use std::error::Error; +use std::str::FromStr; +use sui_transaction_builder::{ObjectInput, TransactionBuilder}; +use sui_types::{Address, TypeTag}; + +use super::{TransactionBuilderInput, build_amount_coin, finish_transaction}; + +pub(super) fn requires_hybrid_funding(coins: &OwnedCoins, amount: u64) -> bool { + coins.address_balance < amount && coins.coin_total() < amount +} + +fn build_transfer_ptb(input: &TransferInput) -> Result> { + if let Some(err) = crate::validate_enough_balance(&input.coins, input.amount) { + return Err(err); + } + if input.coins.coins.is_empty() { + return Err("No SUI coins available for gas".into()); + } + if !input.send_max && requires_hybrid_funding(&input.coins, input.amount) { + return Err("Sui native transfer: amount requires combining Address Balance with Coin objects, which is not supported".into()); + } + + let recipient = Address::from_str(&input.recipient)?; + + let mut ptb = TransactionBuilder::new(); + if input.send_max { + let recipient_argument = ptb.pure(&recipient); + let gas = ptb.gas(); + ptb.transfer_objects(vec![gas], recipient_argument); + return Ok(ptb); + } + + let send_coin = if input.coins.address_balance >= input.amount { + let coin_type: TypeTag = input + .coins + .coin_type + .parse() + .map_err(|err| format!("invalid Sui native coin type {}: {err}", input.coins.coin_type))?; + ptb.funds_withdrawal_coin(coin_type, input.amount) + } else { + let amount = ptb.pure(&input.amount); + let gas = ptb.gas(); + let mut split_results = ptb.split_coins(gas, vec![amount]); + split_results.pop().expect("split_coins should return one argument") + }; + + let recipient_argument = ptb.pure(&recipient); + ptb.transfer_objects(vec![send_coin], recipient_argument); + + Ok(ptb) +} + +pub fn encode_transfer(input: &TransferInput) -> Result> { + let ptb = build_transfer_ptb(input)?; + let gas_objects = input.coins.coins.iter().map(|x| x.object.to_input()).collect::>(); + finish_transaction(ptb, TransactionBuilderInput::new(input.sender.as_str(), input.gas.price, input.gas.budget, gas_objects)) + .map_err(|err| Box::new(err) as Box) +} + +fn build_token_transfer_ptb(input: &TokenTransferInput) -> Result> { + let tokens = &input.tokens; + if let Some(err) = crate::validate_enough_balance(tokens, input.amount) { + return Err(err); + } + + let coin_type: TypeTag = tokens.coin_type.parse().map_err(|err| format!("invalid Sui token coin type {}: {err}", tokens.coin_type))?; + let recipient = Address::from_str(&input.recipient)?; + let mut ptb = TransactionBuilder::new(); + let amount_coin = build_amount_coin(&mut ptb, coin_type, input.amount, tokens.address_balance, &tokens.coins)?; + let recipient_argument = ptb.pure(&recipient); + ptb.transfer_objects(vec![amount_coin], recipient_argument); + + Ok(ptb) +} + +pub fn encode_token_transfer(input: &TokenTransferInput) -> Result> { + let ptb = build_token_transfer_ptb(input)?; + let gas_coin = ObjectInput::immutable(input.gas_coin.object.object_id, input.gas_coin.object.version, input.gas_coin.object.digest); + finish_transaction(ptb, TransactionBuilderInput::new(input.sender.as_str(), input.gas.price, input.gas.budget, vec![gas_coin])) + .map_err(|err| Box::new(err) as Box) +} + +#[cfg(test)] +mod tests { + use crate::{SUI_COIN_TYPE, tx_builder::decode_transaction}; + use gem_encoding::encode_base64; + use primitives::asset_constants::SUI_USDC_TOKEN_ID; + use sui_types::Transaction; + + use super::*; + + #[test] + fn test_encode_transfer() { + let input = TransferInput { + sender: "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".into(), + recipient: "0xe6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c2".into(), + amount: 8993996480, + coins: OwnedCoins::new( + SUI_COIN_TYPE.into(), + vec![Coin { + coin_type: SUI_COIN_TYPE.into(), + balance: 8994756360, + object: Object { + object_id: "0x9f258c85566d977b4c99bb6019560ba99c796e71291269d8f9f3cc9d9f37db46".parse().unwrap(), + digest: "GoAwPNYEBKyAgzmQgnxW23bdhnHaLXcqT3o1nEZo4KPM".parse().unwrap(), + version: 68419468, + }, + }], + 0, + ), + send_max: true, + gas: Gas { budget: 25_000_000, price: 750 }, + }; + + let output = encode_transfer(&input).unwrap(); + let tx: Transaction = bcs::from_bytes(&output.tx_data).unwrap(); + let b64_encoded = encode_base64(&output.tx_data); + let expected_tx = "AAABACDmr4D+GwtC/NlnYuXHD16Nrjn48O4PEYysDVW3TiknwgEBAQABAACpvQST+b0feSpK7cH5nVRTWnWkbDj9VqjyxrfI11gXoQGfJYyFVm2Xe0yZu2AZVgupnHlucSkSadj588ydnzfbRoz/EwQAAAAAIOqzQffiRRpexyiDEtyjm40KqFMf60ohK5jCJ0z3+Lqwqb0Ek/m9H3kqSu3B+Z1UU1p1pGw4/Vao8sa3yNdYF6HuAgAAAAAAAEB4fQEAAAAAAA=="; + let expected_decoded = decode_transaction(expected_tx).unwrap(); + + assert_eq!(tx, expected_decoded); + assert_eq!(b64_encoded, expected_tx); + } + + #[test] + fn test_encode_token_transfer() { + let suip_coin_type = "0xe4239cd951f6c53d9c41e25270d80d31f925ad1655e5ba5b543843d4a66975ee::SUIP::SUIP"; + let input = TokenTransferInput { + sender: "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".into(), + recipient: "0xe6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c2".into(), + amount: 2400000000, + tokens: OwnedCoins::new( + suip_coin_type.into(), + vec![ + Coin { + coin_type: suip_coin_type.into(), + balance: 1400000000, + object: Object { + object_id: "0x1a6b6023d363f5dcad026f83ddb9bb0f987c941f10db2ab86571711a1a9a1ee6".parse().unwrap(), + digest: "CCFDRi15n2mhBVGAoa594VynBKgSRbgZQZgjT4wxFu7B".parse().unwrap(), + version: 67155000, + }, + }, + Coin { + coin_type: suip_coin_type.into(), + balance: 1000000000, + object: Object { + object_id: "0x2fd950f33ecdf9e5d797ca3130811e7a973d4c1da5427ac0c910a8c5f6e8b72d".parse().unwrap(), + digest: "7CsXhia2TGqy7bXnxH4WLbkzYJBPvCnNVuLvzByvLsRh".parse().unwrap(), + version: 67154999, + }, + }, + ], + 0, + ), + gas: Gas { budget: 25_000_000, price: 750 }, + gas_coin: Coin { + coin_type: SUI_COIN_TYPE.into(), + balance: 100000000, + object: Object { + object_id: "0x890f8c604c7cb5cc194dbf4953ad3dbebd81ef7526be351d3514cc3cc26c9c1d".parse().unwrap(), + digest: "3a2sHuj9pJg7RHub4w9EPyBtpxVfHzk52M91HErwMQ4J".parse().unwrap(), + version: 69035764, + }, + }, + }; + + let output = encode_token_transfer(&input).unwrap(); + let tx: Transaction = bcs::from_bytes(&output.tx_data).unwrap(); + let b64_encoded = encode_base64(&output.tx_data); + let expected_tx = "AAAEAQAaa2Aj02P13K0Cb4PdubsPmHyUHxDbKrhlcXEaGpoe5ji0AAQAAAAAIKZSBGYgBc5PwYeX01SAZHnJYxA3pJRvrUZmR7ToQZTWAQAv2VDzPs355deXyjEwgR56lz1MHaVCesDJEKjF9ui3LTe0AAQAAAAAIFwwpOhb+onitRHRqj+wsEA0nNO2KqqOt8/IVbcC0O7oAAgAGA2PAAAAAAAg5q+A/hsLQvzZZ2Llxw9eja45+PDuDxGMrA1Vt04pJ8IDAwEAAAEBAQACAQAAAQECAAEBAwEAAAABAwCpvQST+b0feSpK7cH5nVRTWnWkbDj9VqjyxrfI11gXoQGJD4xgTHy1zBlNv0lTrT2+vYHvdSa+NR01FMw8wmycHfRmHQQAAAAAICYtptS+v/0HkfChzkJo0QzRDQxhli84CM3mMV/dqUBbqb0Ek/m9H3kqSu3B+Z1UU1p1pGw4/Vao8sa3yNdYF6HuAgAAAAAAAEB4fQEAAAAAAA=="; + let expected_decoded = decode_transaction(expected_tx).unwrap(); + + assert_eq!(tx, expected_decoded); + assert_eq!(b64_encoded, expected_tx); + } + + #[test] + fn test_encode_token_transfer_from_address_balance() { + let input = TokenTransferInput { + sender: "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991".into(), + recipient: "0xcf3abaeecfaf42990b8481c03000000000000000000000000000000000000000".into(), + amount: 200_000_000, + tokens: OwnedCoins::new(SUI_USDC_TOKEN_ID.into(), vec![], 2_605_380_809), + gas: Gas { budget: 25_000_000, price: 750 }, + gas_coin: Coin::mock_sui(), + }; + + let output = encode_token_transfer(&input).unwrap(); + let tx: Transaction = bcs::from_bytes(&output.tx_data).unwrap(); + match tx.kind { + sui_types::TransactionKind::ProgrammableTransaction(ptb) => { + assert_eq!(ptb.inputs.len(), 2, "expected withdrawal + recipient inputs only"); + assert!(matches!(ptb.inputs[0], sui_types::Input::FundsWithdrawal(_)), "first input must be FundsWithdrawal"); + assert_eq!(ptb.commands.len(), 2, "expected redeem_funds + transfer_objects"); + } + _ => panic!("expected ProgrammableTransaction"), + } + } + + #[test] + fn test_encode_token_transfer_mixed_balance_and_coin() { + let input = TokenTransferInput { + sender: "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991".into(), + recipient: "0xcf3abaeecfaf42990b8481c03000000000000000000000000000000000000000".into(), + amount: 200_000_000, + tokens: OwnedCoins::new( + SUI_USDC_TOKEN_ID.into(), + vec![Coin { + coin_type: SUI_USDC_TOKEN_ID.into(), + balance: 150_000_000, + object: Object { + object_id: "0xfa8dca3e71a9ab44eef5becf50358d9c665aef33522e77940ee840c03b385bf3".parse().unwrap(), + digest: "HHwqY8eMncQPwrGtdbxGpJ7Sz1QacdvrcUNG9ywtxLs5".parse().unwrap(), + version: 895_958_996, + }, + }], + 60_000_000, + ), + gas: Gas { budget: 25_000_000, price: 750 }, + gas_coin: Coin::mock_sui(), + }; + + let output = encode_token_transfer(&input).unwrap(); + let tx: Transaction = bcs::from_bytes(&output.tx_data).unwrap(); + match tx.kind { + sui_types::TransactionKind::ProgrammableTransaction(ptb) => { + assert!(ptb.inputs.iter().any(|inp| matches!(inp, sui_types::Input::FundsWithdrawal(_)))); + assert!(ptb.inputs.iter().any(|inp| matches!(inp, sui_types::Input::ImmutableOrOwned(_)))); + let withdrawals: Vec = ptb + .inputs + .iter() + .filter_map(|inp| match inp { + sui_types::Input::FundsWithdrawal(w) => w.amount(), + _ => None, + }) + .collect(); + assert_eq!(withdrawals, vec![50_000_000], "expected shortfall withdrawal only"); + } + _ => panic!("expected ProgrammableTransaction"), + } + } + + #[test] + fn test_encode_native_transfer_without_gas_coin_rejected() { + let input = TransferInput { + sender: "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991".into(), + recipient: "0xcf3abaeecfaf42990b8481c03000000000000000000000000000000000000000".into(), + amount: 1_000_000, + coins: OwnedCoins::new(SUI_COIN_TYPE.into(), vec![], 2_000_000), + send_max: false, + gas: Gas { budget: 25_000_000, price: 750 }, + }; + let err = encode_transfer(&input).expect_err("missing Coin for gas must be rejected early"); + assert!(err.to_string().contains("No SUI coins available for gas"), "got: {err}"); + } + + #[test] + fn test_encode_native_transfer_hybrid_rejected() { + let input = TransferInput { + sender: "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991".into(), + recipient: "0xcf3abaeecfaf42990b8481c03000000000000000000000000000000000000000".into(), + amount: 8_000_000_000, + coins: OwnedCoins::new( + SUI_COIN_TYPE.into(), + vec![Coin { + coin_type: SUI_COIN_TYPE.into(), + balance: 5_000_000_000, + object: Object::mock(), + }], + 4_000_000_000, + ), + send_max: false, + gas: Gas { budget: 25_000_000, price: 750 }, + }; + let err = encode_transfer(&input).expect_err("hybrid native SUI must be rejected"); + assert!(err.to_string().contains("not supported"), "error must explain hybrid is unsupported: {err}"); + } +} diff --git a/core/crates/gem_sui/testdata/balance_coin.json b/core/crates/gem_sui/testdata/balance_coin.json new file mode 100644 index 0000000000..fdc84a76ff --- /dev/null +++ b/core/crates/gem_sui/testdata/balance_coin.json @@ -0,0 +1,6 @@ +{ + "coinType": "0x2::sui::SUI", + "coinObjectCount": 1, + "totalBalance": "52855428706", + "lockedBalance": {} +} diff --git a/core/crates/gem_sui/testdata/balance_tokens.json b/core/crates/gem_sui/testdata/balance_tokens.json new file mode 100644 index 0000000000..5b908113a0 --- /dev/null +++ b/core/crates/gem_sui/testdata/balance_tokens.json @@ -0,0 +1,50 @@ +[ + { + "coinType": "0xce7ff77a83ea0cb6fd39bd8748e2ec89a3f41e8efdc3f4eb123e0ca37b184db2::buck::BUCK", + "coinObjectCount": 1, + "totalBalance": "0", + "lockedBalance": {} + }, + { + "coinType": "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", + "coinObjectCount": 1, + "totalBalance": "3685298", + "lockedBalance": {} + }, + { + "coinType": "0x6864a6f921804860930db6ddbe2e16acdf8504495ea7481637a1c8b9a8fe54b::cetus::CETUS", + "coinObjectCount": 1, + "totalBalance": "0", + "lockedBalance": {} + }, + { + "coinType": "0xda1644f58a955833a15abae24f8cc65b5bd8152ce013fde8be0a6a3dcf51fe36::token::TOKEN", + "coinObjectCount": 1, + "totalBalance": "1000", + "lockedBalance": {} + }, + { + "coinType": "0x909cba62ce96d54de25bec9502de5ca7b4f28901747bbf96b76c2e63ec5f1cba::coin::COIN", + "coinObjectCount": 1, + "totalBalance": "0", + "lockedBalance": {} + }, + { + "coinType": "0xe4239cd951f6c53d9c41e25270d80d31f925ad1655e5ba5b543843d4a66975ee::SUIP::SUIP", + "coinObjectCount": 1, + "totalBalance": "0", + "lockedBalance": {} + }, + { + "coinType": "0x2::sui::SUI", + "coinObjectCount": 1, + "totalBalance": "52855428706", + "lockedBalance": {} + }, + { + "coinType": "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN", + "coinObjectCount": 1, + "totalBalance": "0", + "lockedBalance": {} + } +] diff --git a/core/crates/gem_sui/testdata/mayan_mctp_sui_usdc_to_arbitrum_usdc.json b/core/crates/gem_sui/testdata/mayan_mctp_sui_usdc_to_arbitrum_usdc.json new file mode 100644 index 0000000000..850c8b8481 --- /dev/null +++ b/core/crates/gem_sui/testdata/mayan_mctp_sui_usdc_to_arbitrum_usdc.json @@ -0,0 +1,79 @@ +{ + "digest": "AqXACRuimqMVf4wiVjR3Ch5PBunhQAJY3ZfAMF3MXUsW", + "move_call_packages": [ + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df", + "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df", + "0x2aa6c5d56376c371f88a6cc42e852824994993cb9bab8d3e6450cbe3cb32b94e", + "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df", + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x5306f64e312b581766351c07af79c72fcb1cd25147157fdc2f8ad76de9a3fb6a", + "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df", + "0x05680e9030c147b413a489f7891273acc221d49bd061c433e5771bc170fc37ac" + ], + "effects": { + "status": { + "status": "success" + }, + "gasUsed": { + "computationCost": "164000", + "storageCost": "20725200", + "storageRebate": "24761484", + "nonRefundableStorageFee": "250116" + }, + "gasObject": { + "owner": { + "AddressOwner": "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991" + } + } + }, + "events": [ + { + "type": "0xecf47609d7da919ea98e7fd04f6e0648a0a79b337aaad373fa37aac8febf19c8::treasury::Burn<0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC>", + "parsedJson": { + "amount": "2605390809" + }, + "packageId": "0x2aa6c5d56376c371f88a6cc42e852824994993cb9bab8d3e6450cbe3cb32b94e" + }, + { + "type": "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df::bridge_with_fee::BridgeSubmittedWithFee", + "parsedJson": { + "amount_bridged": "2605390809", + "dest_domain": 3 + }, + "packageId": "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df" + }, + { + "type": "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df::init_order::InitMctpLogged", + "parsedJson": { + "amount_in_initial": "2605390809", + "coin_type": "dba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC" + }, + "packageId": "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df" + }, + { + "type": "0x05680e9030c147b413a489f7891273acc221d49bd061c433e5771bc170fc37ac::referrer_logger::ReferrerSet", + "parsedJson": { + "fee_rate_ref": 50 + }, + "packageId": "0x05680e9030c147b413a489f7891273acc221d49bd061c433e5771bc170fc37ac" + } + ], + "balanceChanges": [ + { + "owner": { + "AddressOwner": "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991" + }, + "coinType": "0x2::sui::SUI", + "amount": "3872284" + }, + { + "owner": { + "AddressOwner": "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991" + }, + "coinType": "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", + "amount": "-2605390809" + } + ], + "timestampMs": "1780011954804" +} diff --git a/core/crates/gem_sui/testdata/sponsored_transfer_sui.json b/core/crates/gem_sui/testdata/sponsored_transfer_sui.json new file mode 100644 index 0000000000..9b6d79836b --- /dev/null +++ b/core/crates/gem_sui/testdata/sponsored_transfer_sui.json @@ -0,0 +1,100 @@ +{ + "digest": "9faKcLRswqqC4VcpVPhv7hhWq1M9ZjxAbXDRK4uZDfdk", + "effects": { + "messageVersion": "v1", + "status": { + "status": "success" + }, + "executedEpoch": "1132", + "gasUsed": { + "computationCost": "100000", + "storageCost": "2964000", + "storageRebate": "1956240", + "nonRefundableStorageFee": "19760" + }, + "modifiedAtVersions": [ + { + "objectId": "0x4e0befad7a8bfb1ae3f6b591fef64305040bb193d4e53c874ef1dffc6fa24907", + "sequenceNumber": "886455659" + }, + { + "objectId": "0xd40827e376fc76799755e7159b6180a5158db4f90130fc34be3dacd65546ec45", + "sequenceNumber": "886455659" + } + ], + "transactionDigest": "9faKcLRswqqC4VcpVPhv7hhWq1M9ZjxAbXDRK4uZDfdk", + "created": [ + { + "owner": { + "AddressOwner": "0x1930a5e729ad95a48e4d9dc2ca8a001f8ed18b20077c083cd6b1d3355a7972a5" + }, + "reference": { + "objectId": "0x32e72e427fca3c16d79dacb1d9472e3845e78c7412270e296f921a5bd46f6907", + "version": 886455660, + "digest": "EnKXBdsVv5fdrhLRAnD1Z39iQnNFM6jsGKf6TTiJUnA2" + } + } + ], + "mutated": [ + { + "owner": { + "AddressOwner": "0x00ea18889868519abd2f238966cab9875750bb2859ed3a34debec37781520138" + }, + "reference": { + "objectId": "0x4e0befad7a8bfb1ae3f6b591fef64305040bb193d4e53c874ef1dffc6fa24907", + "version": 886455660, + "digest": "G2zpWhoM21p35xa3PJipVMtPFjvkYSwZYmL4hWu61rW" + } + }, + { + "owner": { + "AddressOwner": "0x1f6cd55584e6d0c19ae34bfc48b1bd9b1b8a166987e34052cfea7f3c795c6d76" + }, + "reference": { + "objectId": "0xd40827e376fc76799755e7159b6180a5158db4f90130fc34be3dacd65546ec45", + "version": 886455660, + "digest": "Am47A7Xqg6XKjTZdM6Q8FnH8r5UctjPihcXC6Y8Ra7yq" + } + } + ], + "gasObject": { + "owner": { + "AddressOwner": "0x1f6cd55584e6d0c19ae34bfc48b1bd9b1b8a166987e34052cfea7f3c795c6d76" + }, + "reference": { + "objectId": "0xd40827e376fc76799755e7159b6180a5158db4f90130fc34be3dacd65546ec45", + "version": 886455660, + "digest": "Am47A7Xqg6XKjTZdM6Q8FnH8r5UctjPihcXC6Y8Ra7yq" + } + }, + "dependencies": [ + "q6aRnCDXk5QTpycigTRS1fWvzrgDm5MnHe5s3wtARp8" + ] + }, + "events": [], + "balanceChanges": [ + { + "owner": { + "AddressOwner": "0x00ea18889868519abd2f238966cab9875750bb2859ed3a34debec37781520138" + }, + "coinType": "0x2::sui::SUI", + "amount": "-5996594751" + }, + { + "owner": { + "AddressOwner": "0x1930a5e729ad95a48e4d9dc2ca8a001f8ed18b20077c083cd6b1d3355a7972a5" + }, + "coinType": "0x2::sui::SUI", + "amount": "5996594751" + }, + { + "owner": { + "AddressOwner": "0x1f6cd55584e6d0c19ae34bfc48b1bd9b1b8a166987e34052cfea7f3c795c6d76" + }, + "coinType": "0x2::sui::SUI", + "amount": "-1107760" + } + ], + "timestampMs": "1779221367067", + "checkpoint": "277411980" +} diff --git a/core/crates/gem_sui/testdata/stake_grpc.json b/core/crates/gem_sui/testdata/stake_grpc.json new file mode 100644 index 0000000000..9452ea44c2 --- /dev/null +++ b/core/crates/gem_sui/testdata/stake_grpc.json @@ -0,0 +1,42 @@ +{ + "digest": "DXKezMGJZaxJRC6a6zCr3JdfquYGxgU1zjV4xrNAaCFB", + "effects": { + "status": { + "status": "success" + }, + "gasUsed": { + "computationCost": "110000", + "storageCost": "922191600", + "storageRebate": "911690604", + "nonRefundableStorageFee": "9208996" + }, + "gasObject": { + "owner": { + "AddressOwner": "0x1930a5e729ad95a48e4d9dc2ca8a001f8ed18b20077c083cd6b1d3355a7972a5" + } + } + }, + "balanceChanges": [ + { + "owner": { + "AddressOwner": "0x1930a5e729ad95a48e4d9dc2ca8a001f8ed18b20077c083cd6b1d3355a7972a5" + }, + "coinType": "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI", + "amount": "-2010610996" + } + ], + "events": [ + { + "type": "0x0000000000000000000000000000000000000000000000000000000000000003::validator::StakingRequestEvent", + "parsedJson": { + "amount": "2000000000", + "epoch": "1130", + "pool_id": "0xb4ca70974bd2877216c6d5838cad197a8dcfda0cf800ab4223d2cccf251dd670", + "staker_address": "0x1930a5e729ad95a48e4d9dc2ca8a001f8ed18b20077c083cd6b1d3355a7972a5", + "validator_address": "0xbba318294a51ddeafa50c335c8e77202170e1f272599a2edc40592100863f638" + }, + "packageId": "0x0000000000000000000000000000000000000000000000000000000000000003" + } + ], + "timestampMs": "1778964551487" +} diff --git a/core/crates/gem_sui/testdata/stakes.json b/core/crates/gem_sui/testdata/stakes.json new file mode 100644 index 0000000000..e9149935f3 --- /dev/null +++ b/core/crates/gem_sui/testdata/stakes.json @@ -0,0 +1,60 @@ +[ + { + "validatorAddress": "0x885c0345bbf4441f39b98caf2295640a4dc3696ee9e8bc68f2101ca5e6f9bbf1", + "stakingPool": "0x1e080de7950fe445674d33f40c7b9a601b696665e4e6a84bd3930b7c007bacbf", + "stakes": [ + { + "stakedSuiId": "0x2469ddb7ef77fed5fed57b95681b90e10dac9c3a86c0adc06041fcdf74dbccdf", + "stakeRequestEpoch": "402", + "stakeActiveEpoch": "403", + "principal": "1000000000", + "status": "Active", + "estimatedReward": "33444860" + }, + { + "stakedSuiId": "0x70e8879504c7e7293e938772a08dbbf7065d06d70bbbcff612e71f00f7e8026a", + "stakeRequestEpoch": "300", + "stakeActiveEpoch": "301", + "principal": "2100000000", + "status": "Active", + "estimatedReward": "90731114" + } + ] + }, + { + "validatorAddress": "0x184fbf9d6c2d2d3f27e27fe3ca2d3b4080bd406221e345e2d36633e638e988e1", + "stakingPool": "0x2f52ce5bfcc5d517a3bdc0c154ab1cbd3757822c5db6a3fa514b9cc81697f999", + "stakes": [ + { + "stakedSuiId": "0x7db8bd3f7effd33b156d55eb2d5459eef07ba1eaddec7a68b7d4661d2fafa146", + "stakeRequestEpoch": "299", + "stakeActiveEpoch": "300", + "principal": "2000000000", + "status": "Active", + "estimatedReward": "88206530" + } + ] + }, + { + "validatorAddress": "0xbba318294a51ddeafa50c335c8e77202170e1f272599a2edc40592100863f638", + "stakingPool": "0xb4ca70974bd2877216c6d5838cad197a8dcfda0cf800ab4223d2cccf251dd670", + "stakes": [ + { + "stakedSuiId": "0x1a392a94d27e7ac05a9e0442f5983ad4c7349fbc34f2092daa17eded01947743", + "stakeRequestEpoch": "859", + "stakeActiveEpoch": "860", + "principal": "1000000000", + "status": "Active", + "estimatedReward": "369417" + }, + { + "stakedSuiId": "0x32c6c9d1de51d1df1d69687ee29c9759c06ae48e6dbb024e2cd81499b4058d51", + "stakeRequestEpoch": "301", + "stakeActiveEpoch": "302", + "principal": "2690000000", + "status": "Active", + "estimatedReward": "110732582" + } + ] + } +] diff --git a/core/crates/gem_sui/testdata/transaction_builder_json.json b/core/crates/gem_sui/testdata/transaction_builder_json.json new file mode 100644 index 0000000000..5fa77ef8c1 --- /dev/null +++ b/core/crates/gem_sui/testdata/transaction_builder_json.json @@ -0,0 +1,47 @@ +{ + "version": 2, + "inputs": [ + { + "Pure": { + "bytes": "AQAAAAAAAAA=" + } + }, + { + "UnresolvedObject": { + "objectId": "0x0000000000000000000000000000000000000000000000000000000000000006" + } + } + ], + "commands": [ + { + "SplitCoins": { + "coin": { + "GasCoin": true + }, + "amounts": [ + { + "Input": 0 + } + ] + } + }, + { + "MoveCall": { + "package": "0x2", + "module": "coin", + "function": "value", + "typeArguments": [ + "0x2::sui::SUI" + ], + "arguments": [ + { + "NestedResult": [ + 0, + 0 + ] + } + ] + } + } + ] +} diff --git a/core/crates/gem_sui/testdata/transfer_sui.json b/core/crates/gem_sui/testdata/transfer_sui.json new file mode 100644 index 0000000000..07b8cd0e2a --- /dev/null +++ b/core/crates/gem_sui/testdata/transfer_sui.json @@ -0,0 +1,79 @@ +{ + "digest": "CJ16PEqq49KFp758iEVwxEkd3CwP7zDfqGYLuLuu9Z63", + "effects": { + "messageVersion": "v1", + "status": { + "status": "success" + }, + "executedEpoch": "704", + "gasUsed": { + "computationCost": "747000", + "storageCost": "1976000", + "storageRebate": "978120", + "nonRefundableStorageFee": "9880" + }, + "modifiedAtVersions": [ + { + "objectId": "0xf6c8e0049fe442feac25a743681180d4feb5211bc73e14083c6b4956ea2f1936", + "sequenceNumber": "476673345" + } + ], + "transactionDigest": "CJ16PEqq49KFp758iEVwxEkd3CwP7zDfqGYLuLuu9Z63", + "created": [ + { + "owner": { + "AddressOwner": "0x9d6b98b18fd26b5efeec68d020dcf1be7a94c2c315353779bc6b3aed44188ddf" + }, + "reference": { + "objectId": "0x16ee212488cd60974b1a39fc8bdc582f1b290c9c9b8f36731e0ed94a0e14d480", + "version": 476673346, + "digest": "qZfk8pBqpiGoPhY5XAzUJimFAC2beC5CjVtah4zn59x" + } + } + ], + "mutated": [ + { + "owner": { + "AddressOwner": "0x93f65b8c16c263343bbf66cf9f8eef69cb1dbc92d13f0c331b0dcaeb76b4aab6" + }, + "reference": { + "objectId": "0xf6c8e0049fe442feac25a743681180d4feb5211bc73e14083c6b4956ea2f1936", + "version": 476673346, + "digest": "CbUAvetQsiezjj6y3N2suryGnaRbTebmd5HNh2s54XGT" + } + } + ], + "gasObject": { + "owner": { + "AddressOwner": "0x93f65b8c16c263343bbf66cf9f8eef69cb1dbc92d13f0c331b0dcaeb76b4aab6" + }, + "reference": { + "objectId": "0xf6c8e0049fe442feac25a743681180d4feb5211bc73e14083c6b4956ea2f1936", + "version": 476673346, + "digest": "CbUAvetQsiezjj6y3N2suryGnaRbTebmd5HNh2s54XGT" + } + }, + "dependencies": [ + "AqtTWk11YTdr5fEMpiv3VA16hB3EdTJAd9y8MM1UcvXW" + ] + }, + "events": [], + "balanceChanges": [ + { + "owner": { + "AddressOwner": "0x93f65b8c16c263343bbf66cf9f8eef69cb1dbc92d13f0c331b0dcaeb76b4aab6" + }, + "coinType": "0x2::sui::SUI", + "amount": "-101744880" + }, + { + "owner": { + "AddressOwner": "0x9d6b98b18fd26b5efeec68d020dcf1be7a94c2c315353779bc6b3aed44188ddf" + }, + "coinType": "0x2::sui::SUI", + "amount": "100000000" + } + ], + "timestampMs": "1742168333886", + "checkpoint": "123444604" +} diff --git a/core/crates/gem_sui/testdata/transfer_token_contract.json b/core/crates/gem_sui/testdata/transfer_token_contract.json new file mode 100644 index 0000000000..4da88fc81e --- /dev/null +++ b/core/crates/gem_sui/testdata/transfer_token_contract.json @@ -0,0 +1,56 @@ +{ + "digest": "7HzEQGTN95E8CKSrwLghNCsx1ikgQMRLWCQpZeeTuYen", + "effects": { + "messageVersion": "v1", + "status": { + "status": "success" + }, + "gasUsed": { + "computationCost": "553000", + "storageCost": "3632800", + "storageRebate": "7840008", + "nonRefundableStorageFee": "79192" + }, + "gasObject": { + "owner": { + "AddressOwner": "0x28bba19f952fa1503c3b53b9c7aa811b14ad27b33aaa5a5abc95b9caceb05f9f" + } + } + }, + "events": [ + { + "type": "0x73fada5297ef08f7e7fd2413bbcd3253ace9d730b5be04bcde192d436d8aa323::timevy_tipping::TimevyWithTip", + "parsedJson": { + "amount": "10000000", + "memory_db_id": [], + "owner": "0x9707c80fdcee7b7eedd3e285ec263e626dda91b2592895bbe5ff3a9c8e31bf34", + "tipper": "0x28bba19f952fa1503c3b53b9c7aa811b14ad27b33aaa5a5abc95b9caceb05f9f" + }, + "packageId": "0x81563ae495b935c0c0127160083dff653e14c280c0d5bc00ad81865b5774cd65" + } + ], + "balanceChanges": [ + { + "owner": { + "AddressOwner": "0x28bba19f952fa1503c3b53b9c7aa811b14ad27b33aaa5a5abc95b9caceb05f9f" + }, + "coinType": "0x2::sui::SUI", + "amount": "3654208" + }, + { + "owner": { + "AddressOwner": "0x28bba19f952fa1503c3b53b9c7aa811b14ad27b33aaa5a5abc95b9caceb05f9f" + }, + "coinType": "0x73fada5297ef08f7e7fd2413bbcd3253ace9d730b5be04bcde192d436d8aa323::tvyn::TVYN", + "amount": "-10000000" + }, + { + "owner": { + "AddressOwner": "0x9707c80fdcee7b7eedd3e285ec263e626dda91b2592895bbe5ff3a9c8e31bf34" + }, + "coinType": "0x73fada5297ef08f7e7fd2413bbcd3253ace9d730b5be04bcde192d436d8aa323::tvyn::TVYN", + "amount": "10000000" + } + ], + "timestampMs": "1773670985903" +} diff --git a/core/crates/gem_ton/Cargo.toml b/core/crates/gem_ton/Cargo.toml new file mode 100644 index 0000000000..6d4f6a23de --- /dev/null +++ b/core/crates/gem_ton/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "gem_ton" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +rpc = [ + "dep:async-trait", + "dep:gem_client", + "dep:chain_traits", + "dep:futures", +] +tvm = ["dep:gem_hash"] +signer = ["tvm", "dep:signer"] +reqwest = ["gem_client/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "primitives/testkit", "settings/testkit"] + +[dependencies] +hex = { workspace = true } +crc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +primitives = { path = "../primitives" } +num-bigint = { workspace = true } +gem_encoding = { path = "../gem_encoding" } + +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } + +# Optional RPC dependencies +async-trait = { workspace = true, optional = true } +gem_client = { path = "../gem_client", optional = true } +chain_traits = { path = "../chain_traits", optional = true } +futures = { workspace = true, optional = true } + +# Optional signer dependencies +signer = { path = "../signer", optional = true } +gem_hash = { path = "../gem_hash", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } +reqwest = { workspace = true } +settings = { path = "../settings", features = ["testkit"] } +primitives = { path = "../primitives", features = ["testkit"] } diff --git a/core/crates/gem_ton/src/address.rs b/core/crates/gem_ton/src/address.rs new file mode 100644 index 0000000000..3768275924 --- /dev/null +++ b/core/crates/gem_ton/src/address.rs @@ -0,0 +1,288 @@ +use std::fmt; +use std::str::FromStr; + +use crc::Crc; +use gem_encoding::{decode_base64_no_pad, decode_base64_url, encode_base64_url}; +use primitives::{Address as AddressTrait, AddressError, SignerError}; +use serde::{Deserialize, Deserializer, de::Error as _}; + +#[cfg(feature = "tvm")] +use crate::tvm::{BagOfCells, BitReader, Cell, CellBuilder, TvmError}; + +type Workchain = i32; +type HashPart = [u8; 32]; +type RawBytes = [u8; 33]; + +const TAG_BOUNCEABLE_MAINNET: u8 = 0x11; +const TAG_NON_BOUNCEABLE_MAINNET: u8 = TAG_BOUNCEABLE_MAINNET | 0x40; +const RAW_ADDRESS_LEN: usize = 33; +const USER_FRIENDLY_ADDRESS_LEN: usize = 36; + +fn crc16(slice: &[u8]) -> u16 { + Crc::::new(&crc::CRC_16_XMODEM).checksum(slice) +} + +#[derive(Clone, Copy, Eq, PartialEq, Hash)] +pub struct Address { + bytes: RawBytes, +} + +impl Address { + pub fn new(workchain: Workchain, hash_part: HashPart) -> Self { + let mut bytes = [0u8; RAW_ADDRESS_LEN]; + bytes[0] = workchain as i8 as u8; + bytes[1..].copy_from_slice(&hash_part); + Self { bytes } + } + + pub fn workchain(&self) -> Workchain { + self.bytes[0] as i8 as i32 + } + + pub fn hash_part(&self) -> &HashPart { + self.bytes[1..].try_into().unwrap() + } + + pub fn try_parse_base64(base64: &str) -> Option { + let bytes = decode_base64_url(base64).or_else(|_| decode_base64_no_pad(base64)).ok()?; + if bytes.len() != USER_FRIENDLY_ADDRESS_LEN { + return None; + } + let expected_crc = u16::from_be_bytes(bytes[34..36].try_into().ok()?); + if expected_crc != crc16(&bytes[..34]) { + return None; + } + let raw_bytes: RawBytes = bytes[1..RAW_ADDRESS_LEN + 1].try_into().ok()?; + Some(Self { bytes: raw_bytes }) + } + + pub fn try_parse_hex(hex_str: &str) -> Option { + let (workchain, hash_part) = hex_str.split_once(':')?; + let workchain = workchain.parse::().ok()?; + let hash_part: HashPart = hex::decode(hash_part).ok()?.try_into().ok()?; + Some(Self::new(workchain, hash_part)) + } + + pub fn parse(value: &str) -> Result { + ::try_parse(value).ok_or_else(|| AddressError::new("invalid TON address")) + } + + pub fn ensure_matches(claimed: Option<&str>, actual: &str) -> Result<(), SignerError> { + let Some(claimed) = claimed.filter(|value| !value.is_empty()) else { + return Ok(()); + }; + if Self::parse(claimed)? != Self::parse(actual)? { + return Err(SignerError::invalid_input("TON from does not match signer address")); + } + Ok(()) + } + + #[cfg(feature = "tvm")] + pub fn to_cell(&self) -> Result { + let mut builder = CellBuilder::new(); + builder.store_address(self)?; + builder.build() + } + + #[cfg(feature = "tvm")] + pub fn to_boc_base64(&self) -> Result { + BagOfCells::from_root(self.to_cell()?).to_base64(true) + } + + #[cfg(feature = "tvm")] + pub fn from_cell(cell: &Cell) -> Result { + let mut reader = BitReader::from_bits(&cell.data, cell.bit_len)?; + let tag = reader.read_uint(2)?; + if tag == 0 { + return Err(TvmError::new("address cell contains null address")); + } + if tag != 0b10 { + return Err(TvmError::new("address cell must contain std address")); + } + if reader.read_bit()? { + return Err(TvmError::new("anycast addresses are not supported")); + } + + let workchain = reader.read_u8()? as i8 as i32; + let mut hash_part = [0u8; 32]; + for byte in &mut hash_part { + *byte = reader.read_u8()?; + } + Ok(Self::new(workchain, hash_part)) + } + + #[cfg(feature = "tvm")] + pub fn from_boc_base64(value: &str) -> Result { + let root = BagOfCells::parse_base64_root(value)?; + Self::from_cell(root.as_ref()) + } + + fn encode_user_friendly(&self, flag: u8) -> String { + let mut buffer = [0u8; USER_FRIENDLY_ADDRESS_LEN]; + + buffer[0] = flag; + buffer[1..RAW_ADDRESS_LEN + 1].copy_from_slice(&self.bytes); + + let crc = crc16(&buffer[..RAW_ADDRESS_LEN + 1]); + buffer[34] = ((crc >> 8) & 0xFF) as u8; + buffer[35] = (crc & 0xFF) as u8; + + encode_base64_url(&buffer) + } + + pub fn encode_bounceable(&self) -> String { + self.encode_user_friendly(TAG_BOUNCEABLE_MAINNET) + } + + pub fn encode_non_bounceable(&self) -> String { + self.encode_user_friendly(TAG_NON_BOUNCEABLE_MAINNET) + } +} + +impl FromStr for Address { + type Err = AddressError; + + fn from_str(address: &str) -> Result { + ::from_str(address) + } +} + +impl fmt::Display for Address { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", ::encode(self)) + } +} + +impl fmt::Debug for Address { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Address") + .field("workchain", &self.workchain()) + .field("hash_part", &hex::encode(self.hash_part())) + .finish() + } +} + +impl<'de> Deserialize<'de> for Address { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(D::Error::custom) + } +} + +impl AddressTrait for Address { + fn try_parse(address: &str) -> Option { + Self::try_parse_base64(address).or_else(|| Self::try_parse_hex(address)) + } + + fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + fn encode(&self) -> String { + self.encode_bounceable() + } +} + +pub fn validate_address(address: &str) -> bool { + Address::is_valid(address) +} + +pub fn hex_to_base64_address(hex_str: &str) -> Option { + Address::try_parse_hex(hex_str).map(|address| address.encode()) +} + +pub fn base64_to_hex_address(base64_str: &str) -> Option { + Address::try_parse_base64(base64_str).map(|address| format!("{}:{}", address.workchain(), hex::encode(address.hash_part()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_address() { + let hex = "0:8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78"; + let encoded = "EQCOh0t62bvrv8SIELiTnJj1BYAkbxmYIEDbyyU8TD2veND8"; + let address = Address::try_parse_hex(hex).unwrap(); + + assert_eq!(address.encode(), encoded); + assert_eq!(
::from_str(hex).unwrap(), address); + assert_eq!(
::from_str(encoded).unwrap(), address); + assert_eq!(Address::try_parse(encoded), Some(address)); + assert!(Address::is_valid(encoded)); + assert!(validate_address(encoded)); + assert_eq!(address.as_bytes().len(), RAW_ADDRESS_LEN); + assert_eq!(address.workchain(), 0); + assert_eq!(hex::encode(address.hash_part()), "8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78"); + } + + #[test] + fn test_address_serde() { + let hex = "0:8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78"; + let encoded = "EQCOh0t62bvrv8SIELiTnJj1BYAkbxmYIEDbyyU8TD2veND8"; + let expected = Address::try_parse_hex(hex).unwrap(); + + let from_hex: Address = serde_json::from_value(serde_json::Value::String(hex.to_string())).unwrap(); + let from_encoded: Address = serde_json::from_value(serde_json::Value::String(encoded.to_string())).unwrap(); + + assert_eq!(from_hex, expected); + assert_eq!(from_encoded, expected); + assert!(serde_json::from_value::
(serde_json::Value::String("invalid".to_string())).is_err()); + } + + #[test] + fn test_hex_to_base64_address() { + let addr = "0:8c50a91220a5ccf086a1b2113b1a78787555f02b20d3fa6e97ba1acd710dbdaa"; + let result = hex_to_base64_address(addr).unwrap(); + + assert_eq!(result, "EQCMUKkSIKXM8IahshE7Gnh4dVXwKyDT-m6XuhrNcQ29qvOh"); + } + + #[test] + fn test_invalid_addresses() { + assert!(Address::try_parse_hex("invalid").is_none()); + assert!(Address::try_parse_hex("abc:8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78").is_none()); + assert!(Address::try_parse_hex("0:invalid_hex").is_none()); + assert!(Address::try_parse_hex("0:abcd1234").is_none()); + assert!(!Address::is_valid("invalid")); + assert!(!validate_address("invalid")); + assert!(Address::try_parse("invalid").is_none()); + } + + #[test] + fn test_base64_to_hex_address() { + let base64 = "EQCOh0t62bvrv8SIELiTnJj1BYAkbxmYIEDbyyU8TD2veND8"; + let hex = base64_to_hex_address(base64).unwrap(); + + assert_eq!(hex, "0:8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78"); + } + + #[test] + fn test_from_base64_url() { + let addr = Address::try_parse_base64("UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg").unwrap(); + + assert_eq!(addr.workchain(), 0); + assert_eq!(hex::encode(addr.hash_part()), "58d5c54fbb8488af7eaad0cdc759ca8f6ff79fc9555106c1339b037ec0a40347"); + } + + #[test] + fn test_round_trip_conversion() { + let original_hex = "0:0e97797708411c29a3cb1f3f810ef4f83f41d990838f7f93ce7082c4ff9aa026"; + let base64 = hex_to_base64_address(original_hex).unwrap(); + let decoded_hex = base64_to_hex_address(&base64).unwrap(); + + assert_eq!(original_hex, decoded_hex); + } + + #[cfg(feature = "tvm")] + #[test] + fn test_address_cell_roundtrip() { + let address = Address::parse("EQCSLWJ9fY7b0A5OI72wxUp27l4fRlc6GvRBeFf6PiPpH4p3").unwrap(); + let boc = address.to_boc_base64().unwrap(); + + assert_eq!(Address::from_boc_base64(&boc).unwrap(), address); + } +} diff --git a/core/crates/gem_ton/src/constants.rs b/core/crates/gem_ton/src/constants.rs new file mode 100644 index 0000000000..3b493e831b --- /dev/null +++ b/core/crates/gem_ton/src/constants.rs @@ -0,0 +1,24 @@ +// Transaction opcodes +pub const JETTON_TRANSFER_OPCODE: u32 = 0x0f8a7ea5; +#[cfg(feature = "signer")] +pub(crate) const NFT_TRANSFER_OPCODE: u32 = 0x5fcc3d14; + +// NFT transfer message amounts +#[cfg(feature = "signer")] +pub(crate) const NFT_TRANSFER_FORWARD_AMOUNT: u64 = 10_000_000; +#[cfg(any(feature = "rpc", feature = "signer"))] +pub(crate) const NFT_TRANSFER_ATTACHMENT: u64 = 50_000_000; + +// TON proxy jetton used by STON.fi for native TON swaps. +pub const TON_PROXY_JETTON_ADDRESS: &str = "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c"; + +// Failed operation opcodes - operations that may show blockchain success but represent failed application operations +pub const JETTON_FAILED_OPERATION_OPCODE: &str = "0x93be2305"; + +// Additional potential failure opcodes found in test data +pub const FAILED_OPERATION_OPCODES: &[&str] = &[ + "0x93be2305", // Failed jetton operation + "0xd6182fce", // Another failure pattern + "0x77d0fee6", // Another failure pattern + "0x98ce9044", // Failed jetton operation (insufficient funds) +]; diff --git a/core/crates/gem_ton/src/lib.rs b/core/crates/gem_ton/src/lib.rs new file mode 100644 index 0000000000..b492b266b3 --- /dev/null +++ b/core/crates/gem_ton/src/lib.rs @@ -0,0 +1,18 @@ +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; + +#[cfg(feature = "signer")] +pub mod signer; + +#[cfg(feature = "tvm")] +pub mod tvm; + +pub mod address; +pub mod constants; +pub mod models; + +pub use address::{Address, validate_address}; +pub use primitives::AddressError; diff --git a/core/crates/gem_ton/src/models/account.rs b/core/crates/gem_ton/src/models/account.rs new file mode 100644 index 0000000000..55a61b97cc --- /dev/null +++ b/core/crates/gem_ton/src/models/account.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletInfo { + pub seqno: Option, +} diff --git a/core/crates/gem_ton/src/models/balance.rs b/core/crates/gem_ton/src/models/balance.rs new file mode 100644 index 0000000000..b37c0f5da2 --- /dev/null +++ b/core/crates/gem_ton/src/models/balance.rs @@ -0,0 +1,64 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_biguint_from_str, deserialize_u64_from_str}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JettonInfo { + pub jetton_content: JettonContent, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JettonContent { + pub data: JettonInfoMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JettonInfoMetadata { + pub name: Option, + pub symbol: Option, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub decimals: u64, + pub uri: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JettonOffchainMetadata { + pub name: String, + pub symbol: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JettonBalances { + pub balances: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JettonBalance { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub balance: BigUint, + pub jetton: Jetton, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Jetton { + pub address: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimpleJettonBalance { + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub balance: BigUint, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JettonWalletsResponse { + pub jetton_wallets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JettonWallet { + pub address: String, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub balance: BigUint, + pub jetton: String, +} diff --git a/core/crates/gem_ton/src/models/block.rs b/core/crates/gem_ton/src/models/block.rs new file mode 100644 index 0000000000..00edb67a1e --- /dev/null +++ b/core/crates/gem_ton/src/models/block.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Chainhead { + pub last: BlockInfo, + #[serde(rename = "first")] + pub first: BlockInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockInfo { + pub seqno: u64, + pub root_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockRef { + pub workchain: i32, + pub shard: String, + pub seqno: i64, +} diff --git a/core/crates/gem_ton/src/models/fee.rs b/core/crates/gem_ton/src/models/fee.rs new file mode 100644 index 0000000000..4c230dec54 --- /dev/null +++ b/core/crates/gem_ton/src/models/fee.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EstimateFee { + pub address: String, + pub body: String, + pub ignore_chksig: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Fees { + pub source_fees: Fee, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Fee { + pub in_fwd_fee: i32, + pub storage_fee: i32, +} diff --git a/core/crates/gem_ton/src/models/mod.rs b/core/crates/gem_ton/src/models/mod.rs new file mode 100644 index 0000000000..54735acd81 --- /dev/null +++ b/core/crates/gem_ton/src/models/mod.rs @@ -0,0 +1,15 @@ +pub mod account; +pub mod balance; +pub mod block; +pub mod fee; +pub mod nft; +pub mod rpc; +pub mod transaction; + +pub use account::*; +pub use balance::*; +pub use block::*; +pub use fee::*; +pub use nft::*; +pub use rpc::*; +pub use transaction::*; diff --git a/core/crates/gem_ton/src/models/nft.rs b/core/crates/gem_ton/src/models/nft.rs new file mode 100644 index 0000000000..444464cde9 --- /dev/null +++ b/core/crates/gem_ton/src/models/nft.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +#[derive(Deserialize, Clone, Debug)] +pub struct NftItemsResponse { + pub nft_items: Vec, + pub metadata: HashMap, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct NftCollectionsResponse { + pub nft_collections: Vec, + pub metadata: HashMap, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct TokenMetadata { + pub token_info: Vec, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct TokenInfo { + pub valid: bool, + pub name: Option, + pub description: Option, + pub image: Option, + pub extra: Option, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct TokenInfoExtra { + pub domain: Option, + pub marketplace: Option, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct NftItem { + pub address: String, + pub collection_address: Option, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct NftCollection { + pub address: String, +} diff --git a/core/crates/gem_ton/src/models/rpc.rs b/core/crates/gem_ton/src/models/rpc.rs new file mode 100644 index 0000000000..83e159e43c --- /dev/null +++ b/core/crates/gem_ton/src/models/rpc.rs @@ -0,0 +1,174 @@ +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{Error as DeError, SeqAccess, Visitor}, + ser::SerializeSeq, +}; +use serde_json::Value; +use std::fmt; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResult { + pub ok: bool, + pub result: T, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RunGetMethodRequest { + pub address: String, + pub method: String, + pub stack: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum StackArg { + Num(String), + Slice(String), +} + +impl StackArg { + pub fn num(value: impl Into) -> Self { + Self::Num(value.into()) + } + + pub fn slice(value: impl Into) -> Self { + Self::Slice(value.into()) + } +} + +impl Serialize for StackArg { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut seq = serializer.serialize_seq(Some(2))?; + match self { + Self::Num(value) => { + seq.serialize_element("num")?; + seq.serialize_element(value)?; + } + Self::Slice(value) => { + seq.serialize_element("tvm.Slice")?; + seq.serialize_element(value)?; + } + } + seq.end() + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct RunGetMethodResult { + pub stack: Vec, + pub exit_code: i32, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum StackEntry { + Num(String), + Cell { bytes: String }, + Slice { bytes: String }, + Unsupported { kind: String, value: Value }, +} + +impl StackEntry { + pub fn as_num(&self) -> Option<&str> { + match self { + Self::Num(value) => Some(value), + Self::Cell { .. } | Self::Slice { .. } | Self::Unsupported { .. } => None, + } + } + + pub fn as_cell_bytes(&self) -> Option<&str> { + match self { + Self::Cell { bytes } | Self::Slice { bytes } => Some(bytes), + Self::Num(_) | Self::Unsupported { .. } => None, + } + } +} + +impl<'de> Deserialize<'de> for StackEntry { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct StackEntryVisitor; + + impl<'de> Visitor<'de> for StackEntryVisitor { + type Value = StackEntry; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a Toncenter stack entry") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let kind = seq.next_element::()?.ok_or_else(|| DeError::custom("missing stack entry kind"))?; + let value = seq.next_element::()?.ok_or_else(|| DeError::custom("missing stack entry value"))?; + + match kind.as_str() { + "num" => value + .as_str() + .map(|value| StackEntry::Num(value.to_string())) + .ok_or_else(|| DeError::custom("invalid num stack entry")), + "cell" => cell_bytes(value) + .map(|bytes| StackEntry::Cell { bytes }) + .ok_or_else(|| DeError::custom("invalid cell stack entry")), + "tvm.Slice" => cell_bytes(value) + .map(|bytes| StackEntry::Slice { bytes }) + .ok_or_else(|| DeError::custom("invalid slice stack entry")), + _ => Ok(StackEntry::Unsupported { kind, value }), + } + } + } + + deserializer.deserialize_seq(StackEntryVisitor) + } +} + +fn cell_bytes(value: Value) -> Option { + match value { + Value::String(bytes) => Some(bytes), + Value::Object(mut object) => object.remove("bytes").and_then(|value| value.as_str().map(str::to_string)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stack_arg_serialization() { + let request = RunGetMethodRequest { + address: "EQCrouter".to_string(), + method: "get_pool_address".to_string(), + stack: vec![StackArg::num("1000"), StackArg::slice("te6cc")], + }; + + let value = serde_json::to_value(request).unwrap(); + + assert_eq!( + value, + serde_json::json!({ + "address": "EQCrouter", + "method": "get_pool_address", + "stack": [["num", "1000"], ["tvm.Slice", "te6cc"]], + }) + ); + } + + #[test] + fn test_stack_entry_deserialization() { + let stack: Vec = serde_json::from_value(serde_json::json!([ + ["num", "0x7"], + ["cell", {"bytes": "te6cc"}], + ["tvm.Slice", "te6slice"] + ])) + .unwrap(); + + assert_eq!(stack[0].as_num(), Some("0x7")); + assert_eq!(stack[1].as_cell_bytes(), Some("te6cc")); + assert_eq!(stack[2].as_cell_bytes(), Some("te6slice")); + } +} diff --git a/core/crates/gem_ton/src/models/transaction.rs b/core/crates/gem_ton/src/models/transaction.rs new file mode 100644 index 0000000000..f424feb281 --- /dev/null +++ b/core/crates/gem_ton/src/models/transaction.rs @@ -0,0 +1,199 @@ +use std::collections::HashMap; + +use num_bigint::BigUint; +use primitives::TransactionState; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_biguint_from_str; + +use crate::address::Address; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecodedBody { + #[serde(rename = "type")] + pub body_type: Option, + pub comment: Option, + pub text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageTransactions { + pub transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraceResponse { + pub traces: Vec, +} + +impl TraceResponse { + pub fn root_transaction(&self) -> Option<&TransactionMessage> { + self.traces.first()?.root_transaction() + } + + pub fn action_state(&self) -> Option { + self.traces.first().map(Trace::action_state) + } + + pub fn has_actions(&self) -> bool { + self.traces.first().is_some_and(Trace::has_actions) + } +} + +#[derive(Debug, Serialize)] +pub struct TraceByMessageQuery { + pub msg_hash: String, + pub include_actions: bool, +} + +#[derive(Debug, Serialize)] +pub struct TraceByTransactionQuery { + pub tx_hash: String, + pub include_actions: bool, +} + +#[derive(Debug, Serialize)] +pub struct TraceByBlockQuery { + pub mc_seqno: u64, + pub include_actions: bool, + pub limit: usize, + pub offset: usize, + pub sort: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct TraceByAddressQuery { + pub account: String, + pub include_actions: bool, + pub limit: usize, + pub offset: usize, + pub sort: &'static str, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Trace { + pub is_incomplete: bool, + pub actions: Vec, + pub transactions_order: Vec, + pub transactions: HashMap, +} + +impl Trace { + pub fn root_transaction(&self) -> Option<&TransactionMessage> { + let transaction_id = self.transactions_order.first()?; + self.transactions.get(transaction_id) + } + + pub fn has_actions(&self) -> bool { + !self.actions.is_empty() + } + + pub fn action_state(&self) -> TransactionState { + if self.is_incomplete { + return TransactionState::Pending; + } + for action in &self.actions { + if action.success == Some(false) { + return TransactionState::Reverted; + } + } + TransactionState::Confirmed + } +} + +pub const TRACE_ACTION_JETTON_SWAP: &str = "jetton_swap"; +pub const TRACE_ACTION_JETTON_TRANSFER: &str = "jetton_transfer"; +pub const TRACE_ACTION_NFT_TRANSFER: &str = "nft_transfer"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraceAction { + pub success: Option, + #[serde(rename = "type")] + pub action_type: Option, + pub details: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JettonSwapDetails { + pub dex: Option, + pub sender: String, + pub asset_in: Option, + pub asset_out: Option, + pub dex_incoming_transfer: SwapTransfer, + pub dex_outgoing_transfer: SwapTransfer, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SwapTransfer { + pub amount: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JettonTransferDetails { + pub asset: String, + pub sender: String, + pub receiver: String, + pub amount: String, + pub comment: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NftTransferDetails { + pub nft_collection: Address, + pub nft_item: Address, + pub old_owner: Address, + pub new_owner: Address, + pub comment: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionMessage { + pub hash: String, + pub now: i64, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub total_fees: BigUint, + pub description: Option, + pub out_msgs: Vec, + pub in_msg: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutMessage { + pub source: String, + pub destination: Option, + pub value: Option, + #[serde(alias = "opcode")] + pub op_code: Option, + pub comment: Option, + pub decoded_body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionInMessage { + pub source: Option, + pub destination: String, + pub value: Option, + pub opcode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionDescription { + pub aborted: bool, + pub compute_ph: Option, + pub action: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComputePhase { + pub success: Option, + pub exit_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionPhase { + pub success: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BroadcastTransaction { + pub hash: String, +} diff --git a/core/crates/gem_ton/src/provider/balances.rs b/core/crates/gem_ton/src/provider/balances.rs new file mode 100644 index 0000000000..d6592b5fdb --- /dev/null +++ b/core/crates/gem_ton/src/provider/balances.rs @@ -0,0 +1,81 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use std::error::Error; + +use gem_client::Client; +use primitives::AssetBalance; + +use crate::provider::balances_mapper::{map_balance_assets, map_balance_tokens, map_coin_balance}; +use crate::rpc::client::TonClient; + +#[async_trait] +impl ChainBalances for TonClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let balance = self.get_balance(address).await?; + Ok(map_coin_balance(balance)) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let balances = self.get_jetton_wallets(address).await?; + Ok(map_balance_tokens(balances, token_ids)) + } + + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Ok(None) + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + let jetton_wallets = self.get_jetton_wallets(address).await?; + Ok(map_balance_assets(jetton_wallets)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainBalances; + use primitives::{Chain, asset_constants::TON_USDT_TOKEN_ID}; + + #[tokio::test] + async fn test_ton_get_balance_coin() -> Result<(), Box> { + let client = create_ton_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + assert_eq!(balance.asset_id.chain, Chain::Ton); + println!("Balance: {:?}", balance); + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + Ok(()) + } + + #[tokio::test] + async fn test_ton_get_balance_tokens() -> Result<(), Box> { + let client = create_ton_test_client(); + let token_ids = vec![TON_USDT_TOKEN_ID.to_string()]; + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids).await?; + + assert_eq!(balances.len(), 1); + for balance in &balances { + assert_eq!(balance.asset_id.chain, Chain::Ton); + + println!("Token balance: {:?}", balance); + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + } + Ok(()) + } + + #[tokio::test] + async fn test_ton_get_balance_assets() -> Result<(), Box> { + let client = create_ton_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + println!("Assets: {}", assets.len()); + + assert!(!assets.is_empty()); + + for asset in assets { + assert_eq!(asset.asset_id.chain, Chain::Ton); + assert!(asset.balance.available > num_bigint::BigUint::from(0u32)); + } + Ok(()) + } +} diff --git a/core/crates/gem_ton/src/provider/balances_mapper.rs b/core/crates/gem_ton/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..9c563d3385 --- /dev/null +++ b/core/crates/gem_ton/src/provider/balances_mapper.rs @@ -0,0 +1,62 @@ +use crate::models::JettonWalletsResponse; +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Chain}; + +pub fn map_coin_balance(balance: String) -> AssetBalance { + AssetBalance::new(Chain::Ton.as_asset_id(), balance.parse::().unwrap_or_default()) +} + +fn jetton_wallets_to_balances(wallets: JettonWalletsResponse) -> impl Iterator { + wallets.jetton_wallets.into_iter().filter_map(|wallet| { + let jetton_token_id = crate::address::hex_to_base64_address(&wallet.jetton)?; + Some(AssetBalance::new(AssetId::from_token(Chain::Ton, &jetton_token_id), wallet.balance)) + }) +} + +pub fn map_balance_tokens(wallets: JettonWalletsResponse, token_ids: Vec) -> Vec { + jetton_wallets_to_balances(wallets) + .filter(|balance| balance.asset_id.token_id.as_ref().is_some_and(|token_id| token_ids.contains(token_id))) + .collect() +} + +pub fn map_balance_assets(wallets: JettonWalletsResponse) -> Vec { + jetton_wallets_to_balances(wallets).filter(|x| x.balance.available > BigUint::from(0u32)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::asset_constants::TON_USDT_TOKEN_ID; + #[test] + fn test_map_balance_coin() { + let balance = "62709394797".to_string(); + let result = map_coin_balance(balance); + + assert_eq!(result.asset_id, Chain::Ton.as_asset_id()); + assert_eq!(result.balance.available, BigUint::from(62709394797_u64)); + } + + #[test] + fn test_map_balance_tokens() { + let response: JettonWalletsResponse = serde_json::from_str(include_str!("../../testdata/balance_jettons.json")).unwrap(); + + let token_ids = vec![TON_USDT_TOKEN_ID.to_string()]; + let result = map_balance_tokens(response, token_ids); + assert_eq!(result.len(), 1); + assert_eq!(result[0].asset_id, AssetId::from_token(Chain::Ton, TON_USDT_TOKEN_ID)); + assert_eq!(result[0].balance.available, BigUint::from(3201565_u64)); + } + + #[test] + fn test_map_balance_assets() { + let response: JettonWalletsResponse = serde_json::from_str(include_str!("../../testdata/balance_jettons.json")).unwrap(); + + let result = map_balance_assets(response); + assert!(!result.is_empty()); + for balance in &result { + assert_eq!(balance.asset_id.chain, Chain::Ton); + assert!(balance.asset_id.token_id.is_some()); + assert!(balance.balance.available > BigUint::from(0u32)); + } + } +} diff --git a/core/crates/gem_ton/src/provider/mod.rs b/core/crates/gem_ton/src/provider/mod.rs new file mode 100644 index 0000000000..b03eb67d1a --- /dev/null +++ b/core/crates/gem_ton/src/provider/mod.rs @@ -0,0 +1,16 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod request_classifier; +pub mod state; +pub mod state_mapper; +pub mod testkit; +pub mod token; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; diff --git a/core/crates/gem_ton/src/provider/preload.rs b/core/crates/gem_ton/src/provider/preload.rs new file mode 100644 index 0000000000..5e3bc3d26a --- /dev/null +++ b/core/crates/gem_ton/src/provider/preload.rs @@ -0,0 +1,323 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use gem_client::Client; +use num_bigint::BigInt; +use primitives::{ + AssetSubtype, FeeOption, FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, + TransactionPreloadInput, swap::SwapQuoteDataType, +}; +use std::collections::HashMap; +use std::error::Error; + +use crate::address::base64_to_hex_address; +use crate::constants::NFT_TRANSFER_ATTACHMENT; +use crate::rpc::client::TonClient; + +const TON_BASE_FEE: u64 = 10_000_000; +const JETTON_ACCOUNT_FEE_EXISTING: u64 = 100_000_000; +const JETTON_ACCOUNT_FEE_EXISTING_WITH_MEMO: u64 = 60_000_000; +const JETTON_ACCOUNT_CREATION: u64 = 200_000_000; +const SWAP_NATIVE_RESERVE: u64 = 310_000_000; + +pub fn calculate_transaction_fee(input: &TransactionLoadInput, recipient_token_address: Option) -> TransactionFee { + let base_fee = BigInt::from(TON_BASE_FEE); + let mut options = HashMap::new(); + + let fee = match &input.input_type { + TransactionInputType::Transfer(asset) | TransactionInputType::Account(asset, _) => { + transfer_fee(asset.id.token_subtype(), input.memo.as_deref(), recipient_token_address.as_deref(), &base_fee, &mut options) + } + TransactionInputType::TransferNft(_, _) => { + options.insert(FeeOption::TokenAccountCreation, BigInt::from(NFT_TRANSFER_ATTACHMENT)); + base_fee.clone() + } + TransactionInputType::Swap(from_asset, _, swap_data) => match &swap_data.data.data_type { + SwapQuoteDataType::Contract => { + options.insert(FeeOption::TokenAccountCreation, BigInt::from(SWAP_NATIVE_RESERVE)); + base_fee + } + SwapQuoteDataType::Transfer => transfer_fee( + from_asset.id.token_subtype(), + input.memo.as_deref(), + recipient_token_address.as_deref(), + &base_fee, + &mut options, + ), + }, + TransactionInputType::TokenApprove(_, _) => base_fee.clone(), + TransactionInputType::Generic(_, _, _) => base_fee.clone(), + TransactionInputType::Perpetual(_, _) => base_fee.clone(), + _ => base_fee.clone(), + }; + + TransactionFee::new_gas_price_type(GasPriceType::regular(fee.clone()), fee.clone(), BigInt::from(1), options) +} + +fn transfer_fee(asset_subtype: AssetSubtype, memo: Option<&str>, recipient_token_address: Option<&str>, base_fee: &BigInt, options: &mut HashMap) -> BigInt { + match asset_subtype { + AssetSubtype::NATIVE => base_fee.clone(), + AssetSubtype::TOKEN => { + let jetton_fee = if recipient_token_address.is_some() { + if memo.is_some() { + BigInt::from(JETTON_ACCOUNT_FEE_EXISTING_WITH_MEMO) + } else { + BigInt::from(JETTON_ACCOUNT_FEE_EXISTING) + } + } else { + BigInt::from(JETTON_ACCOUNT_CREATION) + }; + options.insert(FeeOption::TokenAccountCreation, jetton_fee); + base_fee.clone() + } + } +} + +#[async_trait] +impl ChainTransactionLoad for TonClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let wallet_info = self.get_wallet_information(input.sender_address.clone()).await?; + let sequence = wallet_info.seqno.unwrap_or(0) as u64; + + let asset = input.input_type.get_asset(); + return match &asset.id.token_subtype() { + AssetSubtype::TOKEN => { + let token_id = asset.id.token_id.as_ref().ok_or("Missing token ID for jetton transaction")?; + let jetton_token_id = base64_to_hex_address(token_id).ok_or("Invalid jetton token ID")?.to_uppercase(); + + let sender_wallets = self.get_jetton_wallets(input.sender_address.clone()); + let recipient_wallets = async { + match get_recipient_jetton_wallet(&input) { + Some(address) => self.get_jetton_wallets(address.to_string()).await.map(Some), + None => Ok(None), + } + }; + let (sender_jetton_wallets, recipient_jetton_wallets) = futures::future::try_join(sender_wallets, recipient_wallets).await?; + + let sender_jetton_wallet_address = sender_jetton_wallets.jetton_wallets.iter().find(|wallet| wallet.jetton == jetton_token_id); + let recipient_jetton_wallet_address = recipient_jetton_wallets + .as_ref() + .and_then(|wallets| wallets.jetton_wallets.iter().find(|wallet| wallet.jetton == jetton_token_id)); + + Ok(TransactionLoadMetadata::Ton { + sender_token_address: sender_jetton_wallet_address.map(|x| x.address.clone()), + recipient_token_address: recipient_jetton_wallet_address.map(|x| x.address.clone()), + sequence, + }) + } + AssetSubtype::NATIVE => Ok(TransactionLoadMetadata::Ton { + sender_token_address: None, + recipient_token_address: None, + sequence, + }), + }; + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let fee = calculate_transaction_fee(&input, input.metadata.get_recipient_token_address()?); + + Ok(TransactionLoadData { fee, metadata: input.metadata }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + Ok(vec![ + FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(10000000))), // 0.01 TON + ]) + } +} + +fn get_recipient_jetton_wallet(input: &TransactionPreloadInput) -> Option<&str> { + match &input.input_type { + TransactionInputType::Swap(_, _, swap_data) => match &swap_data.data.data_type { + SwapQuoteDataType::Transfer => Some(&swap_data.data.to), + SwapQuoteDataType::Contract => None, + }, + _ => Some(&input.destination_address), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use num_bigint::BigInt; + use primitives::{Asset, AssetId, AssetType, Chain, GasPriceType, NFTAsset, SwapProvider, TransactionPreloadInput, swap::SwapData}; + + fn create_input(asset_type: AssetType, memo: Option) -> TransactionLoadInput { + let (token_id, name, symbol, decimals) = match asset_type { + AssetType::NATIVE => (None, "TON".to_string(), "TON".to_string(), 9), + AssetType::JETTON => (Some("test_token".to_string()), "Test Token".to_string(), "TEST".to_string(), 6), + _ => panic!("Unsupported asset type"), + }; + + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset { + id: AssetId { + chain: Chain::Ton, + token_id: token_id.clone(), + }, + chain: Chain::Ton, + token_id, + name, + symbol, + decimals, + asset_type, + }), + sender_address: "test".to_string(), + destination_address: "test".to_string(), + value: "1000".to_string(), + gas_price: GasPriceType::regular(BigInt::from(10_000_000u64)), + memo, + is_max_value: false, + metadata: TransactionLoadMetadata::Ton { + sender_token_address: None, + recipient_token_address: None, + sequence: 0, + }, + } + } + + #[test] + fn test_native_ton() { + let fee = calculate_transaction_fee(&create_input(AssetType::NATIVE, None), None); + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE)); + assert_eq!(fee.options.len(), 0); + } + + #[test] + fn test_native_ton_with_memo() { + let fee = calculate_transaction_fee(&create_input(AssetType::NATIVE, Some("memo".to_string())), None); + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE)); + assert_eq!(fee.options.len(), 0); + } + + #[test] + fn test_ton_nft_transfer_fee_includes_attachment() { + let mut input = create_input(AssetType::NATIVE, None); + input.input_type = TransactionInputType::TransferNft(Asset::from_chain(Chain::Ton), NFTAsset::mock_ton()); + + let fee = calculate_transaction_fee(&input, None); + + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE + NFT_TRANSFER_ATTACHMENT)); + assert_eq!(fee.options.get(&FeeOption::TokenAccountCreation), Some(&BigInt::from(NFT_TRANSFER_ATTACHMENT))); + } + + #[test] + fn test_jetton_existing_account() { + let fee = calculate_transaction_fee(&create_input(AssetType::JETTON, None), Some("existing_account".to_string())); + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE + JETTON_ACCOUNT_FEE_EXISTING)); + assert_eq!(fee.options.get(&FeeOption::TokenAccountCreation), Some(&BigInt::from(JETTON_ACCOUNT_FEE_EXISTING))); + } + + #[test] + fn test_jetton_existing_account_with_memo() { + let fee = calculate_transaction_fee(&create_input(AssetType::JETTON, Some("memo".to_string())), Some("existing_account".to_string())); + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE + JETTON_ACCOUNT_FEE_EXISTING_WITH_MEMO)); + assert_eq!( + fee.options.get(&FeeOption::TokenAccountCreation), + Some(&BigInt::from(JETTON_ACCOUNT_FEE_EXISTING_WITH_MEMO)) + ); + } + + #[test] + fn test_jetton_new_account() { + let fee = calculate_transaction_fee(&create_input(AssetType::JETTON, None), None); + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE + JETTON_ACCOUNT_CREATION)); + assert_eq!(fee.options.get(&FeeOption::TokenAccountCreation), Some(&BigInt::from(JETTON_ACCOUNT_CREATION))); + } + + #[test] + fn test_jetton_new_account_ignores_memo() { + let fee = calculate_transaction_fee(&create_input(AssetType::JETTON, Some("memo".to_string())), None); + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE + JETTON_ACCOUNT_CREATION)); + assert_eq!(fee.options.get(&FeeOption::TokenAccountCreation), Some(&BigInt::from(JETTON_ACCOUNT_CREATION))); + } + + #[test] + fn test_swap_contract_native_fee_includes_native_reserve() { + let swap_data = SwapData::mock_contract(SwapProvider::StonfiV2, "400000000", "1000000", "710000000"); + let input = TransactionLoadInput { + input_type: TransactionInputType::Swap(Asset::from_chain(Chain::Ton), Asset::from_chain(Chain::Ton), swap_data), + value: "400000000".to_string(), + ..create_input(AssetType::NATIVE, None) + }; + + let fee = calculate_transaction_fee(&input, None); + + assert_eq!(fee.fee, BigInt::from(320000000u64)); + assert_eq!(fee.options.get(&FeeOption::TokenAccountCreation), Some(&BigInt::from(310000000u64))); + } + + #[test] + fn test_swap_contract_jetton_fee_includes_native_reserve() { + let from_asset = Asset::mock_ton_usdt(); + let swap_data = SwapData::mock_contract(SwapProvider::StonfiV2, "2000000", "400000000", "300000000"); + let input = TransactionLoadInput { + input_type: TransactionInputType::Swap(from_asset, Asset::from_chain(Chain::Ton), swap_data), + value: "2000000".to_string(), + ..create_input(AssetType::JETTON, None) + }; + + let fee = calculate_transaction_fee(&input, None); + + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE + SWAP_NATIVE_RESERVE)); + assert_eq!(fee.options.get(&FeeOption::TokenAccountCreation), Some(&BigInt::from(SWAP_NATIVE_RESERVE))); + } + + #[test] + fn test_swap_transfer_native_fee_uses_transfer_fee() { + let swap_data = SwapData::mock_transfer(SwapProvider::NearIntents, "400000000", "1000000", "ton_deposit_address"); + let input = TransactionLoadInput { + input_type: TransactionInputType::Swap(Asset::from_chain(Chain::Ton), Asset::from_chain(Chain::Near), swap_data), + value: "400000000".to_string(), + ..create_input(AssetType::NATIVE, None) + }; + + let fee = calculate_transaction_fee(&input, None); + + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE)); + assert_eq!(fee.options.len(), 0); + } + + #[test] + fn test_swap_transfer_jetton_fee_uses_token_transfer_fee() { + let swap_data = SwapData::mock_transfer(SwapProvider::NearIntents, "2000000", "400000000", "ton_deposit_address"); + let input = TransactionLoadInput { + input_type: TransactionInputType::Swap(Asset::mock_ton_usdt(), Asset::from_chain(Chain::Near), swap_data), + value: "2000000".to_string(), + ..create_input(AssetType::JETTON, None) + }; + + let fee = calculate_transaction_fee(&input, None); + + assert_eq!(fee.fee, BigInt::from(TON_BASE_FEE + JETTON_ACCOUNT_CREATION)); + assert_eq!(fee.options.get(&FeeOption::TokenAccountCreation), Some(&BigInt::from(JETTON_ACCOUNT_CREATION))); + } + + #[test] + fn test_get_recipient_jetton_wallet() { + let transfer = TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::mock_ton_usdt()), + sender_address: "sender".to_string(), + destination_address: "recipient".to_string(), + }; + assert_eq!(get_recipient_jetton_wallet(&transfer), Some("recipient")); + + let swap_data = SwapData::mock_transfer(SwapProvider::NearIntents, "2000000", "400000000", "ton_deposit_address"); + let transfer_swap = TransactionPreloadInput { + input_type: TransactionInputType::Swap(Asset::mock_ton_usdt(), Asset::from_chain(Chain::Ethereum), swap_data), + sender_address: "sender".to_string(), + destination_address: "0xrecipient".to_string(), + }; + assert_eq!(get_recipient_jetton_wallet(&transfer_swap), Some("ton_deposit_address")); + + let contract_swap = TransactionPreloadInput { + input_type: TransactionInputType::Swap( + Asset::mock_ton_usdt(), + Asset::from_chain(Chain::Ton), + SwapData::mock_contract(SwapProvider::StonfiV2, "2000000", "400000000", "300000000"), + ), + sender_address: "sender".to_string(), + destination_address: "recipient".to_string(), + }; + assert_eq!(get_recipient_jetton_wallet(&contract_swap), None); + } +} diff --git a/core/crates/gem_ton/src/provider/request_classifier.rs b/core/crates/gem_ton/src/provider/request_classifier.rs new file mode 100644 index 0000000000..24378af1c2 --- /dev/null +++ b/core/crates/gem_ton/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/api/v2/sendBocReturnHash") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_ton/src/provider/state.rs b/core/crates/gem_ton/src/provider/state.rs new file mode 100644 index 0000000000..b62a2655c0 --- /dev/null +++ b/core/crates/gem_ton/src/provider/state.rs @@ -0,0 +1,61 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; +use primitives::NodeSyncStatus; + +use crate::provider::state_mapper; +use crate::rpc::client::TonClient; + +#[async_trait] +impl ChainState for TonClient { + async fn get_chain_id(&self) -> Result> { + Ok(self.get_master_head().await?.first.root_hash) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_master_head().await?.last.seqno) + } + + async fn get_node_status(&self) -> Result> { + let chainhead = self.get_master_head().await?; + state_mapper::map_node_status(&chainhead) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_ton_get_chain_id() -> Result<(), Box> { + let client = create_ton_test_client(); + let chain_id = client.get_chain_id().await?; + println!("Ton chain ID: {}", chain_id); + assert!(chain_id == "F6OpKZKqvqeFp6CQmFomXNMfMj2EnaUSOXN+Mh+wVWk="); + Ok(()) + } + + #[tokio::test] + async fn test_ton_get_block_latest_number() -> Result<(), Box> { + let client = create_ton_test_client(); + let latest_block = client.get_block_latest_number().await?; + println!("Latest block: {}", latest_block); + assert!(latest_block > 0); + Ok(()) + } + + #[tokio::test] + async fn test_get_node_status() -> Result<(), Box> { + let client = create_ton_test_client(); + let node_status = client.get_node_status().await?; + + assert!(node_status.in_sync); + assert!(node_status.latest_block_number.is_some()); + assert!(node_status.latest_block_number.unwrap_or(0) > 0); + + Ok(()) + } +} diff --git a/core/crates/gem_ton/src/provider/state_mapper.rs b/core/crates/gem_ton/src/provider/state_mapper.rs new file mode 100644 index 0000000000..06f37878f5 --- /dev/null +++ b/core/crates/gem_ton/src/provider/state_mapper.rs @@ -0,0 +1,30 @@ +use crate::models::Chainhead; +use primitives::NodeSyncStatus; +use std::error::Error; + +pub fn map_node_status(chainhead: &Chainhead) -> Result> { + Ok(NodeSyncStatus::synced(chainhead.last.seqno)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{BlockInfo, Chainhead}; + + #[test] + fn test_map_node_status() { + let block_info = BlockInfo { + seqno: 12345, + root_hash: String::new(), + }; + let chainhead = Chainhead { + first: block_info.clone(), + last: block_info, + }; + let mapped = map_node_status(&chainhead).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(12345)); + assert_eq!(mapped.current_block_number, Some(12345)); + } +} diff --git a/core/crates/gem_ton/src/provider/testkit.rs b/core/crates/gem_ton/src/provider/testkit.rs new file mode 100644 index 0000000000..cce43e2371 --- /dev/null +++ b/core/crates/gem_ton/src/provider/testkit.rs @@ -0,0 +1,75 @@ +#[cfg(test)] +use std::collections::HashMap; + +#[cfg(test)] +use crate::models::{Trace, TraceAction, TraceResponse, TransactionMessage}; +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::TonClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "gyjq/7IJ5KpSvZlnwixaS3RjI2xk1+5pup0k++S/yXY="; +#[cfg(test)] +pub const TEST_TRANSACTION_HEX_HASH: &str = "8328eaffb209e4aa52bd9967c22c5a4b7463236c64d7ee69ba9d24fbe4bfc976"; +#[cfg(test)] +pub const FAILED_SWAP_MESSAGE_HASH: &str = "cf2fc2efd8d6f6b018f949b8f07e7e4b898a34a8bd422fcffb76bdc6e947b7e7"; +#[cfg(test)] +pub const FAILED_SWAP_ROOT_TRANSACTION_HASH: &str = "L5Egpf9I3suIl6CdddcmMS44geWLFKgHi3EbBDz7qy8="; +#[cfg(test)] +pub const FAILED_SWAP_ROOT_TRANSACTION_HEX_HASH: &str = "2f9120a5ff48decb8897a09d75d726312e3881e58b14a8078b711b043cfbab2f"; +#[cfg(test)] +pub const SUCCESS_SWAP_MESSAGE_HASH: &str = "e993d4c13053978b6265157561c454ef731274d836e3139ed64fdf58b6635bf7"; +#[cfg(test)] +pub const SUCCESS_SWAP_ROOT_TRANSACTION_HASH: &str = "6ZPUwTBTl4tiZRV1YcRU73MSdNg24xOe1k/fWLZjW/c="; +#[cfg(test)] +pub const SUCCESS_SWAP_ROOT_TRANSACTION_HEX_HASH: &str = "e993d4c13053978b6265157561c454ef731274d836e3139ed64fdf58b6635bf7"; + +#[cfg(test)] +impl TraceResponse { + pub fn mock(transaction: TransactionMessage, is_incomplete: bool, actions: Vec) -> Self { + Self { + traces: vec![Trace { + is_incomplete, + actions, + transactions_order: vec![transaction.hash.clone()], + transactions: HashMap::from([(transaction.hash.clone(), transaction)]), + }], + } + } + + pub fn mock_block_traces() -> Self { + serde_json::from_str(include_str!("../../testdata/block_traces.json")).unwrap() + } + + pub fn mock_block_trace(index: usize) -> Self { + let traces = Self::mock_block_traces(); + + TraceResponse { + traces: vec![traces.traces[index].clone()], + } + } + + pub fn mock_jetton_swap() -> Self { + serde_json::from_str(include_str!("../../testdata/jetton_swap_trace.json")).unwrap() + } + + pub fn mock_jetton_swap_from_jetton_transfer() -> Self { + serde_json::from_str(include_str!("../../testdata/jetton_swap_from_jetton_transfer_trace.json")).unwrap() + } + + pub fn mock_jetton_transfer() -> Self { + serde_json::from_str(include_str!("../../testdata/jetton_transfer_trace.json")).unwrap() + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_ton_test_client() -> TonClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.ton.url, reqwest::Client::new()); + TonClient::new(reqwest_client) +} diff --git a/core/crates/gem_ton/src/provider/token.rs b/core/crates/gem_ton/src/provider/token.rs new file mode 100644 index 0000000000..b95460ab4f --- /dev/null +++ b/core/crates/gem_ton/src/provider/token.rs @@ -0,0 +1,48 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::Asset; + +use crate::rpc::client::TonClient; + +#[async_trait] +impl ChainToken for TonClient { + async fn get_token_data(&self, token_id: String) -> Result> { + self.get_token_data(token_id).await + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + token_id.starts_with("EQ") && token_id.len() >= 40 && token_id.len() <= 60 + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + + #[tokio::test] + async fn test_ton_get_token_data() -> Result<(), Box> { + let client = create_ton_test_client(); + let token_data = client.get_token_data("EQACLXDwit01stiqK9FvYiJo15luVzfD5zU8uwDSq6JXxbP8".to_string()).await?; + + assert_eq!(token_data.name, "Spintria"); + assert_eq!(token_data.symbol, "SP"); + assert_eq!(token_data.decimals, 8); + + Ok(()) + } + + #[tokio::test] + async fn test_ton_get_token_data_offchain_metadata() -> Result<(), Box> { + let client = create_ton_test_client(); + let token_data = client.get_token_data("EQB-RPtAAQeFSGW3gIj0zREh4N92MGXfqFzxAc6TRvu-zvYT".to_string()).await?; + + assert_eq!(token_data.name, "Circle xStock"); + assert_eq!(token_data.symbol, "CRCLx"); + assert_eq!(token_data.decimals, 8); + + Ok(()) + } +} diff --git a/core/crates/gem_ton/src/provider/transaction_broadcast.rs b/core/crates/gem_ton/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..7679c7981f --- /dev/null +++ b/core/crates/gem_ton/src/provider/transaction_broadcast.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{BroadcastProvider, transaction_broadcast_mapper::map_transaction_broadcast_response_from_str, transactions_mapper::map_transaction_broadcast}, + rpc::client::TonClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for TonClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(data).await?; + map_transaction_broadcast(response.result) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_ton/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_ton/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..b28620f2da --- /dev/null +++ b/core/crates/gem_ton/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,9 @@ +use std::error::Error; + +use crate::models::{ApiResult, BroadcastTransaction}; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::>(response)?; + map_transaction_broadcast(response.result) +} diff --git a/core/crates/gem_ton/src/provider/transaction_state.rs b/core/crates/gem_ton/src/provider/transaction_state.rs new file mode 100644 index 0000000000..95d930b70f --- /dev/null +++ b/core/crates/gem_ton/src/provider/transaction_state.rs @@ -0,0 +1,61 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::TonClient}; + +#[async_trait] +impl ChainTransactionState for TonClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let traces = self.get_traces_by_hash(request.id.clone()).await?; + map_transaction_status(request, traces) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use crate::provider::testkit::*; + use chain_traits::ChainTransactionState; + use primitives::{TransactionState, TransactionStateRequest}; + + #[tokio::test] + async fn test_get_traces_by_message() -> Result<(), Box> { + let client = create_ton_test_client(); + let traces = client.get_traces_by_message(FAILED_SWAP_MESSAGE_HASH.to_string()).await?; + let transaction = traces.root_transaction().ok_or("missing root transaction")?; + + assert!(traces.has_actions()); + assert_eq!(transaction.hash.as_str(), FAILED_SWAP_ROOT_TRANSACTION_HASH); + + Ok(()) + } + + #[tokio::test] + async fn test_ton_transaction_status_confirmed() -> Result<(), Box> { + let client = create_ton_test_client(); + let request = TransactionStateRequest::mock_with_id("w7Tz84LDLoQ3HPCuU0DZbj2sCq-eZKH1vse_wm67kxA="); + let update = client.get_transaction_status(request).await?; + + assert_eq!(update.state, TransactionState::Confirmed); + + println!("Transaction update: {:?}", update); + + Ok(()) + } + + #[tokio::test] + async fn test_ton_transaction_status_reverted() -> Result<(), Box> { + let client = create_ton_test_client(); + let request = TransactionStateRequest::mock_with_id("0676f9e79a1e56c52394be74ffc75c6b2268aa0be094307ee651c23fff775952"); + let update = client.get_transaction_status(request).await?; + + assert_eq!(update.state, TransactionState::Reverted); + + println!("Transaction update: {:?}", update); + + Ok(()) + } +} diff --git a/core/crates/gem_ton/src/provider/transaction_state_mapper.rs b/core/crates/gem_ton/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..f2dbca1b53 --- /dev/null +++ b/core/crates/gem_ton/src/provider/transaction_state_mapper.rs @@ -0,0 +1,107 @@ +use std::error::Error; + +use primitives::{TransactionChange, TransactionStateRequest, TransactionUpdate}; + +use crate::models::TraceResponse; +use crate::provider::transactions_mapper::{base64_hash_to_hex, map_transaction_state}; + +pub fn map_transaction_status(request: TransactionStateRequest, traces: TraceResponse) -> Result> { + let transaction = traces.root_transaction().ok_or("Transaction not found")?; + let state = if traces.has_actions() { + traces.action_state().ok_or("Trace not found")? + } else { + map_transaction_state(transaction) + }; + + let mut changes = vec![TransactionChange::NetworkFee(transaction.total_fees.clone().into())]; + if let Some(transaction_hash) = base64_hash_to_hex(&transaction.hash) + && transaction_hash != request.id + { + changes.push(TransactionChange::HashChange { + old: request.id, + new: transaction_hash, + }); + } + + Ok(TransactionUpdate::new(state, changes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{MessageTransactions, TraceAction}; + use crate::provider::testkit::{FAILED_SWAP_MESSAGE_HASH, FAILED_SWAP_ROOT_TRANSACTION_HEX_HASH, SUCCESS_SWAP_MESSAGE_HASH}; + use primitives::TransactionState; + + #[test] + fn test_map_transaction_status_confirmed() { + let request = TransactionStateRequest::mock_with_id("hash"); + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_transfer_state_success.json")).unwrap(); + let traces = TraceResponse::mock(transactions.transactions.first().unwrap().clone(), false, vec![]); + + let update = map_transaction_status(request, traces).unwrap(); + assert_eq!(update.state, TransactionState::Confirmed); + assert!(!update.changes.is_empty()); + } + + #[test] + fn test_ton_transaction_jetton_transfer_reverted() { + let request = TransactionStateRequest::mock_with_id("hash"); + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_transfer_jetton_error_2.json")).unwrap(); + let traces = TraceResponse::mock(transactions.transactions.first().unwrap().clone(), false, vec![]); + + let update = map_transaction_status(request, traces).unwrap(); + assert_eq!(update.state, TransactionState::Reverted); + assert!(!update.changes.is_empty()); + } + + #[test] + fn test_map_transaction_status_success_trace_action() { + let request = TransactionStateRequest::mock_with_id(SUCCESS_SWAP_MESSAGE_HASH); + let traces = TraceResponse::mock_block_trace(0); + + let update = map_transaction_status(request, traces).unwrap(); + assert_eq!(update.state, TransactionState::Confirmed); + assert!(!update.changes.is_empty()); + assert!(!update.changes.iter().any(|c| matches!(c, TransactionChange::HashChange { .. }))); + } + + #[test] + fn test_map_transaction_status_failed_trace_action() { + let request = TransactionStateRequest::mock_with_id(FAILED_SWAP_MESSAGE_HASH); + let traces = TraceResponse::mock_block_trace(1); + let transaction = traces.root_transaction().unwrap().clone(); + + let root_update = map_transaction_status(request.clone(), TraceResponse::mock(transaction, false, vec![])).unwrap(); + assert_eq!(root_update.state, TransactionState::Confirmed); + + let update = map_transaction_status(request, traces).unwrap(); + assert_eq!(update.state, TransactionState::Reverted); + assert!(!update.changes.is_empty()); + + let hash_change = update.changes.iter().find_map(|change| match change { + TransactionChange::HashChange { old, new } => Some((old.as_str(), new.as_str())), + _ => None, + }); + assert_eq!(hash_change, Some((FAILED_SWAP_MESSAGE_HASH, FAILED_SWAP_ROOT_TRANSACTION_HEX_HASH))); + } + + #[test] + fn test_map_transaction_status_incomplete_trace() { + let request = TransactionStateRequest::mock_with_id("hash"); + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_transfer_state_success.json")).unwrap(); + let traces = TraceResponse::mock( + transactions.transactions.first().unwrap().clone(), + true, + vec![TraceAction { + success: Some(true), + action_type: None, + details: None, + }], + ); + + let update = map_transaction_status(request, traces).unwrap(); + assert_eq!(update.state, TransactionState::Pending); + assert!(!update.changes.is_empty()); + } +} diff --git a/core/crates/gem_ton/src/provider/transactions.rs b/core/crates/gem_ton/src/provider/transactions.rs new file mode 100644 index 0000000000..359c0080c4 --- /dev/null +++ b/core/crates/gem_ton/src/provider/transactions.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::{provider::transactions_mapper::map_trace_transactions, rpc::client::TonClient}; + +#[async_trait] +impl ChainTransactions for TonClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let traces = self.get_traces_by_masterchain_block(block).await?; + Ok(map_trace_transactions(traces.traces)) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + let traces = self.get_traces_by_hash(hash).await?; + Ok(map_trace_transactions(traces.traces).into_iter().next()) + } + + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, limit, .. } = request; + let limit = limit.unwrap_or(100); + let traces = self.get_traces_by_address(address, limit).await?; + Ok(map_trace_transactions(traces.traces)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_TRANSACTION_HEX_HASH, TEST_TRANSACTION_ID, create_ton_test_client}; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_get_transactions_by_block() -> Result<(), Box> { + let latest_block = ChainState::get_block_latest_number(&create_ton_test_client()).await?; + let transactions = ChainTransactions::get_transactions_by_block(&create_ton_test_client(), latest_block).await?; + + println!("Latest block: {}, transactions count: {}", latest_block, transactions.len()); + + assert!(!transactions.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_get_transactions_by_address() -> Result<(), Box> { + let transactions = ChainTransactions::get_transactions_by_address(&create_ton_test_client(), TransactionsRequest::new(TEST_ADDRESS.to_string()).with_limit(10)).await?; + println!("Address: {}, transactions count: {}", TEST_ADDRESS, transactions.len()); + + assert!(!transactions.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_transaction_by_hash() -> Result<(), Box> { + let client = create_ton_test_client(); + let transaction = ChainTransactions::get_transaction_by_hash(&client, TEST_TRANSACTION_ID.to_string()).await?.unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_HEX_HASH); + Ok(()) + } +} diff --git a/core/crates/gem_ton/src/provider/transactions_mapper.rs b/core/crates/gem_ton/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..e5ac0b9767 --- /dev/null +++ b/core/crates/gem_ton/src/provider/transactions_mapper.rs @@ -0,0 +1,497 @@ +use crate::address::{Address, hex_to_base64_address}; +use crate::constants::FAILED_OPERATION_OPCODES; +use crate::models::{ + BroadcastTransaction, JettonSwapDetails, JettonTransferDetails, NftTransferDetails, OutMessage, TRACE_ACTION_JETTON_SWAP, TRACE_ACTION_JETTON_TRANSFER, + TRACE_ACTION_NFT_TRANSFER, Trace, TraceAction, TransactionMessage, +}; +use chrono::DateTime; +use gem_encoding::decode_base64; +use primitives::{AssetId, NFTAssetId, Transaction, TransactionNFTTransferMetadata, TransactionState, TransactionSwapMetadata, TransactionType, chain::Chain}; +use std::error::Error; + +pub fn map_transaction_broadcast(broadcast_result: BroadcastTransaction) -> Result> { + let hash_bytes = decode_base64(&broadcast_result.hash)?; + Ok(hex::encode(hash_bytes)) +} + +pub(crate) fn map_transaction_state(transaction: &TransactionMessage) -> TransactionState { + if let Some(description) = &transaction.description { + if description.aborted { + return TransactionState::Failed; + } + if let Some(compute_phase) = &description.compute_ph { + if !compute_phase.success.unwrap_or(false) { + return TransactionState::Failed; + } + if let Some(exit_code) = compute_phase.exit_code + && exit_code != 0 + && exit_code != 1 + { + return TransactionState::Failed; + } + } + if let Some(action) = &description.action + && !action.success.unwrap_or(false) + { + return TransactionState::Failed; + } + } + + if transaction.out_msgs.is_empty() { + return TransactionState::Failed; + } + + if let Some(in_msg) = &transaction.in_msg + && let Some(opcode) = &in_msg.opcode + && FAILED_OPERATION_OPCODES.contains(&opcode.as_str()) + { + return TransactionState::Reverted; + } + + TransactionState::Confirmed +} + +pub(crate) fn base64_hash_to_hex(base64_hash: &str) -> Option { + decode_base64(base64_hash).ok().map(hex::encode) +} + +pub fn map_trace_transactions(traces: Vec) -> Vec { + traces.into_iter().filter_map(map_root_trace_transaction).collect() +} + +struct TransferDetails { + asset_id: AssetId, + from: String, + to: String, + value: String, + transaction_type: TransactionType, + memo: Option, + metadata: Option, +} + +fn map_root_trace_transaction(trace: Trace) -> Option { + let state = if trace.is_incomplete || trace.has_actions() { Some(trace.action_state()) } else { None }; + let root_hash = trace.transactions_order.first()?; + let root = trace.transactions.get(root_hash)?; + + let details = jetton_swap_details(&trace.actions) + .or_else(|| nft_transfer_details(&trace.actions)) + .or_else(|| jetton_transfer_details(&trace.actions)) + .or_else(|| simple_transfer_details(root))?; + + build_transaction(root, state, details) +} + +fn build_transaction(message: &TransactionMessage, state: Option, details: TransferDetails) -> Option { + let fee_asset_id = Chain::Ton.as_asset_id(); + let state = state.unwrap_or_else(|| map_transaction_state(message)); + let created_at = DateTime::from_timestamp(message.now, 0)?; + let hash = base64_hash_to_hex(&message.hash)?; + + Some(Transaction::new( + hash, + details.asset_id, + details.from, + details.to, + None, + details.transaction_type, + state, + message.total_fees.to_string(), + fee_asset_id, + details.value, + details.memo, + details.metadata, + created_at, + )) +} + +fn find_action<'a>(actions: &'a [TraceAction], action_type: &str) -> Option<&'a TraceAction> { + actions + .iter() + .find(|action| action.action_type.as_deref() == Some(action_type) && action.success == Some(true)) +} + +fn jetton_transfer_details(actions: &[TraceAction]) -> Option { + let details: JettonTransferDetails = serde_json::from_value(find_action(actions, TRACE_ACTION_JETTON_TRANSFER)?.details.clone()?).ok()?; + let token_id = hex_to_base64_address(&details.asset)?; + Some(TransferDetails { + asset_id: AssetId::from_token(Chain::Ton, &token_id), + from: parse_address(&details.sender)?, + to: parse_address(&details.receiver)?, + value: details.amount, + transaction_type: TransactionType::Transfer, + memo: details.comment.filter(|comment| !comment.is_empty()), + metadata: None, + }) +} + +fn nft_transfer_details(actions: &[TraceAction]) -> Option { + let details: NftTransferDetails = serde_json::from_value(find_action(actions, TRACE_ACTION_NFT_TRANSFER)?.details.clone()?).ok()?; + let collection = details.nft_collection.encode_bounceable(); + let item = details.nft_item.encode_bounceable(); + let metadata = TransactionNFTTransferMetadata::from_asset_id(NFTAssetId::new(Chain::Ton, &collection, &item)); + let metadata_value = serde_json::to_value(metadata).ok()?; + + Some(TransferDetails { + asset_id: AssetId::from_chain(Chain::Ton), + from: details.old_owner.encode_non_bounceable(), + to: details.new_owner.encode_non_bounceable(), + value: "0".to_string(), + transaction_type: TransactionType::TransferNFT, + memo: details.comment.filter(|comment| !comment.is_empty()), + metadata: Some(metadata_value), + }) +} + +fn jetton_swap_details(actions: &[TraceAction]) -> Option { + let (sender, metadata) = jetton_swap_metadata(actions)?; + let asset_id = metadata.from_asset.clone(); + let value = metadata.from_value.clone(); + let metadata_value = serde_json::to_value(metadata).ok()?; + + Some(TransferDetails { + asset_id, + from: sender.clone(), + to: sender, + value, + transaction_type: TransactionType::Swap, + memo: None, + metadata: Some(metadata_value), + }) +} + +fn jetton_swap_metadata(actions: &[TraceAction]) -> Option<(String, TransactionSwapMetadata)> { + let action = find_action(actions, TRACE_ACTION_JETTON_SWAP)?; + let swap: JettonSwapDetails = action.details.clone().and_then(|value| serde_json::from_value(value).ok())?; + let sender = parse_address(&swap.sender)?; + let (Some(from_asset), Some(to_asset)) = (ton_asset_id(swap.asset_in.as_deref()), ton_asset_id(swap.asset_out.as_deref())) else { + return None; + }; + let metadata = TransactionSwapMetadata { + from_asset, + from_value: swap.dex_incoming_transfer.amount, + to_asset, + to_value: swap.dex_outgoing_transfer.amount, + provider: swap.dex, + }; + Some((sender, metadata)) +} + +fn ton_asset_id(raw_address: Option<&str>) -> Option { + match raw_address { + None => Some(AssetId::from_chain(Chain::Ton)), + Some(hex_address) => hex_to_base64_address(hex_address).map(|token_id| AssetId::from_token(Chain::Ton, &token_id)), + } +} + +fn simple_transfer_details(message: &TransactionMessage) -> Option { + let asset_id = Chain::Ton.as_asset_id(); + + if message.out_msgs.len() == 1 && is_simple_transfer(message.out_msgs.first()?) { + let out_message = message.out_msgs.first()?; + let to = parse_address(out_message.destination.as_deref()?)?; + return Some(TransferDetails { + asset_id, + from: parse_address(&out_message.source)?, + to, + value: out_message.value.clone().unwrap_or_else(|| "0".to_string()), + transaction_type: TransactionType::Transfer, + memo: extract_memo(out_message), + metadata: None, + }); + } + + if message.out_msgs.is_empty() + && let Some(in_msg) = &message.in_msg + && let (Some(value), Some(source)) = (&in_msg.value, &in_msg.source) + && let Ok(value_int) = value.parse::() + && value_int > 0 + { + return Some(TransferDetails { + asset_id, + from: parse_address(source)?, + to: parse_address(&in_msg.destination)?, + value: value.clone(), + transaction_type: TransactionType::Transfer, + memo: None, + metadata: None, + }); + } + + None +} + +fn parse_address(address: &str) -> Option { + Address::try_parse_hex(address).map(|a| a.encode_non_bounceable()) +} + +fn is_simple_transfer(out_message: &crate::models::OutMessage) -> bool { + match &out_message.op_code { + None => true, + Some(op_code) => op_code == "0x00000000" || op_code == "0x0", + } +} + +fn extract_memo(message: &OutMessage) -> Option { + if let Some(comment) = &message.comment + && !comment.is_empty() + { + return Some(comment.clone()); + } + + if let Some(decoded_body) = &message.decoded_body { + if let Some(text) = &decoded_body.text + && !text.is_empty() + { + return Some(text.clone()); + } + if let Some(comment) = &decoded_body.comment + && !comment.is_empty() + { + return Some(comment.clone()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::address::base64_to_hex_address; + use crate::models::{MessageTransactions, TraceResponse}; + use crate::provider::testkit::{FAILED_SWAP_ROOT_TRANSACTION_HEX_HASH, SUCCESS_SWAP_ROOT_TRANSACTION_HEX_HASH, TEST_TRANSACTION_ID}; + use primitives::testkit::signer_mock::TEST_TON_SENDER; + use serde_json::json; + + const NFT_NEW_OWNER: &str = "UQDSkZZueXRl0lUk4hagLa8KrJzZbmtTE_RPZwTDSIw32WNH"; + + #[test] + fn test_transaction_transfer_state_success() { + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_transfer_state_success.json")).unwrap(); + + assert_eq!(transactions.transactions.len(), 1); + let transaction = &transactions.transactions[0]; + let state = map_transaction_state(transaction); + assert_eq!(state, TransactionState::Confirmed); + } + + #[test] + fn test_transaction_status_response_success() { + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_status_response.json")).unwrap(); + + assert_eq!(transactions.transactions.len(), 1); + let transaction = &transactions.transactions[0]; + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + + let state = map_transaction_state(transaction); + assert_eq!(state, TransactionState::Confirmed); + } + + #[test] + fn test_jetton_transfer_failed() { + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_transfer_jetton_error.json")).unwrap(); + + assert_eq!(transactions.transactions.len(), 1); + let transaction = &transactions.transactions[0]; + assert_eq!(transaction.hash, "ZEC9rE/pUvEHGAJVzDn/6QdWevOOR4sA2dN4BaTA8hQ="); + + let state = map_transaction_state(transaction); + assert_eq!(state, TransactionState::Reverted); + } + + #[test] + fn test_jetton_transfer_success() { + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_transfer_jetton_success.json")).unwrap(); + + assert_eq!(transactions.transactions.len(), 1); + let transaction = &transactions.transactions[0]; + assert_eq!(transaction.hash, "X2rQTJQF38kXLWdQL42pP8NKrd2X1YDyp5h7Erq7sBA="); + + let state = map_transaction_state(transaction); + assert_eq!(state, TransactionState::Confirmed); + } + + #[test] + fn test_jetton_transfer_success_2() { + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_transfer_jetton_success_2.json")).unwrap(); + + assert_eq!(transactions.transactions.len(), 1); + let transaction = &transactions.transactions[0]; + assert_eq!(transaction.hash, "pI2WtPJ6516pwuNti1h+Hetg0NZ8C/kBOboRkayUKL8="); + + let state = map_transaction_state(transaction); + assert_eq!(state, TransactionState::Confirmed); + } + + #[test] + fn test_swap_ton_jetton_success() { + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_swap_ton_jetton_success.json")).unwrap(); + + assert_eq!(transactions.transactions.len(), 1); + let transaction = &transactions.transactions[0]; + assert_eq!(transaction.hash, "wsQ2mvEWkMbw3QnyeBl85O+uuUsDNfuWJnc2mBh8lPg="); + + let state = map_transaction_state(transaction); + assert_eq!(state, TransactionState::Confirmed); + } + + #[test] + fn test_swap_jetton_ton_success() { + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_swap_jetton_ton_success.json")).unwrap(); + + assert_eq!(transactions.transactions.len(), 1); + let transaction = &transactions.transactions[0]; + assert_eq!(transaction.hash, "psAXHb1HyvR53f9LHmOzQWohJu3tDRWbxvZbHB1B+iY="); + + let state = map_transaction_state(transaction); + assert_eq!(state, TransactionState::Confirmed); + } + + #[test] + fn test_transaction_with_null_values() { + let transaction: TransactionMessage = serde_json::from_str(include_str!("../../testdata/transaction_null_values.json")).unwrap(); + + assert_eq!(transaction.hash, "MhO9bk6+qCMfveyGBQYvoklath4SA7F/LegdwACJAvg="); + assert_eq!(transaction.out_msgs.len(), 2); + + assert_eq!(transaction.out_msgs[0].value, None); + assert_eq!(transaction.out_msgs[0].destination, None); + + assert_eq!(transaction.out_msgs[1].value, Some("137245095".to_string())); + } + + #[test] + fn test_map_trace_transactions_jetton_swap() { + let traces = TraceResponse::mock_jetton_swap(); + let transactions = map_trace_transactions(traces.traces); + + assert_eq!(transactions.len(), 1); + let transaction = &transactions[0]; + assert_eq!(transaction.transaction_type, TransactionType::Swap); + assert_eq!(transaction.state, TransactionState::Confirmed); + assert_eq!(transaction.from, "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV"); + assert_eq!(transaction.from, transaction.to); + + let metadata = transaction.metadata.as_ref().expect("swap metadata"); + let swap: TransactionSwapMetadata = serde_json::from_value(metadata.clone()).unwrap(); + assert_eq!(swap.from_asset, AssetId::from_chain(Chain::Ton)); + assert_eq!(swap.from_value, "1000000000"); + assert_eq!(swap.to_asset.chain, Chain::Ton); + assert!(swap.to_asset.token_id.is_some()); + assert_eq!(swap.to_value, "2436222"); + assert_eq!(swap.provider.as_deref(), Some("stonfi_v2")); + } + + #[test] + fn test_map_trace_transactions_jetton_swap_from_jetton_transfer() { + let traces = TraceResponse::mock_jetton_swap_from_jetton_transfer(); + let transactions = map_trace_transactions(traces.traces); + + assert_eq!(transactions.len(), 1); + let transaction = &transactions[0]; + assert_eq!(transaction.transaction_type, TransactionType::Swap); + assert_eq!(transaction.state, TransactionState::Confirmed); + assert_eq!(transaction.from, "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV"); + assert_eq!(transaction.from, transaction.to); + assert_eq!(transaction.asset_id.chain, Chain::Ton); + assert_eq!(transaction.asset_id.token_id.as_deref(), Some("EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs")); + assert_eq!(transaction.value, "1000000"); + + let metadata = transaction.metadata.as_ref().unwrap(); + let swap: TransactionSwapMetadata = serde_json::from_value(metadata.clone()).unwrap(); + assert_eq!(swap.from_asset, AssetId::from_token(Chain::Ton, "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs")); + assert_eq!(swap.from_value, "1000000"); + assert_eq!(swap.to_asset, AssetId::from_chain(Chain::Ton)); + assert_eq!(swap.to_value, "476299454"); + assert_eq!(swap.provider.as_deref(), Some("stonfi_v2")); + } + + #[test] + fn test_map_trace_transactions_jetton_transfer() { + let traces = TraceResponse::mock_jetton_transfer(); + let transactions = map_trace_transactions(traces.traces); + + assert_eq!(transactions.len(), 1); + let transaction = &transactions[0]; + + assert_eq!(transaction.transaction_type, TransactionType::Transfer); + assert_eq!(transaction.state, TransactionState::Confirmed); + assert_eq!(transaction.from, "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV"); + assert_eq!(transaction.to, "UQDSkZZueXRl0lUk4hagLa8KrJzZbmtTE_RPZwTDSIw32WNH"); + assert_eq!(transaction.value, "120000"); + assert_eq!(transaction.asset_id.chain, Chain::Ton); + assert_eq!(transaction.asset_id.token_id.as_deref(), Some("EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs")); + assert_eq!(transaction.fee, "472458"); + assert_eq!(transaction.fee_asset_id, AssetId::from_chain(Chain::Ton)); + assert_eq!(transaction.memo, None); + } + + #[test] + fn test_map_trace_transactions_nft_transfer() { + let transactions: MessageTransactions = serde_json::from_str(include_str!("../../testdata/transaction_transfer_state_success.json")).unwrap(); + let root = transactions.transactions.first().unwrap().clone(); + let nft_asset_id = NFTAssetId::mock_ton(); + let traces = TraceResponse::mock( + root, + false, + vec![TraceAction { + success: Some(true), + action_type: Some(TRACE_ACTION_NFT_TRANSFER.to_string()), + details: Some(json!({ + "nft_collection": base64_to_hex_address(&nft_asset_id.contract_address).unwrap(), + "nft_item": base64_to_hex_address(&nft_asset_id.token_id).unwrap(), + "old_owner": base64_to_hex_address(TEST_TON_SENDER).unwrap(), + "new_owner": base64_to_hex_address(NFT_NEW_OWNER).unwrap(), + "comment": "gift", + })), + }], + ); + + let transactions = map_trace_transactions(traces.traces); + + assert_eq!(transactions.len(), 1); + let transaction = &transactions[0]; + assert_eq!(transaction.transaction_type, TransactionType::TransferNFT); + assert_eq!(transaction.state, TransactionState::Confirmed); + assert_eq!(transaction.asset_id, AssetId::from_chain(Chain::Ton)); + assert_eq!(transaction.from, TEST_TON_SENDER); + assert_eq!(transaction.to, NFT_NEW_OWNER); + assert_eq!(transaction.value, "0"); + assert_eq!(transaction.memo.as_deref(), Some("gift")); + assert_eq!(transaction.nft_asset_id(), Some(nft_asset_id)); + } + + #[test] + fn test_map_trace_transactions_nft_transfer_real() { + let traces: TraceResponse = serde_json::from_str(include_str!("../../testdata/nft_transfer_trace.json")).unwrap(); + + let transactions = map_trace_transactions(traces.traces); + + assert_eq!(transactions.len(), 1); + let transaction = &transactions[0]; + assert_eq!(transaction.transaction_type, TransactionType::TransferNFT); + assert_eq!(transaction.state, TransactionState::Confirmed); + assert_eq!(transaction.asset_id, AssetId::from_chain(Chain::Ton)); + assert_eq!(transaction.value, "0"); + + let nft_asset_id = transaction.nft_asset_id().expect("nft asset id"); + assert_eq!(nft_asset_id.chain, Chain::Ton); + assert!(!nft_asset_id.contract_address.is_empty()); + assert!(!nft_asset_id.token_id.is_empty()); + } + + #[test] + fn test_map_trace_transactions_by_block() { + let traces = TraceResponse::mock_block_traces(); + + assert_eq!(traces.traces.len(), 2); + + let transactions = map_trace_transactions(traces.traces); + let hashes = transactions.iter().map(|transaction| transaction.hash.as_str()).collect::>(); + + assert_eq!(hashes, vec![SUCCESS_SWAP_ROOT_TRANSACTION_HEX_HASH, FAILED_SWAP_ROOT_TRANSACTION_HEX_HASH]); + assert_eq!(transactions[0].state, TransactionState::Confirmed); + assert_eq!(transactions[1].state, TransactionState::Reverted); + } +} diff --git a/core/crates/gem_ton/src/rpc/client.rs b/core/crates/gem_ton/src/rpc/client.rs new file mode 100644 index 0000000000..4df7637fe7 --- /dev/null +++ b/core/crates/gem_ton/src/rpc/client.rs @@ -0,0 +1,185 @@ +use std::{collections::HashMap, error::Error}; + +use primitives::{Asset, AssetId, AssetType, chain::Chain}; +use serde::Serialize; + +use chain_traits::{ChainAccount, ChainAddressStatus, ChainPerpetual, ChainStaking, ChainTraits}; +use gem_client::{Client, ClientExt, build_path_with_query}; + +use crate::models::{ + ApiResult, BroadcastTransaction, Chainhead, JettonInfo, JettonOffchainMetadata, JettonWalletsResponse, NftCollectionsResponse, NftItemsResponse, RunGetMethodRequest, + RunGetMethodResult, SimpleJettonBalance, StackArg, TraceByAddressQuery, TraceByBlockQuery, TraceByMessageQuery, TraceByTransactionQuery, TraceResponse, WalletInfo, +}; + +const TONCENTER_V3_BLOCK_LIMIT: usize = 100; +const TONCENTER_SORT_DESC: &str = "desc"; +const TONCENTER_SORT_ASC: &str = "asc"; + +#[derive(Debug)] +pub struct TonClient { + pub client: C, +} + +impl TonClient { + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_master_head(&self) -> Result> { + Ok(self.client.get("/api/v3/masterchainInfo").await?) + } + + pub async fn get_token_info(&self, token_id: String) -> Result, Box> { + Ok(self.client.get(&format!("/api/v2/getTokenData?address={}", token_id)).await?) + } + + pub async fn get_balance(&self, address: String) -> Result> { + let response: ApiResult = self.client.get(&format!("/api/v2/getAddressBalance?address={}", address)).await?; + Ok(response.result) + } + + pub async fn get_wallet_information(&self, address: String) -> Result> { + let response: ApiResult = self.client.get(&format!("/api/v2/getWalletInformation?address={}", address)).await?; + Ok(response.result) + } + + pub async fn get_token_balance(&self, address: String) -> Result> { + Ok(self.client.get(&format!("/api/v2/getTokenData?address={}", address)).await?) + } + + pub async fn get_native_balance(&self, address: String) -> Result> { + Ok(self.client.get(&format!("/api/v2/getAddressBalance?address={}", address)).await?) + } + + pub async fn broadcast_transaction(&self, data: String) -> Result, Box> { + let body = serde_json::json!({ "boc": data }); + Ok(self.client.post("/api/v2/sendBocReturnHash", &body).await?) + } + + pub async fn run_get_method(&self, address: &str, method: &str, stack: Vec) -> Result> { + self.run_get_method_with_headers(address, method, stack, HashMap::new()).await + } + + pub async fn run_get_method_with_headers( + &self, + address: &str, + method: &str, + stack: Vec, + headers: HashMap, + ) -> Result> { + let request = RunGetMethodRequest { + address: address.to_string(), + method: method.to_string(), + stack, + }; + let response: ApiResult = self.client.post_with_headers("/api/v2/runGetMethod", &request, headers).await?; + if !response.ok { + let message = match response.result.as_str() { + Some(message) => message.to_string(), + None => response.result.to_string(), + }; + return Err(format!("TON runGetMethod failed: {message}").into()); + } + Ok(serde_json::from_value(response.result)?) + } + + pub async fn get_traces_by_message(&self, hash: String) -> Result> { + let query = TraceByMessageQuery { + msg_hash: hash, + include_actions: true, + }; + self.get_traces(query).await + } + + pub async fn get_traces_by_transaction(&self, hash: String) -> Result> { + let query = TraceByTransactionQuery { + tx_hash: hash, + include_actions: true, + }; + self.get_traces(query).await + } + + pub async fn get_traces_by_hash(&self, hash: String) -> Result> { + let traces = self.get_traces_by_message(hash.clone()).await?; + if traces.traces.is_empty() { + self.get_traces_by_transaction(hash).await + } else { + Ok(traces) + } + } + + pub async fn get_traces_by_masterchain_block(&self, block: u64) -> Result> { + let query = TraceByBlockQuery { + mc_seqno: block, + include_actions: true, + limit: TONCENTER_V3_BLOCK_LIMIT, + offset: 0, + sort: TONCENTER_SORT_ASC, + }; + self.get_traces(query).await + } + + pub async fn get_traces_by_address(&self, address: String, limit: usize) -> Result> { + let query = TraceByAddressQuery { + account: address, + include_actions: true, + limit, + offset: 0, + sort: TONCENTER_SORT_DESC, + }; + self.get_traces(query).await + } + + async fn get_traces(&self, query: T) -> Result> { + let path = build_path_with_query("/api/v3/traces", &query)?; + Ok(self.client.get(&path).await?) + } + + pub async fn get_jetton_wallets(&self, address: String) -> Result> { + Ok(self.client.get(&format!("/api/v3/jetton/wallets?owner_address={}&limit=100&offset=0", address)).await?) + } + + pub async fn get_nft_items_by_owner(&self, owner_address: &str) -> Result> { + Ok(self.client.get(&format!("/api/v3/nft/items?owner_address={}&limit=1000&offset=0", owner_address)).await?) + } + + pub async fn get_nft_item(&self, address: &str) -> Result> { + Ok(self.client.get(&format!("/api/v3/nft/items?address={}", address)).await?) + } + + pub async fn get_nft_collection(&self, collection_address: &str) -> Result> { + Ok(self.client.get(&format!("/api/v3/nft/collections?collection_address={}", collection_address)).await?) + } + + pub async fn get_token_data(&self, token_id: String) -> Result> { + let token_info = self.get_token_info(token_id.clone()).await?.result; + let data = &token_info.jetton_content.data; + let decimals = data.decimals as i32; + + let (name, symbol) = match (&data.name, &data.symbol) { + (Some(name), Some(symbol)) => (name.clone(), symbol.clone()), + _ => { + let uri = data.uri.as_ref().ok_or("missing jetton metadata uri")?; + self.get_token_metadata_offchain(uri).await? + } + }; + + Ok(Asset::new(AssetId::from_token(Chain::Ton, &token_id), name, symbol, decimals, AssetType::JETTON)) + } + + async fn get_token_metadata_offchain(&self, uri: &str) -> Result<(String, String), Box> { + let metadata: JettonOffchainMetadata = self.client.get_url(uri).await?; + Ok((metadata.name, metadata.symbol)) + } +} + +impl ChainTraits for TonClient {} +impl ChainAccount for TonClient {} +impl ChainPerpetual for TonClient {} +impl ChainAddressStatus for TonClient {} +impl ChainStaking for TonClient {} +impl chain_traits::ChainProvider for TonClient { + fn get_chain(&self) -> primitives::Chain { + Chain::Ton + } +} diff --git a/core/crates/gem_ton/src/rpc/mod.rs b/core/crates/gem_ton/src/rpc/mod.rs new file mode 100644 index 0000000000..f78daa417c --- /dev/null +++ b/core/crates/gem_ton/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::TonClient; diff --git a/core/crates/gem_ton/src/signer/chain_signer.rs b/core/crates/gem_ton/src/signer/chain_signer.rs new file mode 100644 index 0000000000..4f73f1a74c --- /dev/null +++ b/core/crates/gem_ton/src/signer/chain_signer.rs @@ -0,0 +1,35 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use primitives::{ChainSigner, SignerError, SignerInput}; + +use super::signer::TonSigner; + +#[derive(Default)] +pub struct TonChainSigner; + +impl ChainSigner for TonChainSigner { + fn sign_message(&self, message: &[u8], private_key: &[u8]) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| SignerError::InvalidInput(e.to_string()))? + .as_secs(); + let result = TonSigner::new(private_key)?.sign_personal(message, timestamp)?; + Ok(gem_encoding::encode_base64(&result.signature)) + } + + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + TonSigner::new(private_key)?.sign_transfer(input, None) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + TonSigner::new(private_key)?.sign_token_transfer(input, None) + } + + fn sign_nft_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + TonSigner::new(private_key)?.sign_nft_transfer(input, None) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + TonSigner::new(private_key)?.sign_swap(input, None) + } +} diff --git a/core/crates/gem_ton/src/signer/mod.rs b/core/crates/gem_ton/src/signer/mod.rs new file mode 100644 index 0000000000..540f6828d9 --- /dev/null +++ b/core/crates/gem_ton/src/signer/mod.rs @@ -0,0 +1,12 @@ +mod chain_signer; +mod sign_data; +#[allow(clippy::module_inception)] +mod signer; +#[cfg(test)] +pub(crate) mod testkit; +mod transaction; + +pub use chain_signer::TonChainSigner; +pub use sign_data::{TonSignDataPayload, TonSignDataResponse, TonSignMessageData}; +pub use signer::{TonSignResult, TonSigner}; +pub use transaction::WalletV4R2; diff --git a/core/crates/gem_ton/src/signer/sign_data/message.rs b/core/crates/gem_ton/src/signer/sign_data/message.rs new file mode 100644 index 0000000000..febf744c47 --- /dev/null +++ b/core/crates/gem_ton/src/signer/sign_data/message.rs @@ -0,0 +1,153 @@ +use crc::Crc; +use gem_hash::sha2::sha256; +use primitives::SignerError; +use serde::{Deserialize, Serialize}; + +use super::payload::TonSignDataPayload; +use crate::{ + address::Address, + tvm::{BagOfCells, CellBuilder}, +}; + +const SIGN_DATA_PREFIX: &[u8] = b"\xff\xffton-connect/sign-data/"; +const CELL_SIGN_PREFIX: u32 = 0x75569022; +const SCHEMA_CRC32: Crc = Crc::::new(&crc::CRC_32_ISO_HDLC); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TonSignMessageData { + pub payload: TonSignDataPayload, + pub domain: String, + pub address: String, +} + +impl TonSignMessageData { + pub fn new(payload: TonSignDataPayload, domain: String, address: String) -> Self { + Self { payload, domain, address } + } + + pub fn from_value(payload: serde_json::Value, domain: String, address: String) -> Result { + let payload: TonSignDataPayload = serde_json::from_value(payload).map_err(SignerError::from)?; + Ok(Self { payload, domain, address }) + } + + pub fn from_bytes(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(SignerError::from) + } + + pub fn to_bytes(&self) -> Vec { + serde_json::to_vec(self).unwrap_or_default() + } + + pub fn hash(&self, timestamp: u64) -> Result, SignerError> { + self.hash_with_address(timestamp, &Address::parse(&self.address)?) + } + + pub fn hash_with_address(&self, timestamp: u64, address: &Address) -> Result, SignerError> { + match &self.payload { + TonSignDataPayload::Cell { schema, cell } => self.cell_payload_hash(schema, cell, address, timestamp), + TonSignDataPayload::Text { .. } | TonSignDataPayload::Binary { .. } => { + let domain_bytes = self.domain.as_bytes(); + let (type_prefix, payload_bytes) = self.payload.encode()?; + + let mut msg = Vec::new(); + msg.extend_from_slice(SIGN_DATA_PREFIX); + msg.extend_from_slice(&address.workchain().to_be_bytes()); + msg.extend_from_slice(address.hash_part()); + msg.extend_from_slice(&(domain_bytes.len() as u32).to_be_bytes()); + msg.extend_from_slice(domain_bytes); + msg.extend_from_slice(×tamp.to_be_bytes()); + msg.extend_from_slice(type_prefix.as_bytes()); + msg.extend_from_slice(&(payload_bytes.len() as u32).to_be_bytes()); + msg.extend_from_slice(&payload_bytes); + + Ok(sha256(&msg).to_vec()) + } + } + } + + fn cell_payload_hash(&self, schema: &str, cell: &str, address: &Address, timestamp: u64) -> Result, SignerError> { + let payload = BagOfCells::parse_base64_root(cell)?; + let domain = self.dns_wire_domain()?; + + let mut domain_builder = CellBuilder::new(); + domain_builder.store_slice_snake(&domain)?; + + let mut builder = CellBuilder::new(); + builder + .store_u32(32, CELL_SIGN_PREFIX)? + .store_u32(32, SCHEMA_CRC32.checksum(schema.as_bytes()))? + .store_u64(64, timestamp)? + .store_address(address)? + .store_child(domain_builder.build()?)? + .store_reference(&payload)?; + + Ok(builder.build()?.hash.to_vec()) + } + + fn dns_wire_domain(&self) -> Result, SignerError> { + let mut encoded = Vec::with_capacity(self.domain.len() + 1); + for label in self.domain.split('.').rev() { + if label.is_empty() || label.contains('\0') { + return SignerError::invalid_input_err("invalid TON app domain"); + } + encoded.extend_from_slice(label.as_bytes()); + encoded.push(0); + } + Ok(encoded) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signer::testkit::{TEST_ADDRESS, mock_cell}; + + #[test] + fn test_ton_sign_message_data() { + let payload = TonSignDataPayload::Text { text: "Hello TON".to_string() }; + let data = TonSignMessageData::new(payload.clone(), "example.com".to_string(), TEST_ADDRESS.to_string()); + + let bytes = data.to_bytes(); + let parsed = TonSignMessageData::from_bytes(&bytes).unwrap(); + + assert_eq!(parsed.payload, payload); + assert_eq!(parsed.domain, "example.com"); + assert_eq!(parsed.address, TEST_ADDRESS); + } + + #[test] + fn test_build_sign_data_hash() { + let payload = TonSignDataPayload::Text { text: "Hello TON".to_string() }; + let data = TonSignMessageData::new(payload, "example.com".to_string(), TEST_ADDRESS.to_string()); + + let hash = data.hash(1234567890).unwrap(); + + assert_eq!(hash.len(), 32); + } + + #[test] + fn test_build_sign_data_hash_cell() { + let payload = TonSignDataPayload::Cell { + schema: "comment#00000000 text:SnakeData = InMsgBody;".to_string(), + cell: mock_cell(), + }; + let data = TonSignMessageData::new(payload, "example.com".to_string(), TEST_ADDRESS.to_string()); + let hash = data.hash(1234567890).unwrap(); + + assert_eq!(hex::encode(hash), "6ad868b3019bdbd16bc89eecae337bcfcfab02bcb86432cd0cdbc829dfb49adb"); + } + + #[test] + fn test_build_sign_data_hash_accepts_raw_address() { + let payload = TonSignDataPayload::Text { text: "Hello TON".to_string() }; + let data = TonSignMessageData::new( + payload, + "example.com".to_string(), + "0:58d5c54fbb8488af7eaad0cdc759ca8f6ff79fc9555106c1339b037ec0a40347".to_string(), + ); + + let hash = data.hash(1234567890).unwrap(); + + assert_eq!(hash.len(), 32); + } +} diff --git a/core/crates/gem_ton/src/signer/sign_data/mod.rs b/core/crates/gem_ton/src/signer/sign_data/mod.rs new file mode 100644 index 0000000000..2307215169 --- /dev/null +++ b/core/crates/gem_ton/src/signer/sign_data/mod.rs @@ -0,0 +1,8 @@ +mod message; +mod payload; +mod response; +mod sign; + +pub use message::TonSignMessageData; +pub use payload::TonSignDataPayload; +pub use response::TonSignDataResponse; diff --git a/core/crates/gem_ton/src/signer/sign_data/payload.rs b/core/crates/gem_ton/src/signer/sign_data/payload.rs new file mode 100644 index 0000000000..1aad8993e5 --- /dev/null +++ b/core/crates/gem_ton/src/signer/sign_data/payload.rs @@ -0,0 +1,50 @@ +use gem_encoding::decode_base64; +use primitives::SignerError; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum TonSignDataPayload { + Text { text: String }, + Binary { bytes: String }, + Cell { schema: String, cell: String }, +} + +impl TonSignDataPayload { + pub fn data(&self) -> &str { + match self { + Self::Text { text } => text, + Self::Binary { bytes } => bytes, + Self::Cell { cell, .. } => cell, + } + } + + pub fn encode(&self) -> Result<(&str, Vec), SignerError> { + match self { + Self::Text { text } => Ok(("txt", text.as_bytes().to_vec())), + Self::Binary { bytes } => Ok(("bin", decode_base64(bytes).map_err(|_| SignerError::invalid_input("invalid base64"))?)), + Self::Cell { .. } => Err(SignerError::InvalidInput("Cell payload not supported for sign-data".to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_payload_text() { + let json = r#"{"type":"text","text":"Hello TON"}"#; + let parsed: TonSignDataPayload = serde_json::from_str(json).unwrap(); + + assert_eq!(parsed, TonSignDataPayload::Text { text: "Hello TON".to_string() }); + } + + #[test] + fn test_parse_payload_binary() { + let json = r#"{"type":"binary","bytes":"SGVsbG8="}"#; + let parsed: TonSignDataPayload = serde_json::from_str(json).unwrap(); + + assert_eq!(parsed, TonSignDataPayload::Binary { bytes: "SGVsbG8=".to_string() }); + } +} diff --git a/core/crates/gem_ton/src/signer/sign_data/response.rs b/core/crates/gem_ton/src/signer/sign_data/response.rs new file mode 100644 index 0000000000..d6892cb866 --- /dev/null +++ b/core/crates/gem_ton/src/signer/sign_data/response.rs @@ -0,0 +1,32 @@ +use serde::Serialize; + +use super::payload::TonSignDataPayload; + +#[derive(Serialize)] +pub struct TonSignDataResponse { + pub signature: String, + pub address: String, + pub timestamp: u64, + pub domain: String, + pub payload: TonSignDataPayload, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_response_to_json() { + let response = TonSignDataResponse { + signature: "c2lnbmF0dXJl".to_string(), + address: "0:58d5c54fbb8488af7eaad0cdc759ca8f6ff79fc9555106c1339b037ec0a40347".to_string(), + timestamp: 1234567890, + domain: "example.com".to_string(), + payload: TonSignDataPayload::Text { text: "Hello TON".to_string() }, + }; + + let actual: serde_json::Value = serde_json::from_value(serde_json::to_value(&response).unwrap()).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!("../../../testdata/wc_sign_data_response.json")).unwrap(); + assert_eq!(actual, expected); + } +} diff --git a/core/crates/gem_ton/src/signer/sign_data/sign.rs b/core/crates/gem_ton/src/signer/sign_data/sign.rs new file mode 100644 index 0000000000..8baf2d7825 --- /dev/null +++ b/core/crates/gem_ton/src/signer/sign_data/sign.rs @@ -0,0 +1,102 @@ +use primitives::{Address as AddressTrait, SignerError}; + +use super::message::TonSignMessageData; +use crate::{ + address::Address, + signer::signer::{TonSignResult, TonSigner}, +}; + +impl TonSigner { + pub fn sign_personal(&self, data: &[u8], timestamp: u64) -> Result { + let message_data = TonSignMessageData::from_bytes(data)?; + Address::ensure_matches(Some(message_data.address.as_str()), &self.address().encode())?; + let digest = message_data.hash_with_address(timestamp, self.address())?; + + Ok(TonSignResult { + signature: self.sign(&digest).to_vec(), + public_key: self.public_key().to_vec(), + timestamp, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + address::base64_to_hex_address, + signer::{ + TonSigner, + sign_data::{TonSignDataPayload, TonSignMessageData}, + testkit::{TEST_PUBLIC_KEY, mock_cell, mock_signer, mock_signer_address}, + }, + }; + + #[test] + fn test_sign_ton_personal() { + let payload = TonSignDataPayload::Text { text: "Hello TON".to_string() }; + let message_data = TonSignMessageData::new(payload, "example.com".to_string(), mock_signer_address()); + let data = message_data.to_bytes(); + + let result = mock_signer().sign_personal(&data, 1234567890).unwrap(); + + assert_eq!( + hex::encode(&result.signature), + "626168d23a7db9b8fa2716a7d3e3deeb3999f43dc6dfdd747206b6dba01058a4d785130710e2d4140730a643e2d633e76366f52dda8afd5c2acf4a6acb08ba0b" + ); + assert_eq!(hex::encode(&result.public_key), TEST_PUBLIC_KEY); + assert_eq!(result.timestamp, 1234567890); + } + + #[test] + fn test_sign_ton_personal_accepts_raw_address() { + let payload = TonSignDataPayload::Text { text: "Hello TON".to_string() }; + let address = base64_to_hex_address(&mock_signer_address()).unwrap(); + let message_data = TonSignMessageData::new(payload, "example.com".to_string(), address); + let data = message_data.to_bytes(); + + let result = mock_signer().sign_personal(&data, 1234567890).unwrap(); + + assert_eq!( + hex::encode(&result.signature), + "626168d23a7db9b8fa2716a7d3e3deeb3999f43dc6dfdd747206b6dba01058a4d785130710e2d4140730a643e2d633e76366f52dda8afd5c2acf4a6acb08ba0b" + ); + } + + #[test] + fn test_sign_ton_personal_rejects_invalid_key() { + assert!(TonSigner::new(&[0u8; 16]).is_err()); + } + + #[test] + fn test_sign_ton_personal_cell() { + let payload = TonSignDataPayload::Cell { + schema: "comment#00000000 text:SnakeData = InMsgBody;".to_string(), + cell: mock_cell(), + }; + let message_data = TonSignMessageData::new(payload, "example.com".to_string(), mock_signer_address()); + let data = message_data.to_bytes(); + + let result = mock_signer().sign_personal(&data, 1234567890).unwrap(); + + assert_eq!( + hex::encode(&result.signature), + "8ff07fcdb495d18b9274b8c837738f0217b56049c30ca622a075ca2ad5154b0ae9d364df087d368f78e25d15286a685816f325458f3127f27ca6f6880dac3903" + ); + assert_eq!(result.timestamp, 1234567890); + } + + #[test] + fn test_sign_ton_personal_rejects_mismatched_address() { + let payload = TonSignDataPayload::Text { text: "Hello TON".to_string() }; + let message_data = TonSignMessageData::new( + payload, + "example.com".to_string(), + "0:0000000000000000000000000000000000000000000000000000000000000000".to_string(), + ); + let data = message_data.to_bytes(); + + let result = mock_signer().sign_personal(&data, 1234567890); + + assert_eq!(result.err().unwrap().to_string(), "Invalid input: TON from does not match signer address"); + } +} diff --git a/core/crates/gem_ton/src/signer/signer.rs b/core/crates/gem_ton/src/signer/signer.rs new file mode 100644 index 0000000000..0122036d44 --- /dev/null +++ b/core/crates/gem_ton/src/signer/signer.rs @@ -0,0 +1,40 @@ +use primitives::SignerError; +use signer::Ed25519KeyPair; + +use super::transaction::WalletV4R2; +use crate::address::Address; + +pub struct TonSigner { + key_pair: Ed25519KeyPair, + wallet: WalletV4R2, +} + +impl TonSigner { + pub fn new(private_key: &[u8]) -> Result { + let key_pair = Ed25519KeyPair::from_private_key(private_key)?; + let wallet = WalletV4R2::new(key_pair.public_key_bytes)?; + Ok(Self { key_pair, wallet }) + } + + pub fn wallet(&self) -> &WalletV4R2 { + &self.wallet + } + + pub fn address(&self) -> &Address { + &self.wallet.address + } + + pub fn public_key(&self) -> [u8; 32] { + self.key_pair.public_key_bytes + } + + pub fn sign(&self, digest: &[u8]) -> [u8; 64] { + self.key_pair.sign(digest) + } +} + +pub struct TonSignResult { + pub signature: Vec, + pub public_key: Vec, + pub timestamp: u64, +} diff --git a/core/crates/gem_ton/src/signer/testkit.rs b/core/crates/gem_ton/src/signer/testkit.rs new file mode 100644 index 0000000000..4c81785a17 --- /dev/null +++ b/core/crates/gem_ton/src/signer/testkit.rs @@ -0,0 +1,61 @@ +use num_bigint::BigUint; +use primitives::Address as _; + +use super::{ + TonSigner, + transaction::{ + message::DEFAULT_SEND_MODE, + request::{JettonTransferRequest, TransferRequest}, + }, +}; +use crate::{ + address::Address, + tvm::{BagOfCells, CellBuilder}, +}; + +pub const TEST_ADDRESS: &str = "UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg"; +pub const TEST_PRIVATE_KEY: &str = "1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34"; +pub const TEST_PUBLIC_KEY: &str = "d369452197c2a56481e5e2d3e8bf03de2349f67a63151956822208c2334adee2"; + +pub fn mock_signer() -> TonSigner { + let private_key = hex::decode(TEST_PRIVATE_KEY).unwrap(); + TonSigner::new(&private_key).unwrap() +} + +pub fn mock_signer_address() -> String { + mock_signer().address().encode() +} + +pub fn mock_cell() -> String { + let mut builder = CellBuilder::new(); + builder.store_u32(32, 0).unwrap(); + BagOfCells::from_root(builder.build().unwrap()).to_base64(true).unwrap() +} + +impl TransferRequest { + pub(crate) fn mock(destination: Address) -> Self { + Self { + destination, + value: BigUint::from(10u8), + mode: DEFAULT_SEND_MODE, + bounceable: false, + comment: None, + payload: None, + state_init: None, + } + } +} + +impl JettonTransferRequest { + pub(crate) fn mock(destination: Address) -> Self { + Self { + query_id: 0, + value: BigUint::from(10u8), + destination, + response_address: destination, + custom_payload: None, + forward_ton_amount: BigUint::from(1u8), + comment: None, + } + } +} diff --git a/core/crates/gem_ton/src/signer/transaction/message.rs b/core/crates/gem_ton/src/signer/transaction/message.rs new file mode 100644 index 0000000000..effbe7760f --- /dev/null +++ b/core/crates/gem_ton/src/signer/transaction/message.rs @@ -0,0 +1,103 @@ +use num_bigint::BigUint; +use primitives::SignerError; + +use super::request::{JettonTransferRequest, NftTransferRequest, TransferPayload, TransferRequest}; +use crate::{ + constants::{JETTON_TRANSFER_OPCODE, NFT_TRANSFER_OPCODE}, + tvm::{Cell, CellArc, CellBuilder}, +}; + +pub(crate) const DEFAULT_SEND_MODE: u8 = 0b11; +pub(super) const TRANSFER_ALL_TON_MODE: u8 = DEFAULT_SEND_MODE | 0b1000_0000; + +pub(super) struct InternalMessage { + pub mode: u8, + pub message: Cell, +} + +pub(super) fn build_internal_message(request: &TransferRequest) -> Result { + let payload = build_payload(request)?; + let zero = BigUint::from(0u8); + + let mut builder = CellBuilder::new(); + builder + // int_msg_info$0 ihr_disabled:Bool bounce:Bool bounced:Bool + .store_bit(false)? + .store_bit(true)? + .store_bit(request.bounceable)? + .store_bit(false)? + // src (addr_none) + dest + .store_null_address()? + .store_address(&request.destination)? + // value, currency_collection (empty extra), ihr_fee, fwd_fee, created_lt, created_at + .store_coins(&request.value)? + .store_bit(false)? + .store_coins(&zero)? + .store_coins(&zero)? + .store_u64(64, 0)? + .store_u32(32, 0)?; + + match &request.state_init { + Some(state_init) => { + builder.store_bit(true)?.store_bit(true)?.store_reference(state_init)?; + } + None => { + builder.store_bit(false)?; + } + } + + builder.store_bit(true)?.store_reference(&payload)?; + + Ok(InternalMessage { + mode: request.mode, + message: builder.build()?, + }) +} + +fn build_payload(request: &TransferRequest) -> Result { + match &request.payload { + Some(TransferPayload::Jetton(jetton)) => build_jetton_payload(jetton), + Some(TransferPayload::Nft(nft)) => build_nft_payload(nft), + Some(TransferPayload::Custom(payload)) => Ok(payload.clone()), + None => match &request.comment { + Some(comment) => build_comment_payload(comment), + None => Ok(CellBuilder::new().build()?.into_arc()), + }, + } +} + +fn build_comment_payload(comment: &str) -> Result { + let mut builder = CellBuilder::new(); + builder.store_u32(32, 0)?.store_string_snake(comment)?; + Ok(builder.build()?.into_arc()) +} + +fn build_jetton_payload(request: &JettonTransferRequest) -> Result { + let mut builder = CellBuilder::new(); + builder + .store_u32(32, JETTON_TRANSFER_OPCODE)? + .store_u64(64, request.query_id)? + .store_coins(&request.value)? + .store_address(&request.destination)? + .store_address(&request.response_address)?; + + builder.store_maybe_reference(request.custom_payload.as_ref())?; + let forward_payload = request.comment.as_deref().map(build_comment_payload).transpose()?; + builder.store_coins(&request.forward_ton_amount)?.store_maybe_reference(forward_payload.as_ref())?; + + Ok(builder.build()?.into_arc()) +} + +fn build_nft_payload(request: &NftTransferRequest) -> Result { + let mut builder = CellBuilder::new(); + builder + .store_u32(32, NFT_TRANSFER_OPCODE)? + .store_u64(64, request.query_id)? + .store_address(&request.new_owner)? + .store_address(&request.response_destination)? + .store_maybe_reference(None)?; + + let forward_payload = request.comment.as_deref().map(build_comment_payload).transpose()?; + builder.store_coins(&request.forward_amount)?.store_maybe_reference(forward_payload.as_ref())?; + Ok(builder.build()?.into_arc()) +} diff --git a/core/crates/gem_ton/src/signer/transaction/mod.rs b/core/crates/gem_ton/src/signer/transaction/mod.rs new file mode 100644 index 0000000000..f1b333355b --- /dev/null +++ b/core/crates/gem_ton/src/signer/transaction/mod.rs @@ -0,0 +1,6 @@ +pub(super) mod message; +pub(super) mod request; +mod sign; +mod wallet; + +pub use wallet::WalletV4R2; diff --git a/core/crates/gem_ton/src/signer/transaction/request.rs b/core/crates/gem_ton/src/signer/transaction/request.rs new file mode 100644 index 0000000000..539f680121 --- /dev/null +++ b/core/crates/gem_ton/src/signer/transaction/request.rs @@ -0,0 +1,86 @@ +use std::str::FromStr; + +use num_bigint::BigUint; +use primitives::SignerError; + +use super::message::{DEFAULT_SEND_MODE, TRANSFER_ALL_TON_MODE}; +use crate::{address::Address, tvm::CellArc}; + +pub(crate) struct TransferRequest { + pub destination: Address, + pub value: BigUint, + pub mode: u8, + pub bounceable: bool, + pub comment: Option, + pub payload: Option, + pub state_init: Option, +} + +impl TransferRequest { + pub(crate) fn new_transfer(destination: &str, value: &str, is_max: bool, comment: Option) -> Result { + Ok(Self { + destination: Address::parse(destination)?, + value: BigUint::from_str(value)?, + mode: if is_max { TRANSFER_ALL_TON_MODE } else { DEFAULT_SEND_MODE }, + bounceable: false, + comment, + payload: None, + state_init: None, + }) + } + + pub(crate) fn new_contract_transfer(destination: &str, attached_amount: BigUint, payload: TransferPayload) -> Result { + Ok(Self { + destination: Address::parse(destination)?, + value: attached_amount, + mode: DEFAULT_SEND_MODE, + bounceable: true, + comment: None, + payload: Some(payload), + state_init: None, + }) + } + + pub(crate) fn new_with_payload( + destination: &str, + amount: &str, + comment: Option, + payload: Option, + bounceable: bool, + state_init: Option, + ) -> Result { + Ok(Self { + destination: Address::parse(destination)?, + value: BigUint::from_str(amount)?, + mode: DEFAULT_SEND_MODE, + bounceable, + comment, + payload: payload.map(TransferPayload::Custom), + state_init, + }) + } +} + +pub(crate) enum TransferPayload { + Jetton(JettonTransferRequest), + Nft(NftTransferRequest), + Custom(CellArc), +} + +pub(crate) struct JettonTransferRequest { + pub query_id: u64, + pub value: BigUint, + pub destination: Address, + pub response_address: Address, + pub custom_payload: Option, + pub forward_ton_amount: BigUint, + pub comment: Option, +} + +pub(crate) struct NftTransferRequest { + pub query_id: u64, + pub new_owner: Address, + pub response_destination: Address, + pub forward_amount: BigUint, + pub comment: Option, +} diff --git a/core/crates/gem_ton/src/signer/transaction/sign.rs b/core/crates/gem_ton/src/signer/transaction/sign.rs new file mode 100644 index 0000000000..4ae8fbafc8 --- /dev/null +++ b/core/crates/gem_ton/src/signer/transaction/sign.rs @@ -0,0 +1,260 @@ +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use num_bigint::BigUint; +use primitives::{FeeOption, SignerError, SignerInput}; + +use super::{ + message::{InternalMessage, build_internal_message}, + request::{JettonTransferRequest, NftTransferRequest, TransferPayload, TransferRequest}, +}; +use crate::{ + address::Address, + constants::NFT_TRANSFER_FORWARD_AMOUNT, + signer::signer::TonSigner, + tvm::{BagOfCells, CellBuilder}, +}; + +const STATE_INIT_EXPIRE_AT: u32 = u32::MAX; +const EXTERNAL_EXPIRE_WINDOW_SECS: u64 = 600; + +impl TonSigner { + pub fn sign_transfer(&self, input: &SignerInput, expire_at: Option) -> Result { + let request = TransferRequest::new_transfer(&input.destination_address, &input.value, input.is_max_value, input.memo.clone())?; + self.sign_requests(vec![request], input.metadata.get_sequence()?, expire_at) + } + + pub fn sign_token_transfer(&self, input: &SignerInput, expire_at: Option) -> Result { + let sender_token_address = input + .metadata + .get_sender_token_address()? + .ok_or_else(|| SignerError::invalid_input("missing sender token address"))?; + + let jetton = JettonTransferRequest { + query_id: 0, + value: BigUint::from_str(&input.value)?, + destination: Address::parse(&input.destination_address)?, + response_address: Address::parse(&input.sender_address)?, + custom_payload: None, + forward_ton_amount: BigUint::from(1u8), + comment: input.memo.clone(), + }; + let request = TransferRequest::new_contract_transfer(&sender_token_address, optional_ton_attachment(input)?, TransferPayload::Jetton(jetton))?; + self.sign_requests(vec![request], input.metadata.get_sequence()?, expire_at) + } + + pub fn sign_nft_transfer(&self, input: &SignerInput, expire_at: Option) -> Result { + let nft_asset = input.input_type.get_nft_asset()?; + let nft_item = nft_asset.get_contract_address()?; + let nft = NftTransferRequest { + query_id: 0, + new_owner: Address::parse(&input.destination_address)?, + response_destination: Address::parse(&input.sender_address)?, + forward_amount: BigUint::from(NFT_TRANSFER_FORWARD_AMOUNT), + comment: input.memo.clone(), + }; + let request = TransferRequest::new_contract_transfer(nft_item, optional_ton_attachment(input)?, TransferPayload::Nft(nft))?; + self.sign_requests(vec![request], input.metadata.get_sequence()?, expire_at) + } + + pub fn sign_swap(&self, input: &SignerInput, expire_at: Option) -> Result, SignerError> { + let swap_data = input.input_type.get_swap_data()?; + let request = TransferRequest::new_with_payload( + &swap_data.data.to, + &swap_data.data.value, + input.memo.clone(), + Some(BagOfCells::parse_base64_root(&swap_data.data.data)?), + true, + None, + )?; + Ok(vec![self.sign_requests(vec![request], input.metadata.get_sequence()?, expire_at)?]) + } + + pub(crate) fn sign_requests(&self, requests: Vec, sequence: u64, expire_at: Option) -> Result { + let sequence = u32::try_from(sequence).map_err(|_| SignerError::invalid_input("TON sequence does not fit in u32"))?; + let expire_at = resolve_expire_at(sequence, expire_at)?; + + let internal_messages: Vec = requests.iter().map(build_internal_message).collect::>()?; + let external_body = self.wallet().build_external_body(expire_at, sequence, &internal_messages)?; + let signature = self.sign(&external_body.hash); + let mut body_builder = CellBuilder::new(); + body_builder.store_slice(&signature)?.store_cell(&external_body)?; + let signed_transaction = self.wallet().build_transaction(sequence == 0, body_builder.build()?)?; + + Ok(BagOfCells::from_root(signed_transaction).to_base64(true)?) + } +} + +fn optional_ton_attachment(input: &SignerInput) -> Result { + let Some(value) = input.fee.options.get(&FeeOption::TokenAccountCreation) else { + return Ok(BigUint::ZERO); + }; + value.to_biguint().ok_or_else(|| SignerError::invalid_input("invalid TON amount")) +} + +fn resolve_expire_at(sequence: u32, expire_at: Option) -> Result { + match (sequence, expire_at) { + (0, _) => Ok(STATE_INIT_EXPIRE_AT), + (_, Some(value)) => Ok(value), + (_, None) => { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map_err(SignerError::from_display)?.as_secs(); + u32::try_from(now + EXTERNAL_EXPIRE_WINDOW_SECS).map_err(|_| SignerError::invalid_input("TON expire time does not fit in u32")) + } + } +} + +#[cfg(test)] +mod tests { + use num_bigint::{BigInt, BigUint}; + use primitives::{ + Address as AddressTrait, Asset, AssetId, AssetType, Chain, FeeOption, NFTAsset, SignerInput, TransactionFee, TransactionInputType, TransactionLoadMetadata, + asset_constants::TON_USDT_TOKEN_ID, swap::SwapData, + }; + + use super::super::{ + message::build_internal_message, + request::{JettonTransferRequest, TransferPayload, TransferRequest}, + }; + use crate::{ + address::Address, + constants::NFT_TRANSFER_ATTACHMENT, + signer::{TonSigner, testkit::mock_cell}, + }; + + const TEST_TON_PRIVATE_KEY: &str = "c7702dadcd00d470df27dee0ddd97fbcf9deba52b60f7dd2b296ff42bb1fcad6"; + const TRUST_WALLET_PRIVATE_KEY: &str = "63474e5fe9511f1526a50567ce142befc343e71a49b865ac3908f58667319cb8"; + const SENDER_TOKEN_ADDRESS: &str = "EQAlgB03OjJKdXrlwZiGJD5snSzPKF2VL5bErJn_cqJANGH9"; + + fn test_signer() -> TonSigner { + let private_key = hex::decode(TEST_TON_PRIVATE_KEY).unwrap(); + TonSigner::new(&private_key).unwrap() + } + + #[test] + fn test_sign_transfer() { + let signer = test_signer(); + let address = signer.address().encode(); + + let input = SignerInput::mock_with_input_type( + TransactionInputType::Transfer(Asset::from_chain(Chain::Ton)), + &address, + &address, + "10000", + TransactionLoadMetadata::mock_ton(1), + ); + assert_eq!( + signer.sign_transfer(&input, Some(1_000_000_000)).unwrap(), + "te6cckEBBAEArgABRYgBkF1w67cBLG0e0D7j0y2ShzflCe2JrlAjS4pC8UHg85AMAQGcOZ5W/jkCqNSj9wrP3isRN8k2PsJvAS1Rc7K+ABk/VgsvD4MSlcEFpS56SGhkmC7pSYwJM1Ocd7iIVUCY1DeFAimpoxc7msoAAAAAAQADAgFkQgBkF1w67cBLG0e0D7j0y2ShzflCe2JrlAjS4pC8UHg85BE4gAAAAAAAAAAAAAAAAAEDAABvNxKJ" + ); + } + + #[test] + fn test_sign_token_transfer() { + let signer = test_signer(); + let address = signer.address().encode(); + + let asset = Asset::new(AssetId::from_token(Chain::Ton, TON_USDT_TOKEN_ID), String::new(), String::new(), 8, AssetType::TOKEN); + let input = SignerInput::mock_with_input_type( + TransactionInputType::Transfer(asset), + &address, + &address, + "10000", + TransactionLoadMetadata::mock_ton_jetton(1, SENDER_TOKEN_ADDRESS), + ); + assert_eq!( + signer.sign_token_transfer(&input, Some(1_000_000_000)).unwrap(), + "te6cckEBBAEA/wABRYgBkF1w67cBLG0e0D7j0y2ShzflCe2JrlAjS4pC8UHg85AMAQGcbaO6bjRLkbewbUrj8cYUocJI7vJDeXH4uoZqtTZzf5CRVBRw8rjMKMNg4MEafTwywe6wo2+BhefXkhOtdEakCympoxc7msoAAAAAAQADAgFgYgASwA6bnRklOr1y4MxDEh82TpZnlC7Kl8tiVkz/uVEgGgAAAAAAAAAAAAAAAAABAwCmD4p+pQAAAAAAAAAAInEIAZBdcOu3ASxtHtA+49Mtkoc35Qntia5QI0uKQvFB4PORADILrh124CWNo9oH3HplslDm/KE9sTXKBGlxSF4oPB5yAgKLD74O" + ); + } + + #[test] + fn test_sign_nft_transfer() { + let signer = test_signer(); + let mut input = SignerInput::mock_ton( + TransactionInputType::TransferNft(Asset::from_chain(Chain::Ton), NFTAsset::mock_ton()), + TransactionLoadMetadata::mock_ton(1), + ); + input.fee = TransactionFee::new_from_fee_with_option(BigInt::from(0), FeeOption::TokenAccountCreation, BigInt::from(NFT_TRANSFER_ATTACHMENT)); + + assert_eq!( + signer.sign_nft_transfer(&input, Some(1_000_000_000)).unwrap(), + "te6cckECBAEAAQMAAUWIAZBdcOu3ASxtHtA+49Mtkoc35Qntia5QI0uKQvFB4POQDAEBnNqNNNoZtfnJIY5Ay3QVhWak/TIAnQoTWEq80qOayjpIrJzSwtwHEOKtIL9yqLZw0PhzzP5Q/hDKawfvX80AhAYpqaMXO5rKAAAAAAEAAwIBaGIAV+JOXDw3kOQ4ItjPbzx+NaNiiCQiiZ/HTTaiAwDgzHUgF9eEAAAAAAAAAAAAAAAAAAEDAKVfzD0UAAAAAAAAAACACxq4qfdwkRXv1VoZuOs5Ue3+8/kqqiDYJnNgb9gUgGjwAWNXFT7uEiK9+qtDNx1nKj2/3n8lVUQbBM5sDfsCkA0ccxLQCBmg7No=" + ); + } + + #[test] + fn test_sign_nft_transfer_validates_contract_address() { + let signer = test_signer(); + + let mut nft_asset = NFTAsset::mock_ton(); + nft_asset.contract_address = None; + let input_type = TransactionInputType::TransferNft(Asset::from_chain(Chain::Ton), nft_asset); + let input = SignerInput::mock_ton(input_type, TransactionLoadMetadata::mock_ton(1)); + + assert_eq!( + signer.sign_nft_transfer(&input, Some(1_000_000_000)).unwrap_err().to_string(), + "Invalid input: missing NFT contract address" + ); + } + + /// Deploy parity vector from TrustWallet wallet-core: + /// https://github.com/trustwallet/wallet-core/blob/master/rust/tw_tests/tests/chains/ton/ton_sign.rs + #[test] + fn test_sign_wallet_deploy() { + let private_key = hex::decode(TRUST_WALLET_PRIVATE_KEY).unwrap(); + let signer = TonSigner::new(&private_key).unwrap(); + let destination = Address::parse("EQDYW_1eScJVxtitoBRksvoV9cCYo4uKGWLVNIHB1JqRR3n0").unwrap(); + let request = TransferRequest { + bounceable: true, + ..TransferRequest::mock(destination) + }; + assert_eq!( + signer.sign_requests(vec![request], 0, Some(1_671_135_440)).unwrap(), + "te6cckECGgEAA7IAAkWIAM33x4uAd+uQTyXyCZPxflESlNVHpCeoOECtNsqVW9tmHgECAgE0AwQBnOfG8YGGhFeE+iDE1jxCYeWKElbGDm3oqm2pwAhmVWSzWv5n6vVq8JY0J6p4sL+hqJU3iYPH8TX5mGLfcbbmtwgpqaMX/////wAAAAAAAwUBFP8A9KQT9LzyyAsGAFEAAAAAKamjF/Qsd/kxvqIOxdAVBzEna7suKGCUdmEkWyMZ74Ez7o1BQAFiYgBsLf6vJOEq42xW0AoyWX0K+uBMUcXFDLFqmkDg6k1Io4hQAAAAAAAAAAAAAAAAAQcCASAICQAAAgFICgsE+PKDCNcYINMf0x/THwL4I7vyZO1E0NMf0x/T//QE0VFDuvKhUVG68qIF+QFUEGT5EPKj+AAkpMjLH1JAyx9SMMv/UhD0AMntVPgPAdMHIcAAn2xRkyDXSpbTB9QC+wDoMOAhwAHjACHAAuMAAcADkTDjDQOkyMsfEssfy/8MDQ4PAubQAdDTAyFxsJJfBOAi10nBIJJfBOAC0x8hghBwbHVnvSKCEGRzdHK9sJJfBeAD+kAwIPpEAcjKB8v/ydDtRNCBAUDXIfQEMFyBAQj0Cm+hMbOSXwfgBdM/yCWCEHBsdWe6kjgw4w0DghBkc3RyupJfBuMNEBECASASEwBu0gf6ANTUIvkABcjKBxXL/8nQd3SAGMjLBcsCIs8WUAX6AhTLaxLMzMlz+wDIQBSBAQj0UfKnAgBwgQEI1xj6ANM/yFQgR4EBCPRR8qeCEG5vdGVwdIAYyMsFywJQBs8WUAT6AhTLahLLH8s/yXP7AAIAbIEBCNcY+gDTPzBSJIEBCPRZ8qeCEGRzdHJwdIAYyMsFywJQBc8WUAP6AhPLassfEss/yXP7AAAK9ADJ7VQAeAH6APQEMPgnbyIwUAqhIb7y4FCCEHBsdWeDHrFwgBhQBMsFJs8WWPoCGfQAy2kXyx9SYMs/IMmAQPsABgCKUASBAQj0WTDtRNCBAUDXIMgBzxb0AMntVAFysI4jghBkc3Rygx6xcIAYUAXLBVADzxYj+gITy2rLH8s/yYBA+wCSXwPiAgEgFBUAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAIBWBYXABG4yX7UTQ1wsfgAPbKd+1E0IEBQNch9AQwAsjKB8v/ydABgQEI9ApvoTGACASAYGQAZrc52omhAIGuQ64X/wAAZrx32omhAEGuQ64WPwJiaP4Q=" + ); + } + + #[test] + fn test_sign_swap_uses_custom_payload_transfer() { + let signer = test_signer(); + let mut swap_data = SwapData::mock_with_provider(primitives::SwapProvider::StonfiV2); + swap_data.data.to = SENDER_TOKEN_ADDRESS.to_string(); + swap_data.data.value = "241000000".to_string(); + swap_data.data.data = mock_cell(); + swap_data.data.gas_limit = None; + let input = SignerInput::mock_ton( + TransactionInputType::Swap(Asset::from_chain(Chain::Ton), Asset::from_chain(Chain::Ton), swap_data), + TransactionLoadMetadata::mock_ton(1), + ); + + let signed = signer.sign_swap(&input, Some(1_000_000_000)).unwrap(); + assert_eq!(signed.len(), 1); + assert!(signed[0].starts_with("te6cc")); + } + + #[test] + fn test_long_comments_use_snake_cells() { + let address = Address::parse(SENDER_TOKEN_ADDRESS).unwrap(); + let comment = "memo".repeat(80); + + let transfer = TransferRequest { + comment: Some(comment.clone()), + ..TransferRequest::mock(address) + }; + let native_payload = build_internal_message(&transfer).unwrap().message.references.first().unwrap().clone(); + assert!(!native_payload.references.is_empty()); + + let jetton = TransferRequest { + value: BigUint::ZERO, + bounceable: true, + payload: Some(TransferPayload::Jetton(JettonTransferRequest { + comment: Some(comment), + ..JettonTransferRequest::mock(address) + })), + ..TransferRequest::mock(address) + }; + let jetton_payload = build_internal_message(&jetton).unwrap().message.references.first().unwrap().clone(); + assert_eq!(jetton_payload.references.len(), 1); + assert!(!jetton_payload.references[0].references.is_empty()); + } +} diff --git a/core/crates/gem_ton/src/signer/transaction/wallet.rs b/core/crates/gem_ton/src/signer/transaction/wallet.rs new file mode 100644 index 0000000000..222a802750 --- /dev/null +++ b/core/crates/gem_ton/src/signer/transaction/wallet.rs @@ -0,0 +1,96 @@ +use std::sync::LazyLock; + +use num_bigint::BigUint; +use primitives::SignerError; + +use super::message::InternalMessage; +use crate::{ + address::Address, + tvm::{BagOfCells, Cell, CellArc, CellBuilder}, +}; + +const BASE_WORKCHAIN: i32 = 0; +const DEFAULT_WALLET_ID: i32 = 0x29a9a317; +const WALLET_V4R2_CODE_BOC: &str = include_str!("wallet_v4r2_code.boc.b64"); + +static WALLET_V4R2_CODE: LazyLock = LazyLock::new(|| BagOfCells::parse_base64(WALLET_V4R2_CODE_BOC.trim()).unwrap().get_single_root().unwrap().clone()); + +#[derive(Clone)] +struct StateInit { + code: CellArc, + data: CellArc, +} + +impl StateInit { + fn to_cell(&self) -> Result { + let mut builder = CellBuilder::new(); + builder + .store_bit(false)? + .store_bit(false)? + .store_bit(true)? + .store_bit(true)? + .store_bit(false)? + .store_reference(&self.code)? + .store_reference(&self.data)?; + Ok(builder.build()?) + } +} + +pub struct WalletV4R2 { + public_key: [u8; 32], + pub address: Address, +} + +impl WalletV4R2 { + pub fn new(public_key: [u8; 32]) -> Result { + let state_init = Self::state_init(&public_key)?; + Ok(Self { + public_key, + address: Address::new(BASE_WORKCHAIN, state_init.to_cell()?.hash), + }) + } + + pub fn state_init_base64(&self) -> Result { + Ok(BagOfCells::from_root(Self::state_init(&self.public_key)?.to_cell()?).to_base64(true)?) + } + + pub(super) fn build_external_body(&self, expire_at: u32, sequence: u32, messages: &[InternalMessage]) -> Result { + let mut builder = CellBuilder::new(); + builder + .store_i32(32, DEFAULT_WALLET_ID)? + .store_u32(32, expire_at)? + .store_u32(32, sequence)? + .store_u8(8, 0)?; + for message in messages { + builder.store_u8(8, message.mode)?.store_child(message.message.clone())?; + } + Ok(builder.build()?) + } + + pub(super) fn build_transaction(&self, include_state_init: bool, signed_body: Cell) -> Result { + let mut builder = CellBuilder::new(); + builder + .store_u8(2, 0b10)? + .store_null_address()? + .store_address(&self.address)? + .store_coins(&BigUint::from(0u8))?; + + if include_state_init { + builder.store_bit(true)?.store_bit(true)?.store_child(Self::state_init(&self.public_key)?.to_cell()?)?; + } else { + builder.store_bit(false)?; + } + builder.store_bit(true)?.store_child(signed_body)?; + Ok(builder.build()?) + } + + fn state_init(public_key: &[u8; 32]) -> Result { + let mut data = CellBuilder::new(); + data.store_u32(32, 0)?.store_i32(32, DEFAULT_WALLET_ID)?.store_slice(public_key)?.store_bit(false)?; + + Ok(StateInit { + code: WALLET_V4R2_CODE.clone(), + data: data.build()?.into_arc(), + }) + } +} diff --git a/core/crates/gem_ton/src/signer/transaction/wallet_v4r2_code.boc.b64 b/core/crates/gem_ton/src/signer/transaction/wallet_v4r2_code.boc.b64 new file mode 100644 index 0000000000..e1d04cde08 --- /dev/null +++ b/core/crates/gem_ton/src/signer/transaction/wallet_v4r2_code.boc.b64 @@ -0,0 +1 @@ +te6cckECFAEAAtQAART/APSkE/S88sgLAQIBIAIDAgFIBAUE+PKDCNcYINMf0x/THwL4I7vyZO1E0NMf0x/T//QE0VFDuvKhUVG68qIF+QFUEGT5EPKj+AAkpMjLH1JAyx9SMMv/UhD0AMntVPgPAdMHIcAAn2xRkyDXSpbTB9QC+wDoMOAhwAHjACHAAuMAAcADkTDjDQOkyMsfEssfy/8QERITAubQAdDTAyFxsJJfBOAi10nBIJJfBOAC0x8hghBwbHVnvSKCEGRzdHK9sJJfBeAD+kAwIPpEAcjKB8v/ydDtRNCBAUDXIfQEMFyBAQj0Cm+hMbOSXwfgBdM/yCWCEHBsdWe6kjgw4w0DghBkc3RyupJfBuMNBgcCASAICQB4AfoA9AQw+CdvIjBQCqEhvvLgUIIQcGx1Z4MesXCAGFAEywUmzxZY+gIZ9ADLaRfLH1Jgyz8gyYBA+wAGAIpQBIEBCPRZMO1E0IEBQNcgyAHPFvQAye1UAXKwjiOCEGRzdHKDHrFwgBhQBcsFUAPPFiP6AhPLassfyz/JgED7AJJfA+ICASAKCwBZvSQrb2omhAgKBrkPoCGEcNQICEekk30pkQzmkD6f+YN4EoAbeBAUiYcVnzGEAgFYDA0AEbjJftRNDXCx+AA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA4PABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AAG7SB/oA1NQi+QAFyMoHFcv/ydB3dIAYyMsFywIizxZQBfoCFMtrEszMyXP7AMhAFIEBCPRR8qcCAHCBAQjXGPoA0z/IVCBHgQEI9FHyp4IQbm90ZXB0gBjIywXLAlAGzxZQBPoCFMtqEssfyz/Jc/sAAgBsgQEI1xj6ANM/MFIkgQEI9Fnyp4IQZHN0cnB0gBjIywXLAlAFzxZQA/oCE8tqyx8Syz/Jc/sAAAr0AMntVGliJeU= \ No newline at end of file diff --git a/core/crates/gem_ton/src/tvm/bag.rs b/core/crates/gem_ton/src/tvm/bag.rs new file mode 100644 index 0000000000..b60c77cf4b --- /dev/null +++ b/core/crates/gem_ton/src/tvm/bag.rs @@ -0,0 +1,156 @@ +use super::TvmError; +use crc::Crc; +use gem_encoding::{decode_base64, encode_base64}; + +use super::{ + cell::{Cell, CellArc}, + header::{BOC_MAGIC, BocHeader}, + indexed_cell::{build_index, ordered_indexed_cells}, + invalid, + raw_cell::{RawCell, write_var_uint}, + reader::BitReader, +}; + +const CRC32C: Crc = Crc::::new(&crc::CRC_32_ISCSI); + +#[derive(Clone, Debug, Default)] +pub struct BagOfCells { + roots: Vec, +} + +impl BagOfCells { + pub fn from_root(root: Cell) -> Self { + Self { roots: vec![root.into_arc()] } + } + + pub fn parse_base64(value: &str) -> Result { + let bytes = decode_base64(value).map_err(|_| invalid("invalid base64 BoC"))?; + Self::parse(&bytes) + } + + pub fn parse(bytes: &[u8]) -> Result { + let mut reader = BitReader::new(bytes); + let header = BocHeader::parse(&mut reader)?; + + let root_indexes = (0..header.roots_count).map(|_| reader.read_var_uint(header.ref_bytes)).collect::, _>>()?; + + if header.has_idx { + reader.skip(header.cells_count * header.off_bytes)?; + } + + let raw_cells = header.read_raw_cells(&mut reader)?; + + if header.has_crc32c { + validate_crc32c(&mut reader, bytes)?; + } + + if reader.remaining() != 0 { + return Err(invalid("unexpected trailing BoC bytes")); + } + + let cells = build_cell_tree(&raw_cells)?; + let roots = root_indexes + .iter() + .map(|index| cells.get(*index).cloned().ok_or_else(|| invalid("BoC root out of bounds"))) + .collect::, _>>()?; + Ok(Self { roots }) + } + + pub fn get_single_root(&self) -> Result<&CellArc, TvmError> { + match self.roots.as_slice() { + [root] => Ok(root), + _ => Err(invalid("BoC must contain exactly one root")), + } + } + + pub fn parse_base64_root(value: &str) -> Result { + Ok(Self::parse_base64(value)?.get_single_root()?.clone()) + } + + pub fn to_base64(&self, with_crc32c: bool) -> Result { + Ok(encode_base64(&self.serialize(with_crc32c)?)) + } + + pub fn serialize(&self, with_crc32c: bool) -> Result, TvmError> { + let indexed_cells = build_index(&self.roots); + let ordered_cells = ordered_indexed_cells(&indexed_cells); + + let raw_cells = ordered_cells + .iter() + .map(|indexed| RawCell::from_cell(&indexed.borrow().cell, &indexed_cells)) + .collect::, _>>()?; + + let root_indexes = self + .roots + .iter() + .map(|root| { + indexed_cells + .get(&root.hash) + .map(|indexed| indexed.borrow().index) + .ok_or_else(|| invalid("missing BoC root")) + }) + .collect::, _>>()?; + + let ref_bytes = bytes_needed(raw_cells.len()); + let total_cells_size = raw_cells.iter().map(|cell| cell.size(ref_bytes)).sum::(); + let offset_bytes = bytes_needed(total_cells_size.max(1)); + + let mut output = Vec::new(); + output.extend_from_slice(&BOC_MAGIC.to_be_bytes()); + output.push(((with_crc32c as u8) << 6) | (ref_bytes as u8)); + output.push(offset_bytes as u8); + write_var_uint(&mut output, raw_cells.len(), ref_bytes); + write_var_uint(&mut output, root_indexes.len(), ref_bytes); + write_var_uint(&mut output, 0, ref_bytes); + write_var_uint(&mut output, total_cells_size, offset_bytes); + for root_index in &root_indexes { + write_var_uint(&mut output, *root_index, ref_bytes); + } + for cell in &raw_cells { + cell.write(&mut output, ref_bytes); + } + + if with_crc32c { + output.extend_from_slice(&CRC32C.checksum(&output).to_le_bytes()); + } + + Ok(output) + } +} + +fn validate_crc32c(reader: &mut BitReader<'_>, bytes: &[u8]) -> Result<(), TvmError> { + let expected = reader.read_u32_le()?; + let payload_end = bytes.len().checked_sub(4).ok_or_else(|| invalid("invalid BoC length"))?; + if expected != CRC32C.checksum(&bytes[..payload_end]) { + return Err(invalid("invalid BoC crc32c")); + } + Ok(()) +} + +fn build_cell_tree(raw_cells: &[RawCell]) -> Result, TvmError> { + let mut cells = vec![None; raw_cells.len()]; + for index in (0..raw_cells.len()).rev() { + let raw = &raw_cells[index]; + let references = raw + .references + .iter() + .map(|reference| { + if *reference <= index { + return Err(invalid("BoC references must point to later cells")); + } + cells + .get(*reference) + .and_then(|cell| cell.as_ref().cloned()) + .ok_or_else(|| invalid("BoC reference out of bounds")) + }) + .collect::, _>>()?; + cells[index] = Some(Cell::new(raw.data.clone(), raw.bit_len, references)?.into_arc()); + } + cells.into_iter().map(|cell| cell.ok_or_else(|| invalid("BoC cell out of bounds"))).collect() +} + +fn bytes_needed(value: usize) -> usize { + let value = value.max(1); + let bits = usize::BITS as usize - value.leading_zeros() as usize; + bits.div_ceil(8) +} diff --git a/core/crates/gem_ton/src/tvm/builder.rs b/core/crates/gem_ton/src/tvm/builder.rs new file mode 100644 index 0000000000..fa016821d6 --- /dev/null +++ b/core/crates/gem_ton/src/tvm/builder.rs @@ -0,0 +1,182 @@ +use super::TvmError; +use num_bigint::BigUint; + +use super::{ + cell::{Cell, CellArc, MAX_CELL_BITS, MAX_CELL_REFERENCES}, + writer::BitWriter, +}; +use crate::address::Address; + +#[derive(Default)] +pub struct CellBuilder { + writer: BitWriter, + references: Vec, +} + +impl CellBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn store_bit(&mut self, value: bool) -> Result<&mut Self, TvmError> { + self.writer.write_bit(value)?; + Ok(self) + } + + pub fn store_u8(&mut self, bit_len: usize, value: u8) -> Result<&mut Self, TvmError> { + self.writer.write_uint(bit_len, value as u64)?; + Ok(self) + } + + pub fn store_u32(&mut self, bit_len: usize, value: u32) -> Result<&mut Self, TvmError> { + self.writer.write_uint(bit_len, value as u64)?; + Ok(self) + } + + pub fn store_i32(&mut self, bit_len: usize, value: i32) -> Result<&mut Self, TvmError> { + self.writer.write_uint(bit_len, value as u32 as u64)?; + Ok(self) + } + + pub fn store_u64(&mut self, bit_len: usize, value: u64) -> Result<&mut Self, TvmError> { + self.writer.write_uint(bit_len, value)?; + Ok(self) + } + + pub fn store_slice(&mut self, slice: &[u8]) -> Result<&mut Self, TvmError> { + self.writer.write_bytes(slice)?; + Ok(self) + } + + pub fn store_string(&mut self, value: &str) -> Result<&mut Self, TvmError> { + self.store_slice(value.as_bytes()) + } + + pub fn store_slice_snake(&mut self, slice: &[u8]) -> Result<&mut Self, TvmError> { + let byte_capacity = self.remaining_bits() / 8; + if slice.len() <= byte_capacity { + return self.store_slice(slice); + } + + let (head, tail) = slice.split_at(byte_capacity); + self.store_slice(head)?; + + let mut child = Self::new(); + child.store_slice_snake(tail)?; + self.store_child(child.build()?)?; + Ok(self) + } + + pub fn store_string_snake(&mut self, value: &str) -> Result<&mut Self, TvmError> { + self.store_slice_snake(value.as_bytes()) + } + + pub fn store_uint(&mut self, bit_len: usize, value: &BigUint) -> Result<&mut Self, TvmError> { + let used_bits = value.bits() as usize; + if used_bits > bit_len { + return Err(TvmError::new(format!("value does not fit in {bit_len} bits"))); + } + + let leading_zero_bits = bit_len.saturating_sub(used_bits); + let leading_zero_bytes = leading_zero_bits / 8; + for _ in 0..leading_zero_bytes { + self.store_u8(8, 0)?; + } + + let extra_zero_bits = leading_zero_bits % 8; + for _ in 0..extra_zero_bits { + self.store_bit(false)?; + } + + let bytes = value.to_bytes_be(); + if let Some(high_byte) = bytes.first() { + let high_bits = if used_bits == 0 { + 0 + } else { + let bits = used_bits % 8; + if bits == 0 { 8 } else { bits } + }; + if high_bits > 0 { + for shift in (0..high_bits).rev() { + self.store_bit((high_byte >> shift) & 1 == 1)?; + } + } + for byte in bytes.iter().skip(1) { + self.store_u8(8, *byte)?; + } + } + + Ok(self) + } + + pub fn store_coins(&mut self, value: &BigUint) -> Result<&mut Self, TvmError> { + if value == &BigUint::from(0u8) { + self.store_u8(4, 0)?; + return Ok(self); + } + + let bytes = value.to_bytes_be(); + self.store_u8(4, bytes.len() as u8)?; + self.store_uint(bytes.len() * 8, value) + } + + pub fn store_null_address(&mut self) -> Result<&mut Self, TvmError> { + self.store_u8(2, 0) + } + + pub fn store_address(&mut self, address: &Address) -> Result<&mut Self, TvmError> { + self.store_u8(2, 0b10)?; + self.store_bit(false)?; + self.store_u8(8, address.workchain() as i8 as u8)?; + self.store_slice(address.hash_part())?; + Ok(self) + } + + pub fn store_maybe_address(&mut self, address: Option<&Address>) -> Result<&mut Self, TvmError> { + match address { + Some(address) => self.store_address(address), + None => self.store_null_address(), + } + } + + pub fn store_reference(&mut self, cell: &CellArc) -> Result<&mut Self, TvmError> { + let next_len = self.references.len() + 1; + if next_len > MAX_CELL_REFERENCES { + return Err(TvmError::new(format!("cell exceeds {MAX_CELL_REFERENCES} references"))); + } + self.references.push(cell.clone()); + Ok(self) + } + + pub fn store_maybe_reference(&mut self, cell: Option<&CellArc>) -> Result<&mut Self, TvmError> { + match cell { + Some(cell) => self.store_bit(true)?.store_reference(cell), + None => self.store_bit(false), + } + } + + pub fn store_child(&mut self, cell: Cell) -> Result<&mut Self, TvmError> { + self.store_reference(&cell.into_arc()) + } + + pub fn store_cell_data(&mut self, cell: &Cell) -> Result<&mut Self, TvmError> { + self.writer.write_bits(&cell.data, cell.bit_len)?; + Ok(self) + } + + pub fn store_cell(&mut self, cell: &Cell) -> Result<&mut Self, TvmError> { + self.store_cell_data(cell)?; + for reference in &cell.references { + self.store_reference(reference)?; + } + Ok(self) + } + + pub fn remaining_bits(&self) -> usize { + MAX_CELL_BITS.saturating_sub(self.writer.bit_len) + } + + pub fn build(self) -> Result { + Cell::new(self.writer.bytes, self.writer.bit_len, self.references) + } +} diff --git a/core/crates/gem_ton/src/tvm/cell.rs b/core/crates/gem_ton/src/tvm/cell.rs new file mode 100644 index 0000000000..7a4573af44 --- /dev/null +++ b/core/crates/gem_ton/src/tvm/cell.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use super::TvmError; +use gem_hash::sha2::sha256; + +pub(super) const MAX_CELL_BITS: usize = 1023; +pub(super) const MAX_CELL_REFERENCES: usize = 4; + +pub type CellArc = Arc; + +#[derive(Clone, Debug)] +pub struct Cell { + pub data: Vec, + pub bit_len: usize, + pub references: Vec, + pub(super) depth: u16, + pub hash: [u8; 32], +} + +impl Cell { + pub fn try_new(data: Vec, bit_len: usize, references: Vec) -> Option { + if bit_len > MAX_CELL_BITS || references.len() > MAX_CELL_REFERENCES || data.len() != bit_len.div_ceil(8) { + return None; + } + + let depth = if references.is_empty() { + 0 + } else { + references.iter().map(|reference| reference.depth).max()?.checked_add(1)? + }; + + let mut repr = Vec::with_capacity(2 + data.len() + references.len() * 34); + repr.push(references.len() as u8); + repr.push(Self::bits_descriptor(bit_len)?); + repr.extend_from_slice(&Self::serialized_bits(&data, bit_len)); + repr.extend(references.iter().flat_map(|r| r.depth.to_be_bytes())); + repr.extend(references.iter().flat_map(|r| r.hash)); + + Some(Self { + data, + bit_len, + references, + depth, + hash: sha256(&repr), + }) + } + + pub fn new(data: Vec, bit_len: usize, references: Vec) -> Result { + Self::try_new(data, bit_len, references).ok_or_else(|| TvmError::new("invalid cell")) + } + + pub fn into_arc(self) -> CellArc { + Arc::new(self) + } + + fn bits_descriptor(bit_len: usize) -> Option { + let data_len = bit_len.div_ceil(8); + if data_len > 128 { + return None; + } + Some((data_len * 2 - usize::from(!bit_len.is_multiple_of(8))) as u8) + } + + fn serialized_bits(data: &[u8], bit_len: usize) -> Vec { + let data_len = bit_len.div_ceil(8); + if data_len == 0 { + return Vec::new(); + } + + let mut serialized = data[..data_len].to_vec(); + if !bit_len.is_multiple_of(8) { + let marker_shift = 8 - (bit_len % 8) - 1; + let last_index = serialized.len() - 1; + serialized[last_index] |= 1 << marker_shift; + } + serialized + } +} diff --git a/core/crates/gem_ton/src/tvm/error.rs b/core/crates/gem_ton/src/tvm/error.rs new file mode 100644 index 0000000000..65d8500284 --- /dev/null +++ b/core/crates/gem_ton/src/tvm/error.rs @@ -0,0 +1,24 @@ +use primitives::SignerError; + +#[derive(Debug, Clone)] +pub struct TvmError(pub String); + +impl TvmError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl std::fmt::Display for TvmError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for TvmError {} + +impl From for SignerError { + fn from(err: TvmError) -> Self { + SignerError::InvalidInput(err.0) + } +} diff --git a/core/crates/gem_ton/src/tvm/header.rs b/core/crates/gem_ton/src/tvm/header.rs new file mode 100644 index 0000000000..ced558952b --- /dev/null +++ b/core/crates/gem_ton/src/tvm/header.rs @@ -0,0 +1,81 @@ +use super::TvmError; + +use super::{invalid, raw_cell::RawCell, reader::BitReader}; + +pub(super) const BOC_MAGIC: u32 = 0xb5ee9c72; +const MAX_CELLS: usize = 4096; + +// Header flag byte layout: has_idx:1 | has_crc32c:1 | _:1 | _:1 | _:1 | ref_size:3 +const REF_SIZE_MASK: u8 = 0b0000_0111; +const HAS_IDX_FLAG: u8 = 0b1000_0000; +const HAS_CRC32C_FLAG: u8 = 0b0100_0000; +const MAX_REF_BYTES: usize = 4; +const MAX_OFF_BYTES: usize = 8; + +pub(super) struct BocHeader { + pub has_idx: bool, + pub has_crc32c: bool, + pub ref_bytes: usize, + pub off_bytes: usize, + pub cells_count: usize, + pub roots_count: usize, + pub total_cells_size: usize, +} + +impl BocHeader { + pub(super) fn try_parse(reader: &mut BitReader<'_>) -> Option { + if reader.read_u32().ok()? != BOC_MAGIC { + return None; + } + + let flags = reader.read_u8().ok()?; + let ref_bytes = (flags & REF_SIZE_MASK) as usize; + if ref_bytes == 0 || ref_bytes > MAX_REF_BYTES { + return None; + } + + let off_bytes = reader.read_u8().ok()? as usize; + if off_bytes == 0 || off_bytes > MAX_OFF_BYTES { + return None; + } + + let cells_count = reader.read_var_uint(ref_bytes).ok()?; + let roots_count = reader.read_var_uint(ref_bytes).ok()?; + let absent_count = reader.read_var_uint(ref_bytes).ok()?; + let total_cells_size = reader.read_var_uint(off_bytes).ok()?; + + if cells_count == 0 || cells_count > MAX_CELLS { + return None; + } + if roots_count == 0 || roots_count > cells_count { + return None; + } + if roots_count + absent_count > cells_count { + return None; + } + + Some(Self { + has_idx: flags & HAS_IDX_FLAG != 0, + has_crc32c: flags & HAS_CRC32C_FLAG != 0, + ref_bytes, + off_bytes, + cells_count, + roots_count, + total_cells_size, + }) + } + + pub(super) fn parse(reader: &mut BitReader<'_>) -> Result { + Self::try_parse(reader).ok_or_else(|| invalid("invalid BoC header")) + } + + pub(super) fn read_raw_cells(&self, reader: &mut BitReader<'_>) -> Result, TvmError> { + let start = reader.position(); + let raw_cells = (0..self.cells_count).map(|_| RawCell::parse(reader, self.ref_bytes)).collect::, _>>()?; + + if reader.position().saturating_sub(start) != self.total_cells_size { + return Err(invalid("BoC cell size does not match header")); + } + Ok(raw_cells) + } +} diff --git a/core/crates/gem_ton/src/tvm/indexed_cell.rs b/core/crates/gem_ton/src/tvm/indexed_cell.rs new file mode 100644 index 0000000000..6e20cd1af3 --- /dev/null +++ b/core/crates/gem_ton/src/tvm/indexed_cell.rs @@ -0,0 +1,73 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; + +use super::cell::{CellArc, MAX_CELL_REFERENCES}; + +pub type IndexedCellRef = RefCell; +pub type IndexedCells = BTreeMap<[u8; 32], IndexedCellRef>; + +#[derive(Clone)] +pub struct IndexedCell { + pub index: usize, + pub cell: CellArc, +} + +pub fn build_index(roots: &[CellArc]) -> IndexedCells { + let mut indexed_cells = BTreeMap::new(); + let mut next_index = 0usize; + + let mut frontier = roots.to_vec(); + while !frontier.is_empty() { + let mut next_frontier = Vec::with_capacity(frontier.len() * MAX_CELL_REFERENCES); + for cell in frontier { + if indexed_cells.contains_key(&cell.hash) { + continue; + } + indexed_cells.insert( + cell.hash, + RefCell::new(IndexedCell { + index: next_index, + cell: cell.clone(), + }), + ); + next_index += 1; + next_frontier.extend(cell.references.iter().cloned()); + } + frontier = next_frontier; + } + + loop { + let mut reordered = false; + for parent_hash in indexed_cells.keys().copied().collect::>() { + let Some(parent) = indexed_cells.get(&parent_hash) else { + continue; + }; + let parent_index = parent.borrow().index; + let reference_hashes = parent.borrow().cell.references.iter().map(|reference| reference.hash).collect::>(); + for reference_hash in reference_hashes { + let Some(reference) = indexed_cells.get(&reference_hash) else { + continue; + }; + if reference.borrow().index < parent_index { + reference.borrow_mut().index = next_index; + next_index += 1; + reordered = true; + } + } + } + if !reordered { + break; + } + } + + indexed_cells +} + +pub fn ordered_indexed_cells(indexed_cells: &IndexedCells) -> Vec<&IndexedCellRef> { + let mut ordered = indexed_cells.values().collect::>(); + ordered.sort_unstable_by_key(|cell| cell.borrow().index); + for (real_index, cell) in ordered.iter().enumerate() { + cell.borrow_mut().index = real_index; + } + ordered +} diff --git a/core/crates/gem_ton/src/tvm/mod.rs b/core/crates/gem_ton/src/tvm/mod.rs new file mode 100644 index 0000000000..5845b996f5 --- /dev/null +++ b/core/crates/gem_ton/src/tvm/mod.rs @@ -0,0 +1,20 @@ +mod bag; +mod builder; +mod cell; +mod error; +mod header; +mod indexed_cell; +mod raw_cell; +mod reader; +mod writer; + +pub use bag::BagOfCells; +pub use builder::CellBuilder; +pub use cell::{Cell, CellArc}; +pub use error::TvmError; +pub use reader::BitReader; +pub use writer::BitWriter; + +fn invalid(msg: &'static str) -> TvmError { + TvmError::new(msg) +} diff --git a/core/crates/gem_ton/src/tvm/raw_cell.rs b/core/crates/gem_ton/src/tvm/raw_cell.rs new file mode 100644 index 0000000000..0bb097f315 --- /dev/null +++ b/core/crates/gem_ton/src/tvm/raw_cell.rs @@ -0,0 +1,106 @@ +use super::TvmError; + +use super::{cell::Cell, indexed_cell::IndexedCells, invalid, reader::BitReader}; + +#[derive(Clone)] +pub(super) struct RawCell { + pub data: Vec, + pub bit_len: usize, + pub references: Vec, +} + +impl RawCell { + pub(super) fn try_parse(reader: &mut BitReader<'_>, ref_size: usize) -> Option { + // d1 (refs descriptor): bits[0..3] ref count | bit 3 is_exotic | bit 4 has_hashes | bits[5..7] level mask + let refs_descriptor = reader.read_u8().ok()?; + // d2 (bits descriptor): encodes data length; LSB=0 means full bytes, LSB=1 means last byte has padding + let bits_descriptor = reader.read_u8().ok()?; + + let ref_count = (refs_descriptor & 0b111) as usize; + let is_exotic = refs_descriptor & 0b1000 != 0; + let has_hashes = refs_descriptor & 0b10000 != 0; + let level_mask = refs_descriptor >> 5; + if is_exotic || has_hashes || level_mask != 0 { + return None; + } + + let data_size = ((bits_descriptor >> 1) + (bits_descriptor & 1)) as usize; + let full_bytes = bits_descriptor & 1 == 0; + let data = reader.read_bytes(data_size).ok()?; + let (data, bit_len) = Self::unpad_cell_bits(data, full_bytes)?; + + let references = (0..ref_count).map(|_| reader.read_var_uint(ref_size).ok()).collect::>>()?; + Some(Self { data, bit_len, references }) + } + + pub(super) fn parse(reader: &mut BitReader<'_>, ref_size: usize) -> Result { + Self::try_parse(reader, ref_size).ok_or_else(|| invalid("invalid BoC cell")) + } + + pub(super) fn from_cell(cell: &Cell, indexed_cells: &IndexedCells) -> Result { + let references = cell + .references + .iter() + .map(|reference| { + indexed_cells + .get(&reference.hash) + .map(|indexed| indexed.borrow().index) + .ok_or_else(|| invalid("missing referenced cell")) + }) + .collect::, _>>()?; + + Ok(Self { + data: cell.data.clone(), + bit_len: cell.bit_len, + references, + }) + } + + pub(super) fn size(&self, ref_size: usize) -> usize { + 2 + self.bit_len.div_ceil(8) + self.references.len() * ref_size + } + + pub(super) fn write(&self, output: &mut Vec, ref_size: usize) { + let full_bytes = self.bit_len.is_multiple_of(8); + let data_len = self.bit_len.div_ceil(8); + output.push(self.references.len() as u8); + output.push((data_len * 2 - usize::from(!full_bytes)) as u8); + + if !full_bytes && data_len > 0 { + output.extend_from_slice(&self.data[..data_len - 1]); + let padding_bits = self.bit_len % 8; + output.push(self.data[data_len - 1] | (1 << (8 - padding_bits - 1))); + } else { + output.extend_from_slice(&self.data[..data_len]); + } + + for reference in &self.references { + write_var_uint(output, *reference, ref_size); + } + } + + fn unpad_cell_bits(mut data: Vec, full_bytes: bool) -> Option<(Vec, usize)> { + if data.is_empty() { + return Some((data, 0)); + } + if full_bytes { + let bit_len = data.len() * 8; + return Some((data, bit_len)); + } + + let trailing_zeros = data.last().copied().unwrap_or_default().trailing_zeros(); + if trailing_zeros >= 8 { + return None; + } + let last = data.last_mut()?; + *last &= !(1 << trailing_zeros); + let bit_len = data.len() * 8 - (trailing_zeros as usize + 1); + Some((data, bit_len)) + } +} + +pub(super) fn write_var_uint(output: &mut Vec, value: usize, size: usize) { + for shift in (0..size).rev() { + output.push(((value >> (shift * 8)) & 0xff) as u8); + } +} diff --git a/core/crates/gem_ton/src/tvm/reader.rs b/core/crates/gem_ton/src/tvm/reader.rs new file mode 100644 index 0000000000..bf035f9f17 --- /dev/null +++ b/core/crates/gem_ton/src/tvm/reader.rs @@ -0,0 +1,99 @@ +use super::TvmError; + +#[derive(Clone, Debug)] +pub struct BitReader<'a> { + bytes: &'a [u8], + bit_len: usize, + bit_position: usize, +} + +impl<'a> BitReader<'a> { + pub fn new(bytes: &'a [u8]) -> Self { + Self { + bytes, + bit_len: bytes.len() * 8, + bit_position: 0, + } + } + + pub fn from_bits(bytes: &'a [u8], bit_len: usize) -> Result { + if bit_len > bytes.len() * 8 { + return Err(TvmError::new("bit length exceeds input bytes")); + } + Ok(Self { bytes, bit_len, bit_position: 0 }) + } + + pub fn position(&self) -> usize { + self.bit_position / 8 + } + + pub fn remaining(&self) -> usize { + self.remaining_bits().div_ceil(8) + } + + pub fn skip(&mut self, len: usize) -> Result<(), TvmError> { + let _ = self.read_bytes(len)?; + Ok(()) + } + + pub fn read_bit(&mut self) -> Result { + if self.bit_position >= self.bit_len { + return Err(TvmError::new("unexpected end of input")); + } + + let byte = self.bytes[self.bit_position / 8]; + let shift = 7 - (self.bit_position % 8); + self.bit_position += 1; + Ok((byte >> shift) & 1 == 1) + } + + pub fn read_u8(&mut self) -> Result { + Ok(self.read_uint(8)? as u8) + } + + pub fn read_u32(&mut self) -> Result { + Ok(self.read_uint(32)? as u32) + } + + pub fn read_u32_le(&mut self) -> Result { + let bytes = self.read_bytes(4)?; + Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| TvmError::new("invalid u32"))?)) + } + + pub fn read_uint(&mut self, bit_len: usize) -> Result { + if bit_len > 64 { + return Err(TvmError::new("fixed-size integers above 64 bits are not supported")); + } + + let mut value = 0u64; + for _ in 0..bit_len { + value = (value << 1) | u64::from(self.read_bit()?); + } + Ok(value) + } + + pub fn read_var_uint(&mut self, size: usize) -> Result { + let bytes = self.read_bytes(size)?; + Ok(bytes.into_iter().fold(0usize, |acc, byte| (acc << 8) | byte as usize)) + } + + pub fn read_bytes(&mut self, len: usize) -> Result, TvmError> { + if !self.bit_position.is_multiple_of(8) { + return Err(TvmError::new("byte reads require byte-aligned position")); + } + + let bit_len = len.checked_mul(8).ok_or_else(|| TvmError::new("invalid read length"))?; + if self.remaining_bits() < bit_len { + return Err(TvmError::new("unexpected end of input")); + } + + let start = self.position(); + let end = start.checked_add(len).ok_or_else(|| TvmError::new("invalid read length"))?; + self.bit_position += bit_len; + Ok(self.bytes[start..end].to_vec()) + } + + fn remaining_bits(&self) -> usize { + self.bit_len.saturating_sub(self.bit_position) + } +} diff --git a/core/crates/gem_ton/src/tvm/writer.rs b/core/crates/gem_ton/src/tvm/writer.rs new file mode 100644 index 0000000000..318f40826b --- /dev/null +++ b/core/crates/gem_ton/src/tvm/writer.rs @@ -0,0 +1,60 @@ +use super::TvmError; + +use super::cell::MAX_CELL_BITS; + +#[derive(Default)] +pub struct BitWriter { + pub bytes: Vec, + pub bit_len: usize, +} + +impl BitWriter { + pub fn new() -> Self { + Self::default() + } + + pub fn write_bit(&mut self, value: bool) -> Result<(), TvmError> { + if self.bit_len == MAX_CELL_BITS { + return Err(TvmError::new(format!("cell exceeds {MAX_CELL_BITS} bits"))); + } + if self.bit_len.is_multiple_of(8) { + self.bytes.push(0); + } + if value { + let byte_index = self.bit_len / 8; + let bit_index = 7 - (self.bit_len % 8); + self.bytes[byte_index] |= 1 << bit_index; + } + self.bit_len += 1; + Ok(()) + } + + pub fn write_uint(&mut self, bit_len: usize, value: u64) -> Result<(), TvmError> { + if bit_len > 64 { + return Err(TvmError::new("fixed-size integers above 64 bits are not supported")); + } + if bit_len < 64 && value >> bit_len != 0 { + return Err(TvmError::new("integer does not fit requested bit length")); + } + for shift in (0..bit_len).rev() { + self.write_bit((value >> shift) & 1 == 1)?; + } + Ok(()) + } + + pub fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), TvmError> { + self.write_bits(bytes, bytes.len() * 8) + } + + pub fn write_bits(&mut self, bytes: &[u8], bit_len: usize) -> Result<(), TvmError> { + if bit_len > bytes.len() * 8 { + return Err(TvmError::new("bit length exceeds input bytes")); + } + for index in 0..bit_len { + let byte = bytes[index / 8]; + let shift = 7 - (index % 8); + self.write_bit((byte >> shift) & 1 == 1)?; + } + Ok(()) + } +} diff --git a/core/crates/gem_ton/testdata/balance_jettons.json b/core/crates/gem_ton/testdata/balance_jettons.json new file mode 100644 index 0000000000..3a5a3fad36 --- /dev/null +++ b/core/crates/gem_ton/testdata/balance_jettons.json @@ -0,0 +1,336 @@ +{ + "jetton_wallets": [ + { + "address": "0:69CF6ED219C2AD5DD3B0CE82328E13B70DCD2182FEDAD1458B77FB7EE1BE401A", + "balance": "0", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "jetton": "0:3690254DC15B2297610CDA60744A45F2B710AA4234B89ADB630E99D79B01BD4F", + "last_transaction_lt": "59023100000001", + "code_hash": "p2DWKdU0PnbQRQF9ncIW/IoweoN3gV/rKwpcSQ5zNIY=", + "data_hash": "c1Jnk0NMC7++1mO/vajwkdlDlPCtUANyBHYRIVsH4nM=" + }, + { + "address": "0:F2182CB4C74914C5F95CFEAAEA800555BB64777F699BC60817B83B08141DB2C5", + "balance": "324481500000", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "jetton": "0:21442FE867807D560325B5F36410D49D2E4B653000CF13BDF322221C2FA7ACDA", + "last_transaction_lt": "46064007000003", + "code_hash": "vrBoPr64kn/p/I7AoYvH3ReJlomCWhIeq0bFo6hg0M4=", + "data_hash": "5LGjzYITxp8YAXPMidehHv5nlmUJupR/gG33R4xIvtg=" + }, + { + "address": "0:B5ABAF70EDE26CFEE042212717E7B9BD6AB6B4ADEF2B4B5A3FFBA4F4AA453671", + "balance": "351000000000", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "jetton": "0:65DE083A0007638233B6668354E50E44CD4225F1730D66B8B1F19E5D26690751", + "last_transaction_lt": "46064709000003", + "code_hash": "vrBoPr64kn/p/I7AoYvH3ReJlomCWhIeq0bFo6hg0M4=", + "data_hash": "nXveLROM2cMN/Nd+ITJqH1KvN2X+C7pzsOPJpvo18m8=" + }, + { + "address": "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00", + "balance": "3201565", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "jetton": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE", + "last_transaction_lt": "56460202000001", + "code_hash": "iUaPAseOVwgC45l5yFFvw43wfqdqSDV+BTbyuns+43s=", + "data_hash": "F4/ErQnszp5Cb0Jt+38MPtxokTL2O5ZEV8OCnu3Olgk=" + }, + { + "address": "0:33BD967DA5CA10483CEB6E5C1997818372415AD5718A1E8ED8FC400AD591CD2B", + "balance": "500000000", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "jetton": "0:896E9D240693F03E8046F94E42F9C59F3FF8E792CBE8B467C0ACF179D10F508A", + "last_transaction_lt": "46205258000001", + "code_hash": "G70kKn2Quq4lGKFrmkk04QN8WlrhTAChzLhn7bTpkAA=", + "data_hash": "rSxat3E3CMdMMT2gelgcT/79gH//PlueLyNzO9DLPXU=" + }, + { + "address": "0:C65D37E38DF2DDE2583796A54A3FDE6038AE3BE49A151F86423A94A8E618A409", + "balance": "0", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "jetton": "0:BDF3FA8098D129B54B4F73B5BAC5D1E1FD91EB054169C3916DFC8CCD536D1000", + "last_transaction_lt": "55677433000001", + "code_hash": "Er67DcjiArfib3IeJUfha7nrrsk09lfRnyLnbWK+yHg=", + "data_hash": "cDbPB8rYtrfmmxu6+OU7xqGoXzZ11aWFoZmXX76pIoo=" + } + ], + "address_book": { + "0:21442FE867807D560325B5F36410D49D2E4B653000CF13BDF322221C2FA7ACDA": { + "user_friendly": "EQAhRC_oZ4B9VgMltfNkENSdLktlMADPE73zIiIcL6es2o7-", + "domain": null + }, + "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00": { + "user_friendly": "EQAuaQQMX8BER-YJpXJ2LWfF7GVVvtXLs4o0w9Y-wDJuAMzF", + "domain": null + }, + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null + }, + "0:33BD967DA5CA10483CEB6E5C1997818372415AD5718A1E8ED8FC400AD591CD2B": { + "user_friendly": "EQAzvZZ9pcoQSDzrblwZl4GDckFa1XGKHo7Y_EAK1ZHNK3Uh", + "domain": null + }, + "0:3690254DC15B2297610CDA60744A45F2B710AA4234B89ADB630E99D79B01BD4F": { + "user_friendly": "EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO", + "domain": null + }, + "0:65DE083A0007638233B6668354E50E44CD4225F1730D66B8B1F19E5D26690751": { + "user_friendly": "EQBl3gg6AAdjgjO2ZoNU5Q5EzUIl8XMNZrix8Z5dJmkHUfxI", + "domain": null + }, + "0:69CF6ED219C2AD5DD3B0CE82328E13B70DCD2182FEDAD1458B77FB7EE1BE401A": { + "user_friendly": "EQBpz27SGcKtXdOwzoIyjhO3Dc0hgv7a0UWLd_t-4b5AGrg6", + "domain": null + }, + "0:896E9D240693F03E8046F94E42F9C59F3FF8E792CBE8B467C0ACF179D10F508A": { + "user_friendly": "EQCJbp0kBpPwPoBG-U5C-cWfP_jnksvotGfArPF50Q9Qiv9h", + "domain": null + }, + "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE": { + "user_friendly": "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", + "domain": "usdt-minter.ton" + }, + "0:B5ABAF70EDE26CFEE042212717E7B9BD6AB6B4ADEF2B4B5A3FFBA4F4AA453671": { + "user_friendly": "EQC1q69w7eJs_uBCIScX57m9ara0re8rS1o_-6T0qkU2cXkh", + "domain": null + }, + "0:BDF3FA8098D129B54B4F73B5BAC5D1E1FD91EB054169C3916DFC8CCD536D1000": { + "user_friendly": "EQC98_qAmNEptUtPc7W6xdHh_ZHrBUFpw5Ft_IzNU20QAJav", + "domain": "ton-pidors.ton" + }, + "0:C65D37E38DF2DDE2583796A54A3FDE6038AE3BE49A151F86423A94A8E618A409": { + "user_friendly": "EQDGXTfjjfLd4lg3lqVKP95gOK475JoVH4ZCOpSo5hikCamd", + "domain": null + }, + "0:F2182CB4C74914C5F95CFEAAEA800555BB64777F699BC60817B83B08141DB2C5": { + "user_friendly": "EQDyGCy0x0kUxflc_qrqgAVVu2R3f2mbxggXuDsIFB2yxS6Y", + "domain": null + } + }, + "metadata": { + "0:21442FE867807D560325B5F36410D49D2E4B653000CF13BDF322221C2FA7ACDA": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_masters", + "name": "TON Drift", + "symbol": "DRIFT", + "description": "Exploring the world of blockchain with the TON community. Telegram community -https://t.me/ton_drift", + "image": "https://avatars.githubusercontent.com/u/149593835?s=400&u=2c74de80c13b9fdf76d1f256b6043e91654c86d8&v=4", + "extra": { + "_image_big": "https://imgproxy.toncenter.com/9reg_S6kuEfF0oSYamnOfB6tAQBmfTJB9zdb18pTmA4/pr:big/aHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE0OTU5MzgzNT9zPTQwMCZ1PTJjNzRkZTgwYzEzYjlmZGY3NmQxZjI1NmI2MDQzZTkxNjU0Yzg2ZDgmdj00", + "_image_medium": "https://imgproxy.toncenter.com/B_PA0zQ_cFqP1mZHXuazNMg72OmnxGWBZIfLw97Cro4/pr:medium/aHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE0OTU5MzgzNT9zPTQwMCZ1PTJjNzRkZTgwYzEzYjlmZGY3NmQxZjI1NmI2MDQzZTkxNjU0Yzg2ZDgmdj00", + "_image_small": "https://imgproxy.toncenter.com/Ba1IMbfMK9MpEMRAnxegb1-fHFq6d2clss01s7FYinY/pr:small/aHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE0OTU5MzgzNT9zPTQwMCZ1PTJjNzRkZTgwYzEzYjlmZGY3NmQxZjI1NmI2MDQzZTkxNjU0Yzg2ZDgmdj00", + "decimals": "10" + } + } + ] + }, + "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_wallets", + "extra": { + "balance": "3201565", + "jetton": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + } + } + ] + }, + "0:33BD967DA5CA10483CEB6E5C1997818372415AD5718A1E8ED8FC400AD591CD2B": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_wallets", + "extra": { + "balance": "500000000", + "jetton": "0:896E9D240693F03E8046F94E42F9C59F3FF8E792CBE8B467C0ACF179D10F508A", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + } + } + ] + }, + "0:3690254DC15B2297610CDA60744A45F2B710AA4234B89ADB630E99D79B01BD4F": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_masters", + "name": "STON", + "symbol": "STON", + "description": "STON is the utility token of the STON.fi DEX integrated into the core protocol mechanics. STON allows participation in protocol governance through long-term staking.", + "image": "https://static.ston.fi/logo/ston_symbol.png", + "extra": { + "_image_big": "https://imgproxy.toncenter.com/yFGJJEEY2gebei4OHOy0PaMu6HUGoLv3p7t6QRFDIe4/pr:big/aHR0cHM6Ly9zdGF0aWMuc3Rvbi5maS9sb2dvL3N0b25fc3ltYm9sLnBuZw", + "_image_medium": "https://imgproxy.toncenter.com/P9HJUKPyMuY_9d5MvLGNPaeDukBiZZJTLt-2xvMwOSM/pr:medium/aHR0cHM6Ly9zdGF0aWMuc3Rvbi5maS9sb2dvL3N0b25fc3ltYm9sLnBuZw", + "_image_small": "https://imgproxy.toncenter.com/vwqumxgZofo5DMlvBBpqiw3DVEkslHTGY58wpiKcQA0/pr:small/aHR0cHM6Ly9zdGF0aWMuc3Rvbi5maS9sb2dvL3N0b25fc3ltYm9sLnBuZw", + "decimals": "9", + "social": [ + "https://t.me/stonfidex", + "https://t.me/stonfichat", + "https://twitter.com/ston_fi", + "https://github.com/ston-fi", + "https://discord.com/invite/bdmaGV6qUw", + "https://www.reddit.com/r/STONFi/" + ], + "uri": "https://static.ston.fi/jetton/ston.json", + "websites": [ + "https://ston.fi/", + "https://app.ston.fi/" + ] + } + } + ] + }, + "0:65DE083A0007638233B6668354E50E44CD4225F1730D66B8B1F19E5D26690751": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_masters", + "name": "Lavandos", + "symbol": "LAVE", + "description": "This is a universal token for use in all areas of the decentralized Internet in the TON blockchain, web3, Telegram bots, TON sites. Issue of 4.6 billion coins. Telegram channels: Englishversion: @lave_eng Русскоязычная версия: @lavetoken, @lavefoundation, versión en español: @lave_esp, www.lavetoken.com", + "image": "https://i.ibb.co/Bj5KqK4/IMG-20221213-115545-207.png", + "extra": { + "_image_big": "https://imgproxy.toncenter.com/obukFrkJWD4dJvPH2KF-dA5aQyeb4JrfvYMbe2mmzrI/pr:big/aHR0cHM6Ly9pLmliYi5jby9CajVLcUs0L0lNRy0yMDIyMTIxMy0xMTU1NDUtMjA3LnBuZw", + "_image_medium": "https://imgproxy.toncenter.com/c0JXbHCh6Zezl2FzkDNp6j18zZGBsvduH_yQjPrUr4o/pr:medium/aHR0cHM6Ly9pLmliYi5jby9CajVLcUs0L0lNRy0yMDIyMTIxMy0xMTU1NDUtMjA3LnBuZw", + "_image_small": "https://imgproxy.toncenter.com/dm4VimfBsfGs5d5eoTpGGmvlFm8qopuqCsSRx86sneg/pr:small/aHR0cHM6Ly9pLmliYi5jby9CajVLcUs0L0lNRy0yMDIyMTIxMy0xMTU1NDUtMjA3LnBuZw", + "decimals": "9" + } + } + ] + }, + "0:69CF6ED219C2AD5DD3B0CE82328E13B70DCD2182FEDAD1458B77FB7EE1BE401A": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_wallets", + "extra": { + "balance": "0", + "jetton": "0:3690254DC15B2297610CDA60744A45F2B710AA4234B89ADB630E99D79B01BD4F", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + } + } + ] + }, + "0:896E9D240693F03E8046F94E42F9C59F3FF8E792CBE8B467C0ACF179D10F508A": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_masters", + "name": "Ton Raffles Token", + "symbol": "RAFF", + "description": "$RAFF is a unique utility token, the centerpiece of tonraffles.app ecosystem.", + "image": "https://tonraffles.store/raffjetton/jetton.svg", + "extra": { + "_image_big": "https://imgproxy.toncenter.com/AeBuJwEA9feweGKWh7W2EBzDexxtJDHmelAMI7A3AGM/pr:big/aHR0cHM6Ly90b25yYWZmbGVzLnN0b3JlL3JhZmZqZXR0b24vamV0dG9uLnN2Zw", + "_image_medium": "https://imgproxy.toncenter.com/z32h_3PBXgXkVaI-1aJzKlhX7dp2RaLtoklWPC7cLSs/pr:medium/aHR0cHM6Ly90b25yYWZmbGVzLnN0b3JlL3JhZmZqZXR0b24vamV0dG9uLnN2Zw", + "_image_small": "https://imgproxy.toncenter.com/OUQD0tH38YsBCXbc7vfBmRMajObj9hw4AIucBAlhwEg/pr:small/aHR0cHM6Ly90b25yYWZmbGVzLnN0b3JlL3JhZmZqZXR0b24vamV0dG9uLnN2Zw", + "decimals": "9", + "social": [ + "https://t.me/tonraffles", + "https://twitter.com/tonraffles" + ], + "uri": "https://tonraffles.store/raffjetton/raffmetadata.json", + "websites": [ + "https://tonraffles.app", + "https://tonraffles.org" + ] + } + } + ] + }, + "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_masters", + "name": "Tether USD", + "symbol": "USD₮", + "description": "Tether Token for Tether USD", + "image": "https://tether.to/images/logoCircle.png", + "extra": { + "_image_big": "https://imgproxy.toncenter.com/NjX4Q-12KRVJWesSACylluv2qLLk-edfoM0uBKcZXzc/pr:big/aHR0cHM6Ly90ZXRoZXIudG8vaW1hZ2VzL2xvZ29DaXJjbGUucG5n", + "_image_medium": "https://imgproxy.toncenter.com/xRUZjT_ollIDQsK8yMiW0LS60UhF6Na4xggIr5MkYv0/pr:medium/aHR0cHM6Ly90ZXRoZXIudG8vaW1hZ2VzL2xvZ29DaXJjbGUucG5n", + "_image_small": "https://imgproxy.toncenter.com/Oc_-EyeJL4TIdhqtfbL5QlY2VkekBLAHD7-CV1HfiPY/pr:small/aHR0cHM6Ly90ZXRoZXIudG8vaW1hZ2VzL2xvZ29DaXJjbGUucG5n", + "decimals": "6", + "uri": "https://tether.to/usdt-ton.json" + } + } + ] + }, + "0:B5ABAF70EDE26CFEE042212717E7B9BD6AB6B4ADEF2B4B5A3FFBA4F4AA453671": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_wallets", + "extra": { + "balance": "351000000000", + "jetton": "0:65DE083A0007638233B6668354E50E44CD4225F1730D66B8B1F19E5D26690751", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + } + } + ] + }, + "0:BDF3FA8098D129B54B4F73B5BAC5D1E1FD91EB054169C3916DFC8CCD536D1000": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_masters", + "name": "Tonstakers TON", + "symbol": "tsTON", + "description": "Tonstakers liquid staked TON", + "image": "https://tonstakers.com/jetton/logo.svg", + "extra": { + "_image_big": "https://imgproxy.toncenter.com/zFTsJt9jUSGB0gvXZ3j6kGwe_8LMj3jOtU53rT8DUG0/pr:big/aHR0cHM6Ly90b25zdGFrZXJzLmNvbS9qZXR0b24vbG9nby5zdmc", + "_image_medium": "https://imgproxy.toncenter.com/58vLC798AxNtmMVv5ZnNIDzD_eVSYeXvUx8rCiV9ohM/pr:medium/aHR0cHM6Ly90b25zdGFrZXJzLmNvbS9qZXR0b24vbG9nby5zdmc", + "_image_small": "https://imgproxy.toncenter.com/9Vs7OIIapRfqc45kC3Gj7Rss7yfIdtBAjQVfQKfycQg/pr:small/aHR0cHM6Ly90b25zdGFrZXJzLmNvbS9qZXR0b24vbG9nby5zdmc", + "decimals": "9", + "uri": "https://tonstakers.com/jetton/meta.json" + } + } + ] + }, + "0:C65D37E38DF2DDE2583796A54A3FDE6038AE3BE49A151F86423A94A8E618A409": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_wallets", + "extra": { + "balance": "0", + "jetton": "0:BDF3FA8098D129B54B4F73B5BAC5D1E1FD91EB054169C3916DFC8CCD536D1000", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + } + } + ] + }, + "0:F2182CB4C74914C5F95CFEAAEA800555BB64777F699BC60817B83B08141DB2C5": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "jetton_wallets", + "extra": { + "balance": "324481500000", + "jetton": "0:21442FE867807D560325B5F36410D49D2E4B653000CF13BDF322221C2FA7ACDA", + "owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + } + } + ] + } + } + } \ No newline at end of file diff --git a/core/crates/gem_ton/testdata/block_traces.json b/core/crates/gem_ton/testdata/block_traces.json new file mode 100644 index 0000000000..0f08693bde --- /dev/null +++ b/core/crates/gem_ton/testdata/block_traces.json @@ -0,0 +1,86 @@ +{ + "traces": [ + { + "is_incomplete": false, + "actions": [ + { + "success": true + } + ], + "transactions_order": [ + "6ZPUwTBTl4tiZRV1YcRU73MSdNg24xOe1k/fWLZjW/c=" + ], + "transactions": { + "6ZPUwTBTl4tiZRV1YcRU73MSdNg24xOe1k/fWLZjW/c=": { + "hash": "6ZPUwTBTl4tiZRV1YcRU73MSdNg24xOe1k/fWLZjW/c=", + "now": 1755568380, + "total_fees": "2402737", + "description": { + "aborted": false, + "compute_ph": { + "success": true, + "exit_code": 0 + }, + "action": { + "success": true + } + }, + "out_msgs": [ + { + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9", + "value": "10000000", + "op_code": "0x0", + "decoded_op_name": null, + "body": null, + "comment": null, + "decoded_body": null + } + ], + "in_msg": null + } + } + }, + { + "is_incomplete": false, + "actions": [ + { + "success": false + } + ], + "transactions_order": [ + "L5Egpf9I3suIl6CdddcmMS44geWLFKgHi3EbBDz7qy8=" + ], + "transactions": { + "L5Egpf9I3suIl6CdddcmMS44geWLFKgHi3EbBDz7qy8=": { + "hash": "L5Egpf9I3suIl6CdddcmMS44geWLFKgHi3EbBDz7qy8=", + "now": 1755568390, + "total_fees": "2760123", + "description": { + "aborted": false, + "compute_ph": { + "success": true, + "exit_code": 0 + }, + "action": { + "success": true + } + }, + "out_msgs": [ + { + "source": "0:B77DFEB2A1D8C25DC6F4FDB280454275F6A81DC3CD6884EF0E4FA64A768D1042", + "destination": "0:017CE17C998BAC2342AD12554D94CC3559B2B56958F843BD9F0101208761A5CD", + "value": "25000000", + "op_code": "0x0", + "decoded_op_name": null, + "body": null, + "comment": null, + "decoded_body": null + } + ], + "in_msg": null + } + } + } + ] +} diff --git a/core/crates/gem_ton/testdata/jetton_swap_from_jetton_transfer_trace.json b/core/crates/gem_ton/testdata/jetton_swap_from_jetton_transfer_trace.json new file mode 100644 index 0000000000..dd3bd46a6f --- /dev/null +++ b/core/crates/gem_ton/testdata/jetton_swap_from_jetton_transfer_trace.json @@ -0,0 +1,67 @@ +{ + "traces": [ + { + "is_incomplete": false, + "actions": [ + { + "success": true, + "type": "jetton_swap", + "details": { + "dex": "stonfi_v2", + "sender": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "asset_in": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE", + "asset_out": null, + "dex_incoming_transfer": { + "asset": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:92E1411AE546892F33B2C8A89EA90390D8FF4CFBB917A643B91E73F706FDB9D1", + "source_jetton_wallet": "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00", + "destination_jetton_wallet": "0:922D627D7D8EDBD00E4E23BDB0C54A76EE5E1F46573A1AF4417857FA3E23E91F", + "amount": "1000000" + }, + "dex_outgoing_transfer": { + "asset": null, + "source": "0:92E1411AE546892F33B2C8A89EA90390D8FF4CFBB917A643B91E73F706FDB9D1", + "destination": "0:F1FBE8D453D3D4B4796D3D8657EF0773222D123D8DCA67C9EF3ACD2266F40977", + "source_jetton_wallet": null, + "destination_jetton_wallet": null, + "amount": "476299454" + }, + "peer_swaps": [] + } + } + ], + "transactions_order": [ + "UGYU1HNmqagLqgYKobcgssWuXRjWirScE8StY12h4N8=" + ], + "transactions": { + "UGYU1HNmqagLqgYKobcgssWuXRjWirScE8StY12h4N8=": { + "hash": "UGYU1HNmqagLqgYKobcgssWuXRjWirScE8StY12h4N8=", + "now": 1778820554, + "total_fees": "768090", + "description": { + "aborted": false, + "compute_ph": { "success": true, "exit_code": 0 }, + "action": { "success": true } + }, + "out_msgs": [ + { + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00", + "value": "540000000", + "opcode": "0x0f8a7ea5", + "comment": null, + "decoded_body": null + } + ], + "in_msg": { + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "opcode": "0x9c7256a1" + } + } + } + } + ] +} diff --git a/core/crates/gem_ton/testdata/jetton_swap_trace.json b/core/crates/gem_ton/testdata/jetton_swap_trace.json new file mode 100644 index 0000000000..b2b478593e --- /dev/null +++ b/core/crates/gem_ton/testdata/jetton_swap_trace.json @@ -0,0 +1,55 @@ +{ + "traces": [ + { + "is_incomplete": false, + "actions": [ + { + "success": true, + "type": "jetton_swap", + "details": { + "dex": "stonfi_v2", + "sender": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "asset_in": null, + "asset_out": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE", + "dex_incoming_transfer": { + "asset": null, + "amount": "1000000000" + }, + "dex_outgoing_transfer": { + "asset": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE", + "amount": "2436222" + } + } + } + ], + "transactions_order": [ + "Jy/yVxsw+JLlTyRAM/dcrGXGzmM32RPFJg++f9VOgq0=" + ], + "transactions": { + "Jy/yVxsw+JLlTyRAM/dcrGXGzmM32RPFJg++f9VOgq0=": { + "hash": "Jy/yVxsw+JLlTyRAM/dcrGXGzmM32RPFJg++f9VOgq0=", + "now": 1778565402, + "total_fees": "586312", + "description": { + "aborted": false, + "compute_ph": { "success": true, "exit_code": 0 }, + "action": { "success": true } + }, + "out_msgs": [ + { + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:92E1411AE546892F33B2C8A89EA90390D8FF4CFBB917A643B91E73F706FDB9D1", + "value": "1310000000", + "op_code": "0x0", + "decoded_op_name": null, + "body": null, + "comment": null, + "decoded_body": null + } + ], + "in_msg": null + } + } + } + ] +} diff --git a/core/crates/gem_ton/testdata/jetton_transfer_trace.json b/core/crates/gem_ton/testdata/jetton_transfer_trace.json new file mode 100644 index 0000000000..78972f72a4 --- /dev/null +++ b/core/crates/gem_ton/testdata/jetton_transfer_trace.json @@ -0,0 +1,54 @@ +{ + "traces": [ + { + "is_incomplete": false, + "actions": [ + { + "success": true, + "type": "jetton_transfer", + "details": { + "asset": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE", + "sender": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "receiver": "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9", + "sender_jetton_wallet": "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00", + "receiver_jetton_wallet": "0:5A28F4D8855311FE9348F532C01D6FB968F6A06C4339A1DD56B9CD8AD8A5531D", + "amount": "120000", + "comment": "", + "forward_amount": "1" + } + } + ], + "transactions_order": [ + "Neuo7isoCyC1bmZ1DOyw+9uYJcXgmr+onBW+es66ctA=" + ], + "transactions": { + "Neuo7isoCyC1bmZ1DOyw+9uYJcXgmr+onBW+es66ctA=": { + "hash": "Neuo7isoCyC1bmZ1DOyw+9uYJcXgmr+onBW+es66ctA=", + "now": 1778644898, + "total_fees": "472458", + "description": { + "aborted": false, + "compute_ph": { "success": true, "exit_code": 0 }, + "action": { "success": true } + }, + "out_msgs": [ + { + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00", + "value": "60000000", + "opcode": "0x0f8a7ea5", + "comment": null, + "decoded_body": null + } + ], + "in_msg": { + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "opcode": "0x7ddeb5e4" + } + } + } + } + ] +} diff --git a/core/crates/gem_ton/testdata/nft_transfer_trace.json b/core/crates/gem_ton/testdata/nft_transfer_trace.json new file mode 100644 index 0000000000..eb1e7bfc8d --- /dev/null +++ b/core/crates/gem_ton/testdata/nft_transfer_trace.json @@ -0,0 +1,902 @@ +{ + "traces": [ + { + "trace_id": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "external_hash": "/W8b7O9jJrCnkk5oL0B+IWJggTGz0CzaYSvPI18PW6M=", + "mc_seqno_start": "67809893", + "mc_seqno_end": "67809893", + "start_lt": "78058035000003", + "start_utime": 1779208203, + "end_lt": "78058035000008", + "end_utime": 1779208203, + "trace_info": { + "trace_state": "complete", + "messages": 4, + "transactions": 4, + "pending_messages": 0, + "classification_state": "unclassified" + }, + "is_incomplete": false, + "actions": [ + { + "trace_id": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "action_id": "1lxaMeEe0d3JQbUeAygYRDJ/wcluqvbh+2H63sozCiQ=", + "start_lt": "78058035000004", + "end_lt": "78058035000008", + "start_utime": 1779208203, + "end_utime": 1779208203, + "trace_end_lt": "78058035000008", + "trace_end_utime": 1779208203, + "trace_mc_seqno_end": 67809893, + "transactions": [ + "MO3X9SwDa6nbAHAYayyqZeIjdScUb/gC1R16y/xLA/g=", + "h4RnVfPRifhlLTvm1rLb015KFxUxOR7WcZWl1iXJdhs=", + "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "G1HVcPV+Aeta3ziT+HTZltkbscuv6pppDz1mANKU3zU=" + ], + "success": true, + "type": "nft_transfer", + "details": { + "nft_collection": "0:8C9D25C2C5802EDF8BFBBEB45E391FFD4710AEBF943DA740D0631765386F7CAF", + "nft_item": "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364", + "nft_item_index": "579", + "old_owner": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "new_owner": "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9", + "is_purchase": false, + "query_id": "0", + "response_destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "custom_payload": null, + "forward_payload": "te6cckEBAQEAAgAAAEysuc0=", + "forward_amount": "10000000", + "comment": null, + "is_encrypted_comment": false, + "marketplace": null, + "real_old_owner": null, + "marketplace_address": null, + "payout_amount": null, + "payout_address": null, + "payout_comment": null, + "payout_comment_encrypted": null, + "payout_comment_encoded": null, + "royalty_amount": null, + "royalty_address": null + }, + "trace_external_hash": "/W8b7O9jJrCnkk5oL0B+IWJggTGz0CzaYSvPI18PW6M=", + "finality": "finalized" + } + ], + "trace": { + "tx_hash": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "in_msg_hash": "/W8b7O9jJrCnkk5oL0B+IWJggTGz0CzaYSvPI18PW6M=", + "children": [ + { + "tx_hash": "h4RnVfPRifhlLTvm1rLb015KFxUxOR7WcZWl1iXJdhs=", + "in_msg_hash": "Y/4axAhnEv/drVVleUlEbCbiTxh5kUWa0442YFa9v78=", + "children": [ + { + "tx_hash": "MO3X9SwDa6nbAHAYayyqZeIjdScUb/gC1R16y/xLA/g=", + "in_msg_hash": "9x+fJ3J3otJlHZHKRJ6SR0tVVQsHiuNkTl5eQ5wn+5o=", + "children": [] + }, + { + "tx_hash": "G1HVcPV+Aeta3ziT+HTZltkbscuv6pppDz1mANKU3zU=", + "in_msg_hash": "hk5QfgNH2MGqoFlqGk2NHvOSbj8a9vop/rc+ReW/Xn0=", + "children": [] + } + ] + } + ] + }, + "transactions_order": [ + "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "h4RnVfPRifhlLTvm1rLb015KFxUxOR7WcZWl1iXJdhs=", + "MO3X9SwDa6nbAHAYayyqZeIjdScUb/gC1R16y/xLA/g=", + "G1HVcPV+Aeta3ziT+HTZltkbscuv6pppDz1mANKU3zU=" + ], + "transactions": { + "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=": { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "lt": "78058035000003", + "now": 1779208203, + "mc_block_seqno": 67809893, + "trace_id": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "prev_trans_hash": "k95jS4doWfO/ZERpTKSX13ToNlqFbdH7T40SxjiGnmw=", + "prev_trans_lt": "78053529000019", + "orig_status": "active", + "end_status": "active", + "total_fees": "459727", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "82", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "220534", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "117334", + "total_action_fees": "39110", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "1NcV1OKQOQnDbewVbCGtCoGbVIr1901wsUpYg6zjLNY=", + "tot_msg_size": { + "cells": "2", + "bits": "1365" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 72681391 + }, + "in_msg": { + "hash": "/W8b7O9jJrCnkk5oL0B+IWJggTGz0CzaYSvPI18PW6M=", + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "extra_flags": null, + "created_lt": null, + "created_at": null, + "opcode": "0xd528c2f8", + "decoded_opcode": null, + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "Qnwx7t5Y00USOzb+qyuDIwPTzGKeHrEP3OLVmP+My/o=", + "body": "te6cckEBAwEA3QABnNUowvjK38aHL0MxtL3soW1xQi9U4OxEXTVYitO4tb9AYB04VUfWYzZXXG1adZNxNg5PaBuzNVFEm2ZX/Yy44gspqaMXagySYQAAAMgAAwEBaGIAKyoEmb5dGW+6SiK2NF8R0L6Mohj1ZlSlsqEbx8Rv2bIgF9eEAAAAAAAAAAAAAAAAAAECAKVfzD0UAAAAAAAAAACAGlIyzc8ujLpKpJxC1AW14VWTmy3NamJ+iezgmGkRhvswAM6FKWpQGl51ZuTKImFkWYLixc2NCsRYy79zJEauQV88cxLQCC6EBu0=", + "decoded": { + "@type": "wallet_signed_v4", + "signature": "D528C2F8CADFC6872F4331B4BDECA16D71422F54E0EC445D35588AD3B8B5BF40601D385547D66336575C6D5A759371360E4F681BB33551449B6657FD8CB8E20B", + "subwallet_id": "698983191", + "valid_until": "1779208801", + "msg_seqno": "200", + "op": { + "@type": "simple_send", + "msgs": { + "@type": "msgs_1", + "msg_1": { + "mode": "3", + "msg": { + "@type": "message", + "info": { + "@type": "int_msg_info", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "src": { + "@type": "addr_none" + }, + "dest": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364" + }, + "value": { + "@type": "currencies", + "grams": { + "@type": "nanograms", + "amount": { + "@type": "var_uint", + "len": "4", + "value": "50000000" + } + }, + "other": { + "@type": "extra_currencies", + "dict": { + "@type": "hashmap_empty" + } + } + }, + "ihr_fee": { + "@type": "nanograms", + "amount": { + "@type": "var_uint", + "len": "0", + "value": "0" + } + }, + "fwd_fee": { + "@type": "nanograms", + "amount": { + "@type": "var_uint", + "len": "0", + "value": "0" + } + }, + "created_lt": "0", + "created_at": "0" + }, + "init": { + "@type": "nothing_from_maybe" + }, + "body": { + "@type": "second_from_either", + "value": { + "cell_reference": { + "@type": "nft_transfer", + "query_id": "0", + "new_owner": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9" + }, + "response_destination": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + }, + "custom_payload": { + "@type": "nothing_from_maybe" + }, + "forward_amount": { + "@type": "var_uint", + "len": "3", + "value": "10000000" + }, + "forward_payload": { + "@type": "first_from_either", + "value": { + "@type": "empty_cell" + } + } + } + } + } + } + } + } + } + } + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "Y/4axAhnEv/drVVleUlEbCbiTxh5kUWa0442YFa9v78=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364", + "value": "50000000", + "value_extra_currencies": {}, + "fwd_fee": "78224", + "ihr_fee": "0", + "extra_flags": "0", + "created_lt": "78058035000004", + "created_at": "1779208203", + "opcode": "0x5fcc3d14", + "decoded_opcode": "nft_transfer", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "sy14LY+InZ+i5oMTYWzU4LQYx3m4QdgMD4f5+u9V+lA=", + "body": "te6cckEBAQEAVQAApV/MPRQAAAAAAAAAAIAaUjLNzy6MukqknELUBbXhVZObLc1qYn6J7OCYaRGG+zAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzxzEtAI+Xhrow==", + "decoded": { + "@type": "nft_transfer", + "query_id": "0", + "new_owner": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9" + }, + "response_destination": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + }, + "custom_payload": { + "@type": "nothing_from_maybe" + }, + "forward_amount": { + "@type": "var_uint", + "len": "3", + "value": "10000000" + }, + "forward_payload": { + "@type": "first_from_either", + "value": { + "@type": "empty_cell" + } + } + } + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "55+TX6+nSRLhHdpVxYrS4QIJa3AoOjAsQPss/Dn2r+8=", + "balance": "79060599561", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "MZUCx6QHadMIZGoyQ6TQavv15qYk5Mo1GrVg/bba5oM=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "YHYFU5fgmCNPhxMmWMDJLlvNmFdY0TEAHXaN1hXeyYs=", + "balance": null, + "extra_currencies": null, + "account_status": null, + "frozen_hash": null, + "data_hash": null, + "code_hash": null + }, + "emulated": false, + "finality": "finalized" + }, + "G1HVcPV+Aeta3ziT+HTZltkbscuv6pppDz1mANKU3zU=": { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "G1HVcPV+Aeta3ziT+HTZltkbscuv6pppDz1mANKU3zU=", + "lt": "78058035000008", + "now": 1779208203, + "mc_block_seqno": 67809893, + "trace_id": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "prev_trans_hash": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "prev_trans_lt": "78058035000003", + "orig_status": "active", + "end_status": "active", + "total_fees": "66068", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "0", + "status_change": "unchanged" + }, + "credit_ph": { + "credit": "39260449" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "66068", + "gas_used": "991", + "gas_limit": "588906", + "mode": 0, + "exit_code": 0, + "vm_steps": 29, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "result_code": 0, + "tot_actions": 0, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 0, + "action_list_hash": "lqKW0iTyhcZ77pPDD4owkVfw2qNdxbh+QQt4YwoJz8c=", + "tot_msg_size": { + "cells": "0", + "bits": "0" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 72681391 + }, + "in_msg": { + "hash": "hk5QfgNH2MGqoFlqGk2NHvOSbj8a9vop/rc+ReW/Xn0=", + "source": "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364", + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": "39260449", + "value_extra_currencies": {}, + "fwd_fee": "44446", + "ihr_fee": "0", + "extra_flags": "0", + "created_lt": "78058035000007", + "created_at": "1779208203", + "opcode": "0xd53276db", + "decoded_opcode": "excess", + "ihr_disabled": true, + "bounce": false, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "la37uGspaEdb70INRgaUtfLiBGlqXsrLMlKwhwvngQY=", + "body": "te6cckEBAQEADgAAGNUydtsAAAAAAAAAAPfBmNw=", + "decoded": { + "@type": "excess", + "query_id": "0" + } + }, + "init_state": null + }, + "out_msgs": [], + "account_state_before": { + "hash": "YHYFU5fgmCNPhxMmWMDJLlvNmFdY0TEAHXaN1hXeyYs=", + "balance": null, + "extra_currencies": null, + "account_status": null, + "frozen_hash": null, + "data_hash": null, + "code_hash": null + }, + "account_state_after": { + "hash": "OmLwoo0d/xcoVG98CrG6Rsxd37AlnDU/bymXOsaLwac=", + "balance": "79049255991", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "ELkhovL3s/SVfvsdHqGzAGQEEm+7mDJYHMjjJbYXoF0=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "emulated": false, + "finality": "finalized" + }, + "MO3X9SwDa6nbAHAYayyqZeIjdScUb/gC1R16y/xLA/g=": { + "account": "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9", + "hash": "MO3X9SwDa6nbAHAYayyqZeIjdScUb/gC1R16y/xLA/g=", + "lt": "78058035000007", + "now": 1779208203, + "mc_block_seqno": 67809893, + "trace_id": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "prev_trans_hash": "nhccgeaJ9kqO7TqpAoCCd1P87I5KiQOe/5cHWu76UbE=", + "prev_trans_lt": "77961682000009", + "orig_status": "active", + "end_status": "active", + "total_fees": "67806", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "1738", + "status_change": "unchanged" + }, + "credit_ph": { + "credit": "10000000" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "66068", + "gas_used": "991", + "gas_limit": "149999", + "mode": 0, + "exit_code": 0, + "vm_steps": 29, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "result_code": 0, + "tot_actions": 0, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 0, + "action_list_hash": "lqKW0iTyhcZ77pPDD4owkVfw2qNdxbh+QQt4YwoJz8c=", + "tot_msg_size": { + "cells": "0", + "bits": "0" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 72681391 + }, + "in_msg": { + "hash": "9x+fJ3J3otJlHZHKRJ6SR0tVVQsHiuNkTl5eQ5wn+5o=", + "source": "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364", + "destination": "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9", + "value": "10000000", + "value_extra_currencies": {}, + "fwd_fee": "65068", + "ihr_fee": "0", + "extra_flags": "0", + "created_lt": "78058035000006", + "created_at": "1779208203", + "opcode": "0x05138d91", + "decoded_opcode": "nft_ownership_assigned", + "ihr_disabled": true, + "bounce": false, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "v8dyyFDA/gut4+olPdLnKpVXXyfuwkKy1CK3omeJPc8=", + "body": "te6cckEBAQEAMAAAWwUTjZEAAAAAAAAAAIAGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+ej70zcU", + "decoded": { + "@type": "nft_ownership_assigned", + "query_id": "0", + "prev_owner": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + }, + "forward_payload": { + "@type": "first_from_either", + "value": { + "@type": "empty_cell" + } + } + } + }, + "init_state": null + }, + "out_msgs": [], + "account_state_before": { + "hash": "MJBZhEUeyX+Pe4axolQs0pk39pqCBW7H7e8dlmBohjY=", + "balance": "18188171825", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "6S4VeNcBaNtYZFEAGZ8emt0+NbL17B0jjs7nRVbtLZc=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "PEFRWTQwyRTCRj6q1vi3bsQAyrbXvycoXZMcmGSj5U0=", + "balance": "18198104019", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "6S4VeNcBaNtYZFEAGZ8emt0+NbL17B0jjs7nRVbtLZc=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "emulated": false, + "finality": "finalized" + }, + "h4RnVfPRifhlLTvm1rLb015KFxUxOR7WcZWl1iXJdhs=": { + "account": "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364", + "hash": "h4RnVfPRifhlLTvm1rLb015KFxUxOR7WcZWl1iXJdhs=", + "lt": "78058035000005", + "now": 1779208203, + "mc_block_seqno": 67809893, + "trace_id": "/vQvSjbtgBUiW0f5qfojHX4jvuEbFb0ttlMPkdbvnKY=", + "prev_trans_hash": "hN2o4rVmLlwgGsncba1M14qMIRwdqeRY9gnDcMDZi80=", + "prev_trans_lt": "78053492000003", + "orig_status": "active", + "end_status": "active", + "total_fees": "636705", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": false, + "storage_ph": { + "storage_fees_collected": "83", + "status_change": "unchanged" + }, + "credit_ph": { + "credit": "50000000" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "581868", + "gas_used": "8728", + "gas_limit": "749999", + "mode": 0, + "exit_code": 0, + "vm_steps": 220, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "164268", + "total_action_fees": "54754", + "result_code": 0, + "tot_actions": 2, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 2, + "action_list_hash": "x1y2GuxND/5jLtUwtnZb5C/jHYJnjOv9sv5KGczniV8=", + "tot_msg_size": { + "cells": "3", + "bits": "1846" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 72681391 + }, + "in_msg": { + "hash": "Y/4axAhnEv/drVVleUlEbCbiTxh5kUWa0442YFa9v78=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364", + "value": "50000000", + "value_extra_currencies": {}, + "fwd_fee": "78224", + "ihr_fee": "0", + "extra_flags": "0", + "created_lt": "78058035000004", + "created_at": "1779208203", + "opcode": "0x5fcc3d14", + "decoded_opcode": "nft_transfer", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "sy14LY+InZ+i5oMTYWzU4LQYx3m4QdgMD4f5+u9V+lA=", + "body": "te6cckEBAQEAVQAApV/MPRQAAAAAAAAAAIAaUjLNzy6MukqknELUBbXhVZObLc1qYn6J7OCYaRGG+zAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzxzEtAI+Xhrow==", + "decoded": { + "@type": "nft_transfer", + "query_id": "0", + "new_owner": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9" + }, + "response_destination": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + }, + "custom_payload": { + "@type": "nothing_from_maybe" + }, + "forward_amount": { + "@type": "var_uint", + "len": "3", + "value": "10000000" + }, + "forward_payload": { + "@type": "first_from_either", + "value": { + "@type": "empty_cell" + } + } + } + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "9x+fJ3J3otJlHZHKRJ6SR0tVVQsHiuNkTl5eQ5wn+5o=", + "source": "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364", + "destination": "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9", + "value": "10000000", + "value_extra_currencies": {}, + "fwd_fee": "65068", + "ihr_fee": "0", + "extra_flags": "0", + "created_lt": "78058035000006", + "created_at": "1779208203", + "opcode": "0x05138d91", + "decoded_opcode": "nft_ownership_assigned", + "ihr_disabled": true, + "bounce": false, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "v8dyyFDA/gut4+olPdLnKpVXXyfuwkKy1CK3omeJPc8=", + "body": "te6cckEBAQEAMAAAWwUTjZEAAAAAAAAAAIAGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+ej70zcU", + "decoded": { + "@type": "nft_ownership_assigned", + "query_id": "0", + "prev_owner": { + "@type": "addr_std", + "anycast": { + "@type": "nothing_from_maybe" + }, + "workchain_id": "0", + "address": "33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + }, + "forward_payload": { + "@type": "first_from_either", + "value": { + "@type": "empty_cell" + } + } + } + }, + "init_state": null + }, + { + "hash": "hk5QfgNH2MGqoFlqGk2NHvOSbj8a9vop/rc+ReW/Xn0=", + "source": "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364", + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": "39260449", + "value_extra_currencies": {}, + "fwd_fee": "44446", + "ihr_fee": "0", + "extra_flags": "0", + "created_lt": "78058035000007", + "created_at": "1779208203", + "opcode": "0xd53276db", + "decoded_opcode": "excess", + "ihr_disabled": true, + "bounce": false, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "la37uGspaEdb70INRgaUtfLiBGlqXsrLMlKwhwvngQY=", + "body": "te6cckEBAQEADgAAGNUydtsAAAAAAAAAAPfBmNw=", + "decoded": { + "@type": "excess", + "query_id": "0" + } + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "rvdkDfO+N+G7/NfvruQ6hRtxXlq7XuIspNmaH5uEBVI=", + "balance": "49495204", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "l4YyJQK5eXX8DzKV1Ui9zUTJleSQTSvlbQ2RHXSAZAE=", + "code_hash": "WFkTv+s4jbGZfSvvsYBvLHnxghM34bDo+xgr3sQOKMI=" + }, + "account_state_after": { + "hash": "6h5/G0u85DFNgfYbYbrktXNY6WsqctOrbovHQ4Zv6SM=", + "balance": "49488536", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "EA9/lq4lT5bqz+2mJBK4J01yTkjjTxKynrqabb4wG44=", + "code_hash": "WFkTv+s4jbGZfSvvsYBvLHnxghM34bDo+xgr3sQOKMI=" + }, + "emulated": false, + "finality": "finalized" + } + } + } + ], + "address_book": { + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null, + "interfaces": [ + "wallet_v4r2" + ] + }, + "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364": { + "user_friendly": "EQBWVAkzfLoy33SURWxoviOhfRlEMerMqUtlQjePiN-zZG7j", + "domain": null, + "interfaces": [ + "editable", + "nft_item" + ] + }, + "0:8C9D25C2C5802EDF8BFBBEB45E391FFD4710AEBF943DA740D0631765386F7CAF": { + "user_friendly": "EQCMnSXCxYAu34v7vrReOR_9RxCuv5Q9p0DQYxdlOG98r3l8", + "domain": null, + "interfaces": [ + "nft_collection" + ] + }, + "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9": { + "user_friendly": "UQDSkZZueXRl0lUk4hagLa8KrJzZbmtTE_RPZwTDSIw32WNH", + "domain": null, + "interfaces": [ + "wallet_v4r2" + ] + } + }, + "metadata": { + "0:565409337CBA32DF7494456C68BE23A17D194431EACCA94B6542378F88DFB364": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "nft_items", + "name": "Baseball Cap #579", + "description": "Who's your favorite player?", + "image": "https://cdn.stickerdom.store/1/t/36/12.png?v=3", + "nft_index": "579", + "extra": { + "_image_big": "https://imgproxy.toncenter.com/IXv0by5tfd_WXzxSjpXMgH6yz4sRTNO1QKk--ML9fmU/pr:big/aHR0cHM6Ly9jZG4uc3RpY2tlcmRvbS5zdG9yZS8xL3QvMzYvMTIucG5nP3Y9Mw", + "_image_medium": "https://imgproxy.toncenter.com/S-Fan_MHjp4vVitdqsmD8-lMI9Ujr0KjEk2vJWWivcY/pr:medium/aHR0cHM6Ly9jZG4uc3RpY2tlcmRvbS5zdG9yZS8xL3QvMzYvMTIucG5nP3Y9Mw", + "_image_small": "https://imgproxy.toncenter.com/s7YxZazesZqvOwWiCA00DN36lSToyaanv8T4mYkhwUg/pr:small/aHR0cHM6Ly9jZG4uc3RpY2tlcmRvbS5zdG9yZS8xL3QvMzYvMTIucG5nP3Y9Mw", + "lottie": "https://cdn.stickerdom.store/1/o/36/4e8cbe0a14bc33d2e0e6d8692c2136a5.lottie.json?v=3", + "marketplace": "getgems.io", + "uri": "https://s.getgems.io/nft/b/c/68d2f3838f57a672932bdf12/579/meta.json" + } + } + ] + }, + "0:8C9D25C2C5802EDF8BFBBEB45E391FFD4710AEBF943DA740D0631765386F7CAF": { + "is_indexed": true, + "token_info": [ + { + "valid": true, + "type": "nft_collections", + "name": "Baseball Cap", + "description": "Who's your favorite player?\n\nMeet Dogs and get ready to meet your new best friend who\u2019s always got your back (and your snacks)!", + "image": "https://cdn.stickerdom.store/1/t/36/12.png?v=3", + "extra": { + "_image_big": "https://imgproxy.toncenter.com/IXv0by5tfd_WXzxSjpXMgH6yz4sRTNO1QKk--ML9fmU/pr:big/aHR0cHM6Ly9jZG4uc3RpY2tlcmRvbS5zdG9yZS8xL3QvMzYvMTIucG5nP3Y9Mw", + "_image_medium": "https://imgproxy.toncenter.com/S-Fan_MHjp4vVitdqsmD8-lMI9Ujr0KjEk2vJWWivcY/pr:medium/aHR0cHM6Ly9jZG4uc3RpY2tlcmRvbS5zdG9yZS8xL3QvMzYvMTIucG5nP3Y9Mw", + "_image_small": "https://imgproxy.toncenter.com/s7YxZazesZqvOwWiCA00DN36lSToyaanv8T4mYkhwUg/pr:small/aHR0cHM6Ly9jZG4uc3RpY2tlcmRvbS5zdG9yZS8xL3QvMzYvMTIucG5nP3Y9Mw", + "cover_image": "https://cdn.stickerdom.store/nft/1/0.png?v=3", + "marketplace": "getgems.io", + "social_links": [ + "https://t.me/dogs", + "https://x.com/realDogsHouse", + "https://coinmarketcap.com/currencies/dogs", + "https://t.me/sticker_bot/?startapp=bid_36_cid_1" + ], + "uri": "https://s.getgems.io/nft/b/c/68d2f3838f57a672932bdf12/meta.json" + } + } + ] + } + } +} diff --git a/core/crates/gem_ton/testdata/transaction_null_values.json b/core/crates/gem_ton/testdata/transaction_null_values.json new file mode 100644 index 0000000000..79cbb46255 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_null_values.json @@ -0,0 +1,151 @@ +{ + "account": "0:EC3DE3AEBBAE9EEED59A8173BA4E7F151593045740A541BEF3381E92116FD528", + "hash": "MhO9bk6+qCMfveyGBQYvoklath4SA7F/LegdwACJAvg=", + "lt": "60883059000009", + "now": 1756245539, + "mc_block_seqno": 51319007, + "trace_id": "2bXaEnmuaRRluLX3v3cwn3fkg5MJaw5SglpuBODU/jU=", + "prev_trans_hash": "brYXWdWUWPWCkfn7rYgsEU5PrZRQefTOv9CqWY9JPNU=", + "prev_trans_lt": "60880805000009", + "orig_status": "active", + "end_status": "active", + "total_fees": "7871841", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": false, + "storage_ph": { + "storage_fees_collected": "9713", + "status_change": "unchanged" + }, + "credit_ph": { + "credit": "145722808" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "6976400", + "gas_used": "17441", + "gas_limit": "364307", + "mode": 0, + "exit_code": 0, + "vm_steps": 489, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "1491600", + "total_action_fees": "885728", + "result_code": 0, + "tot_actions": 2, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 2, + "action_list_hash": "tJOgYXVfngLfDNcSlAqVrYCzperA/a3cm7ZCdIu4BGU=", + "tot_msg_size": { + "cells": "6", + "bits": "2763" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 56416536 + }, + "in_msg": { + "hash": "npIsdvD+d8ZEF/Yp3zolnInsxTPCu7V2HhIYQ8eAOSw=", + "source": "0:972DAD9E64D167BE51B1F9CA42CB0A6CEC0714A28C24A82A834BD68D12B9BF63", + "destination": "0:EC3DE3AEBBAE9EEED59A8173BA4E7F151593045740A541BEF3381E92116FD528", + "value": "145722808", + "value_extra_currencies": {}, + "fwd_fee": "649072", + "ihr_fee": "0", + "created_lt": "60883059000008", + "created_at": "1756245539", + "opcode": "0x61ee542d", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "mtZbLr6t65RO2HvMz8eQljJ8ifdkXX9u4FqZg3CA3kI=", + "body": "te6cckEBBAEAjgACaWHuVC1pour754rIPDUbTOgAGOkIRBPjSy9OSlF8IMyX/5/f2tlWxVMI6ai7yiWEF5o1aVzUAQIAh4AL4Kyfa+wI8Htq4GOcOezRURqamttNvJtkRWlyQa37AAAiAXvn9QExolNqlp7na3WLo8P7I9YKgtOHItv5GZqm2iABAglorjGZDgMDAAguHPqCsUthUQ==", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "PqgSozoBWsCMJFdvnld9IJQSnt+bS7xz0Nrd+1w/F2M=", + "source": "0:EC3DE3AEBBAE9EEED59A8173BA4E7F151593045740A541BEF3381E92116FD528", + "destination": null, + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": "60883059000010", + "created_at": "1756245539", + "opcode": "0x9c610de3", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": null, + "message_content": { + "hash": "cotlhtLjZA5RFX0J67cEteoDvhuryfptlNTqJZO8uu8=", + "body": "te6cckEBAgEAXwABWpxhDeMQC98/qAmNEptUtPc7W6xdHh/ZHrBUFpw5Ft/IzNU20QAANRtM41d1swEAWYABjpCEQT40svTkpRfCDMl/+f39rZVsVTCOmou8olhBeaK+4SvK5aupjKpwzDgq2UY=", + "decoded": null + }, + "init_state": null + }, + { + "hash": "yQIpAL47xRd49prIOhKF/g9SMy40UAZFM1DbewEvcSk=", + "source": "0:EC3DE3AEBBAE9EEED59A8173BA4E7F151593045740A541BEF3381E92116FD528", + "destination": "0:DAE153A74D894BBC32748198CD626E4F5DF4A69AD2FA56CE80FC2644B5708D20", + "value": "137245095", + "value_extra_currencies": {}, + "fwd_fee": "605872", + "ihr_fee": "0", + "created_lt": "60883059000011", + "created_at": "1756245539", + "opcode": "0xad4eb6f5", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "Ii1ZqhTJlZ6ojBcL38hVYU9FVJd2PBZgZGkk41L5n7k=", + "body": "te6cckEBAwEAggACYq1OtvVpour754rIPDV3WzgAGOkIRBPjSy9OSlF8IMyX/5/f2tlWxVMI6ai7yiWEF5sBAgCJgAvgrJ9r7Ajwe2rgY5w57NFRGpqa2028m2RFaXJBrfsAAEAQC98/qAmNEptUtPc7W6xdHh/ZHrBUFpw5Ft/IzNU20QAIAAguHPqCa1e/PQ==", + "decoded": null + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "g+hNkLfdoAMBTlYEdLUZsL0P2f50t5y6qF9Y1o6tjRk=", + "balance": "322208700", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "3kFemGlS/yZrqZg5fFssBmVY97DXNCxFuSwv3uWZc/s=", + "code_hash": "EnUJW22jkRKSQG9PQ4b554AJm4VMbe6e4old3OcJJ8E=" + }, + "account_state_after": { + "hash": "bOGwdnB4J/or38K4Qw6yUqa9VJqkvV3zK9wV+DB0a+o=", + "balance": "322208700", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "jZmvVnWYpBK+M2Wp92O323GET10arIUC3qAB6zadT6M=", + "code_hash": "EnUJW22jkRKSQG9PQ4b554AJm4VMbe6e4old3OcJJ8E=" + }, + "emulated": false +} diff --git a/core/crates/gem_ton/testdata/transaction_status_response.json b/core/crates/gem_ton/testdata/transaction_status_response.json new file mode 100644 index 0000000000..c9c872c220 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_status_response.json @@ -0,0 +1,141 @@ +{ + "transactions": [ + { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "gyjq/7IJ5KpSvZlnwixaS3RjI2xk1+5pup0k++S/yXY=", + "lt": "60616678000001", + "now": 1755574728, + "mc_block_seqno": 51055300, + "trace_id": "gyjq/7IJ5KpSvZlnwixaS3RjI2xk1+5pup0k++S/yXY=", + "prev_trans_hash": "m6fTF6rtJAieXMOw30QyKUy4jtSF0gJ2jMJ2ZDKuw+A=", + "prev_trans_lt": "60614140000001", + "orig_status": "active", + "end_status": "active", + "total_fees": "2404282", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "1618", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "1323200", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "440000", + "total_action_fees": "146664", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "gTrt5e626mRDnGB29ENBAV8MfGwceY8EZXHvw0MWPro=", + "tot_msg_size": { + "cells": "2", + "bits": "697" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 56179251 + }, + "in_msg": { + "hash": "R63YFoYN86fUYCUdhGu2iRDFEA5wDDTdwN2GSTijrDo=", + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": null, + "created_at": null, + "opcode": "0x4a1f5e18", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "QJpdR3O4/6CeKwE7IRlZFS2SZ3wHFyalb8WoeX4kysk=", + "body": "te6cckEBAwEAiQABnEofXhiM2gtn/lJEF/r522Ob3X/e684P0Ro00EEnXQDZwuKhjrDrij+P7WE5JgnMZEV8Yt8H13fruCKdc+iF9QMpqaMXaKP0HgAAAI4AAwEBZkIAaUjLNzy6MukqknELUBbXhVZObLc1qYn6J7OCYaRGG+ycxLQAAAAAAAAAAAAAAAAAAQIAADCyNZA=", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "3gXJgsci+rnDDEclA5+VnXT4yiMzK1c7fqq29IGcx/Q=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9", + "value": "10000000", + "value_extra_currencies": {}, + "fwd_fee": "293336", + "ihr_fee": "0", + "created_lt": "60616678000002", + "created_at": "1755574728", + "opcode": null, + "ihr_disabled": true, + "bounce": false, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "lqKW0iTyhcZ77pPDD4owkVfw2qNdxbh+QQt4YwoJz8c=", + "body": "te6cckEBAQEAAgAAAEysuc0=", + "decoded": null + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "Hx/POgDcZ6LSppA7AUJjgrwkL+zOC1npJYosGGv7E3A=", + "balance": "62683757202", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "VxpjBovf4t0PfEIL6bniRkxsaT20VeLT8sDiKWSV/NM=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "iqWR68WdLOvHaJp2am5NS/Y/KX4zgbedMf+udwiuD0o=", + "balance": "62671059584", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "gQR75EnYbzeN9tVya9dqCUWvKmRxegXlEUIScxLSUXE=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "emulated": false + } + ], + "address_book": { + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null + }, + "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9": { + "user_friendly": "UQDSkZZueXRl0lUk4hagLa8KrJzZbmtTE_RPZwTDSIw32WNH", + "domain": null + } + } +} \ No newline at end of file diff --git a/core/crates/gem_ton/testdata/transaction_swap_jetton_ton_success.json b/core/crates/gem_ton/testdata/transaction_swap_jetton_ton_success.json new file mode 100644 index 0000000000..90ef14e884 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_swap_jetton_ton_success.json @@ -0,0 +1,141 @@ +{ + "transactions": [ + { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "psAXHb1HyvR53f9LHmOzQWohJu3tDRWbxvZbHB1B+iY=", + "lt": "60708135000001", + "now": 1755805758, + "mc_block_seqno": 51145890, + "trace_id": "psAXHb1HyvR53f9LHmOzQWohJu3tDRWbxvZbHB1B+iY=", + "prev_trans_hash": "uT0EJD3KLXBG1bog4MCecdwBwEJKrADxHWugta/SBhI=", + "prev_trans_lt": "60707490000017", + "orig_status": "active", + "end_status": "active", + "total_fees": "3680407", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "415", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "1323200", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "1395600", + "total_action_fees": "465192", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "bniBZvs245Se2pVxtWF4kIi/roY3a+SbqgV//v7ZPE8=", + "tot_msg_size": { + "cells": "4", + "bits": "2894" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 56260071 + }, + "in_msg": { + "hash": "kuJu4xz3nLH8xyG77xr0zmwk6D23KAD+kvjPC6Irgy8=", + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": null, + "created_at": null, + "opcode": "0x04a7745b", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "t471XA/CNb40mDrnZ4cocfaXUVx6nXWVeozl7pum8kA=", + "body": "te6cckECBQEAAaMAAZwEp3RbaeYJQaRYDsL4QNh8FsPlXbbMLxn92AxwJBc8JXb1mwcm3JkZLybp9eavGrwYoFdCsad2CIQsovvYOSoEKamjF2inepIAAACVAAMBAWhiABc0ggYv4CIj8wTSuTsWs+L2MqrfauXZxRph6x9gGTcAII8NGAAAAAAAAAAAAAAAAAABAgGuD4p+pQAAAAAAAAAAMPQkCAElwoI1yo0SXmdlkVE9Ugchsf6Z93IvTIdyPOfuDftzowAM6FKWpQGl51ZuTKImFkWYLixc2NCsRYy79zJEauQV88gcnDgBAwHhZmTeKoASRBgwNNn9WaI29x7EJxvjdzmQVt2kzDpev13ECWffZBAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz4AGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+eAAAAANFO93MAEAJVBG+zNaABnQpS1KA0vOrNyZREwsizBcWLmxoViLGXfuZIjVyCvngAAGUAPEkpqs9KnRsK1r0MWCnzUym3mGkZu5VxLHB97NrQGKfjRJmjR", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "L1emqd62PYEG7NcgpaVbYFTRMAE8t0/jxKoEP94rzVs=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00", + "value": "300000000", + "value_extra_currencies": {}, + "fwd_fee": "930408", + "ihr_fee": "0", + "created_lt": "60708135000002", + "created_at": "1755805758", + "opcode": "0x0f8a7ea5", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "nQHsYVoFgp/QNarkUJ4R+aYDWy50pHJuk4qrP4WWJzk=", + "body": "te6cckECAwEAARsAAa4Pin6lAAAAAAAAAAAw9CQIASXCgjXKjRJeZ2WRUT1SByGx/pn3ci9Mh3I85+4N+3OjAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzyBycOAEBAeFmZN4qgBJEGDA02f1Zojb3HsQnG+N3OZBW3aTMOl6/XcQJZ99kEADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPgAZ0KUtSgNLzqzcmURMLIswXFi5saFYixl37mSI1cgr54AAAAA0U73cwAIAlUEb7M1oAGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+eAAAZQA8SSmqz0qdGwrWvQxYKfNTKbeYaRm7lXEscH3s2tAYp+IjNMC0=", + "decoded": null + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "xOejlO1XCIyAZRCb8VYAFr3tHwFvocAx3J+k+9sXF6g=", + "balance": "61497510027", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "E9qo82aTUilOD0HudNJ8NvIAkr0xrk4RpaF0jghJ2aQ=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "KwgWpBt9ZFQxpvI1eVsOtHFRBpJt3To75gOrxXww5eY=", + "balance": null, + "extra_currencies": null, + "account_status": null, + "frozen_hash": null, + "data_hash": null, + "code_hash": null + }, + "emulated": false + } + ], + "address_book": { + "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00": { + "user_friendly": "EQAuaQQMX8BER-YJpXJ2LWfF7GVVvtXLs4o0w9Y-wDJuAMzF", + "domain": null + }, + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null + } + } + } \ No newline at end of file diff --git a/core/crates/gem_ton/testdata/transaction_swap_ton_jetton_success.json b/core/crates/gem_ton/testdata/transaction_swap_ton_jetton_success.json new file mode 100644 index 0000000000..67ad83c347 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_swap_ton_jetton_success.json @@ -0,0 +1,141 @@ +{ + "transactions": [ + { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "wsQ2mvEWkMbw3QnyeBl85O+uuUsDNfuWJnc2mBh8lPg=", + "lt": "60707490000001", + "now": 1755804132, + "mc_block_seqno": 51145246, + "trace_id": "wsQ2mvEWkMbw3QnyeBl85O+uuUsDNfuWJnc2mBh8lPg=", + "prev_trans_hash": "sleJVygz4j0glLXXP76X58R31goQly7Xxop4Q6EB+m8=", + "prev_trans_lt": "60707200000008", + "orig_status": "active", + "end_status": "active", + "total_fees": "3518047", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "187", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "1323200", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "1274000", + "total_action_fees": "424660", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "rqrEyXnOp1Cc0+yoqy5ZAgeCQDRbqglnPR7O6sqUmSw=", + "tot_msg_size": { + "cells": "4", + "bits": "2590" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 56259500 + }, + "in_msg": { + "hash": "9DO+B4tJ62SoAe2j7ek4N7pT67hiPKcYyMXGFZSTWmQ=", + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": null, + "created_at": null, + "opcode": "0x8de742b7", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "2YTW1qnm+LiqhgWvzxFuRUovFbeQHbsiC7ksynGGf90=", + "body": "te6cckECBQEAAX0AAZyN50K3fltWBhlAN2Kr417QbPb2IK3h5V8J7XaYZO0G+RbpyC1PQIATiSVfrwRCVJ04bzU+LjyrOtC2y6jXSKEDKamjF2indDkAAACUAAMBAWhiAEkQYMDTZ/VmiNvcexCcb43c5kFbdpMw6Xr9dxAln32QInCoHAAAAAAAAAAAAAAAAAABAgFkAfODXQAAAAAAAAAAQ7msoAgAZ0KUtSgNLzqzcmURMLIswXFi5saFYixl37mSI1cgr58DAeFmZN4qgBJFrE+vsdt6AcnEd7YYqU7dy8PoyudDXogvCv9HxH0j8ADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPgAZ0KUtSgNLzqzcmURMLIswXFi5saFYixl37mSI1cgr54AAAAA0VC4cwAQAkzMZ4fgAZ0KUtSgNLzqzcmURMLIswXFi5saFYixl37mSI1cgr54AABlADxJKarPSp0bCta9DFgp81Mpt5hpGbuVcSxwfeza0Bin4F3zQ6g==", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "rkMaVCxsx0CcZbezGCRNkeuXxf6LIWKgYxypJ2sk+8Q=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:9220C181A6CFEACD11B7B8F62138DF1BB9CC82B6ED2661D2F5FAEE204B3EFB20", + "value": "1310000000", + "value_extra_currencies": {}, + "fwd_fee": "849340", + "ihr_fee": "0", + "created_lt": "60707490000002", + "created_at": "1755804132", + "opcode": "0x01f3835d", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "4yfeanT1OFnAXzkN4B4y05YUJfvRFGeOspb61qtqkVA=", + "body": "te6cckEBAwEA9QABZAHzg10AAAAAAAAAAEO5rKAIAGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+fAQHhZmTeKoASRaxPr7HbegHJxHe2GKlO3cvD6MrnQ16ILwr/R8R9I/AAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz4AGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+eAAAAANFQuHMACAJMzGeH4AGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+eAAAZQA8SSmqz0qdGwrWvQxYKfNTKbeYaRm7lXEscH3s2tAYp+COxOFg=", + "decoded": null + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "WUwz1swIkvZ8V8N+vko8HMyTeOqcC0SewoJO1x1h79w=", + "balance": "62544697824", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "ShMEt80iy4lLPQskjN7Gry1uJBJIjJ5kV3ENwdpJHFA=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "AjDmdBL+DmFb8E81k6Li0pqqoEl0d2JQNbkZMUVsz4Q=", + "balance": null, + "extra_currencies": null, + "account_status": null, + "frozen_hash": null, + "data_hash": null, + "code_hash": null + }, + "emulated": false + } + ], + "address_book": { + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null + }, + "0:9220C181A6CFEACD11B7B8F62138DF1BB9CC82B6ED2661D2F5FAEE204B3EFB20": { + "user_friendly": "EQCSIMGBps_qzRG3uPYhON8bucyCtu0mYdL1-u4gSz77IBa3", + "domain": null + } + } + } \ No newline at end of file diff --git a/core/crates/gem_ton/testdata/transaction_transfer_jetton_error.json b/core/crates/gem_ton/testdata/transaction_transfer_jetton_error.json new file mode 100644 index 0000000000..3f5010e109 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_transfer_jetton_error.json @@ -0,0 +1,147 @@ +{ + "transactions": [ + { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "ZEC9rE/pUvEHGAJVzDn/6QdWevOOR4sA2dN4BaTA8hQ=", + "lt": "60619724000001", + "now": 1755582352, + "mc_block_seqno": 51058293, + "trace_id": "ZEC9rE/pUvEHGAJVzDn/6QdWevOOR4sA2dN4BaTA8hQ=", + "prev_trans_hash": "8N9gmoV/B1pjRlfcIlJR4VMje0sRxEVMbzNjLtJtS4w=", + "prev_trans_lt": "60619552000005", + "orig_status": "active", + "end_status": "active", + "total_fees": "2764375", + "total_fees_extra_currencies": { + + }, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "112", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "1323200", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "708800", + "total_action_fees": "236263", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "JIFEb99NuLI18m1DReNfG6lGdgOd8xd9NOJ4hwyKYtE=", + "tot_msg_size": { + "cells": "2", + "bits": "1377" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 56182081 + }, + "in_msg": { + "hash": "8ekz7tSWnfp8BDKTpk+6FEHBQTJLbHz0/8TPow1cxlc=", + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": null, + "created_at": null, + "opcode": "0x93be2305", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "NtgiImmEvl7OF7GurdmvqeAcKcHij02QdK0T5urc4hE=", + "body": "te6cckEBAwEA3gABnJO+IwWkXXk9k+D9x1BxfyAmqDSCb5vpn/0QRinF8EvOee/HWGsRiV23m7vDJxw2keqta2El038pam9uDkKo0QopqaMXaKQR5gAAAJEAAwEBaGIAWInUylqBJQs4z7SJyZR1usrLYcUS+sgUWKN/ZuGxDv8gX14QAAAAAAAAAAAAAAAAAAECAKgPin6lAAAAAAAAAAAwGGoIAaUjLNzy6MukqknELUBbXhVZObLc1qYn6J7OCYaRGG+zAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzwgIFAUB/", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "2lie5dgCkAdBOPjZUKAhol9mdBmnZ0L6NhrTozLH6k0=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE", + "value": "200000000", + "value_extra_currencies": { + + }, + "fwd_fee": "472537", + "ihr_fee": "0", + "created_lt": "60619724000002", + "created_at": "1755582352", + "opcode": "0x0f8a7ea5", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "PtNqRovz42DApzYB0dpQWFw2ujThRGgdOUrzEsMMgqY=", + "body": "te6cckEBAQEAVgAAqA+KfqUAAAAAAAAAADAYaggBpSMs3PLoy6SqScQtQFteFVk5stzWpifons4JhpEYb7MADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPCAsLvAYg=", + "decoded": null + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "syLFWYIHc4MXnxfQLtvxSOyejVuLUPOcLPt/GhdOFGo=", + "balance": "62563376153", + "extra_currencies": { + + }, + "account_status": "active", + "frozen_hash": null, + "data_hash": "YkZWC7xxWK2TDH++/8pcalDVtwRZ5+5PePOZ+3Vt2wg=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "O50S4NWwSlgQMUAKCxJdoRNw2VHXUnxb7i44Ipwxoi4=", + "balance": null, + "extra_currencies": null, + "account_status": null, + "frozen_hash": null, + "data_hash": null, + "code_hash": null + }, + "emulated": false + } + ], + "address_book": { + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null + }, + "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE": { + "user_friendly": "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", + "domain": "usdt-minter.ton" + } + } +} \ No newline at end of file diff --git a/core/crates/gem_ton/testdata/transaction_transfer_jetton_error_2.json b/core/crates/gem_ton/testdata/transaction_transfer_jetton_error_2.json new file mode 100644 index 0000000000..6769afa625 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_transfer_jetton_error_2.json @@ -0,0 +1,181 @@ +{ + "transactions": [ + { + "account": "0:B77DFEB2A1D8C25DC6F4FDB280454275F6A81DC3CD6884EF0E4FA64A768D1042", + "hash": "G7qKBuLi36euwhawVUoHy8Ph55c6Rc0PJmDZXXSaxfo=", + "lt": "62301815000001", + "now": 1759806930, + "mc_block_seqno": 52715877, + "trace_id": "G7qKBuLi36euwhawVUoHy8Ph55c6Rc0PJmDZXXSaxfo=", + "prev_trans_hash": "ZlJpFL+WcdSrmEBXx+UCH9rRw5Df92BupqajRn7EnMY=", + "prev_trans_lt": "62301619000005", + "orig_status": "active", + "end_status": "active", + "total_fees": "2760123", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "127", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "1323200", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "705600", + "total_action_fees": "235196", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "/VMTfEn4Hrg3GxaZBk/+w7sIDLrqzTDLQILeHTpbTGA=", + "tot_msg_size": { + "cells": "2", + "bits": "1369" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 57706813 + }, + "in_msg": { + "hash": "Bnb555oeVsUjlL50/8dcayJoqgvglDB+5lHCP/93WVI=", + "source": null, + "destination": "0:B77DFEB2A1D8C25DC6F4FDB280454275F6A81DC3CD6884EF0E4FA64A768D1042", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": null, + "created_at": null, + "opcode": "0x98ce9044", + "decoded_opcode": "", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "VzXEG2l9pn1UtECtzWva8TuURaqB9v7eNXC6n5znAnw=", + "body": "te6cckEBAwEA3QABnJjOkEQt6/dYVd8k1ELbBJRqGooOMNYyYfHoUW+TMwMv5LGK4HjkfVe0L9GFBDjOUZBfMtHfXmuUs4AfzarrHwYpqaMXaOSIIQAAABkAAwEBaGIAAL5wvkzF1hGhVokqpspmGqzZWrSsfCHez4CAkEOw0uagHJw4AAAAAAAAAAAAAAAAAAECAKYPin6lAAAAAAAAAAAicQgBCoRm/yknyh0WqH41uI3RZ1PzQ+U+6+k9XR0QBXDWY4UALd9/rKh2MJdxvT9soBFQnX2qB3DzWiE7w5Ppkp2jRBCCAvBaGXI=", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "wGRRVVVqVa/Gf050TaDR7wa2k5I3ZjYw7s7vcPpT5Fo=", + "source": "0:B77DFEB2A1D8C25DC6F4FDB280454275F6A81DC3CD6884EF0E4FA64A768D1042", + "destination": "0:017CE17C998BAC2342AD12554D94CC3559B2B56958F843BD9F0101208761A5CD", + "value": "60000000", + "value_extra_currencies": {}, + "fwd_fee": "470404", + "ihr_fee": "0", + "created_lt": "62301815000002", + "created_at": "1759806930", + "opcode": "0x0f8a7ea5", + "decoded_opcode": "jetton_transfer", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "zIG1gEahcLatdx8W6PwJUmH5DlkrxFUMxzENAUaYUc4=", + "body": "te6cckEBAQEAVQAApg+KfqUAAAAAAAAAACJxCAEKhGb/KSfKHRaofjW4jdFnU/ND5T7r6T1dHRAFcNZjhQAt33+sqHYwl3G9P2ygEVCdfaoHcPNaITvDk+mSnaNEEIICvAzokA==", + "decoded": { + "type": "jetton_transfer", + "data": { + "amount": { + "len": "2", + "type": "var_uint", + "value": "10000" + }, + "custom_payload": "nothing_from_maybe", + "destination": { + "internal_address": { + "address": "8542337F9493E50E8B543F1ADC46E8B3A9F9A1F29F75F49EAE8E8802B86B31C2", + "anycast": "nothing_from_maybe", + "type": "addr_std", + "workchain_id": "0" + } + }, + "forward_payload": { + "type": "first_from_either", + "value": { + "empty_cell": "" + } + }, + "forward_ton_amount": { + "len": "1", + "type": "var_uint", + "value": "1" + }, + "query_id": "0", + "response_destination": { + "internal_address": { + "address": "B77DFEB2A1D8C25DC6F4FDB280454275F6A81DC3CD6884EF0E4FA64A768D1042", + "anycast": "nothing_from_maybe", + "type": "addr_std", + "workchain_id": "0" + } + } + } + } + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "/xRb6twcxjV5vNuMOn38kzh3zAMYtzUWjcPG8JTHRus=", + "balance": "10445324983", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "o2xFY9J3bmytaO40QdL+fbhdi6f7L6A9QTAtTgTBaeU=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "abIBVl5aPwDFQiVh6YTFoFo5n/68M1YHDOfo2i8X/Mo=", + "balance": null, + "extra_currencies": null, + "account_status": null, + "frozen_hash": null, + "data_hash": null, + "code_hash": null + }, + "emulated": false + } + ], + "address_book": { + "0:017CE17C998BAC2342AD12554D94CC3559B2B56958F843BD9F0101208761A5CD": { + "user_friendly": "EQABfOF8mYusI0KtElVNlMw1WbK1aVj4Q72fAQEgh2GlzXrP", + "domain": null + }, + "0:B77DFEB2A1D8C25DC6F4FDB280454275F6A81DC3CD6884EF0E4FA64A768D1042": { + "user_friendly": "UQC3ff6yodjCXcb0_bKARUJ19qgdw81ohO8OT6ZKdo0QQn18", + "domain": null + } + } +} diff --git a/core/crates/gem_ton/testdata/transaction_transfer_jetton_success.json b/core/crates/gem_ton/testdata/transaction_transfer_jetton_success.json new file mode 100644 index 0000000000..15d8e12b36 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_transfer_jetton_success.json @@ -0,0 +1,141 @@ +{ + "transactions": [ + { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "X2rQTJQF38kXLWdQL42pP8NKrd2X1YDyp5h7Erq7sBA=", + "lt": "60620082000001", + "now": 1755583247, + "mc_block_seqno": 51058646, + "trace_id": "X2rQTJQF38kXLWdQL42pP8NKrd2X1YDyp5h7Erq7sBA=", + "prev_trans_hash": "C4NEczvK9TFCfOGp063xHAVv729TwFqBtBxbhA19IWA=", + "prev_trans_lt": "60619724000005", + "orig_status": "active", + "end_status": "active", + "total_fees": "2764492", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "229", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "1323200", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "708800", + "total_action_fees": "236263", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "aI1vanKHo6oHiEFsdlIZ0nHGvj8PW9pYNdff0he/GHk=", + "tot_msg_size": { + "cells": "2", + "bits": "1377" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 56182416 + }, + "in_msg": { + "hash": "Fy47zRP8xvCPF0/mMkCV3wsHxv2b/KsnTueeW6T+FFc=", + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": null, + "created_at": null, + "opcode": "0x5ee10484", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "WvKQjUL0POOeXMJJj+MrHchTWAQTVerhLRZq+VghWt0=", + "body": "te6cckEBAwEA3gABnF7hBIR84+NFigKH/aDdk/bnfbEaZ0fCucSFVuRnU4QIlbPBvCi+82rxwe0hx1oWmPc7J45UHcp2tljcwm/zMAMpqaMXaKQVZQAAAJIAAwEBaGIAFzSCBi/gIiPzBNK5Oxaz4vYyqt9q5dnFGmHrH2AZNwAgL68IAAAAAAAAAAAAAAAAAAECAKgPin6lAAAAAAAAAAAwGGoIAaUjLNzy6MukqknELUBbXhVZObLc1qYn6J7OCYaRGG+zAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzwgLyK9Yn", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "j5MLkApaOdSi3//3ZvHXlipJrfzwKemFzAI/lnJhY1U=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00", + "value": "100000000", + "value_extra_currencies": {}, + "fwd_fee": "472537", + "ihr_fee": "0", + "created_lt": "60620082000002", + "created_at": "1755583247", + "opcode": "0x0f8a7ea5", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "PtNqRovz42DApzYB0dpQWFw2ujThRGgdOUrzEsMMgqY=", + "body": "te6cckEBAQEAVgAAqA+KfqUAAAAAAAAAADAYaggBpSMs3PLoy6SqScQtQFteFVk5stzWpifons4JhpEYb7MADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPCAsLvAYg=", + "decoded": null + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "+vK7/vAXrh6hQArMVucP62ULdazbXBoHzeRx4VbISRw=", + "balance": "62558393641", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "Z1ZO9829FY3cZjChIGq1QQwViRbmnuO2ptaysbGHl/8=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "5rdz86Uxf/mxCu5+DeZsOEs/ojn8nLpSLzieNouexf4=", + "balance": null, + "extra_currencies": null, + "account_status": null, + "frozen_hash": null, + "data_hash": null, + "code_hash": null + }, + "emulated": false + } + ], + "address_book": { + "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00": { + "user_friendly": "EQAuaQQMX8BER-YJpXJ2LWfF7GVVvtXLs4o0w9Y-wDJuAMzF", + "domain": null + }, + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null + } + } + } \ No newline at end of file diff --git a/core/crates/gem_ton/testdata/transaction_transfer_jetton_success_2.json b/core/crates/gem_ton/testdata/transaction_transfer_jetton_success_2.json new file mode 100644 index 0000000000..484a650068 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_transfer_jetton_success_2.json @@ -0,0 +1,141 @@ +{ + "transactions": [ + { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "pI2WtPJ6516pwuNti1h+Hetg0NZ8C/kBOboRkayUKL8=", + "lt": "60707200000001", + "now": 1755803400, + "mc_block_seqno": 51144957, + "trace_id": "pI2WtPJ6516pwuNti1h+Hetg0NZ8C/kBOboRkayUKL8=", + "prev_trans_hash": "gy8S8xO1m+E4Z8/+P8Ue62pw1S25qC726mLt8OIVMlA=", + "prev_trans_lt": "60620082000008", + "orig_status": "active", + "end_status": "active", + "total_fees": "2820353", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "56090", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "1323200", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "708800", + "total_action_fees": "236263", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "LilR4OgkJtnDXHL/3R5GFzFcyD4xQnDx1i53OhM4pTk=", + "tot_msg_size": { + "cells": "2", + "bits": "1377" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 56259244 + }, + "in_msg": { + "hash": "h/4WaWEBVrtJHnCaO2Apzf8coMfNOKoBpcElL/Lf41c=", + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": null, + "created_at": null, + "opcode": "0x01e47fbf", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "YA4+xeYotHIpemOzXQWHDEOL4VfEOPVWQ+nXXnrNqZs=", + "body": "te6cckEBAwEA3gABnAHkf7/W9LFckqMmw64oTJfR4YLRJBctv90wK6kMEs1g7hEtdmuimlQ1JjGqiqi3RIgkQwFf/mOdJ3yc7LukCgspqaMXaKdxWwAAAJMAAwEBaGIAFzSCBi/gIiPzBNK5Oxaz4vYyqt9q5dnFGmHrH2AZNwAgL68IAAAAAAAAAAAAAAAAAAECAKgPin6lAAAAAAAAAAAw9CQIAaUjLNzy6MukqknELUBbXhVZObLc1qYn6J7OCYaRGG+zAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzwgJoI9+f", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "TNeR6LKlw7vJH2zkuMO8F8cmGWdTb8kyR6eilFOq600=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00", + "value": "100000000", + "value_extra_currencies": {}, + "fwd_fee": "472537", + "ihr_fee": "0", + "created_lt": "60707200000002", + "created_at": "1755803400", + "opcode": "0x0f8a7ea5", + "ihr_disabled": true, + "bounce": true, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "m5jU9cXREiO+f2CTbUFeAMOk7UwsYJHwiiFt18nn5Mg=", + "body": "te6cckEBAQEAVgAAqA+KfqUAAAAAAAAAADD0JAgBpSMs3PLoy6SqScQtQFteFVk5stzWpifons4JhpEYb7MADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPCApnj5Q8=", + "decoded": null + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "IAdCwlcdP+zvNJqvcWTwAgVdv5G9CJq/n3CV62EcwAM=", + "balance": "62551344875", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "9sXAoVw41trit15DVC9Gqz4oA2ykYy3KgEK1A5awk5Y=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "ic68yCfqRh09qyGPvQYA87cmSrUTH+2tDSDlgYQXwiM=", + "balance": null, + "extra_currencies": null, + "account_status": null, + "frozen_hash": null, + "data_hash": null, + "code_hash": null + }, + "emulated": false + } + ], + "address_book": { + "0:2E69040C5FC04447E609A572762D67C5EC6555BED5CBB38A34C3D63EC0326E00": { + "user_friendly": "EQAuaQQMX8BER-YJpXJ2LWfF7GVVvtXLs4o0w9Y-wDJuAMzF", + "domain": null + }, + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null + } + } +} \ No newline at end of file diff --git a/core/crates/gem_ton/testdata/transaction_transfer_state_success.json b/core/crates/gem_ton/testdata/transaction_transfer_state_success.json new file mode 100644 index 0000000000..bab7d6a807 --- /dev/null +++ b/core/crates/gem_ton/testdata/transaction_transfer_state_success.json @@ -0,0 +1,141 @@ +{ + "transactions": [ + { + "account": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "hash": "m6fTF6rtJAieXMOw30QyKUy4jtSF0gJ2jMJ2ZDKuw+A=", + "lt": "60614140000001", + "now": 1755568380, + "mc_block_seqno": 51052801, + "trace_id": "m6fTF6rtJAieXMOw30QyKUy4jtSF0gJ2jMJ2ZDKuw+A=", + "prev_trans_hash": "ADLkIRHBYCCWEIDPBtaapHCObbL2WJVFgsfC4ADli7M=", + "prev_trans_lt": "60614028000001", + "orig_status": "active", + "end_status": "active", + "total_fees": "2402737", + "total_fees_extra_currencies": {}, + "description": { + "type": "ord", + "aborted": false, + "destroyed": false, + "credit_first": true, + "storage_ph": { + "storage_fees_collected": "73", + "status_change": "unchanged" + }, + "compute_ph": { + "skipped": false, + "success": true, + "msg_state_used": false, + "account_activated": false, + "gas_fees": "1323200", + "gas_used": "3308", + "gas_limit": "0", + "gas_credit": "10000", + "mode": 0, + "exit_code": 0, + "vm_steps": 68, + "vm_init_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vm_final_state_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "action": { + "success": true, + "valid": true, + "no_funds": false, + "status_change": "unchanged", + "total_fwd_fees": "440000", + "total_action_fees": "146664", + "result_code": 0, + "tot_actions": 1, + "spec_actions": 0, + "skipped_actions": 0, + "msgs_created": 1, + "action_list_hash": "gTrt5e626mRDnGB29ENBAV8MfGwceY8EZXHvw0MWPro=", + "tot_msg_size": { + "cells": "2", + "bits": "697" + } + } + }, + "block_ref": { + "workchain": 0, + "shard": "8000000000000000", + "seqno": 56176878 + }, + "in_msg": { + "hash": "RkRjRZf80G1niyEQR35Y9ZC6rl8o0VzWjHbqe5aIWJc=", + "source": null, + "destination": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "value": null, + "value_extra_currencies": null, + "fwd_fee": null, + "ihr_fee": null, + "created_lt": null, + "created_at": null, + "opcode": "0x9a788acc", + "ihr_disabled": null, + "bounce": null, + "bounced": null, + "import_fee": "0", + "message_content": { + "hash": "DSWX3EXRy7KVsdfQ0VczWuBXEvogHA5Ck/LgYelpm4c=", + "body": "te6cckEBAwEAiQABnJp4isyCf3IM2yiguxTePBFtLCsSOMfVpSEppSnAlfZdewedWPi47putGeqvaL76kQiYfqwiORP55nyrp2Eb9g0pqaMXaKPbUQAAAI0AAwEBZkIAaUjLNzy6MukqknELUBbXhVZObLc1qYn6J7OCYaRGG+ycxLQAAAAAAAAAAAAAAAAAAQIAADUwtRU=", + "decoded": null + }, + "init_state": null + }, + "out_msgs": [ + { + "hash": "HmojsSy/4NZMTeAztEsnaFd7Eo0j7Fli1ieXCJzVuFw=", + "source": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF", + "destination": "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9", + "value": "10000000", + "value_extra_currencies": {}, + "fwd_fee": "293336", + "ihr_fee": "0", + "created_lt": "60614140000002", + "created_at": "1755568380", + "opcode": null, + "ihr_disabled": true, + "bounce": false, + "bounced": false, + "import_fee": null, + "message_content": { + "hash": "lqKW0iTyhcZ77pPDD4owkVfw2qNdxbh+QQt4YwoJz8c=", + "body": "te6cckEBAQEAAgAAAEysuc0=", + "decoded": null + }, + "init_state": null + } + ], + "account_state_before": { + "hash": "hLLsck/53B5XmmvJB2tWIrWrZuTI2mbCmJ+lDXuFbHg=", + "balance": "62696453275", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "dnGJhZFw5pkGfMu7OzPMfTjuotsi2DQEA6g5wxMoStQ=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "account_state_after": { + "hash": "Hx/POgDcZ6LSppA7AUJjgrwkL+zOC1npJYosGGv7E3A=", + "balance": "62683757202", + "extra_currencies": {}, + "account_status": "active", + "frozen_hash": null, + "data_hash": "VxpjBovf4t0PfEIL6bniRkxsaT20VeLT8sDiKWSV/NM=", + "code_hash": "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=" + }, + "emulated": false + } + ], + "address_book": { + "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF": { + "user_friendly": "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", + "domain": null + }, + "0:D291966E797465D25524E216A02DAF0AAC9CD96E6B5313F44F6704C3488C37D9": { + "user_friendly": "UQDSkZZueXRl0lUk4hagLa8KrJzZbmtTE_RPZwTDSIw32WNH", + "domain": null + } + } + } \ No newline at end of file diff --git a/core/crates/gem_ton/testdata/wc_sign_data_response.json b/core/crates/gem_ton/testdata/wc_sign_data_response.json new file mode 100644 index 0000000000..aec1b981df --- /dev/null +++ b/core/crates/gem_ton/testdata/wc_sign_data_response.json @@ -0,0 +1,10 @@ +{ + "signature": "c2lnbmF0dXJl", + "address": "0:58d5c54fbb8488af7eaad0cdc759ca8f6ff79fc9555106c1339b037ec0a40347", + "timestamp": 1234567890, + "domain": "example.com", + "payload": { + "type": "text", + "text": "Hello TON" + } +} \ No newline at end of file diff --git a/core/crates/gem_tron/Cargo.toml b/core/crates/gem_tron/Cargo.toml new file mode 100644 index 0000000000..ecf935e7b4 --- /dev/null +++ b/core/crates/gem_tron/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "gem_tron" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +bs58 = { workspace = true } +hex = { workspace = true } +primitives = { path = "../primitives" } +signer = { path = "../signer", optional = true } +gem_encoding = { path = "../gem_encoding", features = ["protobuf"], optional = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_serializers = { path = "../serde_serializers", optional = true } +strum = { workspace = true } + +# RPC specific dependencies (optional) +chrono = { workspace = true, features = ["serde"], optional = true } +num-bigint = { workspace = true, optional = true } +num-traits = { workspace = true, optional = true } +number_formatter = { path = "../number_formatter", optional = true } +gem_evm = { path = "../gem_evm", optional = true } +gem_client = { path = "../gem_client", optional = true } +chain_traits = { path = "../chain_traits", optional = true } +gem_hash = { path = "../gem_hash", optional = true } +async-trait = { workspace = true, optional = true } +futures = { workspace = true, optional = true } +alloy-primitives = { workspace = true, optional = true } +alloy-sol-types = { workspace = true, optional = true } + +[features] +default = ["rpc"] +signer = [ + "dep:gem_encoding", + "dep:gem_hash", + "dep:num-bigint", + "dep:num-traits", + "dep:serde_serializers", + "dep:signer", +] +rpc = [ + "dep:chrono", + "dep:num-bigint", + "dep:num-traits", + "dep:number_formatter", + "dep:gem_evm", + "dep:gem_client", + "dep:chain_traits", + "dep:async-trait", + "dep:futures", + "dep:alloy-primitives", + "dep:alloy-sol-types", +] +reqwest = ["gem_client/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "primitives/testkit", "settings/testkit"] + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } +reqwest = { workspace = true } +primitives = { path = "../primitives", features = ["testkit"] } +gem_client = { path = "../gem_client", features = ["reqwest"] } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_tron/src/address/mod.rs b/core/crates/gem_tron/src/address/mod.rs new file mode 100644 index 0000000000..3351e12905 --- /dev/null +++ b/core/crates/gem_tron/src/address/mod.rs @@ -0,0 +1,185 @@ +pub mod serializer; + +use std::fmt; + +#[cfg(feature = "signer")] +use gem_hash::keccak::keccak256; +#[cfg(feature = "signer")] +use primitives::SignerError; +use primitives::{Address as AddressTrait, AddressError, decode_hex}; +#[cfg(feature = "signer")] +use signer::secp256k1_uncompressed_public_key; + +const ADDRESS_PREFIX: u8 = 0x41; +const ADDRESS_LEN: usize = 20; +const PREFIXED_ADDRESS_LEN: usize = ADDRESS_LEN + 1; +const ABI_ADDRESS_PARAMETER_HEX_LEN: usize = 64; +#[cfg(feature = "signer")] +const SECP256K1_UNCOMPRESSED_PUBLIC_KEY_PREFIX: u8 = 0x04; +#[cfg(feature = "signer")] +const SECP256K1_UNCOMPRESSED_PUBLIC_KEY_LEN: usize = 65; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TronAddress([u8; PREFIXED_ADDRESS_LEN]); + +impl TronAddress { + pub fn from_hex(hex_value: &str) -> Option { + let bytes = decode_hex(hex_value).ok()?; + if bytes.len() != PREFIXED_ADDRESS_LEN || bytes.first() != Some(&ADDRESS_PREFIX) { + return None; + } + Some(Self(bytes.try_into().ok()?)) + } + + #[cfg(feature = "signer")] + pub(crate) fn from_hex_or_base58(value: &str) -> Option { + // WalletCore-compatible raw transaction parsing prefers base58 when both formats are technically valid. + Self::try_parse(value).or_else(|| Self::from_hex(value)) + } + + pub fn abi_address_parameter(&self) -> String { + format!("{:0>width$}", hex::encode(self.account_id()), width = ABI_ADDRESS_PARAMETER_HEX_LEN) + } + + pub fn parse(address: &str) -> Result { + Self::try_parse(address).ok_or_else(|| AddressError::new(format!("invalid Tron address: {address}"))) + } + + pub fn account_id(&self) -> &[u8] { + &self.0[1..] + } + + #[cfg(feature = "signer")] + pub(crate) fn from_private_key(private_key: &[u8]) -> Result { + let public_key = secp256k1_uncompressed_public_key(private_key)?; + if public_key.len() != SECP256K1_UNCOMPRESSED_PUBLIC_KEY_LEN || public_key.first() != Some(&SECP256K1_UNCOMPRESSED_PUBLIC_KEY_PREFIX) { + return SignerError::invalid_input_err("Invalid Secp256k1 public key"); + } + + let hash = keccak256(&public_key[1..]); + let account_id: [u8; ADDRESS_LEN] = hash[hash.len() - ADDRESS_LEN..] + .try_into() + .map_err(|_| SignerError::invalid_input("invalid Tron account id length"))?; + Ok(Self::from(account_id)) + } +} + +impl AddressTrait for TronAddress { + fn try_parse(address: &str) -> Option { + let decoded = bs58::decode(address).with_check(None).into_vec().ok()?; + let payload = match decoded.as_slice() { + [ADDRESS_PREFIX, payload @ ..] => payload, + // WalletCore accepts 20-byte base58check payloads and normalizes them with the Tron prefix. + payload => payload, + }; + + let account_id: [u8; ADDRESS_LEN] = payload.try_into().ok()?; + Some(Self::from(account_id)) + } + + fn as_bytes(&self) -> &[u8] { + &self.0 + } + + fn encode(&self) -> String { + bs58::encode(self.0).with_check().into_string() + } +} + +pub fn validate_address(address: &str) -> bool { + TronAddress::is_valid(address) +} + +impl From<[u8; ADDRESS_LEN]> for TronAddress { + fn from(address: [u8; ADDRESS_LEN]) -> Self { + let mut bytes = [0u8; PREFIXED_ADDRESS_LEN]; + bytes[0] = ADDRESS_PREFIX; + bytes[1..].copy_from_slice(&address); + Self(bytes) + } +} + +impl fmt::Display for TronAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_hex() { + assert_eq!( + TronAddress::from_hex("4159f3440fd40722f716144e4490a4de162d3b3fcb").unwrap().encode(), + "TJApZYJwPKuQR7tL6FmvD6jDjbYpHESZGH".to_string() + ); + assert_eq!( + TronAddress::from_hex("41357a7401a0f0c2d4a44a1881a0c622f15d986291").unwrap().encode(), + "TEqyWRKCzREYC2bK2fc3j7pp8XjAa6tJK1".to_string() + ); + assert_eq!( + TronAddress::from_hex("41357a7401a0f0c2d4a44a1881a0c622f15d986291").unwrap().to_string(), + "TEqyWRKCzREYC2bK2fc3j7pp8XjAa6tJK1" + ); + assert_eq!(TronAddress::from_hex("42357a7401a0f0c2d4a44a1881a0c622f15d986291"), None); + } + + #[test] + fn test_to_addr_from_base58() { + let expected: [u8; ADDRESS_LEN] = hex::decode("357a7401a0f0c2d4a44a1881a0c622f15d986291").unwrap().try_into().unwrap(); + assert_eq!(TronAddress::parse("TEqyWRKCzREYC2bK2fc3j7pp8XjAa6tJK1").unwrap().account_id(), expected); + assert_eq!(TronAddress::parse("invalid").unwrap_err().to_string(), "invalid Tron address: invalid"); + } + + #[test] + fn test_abi_address_parameter() { + assert_eq!( + TronAddress::parse("TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb").unwrap().abi_address_parameter(), + "0000000000000000000000002e1d447fa4169390cf5f5b3d12d380decfbfe20f" + ); + assert!(TronAddress::parse("invalid").is_err()); + } + + #[test] + fn test_try_parse_normalizes_prefixed_and_unprefixed_payloads() { + let prefixed = "TEqyWRKCzREYC2bK2fc3j7pp8XjAa6tJK1"; + let payload = hex::decode("357a7401a0f0c2d4a44a1881a0c622f15d986291").unwrap(); + let unprefixed = bs58::encode(&payload).with_check().into_string(); + let expected = hex::decode("41357a7401a0f0c2d4a44a1881a0c622f15d986291").unwrap(); + + assert!(validate_address(prefixed)); + assert!(validate_address(&unprefixed)); + assert_eq!(TronAddress::try_parse(prefixed).unwrap().as_bytes(), expected); + assert_eq!(TronAddress::try_parse(&unprefixed).unwrap().as_bytes(), expected); + } + + #[cfg(feature = "signer")] + #[test] + fn test_from_hex_or_base58() { + let expected = hex::decode("41357a7401a0f0c2d4a44a1881a0c622f15d986291").unwrap(); + + assert_eq!(TronAddress::from_hex_or_base58("TEqyWRKCzREYC2bK2fc3j7pp8XjAa6tJK1").unwrap().as_bytes(), expected); + assert_eq!(TronAddress::from_hex_or_base58("41357a7401a0f0c2d4a44a1881a0c622f15d986291").unwrap().as_bytes(), expected); + assert_eq!(TronAddress::from_hex_or_base58("invalid"), None); + } + + #[cfg(feature = "signer")] + #[test] + fn test_from_private_key() { + let private_key = hex::decode("2d8f68944bdbfbc0769542fba8fc2d2a3de67393334471624364c7006da2aa54").unwrap(); + assert_eq!(TronAddress::from_private_key(&private_key).unwrap().encode(), "TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC"); + assert!(TronAddress::from_private_key(&[0u8; 16]).is_err()); + } + + #[test] + fn test_try_parse_rejects_wrong_prefix() { + let mut decoded = hex::decode("41357a7401a0f0c2d4a44a1881a0c622f15d986291").unwrap(); + decoded[0] = 0x42; + let address = bs58::encode(decoded).with_check().into_string(); + + assert!(TronAddress::try_parse(&address).is_none()); + assert!(!validate_address(&address)); + } +} diff --git a/core/crates/gem_tron/src/address/serializer.rs b/core/crates/gem_tron/src/address/serializer.rs new file mode 100644 index 0000000000..52993cd754 --- /dev/null +++ b/core/crates/gem_tron/src/address/serializer.rs @@ -0,0 +1,34 @@ +use super::TronAddress; +use primitives::Address as _; +#[cfg(feature = "signer")] +use serde::Serializer; +use serde::{Deserialize, Deserializer}; + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.map(|addr| TronAddress::from_hex(&addr).map(|address| address.encode()).unwrap_or(addr))) +} + +#[cfg(feature = "signer")] +pub(crate) mod hex_or_base58 { + use super::*; + use serde::de::Error as _; + + pub(crate) fn serialize(address: &TronAddress, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&hex::encode(address.as_bytes())) + } + + pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + TronAddress::from_hex_or_base58(&value).ok_or_else(|| D::Error::custom("invalid Tron address")) + } +} diff --git a/core/crates/gem_tron/src/lib.rs b/core/crates/gem_tron/src/lib.rs new file mode 100644 index 0000000000..96dfcda0ed --- /dev/null +++ b/core/crates/gem_tron/src/lib.rs @@ -0,0 +1,13 @@ +pub mod address; +pub mod models; + +pub use address::validate_address; + +#[cfg(feature = "signer")] +pub mod signer; + +#[cfg(feature = "rpc")] +pub mod rpc; + +#[cfg(feature = "rpc")] +pub mod provider; diff --git a/core/crates/gem_tron/src/models/account.rs b/core/crates/gem_tron/src/models/account.rs new file mode 100644 index 0000000000..1bd076713b --- /dev/null +++ b/core/crates/gem_tron/src/models/account.rs @@ -0,0 +1,120 @@ +use primitives::Resource; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronAccountRequest { + pub address: String, + pub visible: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronAccount { + pub balance: Option, + pub address: Option, + pub owner_permission: Option, + pub active_permission: Option>, + pub votes: Option>, + #[serde(rename = "frozenV2")] + pub frozen_v2: Option>, + #[serde(rename = "unfrozenV2")] + pub unfrozen_v2: Option>, +} + +impl TronAccount { + pub fn is_staking(&self) -> bool { + self.frozen_v2.as_deref().is_some_and(|items| items.iter().any(|item| item.amount > 0)) + || self.unfrozen_v2.as_deref().is_some_and(|items| items.iter().any(|item| item.unfreeze_amount > 0)) + || self.votes.is_some() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronAccountPermission { + pub id: Option, + pub threshold: u64, + pub keys: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronAccountPermissionKey { + pub address: String, + pub weight: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronAccountOwnerPermission { + pub permission_name: String, + pub threshold: Option, + pub keys: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TronAccountUsage { + #[serde(default)] + pub free_net_used: u64, + #[serde(default)] + pub free_net_limit: u64, + #[serde(rename = "NetUsed", default)] + pub net_used: u64, + #[serde(rename = "NetLimit", default)] + pub net_limit: u64, + #[serde(rename = "EnergyUsed", default)] + pub energy_used: u64, + #[serde(rename = "EnergyLimit", default)] + pub energy_limit: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronEmptyAccount { + pub address: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronVote { + pub vote_address: String, + pub vote_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronFrozen { + #[serde(rename = "type")] + pub frozen_type: Option, + #[serde(default)] + pub amount: u64, +} + +impl TronFrozen { + pub(crate) fn resource(&self) -> Option { + match self.frozen_type.as_deref() { + None => Some(Resource::Bandwidth), + Some(resource) => resource.parse().ok(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronUnfrozen { + #[serde(default)] + pub unfreeze_amount: u64, + pub unfreeze_expire_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronReward { + pub reward: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WitnessesList { + pub witnesses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WitnessAccount { + pub address: String, + pub vote_count: Option, + pub url: String, + pub is_jobs: Option, +} diff --git a/core/crates/gem_tron/src/models/block.rs b/core/crates/gem_tron/src/models/block.rs new file mode 100644 index 0000000000..b7a427822b --- /dev/null +++ b/core/crates/gem_tron/src/models/block.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +type UInt64 = u64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronBlock { + #[serde(rename = "block_header")] + pub block_header: TronHeaderRawData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronHeaderRawData { + pub raw_data: TronHeader, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronHeader { + pub number: UInt64, + pub version: UInt64, + #[serde(rename = "txTrieRoot")] + pub tx_trie_root: String, + pub witness_address: String, + #[serde(rename = "parentHash")] + pub parent_hash: String, + pub timestamp: UInt64, +} diff --git a/core/crates/gem_tron/src/models/chain.rs b/core/crates/gem_tron/src/models/chain.rs new file mode 100644 index 0000000000..325369eae1 --- /dev/null +++ b/core/crates/gem_tron/src/models/chain.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +type Int64 = i64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronChainParameters { + #[serde(rename = "chainParameter")] + pub chain_parameter: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronChainParameter { + pub key: String, + pub value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TronChainParameterKey { + #[serde(rename = "getCreateNewAccountFeeInSystemContract")] + GetCreateNewAccountFeeInSystemContract, + #[serde(rename = "getCreateAccountFee")] + GetCreateAccountFee, + #[serde(rename = "getEnergyFee")] + GetEnergyFee, +} diff --git a/core/crates/gem_tron/src/models/contract.rs b/core/crates/gem_tron/src/models/contract.rs new file mode 100644 index 0000000000..5c67c9df67 --- /dev/null +++ b/core/crates/gem_tron/src/models/contract.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; +use std::error::Error; + +use crate::models::{TransactionData, TronContractType}; + +#[derive(Deserialize)] +struct TriggerSmartContractPayload { + address: Option, + transaction: TriggerSmartContractPayloadTransaction, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum TriggerSmartContractPayloadTransaction { + Direct { raw_data: TransactionData }, + Nested { transaction: TriggerSmartContractNestedTransaction }, +} + +#[derive(Deserialize)] +struct TriggerSmartContractNestedTransaction { + raw_data: TransactionData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronSmartContractCall { + pub contract_address: String, + pub function_selector: String, + pub parameter: Option, + pub fee_limit: Option, + pub call_value: Option, + pub owner_address: String, + pub visible: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronSmartContractResult { + pub result: TronSmartContractResultMessage, + pub constant_result: Vec, + pub energy_used: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronSmartContractResultMessage { + pub result: bool, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TriggerSmartContractData { + pub contract_address: String, + pub data: String, + pub owner_address: String, + pub fee_limit: Option, + pub call_value: Option, +} + +impl TriggerSmartContractData { + pub fn from_payload(data: Option<&[u8]>, sender_address: &str) -> Result, Box> { + let Some(data) = data else { + return Ok(None); + }; + let Ok(payload) = serde_json::from_slice::(data) else { + return Ok(None); + }; + let raw_data = match payload.transaction { + TriggerSmartContractPayloadTransaction::Direct { raw_data } => raw_data, + TriggerSmartContractPayloadTransaction::Nested { transaction } => transaction.raw_data, + }; + let fee_limit = raw_data.fee_limit; + let Some(contract) = raw_data.contract.into_iter().next() else { + return Ok(None); + }; + if contract.contract_type != Some(TronContractType::TriggerSmart) { + return Ok(None); + } + + let value = contract.parameter.value; + let Some(contract_address) = value.contract_address else { + return Err("Invalid Tron contract address".into()); + }; + let Some(data) = value.data else { + return Ok(None); + }; + let owner_address = payload + .address + .filter(|address| !address.is_empty()) + .or(value.owner_address) + .unwrap_or_else(|| sender_address.to_string()); + + Ok(Some(Self { + contract_address, + data, + owner_address, + fee_limit, + call_value: value.call_value, + })) + } +} diff --git a/core/crates/gem_tron/src/models/contract_type.rs b/core/crates/gem_tron/src/models/contract_type.rs new file mode 100644 index 0000000000..553be28b79 --- /dev/null +++ b/core/crates/gem_tron/src/models/contract_type.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use strum::{Display, EnumString}; + +#[cfg(feature = "signer")] +const TYPE_URL_PREFIX: &str = "type.googleapis.com/protocol."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)] +#[repr(u64)] +pub enum TronContractType { + #[strum(serialize = "TransferContract")] + Transfer = 1, + #[strum(serialize = "TransferAssetContract")] + TransferAsset = 2, + #[strum(serialize = "VoteWitnessContract")] + VoteWitness = 4, + #[strum(serialize = "WithdrawBalanceContract")] + WithdrawBalance = 13, + #[strum(serialize = "TriggerSmartContract")] + TriggerSmart = 31, + #[strum(serialize = "FreezeBalanceV2Contract")] + FreezeBalanceV2 = 54, + #[strum(serialize = "UnfreezeBalanceV2Contract")] + UnfreezeBalanceV2 = 55, + #[strum(serialize = "WithdrawExpireUnfreezeContract")] + WithdrawExpireUnfreeze = 56, + #[strum(serialize = "DelegateResourceContract")] + DelegateResource = 57, + #[strum(serialize = "UnDelegateResourceContract")] + UnDelegateResource = 58, +} + +impl TronContractType { + #[cfg(feature = "signer")] + pub(crate) fn id(self) -> u64 { + self as u64 + } + + #[cfg(feature = "signer")] + pub(crate) fn type_url(self) -> String { + format!("{TYPE_URL_PREFIX}{self}") + } +} + +impl Serialize for TronContractType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for TronContractType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(|_| D::Error::custom(format!("unsupported Tron contract type: {value}"))) + } +} diff --git a/core/crates/gem_tron/src/models/mod.rs b/core/crates/gem_tron/src/models/mod.rs new file mode 100644 index 0000000000..46d54b49a8 --- /dev/null +++ b/core/crates/gem_tron/src/models/mod.rs @@ -0,0 +1,238 @@ +use crate::address::serializer::deserialize as tron_address_deserialize; +use primitives::hex::decode_hex_utf8; +use serde::{Deserialize, Deserializer, Serialize}; +use std::{error::Error, fmt}; + +pub mod account; +pub mod block; +pub mod chain; +pub mod contract; +pub mod contract_type; +#[cfg(feature = "signer")] +pub(crate) mod signing; +pub mod transaction; + +pub use account::*; +pub use block::*; +pub use chain::*; +pub use contract::*; +pub use contract_type::*; +#[cfg(feature = "signer")] +pub(crate) use signing::*; +pub use transaction::*; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Block { + pub block_header: BlockHeader, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BlockTransactions { + pub block_header: BlockHeader, + #[serde(default)] + pub transactions: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BlockHeader { + pub raw_data: BlockHeaderData, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BlockHeaderData { + pub number: i64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Transaction { + #[serde(rename = "txID")] + pub transaction_id: String, + pub ret: Vec, + pub raw_data: TransactionData, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ContractRet { + #[serde(rename = "contractRet")] + pub contract_ret: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TransactionData { + pub contract: Vec, + pub fee_limit: Option, + pub data: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Contract { + #[serde(rename = "type")] + #[serde(default, deserialize_with = "deserialize_contract_type_optional", skip_serializing_if = "Option::is_none")] + pub contract_type: Option, + pub parameter: ContractParameter, +} + +fn deserialize_contract_type_optional<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.as_deref().and_then(|value| value.parse().ok())) +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ContractParameter { + pub type_url: String, + pub value: ContractParameterValue, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ContractParameterValue { + pub amount: Option, + #[serde(default, deserialize_with = "tron_address_deserialize")] + pub owner_address: Option, + #[serde(default, deserialize_with = "tron_address_deserialize")] + pub to_address: Option, + #[serde(default, deserialize_with = "tron_address_deserialize")] + pub contract_address: Option, + pub data: Option, + pub frozen_balance: Option, + pub unfreeze_balance: Option, + pub resource: Option, + pub votes: Option>, + pub support: Option, + pub call_value: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct VoteInfo { + pub vote_address: String, + pub vote_count: i64, +} + +pub type BlockTransactionsInfo = Vec; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TransactionReceiptData { + pub id: String, + pub fee: Option, + #[serde(rename = "blockNumber")] + pub block_number: i64, + #[serde(rename = "blockTimeStamp")] + pub block_time_stamp: i64, + pub receipt: TransactionReceipt, + pub log: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TransactionReceipt { + pub result: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TronLog { + pub topics: Option>, + pub data: Option, +} + +#[derive(Serialize, Debug)] +pub struct TriggerConstantContractRequest { + pub owner_address: String, + pub contract_address: String, + pub function_selector: String, + pub parameter: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub call_value: Option, + pub visible: bool, +} + +#[derive(Deserialize, Debug)] +pub struct TriggerConstantContractResponse { + #[serde(default)] + pub constant_result: Vec, + pub result: Option, + pub energy_used: u64, + #[serde(default)] + pub energy_penalty: Option, +} + +impl TriggerConstantContractResponse { + pub fn get_energy(&self) -> Result { + if let Some(error) = self.result.as_ref().and_then(|r| r.check_error()) { + return Err(error); + } + Ok(self.energy_used + self.energy_penalty.unwrap_or_default()) + } +} + +#[derive(Deserialize, Debug)] +pub struct TriggerContractResult { + pub result: Option, + pub code: Option, + pub message: Option, +} + +impl TriggerContractResult { + pub fn check_error(&self) -> Option { + if self.result.unwrap_or(false) { + return None; + } + + let message = self + .message + .as_deref() + .map(|message_hex| decode_hex_utf8(message_hex).unwrap_or_else(|| message_hex.to_string())); + + Some(TronRpcError { code: self.code.clone(), message }) + } +} + +#[derive(Debug, Clone)] +pub struct TronRpcError { + pub code: Option, + pub message: Option, +} + +impl fmt::Display for TronRpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Tron RPC Error {} {}", self.code.as_deref().unwrap_or(""), self.message.as_deref().unwrap_or("")) + } +} + +impl Error for TronRpcError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WitnessesList { + pub witnesses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WitnessAccount { + pub address: String, + pub vote_count: Option, + pub url: String, + pub is_jobs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChainParametersResponse { + pub chain_parameter: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainParameter { + pub key: String, + pub value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronTransactionBroadcast { + #[serde(rename = "txid")] + pub txid: Option, + pub code: Option, + pub message: Option, +} diff --git a/core/crates/gem_tron/src/models/signing/contract.rs b/core/crates/gem_tron/src/models/signing/contract.rs new file mode 100644 index 0000000000..550ae5f911 --- /dev/null +++ b/core/crates/gem_tron/src/models/signing/contract.rs @@ -0,0 +1,330 @@ +use primitives::{Resource, SignerError, TronVote}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_serializers::hex_bytes; + +use crate::{address::TronAddress, models::TronContractType}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "UPPERCASE")] +#[repr(u64)] +pub(crate) enum TronResource { + #[default] + Bandwidth = 0, + Energy = 1, +} + +impl From<&Resource> for TronResource { + fn from(resource: &Resource) -> Self { + match resource { + Resource::Bandwidth => Self::Bandwidth, + Resource::Energy => Self::Energy, + } + } +} + +impl From for u64 { + fn from(resource: TronResource) -> Self { + resource as u64 + } +} + +#[derive(Debug)] +pub(crate) struct TronContractVote { + pub(crate) address: TronAddress, + pub(crate) count: u64, +} + +#[derive(Debug)] +pub(crate) enum TronContract { + Transfer { + owner: TronAddress, + to: TronAddress, + amount: u64, + }, + TriggerSmart { + owner: TronAddress, + contract: TronAddress, + data: Vec, + call_value: Option, + call_token_value: Option, + token_id: Option, + }, + VoteWitness { + owner: TronAddress, + votes: Vec, + support: bool, + }, + FreezeBalanceV2 { + owner: TronAddress, + frozen_balance: u64, + resource: TronResource, + }, + UnfreezeBalanceV2 { + owner: TronAddress, + unfreeze_balance: u64, + resource: TronResource, + }, + WithdrawBalance { + owner: TronAddress, + }, + WithdrawExpireUnfreeze { + owner: TronAddress, + }, +} + +impl TronContract { + pub(crate) fn vote_witness(owner: TronAddress, votes: &[TronVote]) -> Result { + Ok(Self::VoteWitness { + owner, + votes: votes.iter().map(TronContractVote::try_from).collect::, _>>()?, + support: true, + }) + } + + pub(crate) fn kind(&self) -> TronContractType { + match self { + Self::Transfer { .. } => TronContractType::Transfer, + Self::TriggerSmart { .. } => TronContractType::TriggerSmart, + Self::VoteWitness { .. } => TronContractType::VoteWitness, + Self::FreezeBalanceV2 { .. } => TronContractType::FreezeBalanceV2, + Self::UnfreezeBalanceV2 { .. } => TronContractType::UnfreezeBalanceV2, + Self::WithdrawBalance { .. } => TronContractType::WithdrawBalance, + Self::WithdrawExpireUnfreeze { .. } => TronContractType::WithdrawExpireUnfreeze, + } + } + + pub(crate) fn json(&self) -> TronContractJson { + let contract_type = self.kind(); + TronContractJson { + parameter: TronContractParameterJson { + type_url: contract_type.type_url(), + value: self.value_json(), + }, + contract_type, + } + } + + fn value_json(&self) -> TronContractValueJson { + match self { + Self::Transfer { owner, to, amount } => TronContractValueJson::Transfer(TransferContractValue { + amount: *amount, + owner_address: *owner, + to_address: *to, + }), + Self::TriggerSmart { + owner, + contract, + data, + call_value, + call_token_value, + token_id, + } => TronContractValueJson::TriggerSmart(TriggerSmartContractValue { + contract_address: *contract, + data: data.clone(), + owner_address: *owner, + call_value: call_value.filter(|value| *value > 0), + call_token_value: call_token_value.filter(|value| *value > 0), + token_id: token_id.filter(|value| *value > 0), + }), + Self::VoteWitness { owner, votes, support } => TronContractValueJson::VoteWitness(VoteWitnessContractValue { + owner_address: *owner, + support: *support, + votes: votes.iter().map(VoteValue::from).collect(), + }), + Self::FreezeBalanceV2 { owner, frozen_balance, resource } => TronContractValueJson::FreezeBalanceV2(FreezeBalanceV2ContractValue { + frozen_balance: *frozen_balance, + owner_address: *owner, + resource: *resource, + }), + Self::UnfreezeBalanceV2 { + owner, + unfreeze_balance, + resource, + } => TronContractValueJson::UnfreezeBalanceV2(UnfreezeBalanceV2ContractValue { + owner_address: *owner, + resource: *resource, + unfreeze_balance: *unfreeze_balance, + }), + Self::WithdrawBalance { owner } | Self::WithdrawExpireUnfreeze { owner } => TronContractValueJson::Owner(OwnerContractValue { owner_address: *owner }), + } + } + + pub(crate) fn from_json_value(contract_type: TronContractType, value: Value) -> Result { + match contract_type { + TronContractType::Transfer => { + let value: TransferContractValue = serde_json::from_value(value)?; + Ok(Self::Transfer { + owner: value.owner_address, + to: value.to_address, + amount: value.amount, + }) + } + TronContractType::TriggerSmart => { + let value: TriggerSmartContractValue = serde_json::from_value(value)?; + Ok(Self::TriggerSmart { + owner: value.owner_address, + contract: value.contract_address, + data: value.data, + call_value: value.call_value.filter(|value| *value > 0), + call_token_value: value.call_token_value.filter(|value| *value > 0), + token_id: value.token_id.filter(|value| *value > 0), + }) + } + TronContractType::VoteWitness => { + let value: VoteWitnessContractValue = serde_json::from_value(value)?; + Ok(Self::VoteWitness { + owner: value.owner_address, + votes: value.votes.into_iter().map(TronContractVote::from).collect(), + support: value.support, + }) + } + TronContractType::FreezeBalanceV2 => { + let value: FreezeBalanceV2ContractValue = serde_json::from_value(value)?; + Ok(Self::FreezeBalanceV2 { + owner: value.owner_address, + frozen_balance: value.frozen_balance, + resource: value.resource, + }) + } + TronContractType::UnfreezeBalanceV2 => { + let value: UnfreezeBalanceV2ContractValue = serde_json::from_value(value)?; + Ok(Self::UnfreezeBalanceV2 { + owner: value.owner_address, + unfreeze_balance: value.unfreeze_balance, + resource: value.resource, + }) + } + TronContractType::WithdrawBalance => { + let value: OwnerContractValue = serde_json::from_value(value)?; + Ok(Self::WithdrawBalance { owner: value.owner_address }) + } + TronContractType::WithdrawExpireUnfreeze => { + let value: OwnerContractValue = serde_json::from_value(value)?; + Ok(Self::WithdrawExpireUnfreeze { owner: value.owner_address }) + } + TronContractType::TransferAsset | TronContractType::DelegateResource | TronContractType::UnDelegateResource => { + Err(SignerError::invalid_input(format!("unsupported Tron contract type: {contract_type}"))) + } + } + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct TronContractJson { + parameter: TronContractParameterJson, + #[serde(rename = "type")] + contract_type: TronContractType, +} + +#[derive(Debug, Serialize)] +struct TronContractParameterJson { + type_url: String, + value: TronContractValueJson, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum TronContractValueJson { + Transfer(TransferContractValue), + TriggerSmart(TriggerSmartContractValue), + VoteWitness(VoteWitnessContractValue), + FreezeBalanceV2(FreezeBalanceV2ContractValue), + UnfreezeBalanceV2(UnfreezeBalanceV2ContractValue), + Owner(OwnerContractValue), +} + +#[derive(Debug, Deserialize, Serialize)] +struct TransferContractValue { + amount: u64, + #[serde(with = "crate::address::serializer::hex_or_base58")] + owner_address: TronAddress, + #[serde(with = "crate::address::serializer::hex_or_base58")] + to_address: TronAddress, +} + +#[derive(Debug, Deserialize, Serialize)] +struct TriggerSmartContractValue { + #[serde(with = "crate::address::serializer::hex_or_base58")] + contract_address: TronAddress, + #[serde(default, with = "hex_bytes")] + data: Vec, + #[serde(with = "crate::address::serializer::hex_or_base58")] + owner_address: TronAddress, + #[serde(skip_serializing_if = "Option::is_none")] + call_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + call_token_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + token_id: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct VoteWitnessContractValue { + #[serde(with = "crate::address::serializer::hex_or_base58")] + owner_address: TronAddress, + #[serde(default)] + support: bool, + #[serde(default)] + votes: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct VoteValue { + #[serde(with = "crate::address::serializer::hex_or_base58")] + vote_address: TronAddress, + vote_count: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +struct FreezeBalanceV2ContractValue { + frozen_balance: u64, + #[serde(with = "crate::address::serializer::hex_or_base58")] + owner_address: TronAddress, + #[serde(default)] + resource: TronResource, +} + +#[derive(Debug, Deserialize, Serialize)] +struct UnfreezeBalanceV2ContractValue { + #[serde(with = "crate::address::serializer::hex_or_base58")] + owner_address: TronAddress, + #[serde(default)] + resource: TronResource, + unfreeze_balance: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +struct OwnerContractValue { + #[serde(with = "crate::address::serializer::hex_or_base58")] + owner_address: TronAddress, +} + +impl From<&TronContractVote> for VoteValue { + fn from(vote: &TronContractVote) -> Self { + Self { + vote_address: vote.address, + vote_count: vote.count, + } + } +} + +impl From for TronContractVote { + fn from(vote: VoteValue) -> Self { + Self { + address: vote.vote_address, + count: vote.vote_count, + } + } +} + +impl TryFrom<&TronVote> for TronContractVote { + type Error = SignerError; + + fn try_from(vote: &TronVote) -> Result { + Ok(Self { + address: TronAddress::parse(&vote.validator)?, + count: vote.count, + }) + } +} diff --git a/core/crates/gem_tron/src/models/signing/mod.rs b/core/crates/gem_tron/src/models/signing/mod.rs new file mode 100644 index 0000000000..76ac521a9b --- /dev/null +++ b/core/crates/gem_tron/src/models/signing/mod.rs @@ -0,0 +1,10 @@ +mod contract; +mod protobuf; +mod raw_data; +mod wallet_connect; + +use contract::TronContractJson; + +pub(crate) use contract::{TronContract, TronContractVote, TronResource}; +pub(crate) use raw_data::{RawDataJson, SignedTransactionJson, TronRawData}; +pub(crate) use wallet_connect::WalletConnectPayload; diff --git a/core/crates/gem_tron/src/models/signing/protobuf.rs b/core/crates/gem_tron/src/models/signing/protobuf.rs new file mode 100644 index 0000000000..4a7ed09b5e --- /dev/null +++ b/core/crates/gem_tron/src/models/signing/protobuf.rs @@ -0,0 +1,238 @@ +use gem_encoding::protobuf::{MessageEncode, proto_encode}; +use primitives::Address as _; + +use super::{TronContract, TronContractVote}; + +#[derive(Clone, Debug, Default)] +pub(crate) struct RawData { + pub(crate) ref_block_bytes: Option>, + pub(crate) ref_block_hash: Option>, + pub(crate) expiration: Option, + pub(crate) data: Option>, + pub(crate) contracts: Vec, + pub(crate) timestamp: Option, + pub(crate) fee_limit: Option, +} + +proto_encode!(RawData { + 1 => ref_block_bytes: optional_bytes, + 4 => ref_block_hash: optional_bytes, + 8 => expiration: optional_varint_u64, + 10 => data: optional_bytes, + 11 => contracts: repeated_message, + 14 => timestamp: optional_varint_u64, + 18 => fee_limit: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +pub(crate) struct BlockHeaderRaw { + pub(crate) timestamp: Option, + pub(crate) tx_trie_root: Option>, + pub(crate) parent_hash: Option>, + pub(crate) number: Option, + pub(crate) witness_address: Option>, + pub(crate) version: Option, +} + +proto_encode!(BlockHeaderRaw { + 1 => timestamp: optional_varint_u64, + 2 => tx_trie_root: optional_bytes, + 3 => parent_hash: optional_bytes, + 7 => number: optional_varint_u64, + 9 => witness_address: optional_bytes, + 10 => version: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +pub(crate) struct ContractEnvelope { + contract_type: Option, + parameter: Option, +} + +proto_encode!(ContractEnvelope { + 1 => contract_type: optional_varint_u64, + 2 => parameter: optional_message, +}); + +impl From<&TronContract> for ContractEnvelope { + fn from(contract: &TronContract) -> Self { + let contract_type = contract.kind(); + Self { + contract_type: Some(contract_type.id()), + parameter: Some(AnyParameter { + type_url: Some(contract_type.type_url()), + value: Some(contract_value(contract)), + }), + } + } +} + +#[derive(Clone, Debug, Default)] +struct AnyParameter { + type_url: Option, + value: Option>, +} + +proto_encode!(AnyParameter { + 1 => type_url: optional_string, + 2 => value: optional_bytes, +}); + +#[derive(Clone, Debug, Default)] +struct TransferContract { + owner_address: Option>, + to_address: Option>, + amount: Option, +} + +proto_encode!(TransferContract { + 1 => owner_address: optional_bytes, + 2 => to_address: optional_bytes, + 3 => amount: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +struct TriggerSmartContract { + owner_address: Option>, + contract_address: Option>, + call_value: Option, + data: Option>, + call_token_value: Option, + token_id: Option, +} + +proto_encode!(TriggerSmartContract { + 1 => owner_address: optional_bytes, + 2 => contract_address: optional_bytes, + 3 => call_value: optional_varint_u64, + 4 => data: optional_bytes, + 5 => call_token_value: optional_varint_u64, + 6 => token_id: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +struct VoteWitnessContract { + owner_address: Option>, + votes: Vec, + support: Option, +} + +proto_encode!(VoteWitnessContract { + 1 => owner_address: optional_bytes, + 2 => votes: repeated_message, + 3 => support: optional_bool, +}); + +#[derive(Clone, Debug, Default)] +struct Vote { + vote_address: Option>, + vote_count: Option, +} + +proto_encode!(Vote { + 1 => vote_address: optional_bytes, + 2 => vote_count: optional_varint_u64, +}); + +impl From<&TronContractVote> for Vote { + fn from(vote: &TronContractVote) -> Self { + Self { + vote_address: Some(vote.address.as_bytes().to_vec()), + vote_count: (vote.count > 0).then_some(vote.count), + } + } +} + +#[derive(Clone, Debug, Default)] +struct FreezeBalanceV2Contract { + owner_address: Option>, + frozen_balance: Option, + resource: Option, +} + +proto_encode!(FreezeBalanceV2Contract { + 1 => owner_address: optional_bytes, + 2 => frozen_balance: optional_varint_u64, + 3 => resource: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +struct UnfreezeBalanceV2Contract { + owner_address: Option>, + unfreeze_balance: Option, + resource: Option, +} + +proto_encode!(UnfreezeBalanceV2Contract { + 1 => owner_address: optional_bytes, + 2 => unfreeze_balance: optional_varint_u64, + 3 => resource: optional_varint_u64, +}); + +#[derive(Clone, Debug, Default)] +struct OwnerContract { + owner_address: Option>, +} + +proto_encode!(OwnerContract { + 1 => owner_address: optional_bytes, +}); + +fn contract_value(contract: &TronContract) -> Vec { + match contract { + TronContract::Transfer { owner, to, amount } => TransferContract { + owner_address: Some(owner.as_bytes().to_vec()), + to_address: Some(to.as_bytes().to_vec()), + amount: (*amount > 0).then_some(*amount), + } + .encode(), + TronContract::TriggerSmart { + owner, + contract, + data, + call_value, + call_token_value, + token_id, + } => TriggerSmartContract { + owner_address: Some(owner.as_bytes().to_vec()), + contract_address: Some(contract.as_bytes().to_vec()), + call_value: call_value.filter(|value| *value > 0), + data: Some(data.clone()), + call_token_value: call_token_value.filter(|value| *value > 0), + token_id: token_id.filter(|value| *value > 0), + } + .encode(), + TronContract::VoteWitness { owner, votes, support } => VoteWitnessContract { + owner_address: Some(owner.as_bytes().to_vec()), + votes: votes.iter().map(Vote::from).collect(), + support: support.then_some(true), + } + .encode(), + TronContract::FreezeBalanceV2 { owner, frozen_balance, resource } => FreezeBalanceV2Contract { + owner_address: Some(owner.as_bytes().to_vec()), + frozen_balance: (*frozen_balance > 0).then_some(*frozen_balance), + resource: { + let resource = u64::from(*resource); + (resource > 0).then_some(resource) + }, + } + .encode(), + TronContract::UnfreezeBalanceV2 { + owner, + unfreeze_balance, + resource, + } => UnfreezeBalanceV2Contract { + owner_address: Some(owner.as_bytes().to_vec()), + unfreeze_balance: (*unfreeze_balance > 0).then_some(*unfreeze_balance), + resource: { + let resource = u64::from(*resource); + (resource > 0).then_some(resource) + }, + } + .encode(), + TronContract::WithdrawBalance { owner } | TronContract::WithdrawExpireUnfreeze { owner } => OwnerContract { + owner_address: Some(owner.as_bytes().to_vec()), + } + .encode(), + } +} diff --git a/core/crates/gem_tron/src/models/signing/raw_data.rs b/core/crates/gem_tron/src/models/signing/raw_data.rs new file mode 100644 index 0000000000..df65d59b1a --- /dev/null +++ b/core/crates/gem_tron/src/models/signing/raw_data.rs @@ -0,0 +1,176 @@ +use gem_encoding::protobuf::MessageEncode; +use gem_hash::sha2::sha256; +use primitives::{Address as _, SignerError, SignerInput, TransactionLoadMetadata, hex::decode_hex_array}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_serializers::hex_bytes; + +use super::{TronContract, TronContractJson, protobuf}; +use crate::address::TronAddress; +use crate::models::TronContractType; + +const EXPIRATION_DURATION_MS: u64 = 10 * 60 * 60 * 1000; +const BLOCK_HASH_LEN: usize = 32; + +#[derive(Debug)] +pub(crate) struct TronRawData { + ref_block_bytes: Vec, + ref_block_hash: Vec, + expiration: u64, + data: Option>, + contract: TronContract, + timestamp: u64, + fee_limit: u64, +} + +impl TronRawData { + pub(crate) fn from_input(input: &SignerInput, contract: TronContract, fee_limit: u64) -> Result { + let TransactionLoadMetadata::Tron { + block_number, + block_version, + block_timestamp, + transaction_tree_root, + parent_hash, + witness_address, + .. + } = &input.metadata + else { + return SignerError::invalid_input_err("Missing tron metadata"); + }; + let transaction_tree_root = decode_hex_array::(transaction_tree_root)?; + let parent_hash = decode_hex_array::(parent_hash)?; + + let header = protobuf::BlockHeaderRaw { + timestamp: (*block_timestamp > 0).then_some(*block_timestamp), + tx_trie_root: Some(transaction_tree_root.to_vec()), + parent_hash: Some(parent_hash.to_vec()), + number: (*block_number > 0).then_some(*block_number), + witness_address: Some( + TronAddress::from_hex(witness_address) + .ok_or_else(|| SignerError::invalid_input("invalid Tron witness address"))? + .as_bytes() + .to_vec(), + ), + version: (*block_version > 0).then_some(*block_version), + }; + let block_hash = sha256(&header.encode()); + let block_number_bytes = block_number.to_be_bytes(); + + Ok(Self { + ref_block_bytes: block_number_bytes[6..8].to_vec(), + ref_block_hash: block_hash[8..16].to_vec(), + expiration: block_timestamp + .checked_add(EXPIRATION_DURATION_MS) + .ok_or_else(|| SignerError::invalid_input("Tron expiration overflow"))?, + data: input.get_memo().map(|memo| memo.as_bytes().to_vec()), + contract, + timestamp: *block_timestamp, + fee_limit, + }) + } + + pub(crate) fn encode(&self) -> Vec { + protobuf::RawData { + ref_block_bytes: Some(self.ref_block_bytes.clone()), + ref_block_hash: Some(self.ref_block_hash.clone()), + expiration: (self.expiration > 0).then_some(self.expiration), + data: self.data.clone(), + contracts: vec![protobuf::ContractEnvelope::from(&self.contract)], + timestamp: (self.timestamp > 0).then_some(self.timestamp), + fee_limit: (self.fee_limit > 0).then_some(self.fee_limit), + } + .encode() + } + + pub(crate) fn json(&self) -> TronRawDataJson { + TronRawDataJson { + contract: vec![self.contract.json()], + expiration: self.expiration, + fee_limit: (self.fee_limit > 0).then_some(self.fee_limit), + ref_block_bytes: hex::encode(&self.ref_block_bytes), + ref_block_hash: hex::encode(&self.ref_block_hash), + data: self.data.as_ref().map(hex::encode), + timestamp: self.timestamp, + } + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct TronRawDataJson { + contract: Vec, + expiration: u64, + #[serde(skip_serializing_if = "Option::is_none")] + fee_limit: Option, + ref_block_bytes: String, + ref_block_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + timestamp: u64, +} + +#[derive(Serialize)] +pub(crate) struct SignedTransactionJson { + raw_data: TronRawDataJson, + raw_data_hex: String, + signature: Vec, + #[serde(rename = "txID")] + transaction_id: String, +} + +impl SignedTransactionJson { + pub(crate) fn new(raw_data: TronRawDataJson, raw_data_bytes: &[u8], transaction_id: &[u8], signature: String) -> Self { + Self { + raw_data, + raw_data_hex: hex::encode(raw_data_bytes), + signature: vec![signature], + transaction_id: hex::encode(transaction_id), + } + } +} + +#[derive(Deserialize)] +pub(crate) struct RawDataJson { + contract: Vec, + expiration: u64, + #[serde(with = "hex_bytes")] + ref_block_bytes: Vec, + #[serde(with = "hex_bytes")] + ref_block_hash: Vec, + timestamp: u64, + fee_limit: Option, + #[serde(default, with = "hex_bytes::option")] + data: Option>, +} + +impl RawDataJson { + pub(crate) fn encode(self) -> Result, SignerError> { + let contracts = self + .contract + .into_iter() + .map(|contract| TronContract::from_json_value(contract.contract_type, contract.parameter.value).map(|contract| protobuf::ContractEnvelope::from(&contract))) + .collect::, SignerError>>()?; + + Ok(protobuf::RawData { + ref_block_bytes: Some(self.ref_block_bytes), + ref_block_hash: Some(self.ref_block_hash), + expiration: (self.expiration > 0).then_some(self.expiration), + data: self.data, + contracts, + timestamp: (self.timestamp > 0).then_some(self.timestamp), + fee_limit: self.fee_limit.filter(|value| *value > 0), + } + .encode()) + } +} + +#[derive(Deserialize)] +struct RawContractJson { + #[serde(rename = "type")] + contract_type: TronContractType, + parameter: RawParameterJson, +} + +#[derive(Deserialize)] +struct RawParameterJson { + value: Value, +} diff --git a/core/crates/gem_tron/src/models/signing/wallet_connect.rs b/core/crates/gem_tron/src/models/signing/wallet_connect.rs new file mode 100644 index 0000000000..1083c3f63b --- /dev/null +++ b/core/crates/gem_tron/src/models/signing/wallet_connect.rs @@ -0,0 +1,88 @@ +use gem_hash::sha2::sha256; +use primitives::{SignerError, SignerInput, TransferDataOutputType}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use serde_serializers::hex_bytes; + +use super::RawDataJson; + +pub(crate) struct WalletConnectPayload { + transaction: WalletConnectTransaction, + output_type: TransferDataOutputType, +} + +impl WalletConnectPayload { + pub(crate) fn parse(input: &SignerInput) -> Result { + let extra = input.get_data_extra().map_err(SignerError::invalid_input)?; + let data = extra.data.as_ref().ok_or_else(|| SignerError::invalid_input("Missing transaction data"))?; + let payload: WalletConnectRequest = serde_json::from_slice(data)?; + + Ok(Self { + transaction: payload.transaction, + output_type: extra.output_type.clone(), + }) + } + + pub(crate) fn transaction_hash(&self) -> Result<[u8; 32], SignerError> { + self.transaction.hash() + } + + pub(crate) fn into_output(self, transaction_hash: [u8; 32], signature_hex: String) -> Result { + match self.output_type { + TransferDataOutputType::Signature => Ok(signature_hex), + TransferDataOutputType::EncodedTransaction => self.transaction.into_signed_json(hex::encode(transaction_hash), signature_hex), + } + } +} + +#[derive(Deserialize)] +struct WalletConnectRequest { + transaction: WalletConnectTransaction, +} + +#[derive(Deserialize, Serialize)] +struct WalletConnectTransaction { + #[serde(skip_serializing_if = "Option::is_none")] + raw_data: Option, + #[serde(with = "hex_bytes")] + raw_data_hex: Vec, + #[serde(rename = "txID", skip_serializing_if = "Option::is_none")] + transaction_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option>, + // Preserve non-wire dApp fields like `visible`; only raw_data_hex is signed. + #[serde(flatten)] + extra: Map, +} + +impl WalletConnectTransaction { + fn hash(&self) -> Result<[u8; 32], SignerError> { + let raw_data = self.raw_data_hex.as_slice(); + let transaction_hash = sha256(raw_data); + let transaction_id = hex::encode(transaction_hash); + + match &self.transaction_id { + Some(provided_transaction_id) if !provided_transaction_id.eq_ignore_ascii_case(&transaction_id) => { + return SignerError::invalid_input_err("transaction ID does not match hash of raw_data_hex"); + } + None if self.raw_data.is_none() => SignerError::invalid_input_err("Missing raw_data or transaction ID"), + _ => Ok(()), + }?; + + if let Some(raw_data_json) = &self.raw_data { + // The transaction ID validates signed bytes and keeps rendered raw_data and raw_data_hex in sync. + let encoded = serde_json::from_value::(raw_data_json.clone())?.encode()?; + if encoded != raw_data { + return SignerError::invalid_input_err("raw_data does not match raw_data_hex"); + } + } + + Ok(transaction_hash) + } + + fn into_signed_json(mut self, transaction_id: String, signature_hex: String) -> Result { + self.signature = Some(vec![signature_hex]); + self.transaction_id = Some(transaction_id); + serde_json::to_string(&self).map_err(Into::into) + } +} diff --git a/core/crates/gem_tron/src/models/transaction.rs b/core/crates/gem_tron/src/models/transaction.rs new file mode 100644 index 0000000000..14633623bf --- /dev/null +++ b/core/crates/gem_tron/src/models/transaction.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronTransactionBroadcast { + pub result: bool, + pub txid: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronTransactionBroadcastError { + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronTransaction { + pub ret: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronTransactionContractRef { + #[serde(rename = "contractRet")] + pub contract_ret: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronTransactionReceipt { + #[serde(rename = "blockNumber")] + pub block_number: u64, + pub fee: Option, + pub result: Option, + pub receipt: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronReceipt { + pub result: Option, +} diff --git a/core/crates/gem_tron/src/provider/address.rs b/core/crates/gem_tron/src/provider/address.rs new file mode 100644 index 0000000000..831e8691a9 --- /dev/null +++ b/core/crates/gem_tron/src/provider/address.rs @@ -0,0 +1,66 @@ +use async_trait::async_trait; +use chain_traits::ChainAddressStatus; +use gem_client::Client; +use primitives::AddressStatus; +use std::error::Error; + +use crate::provider::address_mapper; +use crate::rpc::client::TronClient; + +#[async_trait] +impl ChainAddressStatus for TronClient { + async fn get_address_status(&self, address: String) -> Result, Box> { + let account = self.get_account(&address).await?; + Ok(address_mapper::map_address_status(&account)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, create_test_client}; + + #[tokio::test] + async fn test_get_address_status_regular() -> Result<(), Box> { + let client = create_test_client(); + + let status = client.get_address_status(TEST_ADDRESS.to_string()).await?; + + assert!(status.is_empty()); + + let status = client.get_address_status("TYeyZXywpA921LEtw2PF3obK4B8Jjgpp32".to_string()).await?; + + assert!(status.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_address_status_multi_signature() -> Result<(), Box> { + let client = create_test_client(); + + let status = client.get_address_status("TDTcR8wBLadFYRekvobSSswHaj351EDNRT".to_string()).await?; + + assert!( + status.contains(&AddressStatus::MultiSignature), + "Expected multi-signature status for known multi-sig wallet" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_get_address_status_multi_signature_owner() -> Result<(), Box> { + let client = create_test_client(); + + let status = client.get_address_status("THzbnFasHU6AsHfbKahznBNC3Ss591zwPS".to_string()).await?; + + assert!( + status.contains(&AddressStatus::MultiSignature), + "Expected multi-signature status for known multi-sig wallet" + ); + + Ok(()) + } +} diff --git a/core/crates/gem_tron/src/provider/address_mapper.rs b/core/crates/gem_tron/src/provider/address_mapper.rs new file mode 100644 index 0000000000..8f66ece9a7 --- /dev/null +++ b/core/crates/gem_tron/src/provider/address_mapper.rs @@ -0,0 +1,180 @@ +use primitives::AddressStatus; + +use crate::models::account::TronAccount; + +pub fn map_address_status(account: &TronAccount) -> Vec { + let address = account.address.as_deref().unwrap_or_default(); + + if let Some(owner_permission) = &account.owner_permission { + if owner_permission.permission_name != "owner" || owner_permission.threshold.unwrap_or(1) > 1 { + return vec![AddressStatus::MultiSignature]; + } + if let Some(keys) = &owner_permission.keys + && (keys.len() != 1 || keys.iter().any(|k| k.address != address)) + { + return vec![AddressStatus::MultiSignature]; + } + } + + if let Some(active_permissions) = &account.active_permission { + if active_permissions.len() > 1 || active_permissions.iter().any(|p| p.threshold > 1) { + return vec![AddressStatus::MultiSignature]; + } + for permission in active_permissions { + if let Some(keys) = &permission.keys + && (keys.len() != 1 || keys.iter().any(|k| k.address != address)) + { + return vec![AddressStatus::MultiSignature]; + } + } + } + + vec![] +} +#[cfg(test)] +mod tests { + use super::*; + use crate::models::account::{TronAccount, TronAccountOwnerPermission, TronAccountPermission, TronAccountPermissionKey}; + + const ADDRESS: &str = "TCXbgZUdJH14fH82rf36LCpFV53dyXLY3b"; + const OTHER_ADDRESS: &str = "TW6gATnfHd4S65BB4h5Y5Wae2k93rRduLz"; + + #[test] + fn test_regular_account() { + assert!(map_address_status(&TronAccount::mock(ADDRESS)).is_empty()); + } + + #[test] + fn test_null_active_permission() { + let mut account = TronAccount::mock(ADDRESS); + account.active_permission = None; + assert!(map_address_status(&account).is_empty()); + } + + #[test] + fn test_multiple_active_permissions() { + let mut account = TronAccount::mock(ADDRESS); + account.active_permission = Some(vec![ + TronAccountPermission { + id: None, + threshold: 1, + keys: None, + }, + TronAccountPermission { + id: None, + threshold: 1, + keys: None, + }, + ]); + assert_eq!(map_address_status(&account), vec![AddressStatus::MultiSignature]); + } + + #[test] + fn test_high_threshold() { + let mut account = TronAccount::mock(ADDRESS); + account.active_permission = Some(vec![TronAccountPermission { + id: None, + threshold: 2, + keys: None, + }]); + assert_eq!(map_address_status(&account), vec![AddressStatus::MultiSignature]); + } + + #[test] + fn test_non_owner_permission_name() { + let mut account = TronAccount::mock(ADDRESS); + account.owner_permission = Some(TronAccountOwnerPermission { + permission_name: "custom".to_string(), + threshold: Some(1), + keys: None, + }); + assert_eq!(map_address_status(&account), vec![AddressStatus::MultiSignature]); + } + + #[test] + fn test_owner_key_address_mismatch() { + let mut account = TronAccount::mock(ADDRESS); + account.owner_permission = Some(TronAccountOwnerPermission { + permission_name: "owner".to_string(), + threshold: Some(1), + keys: Some(vec![TronAccountPermissionKey { + address: OTHER_ADDRESS.to_string(), + weight: 1, + }]), + }); + assert_eq!(map_address_status(&account), vec![AddressStatus::MultiSignature]); + } + + #[test] + fn test_active_key_address_mismatch() { + let mut account = TronAccount::mock(ADDRESS); + account.active_permission = Some(vec![TronAccountPermission { + id: None, + threshold: 1, + keys: Some(vec![TronAccountPermissionKey { + address: OTHER_ADDRESS.to_string(), + weight: 1, + }]), + }]); + assert_eq!(map_address_status(&account), vec![AddressStatus::MultiSignature]); + } + + #[test] + fn test_multiple_keys() { + let mut account = TronAccount::mock(ADDRESS); + account.owner_permission = Some(TronAccountOwnerPermission { + permission_name: "owner".to_string(), + threshold: Some(1), + keys: Some(vec![ + TronAccountPermissionKey { + address: ADDRESS.to_string(), + weight: 1, + }, + TronAccountPermissionKey { + address: OTHER_ADDRESS.to_string(), + weight: 1, + }, + ]), + }); + assert_eq!(map_address_status(&account), vec![AddressStatus::MultiSignature]); + } + + #[test] + fn test_owner_high_threshold() { + let mut account = TronAccount::mock(ADDRESS); + account.owner_permission = Some(TronAccountOwnerPermission { + permission_name: "owner".to_string(), + threshold: Some(2), + keys: None, + }); + assert_eq!(map_address_status(&account), vec![AddressStatus::MultiSignature]); + } + + #[test] + fn test_standard_account_with_staking() { + let account = TronAccount { + balance: Some(5000007), + address: Some(ADDRESS.to_string()), + owner_permission: Some(TronAccountOwnerPermission { + permission_name: "owner".to_string(), + threshold: Some(1), + keys: Some(vec![TronAccountPermissionKey { + address: ADDRESS.to_string(), + weight: 1, + }]), + }), + active_permission: Some(vec![TronAccountPermission { + id: Some(2), + threshold: 1, + keys: Some(vec![TronAccountPermissionKey { + address: ADDRESS.to_string(), + weight: 1, + }]), + }]), + votes: Some(vec![]), + frozen_v2: Some(vec![]), + unfrozen_v2: None, + }; + assert!(map_address_status(&account).is_empty()); + } +} diff --git a/core/crates/gem_tron/src/provider/balances.rs b/core/crates/gem_tron/src/provider/balances.rs new file mode 100644 index 0000000000..b7d6b3e7e7 --- /dev/null +++ b/core/crates/gem_tron/src/provider/balances.rs @@ -0,0 +1,140 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use futures::future::join_all; +use std::error::Error; + +use gem_client::Client; +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Chain, asset_balance::BalanceMetadata}; + +use crate::{ + address::TronAddress, + provider::balances_mapper::{map_balance_staking, map_coin_balance, map_token_balance}, + rpc::{client::TronClient, trongrid::mapper::TronGridMapper}, +}; + +#[async_trait] +impl ChainBalances for TronClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let account = self.get_account(&address).await?; + map_coin_balance(&account) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let parameter = TronAddress::parse(&address)?.abi_address_parameter(); + + let futures: Vec<_> = token_ids + .into_iter() + .map(|token_id| { + let parameter = parameter.clone(); + async move { + let balance_hex = self.trigger_constant_contract(&token_id, "balanceOf(address)", ¶meter).await?; + let asset_id = AssetId::from(self.get_chain(), Some(token_id)); + map_token_balance(&balance_hex, asset_id) + } + }) + .collect(); + join_all(futures).await.into_iter().collect::, _>>() + } + + async fn get_balance_staking(&self, address: String) -> Result, Box> { + let account = self.get_account(&address).await?; + if let Some(address) = &account.address { + let (reward, usage) = futures::try_join!(self.get_reward(address), self.get_account_usage(address))?; + Ok(Some(map_balance_staking(&account, &reward, &usage)?)) + } else { + Ok(Some(AssetBalance::new_staking_with_metadata( + AssetId::from_chain(Chain::Tron), + BigUint::from(0u32), + BigUint::from(0u32), + BigUint::from(0u32), + BalanceMetadata::default(), + ))) + } + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + let account = self.trongrid_client.get_accounts_by_address(&address).await?; + Ok(account.data.into_iter().next().map(TronGridMapper::map_asset_balances).unwrap_or_default()) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_USDT_TOKEN_ID, create_test_client}; + use num_bigint::BigUint; + use primitives::Chain; + + #[tokio::test] + async fn test_tron_get_balance_coin() -> Result<(), Box> { + let client = create_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + + assert_eq!(balance.asset_id.chain, Chain::Tron); + assert_eq!(balance.asset_id.token_id, None); + assert!(balance.balance.available > BigUint::from(0u32)); + + Ok(()) + } + + #[tokio::test] + async fn test_get_balance_tokens() -> Result<(), Box> { + let client = create_test_client(); + let token_ids = vec![TEST_USDT_TOKEN_ID.to_string()]; + + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids.clone()).await?; + + assert_eq!(balances.len(), token_ids.len()); + for (i, balance) in balances.iter().enumerate() { + assert_eq!(balance.asset_id.chain, Chain::Tron); + assert_eq!(balance.asset_id.token_id, Some(token_ids[i].clone())); + assert!(balance.balance.available > BigUint::from(0u32)); + } + + assert!(balances.first().unwrap().balance.available > BigUint::from(0u32), "USDT balance should be greater than 0"); + + Ok(()) + } + + #[tokio::test] + async fn test_get_balance_staking() -> Result<(), Box> { + let client = create_test_client(); + let balance = client.get_balance_staking(TEST_ADDRESS.to_string()).await?; + + let balance = balance.ok_or("Staking balance not found")?; + + assert_eq!(balance.asset_id.chain, Chain::Tron); + assert_eq!(balance.asset_id.token_id, None); + assert!(balance.balance.staked > BigUint::from(0u32)); + + let metadata = balance.balance.metadata.as_ref().ok_or("Metadata not found")?; + + assert!(metadata.bandwidth_available > 0); + assert!(metadata.bandwidth_total >= 600); + + //assert!(metadata.energy_available); + //assert!(metadata.energy_total > 0); + + assert!(metadata.bandwidth_available <= metadata.bandwidth_total); + assert!(metadata.energy_available <= metadata.energy_total); + + Ok(()) + } + + #[tokio::test] + async fn test_tron_get_balance_assets() -> Result<(), Box> { + let client = create_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + assert!(!assets.is_empty(), "TRON test address should have TRC20 tokens"); + + for asset in &assets { + assert_eq!(asset.asset_id.chain, Chain::Tron); + assert!(asset.balance.available > BigUint::from(0u32)); + assert!(asset.asset_id.token_id.is_some()); + } + Ok(()) + } +} diff --git a/core/crates/gem_tron/src/provider/balances_mapper.rs b/core/crates/gem_tron/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..4934f3b117 --- /dev/null +++ b/core/crates/gem_tron/src/provider/balances_mapper.rs @@ -0,0 +1,442 @@ +use std::error::Error; + +use num_bigint::BigUint; +use primitives::{ + AssetBalance, AssetId, Chain, Resource, + asset_balance::{Balance, BalanceMetadata}, + decode_hex, +}; + +use crate::models::{TronAccount, TronAccountUsage, TronReward}; + +pub fn map_coin_balance(account: &TronAccount) -> Result> { + let available_balance = BigUint::from(account.balance.unwrap_or(0)); + Ok(AssetBalance::new(AssetId::from_chain(Chain::Tron), available_balance)) +} + +pub fn map_token_balance(balance_hex: &str, asset_id: AssetId) -> Result> { + let balance_bytes = decode_hex(balance_hex).map_err(|e| format!("Failed to parse hex balance: {e}"))?; + let balance = BigUint::from_bytes_be(&balance_bytes); + + Ok(AssetBalance::new(asset_id, balance)) +} + +pub fn map_metadata_from_usage(usage: &TronAccountUsage, votes: u32) -> BalanceMetadata { + let energy_total = usage.energy_limit; + let energy_available = energy_total.saturating_sub(usage.energy_used); + let bandwidth_total = usage.free_net_limit + usage.net_limit; + let bandwidth_available = usage.free_net_limit.saturating_sub(usage.free_net_used) + usage.net_limit.saturating_sub(usage.net_used); + + BalanceMetadata { + votes, + energy_available: energy_available as u32, + energy_total: energy_total as u32, + bandwidth_available: bandwidth_available as u32, + bandwidth_total: bandwidth_total as u32, + } +} + +pub fn map_staking_balance(account: &TronAccount, reward: &TronReward, usage: &TronAccountUsage) -> Result> { + let (bandwidth_frozen, energy_frozen) = account + .frozen_v2 + .as_deref() + .unwrap_or_default() + .iter() + .fold((0u64, 0u64), |(bandwidth, energy), frozen| match frozen.resource() { + Some(Resource::Bandwidth) => (bandwidth + frozen.amount, energy), + Some(Resource::Energy) => (bandwidth, energy + frozen.amount), + None => (bandwidth, energy), + }); + let votes: u64 = account.votes.as_ref().map_or(0, |votes| votes.iter().map(|vote| vote.vote_count).sum()); + let pending_amount: u64 = account + .unfrozen_v2 + .as_ref() + .map_or(0, |unfrozen_list| unfrozen_list.iter().map(|unfrozen| unfrozen.unfreeze_amount).sum()); + let metadata = map_metadata_from_usage(usage, votes as u32); + + Ok(AssetBalance::new_balance( + AssetId::from_chain(Chain::Tron), + new_stake_balance( + BigUint::from(bandwidth_frozen), + BigUint::from(energy_frozen), + BigUint::from(0u32), + BigUint::from(pending_amount), + BigUint::from(reward.reward), + metadata, + ), + )) +} + +pub fn map_balance_staking(account: &TronAccount, reward: &TronReward, usage: &TronAccountUsage) -> Result> { + if account.is_staking() { + map_staking_balance(account, reward, usage) + } else { + let metadata = map_metadata_from_usage(usage, 0); + Ok(AssetBalance::new_balance( + AssetId::from_chain(Chain::Tron), + new_stake_balance( + BigUint::from(0u32), + BigUint::from(0u32), + BigUint::from(0u32), + BigUint::from(0u32), + BigUint::from(0u32), + metadata, + ), + )) + } +} + +fn new_stake_balance( + frozen: BigUint, // bandwidth frozen + locked: BigUint, // energy frozen + staked: BigUint, // vote amount + pending: BigUint, // unfreezing amount + rewards: BigUint, // voting rewards + metadata: BalanceMetadata, +) -> Balance { + Balance { + available: BigUint::from(0u32), + frozen, + locked, + staked, + pending, + pending_unconfirmed: BigUint::from(0u32), + rewards, + reserved: BigUint::from(0u32), + earn: BigUint::from(0u32), + withdrawable: BigUint::from(0u32), + metadata: Some(metadata), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{TronAccount, TronFrozen, TronReward, TronSmartContractResult, TronUnfrozen, TronVote}; + use primitives::{AssetId, Chain, asset_constants::TRON_USDT_ASSET_ID}; + use serde_json; + + #[test] + fn test_map_coin_balance_with_real_payload() { + let account: TronAccount = serde_json::from_str(include_str!("../../testdata/balance_coin.json")).unwrap(); + let balance = map_coin_balance(&account).unwrap(); + + assert_eq!(balance.asset_id, AssetId::from_chain(Chain::Tron)); + assert_eq!(balance.balance.available, BigUint::from(2928601454_u64)); + } + + #[test] + fn test_map_token_balance_with_real_payload() { + let response: TronSmartContractResult = serde_json::from_str(include_str!("../../testdata/balance_token.json")).unwrap(); + let asset_id: AssetId = TRON_USDT_ASSET_ID.clone(); + let balance = map_token_balance(&response.constant_result[0], asset_id.clone()).unwrap(); + + assert_eq!(balance.asset_id, asset_id); + assert_eq!(balance.balance.available, BigUint::from(136389002_u64)); + } + + #[test] + fn test_map_token_balance_edge_cases() { + let asset_id: AssetId = TRON_USDT_ASSET_ID.clone(); + + let balance = map_token_balance("", asset_id.clone()).unwrap(); + assert_eq!(balance.balance.available, BigUint::from(0u32)); + + let balance = map_token_balance("0x", asset_id.clone()).unwrap(); + assert_eq!(balance.balance.available, BigUint::from(0u32)); + + let balance = map_token_balance("0x0", asset_id.clone()).unwrap(); + assert_eq!(balance.balance.available, BigUint::from(0u32)); + + let balance = map_token_balance("0x821218a", asset_id).unwrap(); + assert_eq!(balance.balance.available, BigUint::from(136389002_u64)); + } + + #[test] + fn test_map_coin_balance_zero_balance() { + let account = TronAccount { + balance: None, + address: Some("TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string()), + owner_permission: None, + active_permission: None, + votes: None, + frozen_v2: None, + unfrozen_v2: None, + }; + + let balance = map_coin_balance(&account).unwrap(); + assert_eq!(balance.balance.available, BigUint::from(0u32)); + } + + #[test] + fn test_map_staking_balance() { + let account = TronAccount { + balance: Some(1000), + address: Some("TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string()), + owner_permission: None, + active_permission: None, + votes: None, + frozen_v2: Some(vec![ + TronFrozen { + frozen_type: Some("BANDWIDTH".to_string()), + amount: 5000000, + }, + TronFrozen { + frozen_type: Some("ENERGY".to_string()), + amount: 3000000, + }, + TronFrozen { + frozen_type: Some("TRON_POWER".to_string()), + amount: 4000000, + }, + TronFrozen { + frozen_type: Some("UNKNOWN".to_string()), + amount: 6000000, + }, + ]), + unfrozen_v2: Some(vec![TronUnfrozen { + unfreeze_amount: 2000000, + unfreeze_expire_time: Some(1234567890), + }]), + }; + + let reward = TronReward { reward: 100000 }; + let usage = TronAccountUsage { + energy_limit: 1000000, + energy_used: 500000, + free_net_limit: 1000000, + free_net_used: 500000, + net_used: 200000, + net_limit: 1000000, + }; + + let balance = map_staking_balance(&account, &reward, &usage).unwrap(); + + assert_eq!(balance.asset_id, AssetId::from_chain(Chain::Tron)); + assert_eq!(balance.balance.frozen, BigUint::from(5000000_u64)); + assert_eq!(balance.balance.locked, BigUint::from(3000000_u64)); + assert_eq!(balance.balance.staked, BigUint::from(0_u64)); + assert_eq!(balance.balance.pending, BigUint::from(2000000_u64)); + assert_eq!(balance.balance.rewards, BigUint::from(100000_u64)); + } + + #[test] + fn test_map_staking_balance_empty_fields() { + let account = TronAccount { + balance: Some(1000), + address: Some("TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string()), + owner_permission: None, + active_permission: None, + votes: None, + frozen_v2: None, + unfrozen_v2: None, + }; + + let reward = TronReward { reward: 0 }; + let usage = TronAccountUsage { + energy_limit: 1000000, + energy_used: 500000, + free_net_limit: 1000000, + free_net_used: 500000, + net_used: 200000, + net_limit: 1000000, + }; + let balance = map_staking_balance(&account, &reward, &usage).unwrap(); + + assert_eq!(balance.asset_id, AssetId::from_chain(Chain::Tron)); + assert_eq!(balance.balance.frozen, BigUint::from(0_u64)); + assert_eq!(balance.balance.locked, BigUint::from(0_u64)); + assert_eq!(balance.balance.staked, BigUint::from(0_u64)); + assert_eq!(balance.balance.pending, BigUint::from(0_u64)); + assert_eq!(balance.balance.rewards, BigUint::from(0_u64)); + } + + #[test] + fn test_map_staking_balance_with_votes() { + let account = TronAccount { + balance: Some(1000), + address: Some("TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string()), + owner_permission: None, + active_permission: None, + votes: Some(vec![ + TronVote { + vote_address: "TJApZYJwPKuQR7tL6FmvD6jDjbYpHESZGH".to_string(), + vote_count: 3000000, + }, + TronVote { + vote_address: "TEqyWRKCzREYC2bK2fc3j7pp8XjAa6tJK1".to_string(), + vote_count: 2000000, + }, + ]), + frozen_v2: Some(vec![TronFrozen { + frozen_type: Some("BANDWIDTH".to_string()), + amount: 8000000, + }]), + unfrozen_v2: None, + }; + + let reward = TronReward { reward: 50000 }; + let usage = TronAccountUsage { + energy_limit: 1000000, + energy_used: 500000, + free_net_limit: 1000000, + free_net_used: 500000, + net_used: 200000, + net_limit: 1000000, + }; + + let balance = map_staking_balance(&account, &reward, &usage).unwrap(); + + assert_eq!(balance.asset_id, AssetId::from_chain(Chain::Tron)); + assert_eq!(balance.balance.metadata.unwrap().votes, 5000000); + assert_eq!(balance.balance.frozen, BigUint::from(8000000_u64)); + assert_eq!(balance.balance.locked, BigUint::from(0_u64)); + assert_eq!(balance.balance.staked, BigUint::from(0_u64)); + assert_eq!(balance.balance.pending, BigUint::from(0_u64)); + assert_eq!(balance.balance.rewards, BigUint::from(50000_u64)); + } + + #[test] + fn test_map_staking_balance_metadata() { + let account = TronAccount { + balance: Some(1000), + address: Some("TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string()), + owner_permission: None, + active_permission: None, + votes: None, + frozen_v2: Some(vec![TronFrozen { + frozen_type: Some("ENERGY".to_string()), + amount: 1000000, + }]), + unfrozen_v2: None, + }; + + let reward = TronReward { reward: 50000 }; + let usage = TronAccountUsage { + energy_limit: 2000000, + energy_used: 800000, + free_net_limit: 1500, + free_net_used: 500, + net_used: 0, + net_limit: 5000, + }; + + let balance = map_staking_balance(&account, &reward, &usage).unwrap(); + let metadata = balance.balance.metadata.as_ref().unwrap(); + + assert_eq!(metadata.energy_available, 1200000); + assert_eq!(metadata.energy_total, 2000000); + assert_eq!(metadata.bandwidth_available, 6000); + assert_eq!(metadata.bandwidth_total, 6500); + } + + #[test] + fn test_new_stake_balance() { + let metadata = BalanceMetadata { + votes: 0, + energy_available: 1000, + energy_total: 2000, + bandwidth_available: 500, + bandwidth_total: 1000, + }; + + let balance = new_stake_balance( + BigUint::from(100_u64), + BigUint::from(200_u64), + BigUint::from(300_u64), + BigUint::from(400_u64), + BigUint::from(500_u64), + metadata.clone(), + ); + + assert_eq!(balance.available, BigUint::from(0_u32)); + assert_eq!(balance.frozen, BigUint::from(100_u64)); + assert_eq!(balance.locked, BigUint::from(200_u64)); + assert_eq!(balance.staked, BigUint::from(300_u64)); + assert_eq!(balance.pending, BigUint::from(400_u64)); + assert_eq!(balance.rewards, BigUint::from(500_u64)); + assert_eq!(balance.reserved, BigUint::from(0_u32)); + assert_eq!(balance.withdrawable, BigUint::from(0_u32)); + assert_eq!(balance.metadata, Some(metadata)); + } + + #[test] + fn test_map_staking_balance_metadata_with_none_values() { + let account = TronAccount { + balance: None, + address: Some("TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string()), + owner_permission: None, + active_permission: None, + votes: None, + frozen_v2: None, + unfrozen_v2: None, + }; + + let reward = TronReward { reward: 0 }; + let usage = TronAccountUsage { + energy_limit: 0, + energy_used: 0, + free_net_limit: 0, + free_net_used: 0, + net_used: 0, + net_limit: 0, + }; + + let balance = map_staking_balance(&account, &reward, &usage).unwrap(); + let metadata = balance.balance.metadata.as_ref().unwrap(); + + assert_eq!(metadata.energy_available, 0); + assert_eq!(metadata.energy_total, 0); + assert_eq!(metadata.bandwidth_available, 0); + assert_eq!(metadata.bandwidth_total, 0); + } + + #[test] + fn test_map_balance_staking_non_staker() { + let account = TronAccount { + balance: Some(1000), + address: Some("TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string()), + owner_permission: None, + active_permission: None, + votes: None, + frozen_v2: None, + unfrozen_v2: None, + }; + let reward = TronReward { reward: 0 }; + let usage = TronAccountUsage { + energy_limit: 0, + energy_used: 0, + free_net_limit: 600, + free_net_used: 100, + net_used: 0, + net_limit: 0, + }; + + let balance = map_balance_staking(&account, &reward, &usage).unwrap(); + let metadata = balance.balance.metadata.unwrap(); + + assert_eq!(metadata.bandwidth_available, 500); + assert_eq!(metadata.bandwidth_total, 600); + assert_eq!(metadata.votes, 0); + } + + #[test] + fn test_map_metadata_from_usage() { + let usage = TronAccountUsage { + energy_limit: 1000000, + energy_used: 500000, + free_net_limit: 1500, + free_net_used: 500, + net_used: 200, + net_limit: 5000, + }; + + let metadata = map_metadata_from_usage(&usage, 100); + + assert_eq!(metadata.votes, 100); + assert_eq!(metadata.energy_available, 500000); + assert_eq!(metadata.energy_total, 1000000); + assert_eq!(metadata.bandwidth_available, 5800); + assert_eq!(metadata.bandwidth_total, 6500); + } +} diff --git a/core/crates/gem_tron/src/provider/mod.rs b/core/crates/gem_tron/src/provider/mod.rs new file mode 100644 index 0000000000..c79c95aa62 --- /dev/null +++ b/core/crates/gem_tron/src/provider/mod.rs @@ -0,0 +1,21 @@ +pub mod address; +pub mod address_mapper; +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod staking; +pub mod staking_mapper; +pub mod state; +pub mod state_mapper; +pub mod testkit; +pub mod token; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; diff --git a/core/crates/gem_tron/src/provider/preload.rs b/core/crates/gem_tron/src/provider/preload.rs new file mode 100644 index 0000000000..b9c1ba11f1 --- /dev/null +++ b/core/crates/gem_tron/src/provider/preload.rs @@ -0,0 +1,229 @@ +use std::collections::HashMap; +use std::error::Error; + +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use num_bigint::BigInt; + +use gem_client::Client; +use number_formatter::BigNumberFormatter; +use primitives::{ + AssetSubtype, FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, + TransactionPreloadInput, TransferDataOutputAction, TronStakeData, +}; + +use crate::{ + address::TronAddress, + models::{ChainParameter, TriggerSmartContractData, account::TronAccountUsage}, + provider::preload_mapper::{calculate_stake_fee_rate, calculate_transfer_fee_rate, calculate_transfer_token_fee_rate, map_stake_data}, + rpc::client::TronClient, +}; + +#[async_trait] +impl ChainTransactionLoad for TronClient { + async fn get_transaction_preload(&self, _input: TransactionPreloadInput) -> Result> { + Ok(TransactionLoadMetadata::None) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + let (block, chain_parameters, account_usage, is_new_account, stake_data) = futures::try_join!( + self.get_tron_block(), + self.get_chain_parameters(), + self.get_account_usage(&input.sender_address), + self.get_is_new_account_for_input_type(&input), + self.get_stake_data(&input) + )?; + + let block = block.block_header.raw_data; + let metadata = TransactionLoadMetadata::Tron { + block_number: block.number, + block_version: block.version, + block_timestamp: block.timestamp, + transaction_tree_root: block.tx_trie_root.clone(), + parent_hash: block.parent_hash.clone(), + witness_address: block.witness_address.clone(), + stake_data, + }; + + let has_memo = input.get_memo().is_some(); + let fee = match &input.input_type { + TransactionInputType::Transfer(asset) | TransactionInputType::TransferNft(asset, _) | TransactionInputType::Account(asset, _) => match &asset.id.token_id { + None => TransactionFee::new_from_fee(calculate_transfer_fee_rate(&chain_parameters, &account_usage, is_new_account, has_memo)?), + Some(token_id) => { + self.estimate_token_transfer_fee( + input.sender_address.clone(), + input.destination_address.clone(), + token_id.clone(), + input.value.clone(), + &chain_parameters, + &account_usage, + ) + .await? + } + }, + TransactionInputType::Generic(_, _, extra) => match extra.output_action { + TransferDataOutputAction::Send => match self + .estimate_fee_with_data(&input.sender_address, extra.data.as_deref(), &chain_parameters, &account_usage) + .await? + { + Some(fee) => fee, + None => TransactionFee::new_from_fee(calculate_transfer_fee_rate(&chain_parameters, &account_usage, is_new_account, has_memo)?), + }, + TransferDataOutputAction::Sign => TransactionFee::new_from_fee(calculate_transfer_fee_rate(&chain_parameters, &account_usage, is_new_account, false)?), + }, + TransactionInputType::Stake(_asset, stake_type) => TransactionFee::new_from_fee(calculate_stake_fee_rate(&chain_parameters, &account_usage, stake_type)?), + TransactionInputType::Swap(from_asset, _, swap_data) => match &from_asset.id.token_id { + None => TransactionFee::new_from_fee(calculate_transfer_fee_rate(&chain_parameters, &account_usage, is_new_account, has_memo)?), + Some(token_id) => { + self.estimate_token_transfer_fee( + input.sender_address.clone(), + swap_data.data.to.clone(), + token_id.clone(), + input.value.clone(), + &chain_parameters, + &account_usage, + ) + .await? + } + }, + _ => TransactionFee::new_from_fee(calculate_transfer_fee_rate(&chain_parameters, &account_usage, is_new_account, has_memo)?), + }; + + Ok(TransactionLoadData { fee, metadata }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1)))]) + } +} + +impl TronClient { + async fn estimate_token_transfer_fee( + &self, + sender_address: String, + destination_address: String, + token_id: String, + value: String, + chain_parameters: &[ChainParameter], + account_usage: &TronAccountUsage, + ) -> Result> { + let destination_parameter = TronAddress::parse(&destination_address)?.abi_address_parameter(); + let estimated_energy = self.estimate_trc20_transfer_gas(sender_address, token_id, destination_parameter, value).await?; + let token_fee = calculate_transfer_token_fee_rate(chain_parameters, account_usage, estimated_energy)?; + + Ok(TransactionFee::new_gas_price_type( + GasPriceType::regular(BigInt::from(token_fee.energy_price)), + BigInt::from(token_fee.fee), + BigInt::from(token_fee.fee_limit), + HashMap::new(), + )) + } + + async fn estimate_fee_with_data( + &self, + sender_address: &str, + data: Option<&[u8]>, + chain_parameters: &[ChainParameter], + account_usage: &TronAccountUsage, + ) -> Result, Box> { + let Some(parsed) = TriggerSmartContractData::from_payload(data, sender_address)? else { + return Ok(None); + }; + + let estimated_energy = self.estimate_energy_with_data(&parsed).await?; + let token_fee = calculate_transfer_token_fee_rate(chain_parameters, account_usage, estimated_energy)?; + + Ok(Some(TransactionFee::new_gas_price_type( + GasPriceType::regular(BigInt::from(token_fee.energy_price)), + BigInt::from(token_fee.fee), + BigInt::from(token_fee.fee_limit), + HashMap::new(), + ))) + } + + async fn get_is_new_account_for_input_type(&self, input: &TransactionLoadInput) -> Result> { + match &input.input_type { + TransactionInputType::Transfer(asset) + | TransactionInputType::TransferNft(asset, _) + | TransactionInputType::Account(asset, _) + | TransactionInputType::Swap(asset, _, _) => match asset.id.token_subtype() { + AssetSubtype::NATIVE => self.is_new_account(input.input_type.swap_to_address().unwrap_or(&input.destination_address)).await, + AssetSubtype::TOKEN => Ok(false), + }, + _ => Ok(false), + } + } + + async fn get_stake_data(&self, input: &TransactionLoadInput) -> Result> { + match &input.input_type { + TransactionInputType::Stake(asset, stake_type) => { + let account = self.get_account(&input.sender_address).await?; + let raw_amount = BigNumberFormatter::value_as_u64(&input.value, 0)?; + let vote_amount = BigNumberFormatter::value_as_u64(&input.value, asset.decimals as u32)?; + map_stake_data(&account, stake_type, raw_amount, vote_amount) + } + _ => Ok(TronStakeData::Votes(vec![])), + } + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_USDT_TOKEN_ID, create_test_client}; + use chain_traits::ChainTransactionLoad; + use num_bigint::BigInt; + use primitives::{Asset, AssetId, AssetType, Chain}; + + #[tokio::test] + async fn test_get_transaction_load_transfer() -> Result<(), Box> { + let client = create_test_client(); + let asset = Asset::from_chain(Chain::Tron); + + let input = TransactionLoadInput::mock_with_input_type(TransactionInputType::Transfer(asset)); + let input = TransactionLoadInput { + sender_address: TEST_ADDRESS.to_string(), + destination_address: "TGas3vJWx6R9wZEq66T3p7T5QAkXHRzh2q".to_string(), + ..input + }; + + let result = client.get_transaction_load(input).await?; + + assert!(result.fee.fee > BigInt::from(0), "Transfer fee should be calculated"); + + if let TransactionLoadMetadata::Tron { block_number, .. } = result.metadata { + assert!(block_number > 0, "Block number should be positive"); + } else { + panic!("Expected Tron metadata"); + } + + Ok(()) + } + + #[tokio::test] + async fn test_get_transaction_load_token_transfer() -> Result<(), Box> { + let client = create_test_client(); + let asset_id = AssetId::from(Chain::Tron, Some(TEST_USDT_TOKEN_ID.to_string())); + let asset = Asset::new(asset_id, "Tether USD".to_string(), "USDT".to_string(), 6, AssetType::TRC20); + + let input = TransactionLoadInput::mock_with_input_type(TransactionInputType::Transfer(asset)); + let input = TransactionLoadInput { + sender_address: TEST_ADDRESS.to_string(), + destination_address: "TGas3vJWx6R9wZEq66T3p7T5QAkXHRzh2q".to_string(), + ..input + }; + + let result = client.get_transaction_load(input).await?; + + assert!(result.fee.gas_limit > result.fee.fee, "Fee limit should be greater than estimated fee"); + assert!(result.fee.gas_limit > BigInt::from(0), "Gas limit should be greater than 0"); + + if let TransactionLoadMetadata::Tron { block_number, .. } = result.metadata { + assert!(block_number > 0, "Block number should be positive"); + } else { + panic!("Expected Tron metadata"); + } + + Ok(()) + } +} diff --git a/core/crates/gem_tron/src/provider/preload_mapper.rs b/core/crates/gem_tron/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..7002e516c4 --- /dev/null +++ b/core/crates/gem_tron/src/provider/preload_mapper.rs @@ -0,0 +1,566 @@ +use std::collections::HashMap; +use std::error::Error; + +use num_bigint::BigInt; + +use crate::models::ChainParameter; +use crate::models::TronAccountUsage; +use crate::models::account::{TronAccount, TronFrozen}; +use crate::rpc::constants::{DEFAULT_BANDWIDTH_BYTES, GET_CREATE_ACCOUNT_FEE, GET_CREATE_NEW_ACCOUNT_FEE_IN_SYSTEM_CONTRACT, GET_ENERGY_FEE, GET_MEMO_FEE, GET_TRANSACTION_FEE}; +use primitives::{StakeType, TronStakeData, TronUnfreeze, TronVote}; + +const FEE_LIMIT_BUFFER_PERCENT: u64 = 20; + +pub fn calculate_transfer_fee_rate( + chain_parameters: &[ChainParameter], + account_usage: &TronAccountUsage, + is_new_account: bool, + has_memo: bool, +) -> Result> { + let bandwidth_price = get_chain_parameter_value(chain_parameters, GET_TRANSACTION_FEE)?; + let memo_fee = if has_memo { get_chain_parameter_value(chain_parameters, GET_MEMO_FEE)? } else { 0 }; + + let base_fee: i64 = if is_new_account { + let activation_fee = get_chain_parameter_value(chain_parameters, GET_CREATE_NEW_ACCOUNT_FEE_IN_SYSTEM_CONTRACT)?; + let new_account_bandwidth_fee = get_chain_parameter_value(chain_parameters, GET_CREATE_ACCOUNT_FEE)?; + + // Account activation only waives this fixed bandwidth fee when the sender has staked/delegated bandwidth. + if account_usage.available_staked_bandwidth() >= DEFAULT_BANDWIDTH_BYTES { + activation_fee + } else { + activation_fee + new_account_bandwidth_fee + } + } else { + bandwidth_fee(account_usage, DEFAULT_BANDWIDTH_BYTES, bandwidth_price as u64) as i64 + }; + + Ok(BigInt::from(base_fee + memo_fee)) +} + +pub fn calculate_transfer_token_fee_rate( + chain_parameters: &[ChainParameter], + account_usage: &TronAccountUsage, + estimated_energy: u64, +) -> Result> { + let energy_price = get_chain_parameter_value(chain_parameters, GET_ENERGY_FEE)? as u64; + let bandwidth_price = get_chain_parameter_value(chain_parameters, GET_TRANSACTION_FEE)? as u64; + + let fee = calculate_token_transfer_fee(account_usage, estimated_energy, energy_price, bandwidth_price); + Ok(fee) +} + +pub fn calculate_stake_fee_rate(chain_parameters: &[ChainParameter], account_usage: &TronAccountUsage, _stake_type: &StakeType) -> Result> { + let bandwidth_price = get_chain_parameter_value(chain_parameters, GET_TRANSACTION_FEE)? as u64; + let fee = bandwidth_fee(account_usage, DEFAULT_BANDWIDTH_BYTES, bandwidth_price); + Ok(BigInt::from(fee)) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TokenTransferFee { + pub fee: u64, + pub fee_limit: u64, + pub energy_price: u64, +} + +pub fn calculate_token_transfer_fee(account_usage: &TronAccountUsage, estimated_energy: u64, energy_price: u64, bandwidth_price: u64) -> TokenTransferFee { + let energy_with_buffer = apply_buffer(estimated_energy, FEE_LIMIT_BUFFER_PERCENT); + let chargeable_energy = account_usage.missing_energy(energy_with_buffer); + + let energy_fee = chargeable_energy * energy_price; + let bandwidth_fee = bandwidth_fee(account_usage, DEFAULT_BANDWIDTH_BYTES, bandwidth_price); + + TokenTransferFee { + fee: energy_fee + bandwidth_fee, + fee_limit: energy_with_buffer * energy_price, + energy_price, + } +} + +fn bandwidth_fee(account_usage: &TronAccountUsage, required: u64, price: u64) -> u64 { + if account_usage.available_bandwidth() >= required { 0 } else { required * price } +} + +fn apply_buffer(value: u64, percent: u64) -> u64 { + value * (100 + percent) / 100 +} + +fn get_chain_parameter_value(parameters: &[ChainParameter], key: &str) -> Result> { + parameters + .iter() + .find(|param| param.key == key) + .and_then(|param| param.value) + .ok_or_else(|| format!("Missing chain parameter: {}", key).into()) +} + +fn calculate_unfreeze_amounts(frozen: Option<&[TronFrozen]>, total: u64) -> Vec { + frozen + .map(|frozen| { + frozen + .iter() + .filter_map(|frozen| frozen.resource().map(|resource| (resource, frozen.amount))) + .filter(|(_, amount)| *amount > 0) + .scan(total, |remaining, (resource, amount)| { + (*remaining > 0).then(|| { + let take = (*remaining).min(amount); + *remaining -= take; + TronUnfreeze { resource, amount: take } + }) + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn map_stake_data(account: &TronAccount, stake_type: &StakeType, raw_amount: u64, vote_amount: u64) -> Result> { + let mut votes: HashMap = account + .votes + .as_ref() + .map(|votes| votes.iter().map(|vote| (vote.vote_address.clone(), vote.vote_count)).collect()) + .unwrap_or_default(); + + match stake_type { + StakeType::Stake(validator) => *votes.entry(validator.id.clone()).or_default() += vote_amount, + StakeType::Unstake(delegation) => { + votes + .entry(delegation.base.validator_id.clone()) + .and_modify(|value| *value = value.saturating_sub(vote_amount)); + votes.retain(|_, value| *value > 0); + if votes.is_empty() { + return Ok(TronStakeData::Unfreeze(calculate_unfreeze_amounts(account.frozen_v2.as_deref(), raw_amount))); + } + } + StakeType::Redelegate(data) => { + votes + .entry(data.delegation.base.validator_id.clone()) + .and_modify(|value| *value = value.saturating_sub(vote_amount)); + *votes.entry(data.to_validator.id.clone()).or_default() += vote_amount; + } + StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) => {} + StakeType::Unfreeze(resource) => { + let available = account + .frozen_v2 + .as_deref() + .unwrap_or_default() + .iter() + .filter(|frozen| frozen.resource() == Some(*resource)) + .map(|frozen| frozen.amount) + .sum::(); + if raw_amount > available { + return Err(format!("Insufficient frozen {} balance: requested {}, available {}", resource.as_ref(), raw_amount, available).into()); + } + return Ok(TronStakeData::Unfreeze(vec![TronUnfreeze { + resource: *resource, + amount: raw_amount, + }])); + } + } + + Ok(TronStakeData::Votes( + votes + .into_iter() + .filter(|(_, count)| *count > 0) + .map(|(validator, count)| TronVote { validator, count }) + .collect(), + )) +} + +impl TronAccountUsage { + pub fn available_bandwidth(&self) -> u64 { + let free = self.free_net_limit.saturating_sub(self.free_net_used); + free.saturating_add(self.available_staked_bandwidth()) + } + + pub fn available_staked_bandwidth(&self) -> u64 { + self.net_limit.saturating_sub(self.net_used) + } + + pub fn missing_bandwidth(&self, required: u64) -> u64 { + required.saturating_sub(self.available_bandwidth()) + } + + pub fn available_energy(&self) -> u64 { + self.energy_limit.saturating_sub(self.energy_used) + } + + pub fn missing_energy(&self, required: u64) -> u64 { + required.saturating_sub(self.available_energy()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::account::{TronAccount, TronFrozen, TronVote as AccountVote}; + use primitives::{Chain, Delegation, DelegationValidator, Resource}; + + fn chain_parameter(key: &str, value: i64) -> ChainParameter { + ChainParameter { + key: key.to_string(), + value: Some(value), + } + } + + fn account_usage(free_bandwidth: u64, staked_bandwidth: u64, available_energy: u64) -> TronAccountUsage { + TronAccountUsage { + free_net_used: 0, + free_net_limit: free_bandwidth, + net_used: 0, + net_limit: staked_bandwidth, + energy_used: 0, + energy_limit: available_energy, + } + } + + #[test] + fn test_apply_buffer() { + assert_eq!(apply_buffer(100, 20), 120); + assert_eq!(apply_buffer(64285, 20), 77142); + assert_eq!(apply_buffer(1000, 0), 1000); + } + + #[test] + fn test_account_usage_bandwidth() { + let usage = TronAccountUsage { + free_net_used: 100, + free_net_limit: 1000, + net_used: 200, + net_limit: 500, + energy_used: 0, + energy_limit: 0, + }; + + assert_eq!(usage.available_bandwidth(), 1200); // (1000-100) + (500-200) + assert_eq!(usage.available_staked_bandwidth(), 300); + assert_eq!(usage.missing_bandwidth(1500), 300); + assert_eq!(usage.missing_bandwidth(1000), 0); + } + + #[test] + fn test_account_usage_energy() { + let usage = TronAccountUsage { + free_net_used: 0, + free_net_limit: 0, + net_used: 0, + net_limit: 0, + energy_used: 10000, + energy_limit: 60000, + }; + + assert_eq!(usage.available_energy(), 50000); + assert_eq!(usage.missing_energy(70000), 20000); + assert_eq!(usage.missing_energy(40000), 0); + } + + #[test] + fn test_calculate_token_transfer_fee_no_staked_resources() { + let usage = account_usage(0, 0, 0); + + let fee = calculate_token_transfer_fee(&usage, 64285, 420, 1000); + + // energy_with_buffer = 64285 * 1.2 = 77142 + // chargeable_energy = 77142 (no staked energy) + // energy_fee = 77142 * 420 = 32,399,640 + // bandwidth_fee = 345 * 1000 = 345,000 + assert_eq!(fee.fee, 77142 * 420 + DEFAULT_BANDWIDTH_BYTES * 1000); + assert_eq!(fee.fee_limit, 77142 * 420); + assert_eq!(fee.energy_price, 420); + } + + #[test] + fn test_calculate_token_transfer_fee_with_staked_energy() { + let usage = account_usage(DEFAULT_BANDWIDTH_BYTES, 0, 60000); + + let fee = calculate_token_transfer_fee(&usage, 64285, 420, 1000); + + // energy_with_buffer = 77142 + // chargeable_energy = 77142 - 60000 = 17142 + // energy_fee = 17142 * 420 = 7,199,640 + // bandwidth_fee = 0 (has enough) + assert_eq!(fee.fee, 17142 * 420); + assert_eq!(fee.fee_limit, 77142 * 420); + } + + #[test] + fn test_calculate_token_transfer_fee_with_full_coverage() { + let usage = account_usage(DEFAULT_BANDWIDTH_BYTES, 0, 100000); + + let fee = calculate_token_transfer_fee(&usage, 64285, 420, 1000); + + // User has more than enough staked energy + assert_eq!(fee.fee, 0); + assert_eq!(fee.fee_limit, 77142 * 420); + } + + #[test] + fn test_calculate_transfer_fee_rate_existing_account() { + let params = vec![chain_parameter(GET_TRANSACTION_FEE, 1000), chain_parameter(GET_MEMO_FEE, 1_000_000)]; + + let with_bandwidth = account_usage(DEFAULT_BANDWIDTH_BYTES, 0, 0); + assert_eq!(calculate_transfer_fee_rate(¶ms, &with_bandwidth, false, false).unwrap(), BigInt::from(0)); + assert_eq!( + calculate_transfer_fee_rate(¶ms, &with_bandwidth, false, true).unwrap(), + BigInt::from(1_000_000), // memo fee only + ); + + let without_bandwidth = account_usage(100, 0, 0); + let burn_bandwidth = DEFAULT_BANDWIDTH_BYTES * 1000; + assert_eq!( + calculate_transfer_fee_rate(¶ms, &without_bandwidth, false, false).unwrap(), + BigInt::from(burn_bandwidth), + ); + assert_eq!( + calculate_transfer_fee_rate(¶ms, &without_bandwidth, false, true).unwrap(), + BigInt::from(burn_bandwidth + 1_000_000), + ); + } + + #[test] + fn test_calculate_transfer_fee_rate_new_account() { + let params = vec![ + chain_parameter(GET_TRANSACTION_FEE, 1000), + chain_parameter(GET_CREATE_ACCOUNT_FEE, 100_000), + chain_parameter(GET_CREATE_NEW_ACCOUNT_FEE_IN_SYSTEM_CONTRACT, 1_000_000), + chain_parameter(GET_MEMO_FEE, 1_000_000), + ]; + + let without_bandwidth = account_usage(0, 0, 0); + assert_eq!( + calculate_transfer_fee_rate(¶ms, &without_bandwidth, true, false).unwrap(), + BigInt::from(1_100_000), // activation + bandwidth + ); + assert_eq!( + calculate_transfer_fee_rate(¶ms, &without_bandwidth, true, true).unwrap(), + BigInt::from(2_100_000), // activation + bandwidth + memo + ); + + let with_free_bandwidth = account_usage(DEFAULT_BANDWIDTH_BYTES, 0, 0); + assert_eq!( + calculate_transfer_fee_rate(¶ms, &with_free_bandwidth, true, false).unwrap(), + BigInt::from(1_100_000), // activation + fixed account creation bandwidth fee + ); + + let with_staked_bandwidth = account_usage(0, DEFAULT_BANDWIDTH_BYTES, 0); + assert_eq!( + calculate_transfer_fee_rate(¶ms, &with_staked_bandwidth, true, false).unwrap(), + BigInt::from(1_000_000), // only activation + ); + } + + #[test] + fn test_calculate_stake_fee_rate() { + let params = vec![chain_parameter(GET_TRANSACTION_FEE, 1000)]; + let stake_type = StakeType::Stake(DelegationValidator::stake(Chain::Tron, "validator".to_string(), "validator".to_string(), true, 0.0, 0.0)); + + let with_bandwidth = account_usage(DEFAULT_BANDWIDTH_BYTES, 0, 0); + assert_eq!(calculate_stake_fee_rate(¶ms, &with_bandwidth, &stake_type).unwrap(), BigInt::from(0)); + + let without_bandwidth = account_usage(100, 0, 0); + let expected = BigInt::from(DEFAULT_BANDWIDTH_BYTES * 1000); + assert_eq!(calculate_stake_fee_rate(¶ms, &without_bandwidth, &stake_type).unwrap(), expected); + } + + #[test] + fn test_get_chain_parameter_value() { + let params = vec![chain_parameter(GET_ENERGY_FEE, 420), chain_parameter(GET_TRANSACTION_FEE, 1000)]; + + assert_eq!(get_chain_parameter_value(¶ms, GET_ENERGY_FEE).unwrap(), 420); + assert_eq!(get_chain_parameter_value(¶ms, GET_TRANSACTION_FEE).unwrap(), 1000); + assert!(get_chain_parameter_value(¶ms, "missing").is_err()); + } + + #[test] + fn test_bandwidth_fee() { + assert_eq!(bandwidth_fee(&account_usage(DEFAULT_BANDWIDTH_BYTES, 0, 0), DEFAULT_BANDWIDTH_BYTES, 1000), 0); + assert_eq!(bandwidth_fee(&account_usage(100, 200, 0), DEFAULT_BANDWIDTH_BYTES, 1000), 0); + assert_eq!(bandwidth_fee(&account_usage(0, 0, 0), DEFAULT_BANDWIDTH_BYTES, 1000), DEFAULT_BANDWIDTH_BYTES * 1000); + assert_eq!(bandwidth_fee(&account_usage(76, 0, 0), DEFAULT_BANDWIDTH_BYTES, 1000), DEFAULT_BANDWIDTH_BYTES * 1000); + } + + #[test] + fn test_calculate_token_transfer_fee_partial_bandwidth() { + let fee = calculate_token_transfer_fee(&account_usage(76, 0, 0), 64285, 420, 1000); + + assert_eq!(fee.fee, 77142 * 420 + DEFAULT_BANDWIDTH_BYTES * 1000); + assert_eq!(fee.fee_limit, 77142 * 420); + } + + #[test] + fn test_calculate_unfreeze_amounts() { + let frozen = vec![ + TronFrozen { + frozen_type: Some("TRON_POWER".to_string()), + amount: 1_000, + }, + TronFrozen { + frozen_type: Some("UNKNOWN".to_string()), + amount: 1_000, + }, + TronFrozen { frozen_type: None, amount: 40 }, + TronFrozen { + frozen_type: Some("ENERGY".to_string()), + amount: 100, + }, + TronFrozen { + frozen_type: Some("BANDWIDTH".to_string()), + amount: 50, + }, + ]; + + assert_eq!( + calculate_unfreeze_amounts(Some(&frozen), 120), + vec![ + TronUnfreeze { + resource: Resource::Bandwidth, + amount: 40 + }, + TronUnfreeze { + resource: Resource::Energy, + amount: 80 + }, + ] + ); + assert_eq!( + calculate_unfreeze_amounts(Some(&frozen), 50), + vec![ + TronUnfreeze { + resource: Resource::Bandwidth, + amount: 40 + }, + TronUnfreeze { + resource: Resource::Energy, + amount: 10 + }, + ] + ); + assert!(calculate_unfreeze_amounts(None, 100).is_empty()); + assert!(calculate_unfreeze_amounts(Some(&frozen), 0).is_empty()); + } + + #[test] + fn test_map_stake_data_unfreeze_uses_raw_amount_when_available() { + let account = TronAccount::mock_with_staking( + None, + Some(vec![TronFrozen { + frozen_type: Some("BANDWIDTH".to_string()), + amount: 1_500_000, + }]), + ); + let result = map_stake_data(&account, &StakeType::Unfreeze(Resource::Bandwidth), 1_000_000, 1).unwrap(); + + assert_eq!( + result, + TronStakeData::Unfreeze(vec![TronUnfreeze { + resource: Resource::Bandwidth, + amount: 1_000_000, + }]) + ); + + let result = map_stake_data(&account, &StakeType::Unfreeze(Resource::Bandwidth), 1_500_000, 1).unwrap(); + + assert_eq!( + result, + TronStakeData::Unfreeze(vec![TronUnfreeze { + resource: Resource::Bandwidth, + amount: 1_500_000, + }]) + ); + } + + #[test] + fn test_map_stake_data_unfreeze_rejects_wrong_resource() { + let account = TronAccount::mock_with_staking( + None, + Some(vec![TronFrozen { + frozen_type: Some("ENERGY".to_string()), + amount: 2_000_000, + }]), + ); + + let result = map_stake_data(&account, &StakeType::Unfreeze(Resource::Bandwidth), 1_000_000, 1); + + assert_eq!(result.unwrap_err().to_string(), "Insufficient frozen bandwidth balance: requested 1000000, available 0"); + } + + #[test] + fn test_map_stake_data_unfreeze_rejects_excess_amount() { + let account = TronAccount::mock_with_staking( + None, + Some(vec![TronFrozen { + frozen_type: Some("BANDWIDTH".to_string()), + amount: 999_999, + }]), + ); + + let result = map_stake_data(&account, &StakeType::Unfreeze(Resource::Bandwidth), 1_000_000, 1); + + assert_eq!( + result.unwrap_err().to_string(), + "Insufficient frozen bandwidth balance: requested 1000000, available 999999" + ); + } + + #[test] + fn test_map_stake_data_unstake_keeps_vote_updates() { + let result = map_stake_data( + &TronAccount::mock_with_staking( + Some(vec![AccountVote { + vote_address: "validator".to_string(), + vote_count: 5, + }]), + None, + ), + &StakeType::Unstake(Delegation::mock_tron("validator")), + 2_000_000, + 2, + ) + .unwrap(); + + assert_eq!( + result, + TronStakeData::Votes(vec![TronVote { + validator: "validator".to_string(), + count: 3, + }]) + ); + } + + #[test] + fn test_map_stake_data_unstake_keeps_existing_unfreeze_split() { + let result = map_stake_data( + &TronAccount::mock_with_staking( + Some(vec![AccountVote { + vote_address: "validator".to_string(), + vote_count: 2, + }]), + Some(vec![ + TronFrozen { + frozen_type: Some("ENERGY".to_string()), + amount: 100, + }, + TronFrozen { + frozen_type: Some("BANDWIDTH".to_string()), + amount: 50, + }, + ]), + ), + &StakeType::Unstake(Delegation::mock_tron("validator")), + 120, + 2, + ) + .unwrap(); + + assert_eq!( + result, + TronStakeData::Unfreeze(vec![ + TronUnfreeze { + resource: Resource::Energy, + amount: 100, + }, + TronUnfreeze { + resource: Resource::Bandwidth, + amount: 20, + }, + ]) + ); + } +} diff --git a/core/crates/gem_tron/src/provider/request_classifier.rs b/core/crates/gem_tron/src/provider/request_classifier.rs new file mode 100644 index 0000000000..887beea7d7 --- /dev/null +++ b/core/crates/gem_tron/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_http_post_path("/wallet/broadcasttransaction") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_tron/src/provider/staking.rs b/core/crates/gem_tron/src/provider/staking.rs new file mode 100644 index 0000000000..e1b26ba881 --- /dev/null +++ b/core/crates/gem_tron/src/provider/staking.rs @@ -0,0 +1,148 @@ +use async_trait::async_trait; +use chain_traits::ChainStaking; +use chrono::{DateTime, Utc}; +use num_bigint::BigUint; +use std::error::Error; + +use gem_client::Client; +use primitives::{Asset, Chain, DelegationBase, DelegationState, DelegationValidator}; + +use super::staking_mapper::map_staking_validators; +use crate::rpc::client::TronClient; +use crate::rpc::constants::{GET_WITNESS_127_PAY_PER_BLOCK, GET_WITNESS_PAY_PER_BLOCK}; + +#[async_trait] +impl ChainStaking for TronClient { + async fn get_staking_apy(&self) -> Result, Box> { + let params = self.get_chain_parameters().await?; + let witnesses = self.get_witnesses_list().await?; + + let block_reward = params.iter().find(|p| p.key == GET_WITNESS_PAY_PER_BLOCK).and_then(|p| p.value).unwrap_or(16_000_000) as f64 / 1_000_000.0; + + let voting_reward = params.iter().find(|p| p.key == GET_WITNESS_127_PAY_PER_BLOCK).and_then(|p| p.value).unwrap_or(160_000_000) as f64 / 1_000_000.0; + + let blocks_per_year = 365.25 * 24.0 * 60.0 * 60.0 / 3.0; + let annual_rewards = (block_reward + voting_reward) * blocks_per_year; + + let total_votes: i64 = witnesses.witnesses.iter().map(|x| x.vote_count.unwrap_or(0)).sum(); + let total_staked_trx = total_votes as f64; + + if total_staked_trx == 0.0 { + return Ok(Some(0.0)); + } + + let apy = (annual_rewards / total_staked_trx) * 100.0; + + Ok(Some(apy)) + } + + async fn get_staking_validators(&self, apy: Option) -> Result, Box> { + let witnesses = self.get_witnesses_list().await?; + Ok(map_staking_validators(witnesses, apy)) + } + + async fn get_staking_delegations(&self, address: String) -> Result, Box> { + let (account, reward, validators) = futures::try_join!(self.get_account(&address), self.get_reward(&address), self.get_staking_validators(None))?; + + let mut delegations = Vec::new(); + let asset_id = Chain::Tron.as_asset_id(); + + if let Some(unfrozen_v2) = account.unfrozen_v2 { + for unfrozen in unfrozen_v2 { + if let Some(expire_time) = unfrozen.unfreeze_expire_time { + let amount = unfrozen.unfreeze_amount; + let completion_date = DateTime::from_timestamp((expire_time / 1000) as i64, 0).unwrap_or_else(Utc::now); + + let now = Utc::now(); + let state = if now < completion_date { + DelegationState::Pending + } else { + DelegationState::AwaitingWithdrawal + }; + + delegations.push(DelegationBase { + asset_id: asset_id.clone(), + state, + balance: BigUint::from(amount), + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date: Some(completion_date), + delegation_id: completion_date.timestamp().to_string(), + validator_id: DelegationValidator::SYSTEM_ID.to_string(), + }); + } + } + } + + if let Some(votes) = account.votes { + let total_votes: u64 = votes.iter().map(|v| v.vote_count).sum(); + let reward_amount = reward.reward; + + for vote in votes { + if validators.iter().any(|v| v.id == vote.vote_address) { + let proportional_reward = if total_votes > 0 { + (reward_amount as f64 * vote.vote_count as f64 / total_votes as f64) as u64 + } else { + 0 + }; + let balance = vote.vote_count * 10_u64.pow(Asset::from_chain(Chain::Tron).decimals as u32); + + delegations.push(DelegationBase { + asset_id: asset_id.clone(), + state: DelegationState::Active, + balance: BigUint::from(balance), + shares: BigUint::from(vote.vote_count), + rewards: BigUint::from(proportional_reward), + completion_date: None, + delegation_id: format!("vote_{}", vote.vote_address), + validator_id: vote.vote_address, + }); + } + } + } + + Ok(delegations) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, create_test_client}; + + #[tokio::test] + async fn test_get_staking_apy() -> Result<(), Box> { + let client = create_test_client(); + let apy = client.get_staking_apy().await?; + let apy_value = apy.expect("Tron staking APY should be present"); + + assert!(apy_value > 0.0 && apy_value < 50.0); + Ok(()) + } + + #[tokio::test] + async fn test_get_staking_validators() -> Result<(), Box> { + let client = create_test_client(); + let apy = client.get_staking_apy().await?; + let validators = client.get_staking_validators(apy).await?; + + assert!(!validators.is_empty()); + assert!(validators.len() > 27); + let system_validator = validators.iter().find(|v| v.id == "system").expect("system validator should exist"); + assert_eq!(system_validator.name, "Unstaking"); + Ok(()) + } + + #[tokio::test] + async fn test_get_staking_delegations() -> Result<(), Box> { + let client = create_test_client(); + let delegations = client.get_staking_delegations(TEST_ADDRESS.to_string()).await?; + for delegation in &delegations { + assert_eq!(delegation.asset_id.chain, Chain::Tron); + assert!(delegation.balance >= BigUint::from(0u32)); + assert!(delegation.rewards >= BigUint::from(0u32)); + assert!(delegation.shares >= BigUint::from(0u32)); + } + Ok(()) + } +} diff --git a/core/crates/gem_tron/src/provider/staking_mapper.rs b/core/crates/gem_tron/src/provider/staking_mapper.rs new file mode 100644 index 0000000000..0c2816974c --- /dev/null +++ b/core/crates/gem_tron/src/provider/staking_mapper.rs @@ -0,0 +1,76 @@ +use crate::address::TronAddress; +use crate::models::WitnessesList; +use primitives::{Address as _, Chain, DelegationValidator, StakeValidator}; + +pub fn map_validators(witnesses: WitnessesList) -> Vec { + witnesses.witnesses.into_iter().map(|x| StakeValidator::new(x.address, x.url)).collect() +} + +pub fn map_staking_validators(witnesses: WitnessesList, apy: Option) -> Vec { + let default_apy = apy.unwrap_or(0.0); + let mut validators: Vec = witnesses + .witnesses + .into_iter() + .filter_map(|witness| { + Some(DelegationValidator::stake( + Chain::Tron, + TronAddress::from_hex(&witness.address)?.encode(), + String::new(), + witness.is_jobs.unwrap_or(false), + 0.0, + default_apy, + )) + }) + .collect(); + + validators.push(DelegationValidator::system(Chain::Tron)); + + validators +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::WitnessAccount; + + fn create_mock_witnesses() -> WitnessesList { + WitnessesList { + witnesses: vec![ + WitnessAccount { + address: "4159f3440fd40722f716144e4490a4de162d3b3fcb".to_string(), + vote_count: Some(1000000), + url: "https://validator1.com".to_string(), + is_jobs: Some(true), + }, + WitnessAccount { + address: "41357a7401a0f0c2d4a44a1881a0c622f15d986291".to_string(), + vote_count: Some(500000), + url: "https://validator2.com".to_string(), + is_jobs: Some(false), + }, + ], + } + } + + #[test] + fn test_map_staking_validators() { + let witnesses = create_mock_witnesses(); + let validators = map_staking_validators(witnesses, Some(4.2)); + + assert_eq!(validators.len(), 3); + + assert_eq!(validators[0].chain, Chain::Tron); + assert_eq!(validators[0].id, "TJApZYJwPKuQR7tL6FmvD6jDjbYpHESZGH"); + assert_eq!(validators[0].name, ""); + assert!(validators[0].is_active); + assert_eq!(validators[0].commission, 0.0); + assert_eq!(validators[0].apr, 4.2); + + assert_eq!(validators[1].id, "TEqyWRKCzREYC2bK2fc3j7pp8XjAa6tJK1"); + assert!(!validators[1].is_active); + + assert_eq!(validators[2].id, DelegationValidator::SYSTEM_ID); + assert_eq!(validators[2].name, DelegationValidator::SYSTEM_NAME); + assert!(validators[2].is_active); + } +} diff --git a/core/crates/gem_tron/src/provider/state.rs b/core/crates/gem_tron/src/provider/state.rs new file mode 100644 index 0000000000..557ee3b365 --- /dev/null +++ b/core/crates/gem_tron/src/provider/state.rs @@ -0,0 +1,63 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use gem_client::Client; +use primitives::NodeSyncStatus; + +use crate::provider::state_mapper; +use crate::rpc::client::TronClient; + +#[async_trait] +impl ChainState for TronClient { + async fn get_chain_id(&self) -> Result> { + Ok("".to_string()) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_latest_block().await? as u64) + } + + async fn get_node_status(&self) -> Result> { + let latest_block = self.get_block_latest_number().await? as i64; + state_mapper::map_node_status(latest_block) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::create_test_client; + + #[tokio::test] + async fn test_get_chain_id() { + let tron_client = create_test_client(); + + let chain_id = tron_client.get_chain_id().await.unwrap(); + + // Tron doesn't have a traditional chain ID like Ethereum + assert_eq!(chain_id, ""); + } + + #[tokio::test] + async fn test_get_block_latest_number() { + let tron_client = create_test_client(); + + let latest_block = tron_client.get_block_latest_number().await.unwrap(); + + // Latest block should be a positive number + assert!(latest_block > 0); + } + + #[tokio::test] + async fn test_get_node_status() -> Result<(), Box> { + let tron_client = create_test_client(); + let node_status = tron_client.get_node_status().await?; + + assert!(node_status.in_sync); + assert!(node_status.latest_block_number.is_some()); + assert!(node_status.latest_block_number.unwrap_or(0) > 0); + + Ok(()) + } +} diff --git a/core/crates/gem_tron/src/provider/state_mapper.rs b/core/crates/gem_tron/src/provider/state_mapper.rs new file mode 100644 index 0000000000..9be167fcdf --- /dev/null +++ b/core/crates/gem_tron/src/provider/state_mapper.rs @@ -0,0 +1,21 @@ +use primitives::NodeSyncStatus; +use std::error::Error; + +pub fn map_node_status(latest_block: i64) -> Result> { + Ok(NodeSyncStatus::synced(latest_block as u64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_node_status() { + let latest_block = 12345i64; + let mapped = map_node_status(latest_block).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(12345)); + assert_eq!(mapped.current_block_number, Some(12345)); + } +} diff --git a/core/crates/gem_tron/src/provider/testkit.rs b/core/crates/gem_tron/src/provider/testkit.rs new file mode 100644 index 0000000000..52192617cf --- /dev/null +++ b/core/crates/gem_tron/src/provider/testkit.rs @@ -0,0 +1,68 @@ +#[cfg(test)] +use crate::models::account::{TronAccount, TronAccountOwnerPermission, TronAccountPermission, TronAccountPermissionKey, TronFrozen, TronVote}; +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::client::TronClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use primitives::asset_constants::TRON_USDT_TOKEN_ID; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "TFdTEn9dJuqh351y8fyJ3eMmghFsZNwakb"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "5a9935a1b7be0150a511111582bbfed62ddb873333b3986bd712e6105fe90ad5"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_USDT_TOKEN_ID: &str = TRON_USDT_TOKEN_ID; + +#[cfg(test)] +impl TronAccount { + pub fn mock(address: &str) -> Self { + Self { + balance: None, + address: Some(address.to_string()), + owner_permission: Some(TronAccountOwnerPermission { + permission_name: "owner".to_string(), + threshold: Some(1), + keys: Some(vec![TronAccountPermissionKey { + address: address.to_string(), + weight: 1, + }]), + }), + active_permission: Some(vec![TronAccountPermission { + id: None, + threshold: 1, + keys: Some(vec![TronAccountPermissionKey { + address: address.to_string(), + weight: 1, + }]), + }]), + votes: None, + frozen_v2: None, + unfrozen_v2: None, + } + } + + pub fn mock_with_staking(votes: Option>, frozen_v2: Option>) -> Self { + Self { + balance: None, + address: None, + owner_permission: None, + active_permission: None, + votes, + frozen_v2, + unfrozen_v2: None, + } + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_test_client() -> TronClient { + use crate::rpc::trongrid::client::TronGridClient; + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.tron.url, reqwest::Client::new()); + let trongrid_client = TronGridClient::new(reqwest_client.clone(), settings.trongrid.key.secret); + TronClient::new(reqwest_client, trongrid_client) +} diff --git a/core/crates/gem_tron/src/provider/token.rs b/core/crates/gem_tron/src/provider/token.rs new file mode 100644 index 0000000000..65a0057337 --- /dev/null +++ b/core/crates/gem_tron/src/provider/token.rs @@ -0,0 +1,45 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use gem_client::Client; +use primitives::Asset; + +use crate::rpc::client::TronClient; + +#[async_trait] +impl ChainToken for TronClient { + async fn get_token_data(&self, token_id: String) -> Result> { + Self::get_token_data(self, token_id).await + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + token_id.starts_with("T") && token_id.len() >= 30 + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_USDT_TOKEN_ID, create_test_client}; + + #[tokio::test] + async fn test_get_token_data() { + let tron_client = create_test_client(); + + let asset = tron_client.get_token_data(TEST_USDT_TOKEN_ID.to_string()).await.unwrap(); + + assert_eq!(asset.symbol, "USDT"); + assert_eq!(asset.decimals, 6); + assert_eq!(asset.id.token_id, Some(TEST_USDT_TOKEN_ID.to_string())); + } + + #[tokio::test] + async fn test_get_is_token_address() { + let tron_client = create_test_client(); + + assert!(tron_client.get_is_token_address(TEST_USDT_TOKEN_ID)); + assert!(!tron_client.get_is_token_address("TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH")); + assert!(!tron_client.get_is_token_address("invalid")); + } +} diff --git a/core/crates/gem_tron/src/provider/transaction_broadcast.rs b/core/crates/gem_tron/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..925fa968be --- /dev/null +++ b/core/crates/gem_tron/src/provider/transaction_broadcast.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{BroadcastProvider, transaction_broadcast_mapper::map_transaction_broadcast_response_from_str, transactions_mapper::map_transaction_broadcast}, + rpc::client::TronClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for TronClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(data).await?; + map_transaction_broadcast(&response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_tron/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_tron/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..c6251268d0 --- /dev/null +++ b/core/crates/gem_tron/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,9 @@ +use std::error::Error; + +use crate::models::TronTransactionBroadcast; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::(response)?; + map_transaction_broadcast(&response) +} diff --git a/core/crates/gem_tron/src/provider/transaction_state.rs b/core/crates/gem_tron/src/provider/transaction_state.rs new file mode 100644 index 0000000000..4b0a2aa0c2 --- /dev/null +++ b/core/crates/gem_tron/src/provider/transaction_state.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::TronClient}; + +#[async_trait] +impl ChainTransactionState for TronClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let receipt = self.get_transaction_reciept(request.id).await?; + Ok(map_transaction_status(&receipt)) + } +} diff --git a/core/crates/gem_tron/src/provider/transaction_state_mapper.rs b/core/crates/gem_tron/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..faf5bc0169 --- /dev/null +++ b/core/crates/gem_tron/src/provider/transaction_state_mapper.rs @@ -0,0 +1,75 @@ +use num_bigint::BigInt; +use primitives::{TransactionChange, TransactionState, TransactionUpdate}; + +use crate::models::TransactionReceiptData; +use crate::rpc::constants::{RECEIPT_FAILED, RECEIPT_OUT_OF_ENERGY}; + +pub fn map_transaction_status(receipt: &TransactionReceiptData) -> TransactionUpdate { + if let Some(receipt_result) = &receipt.receipt.result + && (receipt_result == RECEIPT_OUT_OF_ENERGY || receipt_result == RECEIPT_FAILED) + { + return TransactionUpdate::new_state(TransactionState::Reverted); + } + + if receipt.block_number > 0 { + let mut changes = vec![]; + if let Some(fee) = receipt.fee { + changes.push(TransactionChange::NetworkFee(BigInt::from(fee))); + } + return TransactionUpdate::new(TransactionState::Confirmed, changes); + } + + TransactionUpdate::new_state(TransactionState::Pending) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{TransactionReceipt, TransactionReceiptData}; + + fn create_receipt(result: Option<&str>, block_number: i64, fee: Option) -> TransactionReceiptData { + TransactionReceiptData { + id: "transaction_id".to_string(), + fee, + block_number, + block_time_stamp: 0, + receipt: TransactionReceipt { + result: result.map(|value| value.to_string()), + }, + log: None, + } + } + + #[test] + fn test_map_transaction_status_confirmed() { + let receipt = create_receipt(None, 10, Some(100)); + + let result = map_transaction_status(&receipt); + assert_eq!(result.state, TransactionState::Confirmed); + assert!(!result.changes.is_empty()); + } + + #[test] + fn test_map_transaction_status_reverted_out_of_energy() { + let receipt = create_receipt(Some(RECEIPT_OUT_OF_ENERGY), 10, Some(100)); + + let result = map_transaction_status(&receipt); + assert_eq!(result.state, TransactionState::Reverted); + } + + #[test] + fn test_map_transaction_status_reverted_failed() { + let receipt = create_receipt(Some(RECEIPT_FAILED), 10, Some(100)); + + let result = map_transaction_status(&receipt); + assert_eq!(result.state, TransactionState::Reverted); + } + + #[test] + fn test_map_transaction_status_pending() { + let receipt = create_receipt(None, 0, None); + + let result = map_transaction_status(&receipt); + assert_eq!(result.state, TransactionState::Pending); + } +} diff --git a/core/crates/gem_tron/src/provider/transactions.rs b/core/crates/gem_tron/src/provider/transactions.rs new file mode 100644 index 0000000000..6e9f7cc08c --- /dev/null +++ b/core/crates/gem_tron/src/provider/transactions.rs @@ -0,0 +1,87 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use super::transactions_mapper::{map_transaction, map_transactions_by_address, map_transactions_by_block}; +use crate::rpc::client::TronClient; + +#[async_trait] +impl ChainTransactions for TronClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let block_data = self.get_block_tranactions(block).await?; + if block_data.transactions.is_empty() { + return Ok(vec![]); + } + + let receipts = self.get_block_tranactions_reciepts(block).await?; + Ok(map_transactions_by_block(self.get_chain(), block_data, receipts)) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + Ok(map_transaction( + self.get_chain(), + self.get_transaction(hash.clone()).await?, + self.get_transaction_reciept(hash).await?, + )) + } + + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, limit, .. } = request; + let limit = limit.unwrap_or(20); + let transactions = self.trongrid_client.get_transactions_by_address(&address, limit).await?.data; + + if transactions.is_empty() { + return Ok(vec![]); + } + + let futures = transactions.iter().map(|transaction| self.get_transaction_reciept(transaction.transaction_id.clone())); + let receipts = futures::future::try_join_all(futures).await?; + + Ok(map_transactions_by_address(transactions, receipts)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_TRANSACTION_ID, create_test_client}; + use chain_traits::ChainState; + + #[tokio::test] + async fn test_get_transactions_by_block() { + let tron_client = create_test_client(); + + let latest_block = tron_client.get_block_latest_number().await.unwrap(); + let block_number = latest_block - 25; + let transactions = tron_client.get_transactions_by_block(block_number).await.unwrap(); + + assert!(latest_block > 0); + assert!(!transactions.is_empty()); + + if let Some(transaction) = transactions.first() { + assert!(!transaction.id.hash.is_empty()); + } + } + + #[tokio::test] + async fn test_get_transactions_by_address() { + let tron_client = create_test_client(); + let transactions = tron_client + .get_transactions_by_address(TransactionsRequest::new(TEST_ADDRESS.to_string()).with_limit(1)) + .await + .unwrap(); + + assert!(!transactions.is_empty()); + } + + #[tokio::test] + async fn test_get_transaction_by_hash() { + let tron_client = create_test_client(); + let transaction = tron_client.get_transaction_by_hash(TEST_TRANSACTION_ID.to_string()).await.unwrap().unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + } +} diff --git a/core/crates/gem_tron/src/provider/transactions_mapper.rs b/core/crates/gem_tron/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..70df877555 --- /dev/null +++ b/core/crates/gem_tron/src/provider/transactions_mapper.rs @@ -0,0 +1,359 @@ +use chrono::DateTime; +use num_bigint::BigUint; +use primitives::{ + Address as _, AssetId, Transaction, TransactionResourceTypeMetadata, TransactionState, TransactionType, chain::Chain, decode_hex, hex::decode_hex_utf8, stake_type::Resource, +}; +use std::error::Error; + +use crate::address::TronAddress; +use crate::models::{BlockTransactions, Transaction as TronTransaction, TransactionReceiptData, TronContractType, TronTransactionBroadcast}; +use crate::rpc::constants::ERC20_TRANSFER_EVENT_SIGNATURE; + +fn decode_hex_message(hex_str: &str) -> String { + decode_hex_utf8(hex_str).unwrap_or_else(|| hex_str.to_string()) +} + +fn resource_type_metadata(resource: Option) -> Option { + let resource_type = resource.as_deref().and_then(|resource| resource.parse::().ok()).unwrap_or(Resource::Bandwidth); + serde_json::to_value(TransactionResourceTypeMetadata::new(resource_type)).ok() +} + +pub fn map_transaction_broadcast(response: &TronTransactionBroadcast) -> Result> { + if let Some(message) = &response.message { + Err(decode_hex_message(message).into()) + } else if let Some(txid) = &response.txid { + Ok(txid.clone()) + } else { + Err("Transaction broadcast failed with unknown error".into()) + } +} + +pub fn map_transactions_by_block(chain: Chain, block: BlockTransactions, receipts: Vec) -> Vec { + block + .transactions + .into_iter() + .zip(receipts) + .filter_map(|(transaction, receipt)| map_transaction(chain, transaction, receipt)) + .collect() +} + +pub fn map_transactions_by_address(transactions: Vec, receipts: Vec) -> Vec { + transactions + .into_iter() + .zip(receipts) + .filter_map(|(transaction, receipt)| map_transaction(Chain::Tron, transaction, receipt)) + .collect() +} + +pub fn map_transaction(chain: Chain, transaction: TronTransaction, receipt: TransactionReceiptData) -> Option { + if let (Some(value), Some(contract_result)) = (transaction.raw_data.contract.first().cloned(), transaction.ret.first().cloned()) { + let state: TransactionState = if contract_result.contract_ret == "SUCCESS" { + TransactionState::Confirmed + } else { + TransactionState::Failed + }; + let fee = receipt.fee.unwrap_or_default().to_string(); + let created_at = DateTime::from_timestamp_millis(receipt.block_time_stamp)?; + + let memo = transaction.raw_data.data.as_deref().map(decode_hex_message); + let contract_value = value.parameter.value; + let from = contract_value.owner_address.unwrap_or_default(); + + let contract_type = value.contract_type; + if let Some((transaction_type, to, amount, metadata)) = match contract_type { + Some(TronContractType::Transfer) if !transaction.ret.is_empty() => { + let to = contract_value.to_address.unwrap_or_default(); + Some((TransactionType::Transfer, to, contract_value.amount.unwrap_or_default().to_string(), None)) + } + Some(TronContractType::FreezeBalanceV2) => Some(( + TransactionType::StakeFreeze, + from.clone(), + contract_value.frozen_balance.unwrap_or_default().to_string(), + resource_type_metadata(contract_value.resource.clone()), + )), + Some(TronContractType::UnfreezeBalanceV2) => Some(( + TransactionType::StakeUnfreeze, + from.clone(), + contract_value.unfreeze_balance.unwrap_or_default().to_string(), + resource_type_metadata(contract_value.resource.clone()), + )), + Some(TronContractType::VoteWitness) => { + let votes = contract_value.votes.as_ref()?; + let vote = votes.first()?; + let to = TronAddress::from_hex(vote.vote_address.as_str())?.encode(); + let amount = vote.vote_count * 1_000_000; + Some((TransactionType::StakeDelegate, to, amount.to_string(), None)) + } + _ => None, + } { + let transaction = Transaction::new( + transaction.transaction_id, + chain.as_asset_id(), + from, + to, + None, + transaction_type, + state, + fee, + chain.as_asset_id(), + amount, + memo.clone(), + metadata, + created_at, + ); + return Some(transaction); + } + let logs = receipt.log.unwrap_or_default(); + if contract_type == Some(TronContractType::TriggerSmart) && logs.len() == 1 { + let log = logs.first()?; + let topics = log.topics.as_ref()?; + if topics.len() != 3 || topics.first()?.as_str() != ERC20_TRANSFER_EVENT_SIGNATURE { + return None; + } + + let from_string = format!("41{}", topics[1].chars().skip(24).collect::()); + let to_string = format!("41{}", topics[2].chars().skip(24).collect::()); + let token_id = contract_value.contract_address?; + let from = TronAddress::from_hex(from_string.as_str())?.encode(); + let to = TronAddress::from_hex(to_string.as_str())?.encode(); + let value = BigUint::from_bytes_be(&decode_hex(log.data.as_deref()?).ok()?); + let asset_id = AssetId { chain, token_id: Some(token_id) }; + + let transaction = Transaction::new( + transaction.transaction_id, + asset_id, + from, + to, + None, + TransactionType::Transfer, + state, + fee, + chain.as_asset_id(), + value.to_string(), + memo, + None, + created_at, + ); + + return Some(transaction); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{BlockTransactions, TransactionReceipt, TransactionReceiptData, TronContractType, TronTransactionBroadcast}; + use crate::provider::testkit::TEST_TRANSACTION_ID; + use primitives::asset_constants::TRON_USDT_TOKEN_ID; + + #[test] + fn test_map_transaction_broadcast_error() { + let response: TronTransactionBroadcast = serde_json::from_str(include_str!("../../testdata/transaction_broadcast_error.json")).unwrap(); + + let result = map_transaction_broadcast(&response); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Contract validate error : Cannot transfer TRX to yourself."); + } + + #[test] + fn test_map_transaction_broadcast_success() { + let response: TronTransactionBroadcast = serde_json::from_str(include_str!("../../testdata/transaction_broadcast_success.json")).unwrap(); + + let result = map_transaction_broadcast(&response); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "7f60ccd0594b5c3e0264cca9a6e6e64cb96ee66ce3a796b4356cb8ccc548f62b"); + } + + #[test] + fn test_map_transaction_broadcast_unknown_error() { + let response = TronTransactionBroadcast { + txid: None, + code: None, + message: None, + }; + + let result = map_transaction_broadcast(&response); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Transaction broadcast failed with unknown error"); + } + + #[test] + fn test_map_transaction_freeze_bandwidth() { + let transaction: TronTransaction = serde_json::from_str(include_str!("../../testdata/transaction_freeze.json")).unwrap(); + let receipt = TransactionReceiptData { + id: "test_id".to_string(), + fee: Some(1000), + block_number: 12345, + block_time_stamp: 1758589896000, + receipt: TransactionReceipt { + result: Some("SUCCESS".to_string()), + }, + log: None, + }; + + let result = map_transaction(Chain::Tron, transaction, receipt); + assert!(result.is_some()); + let transaction = result.unwrap(); + assert_eq!(transaction.transaction_type, TransactionType::StakeFreeze); + assert_eq!(transaction.value, "100000000"); + assert_eq!(transaction.from, transaction.to); + assert_eq!(transaction.metadata, serde_json::to_value(TransactionResourceTypeMetadata::new(Resource::Bandwidth)).ok()); + } + + #[test] + fn test_map_transaction_freeze_energy() { + let transaction: TronTransaction = serde_json::from_str(include_str!("../../testdata/transaction_freeze_energy.json")).unwrap(); + let receipt = TransactionReceiptData { + id: "test_id".to_string(), + fee: Some(1000), + block_number: 12345, + block_time_stamp: 1760552376000, + receipt: TransactionReceipt { + result: Some("SUCCESS".to_string()), + }, + log: None, + }; + + let result = map_transaction(Chain::Tron, transaction, receipt); + assert!(result.is_some()); + let transaction = result.unwrap(); + assert_eq!(transaction.transaction_type, TransactionType::StakeFreeze); + assert_eq!(transaction.value, "10000000"); + assert_eq!(transaction.from, transaction.to); + assert_eq!(transaction.metadata, serde_json::to_value(TransactionResourceTypeMetadata::new(Resource::Energy)).ok()); + } + + #[test] + fn test_map_transaction_stake() { + let transaction: TronTransaction = serde_json::from_str(include_str!("../../testdata/transaction_stake.json")).unwrap(); + let receipt = TransactionReceiptData { + id: "test_id".to_string(), + fee: Some(1000), + block_number: 12345, + block_time_stamp: 1758225849000, + receipt: TransactionReceipt { + result: Some("SUCCESS".to_string()), + }, + log: None, + }; + + let result = map_transaction(Chain::Tron, transaction, receipt); + assert!(result.is_some()); + let transaction = result.unwrap(); + assert_eq!(transaction.transaction_type, TransactionType::StakeDelegate); + assert_eq!(transaction.value, "2125000000"); + assert_eq!(transaction.from, "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb"); + assert_eq!(transaction.to, "TJvaAeFb8Lykt9RQcVyyTFN2iDvGMuyD4M"); + } + + #[test] + fn test_map_transaction_unfreeze() { + let transaction: TronTransaction = serde_json::from_str(include_str!("../../testdata/transaction_unfreeze.json")).unwrap(); + let receipt = TransactionReceiptData { + id: "test_id".to_string(), + fee: Some(1000), + block_number: 12345, + block_time_stamp: 1758596982000, + receipt: TransactionReceipt { + result: Some("SUCCESS".to_string()), + }, + log: None, + }; + + let result = map_transaction(Chain::Tron, transaction, receipt); + assert!(result.is_some()); + let transaction = result.unwrap(); + assert_eq!(transaction.transaction_type, TransactionType::StakeUnfreeze); + assert_eq!(transaction.value, "100000000"); + assert_eq!(transaction.from, transaction.to); + assert_eq!(transaction.metadata, serde_json::to_value(TransactionResourceTypeMetadata::new(Resource::Bandwidth)).ok()); + } + + #[test] + fn test_map_transaction_by_hash() { + let transaction: TronTransaction = serde_json::from_str(include_str!("../../testdata/transaction_coin_transfer.json")).unwrap(); + let receipt: TransactionReceiptData = serde_json::from_str(include_str!("../../testdata/transaction_coin_transfer_receipt.json")).unwrap(); + + let result = map_transaction(Chain::Tron, transaction, receipt); + assert!(result.is_some()); + let transaction = result.unwrap(); + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + assert_eq!(transaction.transaction_type, TransactionType::Transfer); + assert_eq!(transaction.value, "25000000"); + assert_ne!(transaction.from, transaction.to); + } + + #[test] + fn test_map_transaction_token_transfer() { + let transaction: TronTransaction = serde_json::from_str(include_str!("../../testdata/transaction_token_transfer.json")).unwrap(); + let receipt = TransactionReceiptData { + id: "test_id".to_string(), + fee: Some(1000), + block_number: 12345, + block_time_stamp: 1727747910000, + receipt: TransactionReceipt { + result: Some("SUCCESS".to_string()), + }, + log: Some(vec![crate::models::TronLog { + topics: Some(vec![ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef".to_string(), + "0000000000000000000000002e1d447fa4169390cf5f5b3d12d380decfbfe20f".to_string(), + "0000000000000000000000006e2cf2878020b966786f01ab45ea1fcef6880092".to_string(), + ]), + data: Some("00000000000000000000000000000000000000000000000000000000017d7840".to_string()), + }]), + }; + + let result = map_transaction(Chain::Tron, transaction, receipt); + assert!(result.is_some()); + let transaction = result.unwrap(); + assert_eq!(transaction.transaction_type, TransactionType::Transfer); + assert_ne!(transaction.from, transaction.to); + } + + #[test] + fn test_map_transactions_by_block_ignores_unsupported_contract_types() { + let block: BlockTransactions = serde_json::from_str(include_str!("../../testdata/block_mixed_contract_types.json")).unwrap(); + let receipts: Vec = serde_json::from_str(include_str!("../../testdata/block_mixed_contract_types_receipts.json")).unwrap(); + + assert_eq!(block.transactions.len(), 5); + assert_eq!(block.transactions[0].raw_data.contract[0].contract_type, Some(TronContractType::DelegateResource)); + assert_eq!(block.transactions[1].raw_data.contract[0].contract_type, Some(TronContractType::UnDelegateResource)); + assert_eq!(block.transactions[2].raw_data.contract[0].contract_type, Some(TronContractType::TransferAsset)); + assert_eq!(block.transactions[3].raw_data.contract[0].contract_type, None); + + let transactions = map_transactions_by_block(Chain::Tron, block, receipts); + + assert_eq!(transactions.len(), 1); + let transaction = transactions.first().unwrap(); + assert_eq!(transaction.hash, "10f1e5b04c0dd39f14d4b5ca270899b36ae9c52ac1b9b64b76360c7373cc0893"); + assert_eq!(transaction.asset_id, AssetId::from_token(Chain::Tron, TRON_USDT_TOKEN_ID)); + assert_eq!(transaction.from, "TWBPGLwQw2EbqYLLw1DJnTDt2ZQ9yJW1JJ"); + assert_eq!(transaction.to, "TViSMURdt2dda6Pf163UBZoSfbV9hECvvc"); + assert_eq!(transaction.value, "249000000"); + assert_eq!(transaction.transaction_type, TransactionType::Transfer); + assert_eq!(transaction.state, TransactionState::Confirmed); + } + + #[test] + fn test_map_transaction_thorchain_swap() { + let transaction: TronTransaction = serde_json::from_str(include_str!("../../testdata/transaction_thorchain_swap.json")).unwrap(); + let receipt = TransactionReceiptData { + id: "test_id".to_string(), + fee: Some(1000), + block_number: 12345, + block_time_stamp: 1771951038000, + receipt: TransactionReceipt { + result: Some("SUCCESS".to_string()), + }, + log: None, + }; + + let transaction = map_transaction(Chain::Tron, transaction, receipt).unwrap(); + assert_eq!(transaction.transaction_type, TransactionType::Transfer); + assert_eq!(transaction.value, "200000000"); + assert_eq!(transaction.memo.as_deref(), Some("=:TRON.USDT:TNAwd1WFe7GHTxovGU9MeT6mi3J4KAZMvP:0/1/0:g1:50")); + } +} diff --git a/core/crates/gem_tron/src/rpc/client.rs b/core/crates/gem_tron/src/rpc/client.rs new file mode 100644 index 0000000000..222c31c50a --- /dev/null +++ b/core/crates/gem_tron/src/rpc/client.rs @@ -0,0 +1,292 @@ +use async_trait::async_trait; +use chain_traits::{ChainAccount, ChainPerpetual, ChainTraits}; +use num_bigint::BigUint; +use primitives::{Asset, AssetId, asset_type::AssetType, chain::Chain, decode_hex}; +use std::{error::Error, str::FromStr}; + +use crate::address::TronAddress; +use crate::models::{ + Block, BlockTransactions, BlockTransactionsInfo, ChainParameter, ChainParametersResponse, Transaction, TransactionReceiptData, TriggerConstantContractRequest, + TriggerConstantContractResponse, TronTransactionBroadcast, WitnessesList, +}; +use crate::models::{ + TriggerSmartContractData, TronAccount, TronAccountRequest, TronAccountUsage, TronBlock, TronEmptyAccount, TronReward, TronSmartContractCall, TronSmartContractResult, +}; +use crate::rpc::constants::{DECIMALS_SELECTOR, DEFAULT_OWNER_ADDRESS, NAME_SELECTOR, SYMBOL_SELECTOR}; +use crate::rpc::trongrid::client::TronGridClient; +use alloy_primitives::Address as AlloyAddress; +use alloy_sol_types::SolCall; +use gem_client::{Client, ClientExt}; +use gem_evm::contracts::erc20::{decode_abi_string, decode_abi_uint8}; + +#[derive(Clone)] +pub struct TronClient { + pub client: C, + pub trongrid_client: TronGridClient, +} + +impl TronClient { + pub fn new(client: C, trongrid_client: TronGridClient) -> Self { + Self { client, trongrid_client } + } + + pub async fn get_block(&self) -> Result> { + Ok(self.client.get("/wallet/getblock").await?) + } + + pub async fn get_block_tranactions(&self, block: u64) -> Result> { + Ok(self.client.get(&format!("/wallet/getblockbynum?num={}", block)).await?) + } + + pub async fn get_block_tranactions_reciepts(&self, block: u64) -> Result> { + Ok(self.client.get(&format!("/wallet/gettransactioninfobyblocknum?num={}", block)).await?) + } + + pub async fn get_transaction(&self, id: String) -> Result> { + Ok(self.client.get(&format!("/wallet/gettransactionbyid?value={}", id)).await?) + } + + pub async fn get_transaction_reciept(&self, id: String) -> Result> { + Ok(self.client.get(&format!("/wallet/gettransactioninfobyid?value={}", id)).await?) + } + + pub async fn trigger_constant_contract(&self, contract_address: &str, function_selector: &str, parameter: &str) -> Result> { + self.trigger_constant_contract_with_owner(DEFAULT_OWNER_ADDRESS, contract_address, function_selector, parameter) + .await + } + + pub async fn trigger_constant_contract_with_owner( + &self, + owner_address: &str, + contract_address: &str, + function_selector: &str, + parameter: &str, + ) -> Result> { + let request = TriggerConstantContractRequest { + owner_address: owner_address.to_owned(), + contract_address: contract_address.to_string(), + function_selector: function_selector.to_string(), + parameter: parameter.to_string(), + fee_limit: None, + call_value: None, + visible: true, + }; + + let response = self.trigger_constant_contract_request(&request).await?; + + if response.constant_result.is_empty() { + return Err("Empty response from Tron contract call".into()); + } + + Ok(response.constant_result[0].clone()) + } + + async fn trigger_constant_contract_request(&self, request: &(impl serde::Serialize + Send + Sync)) -> Result> { + Ok(self.client.post("/wallet/triggerconstantcontract", request).await?) + } + + pub async fn get_token_allowance(&self, owner_address: &str, token_address: &str, spender_address: &str) -> Result> { + let owner = AlloyAddress::from_slice(TronAddress::parse(owner_address)?.account_id()); + let spender = AlloyAddress::from_slice(TronAddress::parse(spender_address)?.account_id()); + let encoded = gem_evm::contracts::erc20::IERC20::allowanceCall { owner, spender }.abi_encode(); + let parameter = hex::encode(&encoded[4..]); + + let result = self + .trigger_constant_contract_with_owner(owner_address, token_address, "allowance(address,address)", ¶meter) + .await?; + let allowance_bytes = decode_hex(&result)?; + let allowance = BigUint::from_bytes_be(&allowance_bytes); + Ok(allowance) + } + + pub async fn estimate_energy( + &self, + owner_address: &str, + contract_address: &str, + function_selector: &str, + parameter: &str, + fee_limit: u64, + call_value: u64, + ) -> Result> { + let request_payload = TriggerConstantContractRequest { + owner_address: owner_address.to_string(), + contract_address: contract_address.to_string(), + function_selector: function_selector.to_string(), + parameter: parameter.to_string(), + fee_limit: Some(fee_limit), + call_value: Some(call_value), + visible: true, + }; + + let response = self.trigger_constant_contract_request(&request_payload).await?; + Ok(response.get_energy()?) + } + + pub async fn estimate_energy_with_data(&self, contract_data: &TriggerSmartContractData) -> Result> { + let request_payload = serde_json::json!({ + "owner_address": contract_data.owner_address, + "contract_address": contract_data.contract_address, + "data": contract_data.data, + "fee_limit": contract_data.fee_limit, + "call_value": contract_data.call_value, + "visible": true, + }); + + let response = self.trigger_constant_contract_request(&request_payload).await?; + Ok(response.get_energy()?) + } +} + +impl TronClient { + pub fn get_chain(&self) -> Chain { + Chain::Tron + } + + pub async fn get_latest_block(&self) -> Result> { + Ok(self.get_block().await?.block_header.raw_data.number) + } + + pub async fn get_witnesses_list(&self) -> Result> { + Ok(self.client.get("/wallet/listwitnesses").await?) + } + + pub async fn get_chain_parameters(&self) -> Result, Box> { + let response: ChainParametersResponse = self.client.get("/wallet/getchainparameters").await?; + Ok(response.chain_parameter) + } + + pub async fn get_token_data(&self, token_id: String) -> Result> { + let name = self.trigger_constant_contract(&token_id, NAME_SELECTOR, "").await?; + let symbol = self.trigger_constant_contract(&token_id, SYMBOL_SELECTOR, "").await?; + let decimals = self.trigger_constant_contract(&token_id, DECIMALS_SELECTOR, "").await?; + + let name = decode_abi_string(&name)?; + let symbol = decode_abi_string(&symbol)?; + let decimals = decode_abi_uint8(&decimals)?; + let asset_id = AssetId::from(Chain::Tron, Some(token_id)); + Ok(Asset::new(asset_id, name, symbol, decimals as i32, AssetType::TRC20)) + } + + pub async fn get_account(&self, address: &str) -> Result> { + let request = TronAccountRequest { + address: address.to_string(), + visible: true, + }; + + Ok(self.client.post("/wallet/getaccount", &request).await?) + } + + pub async fn get_account_usage(&self, address: &str) -> Result> { + let request = TronAccountRequest { + address: address.to_string(), + visible: true, + }; + + Ok(self.client.post("/wallet/getaccountresource", &request).await?) + } + + pub async fn get_reward(&self, address: &str) -> Result> { + let request = TronAccountRequest { + address: address.to_string(), + visible: true, + }; + + Ok(self.client.post("/wallet/getReward", &request).await?) + } + + pub async fn trigger_smart_contract(&self, request: &TronSmartContractCall) -> Result> { + Ok(self.client.post("/wallet/triggerconstantcontract", request).await?) + } + + pub async fn is_new_account(&self, address: &str) -> Result> { + let request = TronAccountRequest { + address: address.to_string(), + visible: true, + }; + + let account: TronEmptyAccount = self.client.post("/wallet/getaccount", &request).await?; + Ok(account.address.is_none_or(|addr| addr.is_empty())) + } + + pub async fn broadcast_transaction(&self, data: String) -> Result> { + let json_value: serde_json::Value = serde_json::from_str(&data)?; + Ok(self.client.post("/wallet/broadcasttransaction", &json_value).await?) + } + + pub async fn get_tron_block(&self) -> Result> { + Ok(self.client.post("/wallet/getnowblock", &serde_json::json!({})).await?) + } + + pub async fn estimate_trc20_transfer_gas( + &self, + sender_address: String, + contract_address: String, + recipient_address: String, + value: String, + ) -> Result> { + let value_bigint = BigUint::from_str(&value).map_err(|e| format!("Failed to parse value as decimal: {}", e))?; + let value_hex = format!("{:0>64}", hex::encode(value_bigint.to_bytes_be())); + let parameter = format!("{}{}", recipient_address, value_hex); + + let request = TriggerConstantContractRequest { + owner_address: sender_address, + contract_address, + function_selector: "transfer(address,uint256)".to_string(), + parameter, + fee_limit: None, + call_value: None, + visible: true, + }; + + Ok(self.trigger_constant_contract_request(&request).await?.energy_used) + } +} + +// Trait implementations required for gateway integration +#[async_trait] +impl ChainTraits for TronClient {} + +#[async_trait] +impl ChainAccount for TronClient {} + +#[async_trait] +impl ChainPerpetual for TronClient {} + +impl chain_traits::ChainProvider for TronClient { + fn get_chain(&self) -> primitives::Chain { + Chain::Tron + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_value_encoding_for_trc20_transfer() { + let value = "1000000".to_string(); // 1 USDT (6 decimals) + let recipient_address = "0000000000000000000000003e1451cdb84d440345de6195b0384d1b77aa4eaa".to_string(); + + let value_bigint = BigUint::from_str(&value).unwrap(); + let value_hex = format!("{:0>64}", hex::encode(value_bigint.to_bytes_be())); + let parameter = format!("{}{}", recipient_address, value_hex); + + // For 1000000 (decimal), the hex should be f4240 padded to 64 chars + assert_eq!(value_hex, "00000000000000000000000000000000000000000000000000000000000f4240"); + assert_eq!( + parameter, + "0000000000000000000000003e1451cdb84d440345de6195b0384d1b77aa4eaa00000000000000000000000000000000000000000000000000000000000f4240" + ); + } + + #[test] + fn test_large_value_encoding() { + let value = "16777216".to_string(); // Large value that was causing issues + + let value_bigint = BigUint::from_str(&value).unwrap(); + let value_hex = format!("{:0>64}", hex::encode(value_bigint.to_bytes_be())); + + // 16777216 decimal = 0x1000000 hex + assert_eq!(value_hex, "0000000000000000000000000000000000000000000000000000000001000000"); + } +} diff --git a/core/crates/gem_tron/src/rpc/constants.rs b/core/crates/gem_tron/src/rpc/constants.rs new file mode 100644 index 0000000000..7228ed3d26 --- /dev/null +++ b/core/crates/gem_tron/src/rpc/constants.rs @@ -0,0 +1,23 @@ +pub const NAME_SELECTOR: &str = "name()"; +pub const SYMBOL_SELECTOR: &str = "symbol()"; +pub const DECIMALS_SELECTOR: &str = "decimals()"; +pub const DEFAULT_OWNER_ADDRESS: &str = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; + +// Bandwidth estimate for TRON transactions +pub const DEFAULT_BANDWIDTH_BYTES: u64 = 300; + +// Chain parameter keys +pub const GET_ENERGY_FEE: &str = "getEnergyFee"; +pub const GET_CREATE_NEW_ACCOUNT_FEE_IN_SYSTEM_CONTRACT: &str = "getCreateNewAccountFeeInSystemContract"; +pub const GET_CREATE_ACCOUNT_FEE: &str = "getCreateAccountFee"; +pub const GET_TRANSACTION_FEE: &str = "getTransactionFee"; +pub const GET_MEMO_FEE: &str = "getMemoFee"; +pub const GET_WITNESS_PAY_PER_BLOCK: &str = "getWitnessPayPerBlock"; +pub const GET_WITNESS_127_PAY_PER_BLOCK: &str = "getWitness127PayPerBlock"; + +// Transaction receipt result constants +pub const RECEIPT_OUT_OF_ENERGY: &str = "OUT_OF_ENERGY"; +pub const RECEIPT_FAILED: &str = "FAILED"; + +// Event signature constants +pub const ERC20_TRANSFER_EVENT_SIGNATURE: &str = "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; diff --git a/core/crates/gem_tron/src/rpc/mod.rs b/core/crates/gem_tron/src/rpc/mod.rs new file mode 100644 index 0000000000..162fc41478 --- /dev/null +++ b/core/crates/gem_tron/src/rpc/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod constants; +pub mod trongrid; + +pub use client::TronClient; diff --git a/core/crates/gem_tron/src/rpc/trongrid/client.rs b/core/crates/gem_tron/src/rpc/trongrid/client.rs new file mode 100644 index 0000000000..27e562c544 --- /dev/null +++ b/core/crates/gem_tron/src/rpc/trongrid/client.rs @@ -0,0 +1,43 @@ +use crate::models::Transaction; +use crate::rpc::trongrid::model::{Data, Trc20Transaction, TronGridAccount}; +use gem_client::{Client, ClientExt}; +use std::collections::HashMap; +use std::error::Error; +use std::result::Result; + +#[derive(Clone)] +pub struct TronGridClient { + client: C, + api_key: String, +} + +impl TronGridClient { + pub fn new(client: C, api_key: String) -> Self { + Self { client, api_key } + } + + fn headers(&self) -> HashMap { + if self.api_key.is_empty() { + HashMap::new() + } else { + let mut headers = HashMap::new(); + headers.insert("TRON-PRO-API-KEY".to_string(), self.api_key.clone()); + headers + } + } + + pub async fn get_transactions_by_address(&self, address: &str, limit: usize) -> Result>, Box> { + let path = &format!("/v1/accounts/{}/transactions?limit={}", address, limit); + Ok(self.client.get_with_headers(path, self.headers()).await?) + } + + pub async fn get_transactions_by_address_trc20(&self, address: &str, limit: usize) -> Result>, Box> { + let path = &format!("/v1/accounts/{}/transactions/trc20?limit={}", address, limit); + Ok(self.client.get_with_headers(path, self.headers()).await?) + } + + pub async fn get_accounts_by_address(&self, address: &str) -> Result>, Box> { + let path = &format!("/v1/accounts/{}", address); + Ok(self.client.get_with_headers(path, self.headers()).await?) + } +} diff --git a/core/crates/gem_tron/src/rpc/trongrid/mapper.rs b/core/crates/gem_tron/src/rpc/trongrid/mapper.rs new file mode 100644 index 0000000000..22ee5eae92 --- /dev/null +++ b/core/crates/gem_tron/src/rpc/trongrid/mapper.rs @@ -0,0 +1,31 @@ +use std::str::FromStr; + +use super::model::TronGridAccount; +use crate::models::{Transaction, TransactionReceiptData}; +use crate::provider::transactions_mapper::map_transaction; +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Chain}; + +pub struct TronGridMapper; + +impl TronGridMapper { + pub fn map_transactions(transactions: Vec, receipts: Vec) -> Vec { + transactions + .into_iter() + .zip(receipts) + .flat_map(|(transaction, receipt)| map_transaction(Chain::Tron, transaction, receipt)) + .collect() + } + + pub fn map_asset_balances(account: TronGridAccount) -> Vec { + account + .trc20 + .into_iter() + .flat_map(|trc20_map| { + trc20_map.into_iter().map(|(contract_address, balance)| { + AssetBalance::new(AssetId::from(Chain::Tron, Some(contract_address)), BigUint::from_str(balance.as_str()).unwrap_or_default()) + }) + }) + .collect() + } +} diff --git a/core/crates/gem_tron/src/rpc/trongrid/mod.rs b/core/crates/gem_tron/src/rpc/trongrid/mod.rs new file mode 100644 index 0000000000..c28e157072 --- /dev/null +++ b/core/crates/gem_tron/src/rpc/trongrid/mod.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod mapper; +pub mod model; diff --git a/core/crates/gem_tron/src/rpc/trongrid/model.rs b/core/crates/gem_tron/src/rpc/trongrid/model.rs new file mode 100644 index 0000000000..9440dbbd68 --- /dev/null +++ b/core/crates/gem_tron/src/rpc/trongrid/model.rs @@ -0,0 +1,19 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Data { + pub data: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronGridAccount { + #[serde(default)] + pub trc20: Vec>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Trc20Transaction { + pub transaction_id: String, +} diff --git a/core/crates/gem_tron/src/signer/chain_signer.rs b/core/crates/gem_tron/src/signer/chain_signer.rs new file mode 100644 index 0000000000..9cbc4b773d --- /dev/null +++ b/core/crates/gem_tron/src/signer/chain_signer.rs @@ -0,0 +1,731 @@ +use primitives::{ChainSigner, SignerError, SignerInput, hex::encode_with_0x}; +use signer::Signer; + +use super::{message::tron_hash_message, transaction}; + +#[derive(Default)] +pub struct TronChainSigner; + +impl ChainSigner for TronChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + transaction::sign_transfer(input, private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + transaction::sign_token_transfer(input, private_key) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + transaction::sign_swap(input, private_key) + } + + fn sign_stake(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + transaction::sign_stake(input, private_key) + } + + fn sign_data(&self, input: &SignerInput, private_key: &[u8]) -> Result { + transaction::sign_data(input, private_key) + } + + fn sign_message(&self, message: &[u8], private_key: &[u8]) -> Result { + let digest = tron_hash_message(message); + let signature = Signer::sign_ethereum_digest(&digest, private_key)?; + Ok(encode_with_0x(&signature)) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use gem_hash::sha2::sha256; + use num_bigint::BigInt; + use primitives::{ + Asset, AssetId, AssetType, Chain, ChainSigner, Delegation, DelegationValidator, GasPriceType, Resource, SignerInput, StakeType, TransactionFee, TransactionInputType, + TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, + WalletConnectionSessionAppMetadata, decode_hex, + }; + use serde_json::{Value, json}; + + use super::TronChainSigner; + + const PRIVATE_KEY: &str = "2d8f68944bdbfbc0769542fba8fc2d2a3de67393334471624364c7006da2aa54"; + const NILE_PRIVATE_KEY: &str = "75065f100e38d3f3b4c5c4235834ba8216de62272a4f03532c44b31a5734360a"; + const SENDER: &str = "TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC"; + const NILE_SENDER: &str = "TWWb9EjUWai17YEVB7FR8hreupYJKG9sMR"; + const RECIPIENT: &str = "THTR75o8xXAgCTQqpiot2AFRAjvW1tSbVV"; + + fn private_key() -> Vec { + hex::decode(PRIVATE_KEY).unwrap() + } + + fn nile_private_key() -> Vec { + hex::decode(NILE_PRIVATE_KEY).unwrap() + } + + fn metadata(stake_data: TronStakeData) -> TransactionLoadMetadata { + TransactionLoadMetadata::Tron { + block_number: 3_111_739, + block_version: 3, + block_timestamp: 1_539_295_479_000, + transaction_tree_root: "64288c2db0641316762a99dbb02ef7c90f968b60f9f2e410835980614332f86d".to_string(), + parent_hash: "00000000002f7b3af4f5f8b9e23a30c530f719f165b742e7358536b280eead2d".to_string(), + witness_address: "415863f6091b8e71766da808b1dd3159790f61de7d".to_string(), + stake_data, + } + } + + fn nile_metadata(stake_data: TronStakeData) -> TransactionLoadMetadata { + TransactionLoadMetadata::Tron { + block_number: 34_395_330, + block_version: 26, + block_timestamp: 1_676_983_541_337, + transaction_tree_root: "9b54db7f84bd19bbad9ff1fccef894c1aade6879450e9e9e2accec751eaa1f52".to_string(), + parent_hash: "00000000020cd4c13a67497a3a433a3105bc5a73a041ee3da98407d5a2a2bf1b".to_string(), + witness_address: "4150d3765e4e670727ebac9d5b598f74b75a3d54a7".to_string(), + stake_data, + } + } + + fn fee(fee: u64, gas_limit: u64) -> TransactionFee { + TransactionFee::new_gas_price_type(GasPriceType::regular(0), BigInt::from(fee), BigInt::from(gas_limit), HashMap::new()) + } + + fn signer_input( + input_type: TransactionInputType, + sender: &str, + destination: &str, + value: &str, + transaction_fee: TransactionFee, + memo: Option<&str>, + metadata: TransactionLoadMetadata, + ) -> SignerInput { + SignerInput::new( + TransactionLoadInput { + input_type, + sender_address: sender.to_string(), + destination_address: destination.to_string(), + value: value.to_string(), + gas_price: GasPriceType::regular(0), + memo: memo.map(str::to_string), + is_max_value: false, + metadata, + }, + transaction_fee, + ) + } + + fn native_input(value: &str, transaction_fee: TransactionFee, memo: Option<&str>) -> SignerInput { + signer_input( + TransactionInputType::Transfer(Asset::from_chain(Chain::Tron)), + SENDER, + RECIPIENT, + value, + transaction_fee, + memo, + metadata(TronStakeData::Votes(vec![])), + ) + } + + fn trc20_asset(contract: &str) -> Asset { + Asset::new(AssetId::from_token(Chain::Tron, contract), "Token".to_string(), "TOKEN".to_string(), 6, AssetType::TRC20) + } + + fn signed_json(output: String) -> Value { + let value: Value = serde_json::from_str(&output).unwrap(); + assert_hash_matches(&value); + value + } + + fn assert_hash_matches(output: &Value) { + let raw_data_hex = output["raw_data_hex"].as_str().unwrap(); + let raw_data = decode_hex(raw_data_hex).unwrap(); + assert_eq!(hex::encode(sha256(&raw_data)), output["txID"].as_str().unwrap()); + } + + fn signature(output: &Value) -> &str { + output["signature"][0].as_str().unwrap() + } + + fn assert_raw_recovery_id(output: &Value) { + let signature = signature(output); + assert_eq!(signature.len(), 130); + assert!(signature.ends_with("00") || signature.ends_with("01")); + } + + fn validator(id: &str) -> DelegationValidator { + DelegationValidator::stake(Chain::Tron, id.to_string(), String::new(), true, 0.0, 0.0) + } + + // Source vector: + // https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Tron/SignerTests.cpp + #[test] + fn sign_transfer_matches_wallet_core() { + let input = native_input("2000000", TransactionFee::default(), None); + let output = signed_json(TronChainSigner.sign_transfer(&input, &private_key()).unwrap()); + + assert_eq!(output["txID"], "dc6f6d9325ee44ab3c00528472be16e1572ab076aa161ccd12515029869d0451"); + assert_eq!( + signature(&output), + "ede769f6df28aefe6a846be169958c155e23e7e5c9621d2e8dce1719b4d952b63e8a8bf9f00e41204ac1bf69b1a663dacdf764367e48e4a5afcd6b055a747fb200" + ); + } + + #[test] + fn sign_transfer_includes_mobile_fee_limit() { + let input = native_input("100", fee(10, 0), None); + let output = signed_json(TronChainSigner.sign_transfer(&input, &private_key()).unwrap()); + + assert_eq!(output["raw_data"]["fee_limit"], 10); + } + + // Source vector: + // https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Tron/SignerTests.cpp + #[test] + fn sign_transfer_with_memo_matches_wallet_core() { + let input = signer_input( + TransactionInputType::Transfer(Asset::from_chain(Chain::Tron)), + "TFnYQCt892UNjn67pjAULTSTkB7YvqsnPp", + "TBUCzgc29vykkvFaEG2mgRtxKvaKe6skwX", + "100000", + TransactionFee::default(), + Some("Test memo"), + TransactionLoadMetadata::Tron { + block_number: 66_725_852, + block_version: 30, + block_timestamp: 1_730_827_017_000, + transaction_tree_root: "a94f115089893f37336baf32dbf6cb7d06adc13cf6bf046d9bc22748bd72e7a6".to_string(), + parent_hash: "0000000003fa27db7d67f93920f64733532412ab6a71eb4089dc48c8ff5e182c".to_string(), + witness_address: "4167e39013be3cdd3814bed152d7439fb5b6791409".to_string(), + stake_data: TronStakeData::Votes(vec![]), + }, + ); + let private_key = hex::decode("7c2108a30f6f69f8dce72a7df897eabadfe9810eee6976b43bdf8c0b0d35337d").unwrap(); + let output = signed_json(TronChainSigner.sign_transfer(&input, &private_key).unwrap()); + + assert_eq!(output["txID"], "20321755964d6ec5bcfc9ebfb15faeb043787ae599fff44442962e12e1c357f1"); + assert_eq!( + signature(&output), + "6fcee79c61f660ec689299f77924f32b5020b4c41593056052ef07d640cc799325103fab130c8691e8a224c96cd0704a698ac356ff789a543c284605668bf38000" + ); + assert_eq!(output["raw_data"]["data"], "54657374206d656d6f"); + } + + #[test] + fn sign_token_transfer_builds_trc20_trigger_contract() { + let input = signer_input( + TransactionInputType::Transfer(trc20_asset(RECIPIENT)), + SENDER, + "TW1dU4L3eNm7Lw8WvieLKEHpXWAussRG9Z", + "1000", + fee(0, 10), + None, + metadata(TronStakeData::Votes(vec![])), + ); + let output = signed_json(TronChainSigner.sign_token_transfer(&input, &private_key()).unwrap()); + let contract = &output["raw_data"]["contract"][0]; + let value = &contract["parameter"]["value"]; + + assert_eq!(contract["type"], "TriggerSmartContract"); + assert_eq!(value["contract_address"], "41521ea197907927725ef36d70f25f850d1659c7c7"); + assert_eq!(value["owner_address"], "415cd0fb0ab3ce40f3051414c604b27756e69e43db"); + assert_eq!( + value["data"], + "a9059cbb000000000000000000000041dbd7c53729b3310e1843083000fa84abad99696100000000000000000000000000000000000000000000000000000000000003e8" + ); + assert_eq!(output["raw_data"]["fee_limit"], 10); + assert_raw_recovery_id(&output); + } + + #[test] + fn sign_token_transfer_uses_gas_limit_as_fee_limit() { + let input = signer_input( + TransactionInputType::Transfer(trc20_asset(RECIPIENT)), + SENDER, + "TW1dU4L3eNm7Lw8WvieLKEHpXWAussRG9Z", + "1000", + fee(10, 20), + None, + metadata(TronStakeData::Votes(vec![])), + ); + let output = signed_json(TronChainSigner.sign_token_transfer(&input, &private_key()).unwrap()); + + assert_eq!(output["raw_data"]["fee_limit"], 20); + } + + #[test] + fn sign_transfer_based_swap_uses_swap_destination() { + let input = signer_input( + TransactionInputType::Swap( + Asset::from_chain(Chain::Tron), + Asset::from_chain(Chain::Tron), + primitives::swap::SwapData { + quote: primitives::swap::SwapQuote { + from_address: SENDER.to_string(), + from_value: "2000000".to_string(), + min_from_value: None, + to_address: "TW1dU4L3eNm7Lw8WvieLKEHpXWAussRG9Z".to_string(), + to_value: "1".to_string(), + provider_data: primitives::swap::SwapProviderData { + provider: primitives::SwapProvider::Okx, + name: "OKX".to_string(), + protocol_name: "okx".to_string(), + }, + slippage_bps: 50, + eta_in_seconds: None, + use_max_amount: None, + }, + data: primitives::swap::SwapQuoteData { + to: "TW1dU4L3eNm7Lw8WvieLKEHpXWAussRG9Z".to_string(), + data_type: primitives::swap::SwapQuoteDataType::Transfer, + value: "2000000".to_string(), + data: String::new(), + memo: None, + approval: None, + gas_limit: None, + }, + }, + ), + SENDER, + RECIPIENT, + "2000000", + TransactionFee::default(), + None, + metadata(TronStakeData::Votes(vec![])), + ); + let output = signed_json(TronChainSigner.sign_swap(&input, &private_key()).unwrap().remove(0)); + + assert_eq!( + output["raw_data"]["contract"][0]["parameter"]["value"]["to_address"], + "41dbd7c53729b3310e1843083000fa84abad996961" + ); + } + + // Source vector: + // https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Tron/SignerTests.cpp + #[test] + fn sign_vote_witness_matches_wallet_core() { + let input = signer_input( + TransactionInputType::Stake(Asset::from_chain(Chain::Tron), StakeType::Stake(validator(RECIPIENT))), + SENDER, + RECIPIENT, + "0", + TransactionFee::default(), + None, + metadata(TronStakeData::Votes(vec![TronVote { + validator: RECIPIENT.to_string(), + count: 3, + }])), + ); + let output = signed_json(TronChainSigner.sign_stake(&input, &private_key()).unwrap().remove(0)); + + assert_eq!(output["txID"], "3f923e9dd9571a66624fafeda27baa3e00aba1709d3fdc5c97c77b81fda18c1f"); + assert_eq!( + signature(&output), + "79ec1073ae1319ef9303a2f5a515876cfd67f8f0e155bdbde1115d391c05358a3c32f148bfafacf07e1619aaed728d9ffbc2c7e4a5046003c7b74feb86fc68e400" + ); + } + + #[test] + fn sign_vote_witness_keeps_multiple_votes() { + let input = signer_input( + TransactionInputType::Stake(Asset::from_chain(Chain::Tron), StakeType::Stake(validator(RECIPIENT))), + SENDER, + RECIPIENT, + "0", + fee(10, 0), + None, + metadata(TronStakeData::Votes(vec![ + TronVote { + validator: "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH".to_string(), + count: 1, + }, + TronVote { + validator: "TCEo1hMAdaJrQmvnGTCcGT2LqrGU4N7Jqf".to_string(), + count: 2, + }, + ])), + ); + let output = signed_json(TronChainSigner.sign_stake(&input, &private_key()).unwrap().remove(0)); + let votes = output["raw_data"]["contract"][0]["parameter"]["value"]["votes"].as_array().unwrap(); + + assert_eq!(votes.len(), 2); + assert_eq!(votes[0]["vote_count"], 1); + assert_eq!(votes[1]["vote_count"], 2); + assert_eq!(output["raw_data"]["fee_limit"], 10); + assert_raw_recovery_id(&output); + } + + #[test] + fn sign_unstake_votes_builds_vote_witness_contract() { + let input = signer_input( + TransactionInputType::Stake(Asset::from_chain(Chain::Tron), StakeType::Unstake(Delegation::mock_tron(RECIPIENT))), + SENDER, + RECIPIENT, + "0", + TransactionFee::default(), + None, + metadata(TronStakeData::Votes(vec![TronVote { + validator: RECIPIENT.to_string(), + count: 2, + }])), + ); + let output = signed_json(TronChainSigner.sign_stake(&input, &private_key()).unwrap().remove(0)); + let contract = &output["raw_data"]["contract"][0]; + let value = &contract["parameter"]["value"]; + let votes = value["votes"].as_array().unwrap(); + + assert_eq!(contract["type"], "VoteWitnessContract"); + assert_eq!(value["owner_address"], "415cd0fb0ab3ce40f3051414c604b27756e69e43db"); + assert_eq!(votes.len(), 1); + assert_eq!(votes[0]["vote_count"], 2); + assert_eq!(value["support"], true); + } + + #[test] + fn sign_freeze_v2_builds_energy_contract() { + let input = signer_input( + TransactionInputType::Stake(Asset::from_chain(Chain::Tron), StakeType::Freeze(Resource::Energy)), + NILE_SENDER, + RECIPIENT, + "10000000", + TransactionFee::default(), + None, + nile_metadata(TronStakeData::Votes(vec![])), + ); + let output = signed_json(TronChainSigner.sign_stake(&input, &nile_private_key()).unwrap().remove(0)); + let value = &output["raw_data"]["contract"][0]["parameter"]["value"]; + + assert_eq!(output["raw_data"]["contract"][0]["type"], "FreezeBalanceV2Contract"); + assert_eq!(value["frozen_balance"], 10_000_000); + assert_eq!(value["resource"], "ENERGY"); + assert_raw_recovery_id(&output); + } + + #[test] + fn sign_unfreeze_v2_builds_contract() { + let input = signer_input( + TransactionInputType::Stake(Asset::from_chain(Chain::Tron), StakeType::Unfreeze(Resource::Energy)), + NILE_SENDER, + RECIPIENT, + "510000000", + TransactionFee::default(), + None, + nile_metadata(TronStakeData::Votes(vec![])), + ); + let output = signed_json(TronChainSigner.sign_stake(&input, &nile_private_key()).unwrap().remove(0)); + let value = &output["raw_data"]["contract"][0]["parameter"]["value"]; + + assert_eq!(output["raw_data"]["contract"][0]["type"], "UnfreezeBalanceV2Contract"); + assert_eq!(value["unfreeze_balance"], 510_000_000); + assert_eq!(value["resource"], "ENERGY"); + } + + // Source vector: + // https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Tron/SignerTests.cpp + #[test] + fn sign_withdraw_rewards_matches_wallet_core() { + let input = signer_input( + TransactionInputType::Stake(Asset::from_chain(Chain::Tron), StakeType::Rewards(vec![])), + SENDER, + RECIPIENT, + "0", + TransactionFee::default(), + None, + metadata(TronStakeData::Votes(vec![])), + ); + let output = signed_json(TronChainSigner.sign_stake(&input, &private_key()).unwrap().remove(0)); + + assert_eq!(output["txID"], "69aaa954dcd61f28a6a73e979addece6e36541522e5b3374b18b4ef9bc3de4cb"); + assert_eq!( + signature(&output), + "cb7d23a5eb23284a25ba6deaa231de0f18d8d103592e3312bff101a4219a3e02167eca24b3f4ce78b34f0c1842b6f7fb8d813f530c4c54342cdedef9f8e1f85100" + ); + } + + #[test] + fn sign_withdraw_expire_unfreeze_builds_contract() { + let input = signer_input( + TransactionInputType::Stake(Asset::from_chain(Chain::Tron), StakeType::Withdraw(Delegation::mock_tron(RECIPIENT))), + NILE_SENDER, + RECIPIENT, + "0", + TransactionFee::default(), + None, + nile_metadata(TronStakeData::Votes(vec![])), + ); + let output = signed_json(TronChainSigner.sign_stake(&input, &nile_private_key()).unwrap().remove(0)); + + assert_eq!(output["raw_data"]["contract"][0]["type"], "WithdrawExpireUnfreezeContract"); + assert_eq!( + output["raw_data"]["contract"][0]["parameter"]["value"]["owner_address"], + "41e151e4937bca41df55a67697724d9a64efcffdd5" + ); + assert_raw_recovery_id(&output); + } + + #[test] + fn sign_unstake_unfreeze_outputs_one_transaction_per_unfreeze() { + let input = signer_input( + TransactionInputType::Stake(Asset::from_chain(Chain::Tron), StakeType::Unstake(Delegation::mock_tron(RECIPIENT))), + SENDER, + RECIPIENT, + "0", + TransactionFee::default(), + None, + metadata(TronStakeData::Unfreeze(vec![ + TronUnfreeze { + resource: Resource::Bandwidth, + amount: 1, + }, + TronUnfreeze { + resource: Resource::Energy, + amount: 2, + }, + ])), + ); + let mut outputs = TronChainSigner.sign_stake(&input, &private_key()).unwrap(); + + assert_eq!(outputs.len(), 2); + assert_eq!(signed_json(outputs.remove(0))["raw_data"]["contract"][0]["parameter"]["value"]["resource"], "BANDWIDTH"); + assert_eq!(signed_json(outputs.remove(0))["raw_data"]["contract"][0]["parameter"]["value"]["resource"], "ENERGY"); + } + + fn raw_transfer_transaction() -> Value { + json!({ + "raw_data": { + "contract": [{ + "parameter": { + "type_url": "type.googleapis.com/protocol.TransferContract", + "value": { + "amount": 2000000u64, + "owner_address": "415cd0fb0ab3ce40f3051414c604b27756e69e43db", + "to_address": "41521ea197907927725ef36d70f25f850d1659c7c7" + } + }, + "type": "TransferContract" + }], + "expiration": 1539331479000u64, + "ref_block_bytes": "7b3b", + "ref_block_hash": "b21ace8d6ac20e7e", + "timestamp": 1539295479000u64 + }, + "raw_data_hex": "0a027b3b2208b21ace8d6ac20e7e40d8abb9bae62c5a67080112630a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412320a15415cd0fb0ab3ce40f3051414c604b27756e69e43db121541521ea197907927725ef36d70f25f850d1659c7c71880897a70d889a4a9e62c", + "txID": "dc6f6d9325ee44ab3c00528472be16e1572ab076aa161ccd12515029869d0451" + }) + } + + fn generic_input(transaction: Value, output_type: TransferDataOutputType) -> SignerInput { + generic_payload(json!({ "transaction": transaction }), output_type) + } + + fn generic_payload(payload: Value, output_type: TransferDataOutputType) -> SignerInput { + let payload = serde_json::to_vec(&payload).unwrap(); + signer_input( + TransactionInputType::Generic( + Asset::from_chain(Chain::Tron), + WalletConnectionSessionAppMetadata { + name: "Test".to_string(), + description: "Test".to_string(), + url: "https://example.com".to_string(), + icon: "https://example.com/icon.png".to_string(), + }, + TransferDataExtra { + data: Some(payload), + output_type, + output_action: TransferDataOutputAction::Sign, + to: SENDER.to_string(), + gas_limit: None, + gas_price: None, + }, + ), + SENDER, + RECIPIENT, + "0", + TransactionFee::default(), + None, + TransactionLoadMetadata::None, + ) + } + + // Source vector: + // https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Tron/SignerTests.cpp + #[test] + fn sign_raw_json_transfer_matches_wallet_core() { + let input = generic_input(raw_transfer_transaction(), TransferDataOutputType::EncodedTransaction); + let output = signed_json(TronChainSigner.sign_data(&input, &private_key()).unwrap()); + + assert_eq!(output["txID"], "dc6f6d9325ee44ab3c00528472be16e1572ab076aa161ccd12515029869d0451"); + assert_eq!( + signature(&output), + "ede769f6df28aefe6a846be169958c155e23e7e5c9621d2e8dce1719b4d952b63e8a8bf9f00e41204ac1bf69b1a663dacdf764367e48e4a5afcd6b055a747fb200" + ); + } + + #[test] + fn sign_raw_json_signature_only_returns_signature() { + let input = generic_input(raw_transfer_transaction(), TransferDataOutputType::Signature); + + assert_eq!( + TronChainSigner.sign_data(&input, &private_key()).unwrap(), + "ede769f6df28aefe6a846be169958c155e23e7e5c9621d2e8dce1719b4d952b63e8a8bf9f00e41204ac1bf69b1a663dacdf764367e48e4a5afcd6b055a747fb200" + ); + } + + #[test] + fn sign_raw_json_without_transaction_id_derives_output_transaction_id() { + let mut transaction = raw_transfer_transaction(); + transaction.as_object_mut().unwrap().remove("txID"); + let input = generic_input(transaction, TransferDataOutputType::EncodedTransaction); + let output = signed_json(TronChainSigner.sign_data(&input, &private_key()).unwrap()); + + assert_eq!(output["txID"], "dc6f6d9325ee44ab3c00528472be16e1572ab076aa161ccd12515029869d0451"); + } + + #[test] + fn sign_raw_json_rejects_missing_raw_data_and_transaction_id() { + let mut transaction = raw_transfer_transaction(); + transaction.as_object_mut().unwrap().remove("raw_data"); + transaction.as_object_mut().unwrap().remove("txID"); + let input = generic_input(transaction, TransferDataOutputType::EncodedTransaction); + + assert_eq!( + TronChainSigner.sign_data(&input, &private_key()).unwrap_err().to_string(), + "Invalid input: Missing raw_data or transaction ID" + ); + } + + #[test] + fn sign_raw_json_wallet_connect_request_preserves_transaction_fields() { + let payload = serde_json::from_str(include_str!("../../../gem_wallet_connect/testdata/tron_send_transaction.json")).unwrap(); + let input = generic_payload(payload, TransferDataOutputType::EncodedTransaction); + let output = signed_json(TronChainSigner.sign_data(&input, &private_key()).unwrap()); + + assert_eq!(output["txID"], "0c195049c6eb9792017e1411604ef691c2a02725603edacb91721831fa85c4b2"); + assert_eq!(output["visible"], false); + assert!(output.get("address").is_none()); + } + + #[test] + fn sign_raw_json_rejects_transaction_id_mismatch() { + let mut transaction = raw_transfer_transaction(); + transaction["txID"] = json!("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + let input = generic_input(transaction, TransferDataOutputType::EncodedTransaction); + + assert_eq!( + TronChainSigner.sign_data(&input, &private_key()).unwrap_err().to_string(), + "Invalid input: transaction ID does not match hash of raw_data_hex" + ); + } + + #[test] + fn sign_raw_json_rejects_raw_data_mismatch() { + let mut transaction = raw_transfer_transaction(); + transaction["raw_data"]["contract"][0]["parameter"]["value"]["amount"] = json!(3_000_000u64); + let input = generic_input(transaction, TransferDataOutputType::EncodedTransaction); + + assert_eq!( + TronChainSigner.sign_data(&input, &private_key()).unwrap_err().to_string(), + "Invalid input: raw_data does not match raw_data_hex" + ); + } + + #[test] + fn sign_raw_json_rejects_unsupported_contract() { + let transaction = json!({ + "raw_data": { + "contract": [{ + "parameter": { + "type_url": "type.googleapis.com/protocol.SetAccountIdContract", + "value": { + "account_id": "74657374", + "owner_address": "415cd0fb0ab3ce40f3051414c604b27756e69e43db" + } + }, + "type": "SetAccountIdContract" + }], + "expiration": 1539331479000u64, + "ref_block_bytes": "7b3b", + "ref_block_hash": "b21ace8d6ac20e7e", + "timestamp": 1539295479000u64 + }, + "raw_data_hex": "0a027b3b2208b21ace8d6ac20e7e40d8abb9bae62c5a56081312520a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5365744163636f756e744964436f6e7472616374121d0a04746573741215415cd0fb0ab3ce40f3051414c604b27756e69e43db70d889a4a9e62c", + "txID": "b3e6d49784acfe62f83f1235bab54613cfb7813dddc8cffc87ced07cafc02fbe" + }); + let input = generic_input(transaction, TransferDataOutputType::EncodedTransaction); + + assert_eq!( + TronChainSigner.sign_data(&input, &private_key()).unwrap_err().to_string(), + "Invalid input: unsupported Tron contract type: SetAccountIdContract" + ); + } + + #[test] + fn sign_raw_json_rejects_known_unsupported_contract() { + let mut transaction = raw_transfer_transaction(); + transaction["raw_data"]["contract"][0]["parameter"]["type_url"] = json!("type.googleapis.com/protocol.DelegateResourceContract"); + transaction["raw_data"]["contract"][0]["type"] = json!("DelegateResourceContract"); + let input = generic_input(transaction, TransferDataOutputType::EncodedTransaction); + + assert_eq!( + TronChainSigner.sign_data(&input, &private_key()).unwrap_err().to_string(), + "Invalid input: unsupported Tron contract type: DelegateResourceContract" + ); + } + + #[test] + fn sign_transfer_rejects_invalid_address() { + let input = signer_input( + TransactionInputType::Transfer(Asset::from_chain(Chain::Tron)), + SENDER, + "INVALID_NOT_BASE58", + "100", + TransactionFee::default(), + None, + metadata(TronStakeData::Votes(vec![])), + ); + + assert_eq!( + TronChainSigner.sign_transfer(&input, &private_key()).unwrap_err().to_string(), + "Invalid input: invalid Tron address: INVALID_NOT_BASE58" + ); + } + + #[test] + fn sign_transfer_rejects_sender_private_key_mismatch() { + let input = native_input("100", TransactionFee::default(), None); + + assert_eq!( + TronChainSigner.sign_transfer(&input, &nile_private_key()).unwrap_err().to_string(), + "Invalid input: Tron sender address does not match private key" + ); + } + + #[test] + fn sign_transfer_rejects_invalid_metadata() { + let input = signer_input( + TransactionInputType::Transfer(Asset::from_chain(Chain::Tron)), + SENDER, + RECIPIENT, + "100", + TransactionFee::default(), + None, + TransactionLoadMetadata::None, + ); + + assert_eq!( + TronChainSigner.sign_transfer(&input, &private_key()).unwrap_err().to_string(), + "Invalid input: Missing tron metadata" + ); + } + + #[test] + fn sign_message_uses_ethereum_recovery_id_offset() { + let signature = TronChainSigner.sign_message(b"This is a message to be signed for Tron", &private_key()).unwrap(); + let bytes = decode_hex(&signature).unwrap(); + + assert!(signature.starts_with("0x")); + assert_eq!(bytes.len(), 65); + assert!(bytes[64] == 27 || bytes[64] == 28); + } +} diff --git a/core/crates/gem_tron/src/signer/message.rs b/core/crates/gem_tron/src/signer/message.rs new file mode 100644 index 0000000000..27d3bbaa0c --- /dev/null +++ b/core/crates/gem_tron/src/signer/message.rs @@ -0,0 +1,18 @@ +use gem_hash::message::hash_personal_message; + +const TRON_MESSAGE_PREFIX: &str = "\x19TRON Signed Message:\n"; + +pub fn tron_hash_message(message: &[u8]) -> [u8; 32] { + hash_personal_message(TRON_MESSAGE_PREFIX, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tron_hash_message() { + let hash = tron_hash_message(b"This is a message to be signed for Tron"); + assert_eq!(hex::encode(hash), "aa8faa6427ddbbcbcdd441df0adec9ddebc1188e0d4cce7a43a3d4bf9496acac"); + } +} diff --git a/core/crates/gem_tron/src/signer/mod.rs b/core/crates/gem_tron/src/signer/mod.rs new file mode 100644 index 0000000000..018aa982f6 --- /dev/null +++ b/core/crates/gem_tron/src/signer/mod.rs @@ -0,0 +1,6 @@ +mod chain_signer; +mod message; +mod transaction; + +pub use chain_signer::TronChainSigner; +pub use message::tron_hash_message; diff --git a/core/crates/gem_tron/src/signer/transaction.rs b/core/crates/gem_tron/src/signer/transaction.rs new file mode 100644 index 0000000000..2c2aa024d5 --- /dev/null +++ b/core/crates/gem_tron/src/signer/transaction.rs @@ -0,0 +1,148 @@ +use gem_hash::sha2::sha256; +use num_bigint::BigUint; +use num_traits::ToPrimitive; +use primitives::{Address as _, SignerError, SignerInput, StakeType, TransactionLoadMetadata, TronStakeData}; +use signer::{SignatureScheme, Signer}; + +use crate::address::TronAddress; +use crate::models::{SignedTransactionJson, TronContract, TronRawData, TronResource, WalletConnectPayload}; + +const ABI_WORD_LEN: usize = 32; +const TRC20_TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb]; + +pub(crate) fn sign_transfer(input: &SignerInput, private_key: &[u8]) -> Result { + sign_native_transfer(input, &input.destination_address, private_key) +} + +pub(crate) fn sign_token_transfer(input: &SignerInput, private_key: &[u8]) -> Result { + sign_token_transfer_to(input, &input.destination_address, private_key) +} + +pub(crate) fn sign_swap(input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let swap = input.input_type.get_swap_data().map_err(SignerError::invalid_input)?; + let from_asset = input.input_type.get_asset(); + + let result = if from_asset.id.is_token() { + sign_token_transfer_to(input, &swap.data.to, private_key)? + } else { + sign_native_transfer(input, &swap.data.to, private_key)? + }; + + Ok(vec![result]) +} + +fn sign_native_transfer(input: &SignerInput, destination: &str, private_key: &[u8]) -> Result { + let owner = validate_sender(input, private_key)?; + let contract = TronContract::Transfer { + owner, + to: TronAddress::parse(destination)?, + amount: input.value_as_u64()?, + }; + let fee_limit = input.fee.fee.to_u64().ok_or_else(|| SignerError::invalid_input("invalid Tron fee"))?; + sign_contract(input, contract, fee_limit, private_key) +} + +fn sign_token_transfer_to(input: &SignerInput, destination: &str, private_key: &[u8]) -> Result { + let owner = validate_sender(input, private_key)?; + let token_id = input.input_type.get_asset().id.get_token_id()?; + let destination = TronAddress::parse(destination)?; + let contract = TronContract::TriggerSmart { + owner, + contract: TronAddress::parse(token_id)?, + data: encode_trc20_transfer(&destination, &input.value)?, + call_value: None, + call_token_value: None, + token_id: None, + }; + let fee_limit = input.fee.gas_limit.to_u64().ok_or_else(|| SignerError::invalid_input("invalid Tron fee limit"))?; + sign_contract(input, contract, fee_limit, private_key) +} + +pub(crate) fn sign_stake(input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let stake_type = input.input_type.get_stake_type().map_err(SignerError::invalid_input)?; + let owner = validate_sender(input, private_key)?; + let fee_limit = input.fee.fee.to_u64().ok_or_else(|| SignerError::invalid_input("invalid Tron fee"))?; + let TransactionLoadMetadata::Tron { stake_data, .. } = &input.metadata else { + return SignerError::invalid_input_err("Missing tron metadata"); + }; + + let contracts = match stake_type { + StakeType::Stake(_) | StakeType::Redelegate(_) => match stake_data { + TronStakeData::Votes(votes) => vec![TronContract::vote_witness(owner, votes)?], + TronStakeData::Unfreeze(_) => return SignerError::invalid_input_err("Expected Tron vote stake data"), + }, + StakeType::Unstake(_) => match stake_data { + TronStakeData::Votes(votes) => vec![TronContract::vote_witness(owner, votes)?], + TronStakeData::Unfreeze(unfreezes) => unfreezes + .iter() + .map(|unfreeze| TronContract::UnfreezeBalanceV2 { + owner, + unfreeze_balance: unfreeze.amount, + resource: TronResource::from(&unfreeze.resource), + }) + .collect(), + }, + StakeType::Rewards(_) => vec![TronContract::WithdrawBalance { owner }], + StakeType::Withdraw(_) => vec![TronContract::WithdrawExpireUnfreeze { owner }], + StakeType::Freeze(resource) => vec![TronContract::FreezeBalanceV2 { + owner, + frozen_balance: input.value_as_u64()?, + resource: TronResource::from(resource), + }], + StakeType::Unfreeze(resource) => vec![TronContract::UnfreezeBalanceV2 { + owner, + unfreeze_balance: input.value_as_u64()?, + resource: TronResource::from(resource), + }], + }; + + contracts.into_iter().map(|contract| sign_contract(input, contract, fee_limit, private_key)).collect() +} + +pub(crate) fn sign_data(input: &SignerInput, private_key: &[u8]) -> Result { + validate_sender(input, private_key)?; + let payload = WalletConnectPayload::parse(input)?; + let transaction_hash = payload.transaction_hash()?; + let signature = sign_raw_hash(&transaction_hash, private_key)?; + payload.into_output(transaction_hash, signature) +} + +fn validate_sender(input: &SignerInput, private_key: &[u8]) -> Result { + let sender = TronAddress::parse(&input.sender_address)?; + if sender != TronAddress::from_private_key(private_key)? { + return SignerError::invalid_input_err("Tron sender address does not match private key"); + } + Ok(sender) +} + +fn sign_contract(input: &SignerInput, contract: TronContract, fee_limit: u64, private_key: &[u8]) -> Result { + let raw_data = TronRawData::from_input(input, contract, fee_limit)?; + let raw_data_bytes = raw_data.encode(); + let transaction_id = sha256(&raw_data_bytes); + let signature = sign_raw_hash(&transaction_id, private_key)?; + + serde_json::to_string(&SignedTransactionJson::new(raw_data.json(), &raw_data_bytes, &transaction_id, signature)).map_err(Into::into) +} + +fn sign_raw_hash(hash: &[u8], private_key: &[u8]) -> Result { + Ok(hex::encode(Signer::sign_digest(SignatureScheme::Secp256k1, hash, private_key)?)) +} + +fn encode_trc20_transfer(destination: &TronAddress, value: &str) -> Result, SignerError> { + let mut data = TRC20_TRANSFER_SELECTOR.to_vec(); + data.extend(pad_left(destination.as_bytes(), ABI_WORD_LEN)?); + data.extend(pad_left( + &value.parse::().map_err(|_| SignerError::invalid_input("invalid TRC20 amount"))?.to_bytes_be(), + ABI_WORD_LEN, + )?); + Ok(data) +} + +fn pad_left(data: &[u8], len: usize) -> Result, SignerError> { + if data.len() > len { + return SignerError::invalid_input_err("value does not fit padded length"); + } + let mut padded = vec![0u8; len - data.len()]; + padded.extend_from_slice(data); + Ok(padded) +} diff --git a/core/crates/gem_tron/testdata/balance_coin.json b/core/crates/gem_tron/testdata/balance_coin.json new file mode 100644 index 0000000000..6a4c398d5d --- /dev/null +++ b/core/crates/gem_tron/testdata/balance_coin.json @@ -0,0 +1,95 @@ +{ + "address": "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb", + "balance": 2928601454, + "votes": [ + { + "vote_address": "TCEo1hMAdaJrQmvnGTCcGT2LqrGU4N7Jqf", + "vote_count": 650 + } + ], + "create_time": 1688690673000, + "latest_opration_time": 1750796031000, + "allowance": 10498200, + "latest_consume_time": 1750796031000, + "latest_consume_free_time": 1730859150000, + "net_window_size": 28800000, + "net_window_optimized": true, + "account_resource": { + "latest_consume_time_for_energy": 1727747913000, + "energy_window_size": 28800000, + "energy_window_optimized": true + }, + "owner_permission": { + "permission_name": "owner", + "threshold": 1, + "keys": [ + { + "address": "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb", + "weight": 1 + } + ] + }, + "active_permission": [ + { + "type": "Active", + "id": 2, + "permission_name": "active", + "threshold": 1, + "operations": "7fff1fc0033ec307000000000000000000000000000000000000000000000000", + "keys": [ + { + "address": "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb", + "weight": 1 + } + ] + } + ], + "frozenV2": [ + { + "amount": 650000000 + }, + { + "type": "ENERGY" + }, + { + "type": "TRON_POWER" + } + ], + "assetV2": [ + { + "key": "1004978", + "value": 800 + }, + { + "key": "1004920", + "value": 8888888 + }, + { + "key": "1005027", + "value": 8888880000 + }, + { + "key": "1005026", + "value": 8888880000 + } + ], + "free_asset_net_usageV2": [ + { + "key": "1004978", + "value": 0 + }, + { + "key": "1004920", + "value": 0 + }, + { + "key": "1005027", + "value": 0 + }, + { + "key": "1005026", + "value": 0 + } + ], + "asset_optimized": true + } \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/balance_token.json b/core/crates/gem_tron/testdata/balance_token.json new file mode 100644 index 0000000000..3773eb570d --- /dev/null +++ b/core/crates/gem_tron/testdata/balance_token.json @@ -0,0 +1,37 @@ +{ + "result": { + "result": true + }, + "energy_used": 4062, + "constant_result": [ + "000000000000000000000000000000000000000000000000000000000821218a" + ], + "energy_penalty": 3127, + "transaction": { + "ret": [ + {} + ], + "visible": false, + "txID": "68c81e013302489f11aa394723deabbe9b46ef87ce6541afb49dafeef3c717e7", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "70a082310000000000000000000000412e1d447fa4169390cf5f5b3d12d380decfbfe20f", + "owner_address": "412e1d447fa4169390cf5f5b3d12d380decfbfe20f", + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "5dc5", + "ref_block_hash": "c1940d63a5f1d43e", + "expiration": 1755650877000, + "timestamp": 1755650817582 + }, + "raw_data_hex": "0a025dc52208c1940d63a5f1d43e40c8ccc9a78c335a8e01081f1289010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412540a15412e1d447fa4169390cf5f5b3d12d380decfbfe20f121541a614f803b6fd780986a42c78ec9c7f77e6ded13c222470a082310000000000000000000000412e1d447fa4169390cf5f5b3d12d380decfbfe20f70aefcc5a78c33" + } + } \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/block_mixed_contract_types.json b/core/crates/gem_tron/testdata/block_mixed_contract_types.json new file mode 100644 index 0000000000..dbbb3c9608 --- /dev/null +++ b/core/crates/gem_tron/testdata/block_mixed_contract_types.json @@ -0,0 +1,126 @@ +{ + "block_header": { + "raw_data": { + "number": 82834891 + } + }, + "transactions": [ + { + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "txID": "delegate_resource_tx", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "balance": 14156000000, + "resource": "ENERGY", + "receiver_address": "410363a3e0da6d956704d84623864afb60143bc879", + "owner_address": "41f1e4436fe066b6e57098ffc66b16bd91a23fcd7b" + }, + "type_url": "type.googleapis.com/protocol.DelegateResourceContract" + }, + "type": "DelegateResourceContract" + } + ] + } + }, + { + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "txID": "undelegate_resource_tx", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "balance": 7024000000, + "resource": "ENERGY", + "receiver_address": "417c122660e47053bbb4318cd67538f90b84f3053a", + "owner_address": "41cf0cc9f4a76259aba39dc683849c22f5381ca05f" + }, + "type_url": "type.googleapis.com/protocol.UnDelegateResourceContract" + }, + "type": "UnDelegateResourceContract" + } + ] + } + }, + { + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "txID": "transfer_asset_tx", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 100, + "asset_name": "1002000", + "owner_address": "41ddb0132599837a32a0ad2c32daa1a8652aaa37e9", + "to_address": "41d8976c02cc751cb514ec2ba58ba975dac9a7b034" + }, + "type_url": "type.googleapis.com/protocol.TransferAssetContract" + }, + "type": "TransferAssetContract" + } + ] + } + }, + { + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "txID": "future_unknown_contract_tx", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "owner_address": "41ddb0132599837a32a0ad2c32daa1a8652aaa37e9" + }, + "type_url": "type.googleapis.com/protocol.FutureUnknownContract" + }, + "type": "FutureUnknownContract" + } + ] + } + }, + { + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "txID": "10f1e5b04c0dd39f14d4b5ca270899b36ae9c52ac1b9b64b76360c7373cc0893", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb000000000000000000000041d8976c02cc751cb514ec2ba58ba975dac9a7b034000000000000000000000000000000000000000000000000000000000ed77040", + "owner_address": "41ddb0132599837a32a0ad2c32daa1a8652aaa37e9", + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "fee_limit": 30000000 + } + } + ] +} diff --git a/core/crates/gem_tron/testdata/block_mixed_contract_types_receipts.json b/core/crates/gem_tron/testdata/block_mixed_contract_types_receipts.json new file mode 100644 index 0000000000..2cdd91424b --- /dev/null +++ b/core/crates/gem_tron/testdata/block_mixed_contract_types_receipts.json @@ -0,0 +1,61 @@ +[ + { + "blockNumber": 82834891, + "blockTimeStamp": 1779173205000, + "receipt": { + "result": "SUCCESS" + }, + "id": "delegate_resource_tx" + }, + { + "blockNumber": 82834891, + "blockTimeStamp": 1779173205000, + "receipt": { + "result": "SUCCESS" + }, + "id": "undelegate_resource_tx" + }, + { + "blockNumber": 82834891, + "blockTimeStamp": 1779173205000, + "receipt": { + "result": "SUCCESS" + }, + "id": "transfer_asset_tx" + }, + { + "blockNumber": 82834891, + "blockTimeStamp": 1779173205000, + "receipt": { + "result": "SUCCESS" + }, + "id": "future_unknown_contract_tx" + }, + { + "id": "10f1e5b04c0dd39f14d4b5ca270899b36ae9c52ac1b9b64b76360c7373cc0893", + "blockNumber": 82834891, + "blockTimeStamp": 1779173205000, + "contractResult": [ + "0000000000000000000000000000000000000000000000000000000000000000" + ], + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "receipt": { + "energy_usage": 64285, + "energy_usage_total": 64285, + "net_usage": 345, + "result": "SUCCESS", + "energy_penalty_total": 49635 + }, + "log": [ + { + "address": "a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "000000000000000000000000ddb0132599837a32a0ad2c32daa1a8652aaa37e9", + "000000000000000000000000d8976c02cc751cb514ec2ba58ba975dac9a7b034" + ], + "data": "000000000000000000000000000000000000000000000000000000000ed77040" + } + ] + } +] diff --git a/core/crates/gem_tron/testdata/transaction_broadcast_error.json b/core/crates/gem_tron/testdata/transaction_broadcast_error.json new file mode 100644 index 0000000000..955351f54a --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_broadcast_error.json @@ -0,0 +1,5 @@ +{ + "code": "CONTRACT_VALIDATE_ERROR", + "txid": "9e4812f9fa672213c4f96cb723d5e33c23d8e44a3729ee7f85bcad5868e8a139", + "message": "436f6e74726163742076616c6964617465206572726f72203a2043616e6e6f74207472616e736665722054525820746f20796f757273656c662e" + } \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/transaction_broadcast_success.json b/core/crates/gem_tron/testdata/transaction_broadcast_success.json new file mode 100644 index 0000000000..315f4b78b5 --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_broadcast_success.json @@ -0,0 +1,4 @@ +{ + "result": true, + "txid": "7f60ccd0594b5c3e0264cca9a6e6e64cb96ee66ce3a796b4356cb8ccc548f62b" + } \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/transaction_coin_transfer.json b/core/crates/gem_tron/testdata/transaction_coin_transfer.json new file mode 100644 index 0000000000..05306097ef --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_coin_transfer.json @@ -0,0 +1,31 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "signature": [ + "f61700f8897a9611634370f46d381b2a063a283f3c7f48e87442e4cc017335fc636b74f8d837c472480a049d72ebca560e8e925408c017d176db17de2befa10e01" + ], + "txID": "5a9935a1b7be0150a511111582bbfed62ddb873333b3986bd712e6105fe90ad5", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 25000000, + "owner_address": "412e1d447fa4169390cf5f5b3d12d380decfbfe20f", + "to_address": "413d9324021d2d2da7805c8e599b00da23bfa2ee2d" + }, + "type_url": "type.googleapis.com/protocol.TransferContract" + }, + "type": "TransferContract" + } + ], + "ref_block_bytes": "316c", + "ref_block_hash": "6ccd1928496fbc63", + "expiration": 1758012717000, + "timestamp": 1757976717000 + }, + "raw_data_hex": "0a02316c22086ccd1928496fbc6340c8efe48d95335a68080112640a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412330a15412e1d447fa4169390cf5f5b3d12d380decfbfe20f1215413d9324021d2d2da7805c8e599b00da23bfa2ee2d18c0f0f50b70c8cdcffc9433" +} \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/transaction_coin_transfer_receipt.json b/core/crates/gem_tron/testdata/transaction_coin_transfer_receipt.json new file mode 100644 index 0000000000..21e40be6c3 --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_coin_transfer_receipt.json @@ -0,0 +1,12 @@ +{ + "id": "5a9935a1b7be0150a511111582bbfed62ddb873333b3986bd712e6105fe90ad5", + "fee": 1100000, + "blockNumber": 75772270, + "blockTimeStamp": 1757976723000, + "contractResult": [ + "" + ], + "receipt": { + "net_fee": 100000 + } +} diff --git a/core/crates/gem_tron/testdata/transaction_freeze.json b/core/crates/gem_tron/testdata/transaction_freeze.json new file mode 100644 index 0000000000..2b2f6accb8 --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_freeze.json @@ -0,0 +1,30 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "signature": [ + "10a8d39982ec6c1d8bd3003f1fb6e7646e2a7239641319ee5a9bb942ad9419871db911efd9eac43c758680206ccbd4afcd16e2ae0f85f5976985762bcc68a8e701" + ], + "txID": "d2a087872ba6b50d802e90a33e7bddaf179a92500652416348cce97558698298", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "frozen_balance": 100000000, + "owner_address": "412e1d447fa4169390cf5f5b3d12d380decfbfe20f" + }, + "type_url": "type.googleapis.com/protocol.FreezeBalanceV2Contract" + }, + "type": "FreezeBalanceV2Contract" + } + ], + "ref_block_bytes": "4f97", + "ref_block_hash": "7e1c269b08f004b1", + "expiration": 1758625896000, + "timestamp": 1758589896000 + }, + "raw_data_hex": "0a024f9722087e1c269b08f004b140c0ac96b297335a58083612540a34747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e467265657a6542616c616e63655632436f6e7472616374121c0a15412e1d447fa4169390cf5f5b3d12d380decfbfe20f1080c2d72f70c08a81a19733" +} \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/transaction_freeze_energy.json b/core/crates/gem_tron/testdata/transaction_freeze_energy.json new file mode 100644 index 0000000000..0f7cab96a6 --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_freeze_energy.json @@ -0,0 +1,31 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "signature": [ + "46c9ccf44a02419b722517f1776fbcc9a931958999a96de5e62e9768fd1461402755aa0a16e6b959284e6c35fdc4bde977f1b92e78a09d3ee56e0bdbcb8bdd0201" + ], + "txID": "3516238cd9d4e7c079beb8a7955f7287c3e394622900dae42e06a91dd71975c5", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "resource": "ENERGY", + "frozen_balance": 10000000, + "owner_address": "412e1d447fa4169390cf5f5b3d12d380decfbfe20f" + }, + "type_url": "type.googleapis.com/protocol.FreezeBalanceV2Contract" + }, + "type": "FreezeBalanceV2Contract" + } + ], + "ref_block_bytes": "4a2a", + "ref_block_hash": "dec35f1e70b1ca6f", + "expiration": 1760588376000, + "timestamp": 1760552376000 + }, + "raw_data_hex": "0a024a2a2208dec35f1e70b1ca6f40c0cffad99e335a5a083612560a34747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e467265657a6542616c616e63655632436f6e7472616374121e0a15412e1d447fa4169390cf5f5b3d12d380decfbfe20f1080ade204180170c0ade5c89e33" +} \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/transaction_stake.json b/core/crates/gem_tron/testdata/transaction_stake.json new file mode 100644 index 0000000000..a4f53aa32b --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_stake.json @@ -0,0 +1,36 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "signature": [ + "2b7379d3e3d2611e6380411f1fb352c947202427c124221956621758c39ea12c226da1f7582fd73ce948f3101abda31cb2dc188e605a528fabc061e70035cddf00" + ], + "txID": "13e1c94e80557043ccb7946c6ef57789955c30eec6c76061bba69ffc8e51601d", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "owner_address": "412e1d447fa4169390cf5f5b3d12d380decfbfe20f", + "votes": [ + { + "vote_address": "4162398d516b555ac64af24416e05c199c01823048", + "vote_count": 2125 + } + ], + "support": true + }, + "type_url": "type.googleapis.com/protocol.VoteWitnessContract" + }, + "type": "VoteWitnessContract" + } + ], + "ref_block_bytes": "75b4", + "ref_block_hash": "f5056a839ac36ca7", + "expiration": 1758261849000, + "timestamp": 1758225849000 + }, + "raw_data_hex": "0a0275b42208f5056a839ac36ca740a8d7ca8496335a6d080412690a30747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e566f74655769746e657373436f6e747261637412350a15412e1d447fa4169390cf5f5b3d12d380decfbfe20f121a0a154162398d516b555ac64af24416e05c199c0182304810cd10180170a8b5b5f39533" +} \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/transaction_thorchain_swap.json b/core/crates/gem_tron/testdata/transaction_thorchain_swap.json new file mode 100644 index 0000000000..61aa8db00c --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_thorchain_swap.json @@ -0,0 +1,32 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "signature": [ + "94308216161b73db9d495731069fc28f2f834d4f02457a18a127b9bfb5e0ae0d185ecc7d69b8d89ddd4b0c3b663a4fbe26ad08cd057909f178e7639ea79fc42600" + ], + "txID": "5977852dfc1e2009a54436efcd896153d459e4ea67a50e931a8d145a58ecee72", + "raw_data": { + "data": "3d3a54524f4e2e555344543a544e4177643157466537474854786f764755394d6554366d69334a344b415a4d76503a302f312f303a67313a3530", + "contract": [ + { + "parameter": { + "value": { + "amount": 200000000, + "owner_address": "4185d9a98816dd5ff04e9575366478e250c7a714b0", + "to_address": "41ae7568fa877622e05ffca36fd451eeca923905ac" + }, + "type_url": "type.googleapis.com/protocol.TransferContract" + }, + "type": "TransferContract" + } + ], + "ref_block_bytes": "3e59", + "ref_block_hash": "83603e1cd8354058", + "expiration": 1771987038000, + "timestamp": 1771951038000 + }, + "raw_data_hex": "0a023e59220883603e1cd835405840b0dea195c933523a3d3a54524f4e2e555344543a544e4177643157466537474854786f764755394d6554366d69334a344b415a4d76503a302f312f303a67313a35305a68080112640a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412330a154185d9a98816dd5ff04e9575366478e250c7a714b0121541ae7568fa877622e05ffca36fd451eeca923905ac188084af5f70b0bc8c84c933" +} diff --git a/core/crates/gem_tron/testdata/transaction_token_transfer.json b/core/crates/gem_tron/testdata/transaction_token_transfer.json new file mode 100644 index 0000000000..e0a925ebe5 --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_token_transfer.json @@ -0,0 +1,32 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "signature": [ + "345226c80d480d000ee58f64e98a8d6e3e6a1f666ef2c371bb4198ba03c2487f27ae925f5987f127190c4cc2258ef1e2067641c77974b72f72f369f1a43add5e01" + ], + "txID": "0b9e01dde099e590564200cbc9f348bb289d6751be8c7a30f70e58355880c9da", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000416e2cf2878020b966786f01ab45ea1fcef688009200000000000000000000000000000000000000000000000000000000017d7840", + "owner_address": "412e1d447fa4169390cf5f5b3d12d380decfbfe20f", + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "7fee", + "ref_block_hash": "04d529ad06ea062c", + "expiration": 1727783910000, + "fee_limit": 32831820, + "timestamp": 1727747910000 + }, + "raw_data_hex": "0a027fee220804d529ad06ea062c40f0f4c8bfa4325aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a15412e1d447fa4169390cf5f5b3d12d380decfbfe20f121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244a9059cbb0000000000000000000000416e2cf2878020b966786f01ab45ea1fcef688009200000000000000000000000000000000000000000000000000000000017d784070f0d2b3aea4329001ccf2d30f" +} \ No newline at end of file diff --git a/core/crates/gem_tron/testdata/transaction_unfreeze.json b/core/crates/gem_tron/testdata/transaction_unfreeze.json new file mode 100644 index 0000000000..88d6c382cb --- /dev/null +++ b/core/crates/gem_tron/testdata/transaction_unfreeze.json @@ -0,0 +1,30 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS" + } + ], + "signature": [ + "5075936e4a22f143bcb9068880690df1da33b1da4e61c576ded47831fadeb95a3aaeff96067272eb655f9f20c5c390c530ecacaf5960168224c7db774119352b00" + ], + "txID": "b8440558a1c1563a6245469a6f2b7243f5dc9c7aa78e4225fd9877cac64f7ac5", + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "owner_address": "412e1d447fa4169390cf5f5b3d12d380decfbfe20f", + "unfreeze_balance": 100000000 + }, + "type_url": "type.googleapis.com/protocol.UnfreezeBalanceV2Contract" + }, + "type": "UnfreezeBalanceV2Contract" + } + ], + "ref_block_bytes": "58d1", + "ref_block_hash": "4ee4a87f499c234d", + "expiration": 1758632982000, + "timestamp": 1758596982000 + }, + "raw_data_hex": "0a0258d122084ee4a87f499c234d40f0ebc6b597335a5a083712560a36747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e556e667265657a6542616c616e63655632436f6e7472616374121c0a15412e1d447fa4169390cf5f5b3d12d380decfbfe20f1080c2d72f70f0c9b1a49733" +} \ No newline at end of file diff --git a/core/crates/gem_wallet_connect/Cargo.toml b/core/crates/gem_wallet_connect/Cargo.toml new file mode 100644 index 0000000000..498bc33313 --- /dev/null +++ b/core/crates/gem_wallet_connect/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gem_wallet_connect" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +primitives = { path = "../primitives" } +gem_evm = { path = "../gem_evm" } +gem_ton = { path = "../gem_ton", features = ["signer"] } + +hex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } +gem_evm = { path = "../gem_evm", features = ["testkit"] } diff --git a/core/crates/gem_wallet_connect/src/actions.rs b/core/crates/gem_wallet_connect/src/actions.rs new file mode 100644 index 0000000000..bf5b472502 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/actions.rs @@ -0,0 +1,131 @@ +use crate::sign_type::SignDigestType; +use primitives::{Chain, TransferDataOutputType, WCEthereumTransaction}; + +#[derive(Debug, Clone, PartialEq)] +pub enum WalletConnectAction { + SignMessage { + chain: Chain, + sign_type: SignDigestType, + data: String, + }, + SignTransaction { + chain: Chain, + transaction_type: WalletConnectTransactionType, + data: String, + }, + SignAllTransactions { + chain: Chain, + transaction_type: WalletConnectTransactionType, + transactions: Vec, + }, + SendTransaction { + chain: Chain, + transaction_type: WalletConnectTransactionType, + data: String, + }, + ChainOperation { + operation: WalletConnectChainOperation, + }, + Unsupported { + method: String, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum WalletConnectTransactionType { + Ethereum, + Solana { output_type: TransferDataOutputType }, + Sui { output_type: TransferDataOutputType }, + Ton { output_type: TransferDataOutputType }, + Tron { output_type: TransferDataOutputType }, +} + +impl WalletConnectTransactionType { + pub fn get_output_type(&self) -> Option { + match self { + Self::Ethereum => None, + Self::Solana { output_type } | Self::Sui { output_type } | Self::Ton { output_type } | Self::Tron { output_type } => Some(output_type.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum WalletConnectChainOperation { + AddChain, + SwitchChain { chain: Chain }, + GetChainId, +} + +#[derive(Debug, Clone)] +pub struct WCEthereumTransactionData { + pub chain_id: Option, + pub from: String, + pub to: String, + pub value: Option, + pub gas: Option, + pub gas_limit: Option, + pub gas_price: Option, + pub max_fee_per_gas: Option, + pub max_priority_fee_per_gas: Option, + pub nonce: Option, + pub data: Option, +} + +#[derive(Debug, Clone)] +pub struct WCSolanaTransactionData { + pub transaction: String, +} + +#[derive(Debug, Clone)] +pub struct WCSuiTransactionData { + pub transaction: String, + pub wallet_address: String, +} + +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] +pub enum WalletConnectTransaction { + Ethereum { + data: WCEthereumTransactionData, + }, + Solana { + data: WCSolanaTransactionData, + output_type: TransferDataOutputType, + }, + Sui { + data: WCSuiTransactionData, + output_type: TransferDataOutputType, + }, + Ton { + messages: String, + output_type: TransferDataOutputType, + }, + Tron { + data: String, + output_type: TransferDataOutputType, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum WalletConnectResponseType { + String { value: String }, + Object { json: String }, +} + +impl From for WCEthereumTransactionData { + fn from(tx: WCEthereumTransaction) -> Self { + Self { + chain_id: tx.chain_id, + from: tx.from, + to: tx.to, + value: tx.value, + gas: tx.gas, + gas_limit: tx.gas_limit, + gas_price: tx.gas_price, + max_fee_per_gas: tx.max_fee_per_gas, + max_priority_fee_per_gas: tx.max_priority_fee_per_gas, + nonce: tx.nonce, + data: tx.data, + } + } +} diff --git a/core/crates/gem_wallet_connect/src/decode.rs b/core/crates/gem_wallet_connect/src/decode.rs new file mode 100644 index 0000000000..91d2d74ca2 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/decode.rs @@ -0,0 +1,72 @@ +use gem_evm::siwe::SiweMessage; +use hex::FromHex; +use primitives::Chain; + +use crate::sign_type::{SignDigestType, SignMessage}; + +pub fn decode_sign_message(chain: Chain, sign_type: SignDigestType, data: String) -> SignMessage { + let mut utf8_value = None; + let message_data = if let Some(stripped) = data.strip_prefix("0x") { + Vec::from_hex(stripped).unwrap_or_else(|_| data.as_bytes().to_vec()) + } else { + utf8_value = Some(data.clone()); + data.into_bytes() + }; + + let raw_text = utf8_value.or_else(|| String::from_utf8(message_data.clone()).ok()).unwrap_or_default(); + + if sign_type == SignDigestType::Eip191 + && let Some(siwe_message) = decode_siwe_message(chain, &raw_text, &message_data) + { + return siwe_message; + } + + SignMessage { + chain, + sign_type, + data: message_data, + } +} + +fn decode_siwe_message(chain: Chain, raw_text: &str, message_data: &[u8]) -> Option { + let message = SiweMessage::try_parse(raw_text)?; + message.validate(chain).ok()?; + + Some(SignMessage { + chain, + sign_type: SignDigestType::Siwe, + data: message_data.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_evm::testkit::siwe_mock::mock_siwe_message; + + #[test] + fn test_decode_sign_message_detects_siwe() { + let message = mock_siwe_message("login.xyz", 1); + let decoded = decode_sign_message(Chain::Ethereum, SignDigestType::Eip191, message.clone()); + + assert_eq!(decoded.sign_type, SignDigestType::Siwe); + assert_eq!(decoded.data, message.into_bytes()); + } + + #[test] + fn test_decode_sign_message_preserves_non_siwe() { + let message = "Hello world".to_string(); + let decoded = decode_sign_message(Chain::Ethereum, SignDigestType::Eip191, message.clone()); + + assert_eq!(decoded.sign_type, SignDigestType::Eip191); + assert_eq!(decoded.data, message.into_bytes()); + } + + #[test] + fn test_decode_sign_message_siwe_chain_mismatch() { + let message = mock_siwe_message("login.xyz", 1); + let decoded = decode_sign_message(Chain::Polygon, SignDigestType::Eip191, message); + + assert_eq!(decoded.sign_type, SignDigestType::Eip191); + } +} diff --git a/core/crates/gem_wallet_connect/src/lib.rs b/core/crates/gem_wallet_connect/src/lib.rs new file mode 100644 index 0000000000..afb5fd367c --- /dev/null +++ b/core/crates/gem_wallet_connect/src/lib.rs @@ -0,0 +1,17 @@ +pub mod actions; +pub mod decode; +pub mod request_handler; +pub mod response_handler; +pub mod session; +pub mod sign_type; +pub mod validator; +pub mod verifier; + +pub use actions::*; +pub use decode::decode_sign_message; +pub use request_handler::WalletConnectRequestHandler; +pub use response_handler::WalletConnectResponseHandler; +pub use session::config_session_properties; +pub use sign_type::SignDigestType; +pub use validator::{SignMessageValidation, validate_send_transaction, validate_sign_message}; +pub use verifier::WalletConnectVerifier; diff --git a/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs b/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs new file mode 100644 index 0000000000..ed99a4917c --- /dev/null +++ b/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs @@ -0,0 +1,169 @@ +use crate::actions::{WalletConnectAction, WalletConnectTransactionType}; +use crate::sign_type::SignDigestType; +use primitives::{Chain, ValueAccess}; +use serde_json::Value; + +pub struct EthereumRequestHandler; + +impl EthereumRequestHandler { + pub fn parse_sign_message(chain: Chain, params: Value, _domain: &str) -> Result { + let data = params.at(0)?.string()?.to_string(); + + Ok(WalletConnectAction::SignMessage { + chain, + sign_type: SignDigestType::Eip191, + data, + }) + } + + pub fn parse_sign_typed_data(chain: Chain, params: Value) -> Result { + let typed_data = params.at(1)?; + let data = if let Some(s) = typed_data.as_str() { + s.to_string() + } else { + serde_json::to_string(typed_data).map_err(|e| format!("Failed to serialize typed data: {}", e))? + }; + + let expected_chain_id = chain + .network_id() + .parse::() + .map_err(|_| format!("Chain {} does not have a numeric network ID", chain))?; + gem_evm::eip712::validate_eip712_chain_id(&data, expected_chain_id)?; + + Ok(WalletConnectAction::SignMessage { + chain, + sign_type: SignDigestType::Eip712, + data, + }) + } + + pub fn parse_sign_transaction(chain: Chain, params: Value) -> Result { + let transaction = params.at(0)?; + let data = serde_json::to_string(transaction).map_err(|e| format!("Failed to serialize transaction: {}", e))?; + + Ok(WalletConnectAction::SignTransaction { + chain, + transaction_type: WalletConnectTransactionType::Ethereum, + data, + }) + } + + pub fn parse_send_transaction(chain: Chain, params: Value) -> Result { + let transaction = params.at(0)?; + let data = serde_json::to_string(transaction).map_err(|e| format!("Failed to serialize transaction: {}", e))?; + + Ok(WalletConnectAction::SendTransaction { + chain, + transaction_type: WalletConnectTransactionType::Ethereum, + data, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_evm::testkit::eip712_mock::mock_eip712_json; + + fn eip712_params(chain_id: u64) -> Value { + let eip712_json = mock_eip712_json(chain_id); + serde_json::json!(["0x123", eip712_json]) + } + + fn eip712_params_object(chain_id: u64) -> Value { + let eip712_value: Value = serde_json::from_str(&mock_eip712_json(chain_id)).unwrap(); + serde_json::json!(["0x123", eip712_value]) + } + + fn eip712_params_without_domain_chain_id() -> Value { + serde_json::json!(["0x123", include_str!("../../../gem_evm/testdata/ens_upload_avatar.json")]) + } + + #[test] + fn test_parse_personal_sign() { + let params = serde_json::from_str(r#"["0x48656c6c6f"]"#).unwrap(); + let action = EthereumRequestHandler::parse_sign_message(Chain::Ethereum, params, "example.com").unwrap(); + assert_eq!( + action, + WalletConnectAction::SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data: "0x48656c6c6f".to_string(), + } + ); + } + + #[test] + fn test_parse_sign_typed_data_matching_chain() { + let result = EthereumRequestHandler::parse_sign_typed_data(Chain::Ethereum, eip712_params(1)); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_sign_typed_data_chain_id_mismatch_polygon_on_ethereum() { + let result = EthereumRequestHandler::parse_sign_typed_data(Chain::Ethereum, eip712_params(137)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Chain ID mismatch")); + } + + #[test] + fn test_parse_sign_typed_data_chain_id_mismatch_ethereum_on_polygon() { + let result = EthereumRequestHandler::parse_sign_typed_data(Chain::Polygon, eip712_params(1)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Chain ID mismatch")); + } + + #[test] + fn test_parse_sign_typed_data_polygon_matching() { + assert!(EthereumRequestHandler::parse_sign_typed_data(Chain::Polygon, eip712_params(137)).is_ok()); + } + + #[test] + fn test_parse_sign_typed_data_bsc_matching() { + assert!(EthereumRequestHandler::parse_sign_typed_data(Chain::SmartChain, eip712_params(56)).is_ok()); + } + + #[test] + fn test_parse_sign_typed_data_arbitrum_matching() { + assert!(EthereumRequestHandler::parse_sign_typed_data(Chain::Arbitrum, eip712_params(42161)).is_ok()); + } + + #[test] + fn test_parse_sign_typed_data_object_params() { + assert!(EthereumRequestHandler::parse_sign_typed_data(Chain::Ethereum, eip712_params_object(1)).is_ok()); + } + + #[test] + fn test_parse_sign_typed_data_object_params_chain_mismatch() { + let result = EthereumRequestHandler::parse_sign_typed_data(Chain::Ethereum, eip712_params_object(137)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Chain ID mismatch")); + } + + #[test] + fn test_parse_sign_typed_data_without_domain_chain_id() { + let result = EthereumRequestHandler::parse_sign_typed_data(Chain::Ethereum, eip712_params_without_domain_chain_id()); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_sign_typed_data_eip712_domain_chain_id_without_schema_rejects() { + let params = serde_json::json!(["0x123", include_str!("../../../gem_evm/testdata/eip712_domain_chain_id_without_schema_field.json")]); + let result = EthereumRequestHandler::parse_sign_typed_data(Chain::Ethereum, params); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("chainId")); + } + + #[test] + fn test_parse_send_transaction() { + let params = serde_json::from_str(r#"[{"to":"0x123","value":"0x0"}]"#).unwrap(); + assert_eq!( + EthereumRequestHandler::parse_send_transaction(Chain::Ethereum, params).unwrap(), + WalletConnectAction::SendTransaction { + chain: Chain::Ethereum, + transaction_type: WalletConnectTransactionType::Ethereum, + data: r#"{"to":"0x123","value":"0x0"}"#.to_string(), + } + ); + } +} diff --git a/core/crates/gem_wallet_connect/src/request_handler/mod.rs b/core/crates/gem_wallet_connect/src/request_handler/mod.rs new file mode 100644 index 0000000000..ea5ac6f7c7 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/request_handler/mod.rs @@ -0,0 +1,270 @@ +mod ethereum; +mod solana; +mod sui; +mod ton; +mod tron; + +use crate::actions::{WCSolanaTransactionData, WCSuiTransactionData, WalletConnectAction, WalletConnectChainOperation, WalletConnectTransaction, WalletConnectTransactionType}; +use ethereum::EthereumRequestHandler; +use primitives::{Chain, ValueAccess, WCEthereumTransaction, WalletConnectCAIP2, WalletConnectRequest, WalletConnectionMethods, hex}; +use serde_json::Value; +use solana::SolanaRequestHandler; +use sui::SuiRequestHandler; +use ton::TonRequestHandler; +use tron::TronRequestHandler; + +pub struct WalletConnectRequestHandler; + +impl WalletConnectRequestHandler { + pub fn parse_request(request: WalletConnectRequest) -> Result { + let method = match serde_json::from_value::(serde_json::Value::String(request.method.clone())) { + Ok(m) => m, + Err(_) => return Ok(WalletConnectAction::Unsupported { method: request.method }), + }; + let params = serde_json::from_str::(&request.params).map_err(|e| format!("Failed to parse params: {}", e))?; + let params = match params { + Value::String(raw_json) => serde_json::from_str::(&raw_json).unwrap_or(Value::String(raw_json)), + value => value, + }; + + let domain = &request.domain; + + match method { + WalletConnectionMethods::PersonalSign => { + let chain = Self::resolve_chain(request.chain_id)?; + EthereumRequestHandler::parse_sign_message(chain, params, domain) + } + WalletConnectionMethods::EthSignTypedData | WalletConnectionMethods::EthSignTypedDataV4 => { + let chain = Self::resolve_chain(request.chain_id)?; + EthereumRequestHandler::parse_sign_typed_data(chain, params) + } + WalletConnectionMethods::EthSignTransaction => { + let chain = Self::resolve_chain(request.chain_id)?; + EthereumRequestHandler::parse_sign_transaction(chain, params) + } + WalletConnectionMethods::EthSendTransaction => { + let chain = Self::resolve_chain(request.chain_id)?; + EthereumRequestHandler::parse_send_transaction(chain, params) + } + WalletConnectionMethods::EthSendRawTransaction => Err("Method not supported".to_string()), + WalletConnectionMethods::EthChainId => Ok(WalletConnectAction::ChainOperation { + operation: WalletConnectChainOperation::GetChainId, + }), + WalletConnectionMethods::WalletAddEthereumChain => Ok(WalletConnectAction::ChainOperation { + operation: WalletConnectChainOperation::AddChain, + }), + WalletConnectionMethods::WalletSwitchEthereumChain => { + let chain = Self::parse_switch_chain_id(¶ms)?; + Ok(WalletConnectAction::ChainOperation { + operation: WalletConnectChainOperation::SwitchChain { chain }, + }) + } + WalletConnectionMethods::SolanaSignMessage => SolanaRequestHandler::parse_sign_message(Chain::Solana, params, domain), + WalletConnectionMethods::SolanaSignTransaction => SolanaRequestHandler::parse_sign_transaction(Chain::Solana, params), + WalletConnectionMethods::SolanaSignAndSendTransaction => SolanaRequestHandler::parse_send_transaction(Chain::Solana, params), + WalletConnectionMethods::SolanaSignAllTransactions => SolanaRequestHandler::parse_sign_all_transactions(params), + WalletConnectionMethods::SuiSignPersonalMessage => SuiRequestHandler::parse_sign_message(Chain::Sui, params, domain), + WalletConnectionMethods::SuiSignTransaction => SuiRequestHandler::parse_sign_transaction(Chain::Sui, params), + WalletConnectionMethods::SuiSignAndExecuteTransaction => SuiRequestHandler::parse_send_transaction(Chain::Sui, params), + WalletConnectionMethods::TonSignData => TonRequestHandler::parse_sign_message(Chain::Ton, params, domain), + WalletConnectionMethods::TonSendMessage => TonRequestHandler::parse_send_transaction(Chain::Ton, params), + WalletConnectionMethods::TronSignMessage => TronRequestHandler::parse_sign_message(Chain::Tron, params, domain), + WalletConnectionMethods::TronSignTransaction => TronRequestHandler::parse_sign_transaction(Chain::Tron, params), + WalletConnectionMethods::TronSendTransaction => TronRequestHandler::parse_send_transaction(Chain::Tron, params), + } + } + + pub fn decode_send_transaction(transaction_type: WalletConnectTransactionType, data: String) -> Result { + match transaction_type { + WalletConnectTransactionType::Ethereum => { + let tx: WCEthereumTransaction = serde_json::from_str(&data).map_err(|e| e.to_string())?; + Ok(WalletConnectTransaction::Ethereum { data: tx.into() }) + } + WalletConnectTransactionType::Solana { output_type } => { + let json: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + let transaction = json + .get("transaction") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing transaction field".to_string())? + .to_string(); + Ok(WalletConnectTransaction::Solana { + data: WCSolanaTransactionData { transaction }, + output_type, + }) + } + WalletConnectTransactionType::Sui { output_type } => { + let json: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + let transaction = json + .get("transaction") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing transaction field".to_string())? + .to_string(); + let wallet_address = json.get("account").or_else(|| json.get("address")).and_then(|v| v.as_str()).unwrap_or_default().to_string(); + Ok(WalletConnectTransaction::Sui { + data: WCSuiTransactionData { transaction, wallet_address }, + output_type, + }) + } + WalletConnectTransactionType::Ton { output_type } => { + let json: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + let messages = json.get("messages").ok_or_else(|| "Missing messages field".to_string())?.to_string(); + Ok(WalletConnectTransaction::Ton { messages, output_type }) + } + WalletConnectTransactionType::Tron { output_type } => Ok(WalletConnectTransaction::Tron { data, output_type }), + } + } + + fn resolve_chain(chain_id: Option) -> Result { + WalletConnectCAIP2::resolve_chain(chain_id) + } + + fn parse_switch_chain_id(params: &Value) -> Result { + let chain_id_text = params.at(0)?.get_value("chainId")?.string()?; + let chain_id = hex::parse_u64_from_hex_or_decimal(chain_id_text).map_err(|error| error.to_string())?; + Chain::from_chain_id(chain_id).ok_or_else(|| "Unsupported chain".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sign_type::SignDigestType; + use gem_evm::testkit::eip712_mock::mock_eip712_json; + use primitives::TransferDataOutputType; + + #[test] + fn test_unsupported_method() { + let request = WalletConnectRequest::mock("unknown_method", "{}", None); + let action = WalletConnectRequestHandler::parse_request(request).unwrap(); + assert_eq!( + action, + WalletConnectAction::Unsupported { + method: "unknown_method".to_string() + } + ); + } + + #[test] + fn test_bitcoin_methods_are_unsupported() { + let request = WalletConnectRequest::mock("signMessage", "{}", Some("bip122:000000000019d6689c085ae165831e93")); + assert_eq!( + WalletConnectRequestHandler::parse_request(request).unwrap(), + WalletConnectAction::Unsupported { + method: "signMessage".to_string() + } + ); + + let request = WalletConnectRequest::mock("sendTransfer", "{}", Some("bip122:000000000019d6689c085ae165831e93")); + assert_eq!( + WalletConnectRequestHandler::parse_request(request).unwrap(), + WalletConnectAction::Unsupported { + method: "sendTransfer".to_string() + } + ); + } + + #[test] + fn test_chain_operation_add_chain() { + let request = WalletConnectRequest::mock("wallet_addEthereumChain", "{}", None); + assert_eq!( + WalletConnectRequestHandler::parse_request(request).unwrap(), + WalletConnectAction::ChainOperation { + operation: WalletConnectChainOperation::AddChain, + } + ); + } + + #[test] + fn test_chain_operation_switch_chain() { + let params = r#"[{"chainId":"0x1"}]"#; + let request = WalletConnectRequest::mock("wallet_switchEthereumChain", params, None); + assert_eq!( + WalletConnectRequestHandler::parse_request(request).unwrap(), + WalletConnectAction::ChainOperation { + operation: WalletConnectChainOperation::SwitchChain { chain: Chain::Ethereum }, + } + ); + } + + #[test] + fn test_chain_operation_switch_chain_missing_chain_id() { + let params = r#"[{}]"#; + let request = WalletConnectRequest::mock("wallet_switchEthereumChain", params, None); + assert!(WalletConnectRequestHandler::parse_request(request).is_err()); + } + + #[test] + fn test_parse_request_eip712_chain_match() { + let eip712_json = mock_eip712_json(1); + let params = serde_json::to_string(&serde_json::json!(["0x123", eip712_json])).unwrap(); + let request = WalletConnectRequest::mock("eth_signTypedData_v4", ¶ms, Some("eip155:1")); + let result = WalletConnectRequestHandler::parse_request(request); + assert!(result.is_ok()); + + match result.unwrap() { + WalletConnectAction::SignMessage { chain, sign_type, .. } => { + assert_eq!(chain, Chain::Ethereum); + assert_eq!(sign_type, SignDigestType::Eip712); + } + _ => panic!("Expected SignMessage action"), + } + } + + #[test] + fn test_parse_request_eip712_chain_mismatch_rejects() { + let eip712_json = mock_eip712_json(137); + let params = serde_json::to_string(&serde_json::json!(["0x123", eip712_json])).unwrap(); + let request = WalletConnectRequest::mock("eth_signTypedData_v4", ¶ms, Some("eip155:1")); + let result = WalletConnectRequestHandler::parse_request(request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Chain ID mismatch")); + } + + #[test] + fn test_parse_request_eip712_cross_chain_attack() { + let eip712_json = mock_eip712_json(137); + let params = serde_json::to_string(&serde_json::json!(["0x123", eip712_json])).unwrap(); + + let request = WalletConnectRequest::mock("eth_signTypedData_v4", ¶ms, Some("eip155:1")); + assert!(WalletConnectRequestHandler::parse_request(request).is_err()); + + let request = WalletConnectRequest::mock("eth_signTypedData_v4", ¶ms, Some("eip155:137")); + assert!(WalletConnectRequestHandler::parse_request(request).is_ok()); + } + + #[test] + fn test_solana_sign_all_transactions_roundtrip() { + let params = include_str!("../../testdata/solana_sign_all_transactions.json"); + let request = WalletConnectRequest::mock("solana_signAllTransactions", params, Some("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")); + let action = WalletConnectRequestHandler::parse_request(request).unwrap(); + match &action { + WalletConnectAction::SignAllTransactions { + chain, + transaction_type, + transactions, + } => { + assert_eq!(*chain, Chain::Solana); + assert_eq!(transactions.len(), 1); + let decoded = WalletConnectRequestHandler::decode_send_transaction(transaction_type.clone(), transactions[0].clone()).unwrap(); + match decoded { + WalletConnectTransaction::Solana { data, output_type } => { + assert!(data.transaction.starts_with("AQAAAAAAAAA")); + assert_eq!(output_type, TransferDataOutputType::EncodedTransaction); + } + _ => panic!("Expected Solana transaction"), + } + } + _ => panic!("Expected SignAllTransactions action"), + } + } + + #[test] + fn test_parse_request_eth_sign_typed_data_v3_chain_mismatch() { + let eip712_json = mock_eip712_json(56); + let params = serde_json::to_string(&serde_json::json!(["0x123", eip712_json])).unwrap(); + let request = WalletConnectRequest::mock("eth_signTypedData", ¶ms, Some("eip155:1")); + let result = WalletConnectRequestHandler::parse_request(request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Chain ID mismatch")); + } +} diff --git a/core/crates/gem_wallet_connect/src/request_handler/solana.rs b/core/crates/gem_wallet_connect/src/request_handler/solana.rs new file mode 100644 index 0000000000..8253aeca4a --- /dev/null +++ b/core/crates/gem_wallet_connect/src/request_handler/solana.rs @@ -0,0 +1,99 @@ +use crate::actions::{WalletConnectAction, WalletConnectTransactionType}; +use crate::sign_type::SignDigestType; +use primitives::{Chain, TransferDataOutputType, ValueAccess}; +use serde_json::Value; + +pub struct SolanaRequestHandler; + +impl SolanaRequestHandler { + pub fn parse_sign_message(_chain: Chain, params: Value, _domain: &str) -> Result { + let message = params.get_value("message")?.string()?.to_string(); + + Ok(WalletConnectAction::SignMessage { + chain: Chain::Solana, + sign_type: SignDigestType::Base58, + data: message, + }) + } + + pub fn parse_sign_transaction(_chain: Chain, params: Value) -> Result { + params.get_value("transaction")?.string()?; + + Ok(WalletConnectAction::SignTransaction { + chain: Chain::Solana, + transaction_type: WalletConnectTransactionType::Solana { + output_type: TransferDataOutputType::Signature, + }, + data: params.to_string(), + }) + } + + pub fn parse_send_transaction(_chain: Chain, params: Value) -> Result { + params.get_value("transaction")?.string()?; + + Ok(WalletConnectAction::SendTransaction { + chain: Chain::Solana, + transaction_type: WalletConnectTransactionType::Solana { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: params.to_string(), + }) + } + + pub fn parse_sign_all_transactions(params: Value) -> Result { + let array = params.get_value("transactions")?.as_array().ok_or("Expected transactions array")?; + let transactions: Vec = array + .iter() + .map(|v| { + let transaction = v.string()?.to_string(); + Ok(serde_json::json!({"transaction": transaction}).to_string()) + }) + .collect::, String>>()?; + + if transactions.is_empty() { + return Err("Empty transactions array".to_string()); + } + + Ok(WalletConnectAction::SignAllTransactions { + chain: Chain::Solana, + transaction_type: WalletConnectTransactionType::Solana { + output_type: TransferDataOutputType::EncodedTransaction, + }, + transactions, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_sign_message() { + let params = serde_json::from_str(r#"{"message":"Hello"}"#).unwrap(); + assert_eq!( + SolanaRequestHandler::parse_sign_message(Chain::Solana, params, "example.com").unwrap(), + WalletConnectAction::SignMessage { + chain: Chain::Solana, + sign_type: SignDigestType::Base58, + data: "Hello".to_string(), + } + ); + } + + #[test] + fn test_sign_transaction() { + let params: Value = serde_json::from_str(r#"{"transaction":"AAACAAhkAAA"}"#).unwrap(); + let expected_data = params.to_string(); + assert_eq!( + SolanaRequestHandler::parse_sign_transaction(Chain::Solana, params).unwrap(), + WalletConnectAction::SignTransaction { + chain: Chain::Solana, + transaction_type: WalletConnectTransactionType::Solana { + output_type: TransferDataOutputType::Signature, + }, + data: expected_data, + } + ); + } +} diff --git a/core/crates/gem_wallet_connect/src/request_handler/sui.rs b/core/crates/gem_wallet_connect/src/request_handler/sui.rs new file mode 100644 index 0000000000..67a7c211a2 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/request_handler/sui.rs @@ -0,0 +1,92 @@ +use crate::actions::{WalletConnectAction, WalletConnectTransactionType}; +use crate::sign_type::SignDigestType; +use primitives::{Chain, TransferDataOutputType, ValueAccess}; +use serde_json::Value; + +pub struct SuiRequestHandler; + +impl SuiRequestHandler { + pub fn parse_sign_message(_chain: Chain, params: Value, _domain: &str) -> Result { + let message = params.get_value("message")?.string()?.to_string(); + + Ok(WalletConnectAction::SignMessage { + chain: Chain::Sui, + sign_type: SignDigestType::SuiPersonal, + data: message, + }) + } + + pub fn parse_sign_transaction(_chain: Chain, params: Value) -> Result { + params.get_value("transaction")?.string()?; + + Ok(WalletConnectAction::SignTransaction { + chain: Chain::Sui, + transaction_type: WalletConnectTransactionType::Sui { + output_type: TransferDataOutputType::Signature, + }, + data: params.to_string(), + }) + } + + pub fn parse_send_transaction(_chain: Chain, params: Value) -> Result { + params.get_value("transaction")?.string()?; + + Ok(WalletConnectAction::SendTransaction { + chain: Chain::Sui, + transaction_type: WalletConnectTransactionType::Sui { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: params.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_sign_message() { + let params = serde_json::from_str(r#"{"message":"Hello Sui"}"#).unwrap(); + assert_eq!( + SuiRequestHandler::parse_sign_message(Chain::Sui, params, "example.com").unwrap(), + WalletConnectAction::SignMessage { + chain: Chain::Sui, + sign_type: SignDigestType::SuiPersonal, + data: "Hello Sui".to_string(), + } + ); + } + + #[test] + fn test_parse_sign_transaction() { + let params: Value = serde_json::from_str(r#"{"address":"0xfa92fe9555eeb34d3d922dae643483cbd18bd607bf900a1df5e82dc22804698e","transaction":"AAACAAhkAAA"}"#).unwrap(); + let expected_data = params.to_string(); + assert_eq!( + SuiRequestHandler::parse_sign_transaction(Chain::Sui, params).unwrap(), + WalletConnectAction::SignTransaction { + chain: Chain::Sui, + transaction_type: WalletConnectTransactionType::Sui { + output_type: TransferDataOutputType::Signature, + }, + data: expected_data, + } + ); + } + + #[test] + fn test_parse_send_transaction() { + let params: Value = serde_json::from_str(r#"{"address":"0xfa92fe9555eeb34d3d922dae643483cbd18bd607bf900a1df5e82dc22804698e","transaction":"AAACAAhkAAA"}"#).unwrap(); + let expected_data = params.to_string(); + assert_eq!( + SuiRequestHandler::parse_send_transaction(Chain::Sui, params).unwrap(), + WalletConnectAction::SendTransaction { + chain: Chain::Sui, + transaction_type: WalletConnectTransactionType::Sui { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: expected_data, + } + ); + } +} diff --git a/core/crates/gem_wallet_connect/src/request_handler/ton.rs b/core/crates/gem_wallet_connect/src/request_handler/ton.rs new file mode 100644 index 0000000000..51f8d71737 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/request_handler/ton.rs @@ -0,0 +1,101 @@ +use crate::actions::{WalletConnectAction, WalletConnectTransactionType}; +use crate::sign_type::SignDigestType; +use gem_ton::signer::TonSignMessageData; +use primitives::{Chain, TransferDataOutputType, ValueAccess}; +use serde_json::Value; + +pub struct TonRequestHandler; + +fn extract_host(url: &str) -> String { + url::Url::parse(url).map(|u| u.host_str().unwrap_or(url).to_string()).unwrap_or_else(|_| url.to_string()) +} + +impl TonRequestHandler { + pub fn parse_sign_message(_chain: Chain, params: Value, domain: &str) -> Result { + let payload = params.at(0)?.clone(); + let from = payload.get_value("from")?.string()?.to_string(); + let host = extract_host(domain); + let ton_data = TonSignMessageData::from_value(payload, host, from).map_err(|e| e.to_string())?; + let data = String::from_utf8(ton_data.to_bytes()).map_err(|e| format!("Failed to encode TonSignMessageData: {}", e))?; + Ok(WalletConnectAction::SignMessage { + chain: Chain::Ton, + sign_type: SignDigestType::TonPersonal, + data, + }) + } + + #[allow(dead_code)] + pub fn parse_sign_transaction(_chain: Chain, params: Value) -> Result { + params.get_value("messages")?; + Ok(WalletConnectAction::SignTransaction { + chain: Chain::Ton, + transaction_type: WalletConnectTransactionType::Ton { + output_type: TransferDataOutputType::Signature, + }, + data: params.to_string(), + }) + } + + pub fn parse_send_transaction(_chain: Chain, params: Value) -> Result { + params.get_value("messages")?; + Ok(WalletConnectAction::SendTransaction { + chain: Chain::Ton, + transaction_type: WalletConnectTransactionType::Ton { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: params.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_ton::signer::TonSignDataPayload; + + #[test] + fn test_parse_sign_message() { + let params = serde_json::from_str(r#"[{"type":"text","text":"Hello TON","from":"UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg"}]"#).unwrap(); + let action = TonRequestHandler::parse_sign_message(Chain::Ton, params, "https://react-app.walletconnect.com").unwrap(); + let WalletConnectAction::SignMessage { chain, sign_type, data } = action else { + panic!("Expected SignMessage action") + }; + assert_eq!(chain, Chain::Ton); + assert_eq!(sign_type, SignDigestType::TonPersonal); + + let parsed: TonSignMessageData = serde_json::from_str(&data).unwrap(); + assert_eq!(parsed.domain, "react-app.walletconnect.com"); + assert_eq!(parsed.address, "UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg"); + assert_eq!(parsed.payload, TonSignDataPayload::Text { text: "Hello TON".to_string() }); + } + + #[test] + fn test_parse_sign_message_extracts_host() { + let params = serde_json::from_str(r#"[{"type":"text","text":"Test","from":"UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg"}]"#).unwrap(); + let action = TonRequestHandler::parse_sign_message(Chain::Ton, params, "https://example.com/path?query=1").unwrap(); + let WalletConnectAction::SignMessage { data, .. } = action else { + panic!("Expected SignMessage action") + }; + + let parsed: TonSignMessageData = serde_json::from_str(&data).unwrap(); + assert_eq!(parsed.domain, "example.com"); + } + + #[test] + fn test_parse_send_transaction() { + let params: Value = serde_json::from_str(r#"{"valid_until":1234567890,"messages":[{"address":"0:1234567890abcdef","amount":"1000000000"}]}"#).unwrap(); + let action = TonRequestHandler::parse_send_transaction(Chain::Ton, params).unwrap(); + + let WalletConnectAction::SendTransaction { chain, transaction_type, .. } = action else { + panic!("Expected SendTransaction action") + }; + assert_eq!(chain, Chain::Ton); + assert_eq!(transaction_type.get_output_type().unwrap(), TransferDataOutputType::EncodedTransaction); + } + + #[test] + fn test_parse_send_transaction_missing_messages() { + let params = serde_json::from_str(r#"{"valid_until": 123}"#).unwrap(); + assert!(TonRequestHandler::parse_send_transaction(Chain::Ton, params).is_err()); + } +} diff --git a/core/crates/gem_wallet_connect/src/request_handler/tron.rs b/core/crates/gem_wallet_connect/src/request_handler/tron.rs new file mode 100644 index 0000000000..a4c8255372 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/request_handler/tron.rs @@ -0,0 +1,115 @@ +use crate::actions::{WalletConnectAction, WalletConnectTransactionType}; +use crate::sign_type::SignDigestType; +use primitives::{Chain, TransferDataOutputType, ValueAccess}; +use serde_json::Value; + +pub struct TronRequestHandler; + +impl TronRequestHandler { + pub fn parse_sign_message(_chain: Chain, params: Value, _domain: &str) -> Result { + let message = params.get_value("message")?.string()?.to_string(); + + Ok(WalletConnectAction::SignMessage { + chain: Chain::Tron, + sign_type: SignDigestType::TronPersonal, + data: message, + }) + } + + pub fn parse_sign_transaction(_chain: Chain, params: Value) -> Result { + params.get_value("transaction")?; + + Ok(WalletConnectAction::SignTransaction { + chain: Chain::Tron, + transaction_type: WalletConnectTransactionType::Tron { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: params.to_string(), + }) + } + + pub fn parse_send_transaction(_chain: Chain, params: Value) -> Result { + params.get_value("transaction")?; + + Ok(WalletConnectAction::SendTransaction { + chain: Chain::Tron, + transaction_type: WalletConnectTransactionType::Tron { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: params.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_sign_message() { + let params = serde_json::from_str(r#"{"message":"Hello"}"#).unwrap(); + assert_eq!( + TronRequestHandler::parse_sign_message(Chain::Tron, params, "example.com").unwrap(), + WalletConnectAction::SignMessage { + chain: Chain::Tron, + sign_type: SignDigestType::TronPersonal, + data: "Hello".to_string(), + } + ); + } + + #[test] + fn test_parse_sign_transaction() { + let params: Value = serde_json::from_str(r#"{"transaction":{"raw_data_hex":"abc"}}"#).unwrap(); + let expected_data = params.to_string(); + assert_eq!( + TronRequestHandler::parse_sign_transaction(Chain::Tron, params).unwrap(), + WalletConnectAction::SignTransaction { + chain: Chain::Tron, + transaction_type: WalletConnectTransactionType::Tron { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: expected_data, + } + ); + } + + #[test] + fn test_parse_send_transaction() { + let params: Value = serde_json::from_str(r#"{"transaction":{"raw_data_hex":"abc"}}"#).unwrap(); + let expected_data = params.to_string(); + assert_eq!( + TronRequestHandler::parse_send_transaction(Chain::Tron, params).unwrap(), + WalletConnectAction::SendTransaction { + chain: Chain::Tron, + transaction_type: WalletConnectTransactionType::Tron { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: expected_data, + } + ); + } + + #[test] + fn test_parse_send_transaction_with_testdata() { + use crate::WalletConnectRequestHandler; + use primitives::WalletConnectRequest; + + let params = include_str!("../../testdata/tron_send_transaction.json"); + let expected_data: serde_json::Value = serde_json::from_str(params.trim()).unwrap(); + let expected_data = expected_data.to_string(); + let request = WalletConnectRequest::mock("tron_sendTransaction", &serde_json::to_string(¶ms.trim()).unwrap(), Some("tron:0x2b6653dc")); + + let action = WalletConnectRequestHandler::parse_request(request).unwrap(); + assert_eq!( + action, + WalletConnectAction::SendTransaction { + chain: Chain::Tron, + transaction_type: WalletConnectTransactionType::Tron { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: expected_data, + } + ); + } +} diff --git a/core/crates/gem_wallet_connect/src/response_handler.rs b/core/crates/gem_wallet_connect/src/response_handler.rs new file mode 100644 index 0000000000..a2242a4856 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/response_handler.rs @@ -0,0 +1,130 @@ +use crate::actions::WalletConnectResponseType; +use primitives::ChainType; + +pub struct WalletConnectResponseHandler; + +impl WalletConnectResponseHandler { + pub fn encode_sign_message(chain_type: ChainType, signature: String) -> WalletConnectResponseType { + match chain_type { + ChainType::Solana | ChainType::Sui | ChainType::Tron => { + let result = serde_json::json!({ + "signature": signature + }); + WalletConnectResponseType::Object { + json: serde_json::to_string(&result).unwrap_or_default(), + } + } + ChainType::Ton => WalletConnectResponseType::Object { json: signature }, + _ => WalletConnectResponseType::String { value: signature }, + } + } + + pub fn encode_sign_transaction(chain_type: ChainType, transaction_id: String) -> WalletConnectResponseType { + match chain_type { + ChainType::Solana | ChainType::Ton => WalletConnectResponseType::Object { + json: serde_json::json!({ "signature": transaction_id }).to_string(), + }, + ChainType::Sui => { + let parts: Vec<&str> = transaction_id.splitn(2, '_').collect(); + let result = if parts.len() == 2 { + serde_json::json!({ + "signature": parts[1], + "transactionBytes": parts[0] + }) + } else { + serde_json::json!({ + "signature": transaction_id, + "transactionBytes": "" + }) + }; + WalletConnectResponseType::Object { json: result.to_string() } + } + ChainType::Tron => WalletConnectResponseType::Object { json: transaction_id }, + _ => WalletConnectResponseType::String { value: transaction_id }, + } + } + + pub fn encode_sign_all_transactions(signed_transactions: Vec) -> WalletConnectResponseType { + WalletConnectResponseType::Object { + json: serde_json::json!({ "transactions": signed_transactions }).to_string(), + } + } + + pub fn encode_send_transaction(chain_type: ChainType, transaction_id: String) -> WalletConnectResponseType { + match chain_type { + ChainType::Sui => WalletConnectResponseType::Object { + json: serde_json::json!({ "digest": transaction_id }).to_string(), + }, + ChainType::Tron => WalletConnectResponseType::Object { + json: serde_json::json!({ "result": true, "txid": transaction_id }).to_string(), + }, + _ => WalletConnectResponseType::String { value: transaction_id }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn object(json: &str) -> WalletConnectResponseType { + WalletConnectResponseType::Object { json: json.to_string() } + } + + fn string(value: &str) -> WalletConnectResponseType { + WalletConnectResponseType::String { value: value.to_string() } + } + + #[test] + fn test_encode_sign_message_ethereum() { + assert_eq!(WalletConnectResponseHandler::encode_sign_message(ChainType::Ethereum, "0xsig".to_string()), string("0xsig")); + } + + #[test] + fn test_encode_sign_message_solana() { + assert_eq!( + WalletConnectResponseHandler::encode_sign_message(ChainType::Solana, "sig123".to_string()), + object(r#"{"signature":"sig123"}"#) + ); + } + + #[test] + fn test_encode_sign_transaction_tron() { + assert_eq!( + WalletConnectResponseHandler::encode_sign_transaction(ChainType::Tron, r#"{"signature":["sig"]}"#.to_string()), + object(r#"{"signature":["sig"]}"#) + ); + } + + #[test] + fn test_encode_sign_transaction_sui() { + assert_eq!( + WalletConnectResponseHandler::encode_sign_transaction(ChainType::Sui, "txbytes_sig123".to_string()), + object(r#"{"signature":"sig123","transactionBytes":"txbytes"}"#) + ); + } + + #[test] + fn test_encode_send_transaction_sui() { + assert_eq!( + WalletConnectResponseHandler::encode_send_transaction(ChainType::Sui, "digest123".to_string()), + object(r#"{"digest":"digest123"}"#) + ); + } + + #[test] + fn test_encode_send_transaction_tron() { + assert_eq!( + WalletConnectResponseHandler::encode_send_transaction(ChainType::Tron, "txid123".to_string()), + object(r#"{"result":true,"txid":"txid123"}"#) + ); + } + + #[test] + fn test_encode_sign_all_transactions() { + assert_eq!( + WalletConnectResponseHandler::encode_sign_all_transactions(vec!["signed_tx_1".to_string(), "signed_tx_2".to_string()]), + object(r#"{"transactions":["signed_tx_1","signed_tx_2"]}"#) + ); + } +} diff --git a/core/crates/gem_wallet_connect/src/session.rs b/core/crates/gem_wallet_connect/src/session.rs new file mode 100644 index 0000000000..a5202461a9 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/session.rs @@ -0,0 +1,50 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use primitives::Chain; + +const TRON_METHOD_VERSION_KEY: &str = "tron_method_version"; +const TRON_METHOD_VERSION_VALUE: &str = "v1"; + +pub fn config_session_properties(mut properties: HashMap, chains: &[Chain]) -> HashMap { + if chains.contains(&Chain::Tron) { + properties = tron_session_properties(properties); + } + properties +} + +pub fn parse_chains(chains: &[String]) -> Vec { + chains.iter().filter_map(|c| Chain::from_str(c).ok()).collect() +} + +fn tron_session_properties(mut properties: HashMap) -> HashMap { + if !properties.contains_key(TRON_METHOD_VERSION_KEY) { + properties.insert(TRON_METHOD_VERSION_KEY.to_string(), TRON_METHOD_VERSION_VALUE.to_string()); + } + properties +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_session_properties_adds_tron() { + let result = config_session_properties(HashMap::new(), &[Chain::Tron]); + assert_eq!(result.get("tron_method_version").unwrap(), "v1"); + } + + #[test] + fn test_config_session_properties_preserves_existing() { + let mut props = HashMap::new(); + props.insert("tron_method_version".to_string(), "v2".to_string()); + let result = config_session_properties(props, &[Chain::Tron]); + assert_eq!(result.get("tron_method_version").unwrap(), "v2"); + } + + #[test] + fn test_config_session_properties_no_tron() { + let result = config_session_properties(HashMap::new(), &[Chain::Ethereum]); + assert!(!result.contains_key("tron_method_version")); + } +} diff --git a/core/crates/gem_wallet_connect/src/sign_type.rs b/core/crates/gem_wallet_connect/src/sign_type.rs new file mode 100644 index 0000000000..1e599c6ea2 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/sign_type.rs @@ -0,0 +1,19 @@ +use primitives::Chain; + +#[derive(Debug, Clone, PartialEq)] +pub enum SignDigestType { + Eip191, + Eip712, + Base58, + SuiPersonal, + Siwe, + TonPersonal, + TronPersonal, +} + +#[derive(Debug)] +pub struct SignMessage { + pub chain: Chain, + pub sign_type: SignDigestType, + pub data: Vec, +} diff --git a/core/crates/gem_wallet_connect/src/validator.rs b/core/crates/gem_wallet_connect/src/validator.rs new file mode 100644 index 0000000000..f49c8ae956 --- /dev/null +++ b/core/crates/gem_wallet_connect/src/validator.rs @@ -0,0 +1,296 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::actions::WalletConnectTransactionType; +use crate::sign_type::SignDigestType; +use gem_evm::domain::host_only; +use gem_evm::siwe::SiweMessage; +use primitives::Chain; + +pub struct SignMessageValidation<'a> { + pub chain: Chain, + pub sign_type: &'a SignDigestType, + pub data: &'a str, + pub session_domain: &'a str, +} + +fn current_timestamp() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) +} + +pub fn validate_sign_message(input: &SignMessageValidation) -> Result<(), String> { + match input.sign_type { + SignDigestType::Eip712 => { + let expected_chain_id = input + .chain + .network_id() + .parse::() + .map_err(|_| format!("Chain {} does not have a numeric network ID", input.chain))?; + gem_evm::eip712::validate_eip712_chain_id(input.data, expected_chain_id) + } + SignDigestType::TonPersonal => { + gem_ton::signer::TonSignMessageData::from_bytes(input.data.as_bytes()).map_err(|e| e.to_string())?; + Ok(()) + } + SignDigestType::Eip191 | SignDigestType::Siwe => validate_siwe(input), + SignDigestType::Base58 | SignDigestType::SuiPersonal | SignDigestType::TronPersonal => Ok(()), + } +} + +fn validate_siwe(input: &SignMessageValidation) -> Result<(), String> { + let text = decode_text(input.data); + let Some(message) = text.as_deref().and_then(SiweMessage::try_parse) else { + if *input.sign_type == SignDigestType::Siwe { + return Err("Invalid SIWE message".to_string()); + } + return Ok(()); + }; + message.validate(input.chain)?; + validate_session_domain(&message, input.session_domain) +} + +fn decode_text(data: &str) -> Option { + if let Some(stripped) = data.strip_prefix("0x") { + hex::decode(stripped).ok().and_then(|bytes| String::from_utf8(bytes).ok()) + } else { + Some(data.to_string()) + } +} + +fn validate_session_domain(message: &SiweMessage, session_domain: &str) -> Result<(), String> { + let session_host = host_only(session_domain).ok_or_else(|| "Invalid session origin".to_string())?; + let message_host = host_only(&message.domain).ok_or_else(|| "Invalid SIWE domain".to_string())?; + if session_host != message_host { + return Err(format!("Domain mismatch: SIWE domain {} does not match session origin {}", message.domain, session_domain)); + } + Ok(()) +} + +pub fn validate_send_transaction(transaction_type: &WalletConnectTransactionType, data: &str) -> Result<(), String> { + let WalletConnectTransactionType::Ton { .. } = transaction_type else { + return Ok(()); + }; + + let json: serde_json::Value = serde_json::from_str(data).map_err(|_| "Invalid JSON".to_string())?; + + if let Some(valid_until) = json.get("valid_until").and_then(|v| v.as_i64()) + && current_timestamp() >= valid_until + { + return Err("Transaction expired".to_string()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_evm::testkit::eip712_mock::mock_eip712_json; + use gem_evm::testkit::siwe_mock::{mock_siwe_message, mock_siwe_message_hex}; + use primitives::TransferDataOutputType; + + fn sign_validation<'a>(chain: Chain, sign_type: &'a SignDigestType, data: &'a str, session_domain: &'a str) -> SignMessageValidation<'a> { + SignMessageValidation { + chain, + sign_type, + data, + session_domain, + } + } + + #[test] + fn test_validate_eip712_chain_match() { + assert!(validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Eip712, &mock_eip712_json(1), "")).is_ok()); + } + + #[test] + fn test_validate_eip712_chain_mismatch() { + let result = validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Eip712, &mock_eip712_json(137), "")); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Chain ID mismatch")); + } + + #[test] + fn test_validate_eip712_polygon() { + assert!(validate_sign_message(&sign_validation(Chain::Polygon, &SignDigestType::Eip712, &mock_eip712_json(137), "")).is_ok()); + } + + #[test] + fn test_validate_eip712_without_domain_chain_id() { + let data = include_str!("../../gem_evm/testdata/ens_upload_avatar.json"); + assert!(validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Eip712, data, "")).is_ok()); + } + + #[test] + fn test_validate_eip712_domain_chain_id_without_schema_rejects() { + let data = include_str!("../../gem_evm/testdata/eip712_domain_chain_id_without_schema_field.json"); + let result = validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Eip712, data, "")); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("chainId")); + } + + #[test] + fn test_validate_eip191_always_ok() { + assert!(validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Eip191, "anything", "example.com")).is_ok()); + } + + #[test] + fn test_validate_ton_send_transaction_expired() { + let ton_type = WalletConnectTransactionType::Ton { + output_type: TransferDataOutputType::EncodedTransaction, + }; + assert!(validate_send_transaction(&ton_type, r#"{"valid_until": 1234567890, "messages": []}"#).is_err()); + } + + #[test] + fn test_validate_ton_send_transaction_valid() { + let ton_type = WalletConnectTransactionType::Ton { + output_type: TransferDataOutputType::EncodedTransaction, + }; + assert!(validate_send_transaction(&ton_type, r#"{"valid_until": 9999999999, "messages": []}"#).is_ok()); + } + + #[test] + fn test_validate_ethereum_send_transaction_always_ok() { + assert!(validate_send_transaction(&WalletConnectTransactionType::Ethereum, "{}").is_ok()); + } + + #[test] + fn test_validate_ton_send_transaction_no_expiry() { + let ton_type = WalletConnectTransactionType::Ton { + output_type: TransferDataOutputType::EncodedTransaction, + }; + assert!(validate_send_transaction(&ton_type, r#"{"messages": []}"#).is_ok()); + } + + #[test] + fn test_validate_ton_sign_message() { + use gem_ton::signer::{TonSignDataPayload, TonSignMessageData}; + + // Invalid: raw JSON without proper encoding + assert!( + validate_sign_message(&sign_validation( + Chain::Ton, + &SignDigestType::TonPersonal, + r#"{"payload":{"text":"Hello"},"domain":"example.com"}"#, + "" + )) + .is_err() + ); + + // Invalid: unknown payload type + assert!( + validate_sign_message(&sign_validation( + Chain::Ton, + &SignDigestType::TonPersonal, + r#"{"payload":{"type":"unknown"},"domain":"example.com"}"#, + "" + )) + .is_err() + ); + + // Valid: text payload + let ton_data = TonSignMessageData::new( + TonSignDataPayload::Text { text: "Hello".to_string() }, + "example.com".to_string(), + "UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg".to_string(), + ); + assert!( + validate_sign_message(&sign_validation( + Chain::Ton, + &SignDigestType::TonPersonal, + &String::from_utf8(ton_data.to_bytes()).unwrap(), + "" + )) + .is_ok() + ); + + // Valid: binary payload + let ton_data = TonSignMessageData::new( + TonSignDataPayload::Binary { bytes: "SGVsbG8=".to_string() }, + "example.com".to_string(), + "UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg".to_string(), + ); + assert!( + validate_sign_message(&sign_validation( + Chain::Ton, + &SignDigestType::TonPersonal, + &String::from_utf8(ton_data.to_bytes()).unwrap(), + "" + )) + .is_ok() + ); + + // Valid: cell payload + let ton_data = TonSignMessageData::new( + TonSignDataPayload::Cell { + schema: "comment#00000000 text:SnakeData = InMsgBody;".to_string(), + cell: "te6c".to_string(), + }, + "example.com".to_string(), + "UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg".to_string(), + ); + assert!( + validate_sign_message(&sign_validation( + Chain::Ton, + &SignDigestType::TonPersonal, + &String::from_utf8(ton_data.to_bytes()).unwrap(), + "" + )) + .is_ok() + ); + } + + #[test] + fn test_validate_siwe() { + let valid = mock_siwe_message("thepoc.xyz", 1); + assert!(validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Siwe, &valid, "https://thepoc.xyz")).is_ok()); + + let with_port = mock_siwe_message("thepoc.xyz:8080", 1); + assert!(validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Siwe, &with_port, "https://thepoc.xyz")).is_ok()); + + let chain_mismatch = mock_siwe_message("thepoc.xyz", 137); + assert!( + validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Siwe, &chain_mismatch, "https://thepoc.xyz")) + .unwrap_err() + .contains("Chain ID mismatch") + ); + + let domain_mismatch = mock_siwe_message("evil.com", 1); + assert!( + validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Siwe, &domain_mismatch, "https://thepoc.xyz")) + .unwrap_err() + .contains("Domain mismatch") + ); + + assert!( + validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Siwe, "not siwe", "https://thepoc.xyz")) + .unwrap_err() + .contains("Invalid SIWE message") + ); + } + + #[test] + fn test_validate_eip191_siwe() { + assert!( + validate_sign_message(&sign_validation( + Chain::Ethereum, + &SignDigestType::Eip191, + &mock_siwe_message_hex("thepoc.xyz", 137), + "https://thepoc.xyz" + )) + .unwrap_err() + .contains("Chain ID mismatch") + ); + assert!( + validate_sign_message(&sign_validation( + Chain::Ethereum, + &SignDigestType::Eip191, + &mock_siwe_message_hex("evil.com", 1), + "https://thepoc.xyz" + )) + .unwrap_err() + .contains("Domain mismatch") + ); + assert!(validate_sign_message(&sign_validation(Chain::Ethereum, &SignDigestType::Eip191, "0x48656c6c6f", "https://example.com")).is_ok()); + } +} diff --git a/core/crates/gem_wallet_connect/src/verifier.rs b/core/crates/gem_wallet_connect/src/verifier.rs new file mode 100644 index 0000000000..8b41bec7bf --- /dev/null +++ b/core/crates/gem_wallet_connect/src/verifier.rs @@ -0,0 +1,81 @@ +use gem_evm::domain::host; +use primitives::WalletConnectionVerificationStatus; + +pub struct WalletConnectVerifier; + +impl WalletConnectVerifier { + pub fn validate_origin(metadata_url: String, origin: Option, validation: WalletConnectionVerificationStatus) -> WalletConnectionVerificationStatus { + match validation { + WalletConnectionVerificationStatus::Verified => Self::validate_verified_origin(metadata_url, origin), + WalletConnectionVerificationStatus::Malicious => WalletConnectionVerificationStatus::Malicious, + WalletConnectionVerificationStatus::Invalid => WalletConnectionVerificationStatus::Invalid, + WalletConnectionVerificationStatus::Unknown => WalletConnectionVerificationStatus::Unknown, + } + } + + fn validate_verified_origin(metadata_url: String, verified_origin: Option) -> WalletConnectionVerificationStatus { + let Some(origin) = verified_origin else { + return WalletConnectionVerificationStatus::Invalid; + }; + + let metadata_domain = host(&metadata_url); + let origin_domain = host(&origin); + + if metadata_domain == origin_domain { + WalletConnectionVerificationStatus::Verified + } else { + WalletConnectionVerificationStatus::Invalid + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unknown_validation() { + let result = WalletConnectVerifier::validate_origin("https://app.uniswap.org".to_string(), None, WalletConnectionVerificationStatus::Unknown); + assert!(matches!(result, WalletConnectionVerificationStatus::Unknown)); + } + + #[test] + fn test_malicious_validation() { + let result = WalletConnectVerifier::validate_origin( + "https://app.uniswap.org".to_string(), + Some("https://malicious.com".to_string()), + WalletConnectionVerificationStatus::Malicious, + ); + assert!(matches!(result, WalletConnectionVerificationStatus::Malicious)); + } + + #[test] + fn test_verified_matching_origin() { + let result = WalletConnectVerifier::validate_origin( + "https://app.uniswap.org".to_string(), + Some("https://app.uniswap.org".to_string()), + WalletConnectionVerificationStatus::Verified, + ); + assert!(matches!(result, WalletConnectionVerificationStatus::Verified)); + } + + #[test] + fn test_verified_mismatched_origin() { + let result = WalletConnectVerifier::validate_origin( + "https://app.uniswap.org".to_string(), + Some("https://different.com".to_string()), + WalletConnectionVerificationStatus::Verified, + ); + assert!(matches!(result, WalletConnectionVerificationStatus::Invalid)); + } + + #[test] + fn test_invalid_validation() { + let result = WalletConnectVerifier::validate_origin( + "https://app.uniswap.org".to_string(), + Some("https://app.uniswap.org".to_string()), + WalletConnectionVerificationStatus::Invalid, + ); + assert!(matches!(result, WalletConnectionVerificationStatus::Invalid)); + } +} diff --git a/core/crates/gem_wallet_connect/testdata/solana_sign_all_transactions.json b/core/crates/gem_wallet_connect/testdata/solana_sign_all_transactions.json new file mode 100644 index 0000000000..763c9185ba --- /dev/null +++ b/core/crates/gem_wallet_connect/testdata/solana_sign_all_transactions.json @@ -0,0 +1,5 @@ +{ + "transactions": [ + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAEB4X7qT7inGBPqFijUWiMASkIQer7GcY6cKR108e8O++ff+KvGFsTU/tNNyl/GWd04onEeG60T3KS3sy+XeFY0Ki7nDUGiTeDXiXs77SALJIRnd7ysD54hjxwwqZWL45jVwMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABSGfiZqB1P+E+1k9Lt+KkKwbOrNCWPffIz6lAwKxvS6uRHvTnhwX5MLyqWayYc3wikPwVLiIIofgiijQL5XRhtsX1PCpIazGy+0gPpCTePUXc/WDVmb3Qe5s/iGDPXfbOVNr91a1Z/vs5sRxA8hmPTdja26wmVOCqpF9Xf15KSAEAwAFAsPxAAADAAkDUMMAAAAAAAAEBgAHAQgJChHyI8aJUuHytv4gXacDAAAAAAQLAAcFBgEICQIICwomuBfuYWfF0z0gXacDAAAAAAEAAAAAAAAACKHsaQAAAAAAAAAAAAABe27qMoJyLSDj0LDOHu9s9YiqUZyCEDCEJV/DPetHbXIABQoLCQAB" + ] +} diff --git a/core/crates/gem_wallet_connect/testdata/tron_send_transaction.json b/core/crates/gem_wallet_connect/testdata/tron_send_transaction.json new file mode 100644 index 0000000000..9beab17317 --- /dev/null +++ b/core/crates/gem_wallet_connect/testdata/tron_send_transaction.json @@ -0,0 +1,28 @@ +{ + "address": "TJoSEwEqt7cT3TUwmEoUYnYs5cZR3xSukM", + "transaction": { + "raw_data": { + "contract": [ + { + "parameter": { + "type_url": "type.googleapis.com/protocol.TriggerSmartContract", + "value": { + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "data": "095ea7b300000000000000000000000060e00625a95cbc180f290e2611c826f90eeba56f0000000000000000000000000000000000000000000000000000000000000000", + "owner_address": "4160e00625a95cbc180f290e2611c826f90eeba56f" + } + }, + "type": "TriggerSmartContract" + } + ], + "expiration": 1770271569000, + "fee_limit": 200000000, + "ref_block_bytes": "b435", + "ref_block_hash": "eb7b23d0ef96d04e", + "timestamp": 1770271511581 + }, + "raw_data_hex": "0a02b4352208eb7b23d0ef96d04e40e8e8a1e3c2335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a154160e00625a95cbc180f290e2611c826f90eeba56f121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244095ea7b300000000000000000000000060e00625a95cbc180f290e2611c826f90eeba56f0000000000000000000000000000000000000000000000000000000000000000709da89ee3c23390018084af5f", + "txID": "0c195049c6eb9792017e1411604ef691c2a02725603edacb91721831fa85c4b2", + "visible": false + } +} diff --git a/core/crates/gem_wallet_connect/testdata/tron_sign_message.json b/core/crates/gem_wallet_connect/testdata/tron_sign_message.json new file mode 100644 index 0000000000..21fd88c609 --- /dev/null +++ b/core/crates/gem_wallet_connect/testdata/tron_sign_message.json @@ -0,0 +1,4 @@ +{ + "address": "TJoSEwEqt7cT3TUwmEoUYnYs5cZR3xSukM", + "message": "This is a message to be signed for Tron" +} \ No newline at end of file diff --git a/core/crates/gem_wallet_connect/testdata/tron_sign_message_response.json b/core/crates/gem_wallet_connect/testdata/tron_sign_message_response.json new file mode 100644 index 0000000000..158576c7d7 --- /dev/null +++ b/core/crates/gem_wallet_connect/testdata/tron_sign_message_response.json @@ -0,0 +1,3 @@ +{ + "signature": "0xa0cbc20e8f0a9c19dd3d97e15fd99eee49edb8c0bcca52b684bbf13e1344b99670201d57633881cb20b0c00b626397530e3165049044b2fa4089840cf41a0a761b" +} diff --git a/core/crates/gem_wallet_connect/testdata/tron_sign_transaction.json b/core/crates/gem_wallet_connect/testdata/tron_sign_transaction.json new file mode 100644 index 0000000000..a12b93ed8f --- /dev/null +++ b/core/crates/gem_wallet_connect/testdata/tron_sign_transaction.json @@ -0,0 +1,28 @@ +{ + "address": "TJoSEwEqt7cT3TUwmEoUYnYs5cZR3xSukM", + "transaction": { + "raw_data": { + "contract": [ + { + "parameter": { + "type_url": "type.googleapis.com/protocol.TriggerSmartContract", + "value": { + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "data": "095ea7b300000000000000000000000060e00625a95cbc180f290e2611c826f90eeba56f0000000000000000000000000000000000000000000000000000000000000000", + "owner_address": "4160e00625a95cbc180f290e2611c826f90eeba56f" + } + }, + "type": "TriggerSmartContract" + } + ], + "expiration": 1770267837000, + "fee_limit": 200000000, + "ref_block_bytes": "af5b", + "ref_block_hash": "64a0e8e5926b22fc", + "timestamp": 1770267778282 + }, + "raw_data_hex": "0a02af5b220864a0e8e5926b22fc40c884bee1c2335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a154160e00625a95cbc180f290e2611c826f90eeba56f121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244095ea7b300000000000000000000000060e00625a95cbc180f290e2611c826f90eeba56f000000000000000000000000000000000000000000000000000000000000000070eab9bae1c23390018084af5f", + "txID": "fb21f360363fcf23f80bbf33f28c1d9972f0bf5e13f20e48430000c88ce88205", + "visible": false + } +} diff --git a/core/crates/gem_wallet_connect/testdata/tron_sign_transaction_nested.json b/core/crates/gem_wallet_connect/testdata/tron_sign_transaction_nested.json new file mode 100644 index 0000000000..64c9ae42c3 --- /dev/null +++ b/core/crates/gem_wallet_connect/testdata/tron_sign_transaction_nested.json @@ -0,0 +1,30 @@ +{ + "address": "TJoSEwEqt7cT3TUwmEoUYnYs5cZR3xSukM", + "transaction": { + "transaction": { + "raw_data": { + "contract": [ + { + "parameter": { + "type_url": "type.googleapis.com/protocol.TriggerSmartContract", + "value": { + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "data": "095ea7b300000000000000000000000060e00625a95cbc180f290e2611c826f90eeba56f0000000000000000000000000000000000000000000000000000000000000000", + "owner_address": "4160e00625a95cbc180f290e2611c826f90eeba56f" + } + }, + "type": "TriggerSmartContract" + } + ], + "expiration": 1770267837000, + "fee_limit": 200000000, + "ref_block_bytes": "af5b", + "ref_block_hash": "64a0e8e5926b22fc", + "timestamp": 1770267778282 + }, + "raw_data_hex": "0a02af5b220864a0e8e5926b22fc40c884bee1c2335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a154160e00625a95cbc180f290e2611c826f90eeba56f121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244095ea7b300000000000000000000000060e00625a95cbc180f290e2611c826f90eeba56f000000000000000000000000000000000000000000000000000000000000000070eab9bae1c23390018084af5f", + "txID": "fb21f360363fcf23f80bbf33f28c1d9972f0bf5e13f20e48430000c88ce88205", + "visible": false + } + } +} diff --git a/core/crates/gem_wallet_connect/testdata/tron_sign_transaction_response.json b/core/crates/gem_wallet_connect/testdata/tron_sign_transaction_response.json new file mode 100644 index 0000000000..8c96fe591e --- /dev/null +++ b/core/crates/gem_wallet_connect/testdata/tron_sign_transaction_response.json @@ -0,0 +1,28 @@ +{ + "raw_data": { + "contract": [ + { + "parameter": { + "type_url": "type.googleapis.com/protocol.TriggerSmartContract", + "value": { + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "data": "095ea7b300000000000000000000000060e00625a95cbc180f290e2611c826f90eeba56f0000000000000000000000000000000000000000000000000000000000000000", + "owner_address": "4160e00625a95cbc180f290e2611c826f90eeba56f" + } + }, + "type": "TriggerSmartContract" + } + ], + "expiration": 1770267837000, + "fee_limit": 200000000, + "ref_block_bytes": "af5b", + "ref_block_hash": "64a0e8e5926b22fc", + "timestamp": 1770267778282 + }, + "raw_data_hex": "0a02af5b220864a0e8e5926b22fc40c884bee1c2335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a154160e00625a95cbc180f290e2611c826f90eeba56f121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244095ea7b300000000000000000000000060e00625a95cbc180f290e2611c826f90eeba56f000000000000000000000000000000000000000000000000000000000000000070eab9bae1c23390018084af5f", + "signature": [ + "943d286dfd1fb6a2cd31c9af7a6cfd23ee062ec2e0abcf82c7daa0c7bb43ab04458e0e88ebe3a94060122cccc8fb4395e5eb922720327df04ae840139c729a1f00" + ], + "txID": "fb21f360363fcf23f80bbf33f28c1d9972f0bf5e13f20e48430000c88ce88205", + "visible": false +} diff --git a/core/crates/gem_xrp/Cargo.toml b/core/crates/gem_xrp/Cargo.toml new file mode 100644 index 0000000000..7408dba453 --- /dev/null +++ b/core/crates/gem_xrp/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "gem_xrp" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +signer = ["dep:bigdecimal", "dep:gem_hash", "dep:signer", "dep:num-traits"] +rpc = ["dep:chrono", "dep:chain_traits", "dep:gem_client", "gem_jsonrpc/client"] +reqwest = ["gem_client/reqwest", "gem_jsonrpc/reqwest"] +chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +async-trait = { workspace = true } +hex = { workspace = true } +primitives = { path = "../primitives" } +number_formatter = { path = "../number_formatter" } +num-bigint = { workspace = true } +bigdecimal = { workspace = true, optional = true } +bs58 = { workspace = true } +gem_hash = { path = "../gem_hash", optional = true } +signer = { path = "../signer", optional = true } +num-traits = { workspace = true, optional = true } + +chain_traits = { path = "../chain_traits", optional = true } +gem_client = { path = "../gem_client", optional = true } +gem_jsonrpc = { path = "../gem_jsonrpc" } + +chrono = { workspace = true, features = ["serde"], optional = true } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +reqwest = { workspace = true } + +primitives = { path = "../primitives", features = ["testkit"] } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_xrp/src/address.rs b/core/crates/gem_xrp/src/address.rs new file mode 100644 index 0000000000..ce331a4606 --- /dev/null +++ b/core/crates/gem_xrp/src/address.rs @@ -0,0 +1,76 @@ +use std::fmt; + +use primitives::Address; +#[cfg(feature = "signer")] +use primitives::SignerError; + +const CLASSIC_ACCOUNT_ID_LENGTH: usize = 20; +const CLASSIC_ADDRESS_LENGTH: usize = CLASSIC_ACCOUNT_ID_LENGTH + 1; +const CLASSIC_PREFIX: u8 = 0x00; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct XrpAddress([u8; CLASSIC_ACCOUNT_ID_LENGTH]); + +impl Address for XrpAddress { + fn try_parse(value: &str) -> Option { + let decoded = bs58::decode(value).with_alphabet(bs58::Alphabet::RIPPLE).with_check(Some(CLASSIC_PREFIX)).into_vec().ok()?; + + if decoded.len() != CLASSIC_ADDRESS_LENGTH { + return None; + } + + let account_id = decoded[1..].try_into().ok()?; + Some(Self(account_id)) + } + + fn as_bytes(&self) -> &[u8] { + &self.0 + } + + fn encode(&self) -> String { + let mut raw = Vec::with_capacity(CLASSIC_ADDRESS_LENGTH); + raw.push(CLASSIC_PREFIX); + raw.extend_from_slice(&self.0); + bs58::encode(raw).with_alphabet(bs58::Alphabet::RIPPLE).with_check().into_string() + } +} + +impl XrpAddress { + #[cfg(feature = "signer")] + pub(crate) fn parse(value: &str) -> Result { + Self::try_parse(value).ok_or_else(|| SignerError::invalid_input("invalid XRP classic address")) + } + + #[cfg(feature = "signer")] + pub(crate) fn as_bytes(&self) -> &[u8; CLASSIC_ACCOUNT_ID_LENGTH] { + &self.0 + } +} + +pub fn validate_address(address: &str) -> bool { + XrpAddress::is_valid(address) +} + +impl fmt::Display for XrpAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CLASSIC_ADDRESS: &str = "rnBFvgZphmN39GWzUJeUitaP22Fr9be75H"; + + #[test] + fn test_parse_addresses() { + let parsed = XrpAddress::from_str(CLASSIC_ADDRESS).unwrap(); + + assert_eq!(hex::encode(parsed.as_bytes()), "2decab42ca805119a9ba2ff305c9afa12f0b86a1"); + assert_eq!(parsed.to_string(), CLASSIC_ADDRESS); + assert!(validate_address(CLASSIC_ADDRESS)); + assert!(XrpAddress::from_str("invalid").is_err()); + assert!(!validate_address("rnBFvgZphmN39GWzUJeUitaP22Fr9be75J")); + } +} diff --git a/core/crates/gem_xrp/src/constants.rs b/core/crates/gem_xrp/src/constants.rs new file mode 100644 index 0000000000..f8958f268f --- /dev/null +++ b/core/crates/gem_xrp/src/constants.rs @@ -0,0 +1,4 @@ +pub const XRP_EPOCH_OFFSET_SECONDS: i64 = 946684800; // XRP epoch starts 2000-01-01 +pub const XRP_DEFAULT_ASSET_DECIMALS: u32 = 15; +pub const RESULT_SUCCESS: &str = "tesSUCCESS"; +pub const TRANSACTION_TYPE_PAYMENT: &str = "Payment"; diff --git a/core/crates/gem_xrp/src/lib.rs b/core/crates/gem_xrp/src/lib.rs new file mode 100644 index 0000000000..25fb8863b0 --- /dev/null +++ b/core/crates/gem_xrp/src/lib.rs @@ -0,0 +1,16 @@ +pub mod address; +pub mod constants; +pub mod models; + +#[cfg(feature = "signer")] +pub mod signer; + +pub use address::{XrpAddress, validate_address}; + +#[cfg(feature = "rpc")] +pub mod rpc; +#[cfg(feature = "rpc")] +pub use constants::*; + +#[cfg(feature = "rpc")] +pub mod provider; diff --git a/core/crates/gem_xrp/src/models/account.rs b/core/crates/gem_xrp/src/models/account.rs new file mode 100644 index 0000000000..1ed7147313 --- /dev/null +++ b/core/crates/gem_xrp/src/models/account.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XrpAccountResult { + pub account_data: Option, + pub ledger_current_index: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XrpAccount { + #[serde(rename = "Balance")] + pub balance: String, + #[serde(rename = "Sequence")] + pub sequence: i32, + #[serde(rename = "OwnerCount")] + pub owner_count: i32, + pub lines: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XrpAccountObjects { + pub account_objects: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct XrpAccountAsset { + pub low_limit: XrpAssetLine, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XrpAssetLine { + pub currency: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XrpAccountLinesResult { + pub lines: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XrpAccountLine { + pub account: String, + pub balance: String, + pub currency: String, +} diff --git a/core/crates/gem_xrp/src/models/asset.rs b/core/crates/gem_xrp/src/models/asset.rs new file mode 100644 index 0000000000..e130a4232f --- /dev/null +++ b/core/crates/gem_xrp/src/models/asset.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRPTokenId { + pub issuer: String, + pub currency: String, +} diff --git a/core/crates/gem_xrp/src/models/block.rs b/core/crates/gem_xrp/src/models/block.rs new file mode 100644 index 0000000000..e405f880e4 --- /dev/null +++ b/core/crates/gem_xrp/src/models/block.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +use super::UInt64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRPLatestBlock { + pub ledger_current_index: UInt64, +} diff --git a/core/crates/gem_xrp/src/models/fee.rs b/core/crates/gem_xrp/src/models/fee.rs new file mode 100644 index 0000000000..1946fe7d65 --- /dev/null +++ b/core/crates/gem_xrp/src/models/fee.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_u64_from_str; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRPFee { + pub drops: XRPDrops, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRPDrops { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub minimum_fee: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub median_fee: u64, +} diff --git a/core/crates/gem_xrp/src/models/mod.rs b/core/crates/gem_xrp/src/models/mod.rs new file mode 100644 index 0000000000..27505db67e --- /dev/null +++ b/core/crates/gem_xrp/src/models/mod.rs @@ -0,0 +1,18 @@ +pub mod account; +pub mod asset; +pub mod block; +pub mod fee; +pub mod result; +pub mod rpc; +pub mod transaction; + +pub type Int64 = i64; +pub type UInt64 = u64; + +pub use account::*; +pub use asset::*; +pub use block::*; +pub use fee::*; +pub use result::*; +pub use rpc::*; +pub use transaction::*; diff --git a/core/crates/gem_xrp/src/models/result.rs b/core/crates/gem_xrp/src/models/result.rs new file mode 100644 index 0000000000..90e356e518 --- /dev/null +++ b/core/crates/gem_xrp/src/models/result.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRPResult { + pub result: T, +} diff --git a/core/crates/gem_xrp/src/models/rpc.rs b/core/crates/gem_xrp/src/models/rpc.rs new file mode 100644 index 0000000000..8591435c9b --- /dev/null +++ b/core/crates/gem_xrp/src/models/rpc.rs @@ -0,0 +1,239 @@ +use num_bigint::BigUint; +use number_formatter::BigNumberFormatter; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_biguint_from_str, deserialize_u64_from_str}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LedgerCurrent { + pub ledger_current_index: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LedgerData { + pub ledger: Ledger, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AccountObjects { + pub account_objects: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountObject { + #[serde(rename = "LowLimit")] + pub low_limit: AccountObjectLimit, + #[serde(rename = "HighLimit")] + pub high_limit: AccountObjectLimit, + #[serde(rename = "Balance")] + pub balance: Balance, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Balance { + pub value: String, +} + +impl AccountObjectLimit { + pub fn symbol(&self) -> Option { + let currency_bytes: Vec = hex::decode(&self.currency).ok()?; + String::from_utf8(currency_bytes.into_iter().filter(|b| *b != 0).collect()).ok() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountObjectLimit { + pub currency: String, + pub issuer: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ledger { + pub close_time: i64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub ledger_index: u64, + pub transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AccountLedger { + pub transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountLedgerTransaction { + pub hash: String, + pub ledger_index: i64, + pub tx_json: AccountLedgerTransactionJSON, + #[serde(rename = "meta")] + pub meta: TransactionMeta, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountLedgerTransactionJSON { + #[serde(rename = "Fee")] + pub fee: Option, + #[serde(rename = "Account")] + pub account: Option, + #[serde(rename = "DeliverMax")] + pub amount: Option, + #[serde(rename = "Destination")] + pub destination: Option, + #[serde(rename = "TransactionType")] + pub transaction_type: String, + pub date: i64, + #[serde(rename = "DestinationTag")] + pub destination_tag: Option, + #[serde(rename = "Memos")] + pub memos: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transaction { + pub hash: String, + #[serde(rename = "Fee")] + pub fee: Option, + #[serde(rename = "Account")] + pub account: Option, + #[serde(rename = "Amount")] + pub amount: Option, + #[serde(rename = "Destination")] + pub destination: Option, + #[serde(rename = "TransactionType")] + pub transaction_type: String, + pub date: Option, + #[serde(rename = "DestinationTag")] + pub destination_tag: Option, + #[serde(rename = "Memos")] + pub memos: Option>, + #[serde(rename = "metaData", alias = "meta")] + pub meta: TransactionMeta, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionMeta { + #[serde(rename = "TransactionResult")] + pub result: String, + pub delivered_amount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Amount { + Null, + Str(String), + Amount(AmountCurrency), +} + +impl Amount { + pub fn as_value_string(&self) -> Option { + match self { + Amount::Null => None, + Amount::Str(amount) => Some(amount.clone()), + Amount::Amount(amount) => BigNumberFormatter::value_from_amount(&amount.value, 15).ok(), + } + } + + pub fn token_id(&self) -> Option { + match self { + Amount::Null => None, + Amount::Str(_) => None, + Amount::Amount(amount) => amount.issuer.clone().or(amount.mpt_issuance_id.clone()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AmountCurrency { + pub value: String, + pub issuer: Option, + pub currency: Option, + pub mpt_issuance_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionMemo { + #[serde(rename = "Memo")] + pub memo: TransactionMemoData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionMemoData { + #[serde(rename = "MemoData")] + pub data: Option, +} + +impl TransactionMemo { + pub fn decoded_data(&self) -> Option { + primitives::hex::decode_hex_utf8(self.memo.data.as_ref()?) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountInfo { + #[serde(rename = "Balance", deserialize_with = "deserialize_u64_from_str")] + pub balance: u64, + #[serde(rename = "Sequence")] + pub sequence: u64, + #[serde(rename = "OwnerCount")] + pub owner_count: u32, + #[serde(rename = "Account")] + pub account: Option, + #[serde(rename = "Flags")] + pub flags: Option, + #[serde(rename = "LedgerEntryType")] + pub ledger_entry_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountInfoResult { + pub account_data: Option, + pub ledger_current_index: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Fee { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub minimum_fee: u64, + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub median_fee: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeesResult { + pub drops: Fee, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionBroadcast { + pub accepted: Option, + pub engine_result_message: Option, + pub hash: Option, + pub tx_json: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionJson { + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionStatus { + pub status: String, + #[serde(rename = "Fee")] + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub fee: BigUint, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_account_object_symbol_rlusd() { + let account_object = AccountObjectLimit { + currency: "524C555344000000000000000000000000000000".to_string(), + issuer: "".to_string(), + }; + assert_eq!(account_object.symbol(), Some("RLUSD".to_string())); + } +} diff --git a/core/crates/gem_xrp/src/models/transaction.rs b/core/crates/gem_xrp/src/models/transaction.rs new file mode 100644 index 0000000000..fe9e87595a --- /dev/null +++ b/core/crates/gem_xrp/src/models/transaction.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRPTransactionBroadcast { + pub accepted: Option, + pub engine_result_message: Option, + pub error_exception: Option, + pub tx_json: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRPTransaction { + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRPTransactionStatus { + pub status: String, +} diff --git a/core/crates/gem_xrp/src/provider/balances.rs b/core/crates/gem_xrp/src/provider/balances.rs new file mode 100644 index 0000000000..5e3f4ca416 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/balances.rs @@ -0,0 +1,109 @@ +use async_trait::async_trait; +use chain_traits::ChainBalances; +use std::error::Error; + +use gem_client::Client; +use primitives::AssetBalance; + +use crate::{ + provider::balances_mapper::{map_balance_assets, map_balance_coin, map_balance_tokens}, + rpc::client::XRPClient, +}; + +#[async_trait] +impl ChainBalances for XRPClient { + async fn get_balance_coin(&self, address: String) -> Result> { + let account = self.get_account_info(&address).await?; + let reserved_amount = self.get_chain().account_activation_fee().unwrap_or(0) as u64; + + map_balance_coin(account, self.get_chain().as_asset_id(), reserved_amount) + } + + async fn get_balance_tokens(&self, address: String, token_ids: Vec) -> Result, Box> { + let objects = self.get_account_objects(&address).await?; + Ok(map_balance_tokens(&objects, token_ids, self.get_chain())) + } + + async fn get_balance_staking(&self, _address: String) -> Result, Box> { + Ok(None) + } + + async fn get_balance_assets(&self, address: String) -> Result, Box> { + let objects = self.get_account_objects(&address).await?; + Ok(map_balance_assets(&objects, self.get_chain())) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use primitives::Chain; + + use super::*; + use crate::provider::testkit::{TEST_ADDRESS, TEST_ADDRESS_EMPTY, create_xrp_test_client}; + + #[tokio::test] + async fn test_xrp_get_balance_coin() -> Result<(), Box> { + let client = create_xrp_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS.to_string()).await?; + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + println!("Balance: {:?} {}", balance.balance.available, balance.asset_id); + Ok(()) + } + + #[tokio::test] + async fn test_xrp_get_balance_coin_empty_account() -> Result<(), Box> { + let client = create_xrp_test_client(); + let balance = client.get_balance_coin(TEST_ADDRESS_EMPTY.to_string()).await?; + assert!(balance.balance.available == num_bigint::BigUint::from(0u32)); + Ok(()) + } + + #[tokio::test] + async fn test_xrp_get_balance_tokens() -> Result<(), Box> { + let client = create_xrp_test_client(); + let token_ids = vec![ + "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De".to_string(), // RLUSD + ]; + let balances = client.get_balance_tokens(TEST_ADDRESS.to_string(), token_ids).await?; + + assert_eq!(balances.len(), 1); + for balance in &balances { + assert_eq!(balance.asset_id.chain, Chain::Xrp); + assert!(balance.balance.available > num_bigint::BigUint::from(0u32)); + } + Ok(()) + } + + #[tokio::test] + async fn test_xrp_get_balance_tokens_empty_account() -> Result<(), Box> { + let client = create_xrp_test_client(); + let token_ids = vec![ + "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De".to_string(), // RLUSD + ]; + let balances = client.get_balance_tokens(TEST_ADDRESS_EMPTY.to_string(), token_ids).await?; + + assert_eq!(balances.len(), 1); + + for balance in &balances { + assert_eq!(balance.asset_id.chain, Chain::Xrp); + assert!(balance.balance.available == num_bigint::BigUint::from(0u32)); + } + Ok(()) + } + + #[tokio::test] + async fn test_xrp_get_balance_assets() -> Result<(), Box> { + let client = create_xrp_test_client(); + let address = TEST_ADDRESS.to_string(); + let assets = client.get_balance_assets(address).await?; + + println!("Assets: {}", assets.len()); + + assert!(!assets.is_empty()); + + for asset in assets { + assert_eq!(asset.asset_id.chain, Chain::Xrp); + } + Ok(()) + } +} diff --git a/core/crates/gem_xrp/src/provider/balances_mapper.rs b/core/crates/gem_xrp/src/provider/balances_mapper.rs new file mode 100644 index 0000000000..fea9659090 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/balances_mapper.rs @@ -0,0 +1,150 @@ +use crate::{ + XRP_DEFAULT_ASSET_DECIMALS, + models::rpc::{AccountInfo, AccountObjects}, +}; +use num_bigint::BigUint; +use number_formatter::BigNumberFormatter; +use primitives::{AssetBalance, AssetId, Balance, Chain}; +use std::{collections::HashMap, error::Error}; + +pub fn map_balance_coin(account: Option, asset_id: AssetId, reserved_amount: u64) -> Result> { + let available = if let Some(account) = account { + account.balance.saturating_sub(reserved_amount) + } else { + 0 + }; + + Ok(AssetBalance::new_balance( + asset_id, + Balance::with_reserved(BigUint::from(available), BigUint::from(reserved_amount)), + )) +} + +fn account_objects_to_balances(objects: &AccountObjects, chain: Chain) -> Vec { + objects + .account_objects + .as_ref() + .unwrap_or(&Vec::new()) + .iter() + .filter_map(|obj| { + if obj.high_limit.currency.len() <= 3 { + return None; + } + + let value = BigNumberFormatter::value_from_amount_biguint(&obj.balance.value, XRP_DEFAULT_ASSET_DECIMALS).unwrap_or_default(); + let is_active = value > BigUint::from(0u32); + let asset_id = AssetId::from_token(chain, &obj.high_limit.issuer); + let balance = Balance::coin_balance(value); + + Some(AssetBalance::new_with_active(asset_id, balance, is_active)) + }) + .collect() +} + +pub fn map_balance_tokens(objects: &AccountObjects, token_ids: Vec, chain: Chain) -> Vec { + let available_balances: HashMap = account_objects_to_balances(objects, chain) + .into_iter() + .filter_map(|x| x.asset_id.token_id.clone().map(|token_id| (token_id, x))) + .collect(); + + token_ids + .into_iter() + .map(|token_id| { + available_balances.get(&token_id).cloned().unwrap_or_else(|| { + let asset_id = AssetId::from_token(chain, &token_id); + let balance = Balance::coin_balance(BigUint::from(0u32)); + AssetBalance::new_with_active(asset_id, balance, false) + }) + }) + .collect() +} + +pub fn map_balance_assets(objects: &AccountObjects, chain: Chain) -> Vec { + account_objects_to_balances(objects, chain) + .into_iter() + .filter(|x| x.balance.available > BigUint::from(0u32)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::result::XRPResult; + use crate::models::rpc::AccountInfo; + use primitives::{AssetId, Chain}; + + #[test] + fn test_map_native_balance() { + let account = AccountInfo { + balance: 10000000, // 10 XRP + sequence: 100, + owner_count: 2, + account: None, + flags: None, + ledger_entry_type: None, + }; + + let asset_id = AssetId::from_chain(Chain::Xrp); + let reserved_amount = 1000000; // 1 XRP reserve + + let result = map_balance_coin(Some(account), asset_id.clone(), reserved_amount).unwrap(); + + assert_eq!(result.asset_id, asset_id); + assert_eq!(result.balance.available, BigUint::from(9000000_u64)); // 10 - 1 = 9 XRP + assert_eq!(result.balance.reserved, BigUint::from(1000000_u64)); + } + + #[test] + fn test_map_native_balance_insufficient() { + let account = AccountInfo { + balance: 500000, // 0.5 XRP + sequence: 100, + owner_count: 2, + account: None, + flags: None, + ledger_entry_type: None, + }; + + let asset_id = AssetId::from_chain(Chain::Xrp); + let reserved_amount = 1000000; // 1 XRP reserve + + let result = map_balance_coin(Some(account), asset_id.clone(), reserved_amount).unwrap(); + + assert_eq!(result.asset_id, asset_id); + assert_eq!(result.balance.available, BigUint::from(0u32)); // Insufficient balance + assert_eq!(result.balance.reserved, BigUint::from(1000000_u64)); + } + + #[test] + fn test_map_balance_tokens() { + let response: XRPResult = serde_json::from_str(include_str!("../testdata/accounts_objects_tokens.json")).unwrap(); + let account_objects = response.result; + + let token_ids = vec!["rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De".to_string()]; + + let result = map_balance_tokens(&account_objects, token_ids, Chain::Xrp); + + assert_eq!(result.len(), 1); + + let balance = &result[0]; + assert_eq!(balance.asset_id, AssetId::from_token(Chain::Xrp, "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De")); + assert_eq!(balance.balance.available, BigUint::from(171000000000000_u64)); + assert!(balance.is_active); + } + + #[test] + fn test_map_balance_assets() { + let response: XRPResult = serde_json::from_str(include_str!("../testdata/accounts_objects_tokens.json")).unwrap(); + let account_objects = response.result; + + let result = map_balance_assets(&account_objects, Chain::Xrp); + + assert!(!result.is_empty()); + for balance in &result { + assert_eq!(balance.asset_id.chain, Chain::Xrp); + assert!(balance.asset_id.token_id.is_some()); + assert!(balance.balance.available > BigUint::from(0u32)); + assert!(balance.is_active); + } + } +} diff --git a/core/crates/gem_xrp/src/provider/mod.rs b/core/crates/gem_xrp/src/provider/mod.rs new file mode 100644 index 0000000000..e23e04be18 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/mod.rs @@ -0,0 +1,18 @@ +pub mod balances; +pub mod balances_mapper; +pub mod preload; +pub mod preload_mapper; +pub mod request_classifier; +pub mod state; +pub mod state_mapper; +pub mod testkit; +pub mod token; +pub mod token_mapper; +pub mod transaction_broadcast; +pub mod transaction_broadcast_mapper; +pub mod transaction_state; +pub mod transaction_state_mapper; +pub mod transactions; +pub mod transactions_mapper; + +pub struct BroadcastProvider; diff --git a/core/crates/gem_xrp/src/provider/preload.rs b/core/crates/gem_xrp/src/provider/preload.rs new file mode 100644 index 0000000000..08c0ad9b15 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/preload.rs @@ -0,0 +1,36 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionLoad; +use num_bigint::BigInt; +use std::error::Error; + +use gem_client::Client; +use primitives::{FeePriority, FeeRate, GasPriceType, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput}; + +use crate::{provider::preload_mapper::map_transaction_preload, rpc::client::XRPClient}; + +#[async_trait] +impl ChainTransactionLoad for XRPClient { + async fn get_transaction_preload(&self, input: TransactionPreloadInput) -> Result> { + let result = self.get_account_info_full(&input.sender_address).await?; + map_transaction_preload(result) + } + + async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { + Ok(TransactionLoadData { + fee: input.default_fee(), + metadata: input.metadata, + }) + } + + async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { + let fees = self.get_fees().await?; + let minimum_fee = fees.drops.minimum_fee; + let median_fee = fees.drops.median_fee; + + Ok(vec![ + FeeRate::new(FeePriority::Slow, GasPriceType::regular(BigInt::from(std::cmp::max(minimum_fee, median_fee / 2)))), + FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(median_fee))), + FeeRate::new(FeePriority::Fast, GasPriceType::regular(BigInt::from(median_fee * 2))), + ]) + } +} diff --git a/core/crates/gem_xrp/src/provider/preload_mapper.rs b/core/crates/gem_xrp/src/provider/preload_mapper.rs new file mode 100644 index 0000000000..7ec6f104b1 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/preload_mapper.rs @@ -0,0 +1,56 @@ +use crate::models::rpc::AccountInfoResult; +use primitives::TransactionLoadMetadata; +use std::error::Error; + +pub fn map_transaction_preload(account_result: AccountInfoResult) -> Result> { + if let Some(account_data) = account_result.account_data { + Ok(TransactionLoadMetadata::Xrp { + sequence: account_data.sequence, + block_number: account_result.ledger_current_index, + }) + } else { + Err("Account not found".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::rpc::{AccountInfo, AccountInfoResult}; + + #[test] + fn test_map_transaction_preload_with_account_data() { + let account_result = AccountInfoResult { + account_data: Some(AccountInfo { + balance: 1000000, + sequence: 12345, + owner_count: 0, + account: Some("rAccount123".to_string()), + flags: Some(0), + ledger_entry_type: Some("AccountRoot".to_string()), + }), + ledger_current_index: 67890, + }; + + let result = map_transaction_preload(account_result).unwrap(); + + if let TransactionLoadMetadata::Xrp { sequence, block_number } = result { + assert_eq!(sequence, 12345); + assert_eq!(block_number, 67890); + } else { + panic!("Expected XRP metadata"); + } + } + + #[test] + fn test_map_transaction_preload_without_account_data() { + let account_result = AccountInfoResult { + account_data: None, + ledger_current_index: 67890, + }; + + let result = map_transaction_preload(account_result); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Account not found"); + } +} diff --git a/core/crates/gem_xrp/src/provider/request_classifier.rs b/core/crates/gem_xrp/src/provider/request_classifier.rs new file mode 100644 index 0000000000..f48a7cca4e --- /dev/null +++ b/core/crates/gem_xrp/src/provider/request_classifier.rs @@ -0,0 +1,14 @@ +use chain_traits::ChainRequestClassifier; +use primitives::{ChainRequest, ChainRequestType}; + +use crate::provider::BroadcastProvider; + +impl ChainRequestClassifier for BroadcastProvider { + fn classify_request(&self, request: ChainRequest<'_>) -> ChainRequestType { + if request.is_json_rpc_method("submit") { + ChainRequestType::Broadcast + } else { + ChainRequestType::Unknown + } + } +} diff --git a/core/crates/gem_xrp/src/provider/state.rs b/core/crates/gem_xrp/src/provider/state.rs new file mode 100644 index 0000000000..f2259d1cec --- /dev/null +++ b/core/crates/gem_xrp/src/provider/state.rs @@ -0,0 +1,53 @@ +use async_trait::async_trait; +use chain_traits::ChainState; +use std::error::Error; + +use crate::provider::state_mapper; +use crate::rpc::client::XRPClient; +use gem_client::Client; +use primitives::NodeSyncStatus; + +#[async_trait] +impl ChainState for XRPClient { + async fn get_chain_id(&self) -> Result> { + Ok("".to_string()) + } + + async fn get_block_latest_number(&self) -> Result> { + Ok(self.get_ledger_current().await?.ledger_current_index as u64) + } + + async fn get_node_status(&self) -> Result> { + let ledger_info = self.get_ledger_current().await?; + state_mapper::map_node_status(&ledger_info) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::create_xrp_test_client; + + #[tokio::test] + async fn test_get_xrp_latest_block() -> Result<(), Box> { + let client = create_xrp_test_client(); + let block_number = client.get_block_latest_number().await?; + + assert!(block_number > 80_000_000, "XRP ledger index should be above 80M, got: {}", block_number); + println!("XRP latest ledger: {}", block_number); + + Ok(()) + } + + #[tokio::test] + async fn test_get_node_status() -> Result<(), Box> { + let client = create_xrp_test_client(); + let node_status = client.get_node_status().await?; + + assert!(node_status.in_sync); + assert!(node_status.latest_block_number.is_some()); + assert!(node_status.latest_block_number.unwrap_or(0) > 80_000_000); + + Ok(()) + } +} diff --git a/core/crates/gem_xrp/src/provider/state_mapper.rs b/core/crates/gem_xrp/src/provider/state_mapper.rs new file mode 100644 index 0000000000..3120b68c80 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/state_mapper.rs @@ -0,0 +1,22 @@ +use crate::models::rpc::LedgerCurrent; +use primitives::NodeSyncStatus; +use std::error::Error; + +pub fn map_node_status(ledger_info: &LedgerCurrent) -> Result> { + Ok(NodeSyncStatus::synced(ledger_info.ledger_current_index as u64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_node_status() { + let ledger_info = LedgerCurrent { ledger_current_index: 80123456 }; + let mapped = map_node_status(&ledger_info).unwrap(); + + assert!(mapped.in_sync); + assert_eq!(mapped.latest_block_number, Some(80123456)); + assert_eq!(mapped.current_block_number, Some(80123456)); + } +} diff --git a/core/crates/gem_xrp/src/provider/testkit.rs b/core/crates/gem_xrp/src/provider/testkit.rs new file mode 100644 index 0000000000..30a35632b2 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/testkit.rs @@ -0,0 +1,24 @@ +#[cfg(all(test, feature = "chain_integration_tests"))] +use crate::rpc::XRPClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_client::ReqwestClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use gem_jsonrpc::client::JsonRpcClient; +#[cfg(all(test, feature = "chain_integration_tests"))] +use settings::testkit::get_test_settings; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS: &str = "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub const TEST_ADDRESS_EMPTY: &str = "rPGZTtsiBXS8izwJcktUmxtzZSic1jbpLi"; +#[cfg(test)] +pub const TEST_TRANSACTION_ID: &str = "474F58E6C78F1DE8542036AB3C16E2B5A4089241DEE3E58142154DC3CA0E8271"; + +#[cfg(all(test, feature = "chain_integration_tests"))] +pub fn create_xrp_test_client() -> XRPClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.xrp.url, reqwest::Client::new()); + let rpc_client = JsonRpcClient::new(reqwest_client); + XRPClient::new(rpc_client) +} diff --git a/core/crates/gem_xrp/src/provider/token.rs b/core/crates/gem_xrp/src/provider/token.rs new file mode 100644 index 0000000000..b2dfe97b90 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/token.rs @@ -0,0 +1,37 @@ +use async_trait::async_trait; +use chain_traits::ChainToken; +use std::error::Error; + +use crate::XRP_DEFAULT_ASSET_DECIMALS; +use crate::rpc::client::XRPClient; +use gem_client::Client; +use primitives::{Asset, AssetId, AssetType}; + +#[async_trait] +impl ChainToken for XRPClient { + async fn get_token_data(&self, token_id: String) -> Result> { + let objects = self.get_account_objects(&token_id).await?; + + if let Some(asset) = objects.account_objects.unwrap_or_default().first() { + let currency = &asset.low_limit.currency; + let currency_bytes = hex::decode(currency.trim_end_matches('0')).map_err(|_| "Invalid currency hex")?; + let symbol = String::from_utf8(currency_bytes.into_iter().filter(|&b| b != 0).collect()).unwrap_or_else(|_| currency.clone()); + + Ok(Asset { + id: AssetId::from_token(self.chain, &token_id), + chain: self.chain, + token_id: Some(token_id.clone()), + name: symbol.clone(), + symbol, + decimals: XRP_DEFAULT_ASSET_DECIMALS as i32, + asset_type: AssetType::TOKEN, + }) + } else { + Err("Token not found".into()) + } + } + + fn get_is_token_address(&self, token_id: &str) -> bool { + token_id.len() == 34 && token_id.starts_with('r') + } +} diff --git a/core/crates/gem_xrp/src/provider/token_mapper.rs b/core/crates/gem_xrp/src/provider/token_mapper.rs new file mode 100644 index 0000000000..a2f78802f0 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/token_mapper.rs @@ -0,0 +1,39 @@ +use crate::XRP_DEFAULT_ASSET_DECIMALS; +use crate::models::rpc::AccountObject; +use primitives::{Asset, AssetId, AssetType, Chain}; +use std::error::Error; + +pub fn map_currency_hex_to_symbol(currency: &str) -> Result> { + let currency_bytes = hex::decode(currency.trim_end_matches('0')).map_err(|_| "Invalid currency hex")?; + let symbol = String::from_utf8(currency_bytes.into_iter().filter(|&b| b != 0).collect()).unwrap_or_else(|_| currency.to_string()); + Ok(symbol) +} + +pub fn map_token_data(object: &AccountObject, token_id: String, chain: Chain) -> Result> { + let symbol = map_currency_hex_to_symbol(&object.low_limit.currency)?; + + Ok(Asset { + id: AssetId::from_token(chain, &token_id), + chain, + token_id: Some(token_id.clone()), + name: symbol.clone(), + symbol, + decimals: XRP_DEFAULT_ASSET_DECIMALS as i32, + asset_type: AssetType::TOKEN, + }) +} + +pub fn is_valid_token_address(token_id: &str) -> bool { + token_id.len() == 34 && token_id.starts_with('r') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_valid_token_address() { + assert!(is_valid_token_address("rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH")); + assert!(!is_valid_token_address("invalid")); + } +} diff --git a/core/crates/gem_xrp/src/provider/transaction_broadcast.rs b/core/crates/gem_xrp/src/provider/transaction_broadcast.rs new file mode 100644 index 0000000000..65079abfc1 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/transaction_broadcast.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactionBroadcast, ChainTransactionDecode}; +use std::error::Error; + +use gem_client::Client; +use primitives::BroadcastOptions; + +use crate::{ + provider::{BroadcastProvider, transaction_broadcast_mapper::map_transaction_broadcast_response_from_str, transactions_mapper::map_transaction_broadcast}, + rpc::client::XRPClient, +}; + +#[async_trait] +impl ChainTransactionBroadcast for XRPClient { + async fn transaction_broadcast(&self, data: String, _options: BroadcastOptions) -> Result> { + let response = self.broadcast_transaction(&data).await?; + map_transaction_broadcast(&response) + } +} + +impl ChainTransactionDecode for BroadcastProvider { + fn decode_transaction_broadcast(&self, response: &str) -> Option { + map_transaction_broadcast_response_from_str(response).ok() + } +} diff --git a/core/crates/gem_xrp/src/provider/transaction_broadcast_mapper.rs b/core/crates/gem_xrp/src/provider/transaction_broadcast_mapper.rs new file mode 100644 index 0000000000..0b8c1c07f3 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/transaction_broadcast_mapper.rs @@ -0,0 +1,11 @@ +use std::error::Error; + +use gem_jsonrpc::types::JsonRpcResult; + +use crate::models::rpc::TransactionBroadcast; +use crate::provider::transactions_mapper::map_transaction_broadcast; + +pub fn map_transaction_broadcast_response_from_str(response: &str) -> Result> { + let response = serde_json::from_str::>(response)?.take()?; + map_transaction_broadcast(&response) +} diff --git a/core/crates/gem_xrp/src/provider/transaction_state.rs b/core/crates/gem_xrp/src/provider/transaction_state.rs new file mode 100644 index 0000000000..d46553757f --- /dev/null +++ b/core/crates/gem_xrp/src/provider/transaction_state.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; +use chain_traits::ChainTransactionState; +use std::error::Error; + +use gem_client::Client; +use primitives::{TransactionStateRequest, TransactionUpdate}; + +use crate::{provider::transaction_state_mapper::map_transaction_status, rpc::client::XRPClient}; + +#[async_trait] +impl ChainTransactionState for XRPClient { + async fn get_transaction_status(&self, request: TransactionStateRequest) -> Result> { + let status = self.get_transaction_status(&request.id).await?; + Ok(map_transaction_status(&status)) + } +} diff --git a/core/crates/gem_xrp/src/provider/transaction_state_mapper.rs b/core/crates/gem_xrp/src/provider/transaction_state_mapper.rs new file mode 100644 index 0000000000..966febe7c7 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/transaction_state_mapper.rs @@ -0,0 +1,52 @@ +use num_bigint::BigInt; +use primitives::{TransactionChange, TransactionState, TransactionUpdate}; + +use crate::models::rpc::TransactionStatus; + +pub fn map_transaction_status(status: &TransactionStatus) -> TransactionUpdate { + let state = match status.status.as_str() { + "success" => TransactionState::Confirmed, + "failed" => TransactionState::Failed, + _ => TransactionState::Pending, + }; + + let changes = vec![TransactionChange::NetworkFee(BigInt::from(status.fee.clone()))]; + + TransactionUpdate { state, changes } +} + +#[cfg(test)] +mod tests { + use super::*; + use num_bigint::BigUint; + + #[test] + fn test_map_transaction_status_success() { + let status = TransactionStatus { + status: "success".to_string(), + fee: BigUint::from(12_u32), + }; + let result = map_transaction_status(&status); + assert_eq!(result.state, TransactionState::Confirmed); + } + + #[test] + fn test_map_transaction_status_failed() { + let status = TransactionStatus { + status: "failed".to_string(), + fee: BigUint::from(8_u32), + }; + let result = map_transaction_status(&status); + assert_eq!(result.state, TransactionState::Failed); + } + + #[test] + fn test_map_transaction_status_pending() { + let status = TransactionStatus { + status: "unknown".to_string(), + fee: BigUint::from(4_u32), + }; + let result = map_transaction_status(&status); + assert_eq!(result.state, TransactionState::Pending); + } +} diff --git a/core/crates/gem_xrp/src/provider/transactions.rs b/core/crates/gem_xrp/src/provider/transactions.rs new file mode 100644 index 0000000000..df82873c9c --- /dev/null +++ b/core/crates/gem_xrp/src/provider/transactions.rs @@ -0,0 +1,44 @@ +use async_trait::async_trait; +use chain_traits::{ChainTransactions, TransactionsRequest}; +use std::error::Error; + +use gem_client::Client; +use primitives::Transaction; + +use crate::provider::transactions_mapper::{map_direct_transaction, map_transactions_by_address, map_transactions_by_block}; +use crate::rpc::client::XRPClient; + +#[async_trait] +impl ChainTransactions for XRPClient { + async fn get_transactions_by_block(&self, block: u64) -> Result, Box> { + let ledger = self.get_block_transactions(block).await?; + Ok(map_transactions_by_block(ledger)) + } + + async fn get_transactions_by_address(&self, request: TransactionsRequest) -> Result, Box> { + let TransactionsRequest { address, limit, .. } = request; + let limit = limit.unwrap_or(100); + let account_ledger = self.get_account_transactions(address, limit).await?; + Ok(map_transactions_by_address(account_ledger)) + } + + async fn get_transaction_by_hash(&self, hash: String) -> Result, Box> { + let transaction = self.get_transaction(&hash).await?; + Ok(map_direct_transaction(self.get_chain(), transaction)) + } +} + +#[cfg(all(test, feature = "chain_integration_tests"))] +mod chain_integration_tests { + use super::*; + use crate::provider::testkit::{TEST_TRANSACTION_ID, create_xrp_test_client}; + + #[tokio::test] + async fn test_xrp_get_transaction_by_hash() -> Result<(), Box> { + let client = create_xrp_test_client(); + let transaction = client.get_transaction_by_hash(TEST_TRANSACTION_ID.to_string()).await?.unwrap(); + + assert_eq!(transaction.hash, TEST_TRANSACTION_ID); + Ok(()) + } +} diff --git a/core/crates/gem_xrp/src/provider/transactions_mapper.rs b/core/crates/gem_xrp/src/provider/transactions_mapper.rs new file mode 100644 index 0000000000..2ccf9f11b8 --- /dev/null +++ b/core/crates/gem_xrp/src/provider/transactions_mapper.rs @@ -0,0 +1,260 @@ +use crate::models::rpc::{AccountLedger, AccountLedgerTransaction, AccountObject, Amount, Ledger, Transaction as XrpTransaction, TransactionBroadcast, TransactionMemo}; +use crate::{RESULT_SUCCESS, TRANSACTION_TYPE_PAYMENT, XRP_DEFAULT_ASSET_DECIMALS, XRP_EPOCH_OFFSET_SECONDS}; +use chrono::DateTime; +use primitives::{Asset, AssetId, AssetType, Transaction, TransactionState, TransactionType, chain::Chain}; +use std::error::Error; + +pub fn map_transaction_broadcast(broadcast_result: &TransactionBroadcast) -> Result> { + if let Some(accepted) = broadcast_result.accepted + && !accepted + { + if let Some(error_msg) = &broadcast_result.engine_result_message { + return Err(format!("Transaction rejected: {}", error_msg).into()); + } + return Err("Transaction was not accepted".into()); + } + + if let Some(hash) = &broadcast_result.hash { + Ok(hash.clone()) + } else if let Some(tx_json) = &broadcast_result.tx_json { + Ok(tx_json.hash.clone()) + } else { + Err("Transaction broadcast failed - no hash returned".into()) + } +} + +pub fn map_transactions_by_block(ledger: crate::models::rpc::Ledger) -> Vec { + map_block_transactions(Chain::Xrp, ledger) +} + +pub fn map_transactions_by_address(account_ledger: crate::models::rpc::AccountLedger) -> Vec { + map_account_transactions(Chain::Xrp, account_ledger) +} + +pub fn map_block_transactions(chain: Chain, ledger: Ledger) -> Vec { + ledger + .transactions + .into_iter() + .flat_map(|x| map_block_transaction(chain, x, ledger.close_time)) + .collect::>() +} + +pub fn map_account_transactions(chain: Chain, ledger: AccountLedger) -> Vec { + ledger + .transactions + .into_iter() + .flat_map(|x| map_account_transaction(chain, x)) + .collect::>() +} + +fn map_transaction_common( + chain: Chain, + hash: String, + account: Option, + destination: Option, + amount: Option, + destination_tag: Option, + memos: Option>, + fee: Option, + transaction_type: String, + meta_result: String, + timestamp: i64, +) -> Option { + if transaction_type == TRANSACTION_TYPE_PAYMENT { + let memo = memos + .as_ref() + .and_then(|m| m.first()) + .and_then(|m| m.decoded_data()) + .or_else(|| destination_tag.map(|x| x.to_string())); + let value = amount.clone()?.as_value_string()?; + let token_id = amount?.token_id(); + let asset_id = AssetId::from(chain, token_id); + let created_at = DateTime::from_timestamp(timestamp, 0)?; + + let state = if meta_result == RESULT_SUCCESS { + TransactionState::Confirmed + } else { + TransactionState::Failed + }; + + return Some(Transaction::new( + hash, + asset_id.clone(), + account.unwrap_or_default(), + destination.unwrap_or_default(), + None, + TransactionType::Transfer, + state, + fee.unwrap_or_default(), + chain.as_asset_id(), + value, + memo, + None, + created_at, + )); + } + None +} + +pub fn map_account_transaction(chain: Chain, transaction: AccountLedgerTransaction) -> Option { + map_transaction_common( + chain, + transaction.hash, + transaction.tx_json.account, + transaction.tx_json.destination, + transaction.tx_json.amount, + transaction.tx_json.destination_tag, + transaction.tx_json.memos, + transaction.tx_json.fee, + transaction.tx_json.transaction_type, + transaction.meta.result, + XRP_EPOCH_OFFSET_SECONDS + transaction.tx_json.date, + ) +} + +pub fn map_block_transaction(chain: Chain, transaction: XrpTransaction, close_time: i64) -> Option { + map_transaction_common( + chain, + transaction.hash, + transaction.account, + transaction.destination, + transaction.amount, + transaction.destination_tag, + transaction.memos, + transaction.fee, + transaction.transaction_type, + transaction.meta.result, + XRP_EPOCH_OFFSET_SECONDS + close_time, + ) +} + +pub fn map_direct_transaction(chain: Chain, transaction: XrpTransaction) -> Option { + map_transaction_common( + chain, + transaction.hash, + transaction.account, + transaction.destination, + transaction.amount, + transaction.destination_tag, + transaction.memos, + transaction.fee, + transaction.transaction_type, + transaction.meta.result, + XRP_EPOCH_OFFSET_SECONDS + transaction.date?, + ) +} + +pub fn map_token_data(chain: Chain, account_objects: Vec) -> Result> { + let account = account_objects.first().ok_or("No account objects found for token_id")?; + let symbol = account.low_limit.symbol().ok_or("Invalid currency")?; + let token_id = &account.low_limit.issuer; + + Ok(Asset::new( + AssetId::from_token(chain, token_id), + symbol.clone(), + symbol.clone(), + XRP_DEFAULT_ASSET_DECIMALS as i32, + AssetType::TOKEN, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::rpc::{LedgerData, TransactionBroadcast}; + use crate::provider::testkit::TEST_TRANSACTION_ID; + use gem_jsonrpc::types::JsonRpcResult; + + #[test] + fn test_map_transaction_broadcast_success() { + let broadcast = serde_json::from_str::>(include_str!("../testdata/transaction_broadcast_success.json")) + .unwrap() + .take() + .unwrap(); + + let result = map_transaction_broadcast(&broadcast); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "04F53F220DD1BCB7CCF279D66FFB986EA41383EFC9378CA1EBF1823D7C89264F"); + } + + #[test] + fn test_map_transaction_broadcast_failed() { + let broadcast = serde_json::from_str::>(include_str!("../testdata/transaction_broadcast_failed.json")) + .unwrap() + .take() + .unwrap(); + + let result = map_transaction_broadcast(&broadcast); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Transaction rejected: Ledger sequence too high."); + } + + #[test] + fn test_map_account_transactions() { + let ledger = serde_json::from_str::>(include_str!("../testdata/account_transactions.json")) + .unwrap() + .take() + .unwrap(); + let transactions = map_account_transactions(Chain::Xrp, ledger); + + let expected_tx = Transaction::new( + "00778C36255A48E753E7CDD3B60243D551ACD4B6ABD6765E9011D28B7566FEAB".to_string(), + Chain::Xrp.as_asset_id(), + "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c".to_string(), + "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "11".to_string(), + Chain::Xrp.as_asset_id(), + "1".to_string(), + None, + None, + DateTime::from_timestamp(1749150631, 0).unwrap(), + ); + + assert_eq!(transactions.first().unwrap(), &expected_tx); + } + + #[test] + fn test_map_account_transaction_thorchain_swap() { + let ledger = serde_json::from_str::>(include_str!("../testdata/account_transaction_thorchain_swap.json")) + .unwrap() + .take() + .unwrap(); + let transactions = map_account_transactions(Chain::Xrp, ledger); + + let tx = transactions.first().unwrap(); + assert_eq!(tx.transaction_type, TransactionType::Transfer); + assert_eq!(tx.value, "30000000"); + assert_eq!(tx.memo.as_deref(), Some("=:b:bc1q3yw4t9xlqq9982qhvp95lgs60xrjdkmjdta0jn:0/1/0:g1:50")); + } + + #[test] + fn test_map_transactions_by_block() { + let ledger = serde_json::from_str::>(include_str!("../testdata/transactions_by_block.json")) + .unwrap() + .take() + .unwrap(); + let transactions = map_transactions_by_block(ledger.ledger); + + assert!(!transactions.is_empty()); + for tx in transactions { + assert_eq!(tx.asset_id.chain, Chain::Xrp); + assert_eq!(tx.transaction_type, TransactionType::Transfer); + } + } + + #[test] + fn test_map_transaction_by_hash() { + let transaction = serde_json::from_str::>(include_str!("../testdata/transaction_by_hash.json")) + .unwrap() + .take() + .unwrap(); + + let mapped = map_direct_transaction(Chain::Xrp, transaction).unwrap(); + + assert_eq!(mapped.hash, TEST_TRANSACTION_ID); + assert_eq!(mapped.from, "rnXZ876yGEhoATQSYegtD8bg8wpA8TTX5a"); + } +} diff --git a/core/crates/gem_xrp/src/rpc/client.rs b/core/crates/gem_xrp/src/rpc/client.rs new file mode 100644 index 0000000000..1cff4530ba --- /dev/null +++ b/core/crates/gem_xrp/src/rpc/client.rs @@ -0,0 +1,142 @@ +use serde::de::DeserializeOwned; +use serde_json::json; +use std::error::Error; + +use crate::models::rpc::{AccountInfo, AccountInfoResult, AccountLedger, AccountObjects, FeesResult, Ledger, LedgerCurrent, LedgerData, TransactionBroadcast, TransactionStatus}; + +use chain_traits::{ChainAddressStatus, ChainPerpetual, ChainProvider, ChainStaking, ChainTraits}; +use gem_client::Client; +use gem_jsonrpc::client::JsonRpcClient as GenericJsonRpcClient; +use primitives::Chain; + +#[derive(Clone, Debug)] +pub struct XRPClient { + client: GenericJsonRpcClient, + pub chain: Chain, +} + +impl XRPClient { + pub fn new(client: GenericJsonRpcClient) -> Self { + Self { client, chain: Chain::Xrp } + } + + pub fn get_chain(&self) -> Chain { + self.chain + } + + pub fn get_client(&self) -> &GenericJsonRpcClient { + &self.client + } + + pub async fn get_account_info(&self, address: &str) -> Result, Box> { + let result = self.get_account_info_full(address).await?; + Ok(result.account_data) + } + + pub async fn get_account_info_full(&self, address: &str) -> Result> { + let params = json!([ + { + "account": address, + "ledger_index": "current" + } + ]); + + Ok(self.client.call("account_info", params).await?) + } + + pub async fn get_ledger_current(&self) -> Result> { + let params = json!([{}]); + Ok(self.client.call("ledger_current", params).await?) + } + + pub async fn get_last_ledger_sequence(&self) -> Result> { + let current = self.get_ledger_current().await?; + Ok((current.ledger_current_index + 20) as u32) + } + + pub async fn get_fees(&self) -> Result> { + let params = json!([{}]); + Ok(self.client.call("fee", params).await?) + } + + pub async fn broadcast_transaction(&self, data: &str) -> Result> { + let params = json!([ + { + "tx_blob": data, + "fail_hard": true + } + ]); + + Ok(self.client.call("submit", params).await?) + } + + pub async fn get_transaction_status(&self, transaction_id: &str) -> Result> { + self.get_transaction(transaction_id).await + } + + pub(crate) async fn get_transaction(&self, transaction_id: &str) -> Result> + where + T: DeserializeOwned + Send, + { + let params = json!([ + { + "transaction": transaction_id + } + ]); + Ok(self.client.call("tx", params).await?) + } + + pub async fn get_account_objects(&self, address: &str) -> Result> { + let params = json!([ + { + "account": address, + "type": "state", + "ledger_index": "validated" + } + ]); + + Ok(self.client.call("account_objects", params).await?) + } + + pub async fn get_block_transactions(&self, block_number: u64) -> Result> { + let params = json!([ + { + "ledger_index": block_number, + "transactions": true, + "expand": true + } + ]); + + let result: LedgerData = self.client.call("ledger", params).await?; + Ok(result.ledger) + } + + pub async fn get_account_transactions(&self, address: String, limit: usize) -> Result> { + let params = json!([ + { + "account": address, + "limit": limit, + "ledger_index_max": -1, + "ledger_index_min": -1 + } + ]); + + Ok(self.client.call("account_tx", params).await?) + } +} + +impl ChainStaking for XRPClient {} + +impl ChainPerpetual for XRPClient {} + +impl ChainAddressStatus for XRPClient {} + +impl chain_traits::ChainAccount for XRPClient {} + +impl ChainTraits for XRPClient {} + +impl ChainProvider for XRPClient { + fn get_chain(&self) -> Chain { + self.chain + } +} diff --git a/core/crates/gem_xrp/src/rpc/mod.rs b/core/crates/gem_xrp/src/rpc/mod.rs new file mode 100644 index 0000000000..c9a80d76b9 --- /dev/null +++ b/core/crates/gem_xrp/src/rpc/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use self::client::XRPClient; diff --git a/core/crates/gem_xrp/src/signer/amount.rs b/core/crates/gem_xrp/src/signer/amount.rs new file mode 100644 index 0000000000..676cb5e298 --- /dev/null +++ b/core/crates/gem_xrp/src/signer/amount.rs @@ -0,0 +1,195 @@ +use std::str::FromStr; + +use bigdecimal::{BigDecimal, Signed, Zero}; +use num_traits::ToPrimitive; +use primitives::{SignerError, hex::decode_hex_array}; + +use crate::address::XrpAddress; + +const POS_SIGN_BIT_MASK: u64 = 0x4000_0000_0000_0000; +const ISSUED_CURRENCY_ZERO: u64 = 0x8000_0000_0000_0000; +const MIN_MANTISSA: u128 = 1_000_000_000_000_000; +const MAX_MANTISSA: u128 = 9_999_999_999_999_999; +const MIN_IOU_EXPONENT: i32 = -96; +const MAX_IOU_EXPONENT: i32 = 80; +const MAX_IOU_PRECISION: usize = 16; +const MAX_DROPS: u64 = 100_000_000_000_000_000; +const CURRENCY_CODE_LENGTH: usize = 20; +const STANDARD_CURRENCY_CODE_LENGTH: usize = 3; +const STANDARD_CURRENCY_CODE_OFFSET: usize = 12; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum XrpAmount { + Native(u64), + Issued(IssuedAmount), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IssuedAmount { + value: BigDecimal, + currency: [u8; CURRENCY_CODE_LENGTH], + issuer: XrpAddress, +} + +impl XrpAmount { + pub(crate) fn native(value: &str) -> Result { + let amount = value.parse::().map_err(|_| SignerError::invalid_input("invalid XRP amount"))?; + if amount > MAX_DROPS { + return Err(SignerError::invalid_input("XRP amount is too large")); + } + Ok(Self::Native(amount)) + } + + pub(crate) fn issued(value: &str, currency: &str, issuer: &str) -> Result { + IssuedAmount::new(value, currency, issuer).map(Self::Issued) + } + + pub(crate) fn encode(&self, buffer: &mut Vec) -> Result<(), SignerError> { + match self { + Self::Native(amount) => buffer.extend_from_slice(&(*amount | POS_SIGN_BIT_MASK).to_be_bytes()), + Self::Issued(amount) => amount.encode(buffer)?, + } + Ok(()) + } +} + +impl IssuedAmount { + fn new(value: &str, currency: &str, issuer: &str) -> Result { + Ok(Self { + value: BigDecimal::from_str(value).map_err(|_| SignerError::invalid_input("invalid XRP token amount"))?, + currency: currency_code_bytes(currency)?, + issuer: XrpAddress::parse(issuer)?, + }) + } + + fn encode(&self, buffer: &mut Vec) -> Result<(), SignerError> { + buffer.extend_from_slice(&self.encoded_value()?.to_be_bytes()); + buffer.extend_from_slice(&self.currency); + buffer.extend_from_slice(self.issuer.as_bytes()); + Ok(()) + } + + fn encoded_value(&self) -> Result { + validate_issued_value(&self.value)?; + + if self.value.is_zero() { + return Ok(ISSUED_CURRENCY_ZERO); + } + + let value = self.value.normalized(); + let is_positive = value.is_positive(); + let (mantissa, scale) = value.as_bigint_and_exponent(); + let mut exponent = -(scale as i32); + let mut mantissa = mantissa.abs().to_u128().ok_or_else(|| SignerError::invalid_input("invalid XRP token amount"))?; + + while mantissa < MIN_MANTISSA && exponent > MIN_IOU_EXPONENT { + mantissa *= 10; + exponent -= 1; + } + + while mantissa > MAX_MANTISSA { + if exponent >= MAX_IOU_EXPONENT { + return Err(SignerError::invalid_input("XRP token amount is too large")); + } + mantissa /= 10; + exponent += 1; + } + + if exponent < MIN_IOU_EXPONENT || mantissa < MIN_MANTISSA { + return Ok(ISSUED_CURRENCY_ZERO); + } + + if exponent > MAX_IOU_EXPONENT || mantissa > MAX_MANTISSA { + return Err(SignerError::invalid_input("XRP token amount is too large")); + } + + let mut encoded = ISSUED_CURRENCY_ZERO; + if is_positive { + encoded |= POS_SIGN_BIT_MASK; + } + encoded |= ((exponent as i64 + 97) as u64) << 54; + encoded |= mantissa as u64; + Ok(encoded) + } +} + +fn currency_code_bytes(value: &str) -> Result<[u8; CURRENCY_CODE_LENGTH], SignerError> { + if value.len() == CURRENCY_CODE_LENGTH * 2 || value.starts_with("0x") { + return decode_hex_array(value).map_err(|_| SignerError::invalid_input("invalid XRP currency code")); + } + + let mut bytes = [0; CURRENCY_CODE_LENGTH]; + let value = value.as_bytes(); + if value.len() > CURRENCY_CODE_LENGTH { + return Err(SignerError::invalid_input("XRP currency symbol is too long")); + } + if value.len() == STANDARD_CURRENCY_CODE_LENGTH { + let end = STANDARD_CURRENCY_CODE_OFFSET + STANDARD_CURRENCY_CODE_LENGTH; + bytes[STANDARD_CURRENCY_CODE_OFFSET..end].copy_from_slice(value); + return Ok(bytes); + } + bytes[..value.len()].copy_from_slice(value); + Ok(bytes) +} + +fn validate_issued_value(value: &BigDecimal) -> Result<(), SignerError> { + if value.is_zero() { + return Ok(()); + } + + let precision = issued_amount_precision(value); + if precision > MAX_IOU_PRECISION { + return Err(SignerError::invalid_input("XRP token amount precision is too large")); + } + + let exponent = issued_amount_exponent(value); + if !(MIN_IOU_EXPONENT..=MAX_IOU_EXPONENT).contains(&exponent) { + return Err(SignerError::invalid_input("XRP token amount exponent is out of range")); + } + + Ok(()) +} + +fn issued_amount_precision(value: &BigDecimal) -> usize { + let (value, _) = value.normalized().into_bigint_and_exponent(); + let (_, digits) = value.into_parts(); + digits.to_string().len() +} + +fn issued_amount_exponent(value: &BigDecimal) -> i32 { + let (_, scale) = value.normalized().as_bigint_and_exponent(); + -(scale as i32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_currency_bytes() { + assert_eq!(hex::encode(currency_code_bytes("USD").unwrap()), "0000000000000000000000005553440000000000"); + assert_eq!( + hex::encode(currency_code_bytes("524C555344000000000000000000000000000000").unwrap()), + "524c555344000000000000000000000000000000" + ); + assert_eq!(hex::encode(currency_code_bytes("RLUSD").unwrap()), "524c555344000000000000000000000000000000"); + } + + #[test] + fn test_issued_amount_precision_ignores_trailing_zeros() { + assert_eq!(issued_amount_precision(&BigDecimal::from_str("1000").unwrap()), 1); + } + + #[test] + fn test_issued_amount_value_encoding() { + let XrpAmount::Issued(amount) = XrpAmount::issued("10", "USD", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn").unwrap() else { + panic!("expected issued amount"); + }; + assert_eq!(amount.encoded_value().unwrap(), 0xd4c38d7ea4c68000); + + let XrpAmount::Issued(amount) = XrpAmount::issued("29.3e-1", "USD", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn").unwrap() else { + panic!("expected issued amount"); + }; + assert_eq!(amount.encoded_value().unwrap(), 0xd48a68d1c9312000); + } +} diff --git a/core/crates/gem_xrp/src/signer/chain_signer.rs b/core/crates/gem_xrp/src/signer/chain_signer.rs new file mode 100644 index 0000000000..dd85cca282 --- /dev/null +++ b/core/crates/gem_xrp/src/signer/chain_signer.rs @@ -0,0 +1,293 @@ +use num_traits::ToPrimitive; +use number_formatter::BigNumberFormatter; +use primitives::{ChainSigner, SignerError, SignerInput, swap::SwapQuoteData}; + +use crate::address::XrpAddress; +use crate::signer::amount::XrpAmount; +use crate::signer::transaction::{XrpPaymentMemo, XrpTransaction, XrpTransactionParams}; + +const LEDGER_SEQUENCE_OFFSET: u64 = 12; +const TRUST_LINE_LIMIT: &str = "690000000000"; + +#[derive(Default)] +pub struct XrpChainSigner; + +impl ChainSigner for XrpChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let amount = XrpAmount::native(&input.value)?; + + sign_payment(input, private_key, amount, &input.destination_address, payment_memo(input.get_memo())?) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let amount = token_amount(input, &input.value)?; + sign_payment(input, private_key, amount, &input.destination_address, token_memo(input.get_memo())?) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let swap = input.input_type.get_swap_data().map_err(SignerError::invalid_input)?; + let amount = XrpAmount::native(&swap.data.value)?; + Ok(vec![sign_payment(input, private_key, amount, &swap.data.to, swap_memo(&swap.data))?]) + } + + fn sign_account_action(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let amount = token_amount(input, TRUST_LINE_LIMIT)?; + XrpTransaction::new_trust_set(params(input, private_key)?, amount).sign(private_key) + } +} + +fn sign_payment(input: &SignerInput, private_key: &[u8], amount: XrpAmount, destination: &str, memo: XrpPaymentMemo) -> Result { + XrpTransaction::new_payment(params(input, private_key)?, amount, destination, memo)?.sign(private_key) +} + +fn params(input: &SignerInput, private_key: &[u8]) -> Result { + let block_number = input.metadata.get_block_number()?; + let sequence = input.metadata.get_sequence()?; + let last_ledger_sequence = block_number + .checked_add(LEDGER_SEQUENCE_OFFSET) + .ok_or_else(|| SignerError::invalid_input("XRP last ledger sequence overflow"))?; + + Ok(XrpTransactionParams { + account: XrpAddress::parse(&input.sender_address)?, + fee: input.fee.fee.to_u64().ok_or_else(|| SignerError::invalid_input("invalid XRP fee"))?, + sequence: u32::try_from(sequence).map_err(SignerError::from_display)?, + last_ledger_sequence: u32::try_from(last_ledger_sequence).map_err(SignerError::from_display)?, + signing_pub_key: ::signer::secp256k1_public_key(private_key)?, + }) +} + +fn token_amount(input: &SignerInput, value: &str) -> Result { + let asset = input.input_type.get_asset(); + let value = BigNumberFormatter::value(value, asset.decimals).map_err(SignerError::from_display)?; + XrpAmount::issued(&value, &asset.symbol, asset.id.get_token_id()?) +} + +fn payment_memo(memo: Option<&str>) -> Result { + let Some(memo) = memo else { + return Ok(XrpPaymentMemo::None); + }; + + match memo.parse::() { + Ok(0) => Ok(XrpPaymentMemo::None), + Ok(value) => Ok(XrpPaymentMemo::DestinationTag(u32::try_from(value).map_err(SignerError::from_display)?)), + Err(_) => Ok(XrpPaymentMemo::Memo(memo.strip_prefix("0x").unwrap_or(memo).as_bytes().to_vec())), + } +} + +fn token_memo(memo: Option<&str>) -> Result { + let Some(memo) = memo else { + return Ok(XrpPaymentMemo::None); + }; + + match memo.parse::() { + Ok(0) | Err(_) => Ok(XrpPaymentMemo::None), + Ok(value) => Ok(XrpPaymentMemo::DestinationTag(u32::try_from(value).map_err(SignerError::from_display)?)), + } +} + +fn swap_memo(data: &SwapQuoteData) -> XrpPaymentMemo { + if let Some(memo) = data.memo.as_deref().filter(|memo| !memo.is_empty()) { + return XrpPaymentMemo::Memo(memo.as_bytes().to_vec()); + } + if data.data.is_empty() { + return XrpPaymentMemo::None; + } + XrpPaymentMemo::Memo(data.data.as_bytes().to_vec()) +} + +#[cfg(test)] +mod tests { + use primitives::{ + AccountDataType, Asset, AssetId, AssetType, Chain, GasPriceType, SwapProvider, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, + swap::{SwapData, SwapProviderData, SwapQuote, SwapQuoteData, SwapQuoteDataType}, + }; + + use super::*; + + fn metadata(sequence: u64, block_number: u64) -> TransactionLoadMetadata { + TransactionLoadMetadata::Xrp { sequence, block_number } + } + + fn signer_input(load: TransactionLoadInput, fee: u64) -> SignerInput { + SignerInput::new(load, TransactionFee::new_from_fee(fee.into())) + } + + fn transfer_input(asset: Asset, sender: &str, destination: &str, value: &str, fee: u64, sequence: u64, block_number: u64, memo: Option<&str>) -> SignerInput { + signer_input( + TransactionLoadInput::mock_transfer(asset, sender, destination, value, fee, memo, metadata(sequence, block_number)), + fee, + ) + } + + fn input_with_type( + input_type: TransactionInputType, + sender: &str, + destination: &str, + value: &str, + fee: u64, + sequence: u64, + block_number: u64, + memo: Option<&str>, + ) -> SignerInput { + signer_input( + TransactionLoadInput { + sender_address: sender.to_string(), + destination_address: destination.to_string(), + value: value.to_string(), + gas_price: GasPriceType::regular(fee), + memo: memo.map(str::to_string), + metadata: metadata(sequence, block_number), + ..TransactionLoadInput::mock_with_input_type(input_type) + }, + fee, + ) + } + + fn token(symbol: &str, issuer: &str) -> Asset { + Asset::new(AssetId::from_token(Chain::Xrp, issuer), symbol.to_string(), symbol.to_string(), 15, AssetType::TOKEN) + } + + // Source vector: + // https://github.com/trustwallet/wallet-core/blob/62ef27c56c6769b3aec3c5167d925dc085646a5c/tests/chains/XRP/TWAnySignerTests.cpp#L17-L33 + #[test] + fn test_sign_transfer_matches_wallet_core() { + let private_key = hex::decode("a5576c0f63da10e584568c8d134569ff44017b0a249eb70657127ae04f38cc77").unwrap(); + let input = transfer_input( + Asset::from_chain(Chain::Xrp), + "rfxdLwsZnoespnTDDb1Xhvbc8EFNdztaoq", + "rU893viamSnsfP3zjzM2KPxjqZjXSXK6VF", + "10", + 10, + 32_268_248, + 32_268_257, + None, + ); + + assert_eq!( + XrpChainSigner.sign_transfer(&input, &private_key).unwrap(), + "12000022000000002401ec5fd8201b01ec5fed61400000000000000a68400000000000000a732103d13e1152965a51a4a9fd9a8b4ea3dd82a4eba6b25fcad5f460a2342bb650333f74463044022037d32835c9394f39b2cfd4eaf5b0a80e0db397ace06630fa2b099ff73e425dbc02205288f780330b7a88a1980fa83c647b5908502ad7de9a44500c08f0750b0d9e8481144c55f5a78067206507580be7bb2686c8460adff983148132e4e20aecf29090ac428a9c43f230a829220d" + ); + } + + // Source vector: + // https://github.com/trustwallet/wallet-core/blob/62ef27c56c6769b3aec3c5167d925dc085646a5c/tests/chains/XRP/TWAnySignerTests.cpp#L134-L152 + #[test] + fn test_sign_token_transfer_matches_wallet_core_custom_currency() { + let private_key = hex::decode("574e99f7946cfa2a6ca9368ca72fd37e42583cddb9ecc746aa4cb194ef4b2480").unwrap(); + let issuer = "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De"; + let input = transfer_input( + token("RLUSD", issuer), + "rDgEGKXWkHHr1HYq2ETnNAs9MdV4R8Gyt", + "r4oPb529jpRA1tVTDARmBuZPYB2CJjKFac", + "1000000000000000", + 12, + 93_674_951, + 187_349_938, + None, + ); + + assert_eq!( + XrpChainSigner.sign_token_transfer(&input, &private_key).unwrap(), + "12000022000000002405955dc7201b0b2abbbe61d4838d7ea4c68000524c555344000000000000000000000000000000e5e961c6a025c9404aa7b662dd1df975be75d13e68400000000000000c7321039c77e9329017ced5f8673ebafcd29687a1fff181140c030062fa77865688fc5d744630440220552e90f417c2cabe39368bb45cf7495ba6ebe395f259a6509c9f3a7296e76a0d02201b37dae0c4c77fa70a451cd4a61c10575c8b052c282c082a32c229e7624a05e381140265c09d122fab2a261a80ee59f1f4cd8fba8cf88314ef20a3d93b00cc729eec11a3058d3d1feb4465e0" + ); + } + + #[test] + fn test_destination_tag_and_memo() { + assert_eq!(payment_memo(Some("123")).unwrap(), XrpPaymentMemo::DestinationTag(123)); + assert_eq!(payment_memo(Some("memo")).unwrap(), XrpPaymentMemo::Memo(b"memo".to_vec())); + assert_eq!(payment_memo(Some("0xhello")).unwrap(), XrpPaymentMemo::Memo(b"hello".to_vec())); + assert_eq!(payment_memo(Some("0")).unwrap(), XrpPaymentMemo::None); + assert_eq!(payment_memo(None).unwrap(), XrpPaymentMemo::None); + assert_eq!(token_memo(Some("123")).unwrap(), XrpPaymentMemo::DestinationTag(123)); + assert_eq!(token_memo(Some("0")).unwrap(), XrpPaymentMemo::None); + assert_eq!(token_memo(Some("memo")).unwrap(), XrpPaymentMemo::None); + } + + #[test] + fn test_swap_memo_is_always_memo_data() { + let data = SwapQuoteData { + to: "rU893viamSnsfP3zjzM2KPxjqZjXSXK6VF".to_string(), + data_type: SwapQuoteDataType::Transfer, + value: "10".to_string(), + data: "fallback".to_string(), + memo: Some("123".to_string()), + approval: None, + gas_limit: None, + }; + + assert_eq!(swap_memo(&data), XrpPaymentMemo::Memo(b"123".to_vec())); + } + + #[test] + fn test_sign_swap_uses_payload_not_provider() { + let private_key = hex::decode("a5576c0f63da10e584568c8d134569ff44017b0a249eb70657127ae04f38cc77").unwrap(); + let input = input_with_type( + TransactionInputType::Swap( + Asset::from_chain(Chain::Xrp), + Asset::from_chain(Chain::Xrp), + SwapData { + quote: SwapQuote { + from_address: "rfxdLwsZnoespnTDDb1Xhvbc8EFNdztaoq".to_string(), + from_value: "10".to_string(), + min_from_value: None, + to_address: "rU893viamSnsfP3zjzM2KPxjqZjXSXK6VF".to_string(), + to_value: "1".to_string(), + provider_data: SwapProviderData { + provider: SwapProvider::Okx, + name: "OKX".to_string(), + protocol_name: "okx".to_string(), + }, + slippage_bps: 50, + eta_in_seconds: None, + use_max_amount: None, + }, + data: SwapQuoteData { + to: "rU893viamSnsfP3zjzM2KPxjqZjXSXK6VF".to_string(), + data_type: SwapQuoteDataType::Transfer, + value: "10".to_string(), + data: "swap:memo".to_string(), + memo: None, + approval: None, + gas_limit: None, + }, + }, + ), + "rfxdLwsZnoespnTDDb1Xhvbc8EFNdztaoq", + "", + "999", + 10, + 32_268_248, + 32_268_257, + None, + ); + + let signed = XrpChainSigner.sign_swap(&input, &private_key).unwrap(); + assert_eq!(signed.len(), 1); + assert_eq!( + signed[0], + "12000022000000002401ec5fd8201b01ec5fed61400000000000000a68400000000000000a732103d13e1152965a51a4a9fd9a8b4ea3dd82a4eba6b25fcad5f460a2342bb650333f74473045022100d18b758a360fc0a4d013b095014410b4fbf0e97a265c10d01d85e86ff35e009a02203c7e860711fd18ca486d211c07669638cbfa39b5f01ccc430e12fdecfa11399281144c55f5a78067206507580be7bb2686c8460adff983148132e4e20aecf29090ac428a9c43f230a829220df9ea7d09737761703a6d656d6fe1f1" + ); + } + + #[test] + fn test_account_action_signs_trust_set() { + let private_key = hex::decode("574e99f7946cfa2a6ca9368ca72fd37e42583cddb9ecc746aa4cb194ef4b2480").unwrap(); + let input = input_with_type( + TransactionInputType::Account(token("RLUSD", "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De"), AccountDataType::Activate), + "rDgEGKXWkHHr1HYq2ETnNAs9MdV4R8Gyt", + "", + "0", + 500, + 93_674_950, + 187_349_938, + None, + ); + + let signed = XrpChainSigner.sign_account_action(&input, &private_key).unwrap(); + assert_eq!( + signed, + "12001422000000002405955dc6201b0b2abbbe63d398838370f34000524c555344000000000000000000000000000000e5e961c6a025c9404aa7b662dd1df975be75d13e6840000000000001f47321039c77e9329017ced5f8673ebafcd29687a1fff181140c030062fa77865688fc5d74473045022100d807b19bc7636d2a4b92f3b1c27897f6076a0f808abf4a403188b50c6e4205fc02202c50debc5aed8c8e193dd68122502b75333557378e9537a88c19233e52870a7781140265c09d122fab2a261a80ee59f1f4cd8fba8cf8" + ); + } +} diff --git a/core/crates/gem_xrp/src/signer/mod.rs b/core/crates/gem_xrp/src/signer/mod.rs new file mode 100644 index 0000000000..1918726b25 --- /dev/null +++ b/core/crates/gem_xrp/src/signer/mod.rs @@ -0,0 +1,5 @@ +mod amount; +mod chain_signer; +mod transaction; + +pub use chain_signer::XrpChainSigner; diff --git a/core/crates/gem_xrp/src/signer/transaction.rs b/core/crates/gem_xrp/src/signer/transaction.rs new file mode 100644 index 0000000000..87ce2bb98e --- /dev/null +++ b/core/crates/gem_xrp/src/signer/transaction.rs @@ -0,0 +1,301 @@ +use gem_hash::sha2::sha512_half; +use primitives::SignerError; + +use crate::address::XrpAddress; +use crate::signer::amount::XrpAmount; + +const SIGNING_PREFIX: [u8; 4] = [0x53, 0x54, 0x58, 0x00]; +const TRANSACTION_TYPE_PAYMENT: u16 = 0; +const TRANSACTION_TYPE_TRUST_SET: u16 = 20; + +const TYPE_UINT16: u8 = 1; +const TYPE_UINT32: u8 = 2; +const TYPE_AMOUNT: u8 = 6; +const TYPE_VL: u8 = 7; +const TYPE_ACCOUNT_ID: u8 = 8; +const TYPE_ST_OBJECT: u8 = 14; +const TYPE_ST_ARRAY: u8 = 15; + +const FIELD_TRANSACTION_TYPE: u8 = 2; +const FIELD_FLAGS: u8 = 2; +const FIELD_SEQUENCE: u8 = 4; +const FIELD_DESTINATION_TAG: u8 = 14; +const FIELD_LAST_LEDGER_SEQUENCE: u8 = 27; +const FIELD_AMOUNT: u8 = 1; +const FIELD_LIMIT_AMOUNT: u8 = 3; +const FIELD_FEE: u8 = 8; +const FIELD_SIGNING_PUB_KEY: u8 = 3; +const FIELD_TXN_SIGNATURE: u8 = 4; +const FIELD_MEMO_DATA: u8 = 13; +const FIELD_ACCOUNT: u8 = 1; +const FIELD_DESTINATION: u8 = 3; +const FIELD_MEMO: u8 = 10; +const FIELD_MEMOS: u8 = 9; + +const OBJECT_END_MARKER: u8 = 0xe1; +const ARRAY_END_MARKER: u8 = 0xf1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct XrpTransaction { + sequence: u32, + last_ledger_sequence: u32, + fee: u64, + signing_pub_key: Vec, + account: XrpAddress, + operation: XrpOperation, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum XrpOperation { + Payment { + amount: XrpAmount, + destination: XrpAddress, + memo: XrpPaymentMemo, + }, + TrustSet { + limit_amount: XrpAmount, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum XrpPaymentMemo { + None, + DestinationTag(u32), + Memo(Vec), +} + +impl XrpTransaction { + pub(crate) fn new_payment(params: XrpTransactionParams, amount: XrpAmount, destination: &str, memo: XrpPaymentMemo) -> Result { + Ok(Self { + sequence: params.sequence, + last_ledger_sequence: params.last_ledger_sequence, + fee: params.fee, + signing_pub_key: params.signing_pub_key, + account: params.account, + operation: XrpOperation::Payment { + amount, + destination: XrpAddress::parse(destination)?, + memo, + }, + }) + } + + pub(crate) fn new_trust_set(params: XrpTransactionParams, limit_amount: XrpAmount) -> Self { + Self { + sequence: params.sequence, + last_ledger_sequence: params.last_ledger_sequence, + fee: params.fee, + signing_pub_key: params.signing_pub_key, + account: params.account, + operation: XrpOperation::TrustSet { limit_amount }, + } + } + + pub(crate) fn sign(&self, private_key: &[u8]) -> Result { + let unsigned = self.encode(None)?; + let mut preimage = Vec::with_capacity(SIGNING_PREFIX.len() + unsigned.len()); + preimage.extend_from_slice(&SIGNING_PREFIX); + preimage.extend_from_slice(&unsigned); + let digest = sha512_half(&preimage); + let mut signature = ::signer::Signer::sign_digest(::signer::SignatureScheme::Secp256k1, &digest, private_key)?; + if signature.len() < 64 { + return Err(SignerError::signing_error("secp256k1 signature too short")); + } + signature.truncate(64); + let der_signature = der_encode_signature(&signature)?; + Ok(hex::encode(self.encode(Some(&der_signature))?)) + } + + fn encode(&self, signature: Option<&[u8]>) -> Result, SignerError> { + let mut buffer = Vec::new(); + + append_u16(&mut buffer, FIELD_TRANSACTION_TYPE, self.transaction_type()); + append_u32(&mut buffer, FIELD_FLAGS, 0); + append_u32(&mut buffer, FIELD_SEQUENCE, self.sequence); + + if let XrpOperation::Payment { + memo: XrpPaymentMemo::DestinationTag(destination_tag), + .. + } = &self.operation + { + append_u32(&mut buffer, FIELD_DESTINATION_TAG, *destination_tag); + } + + append_u32(&mut buffer, FIELD_LAST_LEDGER_SEQUENCE, self.last_ledger_sequence); + + match &self.operation { + XrpOperation::Payment { amount, .. } => append_amount(&mut buffer, FIELD_AMOUNT, amount)?, + XrpOperation::TrustSet { limit_amount } => append_amount(&mut buffer, FIELD_LIMIT_AMOUNT, limit_amount)?, + } + + append_amount(&mut buffer, FIELD_FEE, &XrpAmount::Native(self.fee))?; + append_blob(&mut buffer, FIELD_SIGNING_PUB_KEY, &self.signing_pub_key)?; + + if let Some(signature) = signature { + append_blob(&mut buffer, FIELD_TXN_SIGNATURE, signature)?; + } + + append_address(&mut buffer, FIELD_ACCOUNT, &self.account)?; + + if let XrpOperation::Payment { destination, memo, .. } = &self.operation { + append_address(&mut buffer, FIELD_DESTINATION, destination)?; + append_memo(&mut buffer, memo)?; + } + + Ok(buffer) + } + + fn transaction_type(&self) -> u16 { + match &self.operation { + XrpOperation::Payment { .. } => TRANSACTION_TYPE_PAYMENT, + XrpOperation::TrustSet { .. } => TRANSACTION_TYPE_TRUST_SET, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct XrpTransactionParams { + pub(crate) account: XrpAddress, + pub(crate) fee: u64, + pub(crate) sequence: u32, + pub(crate) last_ledger_sequence: u32, + pub(crate) signing_pub_key: Vec, +} + +fn append_u16(buffer: &mut Vec, field: u8, value: u16) { + append_header(buffer, TYPE_UINT16, field); + buffer.extend_from_slice(&value.to_be_bytes()); +} + +fn append_u32(buffer: &mut Vec, field: u8, value: u32) { + append_header(buffer, TYPE_UINT32, field); + buffer.extend_from_slice(&value.to_be_bytes()); +} + +fn append_amount(buffer: &mut Vec, field: u8, amount: &XrpAmount) -> Result<(), SignerError> { + append_header(buffer, TYPE_AMOUNT, field); + amount.encode(buffer) +} + +fn append_blob(buffer: &mut Vec, field: u8, value: &[u8]) -> Result<(), SignerError> { + append_header(buffer, TYPE_VL, field); + append_variable_length(buffer, value.len())?; + buffer.extend_from_slice(value); + Ok(()) +} + +fn append_address(buffer: &mut Vec, field: u8, value: &XrpAddress) -> Result<(), SignerError> { + append_header(buffer, TYPE_ACCOUNT_ID, field); + append_variable_length(buffer, value.as_bytes().len())?; + buffer.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn append_memo(buffer: &mut Vec, memo: &XrpPaymentMemo) -> Result<(), SignerError> { + match memo { + XrpPaymentMemo::Memo(memo) => { + append_header(buffer, TYPE_ST_ARRAY, FIELD_MEMOS); + append_header(buffer, TYPE_ST_OBJECT, FIELD_MEMO); + append_blob(buffer, FIELD_MEMO_DATA, memo)?; + buffer.push(OBJECT_END_MARKER); + buffer.push(ARRAY_END_MARKER); + Ok(()) + } + XrpPaymentMemo::None | XrpPaymentMemo::DestinationTag(_) => Ok(()), + } +} + +fn append_header(buffer: &mut Vec, type_code: u8, field_code: u8) { + match (type_code < 16, field_code < 16) { + (true, true) => buffer.push((type_code << 4) | field_code), + (true, false) => { + buffer.push(type_code << 4); + buffer.push(field_code); + } + (false, true) => { + buffer.push(field_code); + buffer.push(type_code); + } + (false, false) => { + buffer.push(0); + buffer.push(type_code); + buffer.push(field_code); + } + } +} + +fn append_variable_length(buffer: &mut Vec, length: usize) -> Result<(), SignerError> { + if length <= 192 { + buffer.push(length as u8); + return Ok(()); + } + + if length < 12_481 { + let length = length - 193; + buffer.push(((length >> 8) + 193) as u8); + buffer.push((length & 0xff) as u8); + return Ok(()); + } + + if length <= 918_744 { + let length = length - 12_481; + buffer.push(((length >> 16) + 241) as u8); + buffer.push(((length >> 8) & 0xff) as u8); + buffer.push((length & 0xff) as u8); + return Ok(()); + } + + Err(SignerError::invalid_input("XRP field is too large")) +} + +fn der_encode_signature(signature: &[u8]) -> Result, SignerError> { + if signature.len() != 64 { + return Err(SignerError::signing_error("invalid secp256k1 signature length")); + } + + let r = der_integer(&signature[..32]); + let s = der_integer(&signature[32..]); + let payload_len = r.len() + s.len(); + let mut der = Vec::with_capacity(payload_len + 2); + der.push(0x30); + der.push(payload_len.try_into().map_err(|_| SignerError::signing_error("signature is too large"))?); + der.extend_from_slice(&r); + der.extend_from_slice(&s); + Ok(der) +} + +fn der_integer(value: &[u8]) -> Vec { + let mut bytes = value.iter().skip_while(|b| **b == 0).copied().collect::>(); + if bytes.is_empty() { + bytes.push(0); + } + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + + let mut encoded = Vec::with_capacity(bytes.len() + 2); + encoded.push(0x02); + encoded.push(bytes.len() as u8); + encoded.extend_from_slice(&bytes); + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_variable_length_encoding() { + let mut bytes = Vec::new(); + append_variable_length(&mut bytes, 180).unwrap(); + assert_eq!(hex::encode(&bytes), "b4"); + + let mut bytes = Vec::new(); + append_variable_length(&mut bytes, 1000).unwrap(); + assert_eq!(hex::encode(&bytes), "c427"); + + let mut bytes = Vec::new(); + append_variable_length(&mut bytes, 12_580).unwrap(); + assert_eq!(hex::encode(&bytes), "f10063"); + } +} diff --git a/core/crates/gem_xrp/src/testdata/account_transaction_thorchain_swap.json b/core/crates/gem_xrp/src/testdata/account_transaction_thorchain_swap.json new file mode 100644 index 0000000000..03e4c97fc7 --- /dev/null +++ b/core/crates/gem_xrp/src/testdata/account_transaction_thorchain_swap.json @@ -0,0 +1,39 @@ +{ + "result": { + "account": "r3vqg3dpN7uEafvpqwxaTLBq8mkAzZtTxp", + "ledger_index_min": 32570, + "ledger_index_max": 102363477, + "transactions": [ + { + "meta": { + "AffectedNodes": [], + "TransactionIndex": 1, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "30000000" + }, + "tx_json": { + "Account": "r3vqg3dpN7uEafvpqwxaTLBq8mkAzZtTxp", + "DeliverMax": "30000000", + "Destination": "rwWikcVRNqfMxpMPux5edYfv8DRgvT8Wsu", + "Fee": "5000", + "LastLedgerSequence": 102363477, + "Memos": [ + { + "Memo": { + "MemoData": "3D3A623A62633171337977347439786C7171393938327168767039356C6773363078726A646B6D6A647461306A6E3A302F312F303A67313A3530" + } + } + ], + "Sequence": 102357116, + "TransactionType": "Payment", + "date": 824865440, + "ledger_index": 102363466 + }, + "ledger_index": 102363466, + "hash": "7CF740EDAB5FB094089766DF372228E27C546DC2D946B0FC78CFB1B018BBA1A4", + "validated": true + } + ], + "validated": true + } +} diff --git a/core/crates/gem_xrp/src/testdata/account_transactions.json b/core/crates/gem_xrp/src/testdata/account_transactions.json new file mode 100644 index 0000000000..9de01d2ef2 --- /dev/null +++ b/core/crates/gem_xrp/src/testdata/account_transactions.json @@ -0,0 +1,223 @@ +{ + "result": { + "account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "ledger_index_min": 32570, + "ledger_index_max": 96879330, + "transactions": [ + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c", + "Balance": "13999936", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 95916371 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "605D83E0C821373201428CA41691FFA2DD22ED9159574C71A96D14710401E58D", + "PreviousFields": { + "Balance": "13999948", + "Sequence": 95916370 + }, + "PreviousTxnID": "B8C641A7289D5B91B9E86C1E3742B51F2DF930149CBD386E9EF551365B7F25E6", + "PreviousTxnLgrSeq": 96608988 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "34251974", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046080 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "34251973" + }, + "PreviousTxnID": "C370C5AA3696056A5A6F13D2FBC69E915FF0C61C1022D045717DF580E00B07C1", + "PreviousTxnLgrSeq": 96608985 + } + } + ], + "TransactionIndex": 73, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx_json": { + "Account": "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c", + "DeliverMax": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 96609090, + "Sequence": 95916370, + "SigningPubKey": "EDC9744D12A30F4EEEC8B9D3A608EDC08C7D855A8EB484DDC943C371B39791F05D", + "TransactionType": "Payment", + "TxnSignature": "99566A350D63D34C66802BB6B187984A3D9967E52F54900E4C6CD53E7A8196C03104712F59AD0993DCE0CC6D99D82E32F6C990862F47431187C3AD635D19130B", + "ledger_index": 96608991, + "date": 802465831 + }, + "ledger_index": 96608991, + "hash": "00778C36255A48E753E7CDD3B60243D551ACD4B6ABD6765E9011D28B7566FEAB", + "ledger_hash": "3F598E07707E1446C9BAA40A9B78699469FE1739F84940642EACAF926C8898CB", + "close_time_iso": "2025-06-05T19:10:31Z", + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGMcqxy5gjHPhSKmfYCg4fwKdgyS1nLUfw", + "Balance": "7441250890", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 96599896 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "8502B8C4A5317E10FA30EB0B1131C46DB94AED53C2A20E8E1D12212E63A2B936", + "PreviousFields": { + "Balance": "7435250890" + }, + "PreviousTxnID": "7A8B8A8AA05260A79639D4F1C0F90D9CA6F4454E650F1D639413440EE9595B46", + "PreviousTxnLgrSeq": 96608516 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "34251973", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046080 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "40256973", + "Sequence": 82046079 + }, + "PreviousTxnID": "66B94DCE7F0DDE3B0921462221324310B909DC32829743511B188029DE8BBE13", + "PreviousTxnLgrSeq": 96608933 + } + } + ], + "TransactionIndex": 32, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "6000000" + }, + "tx_json": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "DeliverMax": "6000000", + "Destination": "rGMcqxy5gjHPhSKmfYCg4fwKdgyS1nLUfw", + "Fee": "5000", + "LastLedgerSequence": 96608996, + "Memos": [ + { + "Memo": { + "MemoData": "3D3A733A3078424134443164333562436530653846323845356133343033653761306239393663356435304143343A302F312F303A67313A3530" + } + } + ], + "Sequence": 82046079, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304402203925A7C3E8C13D049129F69FCF925C9A27C6D6FA05943BF8FB489816E1B1DD89022051931C113A60ED8314C59586A82E80CDA658A0105B74B38B70B5F1D719169280", + "ledger_index": 96608985, + "date": 802465811 + }, + "ledger_index": 96608985, + "hash": "C370C5AA3696056A5A6F13D2FBC69E915FF0C61C1022D045717DF580E00B07C1", + "ledger_hash": "FF940DED196DF2A2A2BF21A1B161E25C574608A95FC40845AA42255CDC68A973", + "close_time_iso": "2025-06-05T19:10:11Z", + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c", + "Balance": "14000116", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 95916356 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "605D83E0C821373201428CA41691FFA2DD22ED9159574C71A96D14710401E58D", + "PreviousFields": { + "Balance": "14000128", + "Sequence": 95916355 + }, + "PreviousTxnID": "941B181B2879F1D4B42766944AB4281209EA456BAA4D3D96A372E30455BCB516", + "PreviousTxnLgrSeq": 96608927 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "40256973", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046079 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "40256972" + }, + "PreviousTxnID": "90F57D6F28AAC66E0C0D8487325DDBF0918A360CABAAA13D17B07B43E3118102", + "PreviousTxnLgrSeq": 96608928 + } + } + ], + "TransactionIndex": 38, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx_json": { + "Account": "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c", + "DeliverMax": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 96609032, + "Sequence": 95916355, + "SigningPubKey": "EDC9744D12A30F4EEEC8B9D3A608EDC08C7D855A8EB484DDC943C371B39791F05D", + "TransactionType": "Payment", + "TxnSignature": "FF396C51212EAA10FBB5F47F72E32115DCE82E1C52FCEC2B48114F8316F15F763D3EA7EB2D0EC3CF5E89F233193CCF00AC1FA60C3BDA2C8B733F767E6688BC08", + "ledger_index": 96608933, + "date": 802465610 + }, + "ledger_index": 96608933, + "hash": "66B94DCE7F0DDE3B0921462221324310B909DC32829743511B188029DE8BBE13", + "ledger_hash": "93A8F9564F5C5DFBD9219A4ACE496B73277AC9C48D7F6D6E74EBA74D76E64D8F", + "close_time_iso": "2025-06-05T19:06:50Z", + "validated": true + } + ], + "validated": true, + "marker": { + "ledger": 96608933, + "seq": 38 + }, + "limit": 3, + "status": "success" + }, + "warnings": [ + { + "id": 2001, + "message": "This is a clio server. clio only serves validated data. If you want to talk to rippled, include 'ledger_index':'current' in your request" + } + ] + } \ No newline at end of file diff --git a/core/crates/gem_xrp/src/testdata/accounts_objects_tokens.json b/core/crates/gem_xrp/src/testdata/accounts_objects_tokens.json new file mode 100644 index 0000000000..3224df8688 --- /dev/null +++ b/core/crates/gem_xrp/src/testdata/accounts_objects_tokens.json @@ -0,0 +1,59 @@ +{ + "result": { + "account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "account_objects": [ + { + "Balance": { + "currency": "USD", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 65536, + "HighLimit": { + "currency": "USD", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "49d", + "LedgerEntryType": "RippleState", + "LowLimit": { + "currency": "USD", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "690000" + }, + "LowNode": "0", + "PreviousTxnID": "269CAF7F150BD8A3D2250FCBA1E3F6CA1B7CBF83EB3C892EE6C81FE8B45AE2BC", + "PreviousTxnLgrSeq": 92952306, + "index": "6C670A2AD7FF2F1B59B22FB25EF2ED0C4B121D4934D7C85BC90533E6E49B489F" + }, + { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.171" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LedgerEntryType": "RippleState", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0", + "PreviousTxnID": "D872B32A3279FDACE52276DB9E82263F28E25C907CB663AF3286B3C9A578AA0B", + "PreviousTxnLgrSeq": 95344881, + "index": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E" + } + ], + "ledger_hash": "5E188D92D6608B870401FD15950628407FC7040CA8A5980323E5F1FAB91C23BA", + "ledger_index": 98430992, + "status": "success", + "validated": true + } +} \ No newline at end of file diff --git a/core/crates/gem_xrp/src/testdata/transaction_broadcast_failed.json b/core/crates/gem_xrp/src/testdata/transaction_broadcast_failed.json new file mode 100644 index 0000000000..4501126338 --- /dev/null +++ b/core/crates/gem_xrp/src/testdata/transaction_broadcast_failed.json @@ -0,0 +1,31 @@ +{ + "result": { + "accepted": false, + "account_sequence_available": 82046080, + "account_sequence_next": 82046080, + "applied": false, + "broadcast": false, + "engine_result": "tefMAX_LEDGER", + "engine_result_code": -187, + "engine_result_message": "Ledger sequence too high.", + "kept": false, + "open_ledger_cost": "10", + "queued": false, + "status": "success", + "tx_blob": "12000022000000002404E3EC80201B0000000C6140000000000F4240684000000000001388732103EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA747447304502210085E0CAC2F7ED26B0280270DCD299703FC8948C7EADF5C06E31324355EB593EE502203B02C4F71E3A3AF468E1ABF77EEEDDB49C7F6D6A46F9DB590F9A91C0F454496B81143212F339EF5E4061CB9BA8F59C5AF4009B8536E2831407BE669E2D72EC190FFE43A81E640371607960AB", + "tx_json": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "1000000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 12, + "Sequence": 82046080, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304502210085E0CAC2F7ED26B0280270DCD299703FC8948C7EADF5C06E31324355EB593EE502203B02C4F71E3A3AF468E1ABF77EEEDDB49C7F6D6A46F9DB590F9A91C0F454496B", + "hash": "B8CFA44C2BF1DE4EEC450FE8D6E9125C9B96594C852EBC2D8C2F1BC8D4F2DB9B" + }, + "validated_ledger_index": 98209449 + } +} \ No newline at end of file diff --git a/core/crates/gem_xrp/src/testdata/transaction_broadcast_success.json b/core/crates/gem_xrp/src/testdata/transaction_broadcast_success.json new file mode 100644 index 0000000000..472ec34a41 --- /dev/null +++ b/core/crates/gem_xrp/src/testdata/transaction_broadcast_success.json @@ -0,0 +1,31 @@ +{ + "result": { + "accepted": true, + "account_sequence_available": 82046082, + "account_sequence_next": 82046082, + "applied": true, + "broadcast": true, + "engine_result": "tesSUCCESS", + "engine_result_code": 0, + "engine_result_message": "The transaction was applied. Only final in a validated ledger.", + "kept": true, + "open_ledger_cost": "10", + "queued": false, + "status": "success", + "tx_blob": "12000022000000002404E3EC81201B05DA8FEC6140000000000F4240684000000000001388732103EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA747447304502210088BA38852E6406B6C20894D754768650928FF26A9034BAF45521D02E3A52DB63022043AED91A0919CA9E833E7196BB363054E99DAD027502A190D7ED523CCE54F35C81143212F339EF5E4061CB9BA8F59C5AF4009B8536E2831407BE669E2D72EC190FFE43A81E640371607960AB", + "tx_json": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "1000000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 98209772, + "Sequence": 82046081, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304502210088BA38852E6406B6C20894D754768650928FF26A9034BAF45521D02E3A52DB63022043AED91A0919CA9E833E7196BB363054E99DAD027502A190D7ED523CCE54F35C", + "hash": "04F53F220DD1BCB7CCF279D66FFB986EA41383EFC9378CA1EBF1823D7C89264F" + }, + "validated_ledger_index": 98209759 + } +} \ No newline at end of file diff --git a/core/crates/gem_xrp/src/testdata/transaction_by_address.json b/core/crates/gem_xrp/src/testdata/transaction_by_address.json new file mode 100644 index 0000000000..abe2410a9e --- /dev/null +++ b/core/crates/gem_xrp/src/testdata/transaction_by_address.json @@ -0,0 +1,3846 @@ +{ + "result": { + "account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "ledger_index_min": 32570, + "ledger_index_max": 98408868, + "transactions": [ + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "31224477", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046084 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "31236977", + "Sequence": 82046083 + }, + "PreviousTxnID": "0F26106203E588029FC78550B312D8419F3622015079274955BCBD2D0EF8F5B4", + "PreviousTxnLgrSeq": 98209810 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "34429010", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046908 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "34419010" + }, + "PreviousTxnID": "CF5D30F3C6C5E6F46B1A0ABAB4DC389DD937B11BE4580E121ED9CE50E4799909", + "PreviousTxnLgrSeq": 98209811 + } + } + ], + "TransactionIndex": 29, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "10000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "10000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "2500", + "Flags": 0, + "LastLedgerSequence": 98322664, + "Sequence": 82046083, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3044022045CC35F819A6A29A392FEBD497F769E5D17355477AE5509AA8D3F2FB0A8C9B7A02203FA97F5BC22C54D15DBD82C8256A87176C7125725BBED5F96807A1564DA6CC6D", + "hash": "1498B0EFA4E5989F7F73915C7BF201F84E5678419FAED9541A67A626CD01D246", + "DeliverMax": "10000", + "ctid": "C5DC48E0001D0000", + "date": 809138790, + "ledger_index": 98322656, + "inLedger": 98322656 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6yAvyZ5kgRNWqMkdsoHN4Fiu7fiVdLc3", + "Balance": "16260430", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 95613012 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "022C90AE97273AA9C784D0CAA61BC05B999171F66F36A1576B70D77586847FD4", + "PreviousFields": { + "Balance": "16260442", + "Sequence": 95613011 + }, + "PreviousTxnID": "5DE47325BB59642F41317FC7904729E94EC580E2557110E3D228CAAAAB92DDCF", + "PreviousTxnLgrSeq": 98209765 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "31236977", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046083 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "31236976" + }, + "PreviousTxnID": "883040AB9B4FEE4F6F4CE588D5A3C5BA0D317FC2C6CB2112E71C13EDF9A3FA20", + "PreviousTxnLgrSeq": 98209804 + } + } + ], + "TransactionIndex": 72, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "r6yAvyZ5kgRNWqMkdsoHN4Fiu7fiVdLc3", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 98209909, + "Sequence": 95613011, + "SigningPubKey": "EDE58A5FE80AC08845751AA59988A8E1D5A820F2C67BCFE9197F65AD2E2DAA5297", + "TransactionType": "Payment", + "TxnSignature": "529DB2F27B1CB8B09C8CDA5C816CE1FCAB61219E7248376F5781AFD7120B055897133567162B71BD840FBEBED7C1E8A01210EEEF417C9614947B58864BFC2705", + "hash": "0F26106203E588029FC78550B312D8419F3622015079274955BCBD2D0EF8F5B4", + "DeliverMax": "1", + "ctid": "C5DA901200480000", + "date": 808700180, + "ledger_index": 98209810, + "inLedger": 98209810 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "31236976", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046083 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "32241976", + "Sequence": 82046082 + }, + "PreviousTxnID": "5DE47325BB59642F41317FC7904729E94EC580E2557110E3D228CAAAAB92DDCF", + "PreviousTxnLgrSeq": 98209765 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "34419009", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046908 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "33419009" + }, + "PreviousTxnID": "F8BF5BCE81676312934886D862E58BC2794AD9AD9C9BEA10D424F96F4D690AF3", + "PreviousTxnLgrSeq": 98209765 + } + } + ], + "TransactionIndex": 5, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "1000000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 98209815, + "Sequence": 82046082, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304402202C6A45C13A7327DF6F5E3C9DFAD23C450DF735426ED18FB8AE911C351F263CF6022000E18F76099BA4DE7859190D9E9D0EDC72E3119116C4FF00D2AC5C116FDB468A", + "hash": "883040AB9B4FEE4F6F4CE588D5A3C5BA0D317FC2C6CB2112E71C13EDF9A3FA20", + "DeliverMax": "1000000", + "ctid": "C5DA900C00050000", + "date": 808700152, + "ledger_index": 98209804, + "inLedger": 98209804 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6yAvyZ5kgRNWqMkdsoHN4Fiu7fiVdLc3", + "Balance": "16260442", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 95613011 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "022C90AE97273AA9C784D0CAA61BC05B999171F66F36A1576B70D77586847FD4", + "PreviousFields": { + "Balance": "16260454", + "Sequence": 95613010 + }, + "PreviousTxnID": "272149F365E6D74831A04A3342C95DB309B0C2C26807C5EC2612E2D1D861C092", + "PreviousTxnLgrSeq": 98209663 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "32241976", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046082 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "32241975" + }, + "PreviousTxnID": "04F53F220DD1BCB7CCF279D66FFB986EA41383EFC9378CA1EBF1823D7C89264F", + "PreviousTxnLgrSeq": 98209761 + } + } + ], + "TransactionIndex": 11, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "r6yAvyZ5kgRNWqMkdsoHN4Fiu7fiVdLc3", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 98209864, + "Sequence": 95613010, + "SigningPubKey": "EDE58A5FE80AC08845751AA59988A8E1D5A820F2C67BCFE9197F65AD2E2DAA5297", + "TransactionType": "Payment", + "TxnSignature": "4492397DE08C94FF84ACDC79BBE522DD4BA53924AB34A05090BE1121A72889845EE4AC9D7C09DD5498BEF7C4D30600701E101C3B40254CF3CA86677D623D810E", + "hash": "5DE47325BB59642F41317FC7904729E94EC580E2557110E3D228CAAAAB92DDCF", + "DeliverMax": "1", + "ctid": "C5DA8FE5000B0000", + "date": 808700001, + "ledger_index": 98209765, + "inLedger": 98209765 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "32241975", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046082 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "33246975", + "Sequence": 82046081 + }, + "PreviousTxnID": "272149F365E6D74831A04A3342C95DB309B0C2C26807C5EC2612E2D1D861C092", + "PreviousTxnLgrSeq": 98209663 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "33419008", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046908 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "32419008" + }, + "PreviousTxnID": "AED77F925A17CFD574E0291AE2CB5BE442D15AC12A34809460B417D0D6AEFA66", + "PreviousTxnLgrSeq": 98209663 + } + } + ], + "TransactionIndex": 18, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "1000000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 98209772, + "Sequence": 82046081, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304502210088BA38852E6406B6C20894D754768650928FF26A9034BAF45521D02E3A52DB63022043AED91A0919CA9E833E7196BB363054E99DAD027502A190D7ED523CCE54F35C", + "hash": "04F53F220DD1BCB7CCF279D66FFB986EA41383EFC9378CA1EBF1823D7C89264F", + "DeliverMax": "1000000", + "ctid": "C5DA8FE100120000", + "date": 808699990, + "ledger_index": 98209761, + "inLedger": 98209761 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6yAvyZ5kgRNWqMkdsoHN4Fiu7fiVdLc3", + "Balance": "16260454", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 95613010 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "022C90AE97273AA9C784D0CAA61BC05B999171F66F36A1576B70D77586847FD4", + "PreviousFields": { + "Balance": "16260466", + "Sequence": 95613009 + }, + "PreviousTxnID": "80758FC4C39D1E1BF547CD5AB7F46596F9509C548633DE731A005B154511115D", + "PreviousTxnLgrSeq": 98209437 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "33246975", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046081 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "33246974" + }, + "PreviousTxnID": "B35AE5C214C22338470F4EA42CC0C42A0EE918F06C2D7969434419129A1CECD9", + "PreviousTxnLgrSeq": 98209659 + } + } + ], + "TransactionIndex": 44, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "r6yAvyZ5kgRNWqMkdsoHN4Fiu7fiVdLc3", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 98209762, + "Sequence": 95613009, + "SigningPubKey": "EDE58A5FE80AC08845751AA59988A8E1D5A820F2C67BCFE9197F65AD2E2DAA5297", + "TransactionType": "Payment", + "TxnSignature": "A5ED82ED0BBFC501F01EDA3EE3DD94716EACEFC1FAED78FCAE68F3543F35DE9FB1685932C1D48020D1150282117A6C18CBE9E335AF926E6FFA3C12166766E20E", + "hash": "272149F365E6D74831A04A3342C95DB309B0C2C26807C5EC2612E2D1D861C092", + "DeliverMax": "1", + "ctid": "C5DA8F7F002C0000", + "date": 808699610, + "ledger_index": 98209663, + "inLedger": 98209663 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "33246974", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046081 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "34251974", + "Sequence": 82046080 + }, + "PreviousTxnID": "00778C36255A48E753E7CDD3B60243D551ACD4B6ABD6765E9011D28B7566FEAB", + "PreviousTxnLgrSeq": 96608991 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "32419007", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046908 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "31419007" + }, + "PreviousTxnID": "3A46B7C50C60E862F8F86DD53065B8974F3FE2133A911346E9BF3AE59EE3CE8D", + "PreviousTxnLgrSeq": 95704331 + } + } + ], + "TransactionIndex": 20, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "1000000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 98209670, + "Sequence": 82046080, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3045022100F9F3CB2EBDD7E9020AB6D429F2D71B070E92D9F44ECFE0519533AA8C6D1A3BE7022014CFEACC7A0577A8E8D5EF5D302D77C829DB8D24CC4FD7CB2587080354EF2F93", + "hash": "B35AE5C214C22338470F4EA42CC0C42A0EE918F06C2D7969434419129A1CECD9", + "DeliverMax": "1000000", + "ctid": "C5DA8F7B00140000", + "date": 808699591, + "ledger_index": 98209659, + "inLedger": 98209659 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c", + "Balance": "13999936", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 95916371 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "605D83E0C821373201428CA41691FFA2DD22ED9159574C71A96D14710401E58D", + "PreviousFields": { + "Balance": "13999948", + "Sequence": 95916370 + }, + "PreviousTxnID": "B8C641A7289D5B91B9E86C1E3742B51F2DF930149CBD386E9EF551365B7F25E6", + "PreviousTxnLgrSeq": 96608988 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "34251974", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046080 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "34251973" + }, + "PreviousTxnID": "C370C5AA3696056A5A6F13D2FBC69E915FF0C61C1022D045717DF580E00B07C1", + "PreviousTxnLgrSeq": 96608985 + } + } + ], + "TransactionIndex": 73, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 96609090, + "Sequence": 95916370, + "SigningPubKey": "EDC9744D12A30F4EEEC8B9D3A608EDC08C7D855A8EB484DDC943C371B39791F05D", + "TransactionType": "Payment", + "TxnSignature": "99566A350D63D34C66802BB6B187984A3D9967E52F54900E4C6CD53E7A8196C03104712F59AD0993DCE0CC6D99D82E32F6C990862F47431187C3AD635D19130B", + "hash": "00778C36255A48E753E7CDD3B60243D551ACD4B6ABD6765E9011D28B7566FEAB", + "DeliverMax": "1", + "ctid": "C5C222DF00490000", + "date": 802465831, + "ledger_index": 96608991, + "inLedger": 96608991 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGMcqxy5gjHPhSKmfYCg4fwKdgyS1nLUfw", + "Balance": "7441250890", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 96599896 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "8502B8C4A5317E10FA30EB0B1131C46DB94AED53C2A20E8E1D12212E63A2B936", + "PreviousFields": { + "Balance": "7435250890" + }, + "PreviousTxnID": "7A8B8A8AA05260A79639D4F1C0F90D9CA6F4454E650F1D639413440EE9595B46", + "PreviousTxnLgrSeq": 96608516 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "34251973", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046080 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "40256973", + "Sequence": 82046079 + }, + "PreviousTxnID": "66B94DCE7F0DDE3B0921462221324310B909DC32829743511B188029DE8BBE13", + "PreviousTxnLgrSeq": 96608933 + } + } + ], + "TransactionIndex": 32, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "6000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "6000000", + "Destination": "rGMcqxy5gjHPhSKmfYCg4fwKdgyS1nLUfw", + "Fee": "5000", + "LastLedgerSequence": 96608996, + "Memos": [ + { + "Memo": { + "MemoData": "3D3A733A3078424134443164333562436530653846323845356133343033653761306239393663356435304143343A302F312F303A67313A3530" + } + } + ], + "Sequence": 82046079, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304402203925A7C3E8C13D049129F69FCF925C9A27C6D6FA05943BF8FB489816E1B1DD89022051931C113A60ED8314C59586A82E80CDA658A0105B74B38B70B5F1D719169280", + "hash": "C370C5AA3696056A5A6F13D2FBC69E915FF0C61C1022D045717DF580E00B07C1", + "DeliverMax": "6000000", + "ctid": "C5C222D900200000", + "date": 802465811, + "ledger_index": 96608985, + "inLedger": 96608985 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c", + "Balance": "14000116", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 95916356 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "605D83E0C821373201428CA41691FFA2DD22ED9159574C71A96D14710401E58D", + "PreviousFields": { + "Balance": "14000128", + "Sequence": 95916355 + }, + "PreviousTxnID": "941B181B2879F1D4B42766944AB4281209EA456BAA4D3D96A372E30455BCB516", + "PreviousTxnLgrSeq": 96608927 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "40256973", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046079 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "40256972" + }, + "PreviousTxnID": "90F57D6F28AAC66E0C0D8487325DDBF0918A360CABAAA13D17B07B43E3118102", + "PreviousTxnLgrSeq": 96608928 + } + } + ], + "TransactionIndex": 38, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "rGBpbVC11etyeGpJCAPrfS1of7SrEM2Q2c", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 96609032, + "Sequence": 95916355, + "SigningPubKey": "EDC9744D12A30F4EEEC8B9D3A608EDC08C7D855A8EB484DDC943C371B39791F05D", + "TransactionType": "Payment", + "TxnSignature": "FF396C51212EAA10FBB5F47F72E32115DCE82E1C52FCEC2B48114F8316F15F763D3EA7EB2D0EC3CF5E89F233193CCF00AC1FA60C3BDA2C8B733F767E6688BC08", + "hash": "66B94DCE7F0DDE3B0921462221324310B909DC32829743511B188029DE8BBE13", + "DeliverMax": "1", + "ctid": "C5C222A500260000", + "date": 802465610, + "ledger_index": 96608933, + "inLedger": 96608933 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGtd6GeXddZJaReP3nUornttjUm9neYaiH", + "Balance": "7373981027", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 96600609 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "73D11298047CA0A5612526B86F64306CFC3FB4A60F8A7BF03526B16983C9838B", + "PreviousFields": { + "Balance": "7403386130", + "Sequence": 96600608 + }, + "PreviousTxnID": "7DC0DE436734F341DC984353292F9C8FA052CD13E8DE8D26F0088BA8A7E77E13", + "PreviousTxnLgrSeq": 96606795 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "40256972", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046079 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10868504" + }, + "PreviousTxnID": "3A46B7C50C60E862F8F86DD53065B8974F3FE2133A911346E9BF3AE59EE3CE8D", + "PreviousTxnLgrSeq": 95704331 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "29388468" + }, + "tx": { + "Account": "rGtd6GeXddZJaReP3nUornttjUm9neYaiH", + "Amount": "29388468", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "16635", + "Memos": [ + { + "Memo": { + "MemoData": "4F55543A34463835414536423642393436463039373631373345463734343439363533343845333834393834413437314438313639304138453445424535453046444534" + } + } + ], + "Sequence": 96600608, + "SigningPubKey": "023BC0B80C4795954053887A2B84A4BD9893A520FC4D278E9D67A194907DDA853E", + "TransactionType": "Payment", + "TxnSignature": "3045022100BC9899592C3A0EEBDA00C134EB8EDA4F16569CDA202DAC7A0D69AE9D3FE0F25E0220256E69FDAFAE9F73F30DD4849D7F8E4C29FD7A9AECE94357D3E220911B72392D", + "hash": "90F57D6F28AAC66E0C0D8487325DDBF0918A360CABAAA13D17B07B43E3118102", + "DeliverMax": "29388468", + "ctid": "C5C222A000000000", + "date": 802465590, + "ledger_index": 96608928, + "inLedger": 96608928 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10868504", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046079 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10969004", + "Sequence": 82046078 + }, + "PreviousTxnID": "D872B32A3279FDACE52276DB9E82263F28E25C907CB663AF3286B3C9A578AA0B", + "PreviousTxnLgrSeq": 95344881 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "31419007", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046908 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "31319007" + }, + "PreviousTxnID": "CA30D1DE810C6E440E825CBCD44ECA2A644FB5E49076D98FBE7561620B970A6F", + "PreviousTxnLgrSeq": 95343178 + } + } + ], + "TransactionIndex": 18, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "100000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "100000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "500", + "Flags": 0, + "LastLedgerSequence": 95704342, + "Sequence": 82046078, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "30450221008E10B35166C98F9554549CA7B217A0E334F97424C2A3451EC7C0B31507DF933F02206A79C57F4E1630C550F9E23BFEB7FC73D3A53FE1230D9682F56A0B3FE2333D8C", + "hash": "3A46B7C50C60E862F8F86DD53065B8974F3FE2133A911346E9BF3AE59EE3CE8D", + "DeliverMax": "100000", + "ctid": "C5B4550B00120000", + "date": 798936881, + "ledger_index": 95704331, + "inLedger": 95704331 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.498829476270808" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "699", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "3302E02E14C10A41487339EDAE7017EAB87209BC7D33C4DDF58588F493664B58", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.478829476270808" + } + }, + "PreviousTxnID": "CA30D1DE810C6E440E825CBCD44ECA2A644FB5E49076D98FBE7561620B970A6F", + "PreviousTxnLgrSeq": 95343178 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10969004", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046078 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10974004", + "Sequence": 82046077 + }, + "PreviousTxnID": "136E9A37D8E097D0A3B9E9998EABE233896E58D89D3A325E1477071B4501F5A1", + "PreviousTxnLgrSeq": 95344317 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.171" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.191" + } + }, + "PreviousTxnID": "045BE6F1E31F0A4970399D5635CF44B75EE0A45EBCBD66849DBB8E6793AEA383", + "PreviousTxnLgrSeq": 95344333 + } + } + ], + "TransactionIndex": 1, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.02" + } + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.02" + }, + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 95344892, + "Sequence": 82046077, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3045022100CC68F54A7C0E75093584BAFDF7E68CCEA93583106EBC61F92ADE0F4B6EF27A7E022032094DB6C411431EC80517BF5C934C4137811AB726957DCB4B8BEE2A01F3AB96", + "hash": "D872B32A3279FDACE52276DB9E82263F28E25C907CB663AF3286B3C9A578AA0B", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.02" + }, + "ctid": "C5AED8F100010000", + "date": 797535040, + "ledger_index": 95344881, + "inLedger": 95344881 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.11" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "79a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "r9FnAa9pzCu3k6Dkxk7bXtcSd5EuhZg9p3", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "2E05D217913E0CE02C06CCEB574CF135DD42D04A974571108E6F7E49F983A21A", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.12" + } + }, + "PreviousTxnID": "136E9A37D8E097D0A3B9E9998EABE233896E58D89D3A325E1477071B4501F5A1", + "PreviousTxnLgrSeq": 95344317 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r9FnAa9pzCu3k6Dkxk7bXtcSd5EuhZg9p3", + "Balance": "38985001", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 90500473 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E4FB06377859777E89B87D202CC39547328C118C32C7D020636F29F148A386E8", + "PreviousFields": { + "Balance": "38990001", + "Sequence": 90500472 + }, + "PreviousTxnID": "9B20C0136E4B457F62490CDA02C0E253EE1DE3A2D86F3B3758E72ED332B5F179", + "PreviousTxnLgrSeq": 95344284 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.191" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.181" + } + }, + "PreviousTxnID": "136E9A37D8E097D0A3B9E9998EABE233896E58D89D3A325E1477071B4501F5A1", + "PreviousTxnLgrSeq": 95344317 + } + } + ], + "TransactionIndex": 7, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.01" + } + }, + "tx": { + "Account": "r9FnAa9pzCu3k6Dkxk7bXtcSd5EuhZg9p3", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.01" + }, + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 95344344, + "Sequence": 90500472, + "SigningPubKey": "038472A1E1411864C0948766273C5FD15F328E724D18681B823FEC91449E3AAFC3", + "TransactionType": "Payment", + "TxnSignature": "3045022100B4949051B95D8D7B4FA7BAFE2013081BDEE0295B2FCE223EDDE5D78A17CAA4EA022074730445CC3A651EA281566B308B89D2B61CE1F5EE09921E456F9826A3EB3E01", + "hash": "045BE6F1E31F0A4970399D5635CF44B75EE0A45EBCBD66849DBB8E6793AEA383", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.01" + }, + "ctid": "C5AED6CD00070000", + "date": 797532890, + "ledger_index": 95344333, + "inLedger": 95344333 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.12" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "79a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "r9FnAa9pzCu3k6Dkxk7bXtcSd5EuhZg9p3", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "2E05D217913E0CE02C06CCEB574CF135DD42D04A974571108E6F7E49F983A21A", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + } + }, + "PreviousTxnID": "9B20C0136E4B457F62490CDA02C0E253EE1DE3A2D86F3B3758E72ED332B5F179", + "PreviousTxnLgrSeq": 95344284 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10974004", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046077 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10979004", + "Sequence": 82046076 + }, + "PreviousTxnID": "082E3E1264E908F9553F75409F027B165A983013342F12B90CDED030E70B4353", + "PreviousTxnLgrSeq": 94800749 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.181" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.301" + } + }, + "PreviousTxnID": "CA30D1DE810C6E440E825CBCD44ECA2A644FB5E49076D98FBE7561620B970A6F", + "PreviousTxnLgrSeq": 95343178 + } + } + ], + "TransactionIndex": 21, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.12" + } + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.12" + }, + "Destination": "r9FnAa9pzCu3k6Dkxk7bXtcSd5EuhZg9p3", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 95344326, + "Sequence": 82046076, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3044022074CA8EF58F4B7D4B3A5863DF957E5C4DB8CF5DB5DE51C66A8815A0382F45CE42022065A29DF79642E69233E1E17F6EDA6E02285EB13555595B9D112471AEBF8EA402", + "hash": "136E9A37D8E097D0A3B9E9998EABE233896E58D89D3A325E1477071B4501F5A1", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.12" + }, + "ctid": "C5AED6BD00150000", + "date": 797532821, + "ledger_index": 95344317, + "inLedger": 95344317 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.478829476270808" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "699", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "3302E02E14C10A41487339EDAE7017EAB87209BC7D33C4DDF58588F493664B58", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.778829476270808" + } + }, + "PreviousTxnID": "D1BB342DC4D3B55403DD08934B39F60B9593CC055623B1F803EF1992CB50E115", + "PreviousTxnLgrSeq": 94801792 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "31319007", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046908 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "31324007", + "Sequence": 82046907 + }, + "PreviousTxnID": "1C1DABB248CB95C654A01781C165684C227D9ACDE5836DC0CAEE142DAF478D8D", + "PreviousTxnLgrSeq": 94801802 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.301" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.001" + } + }, + "PreviousTxnID": "6A402139F7E8A7B14A759B413B72B97A02D47683DB8D1DB1D83BCD4A5A8234DA", + "PreviousTxnLgrSeq": 94800737 + } + } + ], + "TransactionIndex": 88, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.3" + } + }, + "tx": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.3" + }, + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 95343187, + "Sequence": 82046907, + "SigningPubKey": "03A3A9B927937FA686AF7B6DB9787D730FFBDA36C3F185421E177414F422FCDC3E", + "TransactionType": "Payment", + "TxnSignature": "30440220221C6CB02F854D3442B6A63FEDAA0B65BD6631A942504F45AAE981C37886C1C0022064DCC1AB85FC2A92D1440479527BC37A1852FE3252B8F0E0D70628734F92B647", + "hash": "CA30D1DE810C6E440E825CBCD44ECA2A644FB5E49076D98FBE7561620B970A6F", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.3" + }, + "ctid": "C5AED24A00580000", + "date": 797528351, + "ledger_index": 95343178, + "inLedger": 95343178 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rJ73aumLPTQQmy5wnGhvrogqf5DDhjuzc9", + "Balance": "58948923", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 94816957 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "D70384C6A81A5375B1DF840FAD6E7B5672780BC1583CEAB7B2247B8D456B28CB", + "PreviousFields": { + "Balance": "58948935", + "Sequence": 94816956 + }, + "PreviousTxnID": "4A7CECC0561A8586B3023A0870179AA2A13FA82837EF6FA3A971EA480FF2E26F", + "PreviousTxnLgrSeq": 94800749 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10979004", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046076 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10979003" + }, + "PreviousTxnID": "C6E1E642A391360D1EBB6B323ACBC65E5B9B1B13F6728E6F9A0CD803F31321B2", + "PreviousTxnLgrSeq": 94215748 + } + } + ], + "TransactionIndex": 80, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "rJ73aumLPTQQmy5wnGhvrogqf5DDhjuzc9", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 94800847, + "Memos": [ + { + "Memo": { + "MemoData": "436C61696D2024524C55534420746F6B656E2061697264726F702061742068747470733A2F2F636C61696D2D7573646875622E636F6D", + "MemoType": "41697264726F70" + } + } + ], + "Sequence": 94816956, + "SigningPubKey": "EDC41841B85D6E6CA8EF82B9E4A08F29CD0966334EAC7F8690DAD6546F2DFC00A6", + "TransactionType": "Payment", + "TxnSignature": "B7C5044F05CA0881CF72FD1BD1DE617F8E8E3BA361856474BB2159E07152998A8898980DFF47290F20A928BA7F89A913993D38A9CD48187018412A4CEF64B10D", + "hash": "082E3E1264E908F9553F75409F027B165A983013342F12B90CDED030E70B4353", + "DeliverMax": "1", + "ctid": "C5A68B6D00500000", + "date": 795409401, + "ledger_index": 94800749, + "inLedger": 94800749 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.165" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "738", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rHYDRjArNRgkrq7Rr4Exow9GeyMoGgnKL8", + "value": "1000000000000000e-1" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "73C18857DB56D51F6EC93B94A4F5C17DEC816031C32524818FFBCA5B0742CFD3", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.166" + } + }, + "PreviousTxnID": "FA35DDD152937163FD592EB836554686617587A72DD0FF06A06B6BA8478E0D76", + "PreviousTxnLgrSeq": 94800735 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.001" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + } + }, + "PreviousTxnID": "C6E1E642A391360D1EBB6B323ACBC65E5B9B1B13F6728E6F9A0CD803F31321B2", + "PreviousTxnLgrSeq": 94215748 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rHYDRjArNRgkrq7Rr4Exow9GeyMoGgnKL8", + "Balance": "5978338", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 94718565 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F89D1FBB3EF18AA9A0B751341D1BB5502CD4234773034F9E3F6439A11DE47489", + "PreviousFields": { + "Balance": "5978348", + "Sequence": 94718564 + }, + "PreviousTxnID": "FA35DDD152937163FD592EB836554686617587A72DD0FF06A06B6BA8478E0D76", + "PreviousTxnLgrSeq": 94800735 + } + } + ], + "TransactionIndex": 18, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.001" + } + }, + "tx": { + "Account": "rHYDRjArNRgkrq7Rr4Exow9GeyMoGgnKL8", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.001" + }, + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "10", + "Flags": 0, + "LastLedgerSequence": 94800756, + "Memos": [ + { + "Memo": { + "MemoData": "546865205852504C20686173204C322A4C617965723220746F6B656E732C2066696E64207468656D202D206C696B6520586F6765202D204F6E2046697273744C65646765722C2058704D61726B657420616E6420584D61676E657469632E2044656C69766572792F586F6765204879647261", + "MemoFormat": "746578742F706C61696E", + "MemoType": "4D656D6F" + } + } + ], + "Sequence": 94718564, + "SigningPubKey": "ED437C536A2B5500333C09B80D0BB42ECB57A1D8FBFB3C1411681A66310B564B66", + "TransactionType": "Payment", + "TxnSignature": "1AB48D78969256F39BB5E07EFF02A5F290185F5AE8026CA538137C6E0E290A78084B4BAC26480022E179EF5B4EED7A2D9FE0719090752329E5BCDBFE2395B80B", + "hash": "6A402139F7E8A7B14A759B413B72B97A02D47683DB8D1DB1D83BCD4A5A8234DA", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.001" + }, + "ctid": "C5A68B6100120000", + "date": 795409351, + "ledger_index": 94800737, + "inLedger": 94800737 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.777829476270808" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "699", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "3302E02E14C10A41487339EDAE7017EAB87209BC7D33C4DDF58588F493664B58", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.667829476270808" + } + }, + "PreviousTxnID": "B7D288A781C42C400E76FFAA5129EB1EC3B4387109431446948205643B79A8AE", + "PreviousTxnLgrSeq": 94215731 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10979003", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046076 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10984003", + "Sequence": 82046075 + }, + "PreviousTxnID": "B7D288A781C42C400E76FFAA5129EB1EC3B4387109431446948205643B79A8AE", + "PreviousTxnLgrSeq": 94215731 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.11" + } + }, + "PreviousTxnID": "B7D288A781C42C400E76FFAA5129EB1EC3B4387109431446948205643B79A8AE", + "PreviousTxnLgrSeq": 94215731 + } + } + ], + "TransactionIndex": 42, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.11" + } + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.11" + }, + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 94215757, + "Sequence": 82046075, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304402205601CE17C1D9A312E2ABFACFFF5834ED620631124390F296C53188E47245FDCC022072380132C2B3C723D3890951931D339B7FDC3FD988407B040A61BCF8D8316C92", + "hash": "C6E1E642A391360D1EBB6B323ACBC65E5B9B1B13F6728E6F9A0CD803F31321B2", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.11" + }, + "ctid": "C59D9E44002A0000", + "date": 793141340, + "ledger_index": 94215748, + "inLedger": 94215748 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.667829476270808" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "699", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "3302E02E14C10A41487339EDAE7017EAB87209BC7D33C4DDF58588F493664B58", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.657829476270808" + } + }, + "PreviousTxnID": "7C0EC4F1F1F5C2251167DA94D2E48FC9E9FCACE598633C8C633281D005760AB1", + "PreviousTxnLgrSeq": 94174713 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10984003", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046075 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10984503", + "Sequence": 82046074 + }, + "PreviousTxnID": "7C0EC4F1F1F5C2251167DA94D2E48FC9E9FCACE598633C8C633281D005760AB1", + "PreviousTxnLgrSeq": 94174713 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.11" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.12" + } + }, + "PreviousTxnID": "7C0EC4F1F1F5C2251167DA94D2E48FC9E9FCACE598633C8C633281D005760AB1", + "PreviousTxnLgrSeq": 94174713 + } + } + ], + "TransactionIndex": 43, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.01" + } + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.01" + }, + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "500", + "Flags": 0, + "LastLedgerSequence": 94215740, + "Sequence": 82046074, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304402204A32DEC73E2A1662425F8E471A1F372D8AECB132817208652091CFA43BB2ACD802207B98D9B1DA5C8453529C6D5FAEC3F34A9DC6C7B32AE62E2D91606FA88C6DF462", + "hash": "B7D288A781C42C400E76FFAA5129EB1EC3B4387109431446948205643B79A8AE", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0.01" + }, + "ctid": "C59D9E33002B0000", + "date": 793141271, + "ledger_index": 94215731, + "inLedger": 94215731 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.657829476270808" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "699", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "3302E02E14C10A41487339EDAE7017EAB87209BC7D33C4DDF58588F493664B58", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1.657829476270808" + } + }, + "PreviousTxnID": "6CFABA222CEF1F1A1568F3D720C1515227D923303FAC4EE69670BABDD1926AFC", + "PreviousTxnLgrSeq": 94174709 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10984503", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046074 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10989503", + "Sequence": 82046073 + }, + "PreviousTxnID": "8D85617BCB6781362F77C043B938E8956945BEB30C7AE603ED63C1F203B33EF6", + "PreviousTxnLgrSeq": 94174632 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.12" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1.12" + } + }, + "PreviousTxnID": "6CFABA222CEF1F1A1568F3D720C1515227D923303FAC4EE69670BABDD1926AFC", + "PreviousTxnLgrSeq": 94174709 + } + } + ], + "TransactionIndex": 78, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "1" + } + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "1" + }, + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 94174722, + "Sequence": 82046073, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304502210098E9118C8148FD2B1E1A1EE21C1377A8BFEA6E8392BC4C1FB416F86B712E92C602203E1D325D0619808DD031B295697910593E859A178F9ABB7B01E1A2CFA2DC04D1", + "hash": "7C0EC4F1F1F5C2251167DA94D2E48FC9E9FCACE598633C8C633281D005760AB1", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "1" + }, + "ctid": "C59CFDF9004E0000", + "date": 792983050, + "ledger_index": 94174713, + "inLedger": 94174713 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1.657829476270808" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "699", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "3302E02E14C10A41487339EDAE7017EAB87209BC7D33C4DDF58588F493664B58", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.777829476270808" + } + }, + "PreviousTxnID": "5894AE2251CB9206F9129E7D632BA47209BBA8A992DE6A4028087F1A53355D1E", + "PreviousTxnLgrSeq": 94174298 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "31329006", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 82046906 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "31334006", + "Sequence": 82046905 + }, + "PreviousTxnID": "4705730F5A134053FFAF7EE0A11CF57BEA2DA713600843A8C5EEE329040608C2", + "PreviousTxnLgrSeq": 94174088 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1.12" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "PreviousFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + } + }, + "PreviousTxnID": "8D85617BCB6781362F77C043B938E8956945BEB30C7AE603ED63C1F203B33EF6", + "PreviousTxnLgrSeq": 94174632 + } + } + ], + "TransactionIndex": 41, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "1.12" + } + }, + "tx": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Amount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "1.12" + }, + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 94174718, + "Sequence": 82046905, + "SigningPubKey": "03A3A9B927937FA686AF7B6DB9787D730FFBDA36C3F185421E177414F422FCDC3E", + "TransactionType": "Payment", + "TxnSignature": "3045022100D28C4180D8660710F7932E96E80A6C986D62D8297A78F3095CFDE10F8278231802200837CEF052C6E815079ADABD66AA53411E45A0587DBA382A561EFAEEB289C12C", + "hash": "6CFABA222CEF1F1A1568F3D720C1515227D923303FAC4EE69670BABDD1926AFC", + "DeliverMax": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "1.12" + }, + "ctid": "C59CFDF500290000", + "date": 792983031, + "ledger_index": 94174709, + "inLedger": 94174709 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "383BB05FBB110F9BE530B75C44DF100F86C5600F0F146593D3E7133FD1C99FF2", + "PreviousTxnID": "EA7ACE1597CF6E07DD281C6C8BF19A95CB850E179E30B4130B41F0C2BF0243CE", + "PreviousTxnLgrSeq": 94174595 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "699", + "Owner": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "RootIndex": "72D151A048AA7142421DC008863EE8C66902DADD9D14DCE7A5A2510361F67138" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "AA65F0ED039787403B11E0B8AB12A968C70724B0DA113F73200FB0EC12DF29F2", + "PreviousTxnID": "EA7ACE1597CF6E07DD281C6C8BF19A95CB850E179E30B4130B41F0C2BF0243CE", + "PreviousTxnLgrSeq": 94174595 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "RootIndex": "CDDAA41A89FD22CAC6BCCB5D9CC12BC838864D217CD1C347A522F5C97BFEF2F2" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "CDDAA41A89FD22CAC6BCCB5D9CC12BC838864D217CD1C347A522F5C97BFEF2F2", + "PreviousTxnID": "269CAF7F150BD8A3D2250FCBA1E3F6CA1B7CBF83EB3C892EE6C81FE8B45AE2BC", + "PreviousTxnLgrSeq": 92952306 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10989503", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046073 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10994503", + "OwnerCount": 1, + "Sequence": 82046072 + }, + "PreviousTxnID": "269CAF7F150BD8A3D2250FCBA1E3F6CA1B7CBF83EB3C892EE6C81FE8B45AE2BC", + "PreviousTxnLgrSeq": 92952306 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "RippleState", + "LedgerIndex": "F43A383173EA146048CFF1CA843BA66E01F2384358D5CC4FABFD5A4151B1672E", + "NewFields": { + "Balance": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 65536, + "HighLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "69a", + "LowLimit": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "6900000000000000e-4" + } + } + } + } + ], + "TransactionIndex": 16, + "TransactionResult": "tesSUCCESS" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "5000", + "Flags": 0, + "LastLedgerSequence": 94174641, + "LimitAmount": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "6900000000000000e-4" + }, + "Sequence": 82046072, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "TrustSet", + "TxnSignature": "3044022027C637E927281796B3F0FB48ACE0B4015741CA25F798EDAE74138AE87F00625E02200CCB6D08ECACA72E2F151BD403FAE0DCF45D8AC4023BB282EC68C58E750EFFCF", + "hash": "8D85617BCB6781362F77C043B938E8956945BEB30C7AE603ED63C1F203B33EF6", + "ctid": "C59CFDA800100000", + "date": 792982732, + "ledger_index": 94174632, + "inLedger": 94174632 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "49c", + "Owner": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "RootIndex": "72D151A048AA7142421DC008863EE8C66902DADD9D14DCE7A5A2510361F67138" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "358FBCF1FA4F9F7A6EB6EC216F350FBCD8D85CABF7B20A27D86EC9681A449B7B", + "PreviousTxnID": "45EF3E2186E9E31ECB9E8BF0A7C00487ED75912AC7CC445A0B147BBFA0951915", + "PreviousTxnLgrSeq": 92952204 + } + }, + { + "ModifiedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "383BB05FBB110F9BE530B75C44DF100F86C5600F0F146593D3E7133FD1C99FF2", + "PreviousTxnID": "E061737F1FB3C4F02111095B8A3787006BC3EB6C6E1305F61880F87CC67FCE9D", + "PreviousTxnLgrSeq": 92952224 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "RippleState", + "LedgerIndex": "6C670A2AD7FF2F1B59B22FB25EF2ED0C4B121D4934D7C85BC90533E6E49B489F", + "NewFields": { + "Balance": { + "currency": "USD", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 65536, + "HighLimit": { + "currency": "USD", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "0" + }, + "HighNode": "49d", + "LowLimit": { + "currency": "USD", + "issuer": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "value": "690000" + } + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "CDDAA41A89FD22CAC6BCCB5D9CC12BC838864D217CD1C347A522F5C97BFEF2F2", + "NewFields": { + "Owner": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "RootIndex": "CDDAA41A89FD22CAC6BCCB5D9CC12BC838864D217CD1C347A522F5C97BFEF2F2" + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10994503", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 82046072 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10999503", + "OwnerCount": 0, + "Sequence": 82046071 + }, + "PreviousTxnID": "702FEE838239556E9C568CD5BBBDAE806BC9CCE2FB23A1339BA32EE4CED28B2C", + "PreviousTxnLgrSeq": 92792849 + } + } + ], + "TransactionIndex": 15, + "TransactionResult": "tesSUCCESS" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "5000", + "Flags": 0, + "LimitAmount": { + "currency": "USD", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "690000" + }, + "Sequence": 82046071, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "TrustSet", + "TxnSignature": "3045022100A362BBCF072C20802DC28889D82B8A0F43ED2AD8EED7D75E2930FA1900A041810220743B37A707ED9A926338E3932D6880BB2738CF61A50FE9F72A1965EF509398BC", + "hash": "269CAF7F150BD8A3D2250FCBA1E3F6CA1B7CBF83EB3C892EE6C81FE8B45AE2BC", + "ctid": "C58A56F2000F0000", + "date": 788223232, + "ledger_index": 92952306, + "inLedger": 92952306 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rKGw3oHcsdnDnw9dnRZZX5TRqyaP6VbGGY", + "Balance": "45676028", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 92703392 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1564594E52712A7805054EED699BAD7EBC1E91F29EAE49A2ED2465960864C18A", + "PreviousFields": { + "Balance": "45676040", + "Sequence": 92703391 + }, + "PreviousTxnID": "FDC7C534EA9B332ED33730E1EF760ED02C769765DA81977C95C7C8866F57A5A7", + "PreviousTxnLgrSeq": 92792849 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10999503", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046071 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10999502" + }, + "PreviousTxnID": "7769F63010D690ECC4DC922730E676BAED2C6325B818439344A7826183650C60", + "PreviousTxnLgrSeq": 92792842 + } + } + ], + "TransactionIndex": 27, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "rKGw3oHcsdnDnw9dnRZZX5TRqyaP6VbGGY", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 92792947, + "Sequence": 92703391, + "SigningPubKey": "EDEA14154F4BF3B6F6F5BE4F1238F97A95F490E4C6FB514DF37D2D4477C7119E53", + "TransactionType": "Payment", + "TxnSignature": "F1E26FACB8FE375AF187FA8457A077093D01FA36AEB39979D23E8FEA1BD406B7441127CC57912F47624CA5B91A55490849D5A875AFDCD67D9A619547EEE36F0A", + "hash": "702FEE838239556E9C568CD5BBBDAE806BC9CCE2FB23A1339BA32EE4CED28B2C", + "DeliverMax": "1", + "ctid": "C587E811001B0000", + "date": 787602381, + "ledger_index": 92792849, + "inLedger": 92792849 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "3808230C7FC1AA9C0A04225FB1DCC0E82730D5D6858892253C908F4AFD43D256", + "NewFields": { + "Account": "rKcd5oyKsjcXTavNPnwXKTXxNDq2Xg7b2V", + "Balance": "5000000", + "Sequence": 92792842 + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10999502", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046071 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "16000002", + "Sequence": 82046070 + }, + "PreviousTxnID": "1D54D11763B569F1A347A73C73573E38C48C109EEFB27D4234FCACC1837701F4", + "PreviousTxnLgrSeq": 92709778 + } + } + ], + "TransactionIndex": 29, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "5000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "5000000", + "Destination": "rKcd5oyKsjcXTavNPnwXKTXxNDq2Xg7b2V", + "Fee": "500", + "Flags": 0, + "Sequence": 82046070, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "30440220249757FFE36AECF9E8AC5ABC947E0625A1F4F9017D1F739DBF6F36167A8C68B402203AC43725DAB52D8B7814C6A194C1B1CE1CE31E675CB34539F72B5F4EF32D94B3", + "hash": "7769F63010D690ECC4DC922730E676BAED2C6325B818439344A7826183650C60", + "DeliverMax": "5000000", + "ctid": "C587E80A001D0000", + "date": 787602360, + "ledger_index": 92792842, + "inLedger": 92792842 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "16000002", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046070 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "16000001" + }, + "PreviousTxnID": "0CC023DA42895F0AACA212A17C9F51C3B1363CA1EC18F941B9E90937F5BE639E", + "PreviousTxnLgrSeq": 92709774 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r67KGTty1t9WVVx2iJ5FcHQUbmcfwB1rz", + "Balance": "52949196", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 92360922 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F6B93AEDFCE53E8B9F1C2E4AF4A443EFF81F3C4368FAB22EDAA9777E8A7AE68D", + "PreviousFields": { + "Balance": "52949208", + "Sequence": 92360921 + }, + "PreviousTxnID": "01F6D6C99D76474D93F905E60FBB7D66BC742457FC883B4F0A65E4E74EE5F3F8", + "PreviousTxnLgrSeq": 92709738 + } + } + ], + "TransactionIndex": 139, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "r67KGTty1t9WVVx2iJ5FcHQUbmcfwB1rz", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 92709877, + "Sequence": 92360921, + "SigningPubKey": "ED598856AECAB367B2F8A3336079C46B085DB0DE34CBCFCB51F5236007D942C5A2", + "TransactionType": "Payment", + "TxnSignature": "CF376484F996A8912E8A03C8E553FADA9CC8164A55D54140FA2FBBB9EE70A6CF3A7F1D77CFE53366D1E21F154674565BE1B31F48234B22C303A315C16C61F502", + "hash": "1D54D11763B569F1A347A73C73573E38C48C109EEFB27D4234FCACC1837701F4", + "DeliverMax": "1", + "ctid": "C586A392008B0000", + "date": 787279451, + "ledger_index": 92709778, + "inLedger": 92709778 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "16000001", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046070 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "1000001" + }, + "PreviousTxnID": "CA56EB542A74436B23C855D33E5BC86A746DD917FA70DABACBB793FB326FA871", + "PreviousTxnLgrSeq": 92707584 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "31339005", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046904 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "46339505", + "Sequence": 82046903 + }, + "PreviousTxnID": "B0AEF3BE73507544E777993142066DDD41811D33CFEACB4A5388F508AA998C41", + "PreviousTxnLgrSeq": 92707586 + } + } + ], + "TransactionIndex": 82, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "15000000" + }, + "tx": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Amount": "15000000", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "500", + "Flags": 0, + "Sequence": 82046903, + "SigningPubKey": "03A3A9B927937FA686AF7B6DB9787D730FFBDA36C3F185421E177414F422FCDC3E", + "TransactionType": "Payment", + "TxnSignature": "3045022100972198AE5255DD6F75D5D3D7EF2B42178800528C53975E30FF82AD88D20E962B0220054BEEC3CC8D9A1AFA9682C6C088B9721BA75F46608EFCD659436590D167108D", + "hash": "0CC023DA42895F0AACA212A17C9F51C3B1363CA1EC18F941B9E90937F5BE639E", + "DeliverMax": "15000000", + "ctid": "C586A38E00520000", + "date": 787279432, + "ledger_index": 92709774, + "inLedger": 92709774 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "1000001", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046070 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "1000000" + }, + "PreviousTxnID": "034C6A41705881471055A180C73C3DF98F27DB0626A6E0985CB23764072E4D17", + "PreviousTxnLgrSeq": 92707530 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r67KGTty1t9WVVx2iJ5FcHQUbmcfwB1rz", + "Balance": "52949687", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 92360881 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F6B93AEDFCE53E8B9F1C2E4AF4A443EFF81F3C4368FAB22EDAA9777E8A7AE68D", + "PreviousFields": { + "Balance": "52949699", + "Sequence": 92360880 + }, + "PreviousTxnID": "37C2AFA7BDAFF74C32824BE38F9938EDBAED25FC473DDA7699EA488DC01557B9", + "PreviousTxnLgrSeq": 92707243 + } + } + ], + "TransactionIndex": 21, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "tx": { + "Account": "r67KGTty1t9WVVx2iJ5FcHQUbmcfwB1rz", + "Amount": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 92707684, + "Sequence": 92360880, + "SigningPubKey": "ED598856AECAB367B2F8A3336079C46B085DB0DE34CBCFCB51F5236007D942C5A2", + "TransactionType": "Payment", + "TxnSignature": "2D85F442F9B629B31F6EBE45ADCD9FEC8EFEE028090D806B45D1E0F51A170D6C42BEC1489B0B40CF11B10436B6E0C773FDDB0EB980297BCD46B76E1D7FABB507", + "hash": "CA56EB542A74436B23C855D33E5BC86A746DD917FA70DABACBB793FB326FA871", + "DeliverMax": "1", + "ctid": "C5869B0000150000", + "date": 787270901, + "ledger_index": 92707584, + "inLedger": 92707584 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "1000000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046070 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "35740004", + "Sequence": 82046069 + }, + "PreviousTxnID": "A45F8CD59E5EBA6146084D16C1E59ED18AFC7B7C72152FD8E8FE8E1AC6C204AF", + "PreviousTxnLgrSeq": 92704860 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "46339504", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046903 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "11600000" + }, + "PreviousTxnID": "A45F8CD59E5EBA6146084D16C1E59ED18AFC7B7C72152FD8E8FE8E1AC6C204AF", + "PreviousTxnLgrSeq": 92704860 + } + } + ], + "TransactionIndex": 44, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "34739504" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "34739504", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "500", + "Flags": 0, + "Sequence": 82046069, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3045022100A088C41A2AA552340646A490CF0CA7D843AF37680C4D4D175C56D11CB83874E402200622FCE62A2EC89ECF04842A12FD15E1A77BD9C20C5AA61E52B05C33BC71158F", + "hash": "034C6A41705881471055A180C73C3DF98F27DB0626A6E0985CB23764072E4D17", + "DeliverMax": "34739504", + "ctid": "C5869ACA002C0000", + "date": 787270691, + "ledger_index": 92707530, + "inLedger": 92707530 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "35740004", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046069 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "35750014", + "Sequence": 82046068 + }, + "PreviousTxnID": "62AE40858EAEC6F1A9973A0B069C33A94630D9C3DD58959ABA1614C3A2C9C094", + "PreviousTxnLgrSeq": 84882520 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "11600000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046903 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "11590000" + }, + "PreviousTxnID": "3995E4C6B7818CE7572A700CC36473E94CE81E8642023C4632D8E04AF1857940", + "PreviousTxnLgrSeq": 84860138 + } + } + ], + "TransactionIndex": 65, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "10000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "10000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "10", + "Flags": 0, + "Sequence": 82046068, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304402204EBB25380936D3678AF4E3E2B96090F7B4581C887DFC27098754E7AA0B1CA47D02205BB207938B38C320D09DC1D421CFF3DA4F3C6E8DB591AC03BF856B85888E67A3", + "hash": "A45F8CD59E5EBA6146084D16C1E59ED18AFC7B7C72152FD8E8FE8E1AC6C204AF", + "DeliverMax": "10000", + "ctid": "C586905C00410000", + "date": 787260311, + "ledger_index": 92704860, + "inLedger": 92704860 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "35750014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046068 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "23750014" + }, + "PreviousTxnID": "545274368AE0F07E529A30BE139BDBE5FCA7764647F820A8ECE14BB41E4B8F98", + "PreviousTxnLgrSeq": 84869363 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "Balance": "32960000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 84551362 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "EABA23FD37237A8AEF160650396F0BF379F7F7FC344D5ADBB29017468C336E01", + "PreviousFields": { + "Balance": "44965000", + "Sequence": 84551361 + }, + "PreviousTxnID": "9FC79F30C69109A9F0136E284A171C1F164FFCEFD9B08EA94EE8E668EF110F51", + "PreviousTxnLgrSeq": 84862037 + } + } + ], + "TransactionIndex": 16, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "12000000" + }, + "tx": { + "Account": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "Amount": "12000000", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "5000", + "Flags": 0, + "Sequence": 84551361, + "SigningPubKey": "03A51A0AD971342481BEA1FCA6D7EA9EA6CDAEC0D5D8A6AAF7AE580B14812939C9", + "TransactionType": "Payment", + "TxnSignature": "3045022100E1DD2910BE9DCF008567A97297FB9B6E0944EC4732126760983EE166FC57B76D022044A0C22A1B14C5172164E89E006D625EE5F930DA036C2B24F954854F71C7F4B0", + "hash": "62AE40858EAEC6F1A9973A0B069C33A94630D9C3DD58959ABA1614C3A2C9C094", + "DeliverMax": "12000000", + "ctid": "C50F345800100000", + "date": 757022101, + "ledger_index": 84882520, + "inLedger": 84882520 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "23750014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046068 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "23740014" + }, + "PreviousTxnID": "3A7BBEC94C106BFA97D6FFD529A76D2998641E16E1BDA4DF10E2C76B30F842C9", + "PreviousTxnLgrSeq": 84869018 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r4uGPFKuV7fVb4MNTythfvZ3igAu6aMoDj", + "Balance": "22085007", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82092338 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F0CAD2CEC5E921253CF24398A88DD395E282239D81A5F12C70623F089C97A41E", + "PreviousFields": { + "Balance": "22100007", + "Sequence": 82092337 + }, + "PreviousTxnID": "3A7BBEC94C106BFA97D6FFD529A76D2998641E16E1BDA4DF10E2C76B30F842C9", + "PreviousTxnLgrSeq": 84869018 + } + } + ], + "TransactionIndex": 29, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "10000" + }, + "tx": { + "Account": "r4uGPFKuV7fVb4MNTythfvZ3igAu6aMoDj", + "Amount": "10000", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "DestinationTag": 123456, + "Fee": "5000", + "Flags": 0, + "Sequence": 82092337, + "SigningPubKey": "02ABD41BAABF67CDBDC916F255A3E145CAB104F19EF3DB49A76D24F5C884FD29F3", + "TransactionType": "Payment", + "TxnSignature": "3045022100A32DEF6FACA0E899C463568BF7EBB1484E19B2E486C66916331D7ED71F414A7602201D16A67350B51CB08F2755844BB4546BE6D7D03457190212666B911FEA84530C", + "hash": "545274368AE0F07E529A30BE139BDBE5FCA7764647F820A8ECE14BB41E4B8F98", + "DeliverMax": "10000", + "ctid": "C50F00F3001D0000", + "date": 756971030, + "ledger_index": 84869363, + "inLedger": 84869363 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "23740014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046068 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "22740014" + }, + "PreviousTxnID": "6F0A9948E8A78262A89AF000CA6C5C6C5A6F6B026FFD3EB5E58E6177B3512D98", + "PreviousTxnLgrSeq": 84869014 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r4uGPFKuV7fVb4MNTythfvZ3igAu6aMoDj", + "Balance": "22100007", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82092337 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F0CAD2CEC5E921253CF24398A88DD395E282239D81A5F12C70623F089C97A41E", + "PreviousFields": { + "Balance": "23105007", + "Sequence": 82092336 + }, + "PreviousTxnID": "6F0A9948E8A78262A89AF000CA6C5C6C5A6F6B026FFD3EB5E58E6177B3512D98", + "PreviousTxnLgrSeq": 84869014 + } + } + ], + "TransactionIndex": 15, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1000000" + }, + "tx": { + "Account": "r4uGPFKuV7fVb4MNTythfvZ3igAu6aMoDj", + "Amount": "1000000", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "DestinationTag": 1234, + "Fee": "5000", + "Flags": 0, + "Sequence": 82092336, + "SigningPubKey": "02ABD41BAABF67CDBDC916F255A3E145CAB104F19EF3DB49A76D24F5C884FD29F3", + "TransactionType": "Payment", + "TxnSignature": "304402206CE2DF0F7A08F07B8BC06C5FF45165432032FDFC8FAB1517B90F0335F3FAA1FE02201572DB45A95771C54FB9C5C13FD125852A423E897A92ED2B88BD650DA0157A2D", + "hash": "3A7BBEC94C106BFA97D6FFD529A76D2998641E16E1BDA4DF10E2C76B30F842C9", + "DeliverMax": "1000000", + "ctid": "C50EFF9A000F0000", + "date": 756969682, + "ledger_index": 84869018, + "inLedger": 84869018 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "22740014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046068 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "22730014" + }, + "PreviousTxnID": "9FC79F30C69109A9F0136E284A171C1F164FFCEFD9B08EA94EE8E668EF110F51", + "PreviousTxnLgrSeq": 84862037 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r4uGPFKuV7fVb4MNTythfvZ3igAu6aMoDj", + "Balance": "23105007", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82092336 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F0CAD2CEC5E921253CF24398A88DD395E282239D81A5F12C70623F089C97A41E", + "PreviousFields": { + "Balance": "23120007", + "Sequence": 82092335 + }, + "PreviousTxnID": "B7D9CB2855564B9B34BF6F6C4708FBC7C8E5A26774778DEE6D1E9B97FEB63DA1", + "PreviousTxnLgrSeq": 82092337 + } + } + ], + "TransactionIndex": 5, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "10000" + }, + "tx": { + "Account": "r4uGPFKuV7fVb4MNTythfvZ3igAu6aMoDj", + "Amount": "10000", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "DestinationTag": 1234, + "Fee": "5000", + "Flags": 0, + "Sequence": 82092335, + "SigningPubKey": "02ABD41BAABF67CDBDC916F255A3E145CAB104F19EF3DB49A76D24F5C884FD29F3", + "TransactionType": "Payment", + "TxnSignature": "3045022100F355AD942216916B0C5286E43C27969A9A760A6C722C14A38E48E2C458F60E5F022013891376EB18B90F00F74B5B744DAB9A5A605FCEC261E41BE8EFA027FF6B2D41", + "hash": "6F0A9948E8A78262A89AF000CA6C5C6C5A6F6B026FFD3EB5E58E6177B3512D98", + "DeliverMax": "10000", + "ctid": "C50EFF9600050000", + "date": 756969670, + "ledger_index": 84869014, + "inLedger": 84869014 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "22730014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046068 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "22855014", + "Sequence": 82046067 + }, + "PreviousTxnID": "9E78EC26B0870C3F190AB99009D0AAA94164750A49ED663D6BB46AAA32359251", + "PreviousTxnLgrSeq": 84861683 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "Balance": "44965000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 84551361 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "EABA23FD37237A8AEF160650396F0BF379F7F7FC344D5ADBB29017468C336E01", + "PreviousFields": { + "Balance": "44845000" + }, + "PreviousTxnID": "1DE3C15D5F3BA9AF058A9539AFF4AB8EA4EB538941FF8AACA54AF6DECFA62929", + "PreviousTxnLgrSeq": 84861617 + } + } + ], + "TransactionIndex": 27, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "120000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "120000", + "Destination": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "DestinationTag": 1, + "Fee": "5000", + "Flags": 0, + "Sequence": 82046067, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3045022100A6B393BF77E13DCD10C17E0DC20076E813C4E73E71655CE94885553B001BCDAB02201DB2742182583C5D65B832953529C0C940DBAC4DEB83EAF97B191355B0F87DC2", + "hash": "9FC79F30C69109A9F0136E284A171C1F164FFCEFD9B08EA94EE8E668EF110F51", + "DeliverMax": "120000", + "ctid": "C50EE455001B0000", + "date": 756942462, + "ledger_index": 84862037, + "inLedger": 84862037 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "DCE0E5991331070D9A3B440D063CD37A2083A25CD88A89EB893091B699072121", + "NewFields": { + "Account": "ragCfSPtTmfCVdWtRW1dgZEfxZjFZr73nF", + "Balance": "12200000", + "Sequence": 84861683 + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "22855014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046067 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "35060014", + "Sequence": 82046066 + }, + "PreviousTxnID": "1DE3C15D5F3BA9AF058A9539AFF4AB8EA4EB538941FF8AACA54AF6DECFA62929", + "PreviousTxnLgrSeq": 84861617 + } + } + ], + "TransactionIndex": 9, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "12200000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "12200000", + "Destination": "ragCfSPtTmfCVdWtRW1dgZEfxZjFZr73nF", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046066, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "30440220099957EC97EBE9442A377177E4337A9DC52DB56EE0BE2C7175FF8985F50059B80220191C19DB933573D37D4F3E9E21768933EB1272CBEAA7C42BB84BF323F82A6548", + "hash": "9E78EC26B0870C3F190AB99009D0AAA94164750A49ED663D6BB46AAA32359251", + "DeliverMax": "12200000", + "ctid": "C50EE2F300090000", + "date": 756941082, + "ledger_index": 84861683, + "inLedger": 84861683 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "35060014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046066 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "47065014", + "Sequence": 82046065 + }, + "PreviousTxnID": "B2692E7D1150E676C5A8DE8DD987B624344AE34D28B3775BC8965F37ECF8C2B1", + "PreviousTxnLgrSeq": 84861577 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "Balance": "44845000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 84551361 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "EABA23FD37237A8AEF160650396F0BF379F7F7FC344D5ADBB29017468C336E01", + "PreviousFields": { + "Balance": "32845000" + }, + "PreviousTxnID": "B2692E7D1150E676C5A8DE8DD987B624344AE34D28B3775BC8965F37ECF8C2B1", + "PreviousTxnLgrSeq": 84861577 + } + } + ], + "TransactionIndex": 33, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "12000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "12000000", + "Destination": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "DestinationTag": 122, + "Fee": "5000", + "Flags": 0, + "Sequence": 82046065, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3045022100B62A116D6506D66379D620E5D803E7C210D19B8E3BE41B0B7A25F9FDE3E8D6D002206FBE07B17BBDB36CB7663C698C332A352D9608C10152EB4C628FE388E7C0A194", + "hash": "1DE3C15D5F3BA9AF058A9539AFF4AB8EA4EB538941FF8AACA54AF6DECFA62929", + "DeliverMax": "12000000", + "ctid": "C50EE2B100210000", + "date": 756940831, + "ledger_index": 84861617, + "inLedger": 84861617 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "47065014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046065 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "47190014", + "Sequence": 82046064 + }, + "PreviousTxnID": "C89C2999390BB58568AF71AC42912253758DD61F87308283ECCEF7C1DC303429", + "PreviousTxnLgrSeq": 84860388 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "Balance": "32845000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 84551361 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "EABA23FD37237A8AEF160650396F0BF379F7F7FC344D5ADBB29017468C336E01", + "PreviousFields": { + "Balance": "32725000" + }, + "PreviousTxnID": "C89C2999390BB58568AF71AC42912253758DD61F87308283ECCEF7C1DC303429", + "PreviousTxnLgrSeq": 84860388 + } + } + ], + "TransactionIndex": 109, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "120000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "120000", + "Destination": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046064, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3044022071D60E80E181BD92529C05713D7A13290689521D51A8F5F24984777D5D6622A502207CC5241A43414423C0626DE0809A93F1318BAA8CD747792899FAFC191D0D5E91", + "hash": "B2692E7D1150E676C5A8DE8DD987B624344AE34D28B3775BC8965F37ECF8C2B1", + "DeliverMax": "120000", + "ctid": "C50EE289006D0000", + "date": 756940672, + "ledger_index": 84861577, + "inLedger": 84861577 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "47190014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046064 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "59195014", + "Sequence": 82046063 + }, + "PreviousTxnID": "3995E4C6B7818CE7572A700CC36473E94CE81E8642023C4632D8E04AF1857940", + "PreviousTxnLgrSeq": 84860138 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "Balance": "32725000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 84551361 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "EABA23FD37237A8AEF160650396F0BF379F7F7FC344D5ADBB29017468C336E01", + "PreviousFields": { + "Balance": "20725000" + }, + "PreviousTxnID": "8C74ECFA90E83A1FCE091BB9221A3663A91D3EA19555487469EC3545137C81FA", + "PreviousTxnLgrSeq": 84858539 + } + } + ], + "TransactionIndex": 198, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "12000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "12000000", + "Destination": "rhV3kavgn9arQEQPTm1AYcpMW2EFB5ZXDW", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046063, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3044022030CAD8E15B9A0C7F51F5908810895147ED289AE9EC5FFCB67EB8139EFF1BE1F202200A418C772AA224BC8A9E482C295038D8D5C0447701E241B85D8CF1F937C6C93F", + "hash": "C89C2999390BB58568AF71AC42912253758DD61F87308283ECCEF7C1DC303429", + "DeliverMax": "12000000", + "ctid": "C50EDDE400C60000", + "date": 756936041, + "ledger_index": 84860388, + "inLedger": 84860388 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "59195014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046063 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "59790014", + "Sequence": 82046062 + }, + "PreviousTxnID": "A942664900C5DD6DAC4272342347023100F188D6FCC370CFF9DCDA043EB3AD15", + "PreviousTxnLgrSeq": 84860023 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "11590000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046903 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "11000000" + }, + "PreviousTxnID": "A942664900C5DD6DAC4272342347023100F188D6FCC370CFF9DCDA043EB3AD15", + "PreviousTxnLgrSeq": 84860023 + } + } + ], + "TransactionIndex": 142, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "590000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "590000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046062, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304402205F1003993299EA3524A4F3CF290597282B57B549A05CC38F0D78436B275914E5022076D54A54A581FD47E9CAA9B66B09545C9661E3B8EB16209EF75BBAA7EC7696B5", + "hash": "3995E4C6B7818CE7572A700CC36473E94CE81E8642023C4632D8E04AF1857940", + "DeliverMax": "590000", + "ctid": "C50EDCEA008E0000", + "date": 756935061, + "ledger_index": 84860138, + "inLedger": 84860138 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "59790014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046062 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "60795014", + "Sequence": 82046061 + }, + "PreviousTxnID": "F6189E298EED2F641481C0FE3B46031243292ABAE2DCC349B85A7017DAA820CB", + "PreviousTxnLgrSeq": 82092335 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "11000000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046903 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "10000000" + }, + "PreviousTxnID": "B46671C5219FEE05A48E6B580B9F7F65A6378802D26149C16CFBB1AA52FAB683", + "PreviousTxnLgrSeq": 82047186 + } + } + ], + "TransactionIndex": 144, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "1000000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046061, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "30440220448DC4F29E78E133D534B3B01D87F5C2E459974CD6008B54E12FC9BAC950127F022005259C83E446598EDD7CE059F3C1E7977C21513C3B170920BD83D7DFCF79552D", + "hash": "A942664900C5DD6DAC4272342347023100F188D6FCC370CFF9DCDA043EB3AD15", + "DeliverMax": "1000000", + "ctid": "C50EDC7700900000", + "date": 756934612, + "ledger_index": 84860023, + "inLedger": 84860023 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "60795014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046061 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "83920014", + "Sequence": 82046060 + }, + "PreviousTxnID": "B46671C5219FEE05A48E6B580B9F7F65A6378802D26149C16CFBB1AA52FAB683", + "PreviousTxnLgrSeq": 82047186 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F0CAD2CEC5E921253CF24398A88DD395E282239D81A5F12C70623F089C97A41E", + "NewFields": { + "Account": "r4uGPFKuV7fVb4MNTythfvZ3igAu6aMoDj", + "Balance": "23120000", + "Sequence": 82092335 + } + } + } + ], + "TransactionIndex": 12, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "23120000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "23120000", + "Destination": "r4uGPFKuV7fVb4MNTythfvZ3igAu6aMoDj", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046060, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3045022100816F15AD7F655BF673A7B64990A390690F709E1342719B1F5F11697F8526921702205FEF4E6197962A6AD71E56DAFD9A24FAF41C0C156E61C37F02207C7EAEE0477B", + "hash": "F6189E298EED2F641481C0FE3B46031243292ABAE2DCC349B85A7017DAA820CB", + "DeliverMax": "23120000", + "ctid": "C4E4A12F000C0000", + "date": 746316751, + "ledger_index": 82092335, + "inLedger": 82092335 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "83920014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046060 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "10000000" + }, + "PreviousTxnID": "11D81E0A58B9089889CD0C009257DFDE93F71F962D6E92F33A6C7EDEB2B3D9DD", + "PreviousTxnLgrSeq": 82047087 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "10000000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046903 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "83925014", + "Sequence": 82046902 + }, + "PreviousTxnID": "11D81E0A58B9089889CD0C009257DFDE93F71F962D6E92F33A6C7EDEB2B3D9DD", + "PreviousTxnLgrSeq": 82047087 + } + } + ], + "TransactionIndex": 2, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "73920014" + }, + "tx": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Amount": "73920014", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046902, + "SigningPubKey": "03A3A9B927937FA686AF7B6DB9787D730FFBDA36C3F185421E177414F422FCDC3E", + "TransactionType": "Payment", + "TxnSignature": "3044022040C4D20AEAE8E781F8591B7F914A98C6437E269C4B25CAA9D9E30721647A038C02204B2D437B0683E93AC3ACB8949439EE6D8C9A42A7AC6006797FBE656084168C40", + "hash": "B46671C5219FEE05A48E6B580B9F7F65A6378802D26149C16CFBB1AA52FAB683", + "DeliverMax": "73920014", + "ctid": "C4E3F0D200020000", + "date": 746144851, + "ledger_index": 82047186, + "inLedger": 82047186 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "10000000", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046060 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "73830007", + "Sequence": 82046059 + }, + "PreviousTxnID": "2B0DDA813D199C8FA3190F0EE010A731731F2EF641ED7CDAA156F501DCD808DF", + "PreviousTxnLgrSeq": 82046947 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "83925014", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046902 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "20100007" + }, + "PreviousTxnID": "2B0DDA813D199C8FA3190F0EE010A731731F2EF641ED7CDAA156F501DCD808DF", + "PreviousTxnLgrSeq": 82046947 + } + } + ], + "TransactionIndex": 26, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "63825007" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "63825007", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046059, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "304402201A5D24174B14514A5202DD07166733AB4C8494B31588362A071981D5C2CD732802207CFBFBC3A376FAE173F80F8C445548B82EA0F4D4EFCD0CFF2C1454D39E71D8FC", + "hash": "11D81E0A58B9089889CD0C009257DFDE93F71F962D6E92F33A6C7EDEB2B3D9DD", + "DeliverMax": "63825007", + "ctid": "C4E3F06F001A0000", + "date": 746144480, + "ledger_index": 82047087, + "inLedger": 82047087 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "73830007", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046059 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "73935007", + "Sequence": 82046058 + }, + "PreviousTxnID": "E74CECA94E04A273E087C82B2EEF7E6A389306D7BE3C117E3DE6ACBAE07BF619", + "PreviousTxnLgrSeq": 82046902 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "20100007", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046902 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "PreviousFields": { + "Balance": "20000007" + }, + "PreviousTxnID": "E513B1D0ABB368D843A61D6934AA24F9F449B8723CAE5A12B69298868E4682BB", + "PreviousTxnLgrSeq": 82046904 + } + } + ], + "TransactionIndex": 22, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "100000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "100000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046058, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3045022100BD00B4AAEBE991B8DBF45EFC6C7A9523E4B9FDB26AB747D067DC56F0E497432B02207920F0E56AA8A98363C5ECA68259B87BD661979B3EA4F8CFC58E62B25589A6DD", + "hash": "2B0DDA813D199C8FA3190F0EE010A731731F2EF641ED7CDAA156F501DCD808DF", + "DeliverMax": "100000", + "ctid": "C4E3EFE300160000", + "date": 746143941, + "ledger_index": 82046947, + "inLedger": 82046947 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "73935007", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046058 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "93940007", + "Sequence": 82046057 + }, + "PreviousTxnID": "5FA19FB84B340008E80BCC59B75BC1E9BA03BB9C6227D719DD6341DBEEE6FCD2", + "PreviousTxnLgrSeq": 82046059 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E8B0491B71A61947208E3EA8A830C9A41F84D3BF8CAF537F67FCCBC4C598B655", + "NewFields": { + "Account": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Balance": "20000000", + "Sequence": 82046902 + } + } + } + ], + "TransactionIndex": 30, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "20000000" + }, + "tx": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Amount": "20000000", + "Destination": "r6AFdJSwGhJxYHETx2KqCTo1wxrtz9roM", + "Fee": "5000", + "Flags": 0, + "Sequence": 82046057, + "SigningPubKey": "03EE216ED53289A5364CB0CC31856D3B271343D927A9E3160E13707E22473DDA74", + "TransactionType": "Payment", + "TxnSignature": "3045022100F99DA6C0D07FEEDFFA5F551C8312F7AAFFB232260A5C6D3F4D55F3A6F878E7D102205B7FF2FA5B3E71A5700E522C454A487C174660612F2111AA73F95B4DACBC4BD1", + "hash": "E74CECA94E04A273E087C82B2EEF7E6A389306D7BE3C117E3DE6ACBAE07BF619", + "DeliverMax": "20000000", + "ctid": "C4E3EFB6001E0000", + "date": 746143771, + "ledger_index": 82046902, + "inLedger": 82046902 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rNEWSizmnEsBAFxRT8xEUhC6bAk7T3ewK", + "Balance": "146870095", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 81918861 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6A0AA730F5189F891EB34F55408E5FF87AF7A36B656DE95A44A7BEEDB3094BD9", + "PreviousFields": { + "Balance": "146870117", + "Sequence": 81918860 + }, + "PreviousTxnID": "25349D89B8D5696B301D551FFF2AAB7B7B30FB965B54E3BA08073D134CD7034B", + "PreviousTxnLgrSeq": 82046043 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "93940007", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 82046057 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "93940000" + }, + "PreviousTxnID": "622C274E9F89AD7E52C8C942F77C7AB0AA15457C3A76D049D25633D32D4AB398", + "PreviousTxnLgrSeq": 82046057 + } + } + ], + "TransactionIndex": 9, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "7" + }, + "tx": { + "Account": "rNEWSizmnEsBAFxRT8xEUhC6bAk7T3ewK", + "Amount": "7", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "15", + "Memos": [ + { + "Memo": { + "MemoData": "57656C636F6D6520746F205852504C210A0A44696420796F75206B6E6F772074686174205852504C20616C736F2068617320646966666572656E7420746F6B656E73206C696B65206F74686572206E6574776F726B733F0A4865726520796F752063616E2066696E642061205852504C20746F6B656E7320466175636574287467293A2040646F6E6174696F6E5F7872706C5F626F740A4A75737420636C69636B2074686520627574746F6E20696E207468652074656C656772616D20626F7420616E642067657420736F6D6520746F6B656E732065766572792064617920286F6E6C7920666F722058554D4D20757365727329" + } + } + ], + "Sequence": 81918860, + "SigningPubKey": "ED91C7C7108A3CBB7802D8F3ADC0C6AEC20DA47F3661E23E07379E95E39659AE2F", + "TransactionType": "Payment", + "TxnSignature": "95298566B7B96422E9E71D55C65B3793232C6B3E095F1D753661EDE0FC75D23D44862CA3436A1377B55346C3D180F03ED93F1467B05043249961545142D2BF05", + "hash": "5FA19FB84B340008E80BCC59B75BC1E9BA03BB9C6227D719DD6341DBEEE6FCD2", + "DeliverMax": "7", + "ctid": "C4E3EC6B00090000", + "date": 746140571, + "ledger_index": 82046059, + "inLedger": 82046059 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rs2dgzYeqYqsk8bvkQR5YPyqsXYcA24MP2", + "Balance": "30059220035611", + "Flags": 131072, + "OwnerCount": 9, + "Sequence": 497400 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "64B6FFE42CFD12696CE8BB04843DAC143820CF5BC4EFB10F51C293CB9B77C14F", + "PreviousFields": { + "Balance": "30059313977611", + "Sequence": 497399 + }, + "PreviousTxnID": "F463073A79326D9BCC51E049020932F37D6C5B93767B4CE58A26FCA721758732", + "PreviousTxnLgrSeq": 82046034 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "NewFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "93940000", + "Sequence": 82046057 + } + } + } + ], + "TransactionIndex": 14, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "93940000" + }, + "tx": { + "Account": "rs2dgzYeqYqsk8bvkQR5YPyqsXYcA24MP2", + "Amount": "93940000", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "2000", + "Flags": 0, + "LastLedgerSequence": 82046357, + "Sequence": 497399, + "SigningPubKey": "0277C97C4B34277FACE49D91BD541D3DB7C435E2179C6FFAAC6BB5588FC1628B0C", + "TransactionType": "Payment", + "TxnSignature": "30430220560E1CDCB02E6DF107B1D4BF256A82933776CC330C498E1101EF6C378E7E5972021F1CB8A686D4086ED32DE7D40C86D5C35866AE0FE4F7AD52573C39B0F17A2399", + "hash": "622C274E9F89AD7E52C8C942F77C7AB0AA15457C3A76D049D25633D32D4AB398", + "DeliverMax": "93940000", + "ctid": "C4E3EC69000E0000", + "date": 746140562, + "ledger_index": 82046057, + "inLedger": 82046057 + }, + "validated": true + } + ], + "validated": true, + "limit": 100, + "status": "success" + }, + "warnings": [ + { + "id": 2001, + "message": "This is a clio server. clio only serves validated data. If you want to talk to rippled, include 'ledger_index':'current' in your request" + } + ] +} \ No newline at end of file diff --git a/core/crates/gem_xrp/src/testdata/transaction_by_hash.json b/core/crates/gem_xrp/src/testdata/transaction_by_hash.json new file mode 100644 index 0000000000..1bbb09052f --- /dev/null +++ b/core/crates/gem_xrp/src/testdata/transaction_by_hash.json @@ -0,0 +1,66 @@ +{ + "result": { + "Account": "rnXZ876yGEhoATQSYegtD8bg8wpA8TTX5a", + "Amount": "1", + "DeliverMax": "1", + "Destination": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 99743783, + "Sequence": 96791415, + "SigningPubKey": "ED0D97D2D94F98BCFA7BD4042BEC4184F07382B1DDE537B5F58E39C72BBEE64385", + "TransactionType": "Payment", + "TxnSignature": "B0E4B6A46E483294D339AE26C65F328D202D9BDBC1C77D46F8E975C12991C70207D1291CF2176510BFC3DD2860F4F4BE89A8107D3FD85347B9E16EC962AD7B05", + "ctid": "C5F1F7C800630000", + "date": 814646090, + "hash": "474F58E6C78F1DE8542036AB3C16E2B5A4089241DEE3E58142154DC3CA0E8271", + "inLedger": 99743688, + "ledger_index": 99743688, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnXZ876yGEhoATQSYegtD8bg8wpA8TTX5a", + "Balance": "16148056", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 96791416 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "A6605A8310929970667343AFC6A85CDC2D2C081ED45DA40C96519D4164F5E7C1", + "PreviousFields": { + "Balance": "16148068", + "Sequence": 96791415 + }, + "PreviousTxnID": "C5AD8847AAB9A1F40D4440016496AFF1767FBABA8EAC580F1B453088D51921A5", + "PreviousTxnLgrSeq": 99743688 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnZmVGX6f4pUYyS4oXYJzoLdRojQV8y297", + "Balance": "25541810", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 82046089 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E15BC5B0A554F216D0849F60A440AC5B420497BD68F1353501C9D42CDC8BA180", + "PreviousFields": { + "Balance": "25541809" + }, + "PreviousTxnID": "19EC0CBD6017A9593647DD86248A75E17A3441F3EDC259CF52D204EE5360BF89", + "PreviousTxnLgrSeq": 99743674 + } + } + ], + "TransactionIndex": 99, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + }, + "status": "success", + "validated": true + } +} diff --git a/core/crates/gem_xrp/src/testdata/transactions_by_block.json b/core/crates/gem_xrp/src/testdata/transactions_by_block.json new file mode 100644 index 0000000000..80240b8d45 --- /dev/null +++ b/core/crates/gem_xrp/src/testdata/transactions_by_block.json @@ -0,0 +1,6103 @@ +{ + "result": { + "ledger": { + "account_hash": "364A45DF4A761F83D4FCFC77565B74E60D725BDA0742FB51F8D5809B04521998", + "close_flags": 0, + "close_time": 809472660, + "close_time_human": "2025-Aug-25 21:31:00.000000000 UTC", + "close_time_iso": "2025-08-25T21:31:00Z", + "close_time_resolution": 10, + "closed": true, + "ledger_hash": "478826D0E8C1C55C5CE5594406165318D189A64A23E47DD51FD6D4ECFDBBD6B7", + "ledger_index": "98408898", + "parent_close_time": 809472652, + "parent_hash": "D4726C1630F4C27D3FB2E05EB22AF7FF6313882FEF62BA789AF10868451EF753", + "total_coins": "99985812060291801", + "transaction_hash": "EFFB31A1C9A7C7A2922DE0CB67FD6787DD2BE52AE23AD24C5762F4ED9BB8D461", + "transactions": [ + { + "Account": "rDeizxSRo6JHjKnih9ivpPkyD2EgXQvhSB", + "Fee": "12", + "Memos": [ + { + "Memo": { + "MemoData": "4E4654204F66666572206163636570746564207669612058506D61726B65742E636F6D" + } + } + ], + "NFTokenBuyOffer": "A12EBA31A042BCF026E6B7CD34FBA7F404A13B005E600144C998CD3E7C47B0AF", + "Sequence": 75761382, + "SigningPubKey": "02AFFCAEB87C9C5B88D2B1A2E46FA2E055FB6F368CFD23A77C5411847A3D6AF0A2", + "SourceTag": 20221212, + "TransactionType": "NFTokenAcceptOffer", + "TxnSignature": "30440220304777CA3984105DE829665837DF5E05A686A854226710A51C55FD536BDC2EC702204C39EB8B81B07561218815824E6FDB51B5BDEF9752B239BE47C43F3ADE6FF3C9", + "hash": "02D6248620503E9FFF7B7E4A967D8C1E18DC961F15A075781E253FB15616A277", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rPevQCKoJKLmNiWrDLoMWg19aG3o4LEUib", + "RootIndex": "2403A4369B0315F92719F7C4484BE285104E57EBE9378F1095F7CD003B75A426" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "2403A4369B0315F92719F7C4484BE285104E57EBE9378F1095F7CD003B75A426", + "PreviousTxnID": "F6E71894F685E257153F1909A93DD66278322B2A4868F0AC39B2DF1777039E5A", + "PreviousTxnLgrSeq": 98408895 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Flags": 1, + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828BF7BD904817131", + "PreviousTxnID": "F6E71894F685E257153F1909A93DD66278322B2A4868F0AC39B2DF1777039E5A", + "PreviousTxnLgrSeq": 98408895, + "RootIndex": "3B416E0A10D952F291ABFF0D537C08594737AAC8A86C978056237F8FF4504AF3" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "3B416E0A10D952F291ABFF0D537C08594737AAC8A86C978056237F8FF4504AF3" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rDeizxSRo6JHjKnih9ivpPkyD2EgXQvhSB", + "Balance": "375033321", + "BurnedNFTokens": 41145, + "FirstNFTokenSequence": 75440949, + "Flags": 0, + "MintedNFTokens": 151040, + "OwnerCount": 206, + "Sequence": 75761383 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "61CD8B4290CD94A4F461849D18830F598EF4C11DD6ECC60543147CD0001AC70B", + "PreviousFields": { + "Balance": "375033332", + "Sequence": 75761382 + }, + "PreviousTxnID": "A457896BFB0D0B9555DC8F4DADA1BB473F4EF93E9AC134C167E2B8FFBA9E357D", + "PreviousTxnLgrSeq": 98408894 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rPevQCKoJKLmNiWrDLoMWg19aG3o4LEUib", + "Balance": "1447243393", + "Flags": 0, + "OwnerCount": 12, + "Sequence": 98030277 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6F24075C6EB033F4D01D6E524934A39A69FB84DE28E99784B0903FFCB79B476B", + "PreviousFields": { + "Balance": "1447243394", + "OwnerCount": 13 + }, + "PreviousTxnID": "F6E71894F685E257153F1909A93DD66278322B2A4868F0AC39B2DF1777039E5A", + "PreviousTxnLgrSeq": 98408895 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "NFTokens": [ + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828487DEF04814361", + "URI": "697066733A2F2F516D5042734751755937536479503933696973566A4E7152444B6574687463364E6E324872683756484E4A635169" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828575C8C04810F04", + "URI": "697066733A2F2F516D636A714263326B7272753643324467714B473753385A5A385958484432313537705043776245315279764269" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8286E815C048169AE", + "URI": "697066733A2F2F516D50634156646D7A4C327558537877327A4A6D324A56723241486E7463484A6244473545424C324234774B3653" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82871CAA104806D14", + "URI": "697066733A2F2F516D65344B75395857773445395A4D6737456E3759355269767767646779355742795A47546961624E336E684C56" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82883FB6304810CD5", + "URI": "697066733A2F2F516D63685257317A76706673516F55664A584B6E36766657334448747A764B3838414B50655764504A79686F6241" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8289B200F0481677F", + "URI": "697066733A2F2F516D586D644D5132334A544C544C4B38316F7268776273754A39577671656E3962444B6A5463715A736331416A53" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828A360400480C0B6", + "URI": "697066733A2F2F516D63475856526564524554754D75336939354A6F445952516F7632634E465639416E484E4B447A367351416936" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828B3E964048143DC", + "URI": "697066733A2F2F516D614445714862586942515234724445536A414A4639314D775550717A50377079645662387A33315953594D73" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828B735D40480E22A", + "URI": "697066733A2F2F516D624C5A584551435039746E4C324C6B72645A36525246626B646F64454D7569586B484D4A574166334C70414D" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828B8DD730480FEC5", + "URI": "697066733A2F2F516D573378514C333372525641454E4769757A79566B667A487566473939755951443472506573316241536D7931" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828D9ECD104816A29", + "URI": "697066733A2F2F516D636B777A7070354C7269566D6F37456D3975726857323168394E44346D4C47484B4272654743726151393159" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828E22CEA0480C360", + "URI": "697066733A2F2F516D554E476D3475477A356862756E464C596161706F71786D7679734A596D6158564C624C4554483345556B4D4A" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828E57947048061AE", + "URI": "697066733A2F2F516D505434706D33656967444A714B6B75674558314D393451706B5241694277644A524D793473625A5A4763486F" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828F10E81048129EB", + "URI": "697066733A2F2F516D5068373476553455445973716A6E6B634771516D64455146516D52556374336874475767454D51624A54674C" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C829033C4C04812EC4", + "URI": "697066733A2F2F516D6278367331564132627653526342735346714D77586F59794C386E74767A77587135664B5776666E446B6247" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C829068B80048167FA", + "URI": "697066733A2F2F516D636B777A7070354C7269566D6F37456D3975726857323168394E44346D4C47484B4272654743726151393159" + } + } + ], + "NextPageMin": "8ACAA32313BB87F729EF7DA56BD13C57BE46E3C8BE46E3C82A31A94F04810EC1", + "PreviousPageMin": "8ACAA32313BB87F729EF7DA56BD13C57BE46E3C8BE46E3C8280EA25C048060B8" + }, + "LedgerEntryType": "NFTokenPage", + "LedgerIndex": "8ACAA32313BB87F729EF7DA56BD13C57BE46E3C8BE46E3C82925F06504811BDB", + "PreviousFields": { + "NFTokens": [ + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828487DEF04814361", + "URI": "697066733A2F2F516D5042734751755937536479503933696973566A4E7152444B6574687463364E6E324872683756484E4A635169" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828575C8C04810F04", + "URI": "697066733A2F2F516D636A714263326B7272753643324467714B473753385A5A385958484432313537705043776245315279764269" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8286E815C048169AE", + "URI": "697066733A2F2F516D50634156646D7A4C327558537877327A4A6D324A56723241486E7463484A6244473545424C324234774B3653" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82871CAA104806D14", + "URI": "697066733A2F2F516D65344B75395857773445395A4D6737456E3759355269767767646779355742795A47546961624E336E684C56" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82883FB6304810CD5", + "URI": "697066733A2F2F516D63685257317A76706673516F55664A584B6E36766657334448747A764B3838414B50655764504A79686F6241" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8289B200F0481677F", + "URI": "697066733A2F2F516D586D644D5132334A544C544C4B38316F7268776273754A39577671656E3962444B6A5463715A736331416A53" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828A360400480C0B6", + "URI": "697066733A2F2F516D63475856526564524554754D75336939354A6F445952516F7632634E465639416E484E4B447A367351416936" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828B3E964048143DC", + "URI": "697066733A2F2F516D614445714862586942515234724445536A414A4639314D775550717A50377079645662387A33315953594D73" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828B735D40480E22A", + "URI": "697066733A2F2F516D624C5A584551435039746E4C324C6B72645A36525246626B646F64454D7569586B484D4A574166334C70414D" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828B8DD730480FEC5", + "URI": "697066733A2F2F516D573378514C333372525641454E4769757A79566B667A487566473939755951443472506573316241536D7931" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828BF7BD904817131", + "URI": "697066733A2F2F516D635A6542474D684D31546341575663694D5558716444756B6972756943346F506E7A474D6965507A6D514635" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828D9ECD104816A29", + "URI": "697066733A2F2F516D636B777A7070354C7269566D6F37456D3975726857323168394E44346D4C47484B4272654743726151393159" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828E22CEA0480C360", + "URI": "697066733A2F2F516D554E476D3475477A356862756E464C596161706F71786D7679734A596D6158564C624C4554483345556B4D4A" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828E57947048061AE", + "URI": "697066733A2F2F516D505434706D33656967444A714B6B75674558314D393451706B5241694277644A524D793473625A5A4763486F" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828F10E81048129EB", + "URI": "697066733A2F2F516D5068373476553455445973716A6E6B634771516D64455146516D52556374336874475767454D51624A54674C" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C829033C4C04812EC4", + "URI": "697066733A2F2F516D6278367331564132627653526342735346714D77586F59794C386E74767A77587135664B5776666E446B6247" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C829068B80048167FA", + "URI": "697066733A2F2F516D636B777A7070354C7269566D6F37456D3975726857323168394E44346D4C47484B4272654743726151393159" + } + } + ] + }, + "PreviousTxnID": "52316F8B598F389D0F70AEC7145F7572421895530D8BC07D3765C33281F7FE24", + "PreviousTxnLgrSeq": 98408769 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Amount": "1", + "Flags": 0, + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828BF7BD904817131", + "NFTokenOfferNode": "0", + "Owner": "rPevQCKoJKLmNiWrDLoMWg19aG3o4LEUib", + "OwnerNode": "0", + "PreviousTxnID": "F6E71894F685E257153F1909A93DD66278322B2A4868F0AC39B2DF1777039E5A", + "PreviousTxnLgrSeq": 98408895 + }, + "LedgerEntryType": "NFTokenOffer", + "LedgerIndex": "A12EBA31A042BCF026E6B7CD34FBA7F404A13B005E600144C998CD3E7C47B0AF" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "NFTokens": [ + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C80333E3A104817119", + "URI": "697066733A2F2F516D6338567573436D774A54484632384C4E7A463845483876566E76613173334D505A4A59314B7052664B657069" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C80753E8BC0481710E", + "URI": "697066733A2F2F516D633643745533724D345234593677324166354C39614170344C41754E383469356E335852717331376B797061" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C80E51B03704816F87", + "URI": "697066733A2F2F516D613136413755616A4E7077654A4634614A45635231667642314275636A62527941475233786D554438654574" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C80F93F286048170F8", + "URI": "697066733A2F2F516D5856317451754538375351384C596641637247576655746563337A4C565379644C3443713867786A58585A36" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C811D9AADE04817130", + "URI": "697066733A2F2F516D6458396D5A4E6373384551354A47586447526A704D6150746A6A424A48434B764757726E557A424B41777078" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C815F9AFD504817125", + "URI": "697066733A2F2F516D5965524A4638777146316D3532784658366A514241615845596D4135336876434868357666346A7763657277" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C81A19B4A00481711A", + "URI": "697066733A2F2F516D524D73574E7648466868523279746A3955436152457173764347737450794B374B6D4A556248697142755A58" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C828BF7BD904817131", + "URI": "697066733A2F2F516D635A6542474D684D31546341575663694D5558716444756B6972756943346F506E7A474D6965507A6D514635" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82A99C89C048170EE", + "URI": "697066733A2F2F516D55515852764D62373456514A6441646E737A4761423575445A5942505071596D3655354C6A74447968386A73" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82B9D3E4504816FB5", + "URI": "697066733A2F2F516D5A6855324D51526E743237504B796E386146674E6431734B773769485757596F3845684C55634D76544D4861" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82CDF80D404817126", + "URI": "697066733A2F2F516D576F626E354E43554C786E774A7069327251373666566F63654B565938586637625A76797565544D54755674" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82F51D7DF04816F2F", + "URI": "697066733A2F2F516D50334C4666783758526F4D78716847677850737047573639596F377166436534704D6A34626A4A656D6F3738" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C830FF85A30481711B", + "URI": "697066733A2F2F516D5436314C336776695A6B5A5731437646527073773972727239684A4647634E37356E41637A614C5932444548" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8351F8ABE04817110", + "URI": "697066733A2F2F516D5572574C6442776F384333427359416247515773487941544C705239354B724343743333506D5A6241797363" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8417F999F048170EF", + "URI": "697066733A2F2F516D576F675236706F364466726F43323353365A4331796469624B523972375065776F7A6A475036515A447A3343" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C842830F4404816FB6", + "URI": "697066733A2F2F516D54373172676761773374654A78434D52784865514A6951363872543237464178314577624464766E676F6673" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C843C551D704817127", + "URI": "697066733A2F2F516D4E6B547132474862505952577366796B63393152706333795A516359355456787638354B4C394E5443325238" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8445D5C1B04816F73", + "URI": "697066733A2F2F516D575262546542515174696848466F7169644B456A41357A34767A3935366534316B6467376165347775735643" + } + } + ], + "NextPageMin": "F87EBD93C2FE22EF434437DE75C5691358A568B3BE46E3C88AE91BDB04816F33" + }, + "LedgerEntryType": "NFTokenPage", + "LedgerIndex": "F87EBD93C2FE22EF434437DE75C5691358A568B3BE46E3C8487D611604816F68", + "PreviousFields": { + "NFTokens": [ + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C80333E3A104817119", + "URI": "697066733A2F2F516D6338567573436D774A54484632384C4E7A463845483876566E76613173334D505A4A59314B7052664B657069" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C80753E8BC0481710E", + "URI": "697066733A2F2F516D633643745533724D345234593677324166354C39614170344C41754E383469356E335852717331376B797061" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C80E51B03704816F87", + "URI": "697066733A2F2F516D613136413755616A4E7077654A4634614A45635231667642314275636A62527941475233786D554438654574" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C80F93F286048170F8", + "URI": "697066733A2F2F516D5856317451754538375351384C596641637247576655746563337A4C565379644C3443713867786A58585A36" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C811D9AADE04817130", + "URI": "697066733A2F2F516D6458396D5A4E6373384551354A47586447526A704D6150746A6A424A48434B764757726E557A424B41777078" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C815F9AFD504817125", + "URI": "697066733A2F2F516D5965524A4638777146316D3532784658366A514241615845596D4135336876434868357666346A7763657277" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C81A19B4A00481711A", + "URI": "697066733A2F2F516D524D73574E7648466868523279746A3955436152457173764347737450794B374B6D4A556248697142755A58" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82A99C89C048170EE", + "URI": "697066733A2F2F516D55515852764D62373456514A6441646E737A4761423575445A5942505071596D3655354C6A74447968386A73" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82B9D3E4504816FB5", + "URI": "697066733A2F2F516D5A6855324D51526E743237504B796E386146674E6431734B773769485757596F3845684C55634D76544D4861" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82CDF80D404817126", + "URI": "697066733A2F2F516D576F626E354E43554C786E774A7069327251373666566F63654B565938586637625A76797565544D54755674" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C82F51D7DF04816F2F", + "URI": "697066733A2F2F516D50334C4666783758526F4D78716847677850737047573639596F377166436534704D6A34626A4A656D6F3738" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C830FF85A30481711B", + "URI": "697066733A2F2F516D5436314C336776695A6B5A5731437646527073773972727239684A4647634E37356E41637A614C5932444548" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8351F8ABE04817110", + "URI": "697066733A2F2F516D5572574C6442776F384333427359416247515773487941544C705239354B724343743333506D5A6241797363" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8417F999F048170EF", + "URI": "697066733A2F2F516D576F675236706F364466726F43323353365A4331796469624B523972375065776F7A6A475036515A447A3343" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C842830F4404816FB6", + "URI": "697066733A2F2F516D54373172676761773374654A78434D52784865514A6951363872543237464178314577624464766E676F6673" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C843C551D704817127", + "URI": "697066733A2F2F516D4E6B547132474862505952577366796B63393152706333795A516359355456787638354B4C394E5443325238" + } + }, + { + "NFToken": { + "NFTokenID": "000803E88ACAA32313BB87F729EF7DA56BD13C57BE46E3C8445D5C1B04816F73", + "URI": "697066733A2F2F516D575262546542515174696848466F7169644B456A41357A34767A3935366534316B6467376165347775735643" + } + } + ] + }, + "PreviousTxnID": "65A2CD8F2137282AE72BB73AA7586EE752D73BD71C16EAFBF4F4358163E277C0", + "PreviousTxnLgrSeq": 98408893 + } + } + ], + "TransactionIndex": 37, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rf9xMSaNSxvTCnsZ6juuNFgYvytMHbYz51", + "Fee": "12", + "Flags": 0, + "LastLedgerSequence": 98409196, + "Sequence": 95067249, + "SigningPubKey": "ED164FF96FA93F6A545DAC907A45EB89FF60C704B8794BB58D152014253A4BC010", + "TakerGets": { + "currency": "XHO", + "issuer": "r9rCp6XCn3WiHWjMUs7c1GBZ85VsCFX4Ur", + "value": "49135.27" + }, + "TakerPays": "1577367", + "TransactionType": "OfferCreate", + "TxnSignature": "D19F0A8D0500078942AF2983535E8D1E57C01115C7D50F91E0764B7C6BAC6BE2A2CCDE99A4DF8750F4361D2A429514FFB75CC2E3C3FB65C6D1EAEE72D30BA301", + "hash": "04708DF9CE5E445BCE40385943A93AE1D89058A1E7366FEC2A4684907D65F7B8", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rf9xMSaNSxvTCnsZ6juuNFgYvytMHbYz51", + "Balance": "5843192", + "Flags": 0, + "OwnerCount": 3, + "Sequence": 95067250 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "9B2F8C2C9BB250260BA895D2CEB9C6885621950E5E1CA6FBB8F04B673B194874", + "PreviousFields": { + "Balance": "5843204", + "OwnerCount": 2, + "Sequence": 95067249 + }, + "PreviousTxnID": "2338514B28DDEEA2B1556BB43CEB26223FEBEC00927D7A373F53FC4E5FE8DF3B", + "PreviousTxnLgrSeq": 98408891 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rf9xMSaNSxvTCnsZ6juuNFgYvytMHbYz51", + "RootIndex": "A7BE98B69C09E9CCEF8CE042D84AB882EA0B645CB653D95851C3A28AA46F7304" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "A7BE98B69C09E9CCEF8CE042D84AB882EA0B645CB653D95851C3A28AA46F7304", + "PreviousTxnID": "99B72F36F92A19B0754906929920841806A1D7D3A271F98BC7FAD9685423F5FD", + "PreviousTxnLgrSeq": 98408883 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "D98BD7CE8A3221264D8D75894B2A433959F7633FE11EEE53511FB374B725E672", + "NewFields": { + "Account": "rf9xMSaNSxvTCnsZ6juuNFgYvytMHbYz51", + "BookDirectory": "F3D527439A49618ABE78F3FBB5649F8C571DADB8910E9D07560B67B584E07EA8", + "Sequence": 95067249, + "TakerGets": { + "currency": "XHO", + "issuer": "r9rCp6XCn3WiHWjMUs7c1GBZ85VsCFX4Ur", + "value": "49135.27" + }, + "TakerPays": "1577367" + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "F3D527439A49618ABE78F3FBB5649F8C571DADB8910E9D07560B67B584E07EA8", + "NewFields": { + "ExchangeRate": "560b67b584e07ea8", + "RootIndex": "F3D527439A49618ABE78F3FBB5649F8C571DADB8910E9D07560B67B584E07EA8", + "TakerGetsCurrency": "00000000000000000000000058484F0000000000", + "TakerGetsIssuer": "57E0C063C06832D960AD6A79A8330ACD6F019D0F" + } + } + } + ], + "TransactionIndex": 18, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rNFugeoj3ZN8Wv6xhuLegUBBPXKCyWLRkB", + "Amount": "1419242600", + "DeliverMax": "1419242600", + "Destination": "rLpvuHZFE46NUyZH5XaMvmYRJZF7aory7t", + "Fee": "5000", + "Sequence": 59545123, + "SigningPubKey": "03270A6352C324D4972BA2F2088A64BD991AD627E9D7D781A7802473979E0A34D6", + "TransactionType": "Payment", + "TxnSignature": "3045022100D9A57616C95A28BD375CDC8863BC43832CB71A9509943ABDF670DA9EA45C232D0220325A3FF6F6DC3146CD781959777B2CE9065C1A688B480D3D79BA894AA4635FD6", + "hash": "08DF166915E87BD0C2EA5E46FEB4FAF42C5D94F6A7E389A25A8BDE101B44AF68", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rNFugeoj3ZN8Wv6xhuLegUBBPXKCyWLRkB", + "Balance": "8344189706", + "Flags": 131072, + "OwnerCount": 0, + "Sequence": 59545124 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6F94B5486DBA5021658BF814570177F012A1680FD1973735094F71957DCFFF9F", + "PreviousFields": { + "Balance": "9763437306", + "Sequence": 59545123 + }, + "PreviousTxnID": "5B866EB4DDD4F7A449187EAA77BB6DACBF0737EBCD5B2A1C717D83495AAEE44F", + "PreviousTxnLgrSeq": 98408883 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLpvuHZFE46NUyZH5XaMvmYRJZF7aory7t", + "Balance": "133900109319", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 83775645 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "74566F9B0EC7CCC31813240F1F2A9DD3D6043064FAB5FB2DA29A7964A9735359", + "PreviousFields": { + "Balance": "132480866719" + }, + "PreviousTxnID": "9F7D1933C0258036C4B5FE5F319DF2D78F38F0C7972E291759FD44454C8C7D0E", + "PreviousTxnLgrSeq": 98408893 + } + } + ], + "TransactionIndex": 35, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1419242600" + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 524288, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "6E6D63455558437865635861523966696C68674372", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353825, + "Sequence": 109353833, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": "7035777630", + "TakerPays": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "20015.0284129425" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "304402201E70C1B35EEFC5280245EA40A6BBA28836436020BB73D4A7A23D3AE68CD2179902201D625008BFF0735F1925FDAF6B33465C9D041CD2694136E647AEA498ECD895A0", + "hash": "0DDD66247083D226E421AD26830393F8061AD0A87F95559255661360AFD90C96", + "metaData": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "0AD7D5DAE136905716FFB3E3AD27A05BD9AAB0A88AEE4EF4138449EC8D7FA531", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1B48F9398C00", + "Flags": 131072, + "OwnerNode": "5", + "Sequence": 109353833, + "TakerGets": "7035777630", + "TakerPays": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "20015.0284129425" + } + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1B48F9398C00", + "NewFields": { + "ExchangeRate": "4f0a1b48f9398c00", + "RootIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1B48F9398C00", + "TakerPaysCurrency": "5553444300000000000000000000000000000000", + "TakerPaysIssuer": "ACF3278B42F2ACC71989C99661DAB9AF8C081981" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "4f0a1bd7000ee000", + "Flags": 0, + "PreviousTxnID": "6AFA5BF36C9268C92244CE22E9FDC7638FB26C76B6B46DA0DEC195A02960B695", + "PreviousTxnLgrSeq": 98408896, + "RootIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1BD7000EE000", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "5553444300000000000000000000000000000000", + "TakerPaysIssuer": "ACF3278B42F2ACC71989C99661DAB9AF8C081981" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1BD7000EE000" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "5A77512773FBA6D90A95434D659D57A3FE315453BCCC31B8956EAF556D15B6E9", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082188", + "Flags": 0, + "OwnerCount": 49, + "Sequence": 109353834 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082203", + "Sequence": 109353833 + }, + "PreviousTxnID": "5A77512773FBA6D90A95434D659D57A3FE315453BCCC31B8956EAF556D15B6E9", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1BD7000EE000", + "BookNode": "0", + "Flags": 131072, + "OwnerNode": "5", + "PreviousTxnID": "6AFA5BF36C9268C92244CE22E9FDC7638FB26C76B6B46DA0DEC195A02960B695", + "PreviousTxnLgrSeq": 98408896, + "Sequence": 109353825, + "TakerGets": "7034279100", + "TakerPays": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "20015.056379976" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "8AB8C8EB6A09E822EE5BB4B3C5FA713A6E8812E7322C2107EE56CFBB39326588" + } + } + ], + "TransactionIndex": 24, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "614633613564336B65646D36466D6359595F494F70", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353815, + "Sequence": 109353828, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "12988.214316" + }, + "TakerPays": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "2.982" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "3045022100F14D14016DD6A2B6D3BA1D5D04648CC27679EE1AE9FA3411748D7B8CB9DBED9602202501A004F56755AD48E113B3F49DFA73421157C4DC353D7B25B1330C27DEB9F9", + "hash": "0F47B0FD48F1BEBABB77160A732FAA9CF758A03B36DDAEEDE546E755FE2316D7", + "metaData": { + "AffectedNodes": [ + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "510827963adfac01", + "Flags": 0, + "PreviousTxnID": "299AEBD6C33376560E66F120CFF0C249893E5900EF6AB5B09FD1FC5BBE39C1DB", + "PreviousTxnLgrSeq": 98408893, + "RootIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE510827963ADFAC01", + "TakerGetsCurrency": "524C555344000000000000000000000000000000", + "TakerGetsIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E", + "TakerPaysCurrency": "0000000000000000000000004554480000000000", + "TakerPaysIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE510827963ADFAC01" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108282264DEB001", + "NewFields": { + "ExchangeRate": "5108282264deb001", + "RootIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108282264DEB001", + "TakerGetsCurrency": "524C555344000000000000000000000000000000", + "TakerGetsIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E", + "TakerPaysCurrency": "0000000000000000000000004554480000000000", + "TakerPaysIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1" + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "2CB5337925511EA63CA660932C62B0AA5C74A2EA660986AC7E410CF245272BC7", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082263", + "Flags": 0, + "OwnerCount": 51, + "Sequence": 109353829 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082278", + "Sequence": 109353828 + }, + "PreviousTxnID": "2CB5337925511EA63CA660932C62B0AA5C74A2EA660986AC7E410CF245272BC7", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE510827963ADFAC01", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "5", + "PreviousTxnID": "299AEBD6C33376560E66F120CFF0C249893E5900EF6AB5B09FD1FC5BBE39C1DB", + "PreviousTxnLgrSeq": 98408893, + "Sequence": 109353815, + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "12987.26194013399" + }, + "TakerPays": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "2.981" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "959FAA98198CC67597162EC765AAE4068DEF1D6621E627754015E6EF7E152995" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "FFDCE51993AA4A0CE79C29CDAB359AE1D7B4EFFBB68DD50565FC7A48073F3ED9", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108282264DEB001", + "OwnerNode": "5", + "Sequence": 109353828, + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "12988.21217390092" + }, + "TakerPays": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "2.982" + } + } + } + } + ], + "TransactionIndex": 19, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Amount": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "9999999999999999" + }, + "DeliverMax": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "9999999999999999" + }, + "Destination": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Fee": "15", + "Flags": 131072, + "Memos": [ + { + "Memo": { + "MemoData": "486F72697A6F6E20566F6C756D6520426F6F73746572" + } + } + ], + "SendMax": "206212", + "Sequence": 98344675, + "SigningPubKey": "03E1C4ED711F104C72600492BD620A6C409C608CB47A17F59E27BDFA601E8BAF6A", + "SourceTag": 111, + "TransactionType": "Payment", + "TxnSignature": "304402207C578C056372DE86610F3A7C5FA826A19481E7256644FF59EEFAA6CF46CB9ECF022043A5E98C8728C95F7EE03EE57B341BD1704453BD50070B21DA4E5765226BD308", + "hash": "1CE90E0CA5F9E0A85762BEF5894EC07C7EFA39261F90F345FCB4940635AAB138", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.74736141016" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "0" + }, + "HighNode": "7", + "LowLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "value": "9999999999999999" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "8ADE0DE31774A01BBE605A7F2D4BA867755E5EA5F280AC6115B3FF6FECAC5F60", + "PreviousFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + } + }, + "PreviousTxnID": "A9E87B84C74F598F2BE70408B1379ADC4BF8BCAD662AA5B9A0B932F73BD01D91", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "8AD1706F10A3BCB0F1F479F9B412C5B0237C7AD107532B40FF1024B1402BB12B", + "Account": "rWTtj1yWvCuT5eVYUFSNm3bVY1812uqVL", + "Balance": "1061116960", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 95675332 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "91FB211B3C00EB92FD003A52CC42E14FA61137A727120C9A15E61FA15BB115BB", + "PreviousFields": { + "Balance": "1060910748" + }, + "PreviousTxnID": "A9E87B84C74F598F2BE70408B1379ADC4BF8BCAD662AA5B9A0B932F73BD01D91", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "14231.56770391043" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "0" + }, + "HighNode": "2", + "LowLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rWTtj1yWvCuT5eVYUFSNm3bVY1812uqVL", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "A46D98A42D1C0FF60B1B40426015A87C98EBCA4BE1887AADE310E257761845D1", + "PreviousFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "14234.31506532059" + } + }, + "PreviousTxnID": "A9E87B84C74F598F2BE70408B1379ADC4BF8BCAD662AA5B9A0B932F73BD01D91", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Balance": "10600603", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98344676 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "C6085F1779BE2722C49A2150C097D0E08071583E428BEA5046A72D691EBEE423", + "PreviousFields": { + "Balance": "10806830", + "Sequence": 98344675 + }, + "PreviousTxnID": "A9E87B84C74F598F2BE70408B1379ADC4BF8BCAD662AA5B9A0B932F73BD01D91", + "PreviousTxnLgrSeq": 98408897 + } + } + ], + "DeliveredAmount": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "2.74736141016" + }, + "TransactionIndex": 5, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "2.74736141016" + } + } + }, + { + "Account": "rUYHZ71yXAS54ZQNvvooLX7rFtZydXjnP", + "Amount": "329174800", + "DeliverMax": "329174800", + "Destination": "rsRy14FvipgqudiGmptJBhr1RtpsgfzKMM", + "DestinationTag": 1288227521, + "Fee": "12", + "Flags": 2147483648, + "LastLedgerSequence": 98411980, + "Sequence": 87860598, + "SigningPubKey": "0228437DB03B770D1358C1DBF5091D38F43E58B1AD646C607316D9C0F09B424894", + "TransactionType": "Payment", + "TxnSignature": "3045022100DAB258BDAB847904B9753B96A0ABDF937C6269A1374995C39BE37C403035FAD90220273203339A250330E2219D601AB98B026EF45F0248B86A582362038EAABAF4AA", + "hash": "225B05E7CC17C3F6F58EFA0F2D11C655FEC282922ED4808C267F5615ED56A572", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rUYHZ71yXAS54ZQNvvooLX7rFtZydXjnP", + "Balance": "2100523899761", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 87860599 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "68DA3B24C6E760BAC282F090515D5FA70217144B50E317BFD2376B904E70BDCD", + "PreviousFields": { + "Balance": "2100853074573", + "Sequence": 87860598 + }, + "PreviousTxnID": "6A6551EFFD30C9CE44958FEDC66AE4FBAFB9044706607F06EA5A546F1149E735", + "PreviousTxnLgrSeq": 98408865 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rsRy14FvipgqudiGmptJBhr1RtpsgfzKMM", + "Balance": "9132158624", + "Flags": 1179648, + "OwnerCount": 1, + "Sequence": 97084 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "B83C16552508C182E24959C54865C9AED510F93AA0A2515342E5CC416CFAC4A1", + "PreviousFields": { + "Balance": "8802983824" + }, + "PreviousTxnID": "EB5B3F65184636D60D69E78D7343763DA5CE4680B04A24E7C9C2AD3DF927101C", + "PreviousTxnLgrSeq": 98408889 + } + } + ], + "TransactionIndex": 2, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "329174800" + } + }, + { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408901, + "OfferSequence": 188944821, + "Sequence": 188944835, + "SigningPubKey": "0253C1DFDCF898FE85F16B71CCE80A5739F7223D54CC9EBA4749616593470298C5", + "TakerGets": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rcEGREd8NmkKRE8GE424sksyt1tJVFZwu", + "value": "551054.74" + }, + "TakerPays": "200000000000", + "TransactionType": "OfferCreate", + "TxnSignature": "3044022068CFCF5C50F07AE23771B6EDB249EE4960321E8233D04A334937A3352438D9FB0220781FD5929439FD828AD242C2C4CD43197CCD0D418039630801700B4E4E10CC60", + "hash": "2B8622A299348840E2C11A4832CA712AC40A695198F16941A48CD56D98DB9B02", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "0", + "IndexPrevious": "0", + "Owner": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "RootIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3", + "PreviousTxnID": "CAEB990B4EC05AB17EADE0CACD1BA2449F2B627BE7B2E09E472D51324AA39E8D", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "1371BCEED87E4C5C53C442854276A5ABB5D41F635AB7C4475A0CE4EC5A24804B", + "NewFields": { + "ExchangeRate": "5a0ce4ec5a24804b", + "RootIndex": "1371BCEED87E4C5C53C442854276A5ABB5D41F635AB7C4475A0CE4EC5A24804B", + "TakerGetsCurrency": "5553444300000000000000000000000000000000", + "TakerGetsIssuer": "06AA7798F7A8FA6914CC1E82C556E5B0A0CCB9E3" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "5a0ce4ec8166604a", + "Flags": 0, + "PreviousTxnID": "F436C2736C60868A6FEF7471E7080ACB2E13559B5C63320D55819295A9A3C456", + "PreviousTxnLgrSeq": 98408896, + "RootIndex": "1371BCEED87E4C5C53C442854276A5ABB5D41F635AB7C4475A0CE4EC8166604A", + "TakerGetsCurrency": "5553444300000000000000000000000000000000", + "TakerGetsIssuer": "06AA7798F7A8FA6914CC1E82C556E5B0A0CCB9E3", + "TakerPaysCurrency": "0000000000000000000000000000000000000000", + "TakerPaysIssuer": "0000000000000000000000000000000000000000" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "1371BCEED87E4C5C53C442854276A5ABB5D41F635AB7C4475A0CE4EC8166604A" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Balance": "11956860867", + "Flags": 0, + "OwnerCount": 22, + "Sequence": 188944836 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1ED8DDFD80F275CB1CE7F18BB9D906655DE8029805D8B95FB9020B30425821EB", + "PreviousFields": { + "Balance": "11956860882", + "Sequence": 188944835 + }, + "PreviousTxnID": "CAEB990B4EC05AB17EADE0CACD1BA2449F2B627BE7B2E09E472D51324AA39E8D", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "6A90EA2173491326809CA24F95710AFD796D0C0C16AD4588C97A578FC4F6B35F", + "NewFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "1371BCEED87E4C5C53C442854276A5ABB5D41F635AB7C4475A0CE4EC5A24804B", + "Sequence": 188944835, + "TakerGets": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rcEGREd8NmkKRE8GE424sksyt1tJVFZwu", + "value": "551054.74" + }, + "TakerPays": "200000000000" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "1371BCEED87E4C5C53C442854276A5ABB5D41F635AB7C4475A0CE4EC8166604A", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "0", + "PreviousTxnID": "F436C2736C60868A6FEF7471E7080ACB2E13559B5C63320D55819295A9A3C456", + "PreviousTxnLgrSeq": 98408896, + "Sequence": 188944821, + "TakerGets": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rcEGREd8NmkKRE8GE424sksyt1tJVFZwu", + "value": "551054.64" + }, + "TakerPays": "200000000000" + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "7F6E687F001B77D85EB4AD341020746E079882FC4D1006423C91B1A589023612" + } + } + ], + "TransactionIndex": 13, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rwXoUk3ksjFm9AsFvPLsKLbycArxChuT2f", + "Fee": "10", + "LastLedgerSequence": 98408916, + "Sequence": 97183034, + "SigningPubKey": "EDE1EDC9FB81F76366D0E3A3408DC8997094671BA0CF4EDDBDE0715A73363E0FCF", + "TakerGets": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rMgrYs2XYgbGaLZ19HbUXfi9rpsaFQYwgc", + "value": "20.278456" + }, + "TakerPays": "4813", + "TransactionType": "OfferCreate", + "TxnSignature": "502CC6886EC5C0DA5392AC041AABA78C31F5ED2655D9B9F8EBAEDCCBA16DD79F459E3EA4453F14C264B6829DB6941FCAF742F478BC1E9CBE3AD6198B0C9B9005", + "hash": "2BD53B7F0ED4E8FD32F4D3BE83DD8A50CAD791B11DD26D0D666AC80D47B2ACC1", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rwXoUk3ksjFm9AsFvPLsKLbycArxChuT2f", + "Balance": "769292400", + "Flags": 0, + "OwnerCount": 122, + "Sequence": 97183035 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "42D7264D6E592D4690DA8A5155193A51BDCBE68D37CA7B76C71A73D0C523488C", + "PreviousFields": { + "Balance": "769287597", + "Sequence": 97183034 + }, + "PreviousTxnID": "C596ECFAE782BA1ABA54E88DEF68C56289B19C542347A31B30671DCBE67C410D", + "PreviousTxnLgrSeq": 98408896 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "101.0740714393187" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rMgrYs2XYgbGaLZ19HbUXfi9rpsaFQYwgc", + "value": "0" + }, + "HighNode": "d2", + "LowLimit": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rsWFkbbpncqAZgKKaP6avU2c6syC2HPbwM", + "value": "0" + }, + "LowNode": "1ff" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "5631905ED36EBEC3D86C4124E1605A954CB5D265FE3F4472E64EC026EBE62173", + "PreviousFields": { + "Balance": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "80.80028499058182" + } + }, + "PreviousTxnID": "C2F741B211268A70306D95502B15108AD079612E41455EE1313F948BF0CEE767", + "PreviousTxnLgrSeq": 98407022 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rsWFkbbpncqAZgKKaP6avU2c6syC2HPbwM", + "Balance": "341055009", + "Flags": 0, + "OwnerCount": 66, + "Sequence": 96931370, + "TicketCount": 35 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "B07421A114BE9B390326C3D36D347526A1E9D4C5B15F08279ECFBA5E67B90739", + "PreviousFields": { + "Balance": "341059822" + }, + "PreviousTxnID": "0205E31717392631A5538367AFA9506260710D488FC785E948235521CABB63DE", + "PreviousTxnLgrSeq": 98408848 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rsWFkbbpncqAZgKKaP6avU2c6syC2HPbwM", + "BookDirectory": "93574D61418F2A220A14D2E4996A44666537F85C7FEBB9DA520EF70FDE9B0D2B", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "1ff", + "Sequence": 96931294, + "TakerGets": "10456985", + "TakerPays": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rMgrYs2XYgbGaLZ19HbUXfi9rpsaFQYwgc", + "value": "44047.92869055568" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "D5DD7A9E1DDDC6E8198E51145F842D7A1D679867179C76FFDEE4A706836B42A2", + "PreviousFields": { + "TakerGets": "10461798", + "TakerPays": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rMgrYs2XYgbGaLZ19HbUXfi9rpsaFQYwgc", + "value": "44068.20247700442" + } + }, + "PreviousTxnID": "C2F741B211268A70306D95502B15108AD079612E41455EE1313F948BF0CEE767", + "PreviousTxnLgrSeq": 98407022 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "46825.04647371931" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rMgrYs2XYgbGaLZ19HbUXfi9rpsaFQYwgc", + "value": "0" + }, + "HighNode": "cf", + "LowLimit": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rwXoUk3ksjFm9AsFvPLsKLbycArxChuT2f", + "value": "149685895.3858135" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "D9AF5818E37F4602542E5C7A9518929A0052D8E41CBCDB726822E81FC145B5AF", + "PreviousFields": { + "Balance": { + "currency": "524950504C450000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "46845.32026016805" + } + }, + "PreviousTxnID": "C2F741B211268A70306D95502B15108AD079612E41455EE1313F948BF0CEE767", + "PreviousTxnLgrSeq": 98407022 + } + } + ], + "TransactionIndex": 15, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rw22L9LYRbEKQ5h7YiHJnxUAmYGERjCSmK", + "Fee": "12", + "Flags": 0, + "LastLedgerSequence": 98408911, + "OfferSequence": 97110589, + "Sequence": 0, + "SigningPubKey": "EDDA7FC9AB82DE4EB40B6F26B5350E236F5A03F96D5EDA56759205659AA5269AFD", + "TakerGets": "10385820", + "TakerPays": { + "currency": "7372667800000000000000000000000000000000", + "issuer": "rDgBV9WrwJ3WwtRWhkekMhDas3muFeKvoS", + "value": "1037959918000920e-3" + }, + "TicketSequence": 97110581, + "TransactionType": "OfferCreate", + "TxnSignature": "F768E4C3F17DF8C5ECBA333F85788D406E4E1DEBBB269CADD80F0B99E6F76936D92D93A80581BB0865E90B0FE6F1293804AB2B57664BCBB1A6AAD30C16DFB80B", + "hash": "2BF2E06395D33C0D504EC5EA301303AD05AFBB5EB84C2B9601724846D04755C1", + "metaData": { + "AffectedNodes": [ + { + "DeletedNode": { + "FinalFields": { + "Account": "rw22L9LYRbEKQ5h7YiHJnxUAmYGERjCSmK", + "Flags": 0, + "OwnerNode": "1039", + "PreviousTxnID": "B28455411B2FD8382498082C10606B939A1F447DD8C9DD203FC26460282DE5F3", + "PreviousTxnLgrSeq": 98408887, + "TicketSequence": 97110581 + }, + "LedgerEntryType": "Ticket", + "LedgerIndex": "19A92B6D94BE6D2BBBD4724DCD44918D9AC3493407030B0C01E75BFF5714B286" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "103a", + "IndexPrevious": "1038", + "Owner": "rw22L9LYRbEKQ5h7YiHJnxUAmYGERjCSmK", + "RootIndex": "4E9891CB8C13C6DB3F0994A53FCBEEA634B63478E5571271DAEF8274EE4E9784" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "4CA920A6E80F8F95C0F5205537156C1E55AB3928FCC628E24064D72450A1B4D8", + "PreviousTxnID": "3B09CEB9AA81A0CB46CE4B24B3B9B60C10487E2CE86C29CC5884664FC60A6BBE", + "PreviousTxnLgrSeq": 98408894 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "7F0019DAC6E7377118813E421231DE2575BB8235293B5AFE3D8871413CB17482", + "NewFields": { + "Account": "rw22L9LYRbEKQ5h7YiHJnxUAmYGERjCSmK", + "BookDirectory": "CA9DC3598690B6CF5FEA3639A9037C489FC9FD889CC34BB65923817FD85B3104", + "OwnerNode": "103b", + "Sequence": 97110581, + "TakerGets": "10385820", + "TakerPays": { + "currency": "7372667800000000000000000000000000000000", + "issuer": "rDgBV9WrwJ3WwtRWhkekMhDas3muFeKvoS", + "value": "1037959918000920e-3" + } + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "103a", + "Owner": "rw22L9LYRbEKQ5h7YiHJnxUAmYGERjCSmK", + "RootIndex": "4E9891CB8C13C6DB3F0994A53FCBEEA634B63478E5571271DAEF8274EE4E9784" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "A13CD7671EF89F428BB27EFC2E923FCEA1069D079030B561C1E7DF1F4DD603EF", + "PreviousTxnID": "3B09CEB9AA81A0CB46CE4B24B3B9B60C10487E2CE86C29CC5884664FC60A6BBE", + "PreviousTxnLgrSeq": 98408894 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rw22L9LYRbEKQ5h7YiHJnxUAmYGERjCSmK", + "Balance": "339803021", + "Flags": 0, + "OwnerCount": 92, + "Sequence": 97110627, + "TicketCount": 62 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "B9BF61C748CF944D2BA40915F9D263C1296233F7768F328BB0DDEA5784C72909", + "PreviousFields": { + "Balance": "339803033", + "OwnerCount": 93, + "TicketCount": 63 + }, + "PreviousTxnID": "3B09CEB9AA81A0CB46CE4B24B3B9B60C10487E2CE86C29CC5884664FC60A6BBE", + "PreviousTxnLgrSeq": 98408894 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "ExchangeRate": "5923817fd85b3104", + "Flags": 0, + "RootIndex": "CA9DC3598690B6CF5FEA3639A9037C489FC9FD889CC34BB65923817FD85B3104", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "7372667800000000000000000000000000000000", + "TakerPaysIssuer": "8B0A7C0E20BA5C3FEBF8FA782C5211AF23BD535A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "CA9DC3598690B6CF5FEA3639A9037C489FC9FD889CC34BB65923817FD85B3104" + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rw22L9LYRbEKQ5h7YiHJnxUAmYGERjCSmK", + "BookDirectory": "CA9DC3598690B6CF5FEA3639A9037C489FC9FD889CC34BB65923817FD85B3104", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "103b", + "PreviousTxnID": "3B09CEB9AA81A0CB46CE4B24B3B9B60C10487E2CE86C29CC5884664FC60A6BBE", + "PreviousTxnLgrSeq": 98408894, + "Sequence": 97110589, + "TakerGets": "10385820", + "TakerPays": { + "currency": "7372667800000000000000000000000000000000", + "issuer": "rDgBV9WrwJ3WwtRWhkekMhDas3muFeKvoS", + "value": "1037959918000920e-3" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "DE46C37AD18FE79F4CAFCB97AD6D3EC8333BAF1D42A2AFCE3F914F64B4D15C48" + } + } + ], + "TransactionIndex": 16, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rHh6NxvrJzbVe287tS7D9MjfjRAr92aJ7w", + "Amount": "9024", + "DeliverMax": "9024", + "Destination": "rHh6NxvrJzbVe287tS7D9MjfjRAr92aJ7w", + "Fee": "10", + "Flags": 131072, + "LastLedgerSequence": 98408916, + "Memos": [ + { + "Memo": { + "MemoData": "41524D59204F47204D61726B6574204D616B6572207632", + "MemoFormat": "746578742F706C61696E", + "MemoType": "7472616465" + } + } + ], + "SendMax": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rwxrCZTjrxXmtYA6v6krDKjjFaRuUiAhvJ", + "value": "105.4623644268429" + }, + "Sequence": 95235495, + "SigningPubKey": "EDDC4A6AFA966C060106BAB0988FA13F041E167F1B800E915E2F8982005DD3BA1F", + "TransactionType": "Payment", + "TxnSignature": "5B557BAF7970B966236B57D7204E615B43C8ABCD9F0DA72ADAF4D3B316B23564FF2E1D9B39C078BD6BDEB7DF0633E1B3C74FAB66CA4A4A83A75A23FED8098C03", + "hash": "335CAF15BD27C672E1E2179835A4EAA557843026B644D325DB32F064126BFCC2", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rHh6NxvrJzbVe287tS7D9MjfjRAr92aJ7w", + "Balance": "30129542", + "BurnedNFTokens": 129, + "FirstNFTokenSequence": 94970939, + "Flags": 0, + "MintedNFTokens": 227, + "OwnerCount": 99, + "Sequence": 95235496 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "093F3A1235765E6B40E2EE6B3BC3FD57DD86625AB7592D9413D9E54AD4897290", + "PreviousFields": { + "Balance": "30120528", + "Sequence": 95235495 + }, + "PreviousTxnID": "9DA66252B91A7A1C2F8E86B813AB83642217E7F771FEEA4FF87AD7F9B756D702", + "PreviousTxnLgrSeq": 98408894 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "BBC3E2F96F04D843EB1BE10E2601BF0332E6367FAB6D836D8C2F5C3173FE7265", + "Account": "rHFofSpDWmyBkzUxknfi1uEeAeTVjpYSrG", + "Balance": "22408519034", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 91653567 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "2646F939228352213E4F1F664F94F03162A17E8A808BC1C2AB1717A0557B7D09", + "PreviousFields": { + "Balance": "22408528058" + }, + "PreviousTxnID": "9DA66252B91A7A1C2F8E86B813AB83642217E7F771FEEA4FF87AD7F9B756D702", + "PreviousTxnLgrSeq": 98408894 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-257821989.5394194" + }, + "Flags": 16908288, + "HighLimit": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rHFofSpDWmyBkzUxknfi1uEeAeTVjpYSrG", + "value": "0" + }, + "HighNode": "0", + "LowLimit": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rwxrCZTjrxXmtYA6v6krDKjjFaRuUiAhvJ", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "4D533F3C25C80572ED2315D7751E060A11DD0FAD9C8AD107661BB5D34C7761F1", + "PreviousFields": { + "Balance": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-257821884.6838362" + } + }, + "PreviousTxnID": "9DA66252B91A7A1C2F8E86B813AB83642217E7F771FEEA4FF87AD7F9B756D702", + "PreviousTxnLgrSeq": 98408894 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-4416359.054977034" + }, + "Flags": 2228224, + "HighLimit": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rHh6NxvrJzbVe287tS7D9MjfjRAr92aJ7w", + "value": "150000000" + }, + "HighNode": "0", + "LowLimit": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rwxrCZTjrxXmtYA6v6krDKjjFaRuUiAhvJ", + "value": "0" + }, + "LowNode": "15" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "FD498A70EA33AEA1C68AA5DC981D1706AE758C63BD4026E71C9B4456F80A1FB3", + "PreviousFields": { + "Balance": { + "currency": "62756C6C00000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-4416463.910560261" + } + }, + "PreviousTxnID": "9DA66252B91A7A1C2F8E86B813AB83642217E7F771FEEA4FF87AD7F9B756D702", + "PreviousTxnLgrSeq": 98408894 + } + } + ], + "TransactionIndex": 29, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "9024" + } + }, + { + "Account": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Amount": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "9999999999999999" + }, + "DeliverMax": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "9999999999999999" + }, + "Destination": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Fee": "15", + "Flags": 131072, + "Memos": [ + { + "Memo": { + "MemoData": "486F72697A6F6E20566F6C756D6520426F6F73746572" + } + } + ], + "SendMax": "110348", + "Sequence": 98427698, + "SigningPubKey": "02B1A3B9C34904ED170BB04CC6915CD600251398DC8632357745019CEB2914B855", + "SourceTag": 111, + "TransactionType": "Payment", + "TxnSignature": "3045022100A6FB0996242AA9F52FEBE2EEEF62B276DC8C19D929613F61FB825CAD3052D44502204AF8E6FBFD053E67AE39733C32B3A81FBF6AFB4B9FB72894A416D46AB7BBD227", + "hash": "382ACF21079DA9892F9A0E2529384456229F689D3973404751226073604D465B", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "KPH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-117208.8522676" + }, + "Flags": 2228224, + "HighLimit": { + "currency": "KPH", + "issuer": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "value": "9999999999999999" + }, + "HighNode": "0", + "LowLimit": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "0" + }, + "LowNode": "3" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "2DA4733DE331686F56AC7BA333BB7DB0489FCF1CCBA9C0A5D77201831F9B03ED", + "PreviousFields": { + "Balance": { + "currency": "KPH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + } + }, + "PreviousTxnID": "0907FCB455CEE803399BC6F2D0EA3313661F8E879A8DE0629D6D1B64CBA83E1E", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Balance": "13888573", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98427699 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "49C35C3086C2A3C574A6CF34CF61100E862A30B3EA154262A177EFBC3A82DFD2", + "PreviousFields": { + "Balance": "13998936", + "Sequence": 98427698 + }, + "PreviousTxnID": "0907FCB455CEE803399BC6F2D0EA3313661F8E879A8DE0629D6D1B64CBA83E1E", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "KPH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "478251733.6070343" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "0" + }, + "HighNode": "3", + "LowLimit": { + "currency": "KPH", + "issuer": "rnvud56swMVK7gFn5ZxHd3wvyGj63yPieA", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "74FA35D018AFAF82C64103BE9A5262E047D5A003EF32EF454C7BE941FF4F6E0B", + "PreviousFields": { + "Balance": { + "currency": "KPH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "478368942.4593019" + } + }, + "PreviousTxnID": "0907FCB455CEE803399BC6F2D0EA3313661F8E879A8DE0629D6D1B64CBA83E1E", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "0BA1C448D782E29D5B8261E50824D1FCE736B7FE79AF75EA4F7175743DFD8DDE", + "Account": "rnvud56swMVK7gFn5ZxHd3wvyGj63yPieA", + "Balance": "448039656", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 98390172 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "DC86A555626F876187C8D10E930E2F2BE5A18AECBAE4F4437E11595412048663", + "PreviousFields": { + "Balance": "447929308" + }, + "PreviousTxnID": "0907FCB455CEE803399BC6F2D0EA3313661F8E879A8DE0629D6D1B64CBA83E1E", + "PreviousTxnLgrSeq": 98408897 + } + } + ], + "DeliveredAmount": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "117208.8522676" + }, + "TransactionIndex": 40, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "117208.8522676" + } + } + }, + { + "Account": "rByivrzo9kYMf8HEnf9z57FFGGVwdTcwF", + "Amount": "107653731", + "DeliverMax": "107653731", + "Destination": "rKBRWUTreGNU9d3pL2gYUo23jn4UdKiAoS", + "DestinationTag": 11034230, + "Fee": "12", + "LastLedgerSequence": 98408916, + "Sequence": 68395699, + "SigningPubKey": "02913C01E65286B7B33F5E14B6907C9B16A713E10E267058B8E616BAB18D77D65C", + "TransactionType": "Payment", + "TxnSignature": "3045022100C04EBEFF3AF287409159598EA39B99B58EA2EF8E1C49B679A5EE1D2872D85668022079F5691508456B3496002FBDF4917421B427C5CFEAEC55DA09925BA50A391CFE", + "hash": "42F1570B3018A61E94C4D6D60FA76FB57E29E7CCF35D0738BECF915248A75588", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rKBRWUTreGNU9d3pL2gYUo23jn4UdKiAoS", + "Balance": "513847984005", + "Flags": 131072, + "OwnerCount": 0, + "Sequence": 87165678 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "B538B259BDD955D0018FC1367D2F99DE8481C86EDB3DF5CB3DF1BA44B957A4F2", + "PreviousFields": { + "Balance": "513740330274" + }, + "PreviousTxnID": "9892C9C3DCE99F20ACED27108572A84345BA28AD51CA2FD3F27A2394ADD0A663", + "PreviousTxnLgrSeq": 98408868 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rByivrzo9kYMf8HEnf9z57FFGGVwdTcwF", + "Balance": "5199988", + "Flags": 0, + "OwnerCount": 21, + "Sequence": 68395700 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "ED371AF2917BF68A82BC905524DD0F2C83DFB478A751F36E96A1846F3B47D8B1", + "PreviousFields": { + "Balance": "112853731", + "Sequence": 68395699 + }, + "PreviousTxnID": "70A5926C05B5D98A2B2C369D7995AE4EA9A0126B9A3764782808D092C3AACB56", + "PreviousTxnLgrSeq": 98408885 + } + } + ], + "TransactionIndex": 3, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "107653731" + } + }, + { + "Account": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Amount": "1000000000000", + "DeliverMax": "1000000000000", + "DeliverMin": "10", + "Destination": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Fee": "15", + "Flags": 131072, + "Memos": [ + { + "Memo": { + "MemoData": "486F72697A6F6E20566F6C756D6520426F6F73746572" + } + } + ], + "SendMax": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "9999999999999999" + }, + "Sequence": 98427699, + "SigningPubKey": "02B1A3B9C34904ED170BB04CC6915CD600251398DC8632357745019CEB2914B855", + "SourceTag": 111, + "TransactionType": "Payment", + "TxnSignature": "304402203071FDD264964BC0E166EDFEDBB1F95CB67F2BB7C5FA2E14C9944AE1E561D0260220091786CD22CFCE035F66AC3FCBF8EC458D8BE1723CC35DA87A292C6CE8842D76", + "hash": "4D84AF2EB5B5DC5C7AE7FA3E9B6BD7AAFC1CA61C3A6CA5238E8FA9921DB76A9F", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "KPH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 2228224, + "HighLimit": { + "currency": "KPH", + "issuer": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "value": "9999999999999999" + }, + "HighNode": "0", + "LowLimit": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "0" + }, + "LowNode": "3" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "2DA4733DE331686F56AC7BA333BB7DB0489FCF1CCBA9C0A5D77201831F9B03ED", + "PreviousFields": { + "Balance": { + "currency": "KPH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-117208.8522676" + } + }, + "PreviousTxnID": "382ACF21079DA9892F9A0E2529384456229F689D3973404751226073604D465B", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Balance": "13997768", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98427700 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "49C35C3086C2A3C574A6CF34CF61100E862A30B3EA154262A177EFBC3A82DFD2", + "PreviousFields": { + "Balance": "13888573", + "Sequence": 98427699 + }, + "PreviousTxnID": "382ACF21079DA9892F9A0E2529384456229F689D3973404751226073604D465B", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "KPH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "478368942.4593019" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "KPH", + "issuer": "rKP5uhdSN7NZMWeN6GJisejpTwX9q7yhBz", + "value": "0" + }, + "HighNode": "3", + "LowLimit": { + "currency": "KPH", + "issuer": "rnvud56swMVK7gFn5ZxHd3wvyGj63yPieA", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "74FA35D018AFAF82C64103BE9A5262E047D5A003EF32EF454C7BE941FF4F6E0B", + "PreviousFields": { + "Balance": { + "currency": "KPH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "478251733.6070343" + } + }, + "PreviousTxnID": "382ACF21079DA9892F9A0E2529384456229F689D3973404751226073604D465B", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "0BA1C448D782E29D5B8261E50824D1FCE736B7FE79AF75EA4F7175743DFD8DDE", + "Account": "rnvud56swMVK7gFn5ZxHd3wvyGj63yPieA", + "Balance": "447930446", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 98390172 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "DC86A555626F876187C8D10E930E2F2BE5A18AECBAE4F4437E11595412048663", + "PreviousFields": { + "Balance": "448039656" + }, + "PreviousTxnID": "382ACF21079DA9892F9A0E2529384456229F689D3973404751226073604D465B", + "PreviousTxnLgrSeq": 98408898 + } + } + ], + "DeliveredAmount": "109210", + "TransactionIndex": 41, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "109210" + } + }, + { + "Account": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Amount": "1000000000000", + "DeliverMax": "1000000000000", + "DeliverMin": "10", + "Destination": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Fee": "15", + "Flags": 131072, + "Memos": [ + { + "Memo": { + "MemoData": "486F72697A6F6E20566F6C756D6520426F6F73746572" + } + } + ], + "SendMax": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "9999999999999999" + }, + "Sequence": 98344678, + "SigningPubKey": "03E1C4ED711F104C72600492BD620A6C409C608CB47A17F59E27BDFA601E8BAF6A", + "SourceTag": 111, + "TransactionType": "Payment", + "TxnSignature": "304402203FA19D7422EFC3158D73DF86C9CFCA27032A644FD8573C45ED5D72B535B66F0A02200E363D84DC25BC38D6F599B254EB6DBF9F1A498CAFAA55F10AB8788B6549C367", + "hash": "55F551E7E8D1D4C64BF19F23F8C3D921144A3D0824CB261F6866185745FF12F8", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "0" + }, + "HighNode": "7", + "LowLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "value": "9999999999999999" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "8ADE0DE31774A01BBE605A7F2D4BA867755E5EA5F280AC6115B3FF6FECAC5F60", + "PreviousFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.74735415286" + } + }, + "PreviousTxnID": "9BEC1CD679B5EF0C28C91C0D152B9AE55D90E6F2D266ED81CE1C80AA0010BEDD", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "8AD1706F10A3BCB0F1F479F9B412C5B0237C7AD107532B40FF1024B1402BB12B", + "Account": "rWTtj1yWvCuT5eVYUFSNm3bVY1812uqVL", + "Balance": "1060916354", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 95675332 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "91FB211B3C00EB92FD003A52CC42E14FA61137A727120C9A15E61FA15BB115BB", + "PreviousFields": { + "Balance": "1061119763" + }, + "PreviousTxnID": "9BEC1CD679B5EF0C28C91C0D152B9AE55D90E6F2D266ED81CE1C80AA0010BEDD", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "14234.31506532059" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "0" + }, + "HighNode": "2", + "LowLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rWTtj1yWvCuT5eVYUFSNm3bVY1812uqVL", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "A46D98A42D1C0FF60B1B40426015A87C98EBCA4BE1887AADE310E257761845D1", + "PreviousFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "14231.56771116773" + } + }, + "PreviousTxnID": "9BEC1CD679B5EF0C28C91C0D152B9AE55D90E6F2D266ED81CE1C80AA0010BEDD", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Balance": "10801164", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98344679 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "C6085F1779BE2722C49A2150C097D0E08071583E428BEA5046A72D691EBEE423", + "PreviousFields": { + "Balance": "10597770", + "Sequence": 98344678 + }, + "PreviousTxnID": "9BEC1CD679B5EF0C28C91C0D152B9AE55D90E6F2D266ED81CE1C80AA0010BEDD", + "PreviousTxnLgrSeq": 98408898 + } + } + ], + "DeliveredAmount": "203409", + "TransactionIndex": 8, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "203409" + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "30686C336652713462466B78677A66353150703763", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353818, + "Sequence": 109353832, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "19988.9307614292" + }, + "TakerPays": "7035531780", + "TransactionType": "OfferCreate", + "TxnSignature": "304502210088765F70CC7290190CA4CB1EDF9B1906955EED300A635DFA1A79D1436AF3DA650220384CABA83315AB145E6988882915F6E94E660E9909C9F672AF8DC8D7F0C7E5EF", + "hash": "5A77512773FBA6D90A95434D659D57A3FE315453BCCC31B8956EAF556D15B6E9", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "5F893E9E9239551781E6EE7BF8F606BBA1A681F7BD65CA0F3DA5ED934CD7638E", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082203", + "Flags": 0, + "OwnerCount": 49, + "Sequence": 109353833 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082218", + "Sequence": 109353832 + }, + "PreviousTxnID": "5F893E9E9239551781E6EE7BF8F606BBA1A681F7BD65CA0F3DA5ED934CD7638E", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "BCBCE3D2249FEE75792092CD6DB86998A7DB61F4F3DD01B65A106B2513F44B07", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "DA8B8197B2CA44ABA26AA783BE3A91C1EE6DD829D9E300155A0C812941C5CF25", + "OwnerNode": "5", + "Sequence": 109353832, + "TakerGets": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "19988.9307614292" + }, + "TakerPays": "7035531780" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "DA8B8197B2CA44ABA26AA783BE3A91C1EE6DD829D9E300155A0C8070B27927B1", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "5", + "PreviousTxnID": "30FD014E664D9C9E4DCE219C85DED8BE0141EE713C75723FCF33DB827C695FB8", + "PreviousTxnLgrSeq": 98408894, + "Sequence": 109353818, + "TakerGets": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "19988.9401076502" + }, + "TakerPays": "7033950590" + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "CAB93C2798805DEC76ABCC7984C836CE7A80AD8F36F6916BA721950AB4A5C695" + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "5a0c8070b27927b1", + "Flags": 0, + "PreviousTxnID": "30FD014E664D9C9E4DCE219C85DED8BE0141EE713C75723FCF33DB827C695FB8", + "PreviousTxnLgrSeq": 98408894, + "RootIndex": "DA8B8197B2CA44ABA26AA783BE3A91C1EE6DD829D9E300155A0C8070B27927B1", + "TakerGetsCurrency": "5553444300000000000000000000000000000000", + "TakerGetsIssuer": "ACF3278B42F2ACC71989C99661DAB9AF8C081981", + "TakerPaysCurrency": "0000000000000000000000000000000000000000", + "TakerPaysIssuer": "0000000000000000000000000000000000000000" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "DA8B8197B2CA44ABA26AA783BE3A91C1EE6DD829D9E300155A0C8070B27927B1" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "DA8B8197B2CA44ABA26AA783BE3A91C1EE6DD829D9E300155A0C812941C5CF25", + "NewFields": { + "ExchangeRate": "5a0c812941c5cf25", + "RootIndex": "DA8B8197B2CA44ABA26AA783BE3A91C1EE6DD829D9E300155A0C812941C5CF25", + "TakerGetsCurrency": "5553444300000000000000000000000000000000", + "TakerGetsIssuer": "ACF3278B42F2ACC71989C99661DAB9AF8C081981" + } + } + } + ], + "TransactionIndex": 23, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 524288, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "51543276767571694671324E493237632D70477658", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353789, + "Sequence": 109353831, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": "1758825100", + "TakerPays": { + "currency": "4155444300000000000000000000000000000000", + "issuer": "rzzubftiPvLVDJu34PQvAP6TtjDekNApn", + "value": "7725.63925175" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "3045022100D1B215218EB1234514666A38826EE1AC1C4CD8D89CB3A600B5F4F5E98289F0A6022028525F778B8A9069CEF573F0185CB8211615CE1CD69547DE55F50CCE6BE214B0", + "hash": "5F893E9E9239551781E6EE7BF8F606BBA1A681F7BD65CA0F3DA5ED934CD7638E", + "metaData": { + "AffectedNodes": [ + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "5CCF07887EB1DF689A22C71014FAE94C5A71504882E360BC4F0F9D0C1CDBE000", + "BookNode": "0", + "Flags": 131072, + "OwnerNode": "5", + "PreviousTxnID": "495C584AC84FBB690420B96663FCF20E4A27F0C5CE584FB746598C1EA54EFEE8", + "PreviousTxnLgrSeq": 98408890, + "Sequence": 109353789, + "TakerGets": "1757907100", + "TakerPays": { + "currency": "4155444300000000000000000000000000000000", + "issuer": "rzzubftiPvLVDJu34PQvAP6TtjDekNApn", + "value": "7725.65012308" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "3BBBA57E8F1A5FA004F9EDF1F99AD14EA89BBE6CC5918AC848E96FD9D780D859" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "5CCF07887EB1DF689A22C71014FAE94C5A71504882E360BC4F0F9AF49A2D0800", + "NewFields": { + "ExchangeRate": "4f0f9af49a2d0800", + "RootIndex": "5CCF07887EB1DF689A22C71014FAE94C5A71504882E360BC4F0F9AF49A2D0800", + "TakerPaysCurrency": "4155444300000000000000000000000000000000", + "TakerPaysIssuer": "0AF80C42E810C68280A9F1D038A74C0A4A9AA7AD" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "4f0f9d0c1cdbe000", + "Flags": 0, + "PreviousTxnID": "495C584AC84FBB690420B96663FCF20E4A27F0C5CE584FB746598C1EA54EFEE8", + "PreviousTxnLgrSeq": 98408890, + "RootIndex": "5CCF07887EB1DF689A22C71014FAE94C5A71504882E360BC4F0F9D0C1CDBE000", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "4155444300000000000000000000000000000000", + "TakerPaysIssuer": "0AF80C42E810C68280A9F1D038A74C0A4A9AA7AD" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "5CCF07887EB1DF689A22C71014FAE94C5A71504882E360BC4F0F9D0C1CDBE000" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "AE4D50A003A65C7056B521CA14BCA418C67A1BA0601D96C672FBF22246BD2875", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082218", + "Flags": 0, + "OwnerCount": 49, + "Sequence": 109353832 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082233", + "Sequence": 109353831 + }, + "PreviousTxnID": "AE4D50A003A65C7056B521CA14BCA418C67A1BA0601D96C672FBF22246BD2875", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "DCB0ED89780471AAD3CE5466AD0630769D58AB34326CE0E7FC26EBBE22B65B80", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "5CCF07887EB1DF689A22C71014FAE94C5A71504882E360BC4F0F9AF49A2D0800", + "Flags": 131072, + "OwnerNode": "5", + "Sequence": 109353831, + "TakerGets": "1758825100", + "TakerPays": { + "currency": "4155444300000000000000000000000000000000", + "issuer": "rzzubftiPvLVDJu34PQvAP6TtjDekNApn", + "value": "7725.63925175" + } + } + } + } + ], + "TransactionIndex": 22, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rGNuURrs1vE4s5Saqwys5z4J1hQAwtTYuR", + "Amount": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rNZS1hXERvSfpbRpG5jNz3bMkHFhEV1H2y", + "value": "0.98624527" + }, + "DeliverMax": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rNZS1hXERvSfpbRpG5jNz3bMkHFhEV1H2y", + "value": "0.98624527" + }, + "DeliverMin": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rNZS1hXERvSfpbRpG5jNz3bMkHFhEV1H2y", + "value": "0.8383084795" + }, + "Destination": "rGNuURrs1vE4s5Saqwys5z4J1hQAwtTYuR", + "Fee": "10", + "Flags": 2147614720, + "LastLedgerSequence": 98408916, + "Memos": [ + { + "Memo": { + "MemoData": "496E6974696174656420766961202453454E54206D61726B6574206D616B6572" + } + } + ], + "SendMax": "579816", + "Sequence": 98135520, + "SigningPubKey": "ED3CE529E2F21C77E06B050D6DE2154D4538D6BBDE8505FCBC857CBE10537A48D1", + "TransactionType": "Payment", + "TxnSignature": "9D07D337B1BE3E59608502D3AF56BD64384B63532DB47C1807F8DA11074E873332DBFB6E56D8658102F188A782B801146805E9696816222A1DE0E3E9BD50730B", + "hash": "647589CF082FFD3479411096324431A321A97CDC31B6505BFA4E28322965CE2F", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "AD6C2895129A5D824E013B1C1E12A9BD29B171F99FC17E7FA5B291F0A3C0FD95", + "Account": "raRVpBQJm3XqmDpUYJu59UWTr34pJdu1nL", + "Balance": "761406990", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 93107887 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1B77A8F07175EECACCDC55C1DEFDDCE6ABCD20E7C09341F7E83ECEC030CCD17C", + "PreviousFields": { + "Balance": "760827257" + }, + "PreviousTxnID": "7B1333B6C72BFC4B3461376497DCF306C502E891A458581CE4C8C74584DEEDC2", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-219.9319102810963" + }, + "Flags": 131072, + "HighLimit": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rGNuURrs1vE4s5Saqwys5z4J1hQAwtTYuR", + "value": "1000000000000000" + }, + "HighNode": "0", + "LowLimit": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rNZS1hXERvSfpbRpG5jNz3bMkHFhEV1H2y", + "value": "0" + }, + "LowNode": "7" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "43F19E684E5051D2E2F27E25B0CB6B490FAB4C35100E7C9B9A53A09BB72EC18A", + "PreviousFields": { + "Balance": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-218.9456650110963" + } + }, + "PreviousTxnID": "C723AC760AC6E951CEB8D849449B7597A7BA3514D8FAA0DD7882686C0481808E", + "PreviousTxnLgrSeq": 98408867 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1294.868163368392" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rNZS1hXERvSfpbRpG5jNz3bMkHFhEV1H2y", + "value": "0" + }, + "HighNode": "6", + "LowLimit": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "raRVpBQJm3XqmDpUYJu59UWTr34pJdu1nL", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "BB3079432408F07CB4B05F529399D5E1A297BA29AFCB0B9D7C805CE94B329E16", + "PreviousFields": { + "Balance": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1295.854408638392" + } + }, + "PreviousTxnID": "7B1333B6C72BFC4B3461376497DCF306C502E891A458581CE4C8C74584DEEDC2", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGNuURrs1vE4s5Saqwys5z4J1hQAwtTYuR", + "Balance": "3177267", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98135521 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "DD032416934F2A73505D00B79DBEC384F8F55AA27C824B5F0B54C97FCAC635DF", + "PreviousFields": { + "Balance": "3757010", + "Sequence": 98135520 + }, + "PreviousTxnID": "C723AC760AC6E951CEB8D849449B7597A7BA3514D8FAA0DD7882686C0481808E", + "PreviousTxnLgrSeq": 98408867 + } + } + ], + "TransactionIndex": 31, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "4D454D4F00000000000000000000000000000000", + "issuer": "rNZS1hXERvSfpbRpG5jNz3bMkHFhEV1H2y", + "value": "0.98624527" + } + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 524288, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "304C77566738466A47455058646A724171734F3675", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353805, + "Sequence": 109353829, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "2.982" + }, + "TakerPays": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "13005.11331" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "30440220429542FD1A91E9C6D66F7541EA3532B8C558B8CF792797748D73BAA40A6F6C3E022076B2EE0340A441A400C2750031EA49DBDD4DD0017FAF52D49A4CCC058C975E0D", + "hash": "65D4324CC71F40C3B513DD56A1E1D57AB5D71D1B6E765D06CD6731A2E141324D", + "metaData": { + "AffectedNodes": [ + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "5108282264deb001", + "Flags": 0, + "PreviousTxnID": "0F47B0FD48F1BEBABB77160A732FAA9CF758A03B36DDAEEDE546E755FE2316D7", + "PreviousTxnLgrSeq": 98408898, + "RootIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108282264DEB001", + "TakerGetsCurrency": "524C555344000000000000000000000000000000", + "TakerGetsIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E", + "TakerPaysCurrency": "0000000000000000000000004554480000000000", + "TakerPaysIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108282264DEB001" + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "5108284d3c1fe000", + "Flags": 0, + "PreviousTxnID": "B20E63C7091A5A9D19F9181E50BBA7CBDAFFF49947FA5A33DDAF7B786C5A444B", + "PreviousTxnLgrSeq": 98408890, + "RootIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108284D3C1FE000", + "TakerGetsCurrency": "524C555344000000000000000000000000000000", + "TakerGetsIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E", + "TakerPaysCurrency": "0000000000000000000000004554480000000000", + "TakerPaysIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108284D3C1FE000" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "0F47B0FD48F1BEBABB77160A732FAA9CF758A03B36DDAEEDE546E755FE2316D7", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082248", + "Flags": 0, + "OwnerCount": 49, + "Sequence": 109353830 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082263", + "OwnerCount": 51, + "Sequence": 109353829 + }, + "PreviousTxnID": "0F47B0FD48F1BEBABB77160A732FAA9CF758A03B36DDAEEDE546E755FE2316D7", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "86CE0F2C12DC63093C63CA0DD813D1E2030E1B12941FA966614A1D62757243E9", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7E7E2AD15200", + "Flags": 131072, + "OwnerNode": "5", + "Sequence": 109353829, + "TakerGets": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "2.982" + }, + "TakerPays": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "13005.11331" + } + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7E7E2AD15200", + "NewFields": { + "ExchangeRate": "580f7e7e2ad15200", + "RootIndex": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7E7E2AD15200", + "TakerGetsCurrency": "0000000000000000000000004554480000000000", + "TakerGetsIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1", + "TakerPaysCurrency": "524C555344000000000000000000000000000000", + "TakerPaysIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "580f7f924ded2600", + "Flags": 0, + "PreviousTxnID": "50D8182F4A53F8FAB51E1DD53F327D33C65D036635E96F91EB1C0BA2C0AA0B23", + "PreviousTxnLgrSeq": 98408892, + "RootIndex": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7F924DED2600", + "TakerGetsCurrency": "0000000000000000000000004554480000000000", + "TakerGetsIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1", + "TakerPaysCurrency": "524C555344000000000000000000000000000000", + "TakerPaysIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7F924DED2600" + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108284D3C1FE000", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "5", + "PreviousTxnID": "B20E63C7091A5A9D19F9181E50BBA7CBDAFFF49947FA5A33DDAF7B786C5A444B", + "PreviousTxnLgrSeq": 98408890, + "Sequence": 109353790, + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "37959.82077529319" + }, + "TakerPays": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "8.716" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "B349B9A78E0B82146F2D3FD65144DEA041721376561F0039F112CC7A9E32692B" + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7F924DED2600", + "BookNode": "0", + "Flags": 131072, + "OwnerNode": "5", + "PreviousTxnID": "50D8182F4A53F8FAB51E1DD53F327D33C65D036635E96F91EB1C0BA2C0AA0B23", + "PreviousTxnLgrSeq": 98408892, + "Sequence": 109353805, + "TakerGets": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "2.981" + }, + "TakerPays": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "13004.287571" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "E5097C3025BD878F2AFFC3AD6EE85CB66A63F61F49CBBD3D09879BBBC0E36AE9" + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE5108282264DEB001", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "5", + "PreviousTxnID": "0F47B0FD48F1BEBABB77160A732FAA9CF758A03B36DDAEEDE546E755FE2316D7", + "PreviousTxnLgrSeq": 98408898, + "Sequence": 109353828, + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "12988.21217390092" + }, + "TakerPays": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "2.982" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "FFDCE51993AA4A0CE79C29CDAB359AE1D7B4EFFBB68DD50565FC7A48073F3ED9" + } + } + ], + "TransactionIndex": 20, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rLBSbJgwwVbbrf9eLcK23mWeVSDQwV8kYj", + "Amount": "1", + "DeliverMax": "1", + "Destination": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 98408984, + "Sequence": 97552219, + "SigningPubKey": "EDFD6F0E0F7FBFA2026D7D21B3D3FD1718BC4F27013DCA84E492F72457F472E81B", + "TransactionType": "Payment", + "TxnSignature": "96F7A4215DD9A9CEB62A632CBC06DFD757AC05A106D9522B6134D258B2ADA425B6CDD7C76481B540DF7C070815890A649F7E1960B6C915D3D4807E491F1DA600", + "hash": "71F604841BC17407934B2E8C9B609AC543F4FDA1B8BFCFD4E6B68338F742DF9B", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Balance": "13997770", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98427700 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "49C35C3086C2A3C574A6CF34CF61100E862A30B3EA154262A177EFBC3A82DFD2", + "PreviousFields": { + "Balance": "13997769" + }, + "PreviousTxnID": "B2D884326E9DAEF43B114533CED8E255330AF4D10BBB825D3248204024F83B06", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLBSbJgwwVbbrf9eLcK23mWeVSDQwV8kYj", + "Balance": "37841986", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 97552220 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "7EFA8F6FDBEE81E2CF90762AE5F2B3D437D3E9219F105820EED22AB097557635", + "PreviousFields": { + "Balance": "37841998", + "Sequence": 97552219 + }, + "PreviousTxnID": "B2D884326E9DAEF43B114533CED8E255330AF4D10BBB825D3248204024F83B06", + "PreviousTxnLgrSeq": 98408898 + } + } + ], + "TransactionIndex": 43, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + } + }, + { + "Account": "rstMNz7rAq4M5YksFxz9pkjxYFxbR2d6eu", + "Fee": "50", + "Flags": 589824, + "LastLedgerSequence": 98408916, + "Memos": [ + { + "Memo": { + "MemoData": "68626F742D313735363135373432353030332D3466366432382D5358505543336463626339383433" + } + } + ], + "Sequence": 97584375, + "SigningPubKey": "ED97390349F5C2493AB3097E6B7BB88BFA154D143D7F5E62F4B73FCC252734F2CE", + "SourceTag": 19089388, + "TakerGets": "5000000", + "TakerPays": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "14.792202425" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "1E369CD7065AA123758806E7312F9EA1DB13500AE98354770C9BF9A370DD425EEA3D9179DB07B3A381E50162C5BDA4F49A87745E325C28A43ECC70B9362B730C", + "hash": "7F16F8ED832A12A24CC99074512B89DF7019704FF78BC7618040BEDC97A77D62", + "metaData": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "245D882F7517DDF708DFD0516749CECA0BC1A2022264291B195CBF8553829B4E", + "NewFields": { + "Account": "rstMNz7rAq4M5YksFxz9pkjxYFxbR2d6eu", + "BookDirectory": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A82AF9A329340", + "Flags": 196608, + "OwnerNode": "2", + "Sequence": 97584375, + "TakerGets": "5000000", + "TakerPays": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "14.792202425" + } + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "ExchangeRate": "4f0a82af9a329340", + "Flags": 0, + "RootIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A82AF9A329340", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "5553444300000000000000000000000000000000", + "TakerPaysIssuer": "ACF3278B42F2ACC71989C99661DAB9AF8C081981" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A82AF9A329340", + "PreviousTxnID": "71AC45353ABC229B274C4C3B36BA428A9D2DF947C7B398266F8C3BEC6665338F", + "PreviousTxnLgrSeq": 98408895 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "1", + "Owner": "rstMNz7rAq4M5YksFxz9pkjxYFxbR2d6eu", + "RootIndex": "61C822FDC1233B7AA70AF9BC588A33808F8EB3D8A6721E080955DF98E9FFCA78" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "8907AEC6B53BD47E48F1449F7F698DAD2080B3E019D3C8261C0A16BBECB2040E", + "PreviousTxnID": "71AC45353ABC229B274C4C3B36BA428A9D2DF947C7B398266F8C3BEC6665338F", + "PreviousTxnLgrSeq": 98408895 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rstMNz7rAq4M5YksFxz9pkjxYFxbR2d6eu", + "Balance": "98240597", + "Flags": 0, + "OwnerCount": 45, + "Sequence": 97584376 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "A440B4C9FFAB6DB6549953DB6FFC79007B1E79536791AEC56FB47F6B485C0028", + "PreviousFields": { + "Balance": "98240647", + "OwnerCount": 44, + "Sequence": 97584375 + }, + "PreviousTxnID": "71AC45353ABC229B274C4C3B36BA428A9D2DF947C7B398266F8C3BEC6665338F", + "PreviousTxnLgrSeq": 98408895 + } + } + ], + "TransactionIndex": 1, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rMdpzP6SSRcNDSgHQGGx4hV3qeRiuRoqQM", + "Fee": "15", + "Flags": 524288, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "58314730376C476F493643436136767476346D372D", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 96276976, + "Sequence": 96276980, + "SigningPubKey": "02F9157DFDE7BE71E317AE313431F615F689CA3D35B6526576DCCB06A300BB0B00", + "TakerGets": { + "currency": "534F4C4F00000000000000000000000000000000", + "issuer": "rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz", + "value": "30.054803" + }, + "TakerPays": "3525519", + "TransactionType": "OfferCreate", + "TxnSignature": "3044022005ECC838164D5987BD86AF789D2A3FD3138AC1D4103205A0A8DF0DB2C11A405C02204501F19BF9877F9B98878EB5E4A252D14E3B5512704784EE74FCCB7569E95775", + "hash": "809DD7B624D92B0C319268363FAEEACE77329D9BF31319305CDDBCAC475D41E7", + "metaData": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "10E0E2513E63D88BBF081404D91600BE60316717FD9D26565DF861F9B12029CF", + "NewFields": { + "Account": "rMdpzP6SSRcNDSgHQGGx4hV3qeRiuRoqQM", + "BookDirectory": "5C8970D155D65DB8FF49B291D7EFFA4A09F9E8A68D9974B25A042ADD5D429937", + "Flags": 131072, + "Sequence": 96276980, + "TakerGets": { + "currency": "534F4C4F00000000000000000000000000000000", + "issuer": "rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz", + "value": "30.054803" + }, + "TakerPays": "3525519" + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "0", + "IndexPrevious": "0", + "Owner": "rMdpzP6SSRcNDSgHQGGx4hV3qeRiuRoqQM", + "RootIndex": "1261DEF9887726607C6C931B6CB1FC7718B674821432D3E138A29F442DF00E45" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "1261DEF9887726607C6C931B6CB1FC7718B674821432D3E138A29F442DF00E45", + "PreviousTxnID": "C34624FDE1FB56BCA8EBB13360B70F003C18474FC07C49693DFED5AB53AB8378", + "PreviousTxnLgrSeq": 98408890 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rMdpzP6SSRcNDSgHQGGx4hV3qeRiuRoqQM", + "Balance": "7794882509", + "Flags": 0, + "OwnerCount": 15, + "Sequence": 96276981 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "2400FD7BAEE16FC502375E649183943F0987A083A221E3F964C1F29420326ACC", + "PreviousFields": { + "Balance": "7794882524", + "Sequence": 96276980 + }, + "PreviousTxnID": "C34624FDE1FB56BCA8EBB13360B70F003C18474FC07C49693DFED5AB53AB8378", + "PreviousTxnLgrSeq": 98408890 + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "5a042a027705b3e6", + "Flags": 0, + "PreviousTxnID": "913389F614EB8BD2E4F8D11D0083622B2874A4CBEB3FB5A375220881BCE5AD00", + "PreviousTxnLgrSeq": 98408890, + "RootIndex": "5C8970D155D65DB8FF49B291D7EFFA4A09F9E8A68D9974B25A042A027705B3E6", + "TakerGetsCurrency": "534F4C4F00000000000000000000000000000000", + "TakerGetsIssuer": "1EB3EAA3AD86242E1D51DC502DD6566BD39E06A6", + "TakerPaysCurrency": "0000000000000000000000000000000000000000", + "TakerPaysIssuer": "0000000000000000000000000000000000000000" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "5C8970D155D65DB8FF49B291D7EFFA4A09F9E8A68D9974B25A042A027705B3E6" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "5C8970D155D65DB8FF49B291D7EFFA4A09F9E8A68D9974B25A042ADD5D429937", + "NewFields": { + "ExchangeRate": "5a042add5d429937", + "RootIndex": "5C8970D155D65DB8FF49B291D7EFFA4A09F9E8A68D9974B25A042ADD5D429937", + "TakerGetsCurrency": "534F4C4F00000000000000000000000000000000", + "TakerGetsIssuer": "1EB3EAA3AD86242E1D51DC502DD6566BD39E06A6" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rMdpzP6SSRcNDSgHQGGx4hV3qeRiuRoqQM", + "BookDirectory": "5C8970D155D65DB8FF49B291D7EFFA4A09F9E8A68D9974B25A042A027705B3E6", + "BookNode": "0", + "Flags": 131072, + "OwnerNode": "0", + "PreviousTxnID": "913389F614EB8BD2E4F8D11D0083622B2874A4CBEB3FB5A375220881BCE5AD00", + "PreviousTxnLgrSeq": 98408890, + "Sequence": 96276976, + "TakerGets": { + "currency": "534F4C4F00000000000000000000000000000000", + "issuer": "rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz", + "value": "29.920382" + }, + "TakerPays": "3506938" + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "601BA030883FCA9222A151FD940E22FE86330743F607DCBD7182FB13946D7548" + } + } + ], + "TransactionIndex": 39, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Amount": "1000000000000", + "DeliverMax": "1000000000000", + "DeliverMin": "10", + "Destination": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Fee": "15", + "Flags": 131072, + "Memos": [ + { + "Memo": { + "MemoData": "486F72697A6F6E20566F6C756D6520426F6F73746572" + } + } + ], + "SendMax": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "9999999999999999" + }, + "Sequence": 98344676, + "SigningPubKey": "03E1C4ED711F104C72600492BD620A6C409C608CB47A17F59E27BDFA601E8BAF6A", + "SourceTag": 111, + "TransactionType": "Payment", + "TxnSignature": "3045022100FB04D8B954DA33ACBEC131AC06A6DF81B6D0B14D9BD7AA33B5DF8AF099E39E890220072C2230CBFAFACE839C96973D535384E303D0697DAB20EE9FCDB8D682136400", + "hash": "8709E1902FE6E42E576F5BDDA06A7A32CE8DBF889637F0AAE32A30515A4DC767", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "0" + }, + "HighNode": "7", + "LowLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "value": "9999999999999999" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "8ADE0DE31774A01BBE605A7F2D4BA867755E5EA5F280AC6115B3FF6FECAC5F60", + "PreviousFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.74736141016" + } + }, + "PreviousTxnID": "1CE90E0CA5F9E0A85762BEF5894EC07C7EFA39261F90F345FCB4940635AAB138", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "8AD1706F10A3BCB0F1F479F9B412C5B0237C7AD107532B40FF1024B1402BB12B", + "Account": "rWTtj1yWvCuT5eVYUFSNm3bVY1812uqVL", + "Balance": "1060913551", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 95675332 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "91FB211B3C00EB92FD003A52CC42E14FA61137A727120C9A15E61FA15BB115BB", + "PreviousFields": { + "Balance": "1061116960" + }, + "PreviousTxnID": "1CE90E0CA5F9E0A85762BEF5894EC07C7EFA39261F90F345FCB4940635AAB138", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "14234.31506532059" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "0" + }, + "HighNode": "2", + "LowLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rWTtj1yWvCuT5eVYUFSNm3bVY1812uqVL", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "A46D98A42D1C0FF60B1B40426015A87C98EBCA4BE1887AADE310E257761845D1", + "PreviousFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "14231.56770391043" + } + }, + "PreviousTxnID": "1CE90E0CA5F9E0A85762BEF5894EC07C7EFA39261F90F345FCB4940635AAB138", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Balance": "10803997", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98344677 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "C6085F1779BE2722C49A2150C097D0E08071583E428BEA5046A72D691EBEE423", + "PreviousFields": { + "Balance": "10600603", + "Sequence": 98344676 + }, + "PreviousTxnID": "1CE90E0CA5F9E0A85762BEF5894EC07C7EFA39261F90F345FCB4940635AAB138", + "PreviousTxnLgrSeq": 98408898 + } + } + ], + "DeliveredAmount": "203409", + "TransactionIndex": 6, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "203409" + } + }, + { + "Account": "rGUxAD5tfV6uQn4Fndrqbdj1TLoPNzcJhh", + "Amount": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rUjp8dVpWFr7WJa93uHjSN16fZgiJivPwa", + "value": "11618110" + }, + "DeliverMax": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rUjp8dVpWFr7WJa93uHjSN16fZgiJivPwa", + "value": "11618110" + }, + "Destination": "rNToYZPtSq2KHMHktC3VkTwmNgYcQf6Zqv", + "Fee": "12", + "Flags": 0, + "LastLedgerSequence": 98409216, + "Sequence": 95135105, + "SigningPubKey": "EDA48E10FEB9C0C69AB3B394B60689F0B0234BED40360A6DFF3D7E917CE0241467", + "TransactionType": "Payment", + "TxnSignature": "2B94407E1750480D397B93E0FCFE8A7727EBDAB18E0213D4F85107B51D5E95E9A75BD4765112414F47521DA1BCB787B57314DB3B8771CD395FA4484BC166B80A", + "hash": "93E142E6256A742CB219FB8EFD16C8686B64C8827392353821CF8D7009085A41", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-12267609.28513504" + }, + "Flags": 2228224, + "HighLimit": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rNToYZPtSq2KHMHktC3VkTwmNgYcQf6Zqv", + "value": "99989357062.514" + }, + "HighNode": "0", + "LowLimit": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rUjp8dVpWFr7WJa93uHjSN16fZgiJivPwa", + "value": "0" + }, + "LowNode": "9c" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "4DEC1BE2C5106214914D60D1CB0D82ED9E68AA180A77072A92C8022155C3A93A", + "PreviousFields": { + "Balance": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-649499.28513504" + } + }, + "PreviousTxnID": "86576813D4A42CB99B817C17244F67FC6EF42037F5DA615CC02D379BD3EF2B24", + "PreviousTxnLgrSeq": 98408889 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGUxAD5tfV6uQn4Fndrqbdj1TLoPNzcJhh", + "Balance": "7016008", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 95135106 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "780BB4426EA00393847F8EDBA51344EB1B47C475533E9E1EDBC84C243CF8B4BB", + "PreviousFields": { + "Balance": "7016020", + "Sequence": 95135105 + }, + "PreviousTxnID": "183E7EBD108834D011C860FC9192D5A922158A0ACD2F0EB41FFF51A0B2441D66", + "PreviousTxnLgrSeq": 98408896 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-12267610.5319083" + }, + "Flags": 2228224, + "HighLimit": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rGUxAD5tfV6uQn4Fndrqbdj1TLoPNzcJhh", + "value": "99989357062.514" + }, + "HighNode": "0", + "LowLimit": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rUjp8dVpWFr7WJa93uHjSN16fZgiJivPwa", + "value": "0" + }, + "LowNode": "9c" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "9300C8D17899CA84718CBC903E9C55E6314A6255E179349E366BF6492B9F9026", + "PreviousFields": { + "Balance": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-23885720.5319083" + } + }, + "PreviousTxnID": "86576813D4A42CB99B817C17244F67FC6EF42037F5DA615CC02D379BD3EF2B24", + "PreviousTxnLgrSeq": 98408889 + } + } + ], + "TransactionIndex": 30, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "4250554700000000000000000000000000000000", + "issuer": "rUjp8dVpWFr7WJa93uHjSN16fZgiJivPwa", + "value": "11618110" + } + } + }, + { + "Account": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Amount": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "9999999999999999" + }, + "DeliverMax": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "9999999999999999" + }, + "Destination": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Fee": "15", + "Flags": 131072, + "Memos": [ + { + "Memo": { + "MemoData": "486F72697A6F6E20566F6C756D6520426F6F73746572" + } + } + ], + "SendMax": "206212", + "Sequence": 98344677, + "SigningPubKey": "03E1C4ED711F104C72600492BD620A6C409C608CB47A17F59E27BDFA601E8BAF6A", + "SourceTag": 111, + "TransactionType": "Payment", + "TxnSignature": "3045022100F99FEB6F5AA8F7B3DCE2C072A767C95435859067AB200A399F50E732445EF548022031AFE53A8FC09E9FAE97BA34B6D816D1EFFDBAB5545215AF0B4444D008528D38", + "hash": "9BEC1CD679B5EF0C28C91C0D152B9AE55D90E6F2D266ED81CE1C80AA0010BEDD", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "2.74735415286" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "0" + }, + "HighNode": "7", + "LowLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "value": "9999999999999999" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "8ADE0DE31774A01BBE605A7F2D4BA867755E5EA5F280AC6115B3FF6FECAC5F60", + "PreviousFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + } + }, + "PreviousTxnID": "8709E1902FE6E42E576F5BDDA06A7A32CE8DBF889637F0AAE32A30515A4DC767", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "8AD1706F10A3BCB0F1F479F9B412C5B0237C7AD107532B40FF1024B1402BB12B", + "Account": "rWTtj1yWvCuT5eVYUFSNm3bVY1812uqVL", + "Balance": "1061119763", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 95675332 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "91FB211B3C00EB92FD003A52CC42E14FA61137A727120C9A15E61FA15BB115BB", + "PreviousFields": { + "Balance": "1060913551" + }, + "PreviousTxnID": "8709E1902FE6E42E576F5BDDA06A7A32CE8DBF889637F0AAE32A30515A4DC767", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "14231.56771116773" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "0" + }, + "HighNode": "2", + "LowLimit": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rWTtj1yWvCuT5eVYUFSNm3bVY1812uqVL", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "A46D98A42D1C0FF60B1B40426015A87C98EBCA4BE1887AADE310E257761845D1", + "PreviousFields": { + "Balance": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "14234.31506532059" + } + }, + "PreviousTxnID": "8709E1902FE6E42E576F5BDDA06A7A32CE8DBF889637F0AAE32A30515A4DC767", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rtoHt9y55yK4egAsQa7spTAvrG3arijNn", + "Balance": "10597770", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98344678 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "C6085F1779BE2722C49A2150C097D0E08071583E428BEA5046A72D691EBEE423", + "PreviousFields": { + "Balance": "10803997", + "Sequence": 98344677 + }, + "PreviousTxnID": "8709E1902FE6E42E576F5BDDA06A7A32CE8DBF889637F0AAE32A30515A4DC767", + "PreviousTxnLgrSeq": 98408898 + } + } + ], + "DeliveredAmount": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "2.74735415286" + }, + "TransactionIndex": 7, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "4455454C53000000000000000000000000000000", + "issuer": "rPR5dpAgG39ZKMw7v8ZH1NwTqV82ciAsX6", + "value": "2.74735415286" + } + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "4D3961376F39476D56766365356B59774931593555", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353817, + "Sequence": 109353836, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": { + "currency": "4555524F50000000000000000000000000000000", + "issuer": "rMkEuRii9w9uBMQDnWV5AA43gvYZR9JxVK", + "value": "8600.6142178402" + }, + "TakerPays": "3518007730", + "TransactionType": "OfferCreate", + "TxnSignature": "3045022100C7E0C7E5B35A5DF8CBD6C20589E57D0DEF95CE017704BA0BCEE554437C84BB56022069E5020B846AFB069034DBB59D772C96F5B5424C80F98624399E239FB481C441", + "hash": "9CD8CEEBBB8C19183A8FC2B15457BC3C580548BA6056DA33AF9C247684AC4F2F", + "metaData": { + "AffectedNodes": [ + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "666F5BA8B27951743F287FFAA52757673DE42ECBB7EB2AC45A0E870DDF4A78B3", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "5", + "PreviousTxnID": "A99484D0ED79080C88E4016FEEB3B5A3E6EF18A95DF5D37520294FC214DD367F", + "PreviousTxnLgrSeq": 98408894, + "Sequence": 109353817, + "TakerGets": { + "currency": "4555524F50000000000000000000000000000000", + "issuer": "rMkEuRii9w9uBMQDnWV5AA43gvYZR9JxVK", + "value": "8600.763071695" + }, + "TakerPays": "3516975290" + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "3DC9A485F97EA212D860706FBB62A4B077761B76C54CEEAA76572AADAA25F5BC" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "BC1ED529021DAADC8086D03C8EAE8891B0E32E5905E7D78D3524447CEDBC3800", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "5a0e870ddf4a78b3", + "Flags": 0, + "PreviousTxnID": "A99484D0ED79080C88E4016FEEB3B5A3E6EF18A95DF5D37520294FC214DD367F", + "PreviousTxnLgrSeq": 98408894, + "RootIndex": "666F5BA8B27951743F287FFAA52757673DE42ECBB7EB2AC45A0E870DDF4A78B3", + "TakerGetsCurrency": "4555524F50000000000000000000000000000000", + "TakerGetsIssuer": "E390D7DD0750ED943B144DFF17B3FD9A9FFBA1EF", + "TakerPaysCurrency": "0000000000000000000000000000000000000000", + "TakerPaysIssuer": "0000000000000000000000000000000000000000" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "666F5BA8B27951743F287FFAA52757673DE42ECBB7EB2AC45A0E870DDF4A78B3" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "666F5BA8B27951743F287FFAA52757673DE42ECBB7EB2AC45A0E8835D89AA86A", + "NewFields": { + "ExchangeRate": "5a0e8835d89aa86a", + "RootIndex": "666F5BA8B27951743F287FFAA52757673DE42ECBB7EB2AC45A0E8835D89AA86A", + "TakerGetsCurrency": "4555524F50000000000000000000000000000000", + "TakerGetsIssuer": "E390D7DD0750ED943B144DFF17B3FD9A9FFBA1EF" + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "68BF08E3FE50B0818B2098F3E79BB936DE83BE22388E904A9DA1D8DE214CDA67", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "666F5BA8B27951743F287FFAA52757673DE42ECBB7EB2AC45A0E8835D89AA86A", + "OwnerNode": "5", + "Sequence": 109353836, + "TakerGets": { + "currency": "4555524F50000000000000000000000000000000", + "issuer": "rMkEuRii9w9uBMQDnWV5AA43gvYZR9JxVK", + "value": "8600.6142178402" + }, + "TakerPays": "3518007730" + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082143", + "Flags": 0, + "OwnerCount": 50, + "Sequence": 109353837 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082158", + "Sequence": 109353836 + }, + "PreviousTxnID": "BC1ED529021DAADC8086D03C8EAE8891B0E32E5905E7D78D3524447CEDBC3800", + "PreviousTxnLgrSeq": 98408898 + } + } + ], + "TransactionIndex": 27, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408900, + "Memos": [ + { + "Memo": { + "MemoData": "2D534B79727073522D3252702D7342376676774F77", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353824, + "Sequence": 109353837, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "9999.4745592644" + }, + "TakerPays": "3518129720", + "TransactionType": "OfferCreate", + "TxnSignature": "304402201CAD5F82BA1EBC5C4063984101018E98C7DCD3F5127936FD62468ED8EE41C34E02203CB01A2A4801155E69FDD7E4DDEECC6B9A08CCA68C27FBE4649AFF1B3333CCC2", + "hash": "9ECE014E56D0C3D625FE6FC2504DAB62408A7634AEEDD843E577F23A0ACD2A53", + "metaData": { + "AffectedNodes": [ + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "CFEC8953D22B1B50F20F46ECBCD51990A26BDB71951ED8265A0C7EFD0B2F4200", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "5", + "PreviousTxnID": "404024D6797A9BE5A2FBA9140F70F1A2D706D5F80AFBBC2F1A3787E2C8F63AFB", + "PreviousTxnLgrSeq": 98408896, + "Sequence": 109353824, + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "9999.472752731124" + }, + "TakerPays": "3517139550" + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "482C141A9951C8C13AE6E73CD26C4695C9B662AE4C79681BE3F7153CE5E9B4C6" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "9CD8CEEBBB8C19183A8FC2B15457BC3C580548BA6056DA33AF9C247684AC4F2F", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082128", + "Flags": 0, + "OwnerCount": 50, + "Sequence": 109353838 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082143", + "Sequence": 109353837 + }, + "PreviousTxnID": "9CD8CEEBBB8C19183A8FC2B15457BC3C580548BA6056DA33AF9C247684AC4F2F", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "5a0c7efd0b2f4200", + "Flags": 0, + "PreviousTxnID": "404024D6797A9BE5A2FBA9140F70F1A2D706D5F80AFBBC2F1A3787E2C8F63AFB", + "PreviousTxnLgrSeq": 98408896, + "RootIndex": "CFEC8953D22B1B50F20F46ECBCD51990A26BDB71951ED8265A0C7EFD0B2F4200", + "TakerGetsCurrency": "524C555344000000000000000000000000000000", + "TakerGetsIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E", + "TakerPaysCurrency": "0000000000000000000000000000000000000000", + "TakerPaysIssuer": "0000000000000000000000000000000000000000" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "CFEC8953D22B1B50F20F46ECBCD51990A26BDB71951ED8265A0C7EFD0B2F4200" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "CFEC8953D22B1B50F20F46ECBCD51990A26BDB71951ED8265A0C7FE38BC86E00", + "NewFields": { + "ExchangeRate": "5a0c7fe38bc86e00", + "RootIndex": "CFEC8953D22B1B50F20F46ECBCD51990A26BDB71951ED8265A0C7FE38BC86E00", + "TakerGetsCurrency": "524C555344000000000000000000000000000000", + "TakerGetsIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E" + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "FBB39BE36BC312662B4E21E134D56D207AE793178E4DCF6D0F0B24ABD6D4DEA8", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "CFEC8953D22B1B50F20F46ECBCD51990A26BDB71951ED8265A0C7FE38BC86E00", + "OwnerNode": "5", + "Sequence": 109353837, + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "9999.47338427628" + }, + "TakerPays": "3518129720" + } + } + } + ], + "TransactionIndex": 28, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408900, + "OfferSequence": 188944817, + "Sequence": 188944831, + "SigningPubKey": "0253C1DFDCF898FE85F16B71CCE80A5739F7223D54CC9EBA4749616593470298C5", + "TakerGets": "11856000000", + "TakerPays": { + "currency": "USD", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "34145.2728864" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "304402207979F2F21E9433C8188EA89F0DFC4BED67B2757FE683BAFF0C4D9FDEE6CC62160220054048221F4E20646DBE5845271BED4E9401375DF6D14DEA25AE3D4C9EF00300", + "hash": "AA2C0DB50A75B38FAD691D9BDB6783B95F6E37D1E04286FB729DEC9976D93EF2", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "0", + "IndexPrevious": "0", + "Owner": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "RootIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3", + "PreviousTxnID": "1E9416E672DA4CBA2BE158C02E188CECE947422E327191C1026520C0F3DE02DD", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Balance": "11956860927", + "Flags": 0, + "OwnerCount": 22, + "Sequence": 188944832 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1ED8DDFD80F275CB1CE7F18BB9D906655DE8029805D8B95FB9020B30425821EB", + "PreviousFields": { + "Balance": "11956860942", + "Sequence": 188944831 + }, + "PreviousTxnID": "1E9416E672DA4CBA2BE158C02E188CECE947422E327191C1026520C0F3DE02DD", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4F0A3A08ACC752FF", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "0", + "PreviousTxnID": "165B1402B5EDB1C1CDF3A882EA02370C1C3C5AF3E20387B65406F46C65D22E0A", + "PreviousTxnLgrSeq": 98408895, + "Sequence": 188944817, + "TakerGets": "11856000000", + "TakerPays": { + "currency": "USD", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "34128.19194719999" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "61DFBCA3D8E8EDCE89B23763AFDC6188C734F63BC392DFF5EB10A07994349845" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "994152E77B6F188C7A1BFF76C3A7BD26CF19518E6E63B8E933CC765251F70D0C", + "NewFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4F0A3B581D30BA00", + "Sequence": 188944831, + "TakerGets": "11856000000", + "TakerPays": { + "currency": "USD", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "34145.2728864" + } + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "4f0a3a08acc752ff", + "Flags": 0, + "PreviousTxnID": "165B1402B5EDB1C1CDF3A882EA02370C1C3C5AF3E20387B65406F46C65D22E0A", + "PreviousTxnLgrSeq": 98408895, + "RootIndex": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4F0A3A08ACC752FF", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "0000000000000000000000005553440000000000", + "TakerPaysIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4F0A3A08ACC752FF" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4F0A3B581D30BA00", + "NewFields": { + "ExchangeRate": "4f0a3b581d30ba00", + "RootIndex": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4F0A3B581D30BA00", + "TakerPaysCurrency": "0000000000000000000000005553440000000000", + "TakerPaysIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1" + } + } + } + ], + "TransactionIndex": 9, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 524288, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "4354775555624366687A5F32444975624332663947", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353821, + "Sequence": 109353830, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": "28139270310", + "TakerPays": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "80124.0396733971" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "304402206684804B72A2CBC253AC5A5C3F710844D7BF8637FE58ADBB98F86B4DA44FB085022044580683FA8310ACFA8DC9D8F40F3C49EDA7DBE57022F3EAEA864DE67990CBF8", + "hash": "AE4D50A003A65C7056B521CA14BCA418C67A1BA0601D96C672FBF22246BD2875", + "metaData": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1DB44D947400", + "NewFields": { + "ExchangeRate": "4f0a1db44d947400", + "RootIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1DB44D947400", + "TakerPaysCurrency": "5553444300000000000000000000000000000000", + "TakerPaysIssuer": "ACF3278B42F2ACC71989C99661DAB9AF8C081981" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "4f0a1ed2af4b0000", + "Flags": 0, + "PreviousTxnID": "4699FA8FB72170DFA18C85E22A1AF53CF761D91515ACC0898CF7A685405528DC", + "PreviousTxnLgrSeq": 98408895, + "RootIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1ED2AF4B0000", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "5553444300000000000000000000000000000000", + "TakerPaysIssuer": "ACF3278B42F2ACC71989C99661DAB9AF8C081981" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1ED2AF4B0000" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "65D4324CC71F40C3B513DD56A1E1D57AB5D71D1B6E765D06CD6731A2E141324D", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "68A18A54181B054855E5428B40C1DB5A7F355C3F6C892968963C46C6CFBD5C83", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1DB44D947400", + "Flags": 131072, + "OwnerNode": "5", + "Sequence": 109353830, + "TakerGets": "28139270310", + "TakerPays": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "80124.0396733971" + } + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082233", + "Flags": 0, + "OwnerCount": 49, + "Sequence": 109353831 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082248", + "Sequence": 109353830 + }, + "PreviousTxnID": "65D4324CC71F40C3B513DD56A1E1D57AB5D71D1B6E765D06CD6731A2E141324D", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "29958A9E8FDB4462AB486E269C735F52ADDAEDF354F9F40E4F0A1ED2AF4B0000", + "BookNode": "0", + "Flags": 131072, + "OwnerNode": "5", + "PreviousTxnID": "4699FA8FB72170DFA18C85E22A1AF53CF761D91515ACC0898CF7A685405528DC", + "PreviousTxnLgrSeq": 98408895, + "Sequence": 109353821, + "TakerGets": "28127197280", + "TakerPays": { + "currency": "5553444300000000000000000000000000000000", + "issuer": "rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE", + "value": "80124.2592596992" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "B45E3BF593F3F718A114886678AB94539DEC5E4DE291AF8FB8D0F1FAE3030FAC" + } + } + ], + "TransactionIndex": 21, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rP4xfxoUTCHsec6TerwDtJr2TLbbBvo4BY", + "Fee": "12", + "Flags": 0, + "LastLedgerSequence": 98409196, + "Sequence": 95416665, + "SigningPubKey": "EDD6BEFCF911C867701D7CBBE642EF22BF03CB00C0540F5C4E064CE2D21DD62689", + "TakerGets": { + "currency": "436152526F747300000000000000000000000000", + "issuer": "rafgLy8LPU3mqMGCRbkJcaGWuDwoUCAuwQ", + "value": "2095263.43" + }, + "TakerPays": "625383", + "TransactionType": "OfferCreate", + "TxnSignature": "D14501DF85315BDDB1BF77E8DA62DF43AE3B71E5CBB65DF371EE122EEEDAB24C011DAE2A91B0B012643BEFFEB10BB5701C667BF8CD6BCC409330E40CF5900C0E", + "hash": "AF11E9C189A01D5516626407B7F219AE089BD7A480F79F07E088D0AD155E4647", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rP4xfxoUTCHsec6TerwDtJr2TLbbBvo4BY", + "RootIndex": "27C301633F51E58333AA9EDA81D8C9CF0EE39E1984270FEF05E7D949851B9FC6" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "27C301633F51E58333AA9EDA81D8C9CF0EE39E1984270FEF05E7D949851B9FC6", + "PreviousTxnID": "B55A695199CA63B590708B5DCDA6525D80201367FE1A43D29196614812B94650", + "PreviousTxnLgrSeq": 98408880 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rP4xfxoUTCHsec6TerwDtJr2TLbbBvo4BY", + "Balance": "3509479", + "Flags": 0, + "OwnerCount": 2, + "Sequence": 95416666 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "9AD247A9742323B5AE24439A5CBD883FFDFA1BE21DE7D9A6A52CAD3B94DF91A7", + "PreviousFields": { + "Balance": "3509491", + "OwnerCount": 1, + "Sequence": 95416665 + }, + "PreviousTxnID": "E434100099D8F5715E5A54DCAC907ACD37B83108D7EEC6AC3B78B3E6B48BFF67", + "PreviousTxnLgrSeq": 98408890 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "C4688C13167914EFC81522BB993855D3719E5E64F053C1BAD92AC8067CA6ABB3", + "NewFields": { + "Account": "rP4xfxoUTCHsec6TerwDtJr2TLbbBvo4BY", + "BookDirectory": "E171F1DCFACF2DCD3BF7B9330B3F772F84B4DE708DC0533B540A9A9C6D7E2B77", + "Sequence": 95416665, + "TakerGets": { + "currency": "436152526F747300000000000000000000000000", + "issuer": "rafgLy8LPU3mqMGCRbkJcaGWuDwoUCAuwQ", + "value": "2095263.43" + }, + "TakerPays": "625383" + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "E171F1DCFACF2DCD3BF7B9330B3F772F84B4DE708DC0533B540A9A9C6D7E2B77", + "NewFields": { + "ExchangeRate": "540a9a9c6d7e2b77", + "RootIndex": "E171F1DCFACF2DCD3BF7B9330B3F772F84B4DE708DC0533B540A9A9C6D7E2B77", + "TakerGetsCurrency": "436152526F747300000000000000000000000000", + "TakerGetsIssuer": "381BF7C170153EF109F6F6A4A9342211319FA876" + } + } + } + ], + "TransactionIndex": 38, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rhQF61no1uX1rgkAnxD48r5X9MS22JoF1L", + "Fee": "12", + "Flags": 2147483648, + "LastLedgerSequence": 98409193, + "LimitAmount": { + "currency": "GNO", + "issuer": "rGXi5T9Tr57CJvxQLqgE5dDewbRZzRXT8n", + "value": "0" + }, + "Memos": [ + { + "Memo": { + "MemoData": "5472616E73616374696F6E20696E69746961746564207669612058506D61726B65742E636F6D" + } + } + ], + "Sequence": 68859938, + "SigningPubKey": "02E287215B51361E184247EE06F1DC80FE6D778C5BBB2B49A0EC26D5C1A77E958E", + "SourceTag": 20221212, + "TransactionType": "TrustSet", + "TxnSignature": "3044022025452B19CBC766687D3D7EB3A76488C6712754245203197D90F701AEBB705BA4022030C18BA1A44E01AA020D8F453EE57474B56C6B7D7AFCFFACA9C0693B2C86028C", + "hash": "B0B40A958EFBA6A8133C8318F87886205CA03D217803D1413DD9C71B55B6B60C", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "6", + "IndexPrevious": "4", + "Owner": "rGXi5T9Tr57CJvxQLqgE5dDewbRZzRXT8n", + "RootIndex": "DE9A72E2C6C172E8F34CE05C62470DDAD872329E8BE289431830368E0C863717" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "779E7BF4EAA5244C4AA61A458C084DFC351565450B2BDBBFED6ABCC8AF709383", + "PreviousTxnID": "C5CFFDE2891BAE52295B032046019D748FAA8829E2A3A83AD10F73966B8F63DD", + "PreviousTxnLgrSeq": 98401472 + } + }, + { + "ModifiedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "7D1A450E0A4F59D7AD17027B48BAD480B37E6E22F49EBDAB4C899A590F1C4466", + "PreviousTxnID": "E62E73B295F309872D577AB9855785CC26D571CA6D49B1E6A8BDB6444CB25E97", + "PreviousTxnLgrSeq": 98407848 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rhQF61no1uX1rgkAnxD48r5X9MS22JoF1L", + "Balance": "141779488", + "FirstNFTokenSequence": 68858762, + "Flags": 0, + "MintedNFTokens": 1, + "OwnerCount": 74, + "Sequence": 68859939 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "AA37E12B3230F50BA62F3C704B634AC3861F1CC6CBF031355DFD8C451464310B", + "PreviousFields": { + "Balance": "141779500", + "OwnerCount": 75, + "Sequence": 68859938 + }, + "PreviousTxnID": "2877CAC952EE8203880B4632706F911327073479C00186CFB1F41B7E331EC666", + "PreviousTxnLgrSeq": 98408871 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "6", + "IndexPrevious": "4", + "Owner": "rhQF61no1uX1rgkAnxD48r5X9MS22JoF1L", + "RootIndex": "A42AB48DF80D59AC9D6F3CC631795AA4041FD95646DF917BFD9BF47D067C1B3F" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "BCC2FBA5A1067FB54672AFCF4A5C6606A0D6B8CE7564ADAEBC3E33712898A70C", + "PreviousTxnID": "2877CAC952EE8203880B4632706F911327073479C00186CFB1F41B7E331EC666", + "PreviousTxnLgrSeq": 98408871 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Balance": { + "currency": "GNO", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 1048576, + "HighLimit": { + "currency": "GNO", + "issuer": "rGXi5T9Tr57CJvxQLqgE5dDewbRZzRXT8n", + "value": "0" + }, + "HighNode": "5", + "LowLimit": { + "currency": "GNO", + "issuer": "rhQF61no1uX1rgkAnxD48r5X9MS22JoF1L", + "value": "0" + }, + "LowNode": "5", + "PreviousTxnID": "951D477F5494A6565B8CA41525E131DDD442BF32DAD2455FE414226438F6242D", + "PreviousTxnLgrSeq": 98408215 + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "CC7C57F3D062B2FFF8750C86096D3B7F609AFA4D53A3B6ED0D0101873468C154", + "PreviousFields": { + "Flags": 1114112, + "LowLimit": { + "currency": "GNO", + "issuer": "rhQF61no1uX1rgkAnxD48r5X9MS22JoF1L", + "value": "100000000" + } + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rUjfTQpvBr6wsGGxMw6sRmRQGG76nvp8Ln", + "Amount": "68589518", + "DeliverMax": "68589518", + "Destination": "rptNz2nNnaGt6WvWk8FgNjWiSqcNXciLeg", + "Fee": "20", + "Flags": 2147483648, + "LastLedgerSequence": 98408920, + "Sequence": 62582518, + "SigningPubKey": "02143F86B0CABD3B8140B07B4495F05A8A9299151FBDFF85EDE0E0098411585801", + "TransactionType": "Payment", + "TxnSignature": "304402205EEB5F5AF4E1681706A50A0B38CACC4A4640D8A5BB3AD62FAE0B8570DCD895A302203D538747425A878E6B0288ADB22B587B1D5897C9C627D11B3AC5CAC311F3FCBB", + "hash": "B22C396931AFEA02BF63973797FA5852897B511CED373E0D4C248D3AA9A0A086", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rUjfTQpvBr6wsGGxMw6sRmRQGG76nvp8Ln", + "Balance": "1743126064413", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 62582519 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "56B144EDDAE34DBFCC785B3E76C5FA7E4E7B4E518FCFE19A08372C60E35350CD", + "PreviousFields": { + "Balance": "1743194653951", + "Sequence": 62582518 + }, + "PreviousTxnID": "0AB94C13E67DF10F2B87D6CB9CD9F5D2DF19D3B39C6FB200F8F3D6531006637A", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rptNz2nNnaGt6WvWk8FgNjWiSqcNXciLeg", + "Balance": "318083998", + "Flags": 0, + "MintedNFTokens": 2, + "OwnerCount": 36, + "Sequence": 68197979 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "D851AF8F3C91B13A4E045C086F73D8A79A2C867947D7321D5DF05D193B836EB9", + "PreviousFields": { + "Balance": "249494480" + }, + "PreviousTxnID": "C980A9D80F827E5E9F2EDEC80BDFAED9C972467D22632F63F3C8993586537F7A", + "PreviousTxnLgrSeq": 98366097 + } + } + ], + "TransactionIndex": 36, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "68589518" + } + }, + { + "Account": "rLBSbJgwwVbbrf9eLcK23mWeVSDQwV8kYj", + "Amount": "1", + "DeliverMax": "1", + "Destination": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 98408984, + "Sequence": 97552218, + "SigningPubKey": "EDFD6F0E0F7FBFA2026D7D21B3D3FD1718BC4F27013DCA84E492F72457F472E81B", + "TransactionType": "Payment", + "TxnSignature": "B8E70A378425A4F597D0E5856947B7ED5D946CF3C62AD8ADBC180B57309903D73E234A2ABBD1CD120F051FBCE28DD9B4D217B5EC2D98280ED3D3DA61785C1E09", + "hash": "B2D884326E9DAEF43B114533CED8E255330AF4D10BBB825D3248204024F83B06", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLYtnspajSgZBhzoDoJMppwsYNMB8SuaUj", + "Balance": "13997769", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 98427700 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "49C35C3086C2A3C574A6CF34CF61100E862A30B3EA154262A177EFBC3A82DFD2", + "PreviousFields": { + "Balance": "13997768" + }, + "PreviousTxnID": "4D84AF2EB5B5DC5C7AE7FA3E9B6BD7AAFC1CA61C3A6CA5238E8FA9921DB76A9F", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLBSbJgwwVbbrf9eLcK23mWeVSDQwV8kYj", + "Balance": "37841998", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 97552219 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "7EFA8F6FDBEE81E2CF90762AE5F2B3D437D3E9219F105820EED22AB097557635", + "PreviousFields": { + "Balance": "37842010", + "Sequence": 97552218 + }, + "PreviousTxnID": "634D6EE3A2E811E7561136917062BCA9CF2914F3D4A0521B48E1C654310F9DA1", + "PreviousTxnLgrSeq": 98408897 + } + } + ], + "TransactionIndex": 42, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "1" + } + }, + { + "Account": "rEiq1iAcpzP8WLjezP9MzAQEJ7jqKMLFSA", + "Amount": "67185533", + "DeliverMax": "67185533", + "Destination": "rD37r1cciGqmdBpqou2DneEiWA1iQsAphP", + "DestinationTag": 265460, + "Fee": "24", + "Flags": 2147483648, + "LastLedgerSequence": 98422266, + "Sequence": 65954707, + "SigningPubKey": "024979DFC8EA12B95CB832E57D9E11AFABB2F4A8F99736F4AA1050F7C3F4297B77", + "TransactionType": "Payment", + "TxnSignature": "3045022100EBA6AB6725C34E8008FCB1B965CD7A387EDA1359EFA6FDB99B86920D846C470902206AA3E3D4AA0AEBFDAAF1C90940CA6940928A9533F05F54F22A99829C78D78370", + "hash": "BB25A30F4BABDBEBEAB90326D8C765E051B864123538B4855884442C1F67482E", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rEiq1iAcpzP8WLjezP9MzAQEJ7jqKMLFSA", + "Balance": "4810803859031", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 65954708 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "38EDA33EB564190519CE39E829A9643F4E3855E4533BE2CABC6A57BD77846AA0", + "PreviousFields": { + "Balance": "4810871044588", + "Sequence": 65954707 + }, + "PreviousTxnID": "A39C97AC08994922546195F2A1DB1D5B6E3255357472E4F6A3DB9BE60A6DC975", + "PreviousTxnLgrSeq": 98408889 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rD37r1cciGqmdBpqou2DneEiWA1iQsAphP", + "Balance": "309221176505", + "Flags": 131072, + "OwnerCount": 0, + "Sequence": 84022151 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "A950B1061997F92CE4F80448E57F858027C9459B4F05DE374BAAEDC0A9C9D83A", + "PreviousFields": { + "Balance": "309153990972" + }, + "PreviousTxnID": "C6DC0109934953F14B2F07401BE069815101CE1E3B7075D452936FA013A3EE66", + "PreviousTxnLgrSeq": 98408897 + } + } + ], + "TransactionIndex": 32, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "67185533" + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 524288, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "683963363272664C7A7A674F565636524B66744146", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353807, + "Sequence": 109353835, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "8.72" + }, + "TakerPays": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "38035.87264" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "304402203116EEE1EE55AE48527011F9514144597799F305131611BDF7322CD3F5FA615C02207BF75558003026A7EBCF058B7AD1B5BB8E10E826101984865E4B579C9EEF451F", + "hash": "BC1ED529021DAADC8086D03C8EAE8891B0E32E5905E7D78D3524447CEDBC3800", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "C0AAA49E6363D649983C75C96F3D1C2E911D540E202C9376CCF81FB3C4FAA168", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082158", + "Flags": 0, + "OwnerCount": 50, + "Sequence": 109353836 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082173", + "Sequence": 109353835 + }, + "PreviousTxnID": "C0AAA49E6363D649983C75C96F3D1C2E911D540E202C9376CCF81FB3C4FAA168", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F81285C6D4600", + "BookNode": "0", + "Flags": 131072, + "OwnerNode": "5", + "PreviousTxnID": "70760F4F7C6DEB29CDE178AF606166EA3C52141B4EE1930BC1F463D17866BE7B", + "PreviousTxnLgrSeq": 98408892, + "Sequence": 109353807, + "TakerGets": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "8.716" + }, + "TakerPays": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "38037.80066" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "97B7A8DB6F4E9F08D97251C0DFDB5DA0366E2560C004FB23C52481963CDFCCD0" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7F22C74D3000", + "NewFields": { + "ExchangeRate": "580f7f22c74d3000", + "RootIndex": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7F22C74D3000", + "TakerGetsCurrency": "0000000000000000000000004554480000000000", + "TakerGetsIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1", + "TakerPaysCurrency": "524C555344000000000000000000000000000000", + "TakerPaysIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "580f81285c6d4600", + "Flags": 0, + "PreviousTxnID": "70760F4F7C6DEB29CDE178AF606166EA3C52141B4EE1930BC1F463D17866BE7B", + "PreviousTxnLgrSeq": 98408892, + "RootIndex": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F81285C6D4600", + "TakerGetsCurrency": "0000000000000000000000004554480000000000", + "TakerGetsIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1", + "TakerPaysCurrency": "524C555344000000000000000000000000000000", + "TakerPaysIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F81285C6D4600" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "DDB0A7AF597703752749036D4F5547CFE796AD7DFAE84AA8FCB56F95F3E55CF3", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "9FF782B64EB8375E290D6A5843079B5DFD633D087B5DA2F4580F7F22C74D3000", + "Flags": 131072, + "OwnerNode": "5", + "Sequence": 109353835, + "TakerGets": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "8.72" + }, + "TakerPays": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "38035.87264" + } + } + } + } + ], + "TransactionIndex": 26, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408899, + "Memos": [ + { + "Memo": { + "MemoData": "2D6E37576E36702D614A6A424A454553512D586B66", + "MemoType": "696E7465726E616C6F726465726964" + } + } + ], + "OfferSequence": 109353790, + "Sequence": 109353834, + "SigningPubKey": "034D6788B751D18BBE92CAF1255431512D6D187446D47FAEE20FDB0BFA8144DB1E", + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "37956.07592" + }, + "TakerPays": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "8.72" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "3045022100835B674B3BF1C110E58CD951A49E8465EBEE8DE6F273FBFEFB6748CB57B070E9022007166885A8AF0E1A714AC633D3F402B8564B3268311A941FB97EEA254DC30607", + "hash": "C0AAA49E6363D649983C75C96F3D1C2E911D540E202C9376CCF81FB3C4FAA168", + "metaData": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "2D3C2B061883642D49F2BCE322C3CF757C5B0BEBDEEC8A842B3492983220F587", + "NewFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "BookDirectory": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE510829777DACAA00", + "OwnerNode": "5", + "Sequence": 109353834, + "TakerGets": { + "currency": "524C555344000000000000000000000000000000", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + "value": "37956.06585377426" + }, + "TakerPays": { + "currency": "ETH", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "8.72" + } + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE510829777DACAA00", + "NewFields": { + "ExchangeRate": "510829777dacaa00", + "RootIndex": "4327BA72D34E3964EE58A909DB6EB2ACB4F16B440CC3A8DE510829777DACAA00", + "TakerGetsCurrency": "524C555344000000000000000000000000000000", + "TakerGetsIssuer": "E5E961C6A025C9404AA7B662DD1DF975BE75D13E", + "TakerPaysCurrency": "0000000000000000000000004554480000000000", + "TakerPaysIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1" + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexPrevious": "2", + "Owner": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "RootIndex": "49B537464A9659478275132402EB6D5E8723F42ED20DB3CF4A4527A9A3F1589A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "66402952B876534E5009F6225EF64515CBDDABEFE234654CB6CD5D9DEA34034F", + "PreviousTxnID": "0DDD66247083D226E421AD26830393F8061AD0A87F95559255661360AFD90C96", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfmdBKhtJw2J22rw1JxQcchQTM68qzE4N2", + "Balance": "74617082173", + "Flags": 0, + "OwnerCount": 50, + "Sequence": 109353835 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "6BC9286C5146B76D0D140873B509D72581AABEB79037BF1D9849830FBF5A9FE6", + "PreviousFields": { + "Balance": "74617082188", + "OwnerCount": 49, + "Sequence": 109353834 + }, + "PreviousTxnID": "0DDD66247083D226E421AD26830393F8061AD0A87F95559255661360AFD90C96", + "PreviousTxnLgrSeq": 98408898 + } + } + ], + "TransactionIndex": 25, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rGuhZqkVANWyHQUSWQzCLPKnho9gzVUPng", + "Amount": "7217", + "DeliverMax": "7217", + "Destination": "rGuhZqkVANWyHQUSWQzCLPKnho9gzVUPng", + "Fee": "12", + "Flags": 131072, + "LastLedgerSequence": 98408916, + "SendMax": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "36891845.1064309" + }, + "Sequence": 96039117, + "SigningPubKey": "0205F767BCAE8D6A354DB0102DA6937A694FE7F16A1A9EFE9A3D2836BA7215B76D", + "TransactionType": "Payment", + "TxnSignature": "3044022045BBEACCCF4A7F21839A9D8EC2260C670B7CC0F37B3E22420237C8AA9AEC39EE02205850639F4BBA0EB6410A3D7A3DE1CB52AC72147DF4E5A77F8D94129D7746D6E3", + "hash": "C5A5E0DE264FB4AEB3E16C5865AA48A98DF48DFABD2D062FB7FD92181F201FB3", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "FIN", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-50093376240.03435" + }, + "Flags": 16908288, + "HighLimit": { + "currency": "FIN", + "issuer": "rPEG6prbpC9faLqACgiESJMsBKQGXKex1m", + "value": "0" + }, + "HighNode": "0", + "LowLimit": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "3E21CC8FCE69FB0DF70D18059BC9C86961C935BE400234536AF0185B8D5E7086", + "PreviousFields": { + "Balance": { + "currency": "FIN", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-50093200564.5817" + } + }, + "PreviousTxnID": "EC58F9B04BD0DF27621A47717719775D89426DA5C721AFD2D02F12DE90420793", + "PreviousTxnLgrSeq": 98408778 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "FIN", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + }, + "Flags": 2228224, + "HighLimit": { + "currency": "FIN", + "issuer": "rGuhZqkVANWyHQUSWQzCLPKnho9gzVUPng", + "value": "1000000000000000e-4" + }, + "HighNode": "1d", + "LowLimit": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "0" + }, + "LowNode": "8d" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "4D8DDE9DF87E5A8474A420BC469D444B9056FD91FDA40EC8F07BFB18FA26FCA0", + "PreviousFields": { + "Balance": { + "currency": "FIN", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-175675.4526524" + } + }, + "PreviousTxnID": "F21021CAC0377119D06322AF8F0657124F11BEF618C73E90B711DB7304C67ABB", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGuhZqkVANWyHQUSWQzCLPKnho9gzVUPng", + "Balance": "202749949", + "Flags": 0, + "OwnerCount": 964, + "Sequence": 96039118 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "8FD0B11C49AA6AD9AD513A13D7619D1AEC17333525F4CE633EFF412CFB1376FC", + "PreviousFields": { + "Balance": "202742885", + "Sequence": 96039117 + }, + "PreviousTxnID": "F21021CAC0377119D06322AF8F0657124F11BEF618C73E90B711DB7304C67ABB", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "09005943DD9E5CD13272F175008E27DFE55E4D3EF861992AA82B468A15ABE472", + "Account": "rPEG6prbpC9faLqACgiESJMsBKQGXKex1m", + "Balance": "2017914641", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 91650029 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "C602C9694A966BF404335F6241CF4D88A7BA98CAB7CC47CDBEF07AA4613A4E10", + "PreviousFields": { + "Balance": "2017921717" + }, + "PreviousTxnID": "EC58F9B04BD0DF27621A47717719775D89426DA5C721AFD2D02F12DE90420793", + "PreviousTxnLgrSeq": 98408778 + } + } + ], + "DeliveredAmount": "7076", + "TransactionIndex": 34, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "7076" + } + }, + { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408900, + "OfferSequence": 188944818, + "Sequence": 188944832, + "SigningPubKey": "0253C1DFDCF898FE85F16B71CCE80A5739F7223D54CC9EBA4749616593470298C5", + "TakerGets": { + "currency": "USD", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "543077.94" + }, + "TakerPays": "200000000000", + "TransactionType": "OfferCreate", + "TxnSignature": "3044022076E1B315DDB0A6732AF9134242ED149D312F3E436E6D3C2FAEABCEA44F1464C402200236F174F6EF07419F605752B491E8A45044D770AD8441008F47412BAC2D9DBB", + "hash": "C6EA02129458C4607380A4E517EBC8377D6A475976767A40BBCC44C8FD8BBF50", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "0", + "IndexPrevious": "0", + "Owner": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "RootIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3", + "PreviousTxnID": "AA2C0DB50A75B38FAD691D9BDB6783B95F6E37D1E04286FB729DEC9976D93EF2", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Balance": "11956860912", + "Flags": 0, + "OwnerCount": 22, + "Sequence": 188944833 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1ED8DDFD80F275CB1CE7F18BB9D906655DE8029805D8B95FB9020B30425821EB", + "PreviousFields": { + "Balance": "11956860927", + "Sequence": 188944832 + }, + "PreviousTxnID": "AA2C0DB50A75B38FAD691D9BDB6783B95F6E37D1E04286FB729DEC9976D93EF2", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "369A8B956649B6615BE8EFEE14B53A08C1B767C17B8E23B3623803553FB08348", + "NewFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "F0B9A528CE25FE77C51C38040A7FEC016C2C841E74C1418D5A0D15685B5C1F0F", + "Sequence": 188944832, + "TakerGets": { + "currency": "USD", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "543077.94" + }, + "TakerPays": "200000000000" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "F0B9A528CE25FE77C51C38040A7FEC016C2C841E74C1418D5A0D15682ADB66B7", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "0", + "PreviousTxnID": "651D9AAB86CBBF657A2C19E218E62050C5C08A2970A9084DA3EF228B760DDD63", + "PreviousTxnLgrSeq": 98408895, + "Sequence": 188944818, + "TakerGets": { + "currency": "USD", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "543078.0599999999" + }, + "TakerPays": "200000000000" + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "8AC647EB41CCDAFFB5D042B95EB1F7FFBEDBBEA4F39E1093A0FEC88B9D42B5E2" + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "5a0d15682adb66b7", + "Flags": 0, + "PreviousTxnID": "651D9AAB86CBBF657A2C19E218E62050C5C08A2970A9084DA3EF228B760DDD63", + "PreviousTxnLgrSeq": 98408895, + "RootIndex": "F0B9A528CE25FE77C51C38040A7FEC016C2C841E74C1418D5A0D15682ADB66B7", + "TakerGetsCurrency": "0000000000000000000000005553440000000000", + "TakerGetsIssuer": "2ADB0B3959D60A6E6991F729E1918B7163925230", + "TakerPaysCurrency": "0000000000000000000000000000000000000000", + "TakerPaysIssuer": "0000000000000000000000000000000000000000" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "F0B9A528CE25FE77C51C38040A7FEC016C2C841E74C1418D5A0D15682ADB66B7" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "F0B9A528CE25FE77C51C38040A7FEC016C2C841E74C1418D5A0D15685B5C1F0F", + "NewFields": { + "ExchangeRate": "5a0d15685b5c1f0f", + "RootIndex": "F0B9A528CE25FE77C51C38040A7FEC016C2C841E74C1418D5A0D15685B5C1F0F", + "TakerGetsCurrency": "0000000000000000000000005553440000000000", + "TakerGetsIssuer": "2ADB0B3959D60A6E6991F729E1918B7163925230" + } + } + } + ], + "TransactionIndex": 10, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rpR875iQ5nfyXWCPrvvkQXt3fAGrckjKzL", + "Fee": "12", + "Flags": 0, + "LastLedgerSequence": 98409196, + "Sequence": 96863179, + "SigningPubKey": "ED683C6CA96DB47DEB087B97A61FDFC8748F50B944D62A3A358EECF8AB51355C76", + "TakerGets": "2499229", + "TakerPays": { + "currency": "NXS", + "issuer": "rNexusA23ZQdtejTCeHZZaiKoJRsrnXboq", + "value": "16660.58" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "BE6006440395CCF37D1F41B2ECB4F82091005B5FB52AC9B75D9E43C65599E48AD05A92F85400E0A6A978DD42E5FB3729553E721E501900876EB544FC3D1C230C", + "hash": "C7B8503F95EF1A24AD638D2C345988F68D797003C788F545265EA154E59BCF71", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "NXS", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "382472.8870362879" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "NXS", + "issuer": "rNexusA23ZQdtejTCeHZZaiKoJRsrnXboq", + "value": "0" + }, + "HighNode": "0", + "LowLimit": { + "currency": "NXS", + "issuer": "rpR875iQ5nfyXWCPrvvkQXt3fAGrckjKzL", + "value": "1000000000" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "169E75B82647841833DFFD7F3F0CCD069851C750227E09BE17F04BAF4F0E735D", + "PreviousFields": { + "Balance": { + "currency": "NXS", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "365812.3070362879" + } + }, + "PreviousTxnID": "D7DB7745AAE509B443AE7907D2978AD125AFA5B04F4828B6D1906744CF04F118", + "PreviousTxnLgrSeq": 98408889 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rpR875iQ5nfyXWCPrvvkQXt3fAGrckjKzL", + "Balance": "4186664", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 96863180 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "31AA96DE144898920CCF29170C1DF94E6EFABDC8447E7259ACA6E4663A3131E9", + "PreviousFields": { + "Balance": "6685905", + "Sequence": 96863179 + }, + "PreviousTxnID": "D7DB7745AAE509B443AE7907D2978AD125AFA5B04F4828B6D1906744CF04F118", + "PreviousTxnLgrSeq": 98408889 + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "57055451cf558787", + "Flags": 0, + "PreviousTxnID": "E5D97DE14885C4BACCA7129316F010E5FD5885F471FC04180F719721194B06A3", + "PreviousTxnLgrSeq": 98408896, + "RootIndex": "3C4C77373B46E59A6CB4598A715BE86A7AAA5285F6645EFD57055451CF558787", + "TakerGetsCurrency": "0000000000000000000000004E58530000000000", + "TakerGetsIssuer": "95C7AFC4CCA0A6D27DEE3B661A8F18FEDE2C1141", + "TakerPaysCurrency": "0000000000000000000000000000000000000000", + "TakerPaysIssuer": "0000000000000000000000000000000000000000" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "3C4C77373B46E59A6CB4598A715BE86A7AAA5285F6645EFD57055451CF558787" + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rBG1URdY6rKacy9u2kAkFhr34nhk9HzvWp", + "BookDirectory": "3C4C77373B46E59A6CB4598A715BE86A7AAA5285F6645EFD57055451CF558787", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "0", + "PreviousTxnID": "E5D97DE14885C4BACCA7129316F010E5FD5885F471FC04180F719721194B06A3", + "PreviousTxnLgrSeq": 98408896, + "Sequence": 96863009, + "TakerGets": { + "currency": "NXS", + "issuer": "rNexusA23ZQdtejTCeHZZaiKoJRsrnXboq", + "value": "0" + }, + "TakerPays": "0" + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "699DEAD185EAD892D88D466EC589DF2BE4411868D5ADBD064E1E3B62D4CCA6C3", + "PreviousFields": { + "TakerGets": { + "currency": "NXS", + "issuer": "rNexusA23ZQdtejTCeHZZaiKoJRsrnXboq", + "value": "16660.58" + }, + "TakerPays": "2499229" + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "NXS", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "370766.1836707396" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "NXS", + "issuer": "rNexusA23ZQdtejTCeHZZaiKoJRsrnXboq", + "value": "0" + }, + "HighNode": "0", + "LowLimit": { + "currency": "NXS", + "issuer": "rBG1URdY6rKacy9u2kAkFhr34nhk9HzvWp", + "value": "1000000000" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "821B8A02372D5485CB88EA42EFB4D281E1B47A4FABD498870D84602E3BD4F309", + "PreviousFields": { + "Balance": { + "currency": "NXS", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "387426.7636707396" + } + }, + "PreviousTxnID": "D7DB7745AAE509B443AE7907D2978AD125AFA5B04F4828B6D1906744CF04F118", + "PreviousTxnLgrSeq": 98408889 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rBG1URdY6rKacy9u2kAkFhr34nhk9HzvWp", + "RootIndex": "BE71EE185F2317C61ECCE4656BB8B136066F89CAE482FB3985F14E335D85AE5A" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "BE71EE185F2317C61ECCE4656BB8B136066F89CAE482FB3985F14E335D85AE5A", + "PreviousTxnID": "E5D97DE14885C4BACCA7129316F010E5FD5885F471FC04180F719721194B06A3", + "PreviousTxnLgrSeq": 98408896 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rBG1URdY6rKacy9u2kAkFhr34nhk9HzvWp", + "Balance": "5942492", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 96863010 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F24FCCFCEA6AD789BA3985CD90399D3146EB166DC701DD111379D521CBE87B01", + "PreviousFields": { + "Balance": "3443263", + "OwnerCount": 2 + }, + "PreviousTxnID": "E5D97DE14885C4BACCA7129316F010E5FD5885F471FC04180F719721194B06A3", + "PreviousTxnLgrSeq": 98408896 + } + } + ], + "TransactionIndex": 4, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408900, + "OfferSequence": 188944820, + "Sequence": 188944834, + "SigningPubKey": "0253C1DFDCF898FE85F16B71CCE80A5739F7223D54CC9EBA4749616593470298C5", + "TakerGets": "11856000000", + "TakerPays": { + "currency": "EUR", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "30051.6201648" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "3044022043A24F8BB4E50F78B6CEF28CE95E073442ABCDBE84B8E3BC5D9D5CABE9A327E9022011AF3DAB7FA15E46F54449942F9FDB403187934B9FD7F609640E8E7055079630", + "hash": "CAEB990B4EC05AB17EADE0CACD1BA2449F2B627BE7B2E09E472D51324AA39E8D", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "0", + "IndexPrevious": "0", + "Owner": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "RootIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3", + "PreviousTxnID": "E3BA7132DCADECAA27F93F5F41BE1D5C88FFB5FFCC3D7421551519ACEECCD0C5", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Balance": "11956860882", + "Flags": 0, + "OwnerCount": 22, + "Sequence": 188944835 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1ED8DDFD80F275CB1CE7F18BB9D906655DE8029805D8B95FB9020B30425821EB", + "PreviousFields": { + "Balance": "11956860897", + "Sequence": 188944834 + }, + "PreviousTxnID": "E3BA7132DCADECAA27F93F5F41BE1D5C88FFB5FFCC3D7421551519ACEECCD0C5", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "BC05A0B94DB6C7C0B2D9E04573F0463DC15DB8033ABA85624F09015017E08F00", + "NewFields": { + "ExchangeRate": "4f09015017e08f00", + "RootIndex": "BC05A0B94DB6C7C0B2D9E04573F0463DC15DB8033ABA85624F09015017E08F00", + "TakerPaysCurrency": "0000000000000000000000004555520000000000", + "TakerPaysIssuer": "2ADB0B3959D60A6E6991F729E1918B7163925230" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "4f0901501dd67000", + "Flags": 0, + "PreviousTxnID": "1B403024678695642DCBAE1552A7874E4DED00359A1CA75F289FDDABA1A2BA9B", + "PreviousTxnLgrSeq": 98408895, + "RootIndex": "BC05A0B94DB6C7C0B2D9E04573F0463DC15DB8033ABA85624F0901501DD67000", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "0000000000000000000000004555520000000000", + "TakerPaysIssuer": "2ADB0B3959D60A6E6991F729E1918B7163925230" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "BC05A0B94DB6C7C0B2D9E04573F0463DC15DB8033ABA85624F0901501DD67000" + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "BC05A0B94DB6C7C0B2D9E04573F0463DC15DB8033ABA85624F0901501DD67000", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "0", + "PreviousTxnID": "1B403024678695642DCBAE1552A7874E4DED00359A1CA75F289FDDABA1A2BA9B", + "PreviousTxnLgrSeq": 98408895, + "Sequence": 188944820, + "TakerGets": "11856000000", + "TakerPays": { + "currency": "EUR", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "30051.6213504" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "CA1AF79FC7D479252CD9B0D85240E85D2164951E3C3F8E93910AB501AB803996" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "DD46B5C661AB4038A009688E06F623B59D09FE9943E6228D282ED934B3F5829B", + "NewFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "BC05A0B94DB6C7C0B2D9E04573F0463DC15DB8033ABA85624F09015017E08F00", + "Sequence": 188944834, + "TakerGets": "11856000000", + "TakerPays": { + "currency": "EUR", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "30051.6201648" + } + } + } + } + ], + "TransactionIndex": 12, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rDeizxSRo6JHjKnih9ivpPkyD2EgXQvhSB", + "Fee": "12", + "Memos": [ + { + "Memo": { + "MemoData": "4E4654204F66666572206163636570746564207669612058506D61726B65742E636F6D" + } + } + ], + "NFTokenBuyOffer": "A12EBA31A042BCF026E6B7CD34FBA7F404A13B005E600144C998CD3E7C47B0AF", + "Sequence": 75761383, + "SigningPubKey": "02AFFCAEB87C9C5B88D2B1A2E46FA2E055FB6F368CFD23A77C5411847A3D6AF0A2", + "SourceTag": 20221212, + "TransactionType": "NFTokenAcceptOffer", + "TxnSignature": "3045022100AB70242425796FD782222B6C0F823A69A01707E1BD4311731462255FF3765D45022037A002132EC72DD1D8ED7239931AF7AB73384BEF57E2AD95CAA9FE09223672B0", + "hash": "CBAA588936C4F560EBC6AA2B131AC274F53ADF4F590A2D686C364ACA406A4626", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rDeizxSRo6JHjKnih9ivpPkyD2EgXQvhSB", + "Balance": "375033309", + "BurnedNFTokens": 41145, + "FirstNFTokenSequence": 75440949, + "Flags": 0, + "MintedNFTokens": 151040, + "OwnerCount": 206, + "Sequence": 75761384 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "61CD8B4290CD94A4F461849D18830F598EF4C11DD6ECC60543147CD0001AC70B", + "PreviousFields": { + "Balance": "375033321", + "Sequence": 75761383 + }, + "PreviousTxnID": "02D6248620503E9FFF7B7E4A967D8C1E18DC961F15A075781E253FB15616A277", + "PreviousTxnLgrSeq": 98408898 + } + } + ], + "TransactionIndex": 44, + "TransactionResult": "tecOBJECT_NOT_FOUND" + } + }, + { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Fee": "15", + "Flags": 0, + "LastLedgerSequence": 98408900, + "OfferSequence": 188944819, + "Sequence": 188944833, + "SigningPubKey": "0253C1DFDCF898FE85F16B71CCE80A5739F7223D54CC9EBA4749616593470298C5", + "TakerGets": "11856000000", + "TakerPays": { + "currency": "USD", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "33935.4691104" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "30450221008B6DE6D6EE354E805A57387E8B42B459C6C71BFD84FAB84EED8F92FB2E6D022C022030EE51B883B4CE29D0CBFBAB43FAFDDE38226F5354B5D3ADEEE52CA3A2A86EEF", + "hash": "E3BA7132DCADECAA27F93F5F41BE1D5C88FFB5FFCC3D7421551519ACEECCD0C5", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "IndexNext": "0", + "IndexPrevious": "0", + "Owner": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "RootIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "0A2600D85F8309FE7F75A490C19613F1CE0C37483B856DB69B8140154C2335F3", + "PreviousTxnID": "C6EA02129458C4607380A4E517EBC8377D6A475976767A40BBCC44C8FD8BBF50", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Balance": "11956860897", + "Flags": 0, + "OwnerCount": 22, + "Sequence": 188944834 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1ED8DDFD80F275CB1CE7F18BB9D906655DE8029805D8B95FB9020B30425821EB", + "PreviousFields": { + "Balance": "11956860912", + "Sequence": 188944833 + }, + "PreviousTxnID": "C6EA02129458C4607380A4E517EBC8377D6A475976767A40BBCC44C8FD8BBF50", + "PreviousTxnLgrSeq": 98408898 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "79C54A4EBD69AB2EADCE313042F36092BE432423CC6A4F784F0A2B3FF1657A00", + "NewFields": { + "ExchangeRate": "4f0a2b3ff1657a00", + "RootIndex": "79C54A4EBD69AB2EADCE313042F36092BE432423CC6A4F784F0A2B3FF1657A00", + "TakerPaysCurrency": "0000000000000000000000005553440000000000", + "TakerPaysIssuer": "2ADB0B3959D60A6E6991F729E1918B7163925230" + } + } + }, + { + "DeletedNode": { + "FinalFields": { + "ExchangeRate": "4f0a2b40093cfe00", + "Flags": 0, + "PreviousTxnID": "ACEDA79AAF2D479F346CAA2C6D771042C49A6A1A0788070DCE669C1211BE1B84", + "PreviousTxnLgrSeq": 98408895, + "RootIndex": "79C54A4EBD69AB2EADCE313042F36092BE432423CC6A4F784F0A2B40093CFE00", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "0000000000000000000000005553440000000000", + "TakerPaysIssuer": "2ADB0B3959D60A6E6991F729E1918B7163925230" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "79C54A4EBD69AB2EADCE313042F36092BE432423CC6A4F784F0A2B40093CFE00" + } + }, + { + "DeletedNode": { + "FinalFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "79C54A4EBD69AB2EADCE313042F36092BE432423CC6A4F784F0A2B40093CFE00", + "BookNode": "0", + "Flags": 0, + "OwnerNode": "0", + "PreviousTxnID": "ACEDA79AAF2D479F346CAA2C6D771042C49A6A1A0788070DCE669C1211BE1B84", + "PreviousTxnLgrSeq": 98408895, + "Sequence": 188944819, + "TakerGets": "11856000000", + "TakerPays": { + "currency": "USD", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "33935.4738528" + } + }, + "LedgerEntryType": "Offer", + "LedgerIndex": "7A2DBD770C2BC415C618FF9636521068518FCDC8ABBEFBEB391294A5433329CD" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "A19B4336AA48CFB15A341B4531FA9A4C264E36BC00B4A3B5BE906B988119296C", + "NewFields": { + "Account": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "BookDirectory": "79C54A4EBD69AB2EADCE313042F36092BE432423CC6A4F784F0A2B3FF1657A00", + "Sequence": 188944833, + "TakerGets": "11856000000", + "TakerPays": { + "currency": "USD", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq", + "value": "33935.4691104" + } + } + } + } + ], + "TransactionIndex": 11, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rfaecmugiPaAkFpBT9Bmq8f3n42oXe6m1C", + "Fee": "12", + "Flags": 0, + "LastLedgerSequence": 98409196, + "Sequence": 93701697, + "SigningPubKey": "ED351EEBC11FA0E79D37AB2F764FF88316817F8A9D42649A9ADF2BC537068D6564", + "TakerGets": { + "currency": "4F70756C656E6365000000000000000000000000", + "issuer": "rs5wxrBTSErywDDfPs5NY4Zorrca5QMGVd", + "value": "101410.36" + }, + "TakerPays": "8387316", + "TransactionType": "OfferCreate", + "TxnSignature": "B83FBE1D1099AE88C955D7F9918A92712B89959C6C9AF796DD1D66A3937119A66C6A0575862EDAD4243095A79F3D82F39ACD0AB29D4BCEB159549D1350655D01", + "hash": "E8BF63F1219DF1DF98025F1A3EAB19C4CD8611C0569944FCE22ACE2C131D7D18", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rfaecmugiPaAkFpBT9Bmq8f3n42oXe6m1C", + "RootIndex": "3BFDD5076D7E7989AC930D77D610EB5F58BD7ED065D06576FB39A50E7D5C03E9" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "3BFDD5076D7E7989AC930D77D610EB5F58BD7ED065D06576FB39A50E7D5C03E9", + "PreviousTxnID": "25E168DD3635CEC5A57773AC2FB826DF87CD20DE7F031FD9D295D034916B3B05", + "PreviousTxnLgrSeq": 98408883 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfaecmugiPaAkFpBT9Bmq8f3n42oXe6m1C", + "Balance": "14427176", + "Flags": 0, + "OwnerCount": 3, + "Sequence": 93701698 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "83F08A53E2197F85766C7C12C88916EC368F239E4CF62AF346EC08DACA2DA5E3", + "PreviousFields": { + "Balance": "14427188", + "OwnerCount": 2, + "Sequence": 93701697 + }, + "PreviousTxnID": "C5E8B0496F335C2770EBCCBF5A50F036C6BEB3D9EF88FB94E969DF19873C527B", + "PreviousTxnLgrSeq": 98408891 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "A6CAD2DB3D1C239AD4B9D7C7840E101E66F8490F782B67734D249AF86CE8AD0C", + "NewFields": { + "Account": "rfaecmugiPaAkFpBT9Bmq8f3n42oXe6m1C", + "BookDirectory": "E6FA4ABF940D7C92C72CBFBA725D635D5333F9271D4CA576561D62215E640DCD", + "Sequence": 93701697, + "TakerGets": { + "currency": "4F70756C656E6365000000000000000000000000", + "issuer": "rs5wxrBTSErywDDfPs5NY4Zorrca5QMGVd", + "value": "101410.36" + }, + "TakerPays": "8387316" + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "E6FA4ABF940D7C92C72CBFBA725D635D5333F9271D4CA576561D62215E640DCD", + "NewFields": { + "ExchangeRate": "561d62215e640dcd", + "RootIndex": "E6FA4ABF940D7C92C72CBFBA725D635D5333F9271D4CA576561D62215E640DCD", + "TakerGetsCurrency": "4F70756C656E6365000000000000000000000000", + "TakerGetsIssuer": "1DB99C4654296E89B2D5C203FC1973EB11AAC390" + } + } + } + ], + "TransactionIndex": 17, + "TransactionResult": "tesSUCCESS" + } + }, + { + "Account": "rGuhZqkVANWyHQUSWQzCLPKnho9gzVUPng", + "Amount": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "175675.452887766" + }, + "DeliverMax": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "175675.452887766" + }, + "Destination": "rGuhZqkVANWyHQUSWQzCLPKnho9gzVUPng", + "Fee": "12", + "Flags": 196608, + "LastLedgerSequence": 98408916, + "Paths": [ + [ + { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "rLqUC2eCPohYvJCEBJ77eCCqVL2uEiczjA", + "type": 48 + }, + { + "currency": "XAH", + "issuer": "rswh1fvyLqHizBS2awu1vs6QcmwTBd9qiv", + "type": 48 + } + ] + ], + "SendMax": "6849", + "Sequence": 96039116, + "SigningPubKey": "0205F767BCAE8D6A354DB0102DA6937A694FE7F16A1A9EFE9A3D2836BA7215B76D", + "TransactionType": "Payment", + "TxnSignature": "304402201D37832C7F681E98CA120C7033D6EBEDE473022FCD9170F03909CC3C3D213B52022072E5BC8122C2FF2D62ED934B1D41FEF70031B66A273F2BEDF3EAE5BDF3E7ADD6", + "hash": "F21021CAC0377119D06322AF8F0657124F11BEF618C73E90B711DB7304C67ABB", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1493401419.981003" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "rLqUC2eCPohYvJCEBJ77eCCqVL2uEiczjA", + "value": "0" + }, + "HighNode": "9e1", + "LowLimit": { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "rB3nE2RcnhAqUj3xYczakzvF72uW6ML9UM", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "37CE14ADEB8497F4FB2F344D75302B9D20DFDD4A4D45B362ECA22A8A8F775270", + "PreviousFields": { + "Balance": { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1493402697.346204" + } + }, + "PreviousTxnID": "30B34F17C2074501F27A33859B6CCE0AE50AF0F631C75D1BDD998DD48620EA3C", + "PreviousTxnLgrSeq": 98408896 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "FIN", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-175675.4526524" + }, + "Flags": 2228224, + "HighLimit": { + "currency": "FIN", + "issuer": "rGuhZqkVANWyHQUSWQzCLPKnho9gzVUPng", + "value": "1000000000000000e-4" + }, + "HighNode": "1d", + "LowLimit": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "0" + }, + "LowNode": "8d" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "4D8DDE9DF87E5A8474A420BC469D444B9056FD91FDA40EC8F07BFB18FA26FCA0", + "PreviousFields": { + "Balance": { + "currency": "FIN", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0" + } + }, + "PreviousTxnID": "B71A80A18CD0832BBAF37DBE88B2DC6F8A629EAE40C2307B694EAC3C64054F69", + "PreviousTxnLgrSeq": 98408513 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "FIN", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "220470425.0731096" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "0" + }, + "HighNode": "d", + "LowLimit": { + "currency": "FIN", + "issuer": "rBcosPZKMbVNFvhn9497KQeWVvhyUMJeNb", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "7E293B99D389B2A3156811806E2060C52024680D45FC00E2D7575671DF78DBE3", + "PreviousFields": { + "Balance": { + "currency": "FIN", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "220646100.525762" + } + }, + "PreviousTxnID": "6CD57F1A6B80AA6D32C1EBF558FCC00EB95ABB4E0740768E3910B803B82982F9", + "PreviousTxnLgrSeq": 98408508 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rGuhZqkVANWyHQUSWQzCLPKnho9gzVUPng", + "Balance": "202742885", + "Flags": 0, + "OwnerCount": 964, + "Sequence": 96039117 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "8FD0B11C49AA6AD9AD513A13D7619D1AEC17333525F4CE633EFF412CFB1376FC", + "PreviousFields": { + "Balance": "202749746", + "Sequence": 96039116 + }, + "PreviousTxnID": "5BAAAAAAF54E22A63D8245C49B624D213E757B2F174B96692E3397AC9159058A", + "PreviousTxnLgrSeq": 98408891 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "XAH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-536.1107734149279" + }, + "Flags": 16908288, + "HighLimit": { + "currency": "XAH", + "issuer": "rBcosPZKMbVNFvhn9497KQeWVvhyUMJeNb", + "value": "0" + }, + "HighNode": "0", + "LowLimit": { + "currency": "XAH", + "issuer": "rswh1fvyLqHizBS2awu1vs6QcmwTBd9qiv", + "value": "0" + }, + "LowNode": "228" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "900788B6A378CEAB3021C06E47949AF8C035258F6457DFC0E14DF8E487668510", + "PreviousFields": { + "Balance": { + "currency": "XAH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-535.6838865850261" + } + }, + "PreviousTxnID": "6CD57F1A6B80AA6D32C1EBF558FCC00EB95ABB4E0740768E3910B803B82982F9", + "PreviousTxnLgrSeq": 98408508 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "78501CC903D699B29D1D5D6A1C4008B8961EBE8E74EB0ABA098D6342F5BDC364", + "Account": "rB3nE2RcnhAqUj3xYczakzvF72uW6ML9UM", + "Balance": "7936728972", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 86795422 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "A35E90F210FA06DB223F67564C27C2A6359C26EC61D1A6CB59E27F83F29390BB", + "PreviousFields": { + "Balance": "7936722123" + }, + "PreviousTxnID": "30B34F17C2074501F27A33859B6CCE0AE50AF0F631C75D1BDD998DD48620EA3C", + "PreviousTxnLgrSeq": 98408896 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "XAH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-40.02326438212984" + }, + "Flags": 16908288, + "HighLimit": { + "currency": "XAH", + "issuer": "r3is7WxnbR18qwBkfJhY5oegLdMWTFZc42", + "value": "0" + }, + "HighNode": "0", + "LowLimit": { + "currency": "XAH", + "issuer": "rswh1fvyLqHizBS2awu1vs6QcmwTBd9qiv", + "value": "0" + }, + "LowNode": "13d" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "A537AD20F6C4FF27D30779A3440BAB2A46D3A0F687F3D1E1C3723CBE5030771A", + "PreviousFields": { + "Balance": { + "currency": "XAH", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-40.45015121203164" + } + }, + "PreviousTxnID": "574EA11CBABA1A560DB7BB5601C5FE424000CA356399CE47FB85B5A3088EAC56", + "PreviousTxnLgrSeq": 98407223 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "119840.5963793337" + }, + "Flags": 16842752, + "HighLimit": { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "rLqUC2eCPohYvJCEBJ77eCCqVL2uEiczjA", + "value": "0" + }, + "HighNode": "9e5", + "LowLimit": { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "r3is7WxnbR18qwBkfJhY5oegLdMWTFZc42", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "EBDC939BCC7274322C3538DDC441EF1F2277F1F4E557F37995D78317944125EA", + "PreviousFields": { + "Balance": { + "currency": "5852646F67650000000000000000000000000000", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "118563.2311783337" + } + }, + "PreviousTxnID": "574EA11CBABA1A560DB7BB5601C5FE424000CA356399CE47FB85B5A3088EAC56", + "PreviousTxnLgrSeq": 98407223 + } + } + ], + "DeliveredAmount": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "175675.4526524" + }, + "TransactionIndex": 33, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "currency": "FIN", + "issuer": "rEJqyQCiqJgqWXLMMJ8cyLwBJUvBA9xmUA", + "value": "175675.4526524" + } + } + }, + { + "Account": "rUWb3ssZnyZRx3WuYaPwPgTsANhNzBxQxW", + "Amount": "764345", + "DeliverMax": "764345", + "DeliverMin": "756702", + "Destination": "rUWb3ssZnyZRx3WuYaPwPgTsANhNzBxQxW", + "Fee": "10", + "Flags": 131072, + "LastLedgerSequence": 98408917, + "SendMax": { + "currency": "666", + "issuer": "rhvf9fe6PP3GC8Bku2Ug7iQPjPDxYZfrxN", + "value": "12.052846" + }, + "Sequence": 95584154, + "SigningPubKey": "ED2FCB20558826710A96131C9144D69BE81921950D9293478B289F5EDA8646FA66", + "TransactionType": "Payment", + "TxnSignature": "49AC70CB173545408D7BB30430CA58A0A3D1482B37DEDC67698B6A8B7B2F17FA8CFF5E64F2C74CA2E01D12DF1E98833C2B2E7585154E039428AB5CB9F0E26C01", + "hash": "FDFBD8C8752283F7222310B8E513C79BA35A6706FB8BAEB9802CDAAF88115648", + "metaData": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "666", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-165716.726988576" + }, + "Flags": 16908288, + "HighLimit": { + "currency": "666", + "issuer": "rfD5fzDHvh5WGiep3TgiSz2GcyqCHX7uCA", + "value": "0" + }, + "HighNode": "0", + "LowLimit": { + "currency": "666", + "issuer": "rhvf9fe6PP3GC8Bku2Ug7iQPjPDxYZfrxN", + "value": "0" + }, + "LowNode": "0" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "4C862452B4B34ED3AB0E06ACA874A3641BD0ED30FF71E503521637FE3DB67DD4", + "PreviousFields": { + "Balance": { + "currency": "666", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-165704.674142576" + } + }, + "PreviousTxnID": "34FC267066CB31B1FA177B2FA688EA85D8F3634387CCC7AC63AAF1539DB7FDB3", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AMMID": "88D86D5293720E3AAAF455C1BC88CEF1E12A08E2DBD8E21BF3FB1769C711CA76", + "Account": "rfD5fzDHvh5WGiep3TgiSz2GcyqCHX7uCA", + "Balance": "10507596762", + "Flags": 26214400, + "OwnerCount": 1, + "Sequence": 91755752 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "5DECB3091F6B011F9AAA7C0882B0CBE15F424E3BFE96D7A71F2925A1A9157FA7", + "PreviousFields": { + "Balance": "10508361044" + }, + "PreviousTxnID": "34FC267066CB31B1FA177B2FA688EA85D8F3634387CCC7AC63AAF1539DB7FDB3", + "PreviousTxnLgrSeq": 98408897 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "666", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-92.0419781747431" + }, + "Flags": 2228224, + "HighLimit": { + "currency": "666", + "issuer": "rUWb3ssZnyZRx3WuYaPwPgTsANhNzBxQxW", + "value": "9998667.999767195" + }, + "HighNode": "0", + "LowLimit": { + "currency": "666", + "issuer": "rhvf9fe6PP3GC8Bku2Ug7iQPjPDxYZfrxN", + "value": "0" + }, + "LowNode": "dd" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "9558037E0D008CCD1CDCFAEFDE1B15165362A326CFA03F017D0186E87C072C10", + "PreviousFields": { + "Balance": { + "currency": "666", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "-104.0948241747431" + } + }, + "PreviousTxnID": "3F3EE98CB7A5EA6E4915C3905AB33798E41A364DA8EA93C9B89FDE4F56B46529", + "PreviousTxnLgrSeq": 98408880 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rUWb3ssZnyZRx3WuYaPwPgTsANhNzBxQxW", + "Balance": "8518222", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 95584155 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "EDC3D6BCB87D550B03215F98BB93727BA55E0ED3377130AA054D28AF90D868D5", + "PreviousFields": { + "Balance": "7753950", + "Sequence": 95584154 + }, + "PreviousTxnID": "65D52655F40E4B9B1D62F97D86E68DECE527CFB4B0DE402862B8262F7FDC7BC2", + "PreviousTxnLgrSeq": 98408889 + } + } + ], + "DeliveredAmount": "764282", + "TransactionIndex": 14, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "764282" + } + } + ] + }, + "ledger_hash": "478826D0E8C1C55C5CE5594406165318D189A64A23E47DD51FD6D4ECFDBBD6B7", + "ledger_index": 98408898, + "status": "success", + "validated": true + } +} \ No newline at end of file diff --git a/core/crates/in_app_notifications/Cargo.toml b/core/crates/in_app_notifications/Cargo.toml new file mode 100644 index 0000000000..d1ac40faa3 --- /dev/null +++ b/core/crates/in_app_notifications/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "in_app_notifications" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +localizer = { path = "../localizer" } +number_formatter = { path = "../number_formatter" } +primitives = { path = "../primitives" } +serde_json = { workspace = true } diff --git a/core/crates/in_app_notifications/src/lib.rs b/core/crates/in_app_notifications/src/lib.rs new file mode 100644 index 0000000000..643806296e --- /dev/null +++ b/core/crates/in_app_notifications/src/lib.rs @@ -0,0 +1,127 @@ +use localizer::LanguageLocalizer; +use number_formatter::{ValueFormatter, ValueStyle}; +use primitives::{ + CoreEmoji, CoreListItem, CoreListItemBadge, CoreListItemIcon, Deeplink, InAppNotification, JsonDecode, NotificationData, NotificationRewardsMetadata, + NotificationRewardsRedeemMetadata, NotificationType, WalletId, +}; + +pub fn map_notification(notification: NotificationData, localizer: &LanguageLocalizer) -> Option { + let wallet_id = WalletId::from_id(¬ification.wallet_id)?; + let item = map_to_list_item(¬ification, localizer); + + Some(InAppNotification { + wallet_id, + read_at: notification.read_at, + created_at: notification.created_at, + item, + }) +} + +fn notification_item( + id: String, + title: String, + subtitle: Option, + value: Option, + subvalue: Option, + emoji: CoreEmoji, + badge: Option, + url: Option, +) -> CoreListItem { + CoreListItem { + id, + title, + subtitle, + value, + subvalue, + icon: Some(CoreListItemIcon::Emoji(emoji)), + badge, + url, + } +} + +fn map_to_list_item(notification: &NotificationData, localizer: &LanguageLocalizer) -> CoreListItem { + let id = notification.id.to_string(); + let url = Some(Deeplink::Rewards { code: None }.to_gem_url()); + + match notification.notification_type { + NotificationType::ReferralJoined => { + let points = notification.metadata.decode::().and_then(|m| m.points).unwrap_or(0); + notification_item( + id, + localizer.notification_reward_pending_title(), + Some(localizer.notification_reward_invite_description()), + Some(format!("+{}", points)), + None, + CoreEmoji::Party, + Some(CoreListItemBadge::New), + url, + ) + } + NotificationType::RewardsEnabled => notification_item( + id, + localizer.notification_rewards_enabled_title(), + Some(localizer.notification_rewards_enabled_description()), + None, + None, + CoreEmoji::Gift, + None, + url, + ), + NotificationType::RewardsCodeDisabled => notification_item( + id, + localizer.notification_rewards_disabled_title(), + Some(localizer.notification_rewards_disabled_description()), + None, + None, + CoreEmoji::Warning, + Some(CoreListItemBadge::New), + url, + ), + NotificationType::RewardsRedeemed => { + let redeem = notification.metadata.decode::(); + let points = redeem.as_ref().map(|m| m.points).unwrap_or(0); + let value = redeem.as_ref().and_then(|m| { + let asset = notification.asset.as_ref()?; + ValueFormatter::format_with_symbol(ValueStyle::Auto, &m.value, asset.decimals, &asset.symbol).ok() + }); + let subtitle = Some(localizer.notification_reward_redeemed_description(points, value.as_deref())); + let subvalue = Some(format!("-{}", points)); + notification_item( + id, + localizer.notification_reward_redeemed_title(), + subtitle, + value.map(|value| format!("+{}", value)), + subvalue, + CoreEmoji::Gift, + None, + url, + ) + } + NotificationType::RewardsCreateUsername => { + let points = notification.metadata.decode::().and_then(|m| m.points).unwrap_or(0); + notification_item( + id, + localizer.notification_reward_title(points), + Some(localizer.notification_reward_create_username_description()), + Some(format!("+{}", points)), + None, + CoreEmoji::Gem, + Some(CoreListItemBadge::New), + url, + ) + } + NotificationType::RewardsInvite => { + let points = notification.metadata.decode::().and_then(|m| m.points).unwrap_or(0); + notification_item( + id, + localizer.notification_reward_title(points), + Some(localizer.notification_reward_invite_description()), + Some(format!("+{}", points)), + None, + CoreEmoji::Party, + Some(CoreListItemBadge::New), + url, + ) + } + } +} diff --git a/core/crates/job_runner/Cargo.toml b/core/crates/job_runner/Cargo.toml new file mode 100644 index 0000000000..5951c6c820 --- /dev/null +++ b/core/crates/job_runner/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "job_runner" +version ={ workspace = true } +edition = { workspace = true } + +[dependencies] +tokio = { workspace = true, features = ["sync", "time"] } +async-trait = { workspace = true } + +gem_tracing = { path = "../tracing" } diff --git a/core/crates/job_runner/src/lib.rs b/core/crates/job_runner/src/lib.rs new file mode 100644 index 0000000000..569cabead4 --- /dev/null +++ b/core/crates/job_runner/src/lib.rs @@ -0,0 +1,200 @@ +use std::fmt::Debug; +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::SystemTime; + +use async_trait::async_trait; +use gem_tracing::{error_with_fields, human_duration, info_with_fields}; +pub mod schedule; +pub use schedule::{JobContext, JobSchedule, RunDecision}; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio::time::{Duration, Instant}; + +pub type ShutdownReceiver = watch::Receiver; +pub type JobError = Box; + +#[async_trait] +pub trait JobStatusReporter: Send + Sync { + async fn report(&self, name: &str, interval: u64, duration: u64, success: bool); +} + +pub async fn sleep_or_shutdown(duration: Duration, shutdown_rx: &ShutdownReceiver) -> bool { + let mut rx = shutdown_rx.clone(); + tokio::select! { + _ = tokio::time::sleep(duration) => false, + _ = rx.changed() => true, + } +} + +pub async fn run_job( + name: Name, + interval_duration: Duration, + reporter: Arc, + shutdown_rx: ShutdownReceiver, + schedule: Arc, + job_fn: F, +) where + Name: Into + Send + 'static, + F: Fn(JobContext) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + R: Debug + Send + Sync + 'static, +{ + let job_name = name.into(); + + loop { + if *shutdown_rx.borrow() { + break; + } + + let decision = schedule.evaluate(job_name.as_str(), interval_duration, SystemTime::now()).await; + let ctx = match decision { + Ok(RunDecision::Run(ctx)) => ctx, + Ok(RunDecision::Wait(wait)) if wait > Duration::ZERO => { + info_with_fields!("job wait", job = job_name.as_str(), wait = human_duration(wait)); + if sleep_or_shutdown(wait, &shutdown_rx).await { + break; + } + continue; + } + Ok(RunDecision::Wait(_)) => continue, + Err(err) => { + error_with_fields!("job schedule evaluation failed", &*err, job = job_name.as_str()); + continue; + } + }; + + let now = Instant::now(); + info_with_fields!("job start", job = job_name.as_str(), interval = human_duration(interval_duration)); + + let result = job_fn(ctx).await; + let duration_ms = now.elapsed().as_millis() as u64; + let duration_display = human_duration(Duration::from_millis(duration_ms)); + + match result { + Ok(value) => { + info_with_fields!( + "job complete", + job = job_name.as_str(), + duration = duration_display.as_str(), + result = format!("{:?}", value) + ); + if let Err(err) = schedule.mark_success(job_name.as_str(), SystemTime::now()).await { + error_with_fields!("job schedule update failed", &*err, job = job_name.as_str()); + } + reporter.report(&job_name, interval_duration.as_secs(), duration_ms, true).await; + } + Err(err) => { + error_with_fields!("job failed", &*err, job = job_name.as_str(), duration = duration_display.as_str()); + reporter.report(&job_name, interval_duration.as_secs(), duration_ms, false).await; + } + } + + if *shutdown_rx.borrow() || sleep_or_shutdown(interval_duration, &shutdown_rx).await { + break; + } + } +} + +pub struct JobPlan { + reporter: Arc, + shutdown_rx: ShutdownReceiver, + schedule: Arc, + handles: Vec, +} + +impl JobPlan { + pub fn new(reporter: Arc, shutdown_rx: ShutdownReceiver, schedule: Arc) -> Self { + Self { + reporter, + shutdown_rx, + schedule, + handles: Vec::new(), + } + } + + pub fn job(mut self, name: Name, interval: Duration, job_fn: F) -> Self + where + Name: Into + Send + 'static, + F: Fn(JobContext) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + R: Debug + Send + Sync + 'static, + { + let job_name = name.into(); + let finished = Arc::new(AtomicBool::new(false)); + let finished_clone = finished.clone(); + let reporter = self.reporter.clone(); + let shutdown_rx = self.shutdown_rx.clone(); + let schedule = self.schedule.clone(); + let job_name_for_handle = job_name.clone(); + let handle = tokio::spawn(async move { + run_job(job_name_for_handle, interval, reporter, shutdown_rx, schedule, job_fn).await; + finished_clone.store(true, Ordering::Relaxed); + }); + self.handles.push(JobHandle::new(job_name, handle, finished)); + self + } + + pub fn finish(self) -> Vec { + self.handles + } +} + +pub struct JobHandle { + name: String, + handle: JoinHandle<()>, + finished: Arc, +} + +impl JobHandle { + pub fn new(name: String, handle: JoinHandle<()>, finished: Arc) -> Self { + Self { name, handle, finished } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn is_finished(&self) -> bool { + self.finished.load(Ordering::Relaxed) + } + + pub fn status_flag(&self) -> Arc { + self.finished.clone() + } + + pub fn into_handle(self) -> JoinHandle<()> { + self.handle + } +} + +#[cfg(test)] +mod tests { + use gem_tracing::human_duration; + use std::time::Duration; + + #[test] + fn duration_zero() { + assert_eq!(human_duration(Duration::ZERO), "0s"); + } + + #[test] + fn duration_sub_second() { + assert_eq!(human_duration(Duration::from_millis(250)), "250ms"); + } + + #[test] + fn duration_seconds_and_minutes() { + assert_eq!(human_duration(Duration::from_secs(12)), "12s"); + assert_eq!(human_duration(Duration::from_secs(90)), "1m 30s"); + assert_eq!(human_duration(Duration::from_secs(65)), "1m 5s"); + } + + #[test] + fn duration_hours_and_days() { + assert_eq!(human_duration(Duration::from_secs(3_600 * 5 + 42)), "5h 42s"); + assert_eq!(human_duration(Duration::from_secs(86_400 + 3_600 * 2)), "1d 2h"); + assert_eq!(human_duration(Duration::from_secs(90_000)), "1d 1h"); + } +} diff --git a/core/crates/job_runner/src/schedule.rs b/core/crates/job_runner/src/schedule.rs new file mode 100644 index 0000000000..b9ad019adf --- /dev/null +++ b/core/crates/job_runner/src/schedule.rs @@ -0,0 +1,21 @@ +use std::time::{Duration, SystemTime}; + +use async_trait::async_trait; + +use crate::JobError; + +pub enum RunDecision { + Run(JobContext), + Wait(Duration), +} + +#[derive(Debug, Clone)] +pub struct JobContext { + pub last_success_at: Option, +} + +#[async_trait] +pub trait JobSchedule: Send + Sync { + async fn evaluate(&self, job_name: &str, interval: Duration, now: SystemTime) -> Result; + async fn mark_success(&self, job_name: &str, timestamp: SystemTime) -> Result<(), JobError>; +} diff --git a/core/crates/localizer/Cargo.toml b/core/crates/localizer/Cargo.toml new file mode 100644 index 0000000000..8a260d292d --- /dev/null +++ b/core/crates/localizer/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "localizer" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +i18n-embed = { version = "0.16.0", features = ["fluent-system", "autoreload"] } +i18n-embed-fl = { version = "0.10.0" } +rust-embed = { version = "8.11.0" } diff --git a/core/crates/localizer/i18n.toml b/core/crates/localizer/i18n.toml new file mode 100644 index 0000000000..454b3802ac --- /dev/null +++ b/core/crates/localizer/i18n.toml @@ -0,0 +1,12 @@ +# (Required) The language identifier of the language used in the +# source code for gettext system, and the primary fallback language +# (for which all strings must be present) when using the fluent +# system. +fallback_language = "en" + +# (Optional) Use the fluent localization system. +[fluent] +# (Required) The path to the assets directory. +# The paths inside the assets directory should be structured like so: +# `assets_dir/{language}/{domain}.ftl` +assets_dir = "i18n" \ No newline at end of file diff --git a/core/crates/localizer/i18n/ar/localizer.ftl b/core/crates/localizer/i18n/ar/localizer.ftl new file mode 100644 index 0000000000..2eb872b170 --- /dev/null +++ b/core/crates/localizer/i18n/ar/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 تم الإرسال: {$value} +notification_sent_description = إلى {$address} +notification_received_title = 💰 تم الاستلام: {$value} +notification_received_description = من {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 التبديل من {$from_symbol} إلى {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 تنبيه سعر {$symbol} +notification_price_alert_up_description = تم زيادة السعر بمقدار {$price_change} إلى {$price} +notification_price_alert_down_title = 📉 {$symbol} تنبيه السعر +notification_price_alert_down_description = تم تخفيض السعر بمقدار {$price_change} إلى {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} أعلى مستوى على الإطلاق +notification_price_alert_all_time_high_description = وصل {$symbol} إلى أعلى مستوى له على الإطلاق عند {$price}. +notification_nft_sent_title = 🖼️ تم إرسال NFT: {$value} +notification_nft_received_title = 🖼️ تم استلام NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 اشتري {$name} +notification_onboarding_create_import_wallet_title = 💎 إنشاء أو استيراد المحفظة +notification_onboarding_create_import_wallet_description = قم بإنشاء محفظة جديدة أو استيراد محفظتك ببضع نقرات فقط. +notification_onboarding_welcome_description = ابدأ رحلتك مع العملات المشفرة. انقر لإعداد محفظة العملات المشفرة الخاصة بك. +notification_onboarding_buy_asset_description = اشتري {$name} اليوم بشكل آمن - بسيط وموثوق وفوري. +support_new_message_title = رسالة جديدة من الدعم +notification_freeze_title = تجميد {$value} +notification_unfreeze_title = إلغاء تجميد {$value} +notification_reward_title = 💎 لقد ربحت {$value} نقاط! +notification_reward_create_username_description = اسم المستخدم جاهز. ادعُ أصدقاءك لربح المكافآت. +notification_reward_invite_description = تم الانضمام عن طريق الإحالة باستخدام رمزك. +notification_rewards_joined_description = لقد انضممت باستخدام رمز إحالة. +rewards_error_referral_code_not_exist = رمز الإحالة غير موجود +rewards_error_referral_device_already_used = تم استخدام هذا الجهاز بالفعل لتطبيق رمز إحالة +rewards_error_referral_cannot_refer_self = لا يمكنك استخدام رمز الإحالة الخاص بك +rewards_error_referral_rewards_not_enabled = المكافآت غير مفعلة لهذا المستخدم +rewards_error_referral_limit_reached = لم نتمكن من التحقق من أهليتك لبرنامج الإحالة. تُمنح مكافآت الإحالة لدعوة الأصدقاء الذين تعرفهم، وتتطلب جهازًا وعنوان محفظة لم يُستخدما للإحالات من قبل. +rewards_error_referral_referrer_limit_reached = لقد وصل رمز الإحالة إلى الحد الأقصى المسموح به. +errors_generic = حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى لاحقاً. +rewards_error_referral_country_ineligible = خدمة الإحالات غير متاحة حاليًا لبلدك: {$value}. +notification_rewards_disabled_title = تم تعطيل رمز الإحالة +notification_rewards_disabled_description = تم تعطيل رمز الإحالة هذا بسبب الانتهاكات المتكررة لشروط برنامج الإحالة الخاص بنا. +rewards_error_referral_eligibility_expired = يجب تطبيق رموز الإحالة في غضون {$value} أيام من إنشاء اسم المستخدم الخاص بك. +notification_reward_pending_title = 💎 إحالة جديدة +notification_reward_pending_description = تم استخدام رمز الإحالة الخاص بك. جارٍ التحقق. +rewards_error_username_daily_limit_reached = تم الوصول إلى الحد الأقصى اليومي لإنشاء أسماء المستخدمين. يرجى المحاولة مرة أخرى غداً. +notification_price_alert_target_title = 🎯 {$symbol} وصل {$price} +notification_price_alert_target_description = الآن في {$price} ({$change}). +notification_stake_rewards_title = 🎁 مكافآت التخزين +notification_stake_rewards_description = {$value} المكافآت متاحة لـ {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/de/localizer.ftl b/core/crates/localizer/i18n/de/localizer.ftl new file mode 100644 index 0000000000..08368feed4 --- /dev/null +++ b/core/crates/localizer/i18n/de/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Gesendet: {$value} +notification_sent_description = An {$address} +notification_received_title = 💰 Empfangen: {$value} +notification_received_description = Von {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Belohnungen einfordern {$value} +notification_withdraw_title = 🔓 Stake {$value} abheben +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} zu {$validator} +notification_swap_title = 🔄 Swap von {$from_symbol} zu {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} Preisalarm +notification_price_alert_up_description = Preis erhöht um {$price_change} auf {$price} +notification_price_alert_down_title = 📉 {$symbol} Preisalarm +notification_price_alert_down_description = Preis um {$price_change} auf {$price} gesenkt +notification_price_alert_all_time_high_title = 🔥 {$symbol} Allzeithoch +notification_price_alert_all_time_high_description = {$symbol} hat mit {$price} ein neues Allzeithoch erreicht. +notification_nft_sent_title = 🖼️ Gesendetes NFT: {$value} +notification_nft_received_title = 🖼️ NFT erhalten: {$value} +notification_onboarding_buy_asset_title = 🚀 Kaufen Sie {$name} +notification_onboarding_create_import_wallet_title = 💎 Wallet erstellen oder importieren +notification_onboarding_create_import_wallet_description = Erstellen Sie mit nur wenigen Fingertipps eine neue Brieftasche oder importieren Sie Ihre eigene. +notification_onboarding_welcome_description = Beginnen Sie Ihre Krypto-Reise. Tippen Sie hier, um Ihr Krypto-Wallet einzurichten. +notification_onboarding_buy_asset_description = Kaufen Sie {$name} noch heute sicher – einfach, zuverlässig und sofort. +support_new_message_title = Neue Nachricht vom Support +notification_freeze_title = {$value} einfrieren +notification_unfreeze_title = {$value} freigeben +notification_reward_title = 💎 Du hast verdient {$value} Punkte! +notification_reward_create_username_description = Benutzername bereit. Lade Freunde ein, um Belohnungen zu erhalten. +notification_reward_invite_description = Ein geworbener Nutzer hat sich über Ihren Code angemeldet. +notification_rewards_joined_description = Du hast dich mit einem Empfehlungscode angemeldet. +rewards_error_referral_code_not_exist = Es existiert kein Empfehlungscode. +rewards_error_referral_device_already_used = Dieses Gerät wurde bereits zur Anwendung eines Empfehlungscodes verwendet. +rewards_error_referral_cannot_refer_self = Sie können keinen eigenen Empfehlungscode verwenden. +rewards_error_referral_rewards_not_enabled = Für diesen Benutzer sind Prämien nicht aktiviert. +rewards_error_referral_limit_reached = Wir konnten Ihre Empfehlungsberechtigung nicht überprüfen. Empfehlungsprämien erhalten Sie für das Einladen von Freunden, die Sie kennen. Dafür benötigen Sie ein Gerät und eine Wallet-Adresse, die zuvor noch nicht für Empfehlungen verwendet wurden. +rewards_error_referral_referrer_limit_reached = Der maximale Betrag an Empfehlungscodes ist erreicht. +errors_generic = Es ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut. +rewards_error_referral_country_ineligible = Für Ihr Land sind derzeit keine Überweisungen möglich: {$value}Die +notification_rewards_disabled_title = Empfehlungscode deaktiviert +notification_rewards_disabled_description = Dieser Empfehlungscode wurde aufgrund wiederholter Verstöße gegen die Bedingungen unseres Empfehlungsprogramms deaktiviert. +rewards_error_referral_eligibility_expired = Empfehlungscodes müssen innerhalb von {$value} Tage bis zur Erstellung Ihres Benutzernamens. +notification_reward_pending_title = 💎 Neue Empfehlung +notification_reward_pending_description = Jemand hat Ihren Empfehlungscode verwendet. Überprüfung ausstehend. +rewards_error_username_daily_limit_reached = Das tägliche Limit für die Erstellung von Benutzernamen ist erreicht. Bitte versuchen Sie es morgen erneut. +notification_price_alert_target_title = 🎯 {$symbol} erreicht {$price} +notification_price_alert_target_description = Jetzt bei {$price} ({$change}). +notification_stake_rewards_title = 🎁 Staking-Belohnungen +notification_stake_rewards_description = {$value} verfügbare Prämien für {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/en/localizer.ftl b/core/crates/localizer/i18n/en/localizer.ftl new file mode 100644 index 0000000000..dba91c6365 --- /dev/null +++ b/core/crates/localizer/i18n/en/localizer.ftl @@ -0,0 +1,64 @@ +notification_sent_title = 💸 Sent: {$value} +notification_sent_description = To {$address} +notification_received_title = 💰 Received: {$value} +notification_received_description = From {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 Swap from {$from_symbol} to {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} Price Alert +notification_price_alert_up_description = Price increased by {$price_change} to {$price} +notification_price_alert_down_title = 📉 {$symbol} Price Alert +notification_price_alert_down_description = Price decreased by {$price_change} to {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} All-Time High +notification_price_alert_all_time_high_description = {$symbol} has reached a new all-time high at {$price}. +notification_nft_sent_title = 🖼️ Sent NFT: {$value} +notification_nft_received_title = 🖼️ Received NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 Buy {$name} +notification_onboarding_create_import_wallet_title = 💎 Create or Import Wallet +notification_onboarding_create_import_wallet_description = Create a new wallet or import yours in just few taps. +notification_onboarding_welcome_description = Start your crypto journey. Tap to set up your crypto wallet. +notification_onboarding_buy_asset_description = Securely buy {$name} today—simple, reliable, and instant. +notification_fiat_purchase_title = 🚀 Bought {$value} +notification_fiat_sale_title = 💰 Sold {$value} +support_new_message_title = New message from Support +notification_freeze_title = Freeze {$value} +notification_unfreeze_title = Unfreeze {$value} +notification_reward_title = 💎 You earned {$value} points! +notification_reward_create_username_description = Username ready. Invite friends to earn rewards. +notification_reward_invite_description = Referral joined using your code. +notification_rewards_joined_description = You joined using a referral code. +rewards_error_referral_code_not_exist = Referral code does not exist +rewards_error_referral_device_already_used = This device has already been used to apply a referral code +rewards_error_referral_cannot_refer_self = Cannot use your own referral code +rewards_error_referral_rewards_not_enabled = Rewards are not enabled for this user +rewards_error_referral_limit_reached = We couldn’t verify your referral eligibility. Referral rewards are for inviting friends you know and require a device and wallet address that haven’t been used for referrals before. +rewards_error_referral_referrer_limit_reached = Referral code has reached their limit. +errors_generic = An unexpected error occurred. Please try again later. +rewards_error_referral_country_ineligible = Referrals are currently unavailable for your country: {$value}. +notification_rewards_disabled_title = Referral code deactivated +notification_rewards_disabled_description = This referral code has been disabled due to repeated violations of our referral program terms. +rewards_error_referral_eligibility_expired = Referral codes must be applied within {$value} days of creating your username. +notification_reward_pending_title = 💎 New Referral +notification_reward_pending_description = Someone used your referral code. Pending verification. +rewards_error_username_daily_limit_reached = Daily username creation limit has been reached. Please try again tomorrow. +notification_price_alert_target_title = 🎯 {$symbol} reached {$price} +notification_price_alert_target_description = Now at {$price} ({$change}). +notification_stake_rewards_title = 🎁 Staking Rewards +notification_stake_rewards_description = {$value} rewards available to on {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_rewards_redeem_points_for_description = You redeemed {$points} points for {$value}. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 diff --git a/core/crates/localizer/i18n/es/localizer.ftl b/core/crates/localizer/i18n/es/localizer.ftl new file mode 100644 index 0000000000..80d878e482 --- /dev/null +++ b/core/crates/localizer/i18n/es/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Enviado: {$value} +notification_sent_description = Para {$address} +notification_received_title = 💰 Recibido: {$value} +notification_received_description = En {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 Cambiar de {$from_symbol} a {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Prueba +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} Alerta de precio +notification_price_alert_up_description = El precio aumentó en {$price_change} a {$price} +notification_price_alert_down_title = 📉 {$symbol} Alerta de precio +notification_price_alert_down_description = El precio se redujo en {$price_change} a {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} Máximo histórico +notification_price_alert_all_time_high_description = {$symbol} ha alcanzado un nuevo máximo histórico en {$price}. +notification_nft_sent_title = 🖼️ NFT enviado: {$value} +notification_nft_received_title = 🖼️ NFT recibido: {$value} +notification_onboarding_buy_asset_title = 🚀 Comprar {$name} +notification_onboarding_create_import_wallet_title = 💎 Crear o importar una billetera +notification_onboarding_create_import_wallet_description = Crea una nueva billetera o importa la tuya con solo unos pocos toques. +notification_onboarding_welcome_description = Empieza tu aventura con las criptomonedas. Toca para configurar tu billetera. +notification_onboarding_buy_asset_description = Compre {$name} de forma segura hoy: simple, confiable e instantáneo. +support_new_message_title = Nuevo mensaje de Soporte +notification_freeze_title = Congelar {$value} +notification_unfreeze_title = Descongelar {$value} +notification_reward_title = 💎 Te lo ganaste {$value} ¡agujas! +notification_reward_create_username_description = Usuario listo. Invita a tus amigos para ganar recompensas. +notification_reward_invite_description = El referido se unió usando tu código. +notification_rewards_joined_description = Te uniste usando un código de referencia. +rewards_error_referral_code_not_exist = El código de referencia no existe +rewards_error_referral_device_already_used = Este dispositivo ya se ha utilizado para aplicar un código de referencia +rewards_error_referral_cannot_refer_self = No puedes usar tu propio código de referencia +rewards_error_referral_rewards_not_enabled = Las recompensas no están habilitadas para este usuario +rewards_error_referral_limit_reached = No pudimos verificar tu elegibilidad para la recomendación. Las recompensas por recomendación se otorgan por invitar a amigos que conoces y requieren un dispositivo y una dirección de billetera que no se hayan usado para recomendaciones anteriormente. +rewards_error_referral_referrer_limit_reached = El código de referencia ha alcanzado su límite. +errors_generic = Se produjo un error inesperado. Inténtelo de nuevo más tarde. +rewards_error_referral_country_ineligible = Las referencias no están disponibles actualmente para tu país: {$value}. +notification_rewards_disabled_title = Código de referencia desactivado +notification_rewards_disabled_description = Este código de referencia ha sido deshabilitado debido a repetidas violaciones de los términos de nuestro programa de referencia. +rewards_error_referral_eligibility_expired = Los códigos de referencia deben aplicarse dentro de {$value} Días de crear tu nombre de usuario. +notification_reward_pending_title = 💎 Nueva referencia +notification_reward_pending_description = Alguien usó tu código de referencia. Pendiente de verificación. +rewards_error_username_daily_limit_reached = Se ha alcanzado el límite diario de creación de nombres de usuario. Inténtalo de nuevo mañana. +notification_price_alert_target_title = 🎯 {$symbol} alcanzó {$price} +notification_price_alert_target_description = Ahora en {$price} ({$change}). +notification_stake_rewards_title = Recompensas por participación +notification_stake_rewards_description = {$value} recompensas disponibles para {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/fa/localizer.ftl b/core/crates/localizer/i18n/fa/localizer.ftl new file mode 100644 index 0000000000..4cd374ffc6 --- /dev/null +++ b/core/crates/localizer/i18n/fa/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 ارسال شده: {$value} +notification_sent_description = به {$address} +notification_received_title = 💰 دریافتی: {$value} +notification_received_description = از {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 از {$from_symbol} به {$to_symbol} جابه‌جا شوید +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} هشدار قیمت +notification_price_alert_up_description = قیمت با {$price_change} به {$price} افزایش یافت +notification_price_alert_down_title = 📉 {$symbol} هشدار قیمت +notification_price_alert_down_description = قیمت با {$price_change} به {$price} کاهش یافت +notification_price_alert_all_time_high_title = 🔥 {$symbol} بالاترین زمان +notification_price_alert_all_time_high_description = {$symbol} به بالاترین حد خود در {$price} رسیده است. +notification_nft_sent_title = 🖼️ NFT ارسال شده: {$value} +notification_nft_received_title = 🖼️ NFT دریافتی: {$value} +notification_onboarding_buy_asset_title = 🚀 خرید {$name} +notification_onboarding_create_import_wallet_title = 💎 ایجاد یا وارد کردن کیف پول +notification_onboarding_create_import_wallet_description = فقط با چند لمس، یک کیف پول جدید ایجاد کنید یا کیف پول خودتان را وارد کنید. +notification_onboarding_welcome_description = سفر کریپتویی خود را آغاز کنید. برای تنظیم کیف پول کریپتوی خود، روی ضربه بزنید. +notification_onboarding_buy_asset_description = همین امروز با خیال راحت از {$name} خرید کنید - ساده، قابل اعتماد و فوری. +support_new_message_title = پیام جدید از پشتیبانی +notification_freeze_title = ثابت کردن {$value} +notification_unfreeze_title = رفع انسداد {$value} +notification_reward_title = 💎 شما سود کردید {$value} امتیاز! +notification_reward_create_username_description = نام کاربری آماده است. دوستان خود را برای کسب جوایز دعوت کنید. +notification_reward_invite_description = ارجاع با استفاده از کد شما پیوست شد. +notification_rewards_joined_description = شما با استفاده از کد معرف عضو شدید. +rewards_error_referral_code_not_exist = کد معرف وجود ندارد +rewards_error_referral_device_already_used = این دستگاه قبلاً برای اعمال کد ارجاع استفاده شده است +rewards_error_referral_cannot_refer_self = نمی‌توانید از کد معرف خودتان استفاده کنید +rewards_error_referral_rewards_not_enabled = امکان دریافت پاداش برای این کاربر وجود ندارد +rewards_error_referral_limit_reached = ما نتوانستیم واجد شرایط بودن شما برای معرفی را تأیید کنیم. جوایز معرفی برای دعوت از دوستانی است که می‌شناسید و به دستگاه و آدرس کیف پولی نیاز دارند که قبلاً برای معرفی استفاده نشده باشند. +rewards_error_referral_referrer_limit_reached = کد معرف به حد مجاز خود رسیده است. +errors_generic = خطای غیرمنتظره‌ای رخ داد. لطفاً بعداً دوباره امتحان کنید. +rewards_error_referral_country_ineligible = در حال حاضر، ارجاعات برای کشور شما در دسترس نیست: {$value}. +notification_rewards_disabled_title = کد معرف غیرفعال شد +notification_rewards_disabled_description = این کد ارجاع به دلیل نقض مکرر شرایط برنامه ارجاع ما غیرفعال شده است. +rewards_error_referral_eligibility_expired = کدهای ارجاع باید در داخل اعمال شوند {$value} روزهایی که نام کاربری خود را ایجاد کرده‌اید. +notification_reward_pending_title = 💎 معرف جدید +notification_reward_pending_description = شخصی از کد معرف شما استفاده کرده است. در انتظار تأیید. +rewards_error_username_daily_limit_reached = محدودیت ایجاد نام کاربری روزانه به پایان رسیده است. لطفاً فردا دوباره امتحان کنید. +notification_price_alert_target_title = 🎯 {$symbol} رسیده {$price} +notification_price_alert_target_description = اکنون در {$price} ({$change}). +notification_stake_rewards_title = 🎁 جوایز شرط بندی +notification_stake_rewards_description = {$value} جوایز موجود برای روشن {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/fr/localizer.ftl b/core/crates/localizer/i18n/fr/localizer.ftl new file mode 100644 index 0000000000..062d15cded --- /dev/null +++ b/core/crates/localizer/i18n/fr/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Envoyé : {$value} +notification_sent_description = Pour {$address} +notification_received_title = 💰Reçu : {$value} +notification_received_description = De {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 Passer de {$from_symbol} à {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 Alerte de prix {$symbol} +notification_price_alert_up_description = Le prix a augmenté de {$price_change} à {$price} +notification_price_alert_down_title = 📉 Alerte de prix {$symbol} +notification_price_alert_down_description = Prix diminué de {$price_change} à {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} Le plus haut historique +notification_price_alert_all_time_high_description = {$symbol} a atteint un nouveau sommet historique à {$price}. +notification_nft_sent_title = 🖼️ NFT envoyé : {$value} +notification_nft_received_title = 🖼️ NFT reçu : {$value} +notification_onboarding_buy_asset_title = 🚀 Achetez {$name} +notification_onboarding_create_import_wallet_title = 💎 Créer ou importer un portefeuille +notification_onboarding_create_import_wallet_description = Créez un nouveau portefeuille ou importez le vôtre en quelques clics. +notification_onboarding_welcome_description = Commencez votre aventure crypto. Appuyez pour configurer votre portefeuille crypto. +notification_onboarding_buy_asset_description = Achetez {$name} en toute sécurité aujourd'hui : simple, fiable et instantané. +support_new_message_title = Nouveau message du support +notification_freeze_title = Geler {$value} +notification_unfreeze_title = Dégeler {$value} +notification_reward_title = 💎 Vous avez gagné {$value} points! +notification_reward_create_username_description = Nom d'utilisateur prêt. Invitez vos amis pour gagner des récompenses. +notification_reward_invite_description = Le parrainage a été effectué en utilisant votre code. +notification_rewards_joined_description = Vous vous êtes inscrit en utilisant un code de parrainage. +rewards_error_referral_code_not_exist = Le code de parrainage n'existe pas. +rewards_error_referral_device_already_used = Cet appareil a déjà été utilisé pour appliquer un code de parrainage +rewards_error_referral_cannot_refer_self = Vous ne pouvez pas utiliser votre propre code de parrainage. +rewards_error_referral_rewards_not_enabled = Les récompenses ne sont pas activées pour cet utilisateur. +rewards_error_referral_limit_reached = Nous n'avons pas pu vérifier votre éligibilité au parrainage. Les récompenses de parrainage sont destinées à récompenser l'invitation d'amis et nécessitent un appareil et une adresse de portefeuille qui n'ont jamais été utilisés pour des parrainages auparavant. +rewards_error_referral_referrer_limit_reached = Le nombre de codes de parrainage a atteint sa limite. +errors_generic = Une erreur inattendue s'est produite. Veuillez réessayer plus tard. +rewards_error_referral_country_ineligible = Les parrainages ne sont actuellement pas disponibles pour votre pays : {$value}. +notification_rewards_disabled_title = Code de parrainage désactivé +notification_rewards_disabled_description = Ce code de parrainage a été désactivé en raison de violations répétées des conditions de notre programme de parrainage. +rewards_error_referral_eligibility_expired = Les codes de parrainage doivent être appliqués dans les {$value} jours de création de votre nom d'utilisateur. +notification_reward_pending_title = 💎 Nouvelle recommandation +notification_reward_pending_description = Quelqu'un a utilisé votre code de parrainage. Vérification en cours. +rewards_error_username_daily_limit_reached = La limite quotidienne de création de noms d'utilisateur a été atteinte. Veuillez réessayer demain. +notification_price_alert_target_title = 🎯 {$symbol} atteint {$price} +notification_price_alert_target_description = Maintenant à {$price} ({$change}). +notification_stake_rewards_title = 🎁 Récompenses de staking +notification_stake_rewards_description = {$value} récompenses disponibles sur {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/he/localizer.ftl b/core/crates/localizer/i18n/he/localizer.ftl new file mode 100644 index 0000000000..22047e02fd --- /dev/null +++ b/core/crates/localizer/i18n/he/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 נשלח: {$value} +notification_sent_description = אל {$address} +notification_received_title = 💰 התקבל: {$value} +notification_received_description = מאת {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 החלפה מ {$from_symbol} ל- {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} התראת מחיר +notification_price_alert_up_description = המחיר עלה ב- {$price_change} ל- {$price} +notification_price_alert_down_title = 📉 {$symbol} התראת מחיר +notification_price_alert_down_description = המחיר ירד ב- {$price_change} ל- {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} שיא כל הזמנים +notification_price_alert_all_time_high_description = {$symbol} הגיע לשיא כל הזמנים ב- {$price}. +notification_nft_sent_title = 🖼️ נשלח NFT: {$value} +notification_nft_received_title = 🖼️ {$value} +notification_onboarding_buy_asset_title = 🚀 קנה את {$name} +notification_onboarding_create_import_wallet_title = 💎 צור או ייבא ארנק +notification_onboarding_create_import_wallet_description = צור ארנק חדש או ייבא ארנק בכמה לחיצות בלבד. +notification_onboarding_welcome_description = התחל את מסע הקריפטו שלך. הקש כדי להגדיר את ארנק הקריפטו שלך. +notification_onboarding_buy_asset_description = קנה את {$name} בצורה מאובטחת עוד היום - פשוט, אמין ומיידי. +support_new_message_title = הודעה חדשה מהתמיכה +notification_freeze_title = הקפאת {$value} +notification_unfreeze_title = הפשר את {$value} +notification_reward_title = 💎 הרווחת {$value} נקודות! +notification_reward_create_username_description = שם המשתמש מוכן. הזמן חברים כדי לצבור פרסים. +notification_reward_invite_description = הפניה הצטרפה באמצעות הקוד שלך. +notification_rewards_joined_description = הצטרפת באמצעות קוד הפניה. +rewards_error_referral_code_not_exist = קוד ההפניה אינו קיים +rewards_error_referral_device_already_used = מכשיר זה כבר שימש להחלת קוד הפניה +rewards_error_referral_cannot_refer_self = לא ניתן להשתמש בקוד ההפניה שלך +rewards_error_referral_rewards_not_enabled = תגמולים אינם מופעלים עבור משתמש זה +rewards_error_referral_limit_reached = לא הצלחנו לאמת את זכאותך להפניה. תגמולי הפניה מיועדים להזמנת חברים שאתה מכיר ודורשים מכשיר וכתובת ארנק שלא שימשו להפניות בעבר. +rewards_error_referral_referrer_limit_reached = קוד ההפניה הגיע למגבלה שלו. +errors_generic = אירעה שגיאה בלתי צפויה. אנא נסה שוב מאוחר יותר. +rewards_error_referral_country_ineligible = הפניות אינן זמינות כעת עבור המדינה שלך: {$value}. +notification_rewards_disabled_title = קוד ההפניה הושבת +notification_rewards_disabled_description = קוד הפניה זה הושבת עקב הפרות חוזרות ונשנות של תנאי תוכנית ההפניות שלנו. +rewards_error_referral_eligibility_expired = יש להחיל קודי הפניה בתוך {$value} ימים של יצירת שם המשתמש שלך. +notification_reward_pending_title = 💎 הפניה חדשה +notification_reward_pending_description = מישהו השתמש בקוד ההפניה שלך. ממתין לאימות. +rewards_error_username_daily_limit_reached = הגעת למגבלת יצירת שמות משתמש יומית. אנא נסה שוב מחר. +notification_price_alert_target_title = 🎯 {$symbol} הגיע {$price} +notification_price_alert_target_description = עכשיו ב {$price} ({$change}). +notification_stake_rewards_title = 🎁 תגמולי הימור +notification_stake_rewards_description = {$value} תגמולים זמינים ל- {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/hi/localizer.ftl b/core/crates/localizer/i18n/hi/localizer.ftl new file mode 100644 index 0000000000..4abd5abe21 --- /dev/null +++ b/core/crates/localizer/i18n/hi/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 भेजा गया: {$value} +notification_sent_description = {$address} को +notification_received_title = 💰 प्राप्त: {$value} +notification_received_description = {$address} से +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 {$from_symbol} से {$to_symbol} पर स्वैप करें +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} मूल्य चेतावनी +notification_price_alert_up_description = कीमत {$price_change} से बढ़कर {$price} हो गई +notification_price_alert_down_title = 📉 {$symbol} मूल्य चेतावनी +notification_price_alert_down_description = कीमत {$price_change} से घटकर {$price} हो गई +notification_price_alert_all_time_high_title = 🔥 {$symbol} सर्वकालिक उच्चतम +notification_price_alert_all_time_high_description = {$symbol} एक नए सर्वकालिक उच्च स्तर {$price} पर पहुंच गया है। +notification_nft_sent_title = 🖼️ भेजा गया NFT: {$value} +notification_nft_received_title = 🖼️ प्राप्त NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 {$name} खरीदें +notification_onboarding_create_import_wallet_title = 💎 वॉलेट बनाएं या आयात करें +notification_onboarding_create_import_wallet_description = बस कुछ ही टैप में नया वॉलेट बनाएं या अपना वॉलेट आयात करें। +notification_onboarding_welcome_description = अपनी क्रिप्टो यात्रा शुरू करें। अपना क्रिप्टो वॉलेट सेट अप करने के लिए टैप करें। +notification_onboarding_buy_asset_description = आज ही सुरक्षित रूप से {$name} खरीदें—सरल, विश्वसनीय और तत्काल। +support_new_message_title = समर्थन से नया संदेश +notification_freeze_title = {$value} को स्थिर करें +notification_unfreeze_title = {$value} को अनफ़्रीज़ करें +notification_reward_title = 💎 आपने कमाया {$value} अंक! +notification_reward_create_username_description = आपका यूज़रनेम तैयार है। इनाम जीतने के लिए दोस्तों को आमंत्रित करें। +notification_reward_invite_description = आपके कोड का उपयोग करके रेफरल ने ज्वाइन किया। +notification_rewards_joined_description = आपने रेफरल कोड का उपयोग करके सदस्यता ली। +rewards_error_referral_code_not_exist = रेफरल कोड मौजूद नहीं है +rewards_error_referral_device_already_used = इस डिवाइस का उपयोग पहले ही रेफरल कोड लागू करने के लिए किया जा चुका है। +rewards_error_referral_cannot_refer_self = आप अपना रेफरल कोड इस्तेमाल नहीं कर सकते। +rewards_error_referral_rewards_not_enabled = इस उपयोगकर्ता के लिए रिवॉर्ड सक्षम नहीं हैं। +rewards_error_referral_limit_reached = हम आपकी रेफरल पात्रता की पुष्टि नहीं कर सके। रेफरल पुरस्कार आपके परिचित मित्रों को आमंत्रित करने के लिए हैं और इसके लिए ऐसे डिवाइस और वॉलेट पते की आवश्यकता होती है जिनका उपयोग पहले कभी रेफरल के लिए नहीं किया गया हो। +rewards_error_referral_referrer_limit_reached = रेफरल कोड की सीमा समाप्त हो गई है। +errors_generic = एक अप्रत्याशित त्रुटि उत्पन्न हुई। कृपया बाद में पुनः प्रयास करें। +rewards_error_referral_country_ineligible = आपके देश के लिए फिलहाल रेफरल उपलब्ध नहीं हैं: {$value}. +notification_rewards_disabled_title = रेफरल कोड निष्क्रिय कर दिया गया है +notification_rewards_disabled_description = हमारे रेफरल प्रोग्राम की शर्तों के बार-बार उल्लंघन के कारण इस रेफरल कोड को निष्क्रिय कर दिया गया है। +rewards_error_referral_eligibility_expired = रेफरल कोड को इसके भीतर लागू किया जाना चाहिए {$value} अपना यूजरनेम बनाने में लगने वाले दिन। +notification_reward_pending_title = 💎 नया रेफरल +notification_reward_pending_description = किसी ने आपका रेफरल कोड इस्तेमाल किया है। सत्यापन की प्रक्रिया जारी है। +rewards_error_username_daily_limit_reached = उपयोगकर्ता नाम बनाने की दैनिक सीमा पूरी हो चुकी है। कृपया कल फिर प्रयास करें। +notification_price_alert_target_title = 🎯 {$symbol} पहुँच गया {$price} +notification_price_alert_target_description = अब में {$price} ({$change}). +notification_stake_rewards_title = 🎁 स्टेकिंग रिवॉर्ड्स +notification_stake_rewards_description = {$value} पुरस्कार उपलब्ध हैं {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/id/localizer.ftl b/core/crates/localizer/i18n/id/localizer.ftl new file mode 100644 index 0000000000..bbfce18df2 --- /dev/null +++ b/core/crates/localizer/i18n/id/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Terkirim: {$value} +notification_sent_description = Untuk {$address} +notification_received_title = 💰 Diterima: {$value} +notification_received_description = Dari {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 Tukar dari {$from_symbol} ke {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} Peringatan Harga +notification_price_alert_up_description = Harga naik sebesar {$price_change} menjadi {$price} +notification_price_alert_down_title = 📉 {$symbol} Peringatan Harga +notification_price_alert_down_description = Harga turun sebesar {$price_change} menjadi {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} Tertinggi Sepanjang Masa +notification_price_alert_all_time_high_description = {$symbol} telah mencapai titik tertinggi baru sepanjang masa di {$price}. +notification_nft_sent_title = 🖼️ NFT yang dikirim: {$value} +notification_nft_received_title = 🖼️ NFT yang diterima: {$value} +notification_onboarding_buy_asset_title = 🚀 Beli {$name} +notification_onboarding_create_import_wallet_title = 💎 Buat atau Impor Dompet +notification_onboarding_create_import_wallet_description = Buat dompet baru atau impor dompet Anda hanya dalam beberapa ketukan. +notification_onboarding_welcome_description = Mulailah perjalanan kripto Anda. Ketuk untuk menyiapkan dompet kripto Anda. +notification_onboarding_buy_asset_description = Beli {$name} dengan aman hari ini—mudah, andal, dan instan. +support_new_message_title = Pesan baru dari Dukungan +notification_freeze_title = Bekukan {$value} +notification_unfreeze_title = Cairkan {$value} +notification_reward_title = 💎 Anda telah mendapatkan {$value} poin! +notification_reward_create_username_description = Nama pengguna sudah siap. Undang teman untuk mendapatkan hadiah. +notification_reward_invite_description = Bergabung melalui referensi menggunakan kode Anda. +notification_rewards_joined_description = Anda bergabung menggunakan kode referensi. +rewards_error_referral_code_not_exist = Kode referensi tidak ada. +rewards_error_referral_device_already_used = Perangkat ini sudah digunakan untuk menerapkan kode referensi. +rewards_error_referral_cannot_refer_self = Tidak dapat menggunakan kode referensi Anda sendiri +rewards_error_referral_rewards_not_enabled = Program hadiah tidak diaktifkan untuk pengguna ini. +rewards_error_referral_limit_reached = Kami tidak dapat memverifikasi kelayakan rujukan Anda. Hadiah rujukan diberikan untuk mengundang teman yang Anda kenal dan memerlukan perangkat serta alamat dompet yang belum pernah digunakan untuk rujukan sebelumnya. +rewards_error_referral_referrer_limit_reached = Kode referensi telah mencapai batasnya. +errors_generic = Terjadi kesalahan yang tidak terduga. Silakan coba lagi nanti. +rewards_error_referral_country_ineligible = Saat ini, program rujukan tidak tersedia untuk negara Anda: {$value}. +notification_rewards_disabled_title = Kode referensi dinonaktifkan +notification_rewards_disabled_description = Kode referensi ini telah dinonaktifkan karena pelanggaran berulang terhadap ketentuan program referensi kami. +rewards_error_referral_eligibility_expired = Kode referensi harus diterapkan dalam {$value} hari-hari untuk membuat nama pengguna Anda. +notification_reward_pending_title = 💎 Referensi Baru +notification_reward_pending_description = Seseorang telah menggunakan kode referensi Anda. Sedang dalam proses verifikasi. +rewards_error_username_daily_limit_reached = Batas pembuatan nama pengguna harian telah tercapai. Silakan coba lagi besok. +notification_price_alert_target_title = 🎯 {$symbol} dicapai {$price} +notification_price_alert_target_description = Sekarang jam {$price} ({$change}). +notification_stake_rewards_title = 🎁 Hadiah Staking +notification_stake_rewards_description = {$value} hadiah yang tersedia untuk {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/it/localizer.ftl b/core/crates/localizer/i18n/it/localizer.ftl new file mode 100644 index 0000000000..94916b030e --- /dev/null +++ b/core/crates/localizer/i18n/it/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Inviato: {$value} +notification_sent_description = A {$address} +notification_received_title = 💰 Ricevuto: {$value} +notification_received_description = Da {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 Passa da {$from_symbol} a {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} Avviso di prezzo +notification_price_alert_up_description = Il prezzo è aumentato di {$price_change} a {$price} +notification_price_alert_down_title = 📉 {$symbol} Avviso di prezzo +notification_price_alert_down_description = Prezzo diminuito di {$price_change} a {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} Massimo storico +notification_price_alert_all_time_high_description = {$symbol} ha raggiunto un nuovo massimo storico a {$price}. +notification_nft_sent_title = 🖼️ NFT inviato: {$value} +notification_nft_received_title = 🖼️ NFT ricevuto: {$value} +notification_onboarding_buy_asset_title = 🚀 Acquista {$name} +notification_onboarding_create_import_wallet_title = 💎 Crea o importa un portafoglio +notification_onboarding_create_import_wallet_description = Crea un nuovo portafoglio o importa il tuo in pochi tocchi. +notification_onboarding_welcome_description = Inizia il tuo viaggio nel mondo delle criptovalute. Tocca per configurare il tuo portafoglio. +notification_onboarding_buy_asset_description = Acquista {$name} oggi stesso in modo sicuro: semplice, affidabile e immediato. +support_new_message_title = Nuovo messaggio dal supporto +notification_freeze_title = Congela {$value} +notification_unfreeze_title = Sblocca {$value} +notification_reward_title = 💎 Hai guadagnato {$value} punti! +notification_reward_create_username_description = Nome utente pronto. Invita gli amici per guadagnare premi. +notification_reward_invite_description = Referral iscritto tramite il tuo codice. +notification_rewards_joined_description = Ti sei iscritto utilizzando un codice di riferimento. +rewards_error_referral_code_not_exist = Il codice di riferimento non esiste +rewards_error_referral_device_already_used = Questo dispositivo è già stato utilizzato per applicare un codice di riferimento +rewards_error_referral_cannot_refer_self = Non è possibile utilizzare il proprio codice di riferimento +rewards_error_referral_rewards_not_enabled = I premi non sono abilitati per questo utente +rewards_error_referral_limit_reached = Non siamo riusciti a verificare la tua idoneità al referral. I premi per i referral servono per invitare amici che conosci e richiedono un dispositivo e un indirizzo wallet che non siano mai stati utilizzati per i referral in precedenza. +rewards_error_referral_referrer_limit_reached = Il codice di riferimento ha raggiunto il limite. +errors_generic = Si è verificato un errore imprevisto. Riprova più tardi. +rewards_error_referral_country_ineligible = Al momento i referral non sono disponibili per il tuo Paese: {$value}. +notification_rewards_disabled_title = Codice di riferimento disattivato +notification_rewards_disabled_description = Questo codice di riferimento è stato disattivato a causa di ripetute violazioni dei termini del nostro programma di riferimento. +rewards_error_referral_eligibility_expired = I codici di riferimento devono essere applicati entro {$value} giorni dalla creazione del tuo nome utente. +notification_reward_pending_title = 💎 Nuovo referral +notification_reward_pending_description = Qualcuno ha utilizzato il tuo codice di riferimento. In attesa di verifica. +rewards_error_username_daily_limit_reached = È stato raggiunto il limite giornaliero di creazione di nomi utente. Riprova domani. +notification_price_alert_target_title = 🎯 {$symbol} raggiunto {$price} +notification_price_alert_target_description = Adesso a {$price} ({$change}). +notification_stake_rewards_title = 🎁 Ricompense per lo Staking +notification_stake_rewards_description = {$value} premi disponibili su {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/ja/localizer.ftl b/core/crates/localizer/i18n/ja/localizer.ftl new file mode 100644 index 0000000000..d28153454c --- /dev/null +++ b/core/crates/localizer/i18n/ja/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 送信済み: {$value} +notification_sent_description = {$address}宛 +notification_received_title = 💰 受信: {$value} +notification_received_description = {$address}から +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 {$from_symbol}から{$to_symbol}に交換 +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol}価格アラート +notification_price_alert_up_description = 価格が{$price_change}増加して{$price}になりました +notification_price_alert_down_title = 📉 {$symbol}価格アラート +notification_price_alert_down_description = 価格が{$price_change}から{$price}に下がりました +notification_price_alert_all_time_high_title = 🔥 {$symbol}史上最高値 +notification_price_alert_all_time_high_description = {$symbol} {$price}で史上最高値に達しました。 +notification_nft_sent_title = 🖼️ 送信したNFT: {$value} +notification_nft_received_title = 🖼️ 受け取ったNFT: {$value} +notification_onboarding_buy_asset_title = 🚀 {$name}を購入する +notification_onboarding_create_import_wallet_title = 💎 ウォレットを作成またはインポートする +notification_onboarding_create_import_wallet_description = 数回タップするだけで、新しいウォレットを作成したり、既存のウォレットをインポートしたりできます。 +notification_onboarding_welcome_description = 暗号通貨の旅を始めましょう。タップして暗号通貨ウォレットを設定しましょう。 +notification_onboarding_buy_asset_description = 今すぐ{$name}を安全に購入しましょう。シンプル、信頼性が高く、即時にご利用いただけます。 +support_new_message_title = サポートからの新しいメッセージ +notification_freeze_title = {$value}を凍結 +notification_unfreeze_title = {$value}を解凍 +notification_reward_title = 💎 獲得しました {$value} ポイント! +notification_reward_create_username_description = ユーザー名の準備ができました。友達を招待して特典を獲得しましょう。 +notification_reward_invite_description = 紹介者はあなたのコードを使用して参加しました。 +notification_rewards_joined_description = 紹介コードを使用して参加しました。 +rewards_error_referral_code_not_exist = 紹介コードが存在しません +rewards_error_referral_device_already_used = このデバイスはすでに紹介コードを適用するために使用されています +rewards_error_referral_cannot_refer_self = 独自の紹介コードは使用できません +rewards_error_referral_rewards_not_enabled = このユーザーに対して特典は有効になっていません +rewards_error_referral_limit_reached = ご紹介資格を確認できませんでした。ご紹介特典は、お知り合いを招待することで獲得できます。ご紹介特典を受け取るには、これまでご紹介に使用されたことのないデバイスとウォレットアドレスが必要です。 +rewards_error_referral_referrer_limit_reached = 紹介コードが制限に達しました。 +errors_generic = 予期しないエラーが発生しました。しばらくしてからもう一度お試しください。 +rewards_error_referral_country_ineligible = 現在、あなたの国では紹介はご利用いただけません: {$value}。 +notification_rewards_disabled_title = 紹介コードが無効になりました +notification_rewards_disabled_description = この紹介コードは、紹介プログラムの規約を繰り返し違反したため無効になりました。 +rewards_error_referral_eligibility_expired = 紹介コードは、 {$value} ユーザー名を作成してから数日経過します。 +notification_reward_pending_title = 💎 新規紹介 +notification_reward_pending_description = 誰かがあなたの紹介コードを使用しました。確認待ちです。 +rewards_error_username_daily_limit_reached = 1日のユーザー名作成数の上限に達しました。明日もう一度お試しください。 +notification_price_alert_target_title = 🎯 {$symbol} 到達した {$price} +notification_price_alert_target_description = 現在 {$price} ({$change})。 +notification_stake_rewards_title = 🎁 ステーキング報酬 +notification_stake_rewards_description = {$value} 利用可能な報酬 {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/ko/localizer.ftl b/core/crates/localizer/i18n/ko/localizer.ftl new file mode 100644 index 0000000000..a54a08133d --- /dev/null +++ b/core/crates/localizer/i18n/ko/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 전송됨: {$value} +notification_sent_description = {$address} 로 +notification_received_title = 💰 수신: {$value} +notification_received_description = {$address} 에서 +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 {$from_symbol} 에서 {$to_symbol} 로 교환 +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} 가격 알림 +notification_price_alert_up_description = 가격이 {$price_change} 상승하여 {$price} 로 변경되었습니다. +notification_price_alert_down_title = 📉 {$symbol} 가격 알림 +notification_price_alert_down_description = 가격이 {$price_change} 하락하여 {$price} 로 변경되었습니다. +notification_price_alert_all_time_high_title = 🔥 {$symbol} 역대 최고 +notification_price_alert_all_time_high_description = {$symbol} {$price} 에서 새로운 최고가를 기록했습니다. +notification_nft_sent_title = 🖼️ NFT 전송: {$value} +notification_nft_received_title = 🖼️ 받은 NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 {$name} 구매 +notification_onboarding_create_import_wallet_title = 💎 지갑 생성 또는 가져오기 +notification_onboarding_create_import_wallet_description = 몇 번만 탭하면 새로운 지갑을 만들거나 기존 지갑을 가져올 수 있습니다. +notification_onboarding_welcome_description = 암호화폐 여정을 시작하세요. 탭하여 암호화폐 지갑을 설정하세요. +notification_onboarding_buy_asset_description = 오늘 {$name} 안전하게 구매하세요. 간편하고 안정적이며 즉각적입니다. +support_new_message_title = 지원팀의 새 메시지 +notification_freeze_title = {$value} 동결 +notification_unfreeze_title = {$value} 동결 해제 +notification_reward_title = 💎 획득하셨습니다 {$value} 전철기! +notification_reward_create_username_description = 사용자 이름이 준비되었습니다. 친구를 초대하여 보상을 받으세요. +notification_reward_invite_description = 추천 코드를 사용하여 가입했습니다. +notification_rewards_joined_description = 추천 코드를 사용하여 가입하셨습니다. +rewards_error_referral_code_not_exist = 추천 코드가 존재하지 않습니다. +rewards_error_referral_device_already_used = 이 기기는 이미 추천 코드를 적용하는 데 사용되었습니다. +rewards_error_referral_cannot_refer_self = 본인의 추천 코드를 사용할 수 없습니다. +rewards_error_referral_rewards_not_enabled = 이 사용자에게는 보상 기능이 활성화되어 있지 않습니다. +rewards_error_referral_limit_reached = 추천 자격 여부를 확인할 수 없습니다. 추천 보상은 아는 친구를 초대했을 때 지급되며, 이전에 추천에 사용된 적이 없는 기기와 지갑 주소가 필요합니다. +rewards_error_referral_referrer_limit_reached = 추천 코드 사용 횟수가 초과되었습니다. +errors_generic = 예기치 않은 오류가 발생했습니다. 나중에 다시 시도해 주세요. +rewards_error_referral_country_ineligible = 현재 귀하의 국가에서는 추천 서비스를 이용하실 수 없습니다. {$value}. +notification_rewards_disabled_title = 추천 코드가 비활성화되었습니다. +notification_rewards_disabled_description = 이 추천 코드는 추천 프로그램 약관을 반복적으로 위반하여 사용이 중단되었습니다. +rewards_error_referral_eligibility_expired = 추천 코드는 반드시 다음 기간 내에 적용해야 합니다. {$value} 사용자 이름을 만드는 데 며칠이 걸립니다. +notification_reward_pending_title = 💎 신규 추천 +notification_reward_pending_description = 누군가 당신의 추천 코드를 사용했습니다. 확인 중입니다. +rewards_error_username_daily_limit_reached = 일일 사용자 이름 생성 한도에 도달했습니다. 내일 다시 시도해 주세요. +notification_price_alert_target_title = 🎯 {$symbol} 도달했다 {$price} +notification_price_alert_target_description = 지금 {$price} ({$change}). +notification_stake_rewards_title = 🎁 스테이킹 보상 +notification_stake_rewards_description = {$value} 다음에서 이용 가능한 보상 {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/pl/localizer.ftl b/core/crates/localizer/i18n/pl/localizer.ftl new file mode 100644 index 0000000000..4f0a6bca46 --- /dev/null +++ b/core/crates/localizer/i18n/pl/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Wysłano: {$value} +notification_sent_description = Do {$address} +notification_received_title = 💰 Otrzymano: {$value} +notification_received_description = Z {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 Zamień z {$from_symbol} na {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 Alert cenowy {$symbol} +notification_price_alert_up_description = Cena wzrosła o {$price_change} do {$price} +notification_price_alert_down_title = 📉 Alert cenowy {$symbol} +notification_price_alert_down_description = Cena obniżona o {$price_change} do {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} najwyższy poziom wszech czasów +notification_price_alert_all_time_high_description = {$symbol} osiągnął nowy, najwyższy poziom wszech czasów na poziomie {$price}. +notification_nft_sent_title = 🖼️ Wysłano NFT: {$value} +notification_nft_received_title = 🖼️ Otrzymano NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 Kup {$name} +notification_onboarding_create_import_wallet_title = 💎 Utwórz lub zaimportuj portfel +notification_onboarding_create_import_wallet_description = Utwórz nowy portfel lub zaimportuj swój za pomocą kilku kliknięć. +notification_onboarding_welcome_description = Rozpocznij swoją podróż kryptowalutową. Stuknij, aby skonfigurować portfel kryptowalutowy. +notification_onboarding_buy_asset_description = Kup {$name} bezpiecznie już dziś — prosto, niezawodnie i natychmiast. +support_new_message_title = Nowa wiadomość od Wsparcia +notification_freeze_title = Zamroź {$value} +notification_unfreeze_title = Odmroź {$value} +notification_reward_title = 💎 Zarobiłeś {$value} zwrotnica! +notification_reward_create_username_description = Nazwa użytkownika gotowa. Zaproś znajomych i zdobądź nagrody. +notification_reward_invite_description = Polecenie zostało dodane przy użyciu Twojego kodu. +notification_rewards_joined_description = Dołączyłeś używając kodu polecającego. +rewards_error_referral_code_not_exist = Kod polecający nie istnieje +rewards_error_referral_device_already_used = To urządzenie zostało już użyte do zastosowania kodu polecającego +rewards_error_referral_cannot_refer_self = Nie można używać własnego kodu polecającego +rewards_error_referral_rewards_not_enabled = Nagrody nie są włączone dla tego użytkownika +rewards_error_referral_limit_reached = Nie mogliśmy zweryfikować Twojej kwalifikowalności do polecenia. Nagrody za polecenia przysługują za zapraszanie znajomych, których znasz, i wymagają urządzenia oraz adresu portfela, które nie były wcześniej używane do poleceń. +rewards_error_referral_referrer_limit_reached = Kod polecający osiągnął limit. +errors_generic = Wystąpił nieoczekiwany błąd. Spróbuj ponownie później. +rewards_error_referral_country_ineligible = Polecenia są obecnie niedostępne dla Twojego kraju: {$value}. +notification_rewards_disabled_title = Kod polecający został dezaktywowany +notification_rewards_disabled_description = Ten kod polecający został wyłączony z powodu powtarzających się naruszeń warunków naszego programu poleceń. +rewards_error_referral_eligibility_expired = Kody polecające muszą zostać zastosowane w ciągu {$value} dni od utworzenia nazwy użytkownika. +notification_reward_pending_title = 💎 Nowe polecenie +notification_reward_pending_description = Ktoś użył Twojego kodu polecającego. Oczekuje na weryfikację. +rewards_error_username_daily_limit_reached = Osiągnięto dzienny limit tworzenia nazw użytkowników. Spróbuj ponownie jutro. +notification_price_alert_target_title = 🎯 {$symbol} osiągnięty {$price} +notification_price_alert_target_description = Teraz w {$price} ({$change}). +notification_stake_rewards_title = 🎁 Nagrody za staking +notification_stake_rewards_description = {$value} nagrody dostępne dla {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/pt-BR/localizer.ftl b/core/crates/localizer/i18n/pt-BR/localizer.ftl new file mode 100644 index 0000000000..968d26fcec --- /dev/null +++ b/core/crates/localizer/i18n/pt-BR/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Enviado: {$value} +notification_sent_description = Para {$address} +notification_received_title = 💰 Recebido: {$value} +notification_received_description = De {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 Trocar de {$from_symbol} para {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Teste +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} Alerta de preço +notification_price_alert_up_description = Preço aumentado em {$price_change} para {$price} +notification_price_alert_down_title = 📉 {$symbol} Alerta de preço +notification_price_alert_down_description = Preço reduzido em {$price_change} para {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} Máximo histórico +notification_price_alert_all_time_high_description = {$symbol} atingiu um novo recorde histórico em {$price}. +notification_nft_sent_title = 🖼️ NFT enviado: {$value} +notification_nft_received_title = 🖼️ NFT recebido: {$value} +notification_onboarding_buy_asset_title = 🚀 Compre {$name} +notification_onboarding_create_import_wallet_title = 💎 Criar ou importar carteira +notification_onboarding_create_import_wallet_description = Crie uma nova carteira ou importe a sua em apenas alguns toques. +notification_onboarding_welcome_description = Comece sua jornada com criptomoedas. Toque para configurar sua carteira de criptomoedas. +notification_onboarding_buy_asset_description = Compre {$name} com segurança hoje mesmo — simples, confiável e instantâneo. +support_new_message_title = Nova mensagem do Suporte +notification_freeze_title = Congelar {$value} +notification_unfreeze_title = Descongelar {$value} +notification_reward_title = 💎 Você ganhou {$value} pontos! +notification_reward_create_username_description = Nome de usuário pronto. Convide amigos para ganhar recompensas. +notification_reward_invite_description = A pessoa indicada entrou usando seu código. +notification_rewards_joined_description = Você se cadastrou usando um código de indicação. +rewards_error_referral_code_not_exist = O código de indicação não existe. +rewards_error_referral_device_already_used = Este dispositivo já foi utilizado para aplicar um código de referência. +rewards_error_referral_cannot_refer_self = Não é possível usar seu próprio código de indicação. +rewards_error_referral_rewards_not_enabled = As recompensas não estão ativadas para este usuário. +rewards_error_referral_limit_reached = Não foi possível verificar sua elegibilidade para o programa de indicações. As recompensas por indicações são concedidas a quem convida amigos que você conhece e exigem um dispositivo e um endereço de carteira que não tenham sido usados para indicações anteriormente. +rewards_error_referral_referrer_limit_reached = O código de indicação atingiu o limite de usos. +errors_generic = Ocorreu um erro inesperado. Tente novamente mais tarde. +rewards_error_referral_country_ineligible = No momento, não há encaminhamentos disponíveis para o seu país: {$value}. +notification_rewards_disabled_title = Código de indicação desativado +notification_rewards_disabled_description = Este código de indicação foi desativado devido a repetidas violações dos termos do nosso programa de indicações. +rewards_error_referral_eligibility_expired = Os códigos de referência devem ser aplicados dentro de {$value} dias para criar seu nome de usuário. +notification_reward_pending_title = 💎 Nova Indicação +notification_reward_pending_description = Alguém usou seu código de indicação. Aguardando verificação. +rewards_error_username_daily_limit_reached = O limite diário de criação de nomes de usuário foi atingido. Tente novamente amanhã. +notification_price_alert_target_title = 🎯 {$symbol} alcançado {$price} +notification_price_alert_target_description = Agora em {$price} ({$change}). +notification_stake_rewards_title = 🎁 Recompensas de staking +notification_stake_rewards_description = {$value} recompensas disponíveis para em {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/ru/localizer.ftl b/core/crates/localizer/i18n/ru/localizer.ftl new file mode 100644 index 0000000000..109e145b17 --- /dev/null +++ b/core/crates/localizer/i18n/ru/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Отправлено: {$value} +notification_sent_description = На {$address} +notification_received_title = 💰 Получено: {$value} +notification_received_description = От {$address} +notification_unstake_title = 🔒 Отменен стейкинг {$value} +notification_claim_rewards_title = 🎁 Получены награды {$value} +notification_withdraw_title = 🔓 Выведен стейкинг {$value} +notification_redelegate_title = 🔄 Переделегировано {$value} +notification_redelegate_validator_title = 🔄 Переделегировано {$value} в ${validator} +notification_swap_title = 🔄 Обмен с {$from_symbol} на {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Тест +notification_token_approval_title = ✅ Одобрен токен {$token} +notification_stake_title = 🔒 Стейк {$value} +notification_price_alert_up_title = 📈 {$symbol} Оповещение о цене +notification_price_alert_up_description = Цена увеличилась на {$price_change} до {$price} +notification_price_alert_down_title = 📉 {$symbol} Уведомление о ценах +notification_price_alert_down_description = Цена снизилась на {$price_change} до {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} Абсолютный максимум +notification_price_alert_all_time_high_description = {$symbol} достиг нового исторического максимума на уровне {$price}. +notification_nft_sent_title = 🖼️ Отправлено NFT: {$value} +notification_nft_received_title = 🖼️ Получено NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 Купить {$name} +notification_onboarding_create_import_wallet_title = 💎 Создать или импортировать кошелек +notification_onboarding_create_import_wallet_description = Создайте новый кошелек или импортируйте свой всего за несколько нажатий. +notification_onboarding_welcome_description = Начните свое криптопутешествие. Нажмите, чтобы настроить свой криптокошелек. +notification_onboarding_buy_asset_description = Безопасно купите {$name} сегодня — просто, надежно и мгновенно. +support_new_message_title = Новое сообщение от службы поддержки +notification_freeze_title = Заморозить {$value} +notification_unfreeze_title = Разморозить {$value} +notification_reward_title = 💎 Вы заслужили {$value} баллов! +notification_reward_create_username_description = Имя пользователя готово. Приглашайте друзей, чтобы получать награды. +notification_reward_invite_description = Реферал зарегистрировался, используя ваш код. +notification_rewards_joined_description = Вы зарегистрировались, используя реферальный код. +rewards_error_referral_code_not_exist = Реферальный код не существует. +rewards_error_referral_device_already_used = Это устройство уже использовалось для применения реферального кода. +rewards_error_referral_cannot_refer_self = Невозможно использовать собственный реферальный код +rewards_error_referral_rewards_not_enabled = Для этого пользователя функция начисления вознаграждений не активирована. +rewards_error_referral_limit_reached = Мы не смогли подтвердить ваше право на участие в реферальной программе. Реферальные вознаграждения начисляются за приглашение знакомых друзей и требуют наличия устройства и адреса кошелька, которые ранее не использовались для реферальных программ. +rewards_error_referral_referrer_limit_reached = Реферальный код исчерпан. +errors_generic = Произошла непредвиденная ошибка. Пожалуйста, попробуйте позже. +rewards_error_referral_country_ineligible = В настоящее время реферальные программы недоступны для вашей страны: {$value}. +notification_rewards_disabled_title = Реферальный код деактивирован +notification_rewards_disabled_description = Данный реферальный код отключен из-за неоднократных нарушений условий нашей реферальной программы. +rewards_error_referral_eligibility_expired = Реферальные коды необходимо применить в течение {$value} дней с момента создания вашего имени пользователя. +notification_reward_pending_title = 💎 Новый реферальный аккаунт +notification_reward_pending_description = Кто-то использовал ваш реферальный код. Ожидается подтверждение. +rewards_error_username_daily_limit_reached = Достигнут дневной лимит на создание новых имен пользователей. Пожалуйста, попробуйте завтра. +notification_price_alert_target_title = 🎯 {$symbol} достиг {$price} +notification_price_alert_target_description = Сейчас в {$price} ({$change}). +notification_stake_rewards_title = 🎁 Награды за стейкинг +notification_stake_rewards_description = {$value} вознаграждения, доступные на {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/th/localizer.ftl b/core/crates/localizer/i18n/th/localizer.ftl new file mode 100644 index 0000000000..fb856ff2b9 --- /dev/null +++ b/core/crates/localizer/i18n/th/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 ส่ง: {$value} +notification_sent_description = ถึง {$address} +notification_received_title = 💰 ได้รับ: {$value} +notification_received_description = จาก {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 สลับจาก {$from_symbol} เป็น {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} การแจ้งเตือนราคา +notification_price_alert_up_description = ราคาเพิ่มขึ้น {$price_change} เป็น {$price} +notification_price_alert_down_title = 📉 {$symbol} การแจ้งเตือนราคา +notification_price_alert_down_description = ราคาลดลง {$price_change} เป็น {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} สูงสุดตลอดกาล +notification_price_alert_all_time_high_description = {$symbol} ได้ทำสถิติสูงสุดตลอดกาลใหม่ที่ {$price} +notification_nft_sent_title = 🖼️ ส่ง NFT: {$value} +notification_nft_received_title = 🖼️ ได้รับ NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 ซื้อ {$name} +notification_onboarding_create_import_wallet_title = 💎 สร้างหรือนำเข้ากระเป๋าสตางค์ +notification_onboarding_create_import_wallet_description = สร้างกระเป๋าเงินใหม่หรือโหลดกระเป๋าเงินของคุณเองเพียงไม่กี่แตะ +notification_onboarding_welcome_description = เริ่มต้นการเดินทางสู่โลกคริปโตของคุณ แตะเพื่อตั้งค่ากระเป๋าเงินคริปโตของคุณ +notification_onboarding_buy_asset_description = ซื้อ {$name} อย่างปลอดภัยวันนี้ ง่ายดาย เชื่อถือได้ และทันที +support_new_message_title = ข้อความใหม่จากฝ่ายสนับสนุน +notification_freeze_title = แช่แข็ง {$value} +notification_unfreeze_title = ยกเลิกการแช่แข็ง {$value} +notification_reward_title = 💎 คุณได้รับแล้ว {$value} คะแนน! +notification_reward_create_username_description = ชื่อผู้ใช้พร้อมแล้ว เชิญเพื่อนเพื่อรับรางวัล +notification_reward_invite_description = ผู้เข้าร่วมได้รับการแนะนำโดยใช้รหัสของคุณ +notification_rewards_joined_description = คุณเข้าร่วมโดยใช้รหัสแนะนำ +rewards_error_referral_code_not_exist = ไม่มีรหัสแนะนำ +rewards_error_referral_device_already_used = อุปกรณ์นี้เคยถูกใช้เพื่อใส่รหัสแนะนำมาแล้ว +rewards_error_referral_cannot_refer_self = ไม่สามารถใช้รหัสแนะนำของคุณเองได้ +rewards_error_referral_rewards_not_enabled = ระบบรางวัลไม่ได้เปิดใช้งานสำหรับผู้ใช้รายนี้ +rewards_error_referral_limit_reached = เราไม่สามารถตรวจสอบสิทธิ์การแนะนำของคุณได้ รางวัลการแนะนำมีไว้สำหรับการเชิญเพื่อนที่คุณรู้จัก และต้องใช้อุปกรณ์และที่อยู่กระเป๋าเงินดิจิทัลที่ไม่เคยใช้สำหรับการแนะนำมาก่อน +rewards_error_referral_referrer_limit_reached = รหัสแนะนำเพื่อนถูกใช้งานจนเต็มจำนวนแล้ว +errors_generic = เกิดข้อผิดพลาดที่ไม่คาดคิด โปรดลองใหม่อีกครั้งในภายหลัง +rewards_error_referral_country_ineligible = ขณะนี้ยังไม่สามารถให้บริการส่งต่อผู้ป่วยสำหรับประเทศของคุณได้: {$value}- +notification_rewards_disabled_title = รหัสแนะนำถูกปิดใช้งานแล้ว +notification_rewards_disabled_description = รหัสแนะนำนี้ถูกปิดใช้งานเนื่องจากมีการละเมิดข้อกำหนดของโปรแกรมแนะนำเพื่อนของเราซ้ำหลายครั้ง +rewards_error_referral_eligibility_expired = ต้องใช้รหัสแนะนำภายใน {$value} ใช้เวลาหลายวันในการสร้างชื่อผู้ใช้ของคุณ +notification_reward_pending_title = 💎 แนะนำเพื่อนใหม่ +notification_reward_pending_description = มีคนใช้รหัสแนะนำของคุณแล้ว กำลังรอการตรวจสอบ +rewards_error_username_daily_limit_reached = เนื่องจากได้สร้างชื่อผู้ใช้รายวันครบจำนวนที่กำหนดแล้ว โปรดลองใหม่ในวันพรุ่งนี้ +notification_price_alert_target_title = 🎯 {$symbol} ถึง {$price} +notification_price_alert_target_description = ขณะนี้ที่ {$price} ({$change}) +notification_stake_rewards_title = 🎁 รางวัลจากการฝากเงิน +notification_stake_rewards_description = {$value} รางวัลที่มีให้สำหรับ {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/tr/localizer.ftl b/core/crates/localizer/i18n/tr/localizer.ftl new file mode 100644 index 0000000000..d6851c2279 --- /dev/null +++ b/core/crates/localizer/i18n/tr/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Gönderildi: {$value} +notification_sent_description = {$address} adresine +notification_received_title = 💰 Alındı: {$value} +notification_received_description = {$address} adresinden +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 {$from_symbol} dan {$to_symbol} a geçiş +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} Fiyat Uyarısı +notification_price_alert_up_description = Fiyat {$price_change} artarak {$price} oldu +notification_price_alert_down_title = 📉 {$symbol} Fiyat Uyarısı +notification_price_alert_down_description = Fiyat {$price_change} düşerek {$price} oldu +notification_price_alert_all_time_high_title = 🔥 {$symbol} Tüm Zamanların En Yüksek Seviyesi +notification_price_alert_all_time_high_description = {$symbol} {$price} seviyesinde yeni bir tüm zamanların en yüksek seviyesine ulaştı. +notification_nft_sent_title = 🖼️ Gönderilen NFT: {$value} +notification_nft_received_title = 🖼️ Alınan NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 {$name} satın al +notification_onboarding_create_import_wallet_title = 💎 Cüzdan Oluşturun veya İçe Aktarın +notification_onboarding_create_import_wallet_description = Sadece birkaç dokunuşla yeni bir cüzdan oluşturun veya kendi cüzdanınızı içe aktarın. +notification_onboarding_welcome_description = Kripto yolculuğunuza başlayın. Kripto cüzdanınızı kurmak için dokunun. +notification_onboarding_buy_asset_description = {$name} bugün güvenli bir şekilde satın alın: basit, güvenilir ve anında. +support_new_message_title = Destek'ten yeni mesaj +notification_freeze_title = {$value} dondur +notification_unfreeze_title = {$value} çöz +notification_reward_title = 💎 Bunu hak ettiniz {$value} puanlar! +notification_reward_create_username_description = Kullanıcı adınız hazır. Arkadaşlarınızı davet edin ve ödüller kazanın. +notification_reward_invite_description = Referansınız kullanılarak katılım sağlandı. +notification_rewards_joined_description = Referans kodu kullanarak katıldınız. +rewards_error_referral_code_not_exist = Yönlendirme kodu mevcut değil. +rewards_error_referral_device_already_used = Bu cihaz daha önce bir yönlendirme kodu uygulamak için kullanıldı. +rewards_error_referral_cannot_refer_self = Kendi referans kodunuzu kullanamazsınız. +rewards_error_referral_rewards_not_enabled = Bu kullanıcı için ödüller etkinleştirilmemiş. +rewards_error_referral_limit_reached = Referans uygunluğunuzu doğrulayamadık. Referans ödülleri, tanıdığınız arkadaşlarınızı davet etmeniz karşılığında verilir ve daha önce referans için kullanılmamış bir cihaz ve cüzdan adresi gerektirir. +rewards_error_referral_referrer_limit_reached = Yönlendirme kodu limitine ulaşıldı. +errors_generic = Beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin. +rewards_error_referral_country_ineligible = Şu anda ülkeniz için yönlendirme hizmeti mevcut değil: {$value}. +notification_rewards_disabled_title = Yönlendirme kodu devre dışı bırakıldı. +notification_rewards_disabled_description = Bu yönlendirme kodu, yönlendirme programı şartlarımızın tekrar tekrar ihlal edilmesi nedeniyle devre dışı bırakılmıştır. +rewards_error_referral_eligibility_expired = Sevk kodları şu süre içinde uygulanmalıdır: {$value} Kullanıcı adınızı oluşturmanızın üzerinden geçen günler. +notification_reward_pending_title = 💎 Yeni Referans +notification_reward_pending_description = Birisi sizin referans kodunuzu kullandı. Doğrulama bekleniyor. +rewards_error_username_daily_limit_reached = Günlük kullanıcı adı oluşturma limitine ulaşıldı. Lütfen yarın tekrar deneyin. +notification_price_alert_target_title = 🎯 {$symbol} ulaşmış {$price} +notification_price_alert_target_description = Şimdi {$price} ({$change}). +notification_stake_rewards_title = 🎁 Bahis Ödülleri +notification_stake_rewards_description = {$value} ödüllere şu adresten ulaşabilirsiniz: {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/uk/localizer.ftl b/core/crates/localizer/i18n/uk/localizer.ftl new file mode 100644 index 0000000000..ba44bdb8f9 --- /dev/null +++ b/core/crates/localizer/i18n/uk/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Надіслано: {$value} +notification_sent_description = На {$address} +notification_received_title = 💰 Отримано: {$value} +notification_received_description = От {$address} +notification_unstake_title = 🔒 Відміна стейкінгу {$value} +notification_claim_rewards_title = 🎁 Отримано винагороду {$value} +notification_withdraw_title = 🔓 Знятий стейкінг {$value} +notification_redelegate_title = 🔄 Переделегувано {$value} +notification_redelegate_validator_title = 🔄 Переделегувано {$value} на {$validator} +notification_swap_title = 🔄 Обмін з {$from_symbol} на {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Тест +notification_token_approval_title = ✅ Дозволен токен {$token} +notification_stake_title = 🔒 Стейк {$value} +notification_price_alert_up_title = 📈 {$symbol} Сповіщення про ціни +notification_price_alert_up_description = Ціна зросла на {$price_change} до {$price} +notification_price_alert_down_title = 📉 {$symbol} Сповіщення про ціни +notification_price_alert_down_description = Ціна знижена на {$price_change} до {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} Максимум за весь час +notification_price_alert_all_time_high_description = {$symbol} досяг нового історичного максимуму за {$price}. +notification_nft_sent_title = 🖼️ Відправлено NFT: {$value} +notification_nft_received_title = 🖼️ Отримано NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 Купити {$name} +notification_onboarding_create_import_wallet_title = 💎 Створити або імпортувати гаманець +notification_onboarding_create_import_wallet_description = Створіть новий гаманець або імпортуйте свій лише за кілька натискань. +notification_onboarding_welcome_description = Розпочніть свою крипто-подорож. Натисніть, щоб налаштувати свій криптогаманець. +notification_onboarding_buy_asset_description = Безпечно купуйте {$name} сьогодні — просто, надійно та миттєво. +support_new_message_title = Нове повідомлення від служби підтримки +notification_freeze_title = Заморозити {$value} +notification_unfreeze_title = Розморозити {$value} +notification_reward_title = 💎 Ви заробили {$value} бали! +notification_reward_create_username_description = Ім'я користувача готове. Запросіть друзів, щоб отримати винагороди. +notification_reward_invite_description = Реферал приєднано за допомогою вашого коду. +notification_rewards_joined_description = Ви приєдналися, використовуючи реферальний код. +rewards_error_referral_code_not_exist = Реферальний код не існує +rewards_error_referral_device_already_used = Цей пристрій вже використовувався для застосування реферального коду +rewards_error_referral_cannot_refer_self = Не можна використовувати власний реферальний код +rewards_error_referral_rewards_not_enabled = Нагороди не ввімкнено для цього користувача +rewards_error_referral_limit_reached = Нам не вдалося перевірити вашу відповідність вимогам для рефералів. Реферальні винагороди надаються за запрошення друзів, яких ви знаєте, і вимагають пристрою та адреси гаманця, які раніше не використовувалися для рефералів. +rewards_error_referral_referrer_limit_reached = Реферальний код досяг ліміту. +errors_generic = Сталася неочікувана помилка. Будь ласка, спробуйте пізніше. +rewards_error_referral_country_ineligible = Наразі реферали для вашої країни недоступні: {$value}. +notification_rewards_disabled_title = Реферальний код деактивовано +notification_rewards_disabled_description = Цей реферальний код було вимкнено через неодноразові порушення умов нашої реферальної програми. +rewards_error_referral_eligibility_expired = Реферальні коди необхідно застосувати протягом {$value} днів створення вашого імені користувача. +notification_reward_pending_title = 💎 Нове реферале +notification_reward_pending_description = Хтось використав ваш реферальний код. Очікує підтвердження. +rewards_error_username_daily_limit_reached = Досягнуто щоденного ліміту створення імен користувачів. Будь ласка, спробуйте ще раз завтра. +notification_price_alert_target_title = 🎯 {$symbol} досяг {$price} +notification_price_alert_target_description = Зараз о {$price} ({$change}). +notification_stake_rewards_title = 🎁 Винагороди за стейкінг +notification_stake_rewards_description = {$value} винагороди, доступні для {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/vi/localizer.ftl b/core/crates/localizer/i18n/vi/localizer.ftl new file mode 100644 index 0000000000..f5e65ae7ee --- /dev/null +++ b/core/crates/localizer/i18n/vi/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 Đã gửi: {$value} +notification_sent_description = Đến {$address} +notification_received_title = 💰 Đã nhận: {$value} +notification_received_description = Từ {$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 Đổi từ {$from_symbol} sang {$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol} Cảnh báo giá +notification_price_alert_up_description = Giá tăng {$price_change} thành {$price} +notification_price_alert_down_title = 📉 {$symbol} Cảnh báo giá +notification_price_alert_down_description = Giá giảm {$price_change} xuống {$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol} Mức cao nhất mọi thời đại +notification_price_alert_all_time_high_description = {$symbol} đã đạt mức cao kỷ lục mới tại {$price}. +notification_nft_sent_title = 🖼️ Đã gửi NFT: {$value} +notification_nft_received_title = 🖼️ Đã nhận NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 Mua {$name} +notification_onboarding_create_import_wallet_title = 💎 Tạo hoặc Nhập Ví +notification_onboarding_create_import_wallet_description = Tạo ví mới hoặc nhập ví của bạn chỉ bằng vài thao tác. +notification_onboarding_welcome_description = Bắt đầu hành trình tiền điện tử của bạn. Nhấn để thiết lập ví tiền điện tử của bạn. +notification_onboarding_buy_asset_description = Mua {$name} một cách an toàn ngay hôm nay—đơn giản, đáng tin cậy và nhanh chóng. +support_new_message_title = Tin nhắn mới từ bộ phận Hỗ trợ +notification_freeze_title = Đóng băng {$value} +notification_unfreeze_title = Giải phóng {$value} +notification_reward_title = 💎 Bạn đã kiếm được {$value} điểm! +notification_reward_create_username_description = Tên người dùng đã sẵn sàng. Mời bạn bè để nhận phần thưởng. +notification_reward_invite_description = Người được giới thiệu đã tham gia bằng mã của bạn. +notification_rewards_joined_description = Bạn đã tham gia bằng mã giới thiệu. +rewards_error_referral_code_not_exist = Mã giới thiệu không tồn tại +rewards_error_referral_device_already_used = Thiết bị này đã được sử dụng để áp dụng mã giới thiệu. +rewards_error_referral_cannot_refer_self = Không thể sử dụng mã giới thiệu của chính bạn. +rewards_error_referral_rewards_not_enabled = Tính năng phần thưởng chưa được kích hoạt cho người dùng này. +rewards_error_referral_limit_reached = Chúng tôi không thể xác minh tư cách tham gia chương trình giới thiệu của bạn. Phần thưởng giới thiệu chỉ dành cho việc mời bạn bè mà bạn quen biết và yêu cầu thiết bị cũng như địa chỉ ví chưa từng được sử dụng cho mục đích giới thiệu trước đây. +rewards_error_referral_referrer_limit_reached = Mã giới thiệu đã đạt đến giới hạn. +errors_generic = Đã xảy ra lỗi không mong muốn. Vui lòng thử lại sau. +rewards_error_referral_country_ineligible = Hiện tại, dịch vụ giới thiệu chưa khả dụng cho quốc gia của bạn: {$value}. +notification_rewards_disabled_title = Mã giới thiệu đã bị vô hiệu hóa +notification_rewards_disabled_description = Mã giới thiệu này đã bị vô hiệu hóa do vi phạm nhiều lần các điều khoản của chương trình giới thiệu. +rewards_error_referral_eligibility_expired = Mã giới thiệu phải được áp dụng trong vòng... {$value} ngày tạo tên người dùng của bạn. +notification_reward_pending_title = 💎 Người giới thiệu mới +notification_reward_pending_description = Ai đó đã sử dụng mã giới thiệu của bạn. Đang chờ xác minh. +rewards_error_username_daily_limit_reached = Đã đạt đến giới hạn tạo tên người dùng mỗi ngày. Vui lòng thử lại vào ngày mai. +notification_price_alert_target_title = 🎯 {$symbol} đạt {$price} +notification_price_alert_target_description = Hiện tại {$price} ({$change}). +notification_stake_rewards_title = 🎁 Phần thưởng khi đặt cược +notification_stake_rewards_description = {$value} phần thưởng có sẵn trên {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/zh-Hans/localizer.ftl b/core/crates/localizer/i18n/zh-Hans/localizer.ftl new file mode 100644 index 0000000000..30517294e9 --- /dev/null +++ b/core/crates/localizer/i18n/zh-Hans/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 已发送: {$value} +notification_sent_description = 至{$address} +notification_received_title = 💰 已收到: {$value} +notification_received_description = 来自{$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 从{$from_symbol}兑换为{$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol}价格警报 +notification_price_alert_up_description = 价格上涨{$price_change}至{$price} +notification_price_alert_down_title = 📉 {$symbol}价格警报 +notification_price_alert_down_description = 价格下降{$price_change}至{$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol}历史最高 +notification_price_alert_all_time_high_description = {$symbol}价格已达到{$price}的历史新高。 +notification_nft_sent_title = 🖼️ 已发送 NFT: {$value} +notification_nft_received_title = 🖼️ 收到 NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 购买{$name} +notification_onboarding_create_import_wallet_title = 💎 创建或导入钱包 +notification_onboarding_create_import_wallet_description = 只需轻点几下即可创建新钱包或导入您的钱包。 +notification_onboarding_welcome_description = 开启您的加密货币之旅。点击设置您的加密货币钱包。 +notification_onboarding_buy_asset_description = 今天安全地购买{$name} ——简单、可靠、即时。 +support_new_message_title = 来自支持的新消息 +notification_freeze_title = 冻结{$value} +notification_unfreeze_title = 解冻{$value} +notification_reward_title = 💎 您已获得 {$value} 得分! +notification_reward_create_username_description = 用户名已准备就绪。邀请好友即可获得奖励。 +notification_reward_invite_description = 推荐人使用您的邀请码加入。 +notification_rewards_joined_description = 您是通过推荐码加入的。 +rewards_error_referral_code_not_exist = 推荐码不存在 +rewards_error_referral_device_already_used = 此设备已用于应用推荐码 +rewards_error_referral_cannot_refer_self = 不能使用您自己的推荐码 +rewards_error_referral_rewards_not_enabled = 该用户未启用奖励功能 +rewards_error_referral_limit_reached = 我们无法验证您的推荐资格。推荐奖励仅适用于邀请您认识的朋友,并且需要使用之前未用于推荐的设备和钱包地址。 +rewards_error_referral_referrer_limit_reached = 推荐码已达到使用上限。 +errors_generic = 发生意外错误,请稍后重试。 +rewards_error_referral_country_ineligible = 目前您所在的国家/地区无法进行转诊: {$value}。 +notification_rewards_disabled_title = 推荐码已停用 +notification_rewards_disabled_description = 由于多次违反我们的推荐计划条款,此推荐码已被禁用。 +rewards_error_referral_eligibility_expired = 推荐码必须在有效期内使用。 {$value} 创建用户名所需天数。 +notification_reward_pending_title = 💎 新推荐 +notification_reward_pending_description = 有人使用了您的推荐码。正在等待验证。 +rewards_error_username_daily_limit_reached = 每日用户名创建数量已达上限,请明日再试。 +notification_price_alert_target_title = 🎯 {$symbol} 达到 {$price} +notification_price_alert_target_description = 现在在 {$price} ({$change})。 +notification_stake_rewards_title = 🎁 质押奖励 +notification_stake_rewards_description = {$value} 可获得的奖励 {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/i18n/zh-Hant/localizer.ftl b/core/crates/localizer/i18n/zh-Hant/localizer.ftl new file mode 100644 index 0000000000..0732c03feb --- /dev/null +++ b/core/crates/localizer/i18n/zh-Hant/localizer.ftl @@ -0,0 +1,63 @@ +notification_sent_title = 💸 已發送: {$value} +notification_sent_description = 至{$address} +notification_received_title = 💰 已收到: {$value} +notification_received_description = 來自{$address} +notification_unstake_title = 🔒 Unstake {$value} +notification_claim_rewards_title = 🎁 Claim Rewards {$value} +notification_withdraw_title = 🔓 Withdraw {$value} +notification_redelegate_title = 🔄 Redelegate {$value} +notification_redelegate_validator_title = 🔄 Redelegate {$value} to {$validator} +notification_swap_title = 🔄 從{$from_symbol}兌換為{$to_symbol} +notification_swap_description = {$from_value} > {$to_value} +notification_test = Test +notification_token_approval_title = ✅ Token Approval {$token} +notification_stake_title = 🔒 Stake {$value} +notification_price_alert_up_title = 📈 {$symbol}價格提醒 +notification_price_alert_up_description = 物價上漲了{$price_change}至{$price} +notification_price_alert_down_title = 📉 {$symbol}價格提醒 +notification_price_alert_down_description = 價格下降了{$price_change}至{$price} +notification_price_alert_all_time_high_title = 🔥 {$symbol}歷史新高 +notification_price_alert_all_time_high_description = {$symbol}已達歷史新高,售價為{$price} 。 +notification_nft_sent_title = 🖼️ 已發送 NFT: {$value} +notification_nft_received_title = 🖼️ 收到 NFT: {$value} +notification_onboarding_buy_asset_title = 🚀 購買{$name} +notification_onboarding_create_import_wallet_title = 💎 建立或導入錢包 +notification_onboarding_create_import_wallet_description = 只需輕按幾下即可建立新錢包或匯入您的錢包。 +notification_onboarding_welcome_description = 開啟您的加密貨幣之旅。點擊設定您的加密貨幣錢包。 +notification_onboarding_buy_asset_description = 今天就安全地購買{$name} -簡單、可靠、即時。 +support_new_message_title = 來自支援的新消息 +notification_freeze_title = 結凍{$value} +notification_unfreeze_title = 解凍{$value} +notification_reward_title = 💎 您已獲得 {$value} 得分! +notification_reward_create_username_description = 用戶名已準備就緒。邀請好友即可獲得獎勵。 +notification_reward_invite_description = 推薦人使用您的邀請碼加入。 +notification_rewards_joined_description = 您是透過推薦碼加入的。 +rewards_error_referral_code_not_exist = 推薦碼不存在 +rewards_error_referral_device_already_used = 此設備已用於應用推薦碼 +rewards_error_referral_cannot_refer_self = 不能使用您自己的推薦碼 +rewards_error_referral_rewards_not_enabled = 該用戶未啟用獎勵功能 +rewards_error_referral_limit_reached = 我們無法驗證您的推薦資格。推薦獎勵僅適用於邀請您認識的朋友,並且需要使用之前未用於推薦的設備和錢包地址。 +rewards_error_referral_referrer_limit_reached = 推薦碼已達到使用上限。 +errors_generic = 發生意外錯誤,請稍後重試。 +rewards_error_referral_country_ineligible = 目前您所在的國家無法進行轉診: {$value}。 +notification_rewards_disabled_title = 推薦碼已停用 +notification_rewards_disabled_description = 由於多次違反我們的建議計畫條款,此推薦碼已被停用。 +rewards_error_referral_eligibility_expired = 推薦碼必須在有效期限內使用。 {$value} 建立使用者名稱所需天數。 +notification_reward_pending_title = 💎 新推薦 +notification_reward_pending_description = 有人使用了您的推薦碼。正在等待驗證。 +rewards_error_username_daily_limit_reached = 每日使用者名稱建立數量已達上限,請明日再試。 +notification_price_alert_target_title = 🎯 {$symbol} 達到 {$price} +notification_price_alert_target_description = 現在在 {$price} ({$change})。 +notification_stake_rewards_title = 🎁 質押獎勵 +notification_stake_rewards_description = {$value} 可獲得的獎勵 {$chain} +notification_rewards_enabled_title = 💎 Rewards Unlocked +notification_rewards_enabled_description = You can now earn rewards by inviting friends. +notification_rewards_redeem_points_title = 🎁 Reward Redeemed +notification_rewards_redeem_points_description = You redeemed {$value} points. +notification_perpetual_long_title = 📈 Long {$coin} +notification_perpetual_open_description = Entered at {$price} 🚀 +notification_perpetual_short_title = 📉 Short {$coin} +notification_perpetual_close_positive_description = You made {$pnl} 💰 +notification_perpetual_close_negative_description = You lost {$pnl} 😕 +notification_stake_to_description = To {$validator} +notification_stake_from_description = From {$validator} diff --git a/core/crates/localizer/src/lib.rs b/core/crates/localizer/src/lib.rs new file mode 100644 index 0000000000..eb3d378fec --- /dev/null +++ b/core/crates/localizer/src/lib.rs @@ -0,0 +1,322 @@ +use std::sync::{Arc, LazyLock}; + +use i18n_embed::{ + DefaultLocalizer, LanguageLoader, Localizer, RustEmbedNotifyAssets, + fluent::{FluentLanguageLoader, fluent_language_loader}, +}; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "i18n/"] +pub struct LocalizationsEmbed; + +pub static LOCALIZATIONS: LazyLock> = + LazyLock::new(|| RustEmbedNotifyAssets::new(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("i18n/"))); + +macro_rules! fl { + ($loader:expr, $message_id:literal) => {{ + i18n_embed_fl::fl!($loader, $message_id) + }}; + ($loader:expr, $message_id:literal, $($args:expr),*) => {{ + i18n_embed_fl::fl!($loader, $message_id, $($args), *) + }}; +} + +pub struct LanguageLocalizer { + loader: Arc, + localizer: DefaultLocalizer<'static>, +} + +pub struct LanguageNotification { + pub title: String, + pub description: String, +} + +impl Default for LanguageLocalizer { + fn default() -> Self { + Self::new() + } +} + +impl LanguageLocalizer { + pub fn new() -> Self { + let loader = Arc::new(fluent_language_loader!()); + + loader.load_fallback_language(&*LOCALIZATIONS).expect("Error while loading fallback language"); + + let loader_ref: &'static FluentLanguageLoader = unsafe { &*(Arc::as_ptr(&loader) as *const _) }; + + let localizer = DefaultLocalizer::new(loader_ref, &*LOCALIZATIONS); + + Self { loader, localizer } + } + + pub fn new_with_language(language: &str) -> Self { + let localizer = Self::new(); + localizer.select_language(language).unwrap_or_default(); + localizer + } + + pub fn select_language(&self, language: &str) -> Result> { + let lang_id = language.parse()?; + self.localizer.select(&[lang_id])?; + Ok(true) + } + + pub fn price_alert_up(&self, symbol: &str, price: &str, price_change: &str) -> LanguageNotification { + LanguageNotification { + title: fl!(self.loader.as_ref(), "notification_price_alert_up_title", symbol = symbol), + description: fl!(self.loader.as_ref(), "notification_price_alert_up_description", price = price, price_change = price_change), + } + } + + pub fn price_alert_down(&self, symbol: &str, price: &str, price_change: &str) -> LanguageNotification { + LanguageNotification { + title: fl!(self.loader.as_ref(), "notification_price_alert_down_title", symbol = symbol), + description: fl!( + self.loader.as_ref(), + "notification_price_alert_down_description", + price = price, + price_change = price_change + ), + } + } + + pub fn price_alert_target(&self, symbol: &str, target_price: &str, current_price: &str, change: &str) -> LanguageNotification { + LanguageNotification { + title: fl!(self.loader.as_ref(), "notification_price_alert_target_title", symbol = symbol, price = target_price), + description: fl!(self.loader.as_ref(), "notification_price_alert_target_description", price = current_price, change = change), + } + } + + pub fn price_alert_all_time_high(&self, symbol: &str, price: &str) -> LanguageNotification { + LanguageNotification { + title: fl!(self.loader.as_ref(), "notification_price_alert_all_time_high_title", symbol = symbol), + description: fl!(self.loader.as_ref(), "notification_price_alert_all_time_high_description", symbol = symbol, price = price), + } + } + + // notifications + pub fn test(&self) -> String { + fl!(self.loader.as_ref(), "notification_test") + } + + pub fn notification_transfer_title(&self, is_sent: bool, value: &str) -> String { + if is_sent { + fl!(self.loader.as_ref(), "notification_sent_title", value = value) + } else { + fl!(self.loader.as_ref(), "notification_received_title", value = value) + } + } + + pub fn notification_nft_transfer_title(&self, is_sent: bool, value: &str) -> String { + if is_sent { + fl!(self.loader.as_ref(), "notification_nft_sent_title", value = value) + } else { + fl!(self.loader.as_ref(), "notification_nft_received_title", value = value) + } + } + + pub fn notification_transfer_description(&self, is_sent: bool, to_address: &str, from_address: &str) -> String { + if is_sent { + fl!(self.loader.as_ref(), "notification_sent_description", address = to_address) + } else { + fl!(self.loader.as_ref(), "notification_received_description", address = from_address) + } + } + + pub fn notification_sent_description(&self, address: &str) -> String { + fl!(self.loader.as_ref(), "notification_sent_description", address = address) + } + + pub fn notification_received_description(&self, address: &str) -> String { + fl!(self.loader.as_ref(), "notification_received_description", address = address) + } + + pub fn notification_token_approval_title(&self, token: &str) -> String { + fl!(self.loader.as_ref(), "notification_token_approval_title", token = token) + } + + pub fn notification_freeze_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_freeze_title", value = value) + } + + pub fn notification_unfreeze_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_unfreeze_title", value = value) + } + + pub fn notification_stake_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_stake_title", value = value) + } + + pub fn notification_unstake_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_unstake_title", value = value) + } + + pub fn notification_redelegate_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_redelegate_title", value = value) + } + + pub fn notification_withdraw_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_withdraw_title", value = value) + } + + pub fn notification_claim_rewards_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_claim_rewards_title", value = value) + } + + pub fn notification_swap_title(&self, from_symbol: &str, to_symbol: &str) -> String { + fl!(self.loader.as_ref(), "notification_swap_title", from_symbol = from_symbol, to_symbol = to_symbol) + } + + pub fn notification_swap_description(&self, from_value: &str, to_value: &str) -> String { + fl!(self.loader.as_ref(), "notification_swap_description", from_value = from_value, to_value = to_value) + } + + // onboarding + pub fn notification_onboarding_buy_asset(&self, name: &str) -> (String, String) { + ( + fl!(self.loader.as_ref(), "notification_onboarding_buy_asset_title", name = name), + fl!(self.loader.as_ref(), "notification_onboarding_buy_asset_description", name = name), + ) + } + + pub fn notification_fiat_purchase_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_fiat_purchase_title", value = value) + } + + pub fn notification_fiat_sale_title(&self, value: &str) -> String { + fl!(self.loader.as_ref(), "notification_fiat_sale_title", value = value) + } + + // support + pub fn notification_support_new_message_title(&self) -> String { + fl!(self.loader.as_ref(), "support_new_message_title") + } + + // rewards + pub fn notification_reward_title(&self, points: i32) -> String { + fl!(self.loader.as_ref(), "notification_reward_title", value = points) + } + + pub fn notification_reward_create_username_description(&self) -> String { + fl!(self.loader.as_ref(), "notification_reward_create_username_description") + } + + pub fn notification_reward_invite_description(&self) -> String { + fl!(self.loader.as_ref(), "notification_reward_invite_description") + } + + pub fn notification_reward_joined_description(&self) -> String { + fl!(self.loader.as_ref(), "notification_rewards_joined_description") + } + + pub fn notification_reward_pending_title(&self) -> String { + fl!(self.loader.as_ref(), "notification_reward_pending_title") + } + + pub fn notification_reward_pending_description(&self) -> String { + fl!(self.loader.as_ref(), "notification_reward_pending_description") + } + + pub fn notification_reward_redeemed_title(&self) -> String { + fl!(self.loader.as_ref(), "notification_rewards_redeem_points_title") + } + + pub fn notification_reward_redeemed_description(&self, points: i32, value: Option<&str>) -> String { + match value { + Some(value) => { + fl!( + self.loader.as_ref(), + "notification_rewards_redeem_points_for_description", + points = points.abs(), + value = value + ) + } + None => fl!(self.loader.as_ref(), "notification_rewards_redeem_points_description", value = points.abs()), + } + } + + pub fn errors_generic(&self) -> String { + fl!(self.loader.as_ref(), "errors_generic") + } + + pub fn rewards_error_referral_code_not_exist(&self) -> String { + fl!(self.loader.as_ref(), "rewards_error_referral_code_not_exist") + } + + pub fn rewards_error_referral_device_already_used(&self) -> String { + fl!(self.loader.as_ref(), "rewards_error_referral_device_already_used") + } + + pub fn rewards_error_referral_cannot_refer_self(&self) -> String { + fl!(self.loader.as_ref(), "rewards_error_referral_cannot_refer_self") + } + + pub fn rewards_error_referral_rewards_not_enabled(&self) -> String { + fl!(self.loader.as_ref(), "rewards_error_referral_rewards_not_enabled") + } + + pub fn rewards_error_referral_limit_reached(&self) -> String { + fl!(self.loader.as_ref(), "rewards_error_referral_limit_reached") + } + + pub fn rewards_error_referral_referrer_limit_reached(&self) -> String { + fl!(self.loader.as_ref(), "rewards_error_referral_referrer_limit_reached") + } + + pub fn rewards_error_referral_eligibility_expired(&self, days: i64) -> String { + fl!(self.loader.as_ref(), "rewards_error_referral_eligibility_expired", value = days) + } + + pub fn rewards_error_referral_country_ineligible(&self, country: &str) -> String { + fl!(self.loader.as_ref(), "rewards_error_referral_country_ineligible", value = country) + } + + pub fn rewards_error_username_daily_limit_reached(&self) -> String { + fl!(self.loader.as_ref(), "rewards_error_username_daily_limit_reached") + } + + pub fn notification_rewards_enabled_title(&self) -> String { + fl!(self.loader.as_ref(), "notification_rewards_enabled_title") + } + + pub fn notification_rewards_enabled_description(&self) -> String { + fl!(self.loader.as_ref(), "notification_rewards_enabled_description") + } + + pub fn notification_rewards_disabled_title(&self) -> String { + fl!(self.loader.as_ref(), "notification_rewards_disabled_title") + } + + pub fn notification_rewards_disabled_description(&self) -> String { + fl!(self.loader.as_ref(), "notification_rewards_disabled_description") + } + + pub fn notification_perpetual_long_title(&self, coin: &str) -> String { + fl!(self.loader.as_ref(), "notification_perpetual_long_title", coin = coin) + } + + pub fn notification_perpetual_short_title(&self, coin: &str) -> String { + fl!(self.loader.as_ref(), "notification_perpetual_short_title", coin = coin) + } + + pub fn notification_perpetual_open_description(&self, price: &str) -> String { + fl!(self.loader.as_ref(), "notification_perpetual_open_description", price = price) + } + + pub fn notification_perpetual_close_positive_description(&self, pnl: &str) -> String { + fl!(self.loader.as_ref(), "notification_perpetual_close_positive_description", pnl = pnl) + } + + pub fn notification_perpetual_close_negative_description(&self, pnl: &str) -> String { + fl!(self.loader.as_ref(), "notification_perpetual_close_negative_description", pnl = pnl) + } + + pub fn notification_stake_rewards(&self, value: &str, chain: &str) -> LanguageNotification { + LanguageNotification { + title: fl!(self.loader.as_ref(), "notification_stake_rewards_title"), + description: fl!(self.loader.as_ref(), "notification_stake_rewards_description", value = value, chain = chain), + } + } +} diff --git a/core/crates/localizer/tests/localizer.rs b/core/crates/localizer/tests/localizer.rs new file mode 100644 index 0000000000..63e92ed0bb --- /dev/null +++ b/core/crates/localizer/tests/localizer.rs @@ -0,0 +1,52 @@ +use localizer::LanguageLocalizer; + +/// Test that the expected languages and fallback language are +/// available. +#[test] +fn test_specific_language() { + let localizer = LanguageLocalizer::new_with_language("es"); + assert_eq!(&localizer.test(), "Prueba"); + + localizer.select_language("pt-BR").unwrap(); + + assert_eq!(&localizer.test(), "Teste"); +} + +#[test] +fn test_invalid_language_fallback() { + let localizer = LanguageLocalizer::new_with_language("unknown"); + assert_eq!(&localizer.test(), "Test"); +} + +#[test] +fn test_pass_argment() { + let localizer = LanguageLocalizer::new_with_language("es"); + assert_eq!(&localizer.notification_transfer_title(true, "1 BTC"), "💸 Enviado: \u{2068}1 BTC\u{2069}"); +} + +#[test] +fn test_reward_redeemed_description() { + let localizer = LanguageLocalizer::new_with_language("en"); + + assert_eq!( + &localizer.notification_reward_redeemed_description(650, Some("1 USDT")), + "You redeemed \u{2068}650\u{2069} points for \u{2068}1 USDT\u{2069}." + ); + assert_eq!(&localizer.notification_reward_redeemed_description(650, None), "You redeemed \u{2068}650\u{2069} points."); +} + +#[test] +fn test_fiat_purchase_title() { + let localizer = LanguageLocalizer::new_with_language("en"); + + assert_eq!(&localizer.notification_fiat_purchase_title("0.01 ETH"), "🚀 Bought \u{2068}0.01 ETH\u{2069}"); +} + +#[test] +fn test_price_alert_target_uses_target_and_current_price() { + let localizer = LanguageLocalizer::new_with_language("en"); + let message = localizer.price_alert_target("Bitcoin (BTC)", "$81,000.00", "$80,954.00", "-0.27%"); + + assert_eq!(message.title, "\u{1f3af} \u{2068}Bitcoin (BTC)\u{2069} reached \u{2068}$81,000.00\u{2069}"); + assert_eq!(message.description, "Now at \u{2068}$80,954.00\u{2069} (\u{2068}-0.27%\u{2069})."); +} diff --git a/core/crates/metrics/Cargo.toml b/core/crates/metrics/Cargo.toml new file mode 100644 index 0000000000..af5ba58151 --- /dev/null +++ b/core/crates/metrics/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "metrics" +version = "1.0.0" +edition = "2021" + +[dependencies] +prometheus-client = { workspace = true } diff --git a/core/crates/metrics/src/domain.rs b/core/crates/metrics/src/domain.rs new file mode 100644 index 0000000000..15266f7c71 --- /dev/null +++ b/core/crates/metrics/src/domain.rs @@ -0,0 +1,6 @@ +use prometheus_client::registry::Registry; + +pub trait MetricsDomain { + fn name(&self) -> &'static str; + fn init(&mut self, registry: &mut Registry); +} diff --git a/core/crates/metrics/src/histogram.rs b/core/crates/metrics/src/histogram.rs new file mode 100644 index 0000000000..e0a2cb8d33 --- /dev/null +++ b/core/crates/metrics/src/histogram.rs @@ -0,0 +1,7 @@ +use prometheus_client::metrics::histogram::Histogram; + +pub const LATENCY_BUCKETS: [f64; 5] = [0.1, 0.5, 1.0, 2.5, 5.0]; + +pub fn latency() -> Histogram { + Histogram::new(LATENCY_BUCKETS) +} diff --git a/core/crates/metrics/src/lib.rs b/core/crates/metrics/src/lib.rs new file mode 100644 index 0000000000..b692feec8a --- /dev/null +++ b/core/crates/metrics/src/lib.rs @@ -0,0 +1,7 @@ +mod domain; +pub mod histogram; +mod registry; + +pub use domain::MetricsDomain; +pub use prometheus_client; +pub use registry::MetricsRegistry; diff --git a/core/crates/metrics/src/registry.rs b/core/crates/metrics/src/registry.rs new file mode 100644 index 0000000000..926232bb1b --- /dev/null +++ b/core/crates/metrics/src/registry.rs @@ -0,0 +1,35 @@ +use prometheus_client::encoding::text::encode; +use prometheus_client::registry::Registry; + +#[derive(Debug)] +pub struct MetricsRegistry { + registry: Registry, +} + +impl MetricsRegistry { + pub fn new() -> Self { + Self { registry: Registry::default() } + } + + pub fn with_prefix(prefix: impl Into) -> Self { + Self { + registry: Registry::with_prefix(prefix), + } + } + + pub fn registry_mut(&mut self) -> &mut Registry { + &mut self.registry + } + + pub fn encode(&self) -> String { + let mut buffer = String::new(); + encode(&mut buffer, &self.registry).unwrap(); + buffer + } +} + +impl Default for MetricsRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/core/crates/name_resolver/Cargo.toml b/core/crates/name_resolver/Cargo.toml new file mode 100644 index 0000000000..d4e5ec40bf --- /dev/null +++ b/core/crates/name_resolver/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "name_resolver" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +reqwest = { workspace = true } +async-trait = { workspace = true } +gem_encoding = { path = "../gem_encoding", features = ["protobuf"] } +borsh = { workspace = true } +hex = { workspace = true } +alloy-primitives = { workspace = true } +alloy-sol-types = { workspace = true } +alloy-ens = { version = "2.0.5" } +gem_client = { path = "../gem_client", features = ["reqwest"] } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["reqwest"] } +idna = { version = "1.1.0" } + +settings = { path = "../settings" } +primitives = { path = "../primitives" } +gem_evm = { path = "../gem_evm" } +gem_ton = { path = "../gem_ton" } +gem_solana = { path = "../gem_solana" } +gem_hash = { path = "../gem_hash" } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[[test]] +name = "integration_test" +test = false diff --git a/core/crates/name_resolver/README.md b/core/crates/name_resolver/README.md new file mode 100644 index 0000000000..68248abfec --- /dev/null +++ b/core/crates/name_resolver/README.md @@ -0,0 +1,29 @@ +# Name Resolver + +## Supported Name Providers + +- [x] [Ethereum Name Service](https://ens.domains/) +- - .eth +- [x] [Base Name Service](https://www.basename.app/) +- - .base +- [x] [Unstoppable Domains](https://unstoppabledomains.com/) +- - .crypto +- [x] [Solana Name Service](https://www.sns.id/) +- - .sol +- [x] [TON Domains](https://dns.ton.org/) +- - .ton +- [x] [Aptos Names](https://www.aptosnames.com/) +- - .apt +- [x] [d.id](https://d.id/) +- - .bit +- [x] [Interchain Nameservice](https://www.icns.xyz/) +- - .atom +- - .osmo +- [x] [Lens](https://www.lens.xyz/) +- - .lens +- [x] [eths.center](https://eths.center/) +- - .tree +- [x] [Space ID](https://space.id) +- - .bnb +- [x] [Sui Name Service](https://suins.io/) +- - .sui diff --git a/core/crates/name_resolver/src/alldomains/client.rs b/core/crates/name_resolver/src/alldomains/client.rs new file mode 100644 index 0000000000..11fdbaade8 --- /dev/null +++ b/core/crates/name_resolver/src/alldomains/client.rs @@ -0,0 +1,165 @@ +use std::{error::Error, str::FromStr}; + +use async_trait::async_trait; +use gem_encoding::decode_base64; +use gem_hash::sha2::sha256; +use serde_json::{self, json}; + +use gem_client::ReqwestClient; +use gem_jsonrpc::JsonRpcClient; +use gem_solana::{COMMITMENT_CONFIRMED, Pubkey, find_program_address}; +use primitives::{ + chain::Chain, + contract_constants::{SOLANA_ALLDOMAINS_ANS_PROGRAM_ID, SOLANA_ALLDOMAINS_NAME_HOUSE_PROGRAM_ID, SOLANA_ALLDOMAINS_ROOT_PUBLIC_KEY, SOLANA_ALLDOMAINS_TLD_HOUSE_PROGRAM_ID}, + name::NameProvider, +}; + +use crate::client::NameClient; +use crate::model::NameQuery; + +use super::model::NameRecordHeader; + +const HASH_PREFIX: &str = "ALT Name Service"; +const TLD_HOUSE_PREFIX: &str = "tld_house"; +const NAME_HOUSE_PREFIX: &str = "name_house"; +const NFT_RECORD_PREFIX: &str = "nft_record"; + +pub struct AllDomainsClient { + client: JsonRpcClient, +} + +impl AllDomainsClient { + pub fn new(url: String) -> Self { + let reqwest_client = gem_client::builder().build().expect("Failed to build reqwest client"); + Self { + client: JsonRpcClient::new(ReqwestClient::new(url, reqwest_client)), + } + } + + async fn get_hashed_name(&self, name: &str) -> Result<[u8; 32], Box> { + Ok(sha256(&[HASH_PREFIX.as_bytes(), name.as_bytes()].concat())) + } + + fn get_name_account_key_with_bump( + &self, + hashed_name: &[u8; 32], + name_class: Option, + parent_name: Option, + ) -> Result<(Pubkey, u8), Box> { + let mut seeds = Vec::new(); + seeds.push(hashed_name.as_ref()); + + let default_pubkey = Pubkey::new([0u8; 32]); + let name_class_key = name_class.unwrap_or(default_pubkey); + let parent_name_key = parent_name.unwrap_or(default_pubkey); + seeds.push(name_class_key.as_bytes().as_ref()); + seeds.push(parent_name_key.as_bytes().as_ref()); + + let ans_program_id = Pubkey::from_str(SOLANA_ALLDOMAINS_ANS_PROGRAM_ID)?; + Ok(find_program_address(&ans_program_id, &seeds)?) + } + + fn find_tld_house(&self, tld_string: &str) -> Result<(Pubkey, u8), Box> { + let tld_lower = tld_string.to_lowercase(); + let seeds = &[TLD_HOUSE_PREFIX.as_bytes(), tld_lower.as_bytes()]; + + let tld_house_program_id = Pubkey::from_str(SOLANA_ALLDOMAINS_TLD_HOUSE_PROGRAM_ID)?; + Ok(find_program_address(&tld_house_program_id, seeds)?) + } + + fn find_name_house(&self, tld_house: Pubkey) -> Result<(Pubkey, u8), Box> { + let seeds = &[NAME_HOUSE_PREFIX.as_bytes(), tld_house.as_bytes().as_ref()]; + let name_house_program_id = Pubkey::from_str(SOLANA_ALLDOMAINS_NAME_HOUSE_PROGRAM_ID)?; + Ok(find_program_address(&name_house_program_id, seeds)?) + } + + fn find_nft_record(&self, name_account: Pubkey, name_house_account: Pubkey) -> Result<(Pubkey, u8), Box> { + let seeds = &[NFT_RECORD_PREFIX.as_bytes(), name_house_account.as_bytes().as_ref(), name_account.as_bytes().as_ref()]; + let name_house_program_id = Pubkey::from_str(SOLANA_ALLDOMAINS_NAME_HOUSE_PROGRAM_ID)?; + Ok(find_program_address(&name_house_program_id, seeds)?) + } + + async fn get_origin_name_account_key(&self) -> Result> { + let root_pubkey = Pubkey::from_str(SOLANA_ALLDOMAINS_ROOT_PUBLIC_KEY)?; + Ok(root_pubkey) + } + + async fn get_name_owner(&self, name_account_key: Pubkey, tld_house: Option) -> Result> { + let response: serde_json::Value = self + .client + .call( + "getAccountInfo", + vec![json!(name_account_key.to_string()), json!({"encoding": "base64", "commitment": COMMITMENT_CONFIRMED})], + ) + .await?; + + let account_value = response.get("value").ok_or("Invalid response format")?; + if account_value.is_null() { + return Err("Account not found or domain does not exist".into()); + } + + let account_obj = account_value.as_object().ok_or("Invalid account data format")?; + let data_array = account_obj.get("data").and_then(|d| d.as_array()).ok_or("No data field in account")?; + + if data_array.is_empty() { + return Err("Empty account data".into()); + } + + let base64_data = data_array[0].as_str().ok_or("Invalid base64 data format")?; + let data = decode_base64(base64_data)?; + let name_record = NameRecordHeader::try_from_slice(&data)?; + + if !name_record.is_valid() { + return Err("Name record is not valid or expired".into()); + } + + let owner = name_record.owner; + + if let Some(tld_house) = tld_house { + let (name_house, _) = self.find_name_house(tld_house)?; + let (nft_record, _) = self.find_nft_record(name_account_key, name_house)?; + + if owner == nft_record { + return Err("NFT owner resolution is not supported".into()); + } + } + + Ok(owner) + } +} + +#[async_trait] +impl NameClient for AllDomainsClient { + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + let tld = &query.suffix; + if tld.is_empty() || tld.contains('.') { + return Err("Invalid domain format".into()); + } + + let name_origin_tld_key = self.get_origin_name_account_key().await?; + + let tld_name = format!(".{tld}"); + let parent_hashed_name = self.get_hashed_name(&tld_name).await?; + let (parent_account_key, _) = self.get_name_account_key_with_bump(&parent_hashed_name, None, Some(name_origin_tld_key))?; + + let domain_hashed_name = self.get_hashed_name(&query.name).await?; + let (domain_account_key, _) = self.get_name_account_key_with_bump(&domain_hashed_name, None, Some(parent_account_key))?; + + let (tld_house, _) = self.find_tld_house(&tld_name)?; + let name_owner = self.get_name_owner(domain_account_key, Some(tld_house)).await?; + + Ok(name_owner.to_string()) + } + + fn provider(&self) -> NameProvider { + NameProvider::AllDomains + } + + fn domains(&self) -> Vec<&'static str> { + vec!["skr", "saga", "poor", "bonk", "solana"] + } + + fn chains(&self) -> Vec { + vec![Chain::Solana] + } +} diff --git a/core/crates/name_resolver/src/alldomains/mod.rs b/core/crates/name_resolver/src/alldomains/mod.rs new file mode 100644 index 0000000000..9fa69d777d --- /dev/null +++ b/core/crates/name_resolver/src/alldomains/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod model; + +pub use client::AllDomainsClient; +pub use model::NameRecordHeader; diff --git a/core/crates/name_resolver/src/alldomains/model.rs b/core/crates/name_resolver/src/alldomains/model.rs new file mode 100644 index 0000000000..530d23118d --- /dev/null +++ b/core/crates/name_resolver/src/alldomains/model.rs @@ -0,0 +1,46 @@ +use std::time::SystemTime; + +use borsh::{BorshDeserialize, BorshSerialize}; +use gem_solana::Pubkey; + +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub struct NameRecordHeader { + pub discriminator: [u8; 8], + pub parent_name: Pubkey, + pub owner: Pubkey, + pub nclass: Pubkey, + pub expires_at: u64, + pub created_at: u64, + pub non_transferable: u8, + pub padding: [u8; 79], +} + +impl NameRecordHeader { + pub const BYTE_SIZE: usize = 8 + 32 + 32 + 32 + 8 + 8 + 1 + 79; + pub const EXPECTED_DISCRIMINATOR: [u8; 8] = [68, 72, 88, 44, 15, 167, 103, 243]; + + pub fn try_from_slice(data: &[u8]) -> Result> { + if data.len() < Self::BYTE_SIZE { + return Err("Invalid data length for NameRecordHeader".into()); + } + + let header: NameRecordHeader = BorshDeserialize::try_from_slice(data)?; + + if header.discriminator != Self::EXPECTED_DISCRIMINATOR { + return Err("Invalid discriminator for NameRecordHeader".into()); + } + + Ok(header) + } + + pub fn is_valid(&self) -> bool { + if self.expires_at == 0 { + return true; + } + + let current_time = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let grace_period = 45 * 24 * 60 * 60; + + self.expires_at + grace_period > current_time + } +} diff --git a/core/crates/name_resolver/src/aptos.rs b/core/crates/name_resolver/src/aptos.rs new file mode 100644 index 0000000000..07e4420fd2 --- /dev/null +++ b/core/crates/name_resolver/src/aptos.rs @@ -0,0 +1,51 @@ +use crate::client::NameClient; +use crate::model::NameQuery; +use async_trait::async_trait; +use primitives::{Chain, NameProvider}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolveName { + pub address: String, +} + +pub struct AptosClient { + url: String, + client: Client, +} + +impl AptosClient { + pub fn new(url: String) -> Self { + let client = Client::new(); + Self { url, client } + } + + async fn resolve_name(&self, name: &str) -> Result> { + let url = format!("{}/api/mainnet/v1/address/{}", self.url, name); + let response = self.client.get(&url).send().await?.json::().await?; + + Ok(response.address) + } +} + +#[async_trait] +impl NameClient for AptosClient { + fn provider(&self) -> NameProvider { + NameProvider::Aptos + } + + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + let address = self.resolve_name(&query.domain).await?; + Ok(address) + } + + fn domains(&self) -> Vec<&'static str> { + vec!["apt"] + } + + fn chains(&self) -> Vec { + vec![Chain::Aptos] + } +} diff --git a/core/crates/name_resolver/src/base/contract.rs b/core/crates/name_resolver/src/base/contract.rs new file mode 100644 index 0000000000..59b65edccd --- /dev/null +++ b/core/crates/name_resolver/src/base/contract.rs @@ -0,0 +1,17 @@ +use alloy_sol_types::sol; + +sol! { + /// Interface for the L2Resolver + interface L2Resolver { + /// Returns the address associated with a node. + /// @param node The ENS node to query. + /// @return The associated address. + function addr(bytes32 node) external view returns (address); + + /// Returns the text record associated with an ENS node and key. + /// @param node The ENS node to query. + /// @param key The text record key. + /// @return The text record. + function text(bytes32 node, string key) external view returns (string); + } +} diff --git a/core/crates/name_resolver/src/base/mod.rs b/core/crates/name_resolver/src/base/mod.rs new file mode 100644 index 0000000000..aaf6ea7ab1 --- /dev/null +++ b/core/crates/name_resolver/src/base/mod.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod provider; + +pub use provider::Basenames; diff --git a/core/crates/name_resolver/src/base/provider.rs b/core/crates/name_resolver/src/base/provider.rs new file mode 100644 index 0000000000..db2d794300 --- /dev/null +++ b/core/crates/name_resolver/src/base/provider.rs @@ -0,0 +1,81 @@ +use alloy_ens::namehash; +use alloy_primitives::{Address, Bytes, hex}; +use alloy_sol_types::SolCall; +use async_trait::async_trait; +use gem_client::ReqwestClient; +use gem_jsonrpc::JsonRpcClient; +use serde_json::json; +use std::error::Error; +use std::str::FromStr; + +use super::contract::L2Resolver; +use crate::client::NameClient; +use crate::model::NameQuery; +use primitives::{chain::Chain, name::NameProvider}; + +const L2_RESOLVER_ADDRESS: &str = "0xC6d566A56A1aFf6508b41f6c90ff131615583BCD"; + +pub struct Basenames { + client: JsonRpcClient, + resolver_address: Address, + chain: Chain, +} + +impl Basenames { + pub fn new(provider_url: String) -> Self { + let reqwest_client = gem_client::builder().build().expect("Failed to build reqwest client"); + let client = JsonRpcClient::new(ReqwestClient::new(provider_url, reqwest_client)); + let resolver_address = Address::from_str(L2_RESOLVER_ADDRESS).expect("Invalid resolver address"); + Self { + client, + resolver_address, + chain: Chain::Base, + } + } + + async fn get_address_from_resolver(&self, name: &str) -> Result> { + let node = namehash(name); + let call_data = L2Resolver::addrCall { node }.abi_encode(); + + let params = json!([ + { + "to": self.resolver_address.to_string(), + "data": hex::encode_prefixed(&call_data) + }, + "latest" + ]); + + let result: String = self.client.call("eth_call", params).await?; + let response_bytes = Bytes::from(hex::decode(&result)?); + + L2Resolver::addrCall::abi_decode_returns(response_bytes.as_ref()).map_err(Into::into) + } +} + +#[async_trait] +impl NameClient for Basenames { + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + match self.get_address_from_resolver(&query.domain).await { + Ok(addr) => { + if addr.is_zero() { + Err("Address not found".into()) + } else { + Ok(addr.to_checksum(None)) + } + } + Err(e) => Err(e), + } + } + + fn provider(&self) -> NameProvider { + NameProvider::Basenames + } + + fn domains(&self) -> Vec<&'static str> { + vec!["base.eth"] + } + + fn chains(&self) -> Vec { + vec![self.chain] + } +} diff --git a/core/crates/name_resolver/src/client.rs b/core/crates/name_resolver/src/client.rs new file mode 100644 index 0000000000..d66d264bff --- /dev/null +++ b/core/crates/name_resolver/src/client.rs @@ -0,0 +1,180 @@ +use std::error::Error; +use std::sync::Arc; + +use async_trait::async_trait; +use primitives::chain::Chain; +use primitives::name::{NameProvider, NameRecord}; + +use crate::error::NameError; +use crate::model::NameQuery; + +#[async_trait] +pub trait NameClient { + async fn resolve(&self, query: &NameQuery, chain: Chain) -> Result>; + fn provider(&self) -> NameProvider; + fn domains(&self) -> Vec<&'static str>; + fn chains(&self) -> Vec; +} + +#[async_trait] +impl NameClient for Arc +where + T: NameClient + ?Sized, +{ + async fn resolve(&self, query: &NameQuery, chain: Chain) -> Result> { + (**self).resolve(query, chain).await + } + + fn provider(&self) -> NameProvider { + (**self).provider() + } + + fn domains(&self) -> Vec<&'static str> { + (**self).domains() + } + fn chains(&self) -> Vec { + (**self).chains() + } +} + +pub struct NameConfig { + pub max_name_length: usize, +} + +pub struct Client { + providers: Vec>, + config: NameConfig, +} + +impl Client { + pub fn new(providers: Vec>, config: NameConfig) -> Self { + Self { providers, config } + } + + pub async fn resolve(&self, name: &str, chain: Chain) -> Result> { + let query = NameQuery::new(name); + if query.name.len() > self.config.max_name_length { + return Err(NameError::new(format!("name '{}' exceeds maximum length of {}", query.name, self.config.max_name_length)).into()); + } + + let provider = self.matched_provider(name, chain)?; + let address = provider.resolve(&query, chain).await?; + + Ok(NameRecord { + provider: provider.provider(), + address, + name: name.to_string(), + chain, + }) + } + + fn matched_provider(&self, name: &str, chain: Chain) -> Result<&(dyn NameClient + Send + Sync), Box> { + self.providers + .iter() + .enumerate() + .filter(|(_, provider)| provider.chains().contains(&chain)) + .filter_map(|(index, provider)| { + provider + .domains() + .iter() + .filter_map(|domain| domain_match_len(name, domain)) + .max() + .map(|match_len| (match_len, index, provider.as_ref())) + }) + .max_by(|left, right| left.0.cmp(&right.0).then(right.1.cmp(&left.1))) + .map(|(_, _, provider)| provider) + .ok_or_else(|| NameError::new(format!("No provider found for name: {name}")).into()) + } +} + +fn domain_match_len(name: &str, domain: &str) -> Option { + if domain == "*" { + return Some(0); + } + + if name.eq_ignore_ascii_case(domain) { + Some(domain.len()) + } else if name.len() > domain.len() { + let offset = name.len() - domain.len(); + let has_dot = name.as_bytes().get(offset - 1).is_some_and(|byte| *byte == b'.'); + let is_suffix_match = name.get(offset..).is_some_and(|suffix| suffix.eq_ignore_ascii_case(domain)); + + if has_dot && is_suffix_match { Some(domain.len()) } else { None } + } else { + None + } +} + +#[cfg(test)] +mod tests { + use crate::testkit::TestProvider; + use primitives::name::NameProvider; + + use super::{Client, NameConfig}; + use primitives::chain::Chain; + + #[tokio::test] + async fn test_resolve_prefers_longer_domain_match() { + let client = Client::new( + vec![ + Box::new(TestProvider::new( + NameProvider::Ens, + vec!["*"], + vec![Chain::Base], + Ok("0x0000000000000000000000000000000000000001"), + )), + Box::new(TestProvider::new( + NameProvider::Basenames, + vec!["base.eth"], + vec![Chain::Base], + Ok("0x0000000000000000000000000000000000000002"), + )), + ], + NameConfig { max_name_length: 20 }, + ); + + let record = client.resolve("alice.base.eth", Chain::Base).await.unwrap(); + + assert_eq!(record.provider, NameProvider::Basenames); + assert_eq!(record.address, "0x0000000000000000000000000000000000000002"); + } + + #[tokio::test] + async fn test_resolve_rejects_long_name() { + let client = Client::new( + vec![Box::new(TestProvider::new( + NameProvider::Injective, + vec!["inj"], + vec![Chain::Injective], + Ok("inj14apqz6u2nprsly3j0mqa6jwpxnmnphq3pp0q9g"), + ))], + NameConfig { max_name_length: 20 }, + ); + + let result = client.resolve("inj1kly3z4r8pzgfhh9cx5x69xjw0j4evlepq6ccgw.inj", Chain::Injective).await; + assert_eq!( + result.unwrap_err().to_string(), + "name 'inj1kly3z4r8pzgfhh9cx5x69xjw0j4evlepq6ccgw' exceeds maximum length of 20" + ); + } + + #[tokio::test] + async fn test_resolve_returns_more_specific_provider_error() { + let client = Client::new( + vec![ + Box::new(TestProvider::new( + NameProvider::Ens, + vec!["*"], + vec![Chain::Base], + Ok("0x0000000000000000000000000000000000000003"), + )), + Box::new(TestProvider::new(NameProvider::Basenames, vec!["base.eth"], vec![Chain::Base], Err("failed"))), + ], + NameConfig { max_name_length: 20 }, + ); + + let error = client.resolve("alice.base.eth", Chain::Base).await.err().unwrap(); + + assert_eq!(error.to_string(), "failed"); + } +} diff --git a/core/crates/name_resolver/src/codec/mod.rs b/core/crates/name_resolver/src/codec/mod.rs new file mode 100644 index 0000000000..167a31efba --- /dev/null +++ b/core/crates/name_resolver/src/codec/mod.rs @@ -0,0 +1,5 @@ +use std::error::Error; +pub trait Codec { + fn encode(bytes: Vec) -> Result>; + fn decode(string: &str) -> Result, Box>; +} diff --git a/core/crates/name_resolver/src/did.rs b/core/crates/name_resolver/src/did.rs new file mode 100644 index 0000000000..11b4d1407c --- /dev/null +++ b/core/crates/name_resolver/src/did.rs @@ -0,0 +1,67 @@ +use async_trait::async_trait; +use primitives::chain::Chain; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +use crate::client::NameClient; +use crate::model::NameQuery; +use primitives::NameProvider; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Data { + pub data: T, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Records { + pub records: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Record { + pub key: String, + pub value: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Account { + pub account: String, +} + +pub struct DidClient { + api_url: String, + client: Client, +} + +impl DidClient { + pub fn new(api_url: String) -> Self { + let client = Client::new(); + Self { api_url, client } + } +} + +#[async_trait] +impl NameClient for DidClient { + fn provider(&self) -> NameProvider { + NameProvider::Did + } + + async fn resolve(&self, query: &NameQuery, chain: Chain) -> Result> { + let url = format!("{}/v2/account/records", self.api_url); + let account = Account { account: query.domain.clone() }; + let records = self.client.post(&url).json(&account).send().await?.json::>().await?.data.records; + + let record = records.iter().find(|r| r.key == format!("address.{}", chain.as_slip44())).ok_or("address not found")?; + + Ok(record.value.clone()) + } + + fn domains(&self) -> Vec<&'static str> { + vec!["bit"] + } + + fn chains(&self) -> Vec { + Chain::all() + } +} diff --git a/core/crates/name_resolver/src/ens/client.rs b/core/crates/name_resolver/src/ens/client.rs new file mode 100644 index 0000000000..9921dd4448 --- /dev/null +++ b/core/crates/name_resolver/src/ens/client.rs @@ -0,0 +1,51 @@ +use async_trait::async_trait; +use std::error::Error; + +use super::provider::Provider; +use crate::client::NameClient; +use crate::model::NameQuery; +use gem_evm::ethereum_address_checksum; +use primitives::{chain::Chain, name::NameProvider}; + +pub struct ENSClient { + provider: Provider, +} + +impl ENSClient { + pub fn new(url: String) -> Self { + Self { + provider: Provider::new(url).unwrap(), + } + } +} + +#[async_trait] +impl NameClient for ENSClient { + fn provider(&self) -> NameProvider { + NameProvider::Ens + } + + async fn resolve(&self, query: &NameQuery, chain: Chain) -> Result> { + let address = self.provider.resolve_name(&query.domain, chain).await?; + let address = ethereum_address_checksum(&address)?; + Ok(address) + } + + fn domains(&self) -> Vec<&'static str> { + vec!["eth", "com", "xyz", "dev"] + } + + fn chains(&self) -> Vec { + vec![ + Chain::Ethereum, + Chain::SmartChain, + Chain::Polygon, + Chain::Optimism, + Chain::Arbitrum, + Chain::Base, + Chain::AvalancheC, + Chain::Fantom, + Chain::Gnosis, + ] + } +} diff --git a/core/crates/name_resolver/src/ens/contract.rs b/core/crates/name_resolver/src/ens/contract.rs new file mode 100644 index 0000000000..7a5762d18a --- /dev/null +++ b/core/crates/name_resolver/src/ens/contract.rs @@ -0,0 +1,86 @@ +use alloy_ens::namehash; +use alloy_primitives::{Address, Bytes, hex}; +use alloy_sol_types::{SolCall, sol}; +use gem_client::ReqwestClient; +use gem_jsonrpc::JsonRpcClient; +use serde_json::json; +use std::error::Error; +use std::str::FromStr; + +sol! { + interface ENSRegistry { + function resolver(bytes32 node) external view returns (address); + } + + interface ENSResolver { + function addr(bytes32 node) external view returns (address); + + function addr_with_coin_type(bytes32 node, uint256 coin_type) external view returns (bytes); + } +} + +pub struct Contract { + pub registry_address: Address, + pub client: JsonRpcClient, +} + +impl Contract { + pub fn new(rpc_url: &str, registry_address_hex: &str) -> Result> { + let reqwest_client = gem_client::builder().build().expect("Failed to build reqwest client"); + let client = JsonRpcClient::new(ReqwestClient::new(rpc_url.to_string(), reqwest_client)); + let registry_address = Address::from_str(registry_address_hex)?; + Ok(Self { registry_address, client }) + } + pub async fn resolver(&self, name: &str) -> Result> { + let node = namehash(name); + let call = ENSRegistry::resolverCall { node }; + let calldata = Bytes::from(call.abi_encode()); + + let result_bytes = self.eth_call(self.registry_address, calldata).await?; + if result_bytes.is_empty() || result_bytes.iter().all(|&b| b == 0) { + return Err("No resolver set or resolver address is zero".into()); + } + // The result of resolver(bytes32) is an address, which is 20 bytes. + // It might be padded to 32 bytes in the return data. + // Address::decode expects exactly 20 bytes or a 32-byte slice where first 12 are zero. + if result_bytes.len() == 32 && result_bytes[0..12].iter().all(|&b| b == 0) { + Ok(Address::from_slice(&result_bytes[12..])) + } else if result_bytes.len() == 20 { + Ok(Address::from_slice(&result_bytes)) + } else { + Err("Invalid resolver address format returned".into()) + } + } + + pub async fn legacy_addr(&self, resolver_address_hex: &str, name: &str) -> Result> { + let node = namehash(name); + let resolver_address = Address::from_str(resolver_address_hex)?; + let call = ENSResolver::addrCall { node }; + let calldata = Bytes::from(call.abi_encode()); + + let result_bytes = self.eth_call(resolver_address, calldata).await?; + if result_bytes.is_empty() || result_bytes.iter().all(|&b| b == 0) { + return Err("No address found or address is zero".into()); + } + if result_bytes.len() == 32 && result_bytes[0..12].iter().all(|&b| b == 0) { + Ok(Address::from_slice(&result_bytes[12..])) + } else if result_bytes.len() == 20 { + Ok(Address::from_slice(&result_bytes)) + } else { + Err("Invalid address format returned".into()) + } + } + + async fn eth_call(&self, to: Address, data: Bytes) -> Result> { + let params = json!([ + { + "to": to.to_string(), + "data": hex::encode_prefixed(&data) + }, + "latest" + ]); + let result: String = self.client.call("eth_call", params).await?; + let bytes = hex::decode(&result)?; + Ok(Bytes::from(bytes)) + } +} diff --git a/core/crates/name_resolver/src/ens/mod.rs b/core/crates/name_resolver/src/ens/mod.rs new file mode 100644 index 0000000000..14cdd29287 --- /dev/null +++ b/core/crates/name_resolver/src/ens/mod.rs @@ -0,0 +1,7 @@ +mod client; +mod contract; +mod normalizer; +mod provider; + +pub use client::ENSClient; +pub use normalizer::normalize_domain; diff --git a/core/crates/name_resolver/src/ens/normalizer.rs b/core/crates/name_resolver/src/ens/normalizer.rs new file mode 100644 index 0000000000..9a857bec5d --- /dev/null +++ b/core/crates/name_resolver/src/ens/normalizer.rs @@ -0,0 +1,8 @@ +use idna::uts46::{AsciiDenyList, DnsLength, Hyphens, Uts46}; + +pub fn normalize_domain(name: &str) -> Result { + let uts46 = Uts46::new(); + let flags = AsciiDenyList::STD3; + let normalized = uts46.to_ascii(name.as_bytes(), flags, Hyphens::Allow, DnsLength::Ignore)?; + Ok(normalized.into_owned()) +} diff --git a/core/crates/name_resolver/src/ens/provider.rs b/core/crates/name_resolver/src/ens/provider.rs new file mode 100644 index 0000000000..70703593f5 --- /dev/null +++ b/core/crates/name_resolver/src/ens/provider.rs @@ -0,0 +1,26 @@ +use std::error::Error; + +use super::{contract::Contract, normalizer::normalize_domain}; +use primitives::Chain; + +const REGISTRY: &str = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"; +pub struct Provider { + contract: Contract, +} + +impl Provider { + pub fn new(url: String) -> Result> { + let contract = Contract::new(&url, REGISTRY)?; + Ok(Provider { contract }) + } + + pub async fn resolve_name(&self, name: &str, _chain: Chain) -> Result> { + let name = normalize_domain(name)?; + let resolver_address = self.contract.resolver(&name).await?; + // TODO: support other chain lookup + // TODO: support recursive parent lookup + // TODO: support off chain lookup CCIP-Read + let addr = self.contract.legacy_addr(&resolver_address.to_string(), &name).await?; + Ok(addr.to_checksum(None)) + } +} diff --git a/core/crates/name_resolver/src/error.rs b/core/crates/name_resolver/src/error.rs new file mode 100644 index 0000000000..4e2a419d36 --- /dev/null +++ b/core/crates/name_resolver/src/error.rs @@ -0,0 +1,19 @@ +use std::error::Error; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +#[derive(Debug)] +pub struct NameError(String); + +impl NameError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for NameError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult { + write!(formatter, "{}", self.0) + } +} + +impl Error for NameError {} diff --git a/core/crates/name_resolver/src/eths.rs b/core/crates/name_resolver/src/eths.rs new file mode 100644 index 0000000000..78c89069c9 --- /dev/null +++ b/core/crates/name_resolver/src/eths.rs @@ -0,0 +1,49 @@ +use async_trait::async_trait; +use primitives::chain::Chain; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +use crate::client::NameClient; +use crate::model::NameQuery; +use primitives::NameProvider; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolveRecord { + pub owner: String, +} + +pub struct EthsClient { + api_url: String, + client: Client, +} + +impl EthsClient { + pub fn new(api_url: String) -> Self { + let client = Client::new(); + Self { api_url, client } + } +} + +#[async_trait] +impl NameClient for EthsClient { + fn provider(&self) -> NameProvider { + NameProvider::Tree + } + + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + let url = format!("{}/resolve/{}", self.api_url, query.domain); + let record: ResolveRecord = self.client.get(&url).send().await?.json().await?; + let address = record.owner; + + Ok(address) + } + + fn domains(&self) -> Vec<&'static str> { + vec!["tree", "eths", "honk"] + } + + fn chains(&self) -> Vec { + vec![Chain::Ethereum, Chain::Polygon, Chain::SmartChain] + } +} diff --git a/core/crates/name_resolver/src/hyperliquid/contracts.rs b/core/crates/name_resolver/src/hyperliquid/contracts.rs new file mode 100644 index 0000000000..db5722cf59 --- /dev/null +++ b/core/crates/name_resolver/src/hyperliquid/contracts.rs @@ -0,0 +1,11 @@ +use alloy_sol_types::sol; + +sol! { + interface Router { + function getCurrentRegistrator() external view returns (address); + } + + interface Registrator { + function getFullRecordJSON(bytes32 _namehash) external view returns (string); + } +} diff --git a/core/crates/name_resolver/src/hyperliquid/mod.rs b/core/crates/name_resolver/src/hyperliquid/mod.rs new file mode 100644 index 0000000000..cc0e6a2c6f --- /dev/null +++ b/core/crates/name_resolver/src/hyperliquid/mod.rs @@ -0,0 +1,5 @@ +mod contracts; +mod provider; +mod record; + +pub use provider::Hyperliquid; diff --git a/core/crates/name_resolver/src/hyperliquid/provider.rs b/core/crates/name_resolver/src/hyperliquid/provider.rs new file mode 100644 index 0000000000..e88eb348b8 --- /dev/null +++ b/core/crates/name_resolver/src/hyperliquid/provider.rs @@ -0,0 +1,126 @@ +use alloy_ens::namehash; +use alloy_primitives::{Address, Bytes, hex}; +use alloy_sol_types::SolCall; +use async_trait::async_trait; +use gem_client::ReqwestClient; +use gem_jsonrpc::JsonRpcClient; +use serde_json::json; +use std::{error::Error, str::FromStr}; + +use super::{ + contracts::{Registrator, Router}, + record::Record, +}; +use crate::{client::NameClient, ens::normalize_domain, model::NameQuery}; +use primitives::{Chain, EVMChain, NameProvider}; + +const ROUTER_ADDRESS: &str = "0x25d1971d6dc9812ea1111662008f07735c74bff5"; + +pub struct Hyperliquid { + client: JsonRpcClient, + router_address: Address, +} + +impl Hyperliquid { + pub fn new(provider_url: String) -> Self { + let reqwest_client = gem_client::builder().build().expect("Failed to build reqwest client"); + let client = JsonRpcClient::new(ReqwestClient::new(provider_url, reqwest_client)); + let router_address = Address::from_str(ROUTER_ADDRESS).expect("Invalid Router address"); + + Self { client, router_address } + } + + pub fn is_valid_name(name: &str) -> bool { + let labels = name.split('.').collect::>(); + if labels.is_empty() { + return false; + } + + !labels.iter().any(|label| label.is_empty()) + } + + async fn eth_call(&self, to: Address, call_data: &[u8]) -> Result> { + let params = json!([ + { + "to": to.to_string(), + "data": hex::encode_prefixed(call_data) + }, + "latest" + ]); + + let result_str: String = self.client.call("eth_call", params).await?; + let result = Bytes::from(hex::decode(&result_str).map_err(|e| format!("Failed to decode hex response: {}", e))?); + Ok(result) + } +} + +#[async_trait] +impl NameClient for Hyperliquid { + fn chains(&self) -> Vec { + vec![Chain::Bitcoin, Chain::Ethereum, Chain::Solana, Chain::Hyperliquid] + } + + async fn resolve(&self, query: &NameQuery, chain: Chain) -> Result> { + let name = normalize_domain(&query.domain)?; + if !Self::is_valid_name(&name) { + return Err(format!("Invalid name: {name}").into()); + } + let node = namehash(&name); + + // Get current registrator from Router + let router_call_data = Router::getCurrentRegistratorCall {}.abi_encode(); + let router_result = self.eth_call(self.router_address, &router_call_data).await?; + let registrator = Router::getCurrentRegistratorCall::abi_decode_returns(&router_result)?.0; + + // Get full record JSON + let registrator_call_data = Registrator::getFullRecordJSONCall { _namehash: node }.abi_encode(); + let registrator_result = self.eth_call(Address::from(registrator), ®istrator_call_data).await?; + let record_json = Registrator::getFullRecordJSONCall::abi_decode_returns(®istrator_result)?; + let record: Record = serde_json::from_str(&record_json)?; + + // Get Resolved address for HyperEVM + if chain == Chain::Hyperliquid { + let resolved_address = &record.name.resolved; + let address = Address::from_str(resolved_address)?; + return Ok(address.to_checksum(None)); + } + + let slip44 = chain.as_slip44(); + let chain_address = record.data.chain_addresses.get(&slip44.to_string()).ok_or("Chain not found".to_string())?; + Ok(match EVMChain::from_chain(chain) { + Some(_) => Address::from_str(chain_address)?.to_checksum(None), + None => chain_address.to_string(), + }) + } + + fn provider(&self) -> NameProvider { + NameProvider::Hyperliquid + } + + fn domains(&self) -> Vec<&'static str> { + vec!["hl"] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_valid_name() { + assert!(Hyperliquid::is_valid_name("test.hl")); + assert!(Hyperliquid::is_valid_name("a.test.hl")); + assert!(Hyperliquid::is_valid_name("a.b.test.hl")); + assert!(Hyperliquid::is_valid_name("foo-bar.hl")); + assert!(Hyperliquid::is_valid_name("123.hl")); + assert!(Hyperliquid::is_valid_name("🐈🐈🐈🐈🐈🐈🐈.hl")); + + assert!(!Hyperliquid::is_valid_name("test..hl")); // Empty label + assert!(!Hyperliquid::is_valid_name("test.hl.")); // Trailing dot + assert!(!Hyperliquid::is_valid_name(".test.hl")); // Leading dot on label + assert!(!Hyperliquid::is_valid_name("test.hl..")); + assert!(!Hyperliquid::is_valid_name("test.hl..hl")); + assert!(!Hyperliquid::is_valid_name("")); // Empty name + assert!(!Hyperliquid::is_valid_name(".hl")); // Only TLD with leading dot + } +} diff --git a/core/crates/name_resolver/src/hyperliquid/record.rs b/core/crates/name_resolver/src/hyperliquid/record.rs new file mode 100644 index 0000000000..ebd3f6537d --- /dev/null +++ b/core/crates/name_resolver/src/hyperliquid/record.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Record { + pub name: NameRecord, + pub data: DataRecord, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct NameRecord { + pub resolved: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DataRecord { + pub chain_addresses: HashMap, +} diff --git a/core/crates/name_resolver/src/icns.rs b/core/crates/name_resolver/src/icns.rs new file mode 100644 index 0000000000..156e302f73 --- /dev/null +++ b/core/crates/name_resolver/src/icns.rs @@ -0,0 +1,79 @@ +use async_trait::async_trait; + +use gem_encoding::encode_base64; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, error::Error, sync::LazyLock}; + +use crate::client::NameClient; +use crate::model::NameQuery; +use primitives::{Chain, NameProvider}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Data { + pub data: T, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Record { + pub bech32_address: String, +} + +const RESOLVER: &str = "osmo1xk0s8xgktn9x5vwcgtjdxqzadg88fgn33p8u9cnpdxwemvxscvast52cdd"; + +// https://github.com/satoshilabs/slips/blob/master/slip-0173.md +static DOMAIN_MAP: LazyLock> = + LazyLock::new(|| HashMap::from([("cosmos", Chain::Cosmos), ("osmo", Chain::Osmosis), ("celestia", Chain::Celestia), ("sei", Chain::Sei)])); + +pub struct IcnsClient { + api_url: String, + client: Client, +} + +impl IcnsClient { + pub fn new(api_url: String) -> Self { + let client = Client::new(); + + Self { api_url, client } + } +} + +#[async_trait] +impl NameClient for IcnsClient { + fn provider(&self) -> NameProvider { + NameProvider::Icns + } + + async fn resolve(&self, query: &NameQuery, chain: Chain) -> Result> { + let suffix = &query.suffix; + if !DOMAIN_MAP.contains_key(suffix.as_str()) { + return Err(format!("unsupported domain: {suffix}").into()); + } + + // chain type should match domain type + let suffix_chain = DOMAIN_MAP.get(suffix.as_str()).unwrap(); + if *suffix_chain != chain { + return Err(format!("domain: {suffix} doesn't match chain: {chain}").into()); + } + + let rpc_query = serde_json::json!({ + "address_by_icns": { + "icns": query.domain, + }, + }); + + let b64 = encode_base64(rpc_query.to_string().as_bytes()); + let url = format!("{}/cosmwasm/wasm/v1/contract/{}/smart/{}", self.api_url, RESOLVER, b64); + let address = self.client.get(&url).send().await?.json::>().await?.data.bech32_address; + + Ok(address) + } + + fn domains(&self) -> Vec<&'static str> { + vec![] // DOMAIN_MAP.keys().cloned().collect() + } + + fn chains(&self) -> Vec { + DOMAIN_MAP.values().cloned().collect() + } +} diff --git a/core/crates/name_resolver/src/injective.rs b/core/crates/name_resolver/src/injective.rs new file mode 100644 index 0000000000..e60b149ef9 --- /dev/null +++ b/core/crates/name_resolver/src/injective.rs @@ -0,0 +1,82 @@ +use alloy_ens::namehash; +use async_trait::async_trait; +use gem_encoding::encode_base64; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +use crate::client::NameClient; +use crate::model::NameQuery; +use primitives::{Chain, NameProvider}; + +pub struct InjectiveNameClient { + url: String, + client: Client, +} + +// request +#[derive(Debug, Deserialize, Serialize)] +pub struct Resolver { + resolver: ResolverNode, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolverNode { + node: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolverAddress { + address: ResolverNode, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolverDataResponse { + data: ResolverData, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolverData { + address: String, +} + +//const REGISTER_ADDRESS: &str = "inj1hm8vs8sr2h9nk0x66vctfs528wrp6k3gtgg275"; +const RESOLVER_ADDRESS: &str = "inj1x9m0hceug9qylcyrrtwqtytslv2jrph433thgu"; + +impl InjectiveNameClient { + pub fn new(url: String) -> Self { + let client = Client::new(); + Self { url, client } + } +} + +#[async_trait] +impl NameClient for InjectiveNameClient { + fn provider(&self) -> NameProvider { + NameProvider::Injective + } + + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + let hash = namehash(&query.domain); + let resolve = ResolverAddress { + address: ResolverNode { node: hash.to_vec() }, + }; + + let string = serde_json::to_string(&resolve)?; + let encoded = encode_base64(string.as_bytes()); + + let url = format!("{}/cosmwasm/wasm/v1/contract/{}/smart/{}", self.url, RESOLVER_ADDRESS, encoded); + + let response = self.client.get(&url).send().await?.json::().await?; + + Ok(response.data.address) + } + + fn domains(&self) -> Vec<&'static str> { + vec!["inj"] + } + + fn chains(&self) -> Vec { + vec![Chain::Injective] + } +} diff --git a/core/crates/name_resolver/src/lens.rs b/core/crates/name_resolver/src/lens.rs new file mode 100644 index 0000000000..a73643a47d --- /dev/null +++ b/core/crates/name_resolver/src/lens.rs @@ -0,0 +1,70 @@ +use async_trait::async_trait; + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +use crate::client::NameClient; +use crate::model::NameQuery; +use primitives::{Chain, NameProvider}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Data { + pub data: T, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Record { + pub handle_to_address: Option, +} + +pub struct LensClient { + api_url: String, + client: Client, +} + +impl LensClient { + pub fn new(api_url: String) -> Self { + let client = Client::new(); + Self { api_url, client } + } +} + +#[async_trait] +impl NameClient for LensClient { + fn provider(&self) -> NameProvider { + NameProvider::Lens + } + + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + let handle = format!("{}/{}", query.suffix, query.name); + + let query = format!("query {{handleToAddress(request: {{handle: \"{handle}\" }} )}}"); + let query = serde_json::json!({ + "query": query, + }); + + let address = self + .client + .post(&self.api_url) + .json(&query) + .send() + .await? + .json::>() + .await? + .data + .handle_to_address; + + address.ok_or("address not found".into()) + } + + fn domains(&self) -> Vec<&'static str> { + vec!["lens"] + } + + fn chains(&self) -> Vec { + // Add all evm chains? + vec![Chain::Ethereum, Chain::Polygon] + } +} diff --git a/core/crates/name_resolver/src/lib.rs b/core/crates/name_resolver/src/lib.rs new file mode 100644 index 0000000000..0ef7bb70b7 --- /dev/null +++ b/core/crates/name_resolver/src/lib.rs @@ -0,0 +1,49 @@ +use client::NameClient; +use settings::Settings; + +pub mod alldomains; +pub mod aptos; +pub mod base; +pub mod client; +pub mod codec; +pub mod did; +pub mod ens; +pub mod error; +pub mod eths; +pub mod hyperliquid; +pub mod icns; +pub mod injective; +pub mod lens; +pub mod model; +pub mod sns; +pub mod spaceid; +pub mod suins; +#[cfg(test)] +pub mod testkit; +pub mod ton; +pub mod ton_codec; +pub mod ud; + +pub struct NameProviderFactory {} + +impl NameProviderFactory { + pub fn create_providers(settings: Settings) -> Vec> { + vec![ + Box::new(ens::ENSClient::new(settings.name.ens.url)), + Box::new(ud::UDClient::new(settings.name.ud.url, settings.name.ud.key.secret)), + Box::new(sns::SNSClient::new(settings.name.sns.url)), + Box::new(ton::TONClient::new(settings.name.ton.url)), + Box::new(eths::EthsClient::new(settings.name.eths.url)), + Box::new(spaceid::SpaceIdClient::new(settings.name.spaceid.url)), + Box::new(did::DidClient::new(settings.name.did.url)), + Box::new(suins::SuinsClient::new(settings.name.suins.url)), + Box::new(aptos::AptosClient::new(settings.name.aptos.url)), + Box::new(injective::InjectiveNameClient::new(settings.name.injective.url)), + Box::new(icns::IcnsClient::new(settings.name.icns.url)), + Box::new(lens::LensClient::new(settings.name.lens.url)), + Box::new(base::Basenames::new(settings.name.base.url)), + Box::new(hyperliquid::Hyperliquid::new(settings.name.hyperliquid.url)), + Box::new(alldomains::AllDomainsClient::new(settings.name.alldomains.url)), + ] + } +} diff --git a/core/crates/name_resolver/src/model.rs b/core/crates/name_resolver/src/model.rs new file mode 100644 index 0000000000..b42ea846a7 --- /dev/null +++ b/core/crates/name_resolver/src/model.rs @@ -0,0 +1,49 @@ +pub struct NameQuery { + pub name: String, + pub domain: String, + pub suffix: String, +} + +impl NameQuery { + pub fn new(domain: &str) -> Self { + let name = domain.split('.').next().unwrap_or(domain).to_string(); + let suffix = domain.get(name.len() + 1..).unwrap_or_default().to_string(); + Self { + name, + domain: domain.to_string(), + suffix, + } + } +} + +#[cfg(test)] +mod tests { + use super::NameQuery; + + #[test] + fn test_name_query_single_suffix() { + let query = NameQuery::new("alice.eth"); + + assert_eq!(query.name, "alice"); + assert_eq!(query.domain, "alice.eth"); + assert_eq!(query.suffix, "eth"); + } + + #[test] + fn test_name_query_multi_suffix() { + let query = NameQuery::new("alice.base.eth"); + + assert_eq!(query.name, "alice"); + assert_eq!(query.domain, "alice.base.eth"); + assert_eq!(query.suffix, "base.eth"); + } + + #[test] + fn test_name_query_no_dot() { + let query = NameQuery::new("alice"); + + assert_eq!(query.name, "alice"); + assert_eq!(query.domain, "alice"); + assert_eq!(query.suffix, ""); + } +} diff --git a/core/crates/name_resolver/src/sns.rs b/core/crates/name_resolver/src/sns.rs new file mode 100644 index 0000000000..4df060a3ea --- /dev/null +++ b/core/crates/name_resolver/src/sns.rs @@ -0,0 +1,84 @@ +use crate::client::NameClient; +use crate::model::NameQuery; +use async_trait::async_trait; +use primitives::{Chain, NameProvider}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolveDomain { + pub s: String, + pub result: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolveRecord { + pub s: String, + pub result: ResolveRecordResult, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolveRecordResult { + pub deserialized: String, +} + +pub struct SNSClient { + url: String, + client: Client, +} + +impl SNSClient { + pub fn new(url: String) -> Self { + let client = Client::new(); + Self { url, client } + } + + async fn resolve_hex_address(&self, name: &str, record: &str) -> Result> { + let url = format!("{}/record-v2/{}/{}", self.url, name, record); + let response = self.client.get(&url).send().await?.json::().await?; + + if response.s != "ok" { + return Err("error".to_string().into()); + } + + Ok(response.result.deserialized) + } + + async fn resolve_sol_address(&self, name: &str, _chain: &Chain) -> Result> { + let url = format!("{}/resolve/{}", self.url, name); + let response = self.client.get(&url).send().await?.json::().await?; + + if response.s != "ok" { + return Err("error".to_string().into()); + } + Ok(response.result) + } +} + +#[async_trait] +impl NameClient for SNSClient { + fn provider(&self) -> NameProvider { + NameProvider::Sns + } + + async fn resolve(&self, query: &NameQuery, chain: Chain) -> Result> { + match chain { + Chain::Solana => { + return self.resolve_sol_address(&query.domain, &chain.clone()).await; + } + Chain::SmartChain => { + return self.resolve_hex_address(&query.domain, "BSC").await; + } + _ => return Err("error".to_string().into()), + } + } + + fn domains(&self) -> Vec<&'static str> { + vec!["sol"] + } + + fn chains(&self) -> Vec { + vec![Chain::Solana, Chain::SmartChain] + } +} diff --git a/core/crates/name_resolver/src/spaceid.rs b/core/crates/name_resolver/src/spaceid.rs new file mode 100644 index 0000000000..cef2b9be0e --- /dev/null +++ b/core/crates/name_resolver/src/spaceid.rs @@ -0,0 +1,54 @@ +use async_trait::async_trait; +use primitives::chain::Chain; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +use crate::client::NameClient; +use crate::model::NameQuery; +use primitives::NameProvider; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolveRecord { + pub code: i32, + pub address: String, +} + +pub struct SpaceIdClient { + api_url: String, + client: Client, +} + +impl SpaceIdClient { + pub fn new(api_url: String) -> Self { + let client = Client::new(); + Self { api_url, client } + } +} + +#[async_trait] +impl NameClient for SpaceIdClient { + fn provider(&self) -> NameProvider { + NameProvider::Spaceid + } + + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + let tld = &query.suffix; + let url = format!("{}/v1/getAddress?tld={}&domain={}", self.api_url, tld, query.domain); + let record: ResolveRecord = self.client.get(&url).send().await?.json().await?; + if record.code != 0 { + return Err("SpaceIdClient: code != 0".into()); + } + let address = record.address; + + Ok(address) + } + + fn domains(&self) -> Vec<&'static str> { + vec!["bnb", "arb"] + } + + fn chains(&self) -> Vec { + vec![Chain::SmartChain, Chain::Arbitrum] + } +} diff --git a/core/crates/name_resolver/src/suins/client.rs b/core/crates/name_resolver/src/suins/client.rs new file mode 100644 index 0000000000..fcd59b69f4 --- /dev/null +++ b/core/crates/name_resolver/src/suins/client.rs @@ -0,0 +1,71 @@ +use crate::client::NameClient; +use crate::model::NameQuery; +use async_trait::async_trait; +use gem_encoding::protobuf::{decode_grpc_frame, encode_grpc_frame}; +use gem_jsonrpc::grpc::{GrpcTransport, ReqwestGrpcTransport}; +use primitives::NameProvider; +use primitives::chain::Chain; +use std::error::Error; + +use super::proto::{decode_lookup_name_response, encode_lookup_name_request}; + +const PATH_LOOKUP_NAME: &str = "/sui.rpc.v2.NameService/LookupName"; + +#[derive(Clone, Debug)] +pub struct SuinsClient { + api_url: String, + transport: ReqwestGrpcTransport, +} + +impl SuinsClient { + pub fn new(api_url: String) -> Self { + Self { + api_url, + transport: ReqwestGrpcTransport::new(), + } + } + + async fn lookup_name(&self, name: &str) -> Result> { + let body = self + .transport + .unary(&self.api_url, PATH_LOOKUP_NAME, encode_grpc_frame(&encode_lookup_name_request(name))) + .await + .map_err(|error| format!("SuiNS gRPC request failed: {error}"))?; + + decode_lookup_name_response(decode_grpc_frame(&body)?) + } +} + +#[async_trait] +impl NameClient for SuinsClient { + fn provider(&self) -> NameProvider { + NameProvider::Suins + } + + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + self.lookup_name(&query.domain).await + } + + fn domains(&self) -> Vec<&'static str> { + vec!["sui"] + } + + fn chains(&self) -> Vec { + vec![Chain::Sui] + } +} + +#[cfg(test)] +mod tests { + use gem_encoding::protobuf::{encode_grpc_frame, encode_string_field}; + + #[test] + fn test_encode_grpc_message() { + let payload = encode_string_field(1, "alpha.sui"); + let encoded = encode_grpc_frame(&payload); + + assert_eq!(encoded[0], 0); + assert_eq!(u32::from_be_bytes(encoded[1..5].try_into().unwrap()), payload.len() as u32); + assert_eq!(&encoded[5..], payload.as_slice()); + } +} diff --git a/core/crates/name_resolver/src/suins/mod.rs b/core/crates/name_resolver/src/suins/mod.rs new file mode 100644 index 0000000000..4881e6e5e1 --- /dev/null +++ b/core/crates/name_resolver/src/suins/mod.rs @@ -0,0 +1,4 @@ +mod client; +mod proto; + +pub use client::SuinsClient; diff --git a/core/crates/name_resolver/src/suins/proto.rs b/core/crates/name_resolver/src/suins/proto.rs new file mode 100644 index 0000000000..9a84fc1789 --- /dev/null +++ b/core/crates/name_resolver/src/suins/proto.rs @@ -0,0 +1,93 @@ +use gem_encoding::protobuf::{MessageDecode, MessageEncode, MessageResult, proto_decode, proto_encode}; + +// Field numbers mirror sui-rpc v0.3.1 SuiNS schema: +// https://docs.rs/crate/sui-rpc/0.3.1/source/vendored/proto/sui/rpc/v2/name_service.proto + +pub(super) fn encode_lookup_name_request(name: &str) -> Vec { + LookupNameRequest { name: Some(name.to_string()) }.encode() +} + +pub(super) fn decode_lookup_name_response(data: &[u8]) -> MessageResult { + LookupNameResponse::decode(data)? + .record + .and_then(|record| record.target_address) + .ok_or_else(|| "SuiNS record has no target address".into()) +} + +#[derive(Clone, Debug, Default)] +struct LookupNameRequest { + name: Option, +} + +proto_encode!(LookupNameRequest { + 1 => name: optional_string, +}); + +#[derive(Clone, Debug, Default)] +struct LookupNameResponse { + record: Option, +} + +proto_decode!(LookupNameResponse { + 1 => record: optional_message, +}); + +#[derive(Clone, Debug, Default)] +struct NameRecord { + target_address: Option, +} + +proto_decode!(NameRecord { + 5 => target_address: optional_string, +}); + +#[cfg(test)] +mod tests { + use super::*; + use gem_encoding::protobuf::{decode_grpc_frame, encode_bytes_field, encode_grpc_frame, encode_string_field}; + + #[test] + fn test_encode_lookup_name_request() { + assert_eq!(encode_lookup_name_request("alpha.sui"), encode_string_field(1, "alpha.sui")); + } + + #[test] + fn test_decode_lookup_name_response() { + let target = "0x54e5c2a6f1276ac2ff623ac54e53e5a61a576906b3ec42fac8fe8bf5615d0957"; + let record = [ + encode_string_field(1, "record-id"), + encode_string_field(2, "alpha.sui"), + encode_string_field(5, target), + encode_bytes_field(6, &[encode_string_field(1, "avatar"), encode_string_field(2, "ipfs://avatar")].concat()), + ] + .concat(); + let response = encode_bytes_field(1, &record); + + assert_eq!(decode_lookup_name_response(&response).unwrap(), target); + } + + #[test] + fn test_decode_lookup_name_response_rejects_missing_target() { + let record = encode_string_field(2, "alpha.sui"); + let response = encode_bytes_field(1, &record); + + assert_eq!(decode_lookup_name_response(&response).unwrap_err().to_string(), "SuiNS record has no target address"); + } + + #[test] + fn test_decode_grpc_message_rejects_truncated_frame() { + let payload = encode_string_field(1, "alpha.sui"); + let mut frame = vec![0]; + frame.extend_from_slice(&((payload.len() + 1) as u32).to_be_bytes()); + frame.extend_from_slice(&payload); + + assert_eq!(decode_grpc_frame(&frame).unwrap_err().to_string(), "truncated gRPC response frame"); + } + + #[test] + fn test_decode_grpc_message_roundtrip() { + let payload = encode_lookup_name_request("alpha.sui"); + + assert_eq!(decode_grpc_frame(&encode_grpc_frame(&payload)).unwrap(), payload); + } +} diff --git a/core/crates/name_resolver/src/testkit.rs b/core/crates/name_resolver/src/testkit.rs new file mode 100644 index 0000000000..02954f9aad --- /dev/null +++ b/core/crates/name_resolver/src/testkit.rs @@ -0,0 +1,58 @@ +use std::error::Error; + +use async_trait::async_trait; +use primitives::chain::Chain; +use primitives::name::NameProvider; + +use crate::client::NameClient; +use crate::error::NameError; +use crate::model::NameQuery; + +pub struct TestProvider { + provider: NameProvider, + domains: Vec<&'static str>, + chains: Vec, + response: Result, +} + +impl TestProvider { + pub fn new(provider: NameProvider, domains: Vec<&'static str>, chains: Vec, response: Result<&'static str, &'static str>) -> Self { + let response = match response { + Ok(address) => Ok(address.to_string()), + Err(error) => Err(error), + }; + + Self { + provider, + domains, + chains, + response, + } + } + + pub fn boxed(provider: NameProvider, domains: Vec<&'static str>, chains: Vec, response: Result<&'static str, &'static str>) -> Box { + Box::new(Self::new(provider, domains, chains, response)) + } +} + +#[async_trait] +impl NameClient for TestProvider { + async fn resolve(&self, _query: &NameQuery, _chain: Chain) -> Result> { + match &self.response { + Ok(address) => Ok(address.clone()), + Err(error) => Err(Box::new(NameError::new(error.to_string()))), + } + } + + fn provider(&self) -> NameProvider { + self.provider.clone() + } + + fn domains(&self) -> Vec<&'static str> { + self.domains.clone() + } + + fn chains(&self) -> Vec { + self.chains.clone() + } +} diff --git a/core/crates/name_resolver/src/ton.rs b/core/crates/name_resolver/src/ton.rs new file mode 100644 index 0000000000..abda48814b --- /dev/null +++ b/core/crates/name_resolver/src/ton.rs @@ -0,0 +1,85 @@ +use crate::codec::Codec; +use crate::{client::NameClient, model::NameQuery, ton_codec}; +use async_trait::async_trait; +use primitives::{Chain, NameProvider}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; + +pub struct TONClient { + url: String, + client: Client, +} + +impl TONClient { + pub fn new(url: String) -> Self { + let client = Client::new(); + Self { url, client } + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct DnsRecord { + dns_wallet: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct DnsRecordsResponse { + records: Vec, +} + +#[async_trait] +impl NameClient for TONClient { + fn provider(&self) -> NameProvider { + NameProvider::Ton + } + + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + let url = format!("{}/api/v3/dns/records", self.url); + let response = self + .client + .get(&url) + .query(&[("domain", query.domain.as_str()), ("limit", "1")]) + .send() + .await? + .error_for_status()? + .json::() + .await?; + let address = response.records.first().and_then(|record| record.dns_wallet.as_deref()).ok_or("missing TON DNS wallet")?; + + // always encode as Bounceable address + ton_codec::TonCodec::encode(address.as_bytes().to_vec()) + } + + fn domains(&self) -> Vec<&'static str> { + vec!["ton"] + } + + fn chains(&self) -> Vec { + vec![Chain::Ton] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encoding() { + let raw = "0:8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78"; + let address = ton_codec::TonCodec::encode(raw.as_bytes().to_vec()).unwrap(); + + assert_eq!(address, "EQCOh0t62bvrv8SIELiTnJj1BYAkbxmYIEDbyyU8TD2veND8"); + } + + #[test] + fn test_dns_records_response() { + let response: DnsRecordsResponse = serde_json::from_str(include_str!("../testdata/ton_dns_records_response.json")).unwrap(); + let address = response.records.first().unwrap().dns_wallet.as_deref().unwrap(); + + assert_eq!( + ton_codec::TonCodec::encode(address.as_bytes().to_vec()).unwrap(), + "EQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzyiQ" + ); + } +} diff --git a/core/crates/name_resolver/src/ton_codec.rs b/core/crates/name_resolver/src/ton_codec.rs new file mode 100644 index 0000000000..71c817d1a8 --- /dev/null +++ b/core/crates/name_resolver/src/ton_codec.rs @@ -0,0 +1,54 @@ +use crate::codec::Codec; + +use gem_ton::address::Address; +use primitives::Address as AddressTrait; +use std::error::Error; + +pub struct TonCodec {} + +impl Codec for TonCodec { + fn decode(string: &str) -> Result, Box> { + let address = Address::try_parse(string).ok_or("invalid TON address")?; + Ok(address.hash_part().to_vec()) + } + + /// Encode to master chain base64 address + fn encode(bytes: Vec) -> Result> { + let hash_part: [u8; 32] = { + // raw hex address is 33 bytes + if bytes.len() == 66 { + let decoded = hex::decode(&bytes[2..])?; + decoded.as_slice().try_into()? + } else { + bytes.as_slice().try_into()? + } + }; + let address = Address::new(0, hash_part); + Ok(address.encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hex; + + #[test] + fn test_encode() { + let raw = "0:8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78"; + let address = TonCodec::encode(raw.as_bytes().to_vec()).unwrap(); + + assert_eq!(address, "EQCOh0t62bvrv8SIELiTnJj1BYAkbxmYIEDbyyU8TD2veND8"); + } + + #[test] + fn test_decode() { + let string = "EQCOh0t62bvrv8SIELiTnJj1BYAkbxmYIEDbyyU8TD2veND8"; + let raw = "0:8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78"; + let bytes = TonCodec::decode(string).unwrap(); + let bytes2 = TonCodec::decode(raw).unwrap(); + + assert_eq!(bytes, bytes2); + assert_eq!(hex::encode(bytes), "8e874b7ad9bbebbfc48810b8939c98f50580246f19982040dbcb253c4c3daf78"); + } +} diff --git a/core/crates/name_resolver/src/ud.rs b/core/crates/name_resolver/src/ud.rs new file mode 100644 index 0000000000..2750e65f32 --- /dev/null +++ b/core/crates/name_resolver/src/ud.rs @@ -0,0 +1,127 @@ +use async_trait::async_trait; +use primitives::chain::Chain; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, error::Error}; + +use crate::client::NameClient; +use crate::model::NameQuery; +use primitives::NameProvider; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResolveDomain { + pub records: HashMap, +} + +pub struct UDClient { + api_url: String, + api_key: String, + client: Client, +} + +impl UDClient { + pub fn new(api_url: String, api_key: String) -> Self { + let client = Client::new(); + Self { api_url, api_key, client } + } + + fn map(&self, chain: Chain, records: HashMap) -> Option { + match chain { + Chain::Bitcoin => records.get("crypto.BTC.address").cloned(), + Chain::Solana => records.get("crypto.SOL.address").cloned(), + Chain::Ethereum => records.get("crypto.ETH.address").cloned(), + Chain::Polygon => records.get("crypto.MATIC.version.MATIC.address").cloned(), + Chain::Base => records.get("crypto.ETH.address").cloned(), + Chain::Arbitrum => records.get("crypto.ETH.address").cloned(), + Chain::Optimism => records.get("crypto.ETH.address").cloned(), + Chain::AvalancheC => records.get("crypto.ETH.address").cloned(), + Chain::Tron => records.get("crypto.TRX.address").cloned(), + Chain::Cosmos => records.get("crypto.ATOM.address").cloned(), + Chain::Doge => records.get("crypto.DOGE.address").cloned(), + Chain::SmartChain => records.get("crypto.BNB.version.BEP20.address").cloned(), + Chain::Aptos => records.get("crypto.APT.address").cloned(), + _ => None, + } + } +} + +#[async_trait] +impl NameClient for UDClient { + fn provider(&self) -> NameProvider { + NameProvider::Ud + } + + async fn resolve(&self, query: &NameQuery, chain: Chain) -> Result> { + let url = format!("{}/resolve/domains/{}", self.api_url, query.domain); + let response = self.client.get(&url).bearer_auth(self.api_key.clone()).send().await?.json::().await?; + let records = response.records; + + let address = self.map(chain, records); + match address { + None => Err("address not found".into()), + Some(address) => Ok(address), + } + } + + fn domains(&self) -> Vec<&'static str> { + // https://api.unstoppabledomains.com/resolve/supported_tlds + vec![ + "altimist", + "anime", + "austin", + "binanceus", + "bitcoin", + "bitget", + "blockchain", + "clay", + "crypto", + "dao", + "dfz", + "farms", + "go", + "hi", + "klever", + "kresus", + "kryptic", + "manga", + "metropolis", + "nft", + "pog", + "polygon", + "pudgy", + "raiin", + "secret", + "smobler", + "stepn", + "tball", + "ubu", + "unstoppable", + "wallet", + "witg", + "wrkx", + "x", + "888", + "zil", + "ca", + "com", + "pw", + "eth", + ] + } + + fn chains(&self) -> Vec { + vec![ + Chain::Bitcoin, + Chain::Ethereum, + Chain::Solana, + Chain::Tron, + Chain::Cosmos, + Chain::Doge, + Chain::SmartChain, + Chain::Polygon, + Chain::Optimism, + Chain::AvalancheC, + Chain::Aptos, + ] + } +} diff --git a/core/crates/name_resolver/testdata/ton_dns_records_response.json b/core/crates/name_resolver/testdata/ton_dns_records_response.json new file mode 100644 index 0000000000..26ecb7b6dc --- /dev/null +++ b/core/crates/name_resolver/testdata/ton_dns_records_response.json @@ -0,0 +1,9 @@ +{ + "records": [ + { + "domain": "gemcoder.ton", + "dns_wallet": "0:33A14A5A9406979D59B9328898591660B8B1736342B11632EFDCC911AB9057CF" + } + ], + "address_book": {} +} diff --git a/core/crates/name_resolver/tests/integration_test.rs b/core/crates/name_resolver/tests/integration_test.rs new file mode 100644 index 0000000000..f2ffdd460f --- /dev/null +++ b/core/crates/name_resolver/tests/integration_test.rs @@ -0,0 +1,83 @@ +#[cfg(test)] +mod tests { + use std::env; + + use name_resolver::{ + alldomains::AllDomainsClient, + base::Basenames, + client::{NameClient, NameConfig}, + ens::ENSClient, + hyperliquid::Hyperliquid, + injective::InjectiveNameClient, + model::NameQuery, + suins::SuinsClient, + }; + use primitives::{Chain, node_config::get_nodes_for_chain}; + use settings::Settings; + + #[tokio::test] + async fn test_resolver_eth() { + // this test is ignored from UT cause it connects to the real network + let nodes = get_nodes_for_chain(Chain::Ethereum); + let client = ENSClient::new(nodes[0].url.clone()); + let address = client.resolve(&NameQuery::new("vitalik.eth"), Chain::Ethereum).await; + assert_eq!(address.unwrap(), "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") + } + + #[tokio::test] + async fn test_resolver_ens_imported_name() { + let nodes = get_nodes_for_chain(Chain::Ethereum); + let client = name_resolver::client::Client::new(vec![Box::new(ENSClient::new(nodes[0].url.clone()))], NameConfig { max_name_length: 20 }); + let address = client.resolve("farcaster.xyz", Chain::Ethereum).await.unwrap().address; + assert_eq!(address, "0xF12E89805E10d96c0CDf22da88aED361eD9329cA"); + } + + #[tokio::test] + async fn test_resolve_basenames() { + let nodes = get_nodes_for_chain(Chain::Base); + let client = Basenames::new(nodes[0].url.clone()); + let address = client.resolve(&NameQuery::new("h3rman.base.eth"), Chain::Base).await.unwrap(); + assert_eq!(address.to_lowercase(), "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_lowercase()) + } + + #[tokio::test] + async fn test_resolve_injective() { + let nodes = get_nodes_for_chain(Chain::Injective); + let client = InjectiveNameClient::new(nodes[0].url.clone()); + let address_result = client.resolve(&NameQuery::new("test.inj"), Chain::Injective).await; + assert_eq!(address_result.unwrap(), "inj14apqz6u2nprsly3j0mqa6jwpxnmnphq3pp0q9g"); + } + + #[tokio::test] + async fn test_resolve_suins() { + let nodes = get_nodes_for_chain(Chain::Sui); + let client = SuinsClient::new(nodes[0].url.clone()); + let address_result = client.resolve(&NameQuery::new("alpha.sui"), Chain::Sui).await; + assert_eq!(address_result.unwrap(), "0x54e5c2a6f1276ac2ff623ac54e53e5a61a576906b3ec42fac8fe8bf5615d0957"); + } + + #[tokio::test] + async fn test_resolve_hlnames() { + let current_dir = env::current_dir().unwrap(); + let path = current_dir.join("../../Settings.yaml"); + let settings = Settings::new_setting_path(path).unwrap(); + let client = Hyperliquid::new(settings.name.hyperliquid.url); + let name = "TESTOOOR.HL"; + let address = client.resolve(&NameQuery::new(name), Chain::Ethereum).await.unwrap(); + assert_eq!(address, "0xb43f5153B1c867BF78ACB3C35aa9b8ae366415c5"); + + let address = client.resolve(&NameQuery::new(name), Chain::Hyperliquid).await.unwrap(); + assert_eq!(address, "0xF26F5551E96aE5162509B25925fFfa7F07B2D652"); + + let address = client.resolve(&NameQuery::new(name), Chain::Solana).await.unwrap(); + assert_eq!(address, "CKAvaYmwqCbg8nZCUCNj6Cvr11HauALtNoGT7WirPoAp"); + } + + #[tokio::test] + async fn test_resolve_alldomains() { + let nodes = get_nodes_for_chain(Chain::Solana); + let client = AllDomainsClient::new(nodes[0].url.clone()); + let address = client.resolve(&NameQuery::new("miester.poor"), Chain::Solana).await.unwrap(); + assert_eq!(address.trim(), "2EGGxj2qbNAJNgLCPKca8sxZYetyTjnoRspTPjzN2D67"); + } +} diff --git a/core/crates/nft/Cargo.toml b/core/crates/nft/Cargo.toml new file mode 100644 index 0000000000..f68e91b087 --- /dev/null +++ b/core/crates/nft/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "nft" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +reqwest = { workspace = true } +futures.workspace = true +async-trait = { workspace = true } + +primitives = { path = "../primitives" } +storage = { path = "../storage" } +gem_client = { path = "../gem_client", features = ["reqwest"] } +gem_evm = { path = "../gem_evm" } +gem_ton = { path = "../gem_ton", features = ["rpc"] } + +settings = { path = "../settings", optional = true } + +[dev-dependencies] +tokio = { workspace = true } + +[features] +nft_integration_tests = ["dep:settings"] diff --git a/core/crates/nft/src/client.rs b/core/crates/nft/src/client.rs new file mode 100644 index 0000000000..61354afe35 --- /dev/null +++ b/core/crates/nft/src/client.rs @@ -0,0 +1,316 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; + +use primitives::nft::NFTAssetData; +use primitives::{AssetId, Chain, ImageFormatter, NFTAsset, NFTAssetId, NFTCollection, NFTCollectionId, NFTData}; +use storage::database::devices::DevicesStore; +use storage::database::nft::{NftAssetFilter, NftCollectionFilter}; +use storage::models::{NewNftAssetRow, NewNftCollectionRow, NftCollectionRow, NftLinkRow}; +use storage::{Database, DatabaseError, NftRepository, WalletsRepository}; + +use crate::NFTProviderConfig; +use crate::provider_client::NFTProviderClient; + +pub struct NFTClient { + database: Database, + provider_client: NFTProviderClient, + assets_url: String, +} + +impl NFTClient { + pub fn new(database: Database, provider_client: NFTProviderClient, assets_url: String) -> Self { + Self { + database, + provider_client, + assets_url, + } + } + + pub fn from_config(database: Database, config: NFTProviderConfig, assets_url: String) -> Self { + Self::new(database, NFTProviderClient::new(config), assets_url) + } + + pub async fn update_collection(&self, collection_id: &str) -> Result> { + self.refresh_collection(collection_id.parse()?).await?; + Ok(true) + } + + pub async fn update_asset(&self, asset_id: &str) -> Result> { + self.refresh_asset(asset_id.parse()?).await?; + Ok(true) + } + + pub async fn refresh_collection(&self, collection_id: NFTCollectionId) -> Result> { + let collection = self.provider_client.get_nft_collection(collection_id).await?; + self.upsert_collection(collection.clone())?; + Ok(self.with_urls_collection(collection)) + } + + pub async fn refresh_asset(&self, asset_id: NFTAssetId) -> Result<(), Box> { + let collection = self.provider_client.get_nft_collection(asset_id.get_collection_id()).await?; + let collection_row = self.upsert_collection(collection)?; + let asset = self.provider_client.get_nft_asset(asset_id).await?; + self.database.nft()?.upsert_nft_asset(NewNftAssetRow::from_primitive(asset, collection_row.id))?; + Ok(()) + } + + pub async fn get_nft_assets_by_wallet_id(&self, device_id: i32, wallet_id: i32) -> Result, Box> { + let subscriptions = self.database.wallets()?.get_subscriptions_by_wallet_id(device_id, wallet_id)?; + + let mut asset_ids: HashSet = HashSet::new(); + for (sub, addr) in subscriptions { + let chain = sub.chain.0; + let ids = match self.get_provider_asset_ids(chain, &addr.address).await { + Ok(ids) => ids, + Err(_) => self.get_cached_asset_ids(addr.id, chain)?, + }; + asset_ids.extend(ids); + } + + self.preload(asset_ids.into_iter().collect()).await + } + + pub fn get_nft_asset_data(&self, asset_id: NFTAssetId) -> Result> { + let asset = self.with_urls_asset(self.load_nft_asset(&asset_id.to_string())?); + let collection = self.with_urls_collection(self.load_nft_collection(&asset.collection_id.to_string())?); + + Ok(NFTAssetData { collection, asset }) + } + + async fn get_provider_asset_ids(&self, chain: Chain, address: &str) -> Result, Box> { + Ok(self + .provider_client + .get_nft_data(chain, address) + .await? + .into_iter() + .flat_map(|d| d.assets.into_iter().map(|a| a.id)) + .collect()) + } + + fn get_cached_asset_ids(&self, address_id: i32, chain: Chain) -> Result, Box> { + Ok(self + .database + .nft()? + .get_nft_assets_by_filter(vec![NftAssetFilter::AddressId(address_id)])? + .into_iter() + .filter(|row| row.chain.0 == chain) + .map(|row| row.identifier.0) + .collect()) + } + + fn with_urls_asset(&self, asset: NFTAsset) -> NFTAsset { + let id = asset.id.to_string(); + let preview_url = ImageFormatter::get_nft_asset_url(&self.assets_url, &id); + let resource_url = ImageFormatter::get_nft_asset_resource_url(&self.assets_url, &id); + asset.with_urls(preview_url, resource_url) + } + + fn with_urls_collection(&self, collection: NFTCollection) -> NFTCollection { + let preview_url = ImageFormatter::get_nft_collection_url(&self.assets_url, &collection.id.to_string()); + collection.with_preview_url(preview_url) + } + + fn with_urls_data(&self, data: NFTData) -> NFTData { + NFTData { + collection: self.with_urls_collection(data.collection), + assets: data.assets.into_iter().map(|a| self.with_urls_asset(a)).collect(), + } + } + + fn upsert_collection(&self, collection: NFTCollection) -> Result> { + let row = self.database.nft()?.upsert_nft_collection(NewNftCollectionRow::from_primitive(collection.clone()))?; + let links: Vec = collection + .links + .into_iter() + .filter(|link| !link.url.is_empty()) + .map(|link| NftLinkRow::from_primitive(row.id, link)) + .collect(); + self.database.nft()?.set_nft_collection_links(row.id, links)?; + Ok(row) + } + + pub async fn preload(&self, assets: Vec) -> Result, Box> { + let collection_ids: HashSet = assets.iter().map(|x| x.get_collection_id()).collect(); + let collection_id_map = self.preload_collections(collection_ids.into_iter().collect()).await?; + self.preload_assets(assets.clone(), &collection_id_map).await?; + self.get_nfts(assets).await + } + + pub async fn preload_collections(&self, collection_ids: Vec) -> Result, Box> { + let identifiers: Vec = collection_ids.iter().map(|x| x.to_string()).collect(); + let existing = self.get_nft_collection_id_map(&identifiers)?; + + let mut new_collections: Vec = Vec::new(); + for id in collection_ids.into_iter().filter(|id| !existing.contains_key(&id.to_string())) { + if let Ok(collection) = self.provider_client.get_nft_collection(id).await { + new_collections.push(collection); + } + } + + if new_collections.is_empty() { + return Ok(existing); + } + + let rows = new_collections.iter().cloned().map(NewNftCollectionRow::from_primitive).collect(); + self.database.nft()?.add_nft_collections(rows)?; + + let map = self.get_nft_collection_id_map(&identifiers)?; + + let links: Vec = new_collections + .into_iter() + .flat_map(|collection| { + let pk = map.get(&collection.id.to_string()).copied(); + collection + .links + .into_iter() + .filter(|link| !link.url.is_empty()) + .filter_map(move |link| pk.map(|pk| NftLinkRow::from_primitive(pk, link))) + }) + .collect(); + self.database.nft()?.add_nft_collections_links(links)?; + + Ok(map) + } + + pub async fn preload_assets(&self, asset_ids: Vec, collection_id_map: &HashMap) -> Result, Box> { + let identifiers: Vec = asset_ids.iter().map(|x| x.to_string()).collect(); + let existing = self.get_nft_asset_id_map(&identifiers)?; + + let mut new_assets: Vec = Vec::new(); + for id in asset_ids.into_iter().filter(|id| !existing.contains_key(&id.to_string())) { + if let Ok(asset) = self.provider_client.get_nft_asset(id).await { + new_assets.push(asset); + } + } + + let rows: Vec = new_assets + .into_iter() + .filter_map(|asset| collection_id_map.get(&asset.collection_id.to_string()).map(|&pk| NewNftAssetRow::from_primitive(asset, pk))) + .collect(); + + if rows.is_empty() { + return Ok(existing); + } + + self.database.nft()?.add_nft_assets(rows)?; + self.get_nft_asset_id_map(&identifiers) + } + + fn get_nft_collection_id_map(&self, identifiers: &[String]) -> Result, Box> { + Ok(self + .database + .nft()? + .get_nft_collections_by_filter(vec![NftCollectionFilter::Identifiers(identifiers.to_vec())])? + .into_iter() + .map(|c| (c.identifier.to_string(), c.id)) + .collect()) + } + + fn get_nft_asset_id_map(&self, identifiers: &[String]) -> Result, Box> { + Ok(self + .database + .nft()? + .get_nft_assets_by_filter(vec![NftAssetFilter::Identifiers(identifiers.to_vec())])? + .into_iter() + .map(|a| (a.identifier.to_string(), a.id)) + .collect()) + } + + fn load_nft_assets(&self, asset_identifiers: Vec) -> Result, Box> { + let assets = self.database.nft()?.get_nft_assets_by_filter(vec![NftAssetFilter::Identifiers(asset_identifiers)])?; + let collection_ids: Vec = assets.iter().map(|a| a.collection_id).collect::>().into_iter().collect(); + let collection_identifiers: HashMap = self + .database + .nft()? + .get_nft_collections_by_filter(vec![NftCollectionFilter::Ids(collection_ids)])? + .into_iter() + .map(|c| (c.id, c.identifier.0)) + .collect(); + Ok(assets + .into_iter() + .filter_map(|row| { + let identifier = collection_identifiers.get(&row.collection_id).cloned()?; + Some(row.as_primitive(identifier.into())) + }) + .collect()) + } + + async fn get_nfts(&self, assets: Vec) -> Result, Box> { + let mut by_collection: HashMap> = HashMap::new(); + for asset in assets { + by_collection.entry(asset.get_collection_id()).or_default().push(asset); + } + + by_collection + .into_iter() + .map(|(collection_id, asset_ids)| { + let collection = self.load_nft_collection(&collection_id.to_string())?; + let assets = self.load_nft_assets(asset_ids.into_iter().map(|x| x.to_string()).collect())?; + Ok(self.with_urls_data(NFTData { collection, assets })) + }) + .collect() + } + + pub fn load_nft_asset(&self, asset_id: &str) -> Result> { + self.load_nft_assets(vec![asset_id.to_string()])? + .into_iter() + .next() + .ok_or_else(|| DatabaseError::not_found("NftAsset", asset_id).into()) + } + + pub fn load_nft_collection(&self, collection_id: &str) -> Result> { + let row = self.database.nft()?.get_nft_collection(collection_id)?; + let links = self.database.nft()?.get_nft_collection_links(row.id)?.into_iter().map(|x| x.as_primitive()).collect(); + Ok(row.as_primitive(links)) + } + + pub async fn update_assets_for_addresses(&self, addresses: HashMap) -> Result, Box> { + let address_id_map: HashMap = self + .database + .wallets()? + .get_addresses(addresses.values().cloned().collect())? + .into_iter() + .map(|row| (row.address, row.id)) + .collect(); + + let mut all_asset_ids: HashSet = HashSet::new(); + let mut owned_by_address: HashMap> = HashMap::new(); + let mut chains_by_address: HashMap> = HashMap::new(); + for (chain, address) in addresses { + let Ok(ids) = self.get_provider_asset_ids(chain, &address).await else { continue }; + if let Some(&address_id) = address_id_map.get(&address) { + chains_by_address.entry(address_id).or_default().insert(chain); + owned_by_address.entry(address_id).or_default().extend(ids.iter().cloned()); + } + all_asset_ids.extend(ids); + } + + let asset_ids: Vec = all_asset_ids.into_iter().collect(); + let collection_ids: Vec = asset_ids.iter().map(|x| x.get_collection_id()).collect::>().into_iter().collect(); + let collection_id_map = self.preload_collections(collection_ids).await?; + let asset_id_map = self.preload_assets(asset_ids.clone(), &collection_id_map).await?; + + for (address_id, owned) in owned_by_address { + let current_asset_ids: Vec = owned.into_iter().filter_map(|id| asset_id_map.get(&id.to_string()).copied()).collect(); + let chains: Vec = chains_by_address.remove(&address_id).unwrap_or_default().into_iter().collect(); + self.database.nft()?.set_nft_asset_associations(address_id, chains, current_asset_ids)?; + } + + self.get_nfts(asset_ids).await + } + + pub fn report_nft(&self, device_id: &str, collection_id: String, asset_id: Option, reason: Option) -> Result> { + let mut client = self.database.client()?; + let device = DevicesStore::get_device(&mut client, device_id)?; + let collection_pk = client.nft().get_nft_collection(&collection_id)?.id; + let asset_pk = asset_id.and_then(|id| client.nft().get_nft_asset(&id.to_string()).ok().map(|row| row.id)); + + client.nft().add_nft_report(storage::models::NewNftReportRow { + device_id: device.id, + collection_id: collection_pk, + asset_id: asset_pk, + reason, + })?; + Ok(true) + } +} diff --git a/core/crates/nft/src/config.rs b/core/crates/nft/src/config.rs new file mode 100644 index 0000000000..89aa55a1ae --- /dev/null +++ b/core/crates/nft/src/config.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone)] +pub struct NFTProviderConfig { + pub opensea_key: String, + pub magiceden_key: String, + pub ton_url: String, +} + +impl NFTProviderConfig { + pub fn new(opensea_key: String, magiceden_key: String, ton_url: String) -> Self { + Self { + opensea_key, + magiceden_key, + ton_url, + } + } +} diff --git a/core/crates/nft/src/factory.rs b/core/crates/nft/src/factory.rs new file mode 100644 index 0000000000..04dda37802 --- /dev/null +++ b/core/crates/nft/src/factory.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use gem_client::ReqwestClient; +use gem_ton::rpc::client::TonClient; + +use crate::config::NFTProviderConfig; +use crate::provider::NFTProvider; +use crate::providers::magiceden; +use crate::providers::opensea; +use crate::providers::{MagicEdenEvmClient, MagicEdenSolanaClient, OpenSeaClient}; + +pub struct NFTProviderFactory; + +impl NFTProviderFactory { + pub fn new_providers(config: NFTProviderConfig) -> Vec> { + let opensea_client = opensea::create_client(&config.opensea_key); + let magiceden_client = magiceden::create_client(&config.magiceden_key); + let ton_client = ReqwestClient::new(config.ton_url, reqwest::Client::new()); + + vec![ + Arc::new(OpenSeaClient::new(opensea_client)), + Arc::new(MagicEdenSolanaClient::new(magiceden_client.clone())), + Arc::new(MagicEdenEvmClient::new(magiceden_client)), + Arc::new(TonClient::new(ton_client)), + ] + } +} diff --git a/core/crates/nft/src/lib.rs b/core/crates/nft/src/lib.rs new file mode 100644 index 0000000000..2012db8126 --- /dev/null +++ b/core/crates/nft/src/lib.rs @@ -0,0 +1,16 @@ +pub mod client; +pub mod config; +pub mod factory; +pub mod provider; +pub mod provider_client; +pub mod providers; + +#[cfg(any(test, feature = "nft_integration_tests"))] +pub mod testkit; + +pub use client::NFTClient; +pub use config::NFTProviderConfig; +pub use factory::NFTProviderFactory; +pub use provider::{NFTProvider, NFTProviders}; +pub use provider_client::NFTProviderClient; +pub use providers::{MagicEdenEvmClient, MagicEdenSolanaClient, OpenSeaClient}; diff --git a/core/crates/nft/src/provider.rs b/core/crates/nft/src/provider.rs new file mode 100644 index 0000000000..64136e4cf5 --- /dev/null +++ b/core/crates/nft/src/provider.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use std::error::Error; +use std::sync::Arc; + +use async_trait::async_trait; +use primitives::{Chain, NFTAsset, NFTAssetId, NFTChain, NFTCollection, NFTCollectionId, NFTData}; + +#[async_trait] +pub trait NFTProvider: Send + Sync { + fn name(&self) -> &'static str; + fn chains(&self) -> &'static [NFTChain]; + async fn get_assets(&self, chain: Chain, address: String) -> Result, Box>; + async fn get_collection(&self, collection: NFTCollectionId) -> Result>; + async fn get_asset(&self, asset_id: NFTAssetId) -> Result>; + async fn get_nft_assets(&self, chain: Chain, address: String) -> Result, Box> { + let ids = self.get_assets(chain, address).await?; + let mut assets = Vec::with_capacity(ids.len()); + for id in ids { + if let Ok(asset) = self.get_asset(id).await { + assets.push(asset); + } + } + Ok(assets) + } + async fn get_nft_data(&self, chain: Chain, address: String) -> Result, Box> { + let assets = self.get_nft_assets(chain, address).await?; + let mut by_collection: HashMap> = HashMap::new(); + for asset in assets { + by_collection.entry(asset.collection_id.clone()).or_default().push(asset); + } + let mut result = Vec::with_capacity(by_collection.len()); + for (collection_id, assets) in by_collection { + if let Ok(collection) = self.get_collection(collection_id).await { + result.push(NFTData { collection, assets }); + } + } + Ok(result) + } +} + +pub struct NFTProviders { + providers: Vec>, +} + +impl NFTProviders { + pub fn new(providers: Vec>) -> Self { + Self { providers } + } + + fn providers_for_chain(&self, chain: Chain) -> impl Iterator> { + self.providers + .iter() + .filter(move |provider| provider.chains().iter().any(|nft_chain| Chain::from(*nft_chain) == chain)) + } + + async fn fetch_assets(chain: Chain, address: String, providers: impl Iterator>) -> Vec { + for provider in providers { + if let Ok(ids) = provider.get_assets(chain, address.clone()).await { + return ids; + } + } + vec![] + } + + pub async fn get_assets(&self, addresses: HashMap) -> Vec { + let futures = addresses.into_iter().map(|(chain, address)| { + let providers = self.providers_for_chain(chain); + async move { Self::fetch_assets(chain, address, providers).await } + }); + + futures::future::join_all(futures).await.into_iter().flatten().collect() + } + + pub async fn get_collection(&self, collection_id: NFTCollectionId) -> Option { + for provider in self.providers_for_chain(collection_id.chain) { + if let Ok(collection) = provider.get_collection(collection_id.clone()).await { + return Some(collection); + } + } + None + } + + pub async fn get_asset(&self, asset_id: NFTAssetId) -> Option { + for provider in self.providers_for_chain(asset_id.chain) { + if let Ok(asset) = provider.get_asset(asset_id.clone()).await { + return Some(asset); + } + } + None + } + + pub async fn get_nft_data(&self, chain: Chain, address: &str) -> Result, Box> { + let provider = self + .providers_for_chain(chain) + .next() + .ok_or_else(|| format!("no NFT provider for chain {}", chain.as_ref()))?; + provider.get_nft_data(chain, address.to_string()).await + } +} diff --git a/core/crates/nft/src/provider_client.rs b/core/crates/nft/src/provider_client.rs new file mode 100644 index 0000000000..47f9ceaa10 --- /dev/null +++ b/core/crates/nft/src/provider_client.rs @@ -0,0 +1,37 @@ +use std::collections::HashMap; +use std::error::Error; + +use primitives::{Chain, NFTAsset, NFTAssetId, NFTCollection, NFTCollectionId, NFTData}; + +use crate::NFTProviderConfig; +use crate::factory::NFTProviderFactory; +use crate::provider::NFTProviders; + +pub struct NFTProviderClient { + providers: NFTProviders, +} + +impl NFTProviderClient { + pub fn new(config: NFTProviderConfig) -> Self { + let providers = NFTProviderFactory::new_providers(config); + Self { + providers: NFTProviders::new(providers), + } + } + + pub async fn get_nft_asset(&self, asset_id: NFTAssetId) -> Result> { + self.providers.get_asset(asset_id).await.ok_or_else(|| "Asset not found".into()) + } + + pub async fn get_nft_collection(&self, collection_id: NFTCollectionId) -> Result> { + self.providers.get_collection(collection_id).await.ok_or_else(|| "Collection not found".into()) + } + + pub async fn get_nft_data(&self, chain: Chain, address: &str) -> Result, Box> { + self.providers.get_nft_data(chain, address).await + } + + pub async fn get_asset_ids_for_addresses(&self, addresses: HashMap) -> Vec { + self.providers.get_assets(addresses).await + } +} diff --git a/core/crates/nft/src/providers/attribute.rs b/core/crates/nft/src/providers/attribute.rs new file mode 100644 index 0000000000..54943a0eac --- /dev/null +++ b/core/crates/nft/src/providers/attribute.rs @@ -0,0 +1,10 @@ +use serde_json::Value; + +pub fn json_attribute_value(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + Value::Bool(value) => Some(value.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) => None, + } +} diff --git a/core/crates/nft/src/providers/magiceden/evm/client.rs b/core/crates/nft/src/providers/magiceden/evm/client.rs new file mode 100644 index 0000000000..a39896bf32 --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/evm/client.rs @@ -0,0 +1,61 @@ +use super::model::{CollectionDetail, CollectionsResponse, TokenDetailResponse, TokensResponse}; +use primitives::Chain; +use std::error::Error; + +pub struct MagicEdenEvmClient { + client: reqwest::Client, +} + +impl MagicEdenEvmClient { + pub fn new(client: reqwest::Client) -> Self { + Self { client } + } + + fn chain_id(chain: Chain) -> Result<&'static str, Box> { + match chain { + Chain::SmartChain => Ok("bsc"), + _ => Err(format!("Unsupported EVM chain for MagicEden: {:?}", chain).into()), + } + } + + pub async fn get_nfts_by_wallet(&self, chain: Chain, wallet_address: &str) -> Result> { + let chain_id = Self::chain_id(chain)?; + let url = format!("{}/v4/evm-public/assets/user-assets", super::super::BASE_URL); + let response: TokensResponse = self + .client + .get(&url) + .query(&[("chain", chain_id), ("walletAddresses[]", wallet_address)]) + .send() + .await? + .json() + .await?; + + Ok(response) + } + + pub async fn fetch_collection_detail(&self, chain: Chain, collection_id: &str) -> Result> { + let chain_id = Self::chain_id(chain)?; + let url = format!("{}/v4/evm-public/collections", super::super::BASE_URL); + let body = serde_json::json!({"chain": chain_id, "collectionIds": [collection_id.to_lowercase()]}); + let response: CollectionsResponse = self.client.post(&url).json(&body).send().await?.json().await?; + response.collections.into_iter().next().ok_or_else(|| "Collection not found".into()) + } + + pub async fn get_token(&self, chain: Chain, collection_id: &str, token_id: &str) -> Result> { + let chain_id = Self::chain_id(chain)?; + let collection_id_lower = collection_id.to_lowercase(); + let asset_id = format!("{}:{}", collection_id_lower, token_id); + let url = format!("{}/v4/evm-public/assets/collection-assets", super::super::BASE_URL); + let response: TokensResponse = self + .client + .get(&url) + .query(&[("chain", chain_id), ("collectionId", &collection_id_lower), ("assetIds[]", &asset_id)]) + .send() + .await? + .json() + .await?; + + let token = response.assets.into_iter().next().ok_or("Token not found")?.asset; + Ok(TokenDetailResponse { token }) + } +} diff --git a/core/crates/nft/src/providers/magiceden/evm/mapper.rs b/core/crates/nft/src/providers/magiceden/evm/mapper.rs new file mode 100644 index 0000000000..077de0da01 --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/evm/mapper.rs @@ -0,0 +1,144 @@ +use gem_evm::ethereum_address_checksum; +use primitives::{Chain, LinkType, NFTAsset, NFTAssetId, NFTAttribute, NFTAttributeType, NFTCollectionId, NFTImages, NFTResource, NFTType, VerificationStatus}; + +use crate::providers::attribute::json_attribute_value; + +use super::model::{Attribute, CollectionDetail, TokenAsset, TokenDetail}; + +pub fn map_assets(assets: Vec, chain: Chain) -> Vec { + assets.into_iter().flat_map(|token_asset| token_asset.asset.as_asset_id(chain)).collect() +} + +pub fn map_collection(collection: CollectionDetail, collection_id: NFTCollectionId) -> primitives::NFTCollection { + collection.as_primitive(collection_id) +} + +pub fn map_asset(token: TokenDetail, asset_id: NFTAssetId) -> Option { + token.as_primitive(asset_id) +} + +impl TokenDetail { + pub fn as_asset_id(&self, chain: Chain) -> Option { + let contract_address = ethereum_address_checksum(&self.collection_id).ok()?; + Some(NFTAssetId::new(chain, &contract_address, &self.token_id)) + } + + pub fn as_primitive(&self, asset: NFTAssetId) -> Option { + let image_url = self.media_v2.as_ref().and_then(|m| m.main.as_ref()).and_then(|main| main.uri.clone()).unwrap_or_default(); + + let token_type = match self.standard.as_deref() { + Some("ERC721") => NFTType::ERC721, + Some("ERC1155") => NFTType::ERC1155, + _ => return None, + }; + + let collection_id = asset.get_collection_id(); + Some(NFTAsset { + chain: asset.chain, + contract_address: Some(asset.contract_address.clone()), + token_id: asset.token_id.clone(), + id: asset, + collection_id, + token_type, + name: self.name.clone().unwrap_or_default(), + description: self.description.clone(), + resource: NFTResource::from_url(&image_url), + images: NFTImages { + preview: NFTResource::from_url(&image_url), + }, + attributes: self.attributes.clone().unwrap_or_default().iter().flat_map(|x| x.as_attribute()).collect(), + }) + } +} + +impl Attribute { + pub fn as_attribute(&self) -> Option { + let value = json_attribute_value(&self.value)?; + Some(NFTAttribute::new(self.trait_type.clone(), value, NFTAttributeType::String)) + } +} + +impl CollectionDetail { + pub fn as_primitive(&self, collection: NFTCollectionId) -> primitives::NFTCollection { + let image_url = self.media.as_ref().and_then(|m| m.url.clone()).unwrap_or_default(); + let contract_address = ethereum_address_checksum(&self.id).unwrap_or_else(|_| self.id.clone()); + + primitives::NFTCollection { + chain: collection.chain, + id: collection, + contract_address, + name: self.name.clone(), + symbol: self.symbol.clone(), + description: self.description.clone(), + images: NFTImages { + preview: NFTResource::from_url(&image_url), + }, + status: VerificationStatus::Verified, + links: self.as_links(), + is_verified: true, + } + } + + pub fn as_links(&self) -> Vec { + let mut links = vec![]; + if let Some(social) = &self.social { + if let Some(twitter) = &social.twitter_url { + links.push(primitives::AssetLink::new(twitter, LinkType::X)); + } + if let Some(website) = &social.website_url { + links.push(primitives::AssetLink::new(website, LinkType::Website)); + } + if let Some(discord) = &social.discord_url { + links.push(primitives::AssetLink::new(discord, LinkType::Discord)); + } + } + links + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::magiceden::evm::model; + use primitives::{Chain, NFTCollectionId}; + + #[test] + fn test_map_assets() { + let response: model::TokensResponse = serde_json::from_str(include_str!("../../../testdata/magiceden/evm_assets.json")).unwrap(); + let asset_ids = map_assets(response.assets, Chain::SmartChain); + + assert!(!asset_ids.is_empty()); + if let Some(first_asset) = asset_ids.first() { + assert_eq!(first_asset.chain, Chain::SmartChain); + assert!(!first_asset.contract_address.is_empty()); + assert!(!first_asset.token_id.is_empty()); + } + } + + #[test] + fn test_map_collection() { + let response: model::CollectionsResponse = serde_json::from_str(include_str!("../../../testdata/magiceden/evm_collection.json")).unwrap(); + let collection = response.collections.into_iter().next().unwrap(); + let collection_id = NFTCollectionId::new(Chain::SmartChain, &collection.id); + let nft_collection = map_collection(collection, collection_id); + + assert_eq!(nft_collection.chain, Chain::SmartChain); + assert_eq!(nft_collection.name, "Reefers by CoralApp"); + assert!(nft_collection.description.is_some()); + assert!(!nft_collection.links.is_empty()); + } + + #[test] + fn test_map_asset() { + let asset_response: model::TokenDetailResponse = serde_json::from_str(include_str!("../../../testdata/magiceden/evm_asset.json")).unwrap(); + let token = asset_response.token; + let asset_id = NFTAssetId::new(Chain::SmartChain, &token.collection_id, &token.token_id); + let nft_asset = map_asset(token, asset_id).expect("Failed to map asset"); + + assert_eq!(nft_asset.chain, Chain::SmartChain); + assert_eq!(nft_asset.token_id, "410"); + assert!(nft_asset.name.contains("Reefers")); + assert!(nft_asset.description.is_some()); + assert!(!nft_asset.attributes.is_empty()); + } +} diff --git a/core/crates/nft/src/providers/magiceden/evm/mod.rs b/core/crates/nft/src/providers/magiceden/evm/mod.rs new file mode 100644 index 0000000000..7d185d6911 --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/evm/mod.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod mapper; +pub mod model; +pub mod provider; + +pub use client::MagicEdenEvmClient; diff --git a/core/crates/nft/src/providers/magiceden/evm/model.rs b/core/crates/nft/src/providers/magiceden/evm/model.rs new file mode 100644 index 0000000000..cc8343dbf5 --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/evm/model.rs @@ -0,0 +1,86 @@ +use serde::Deserialize; + +#[derive(Deserialize, Clone, Debug)] +pub struct TokensResponse { + pub assets: Vec, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct TokenAsset { + pub asset: TokenDetail, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Token { + pub token_id: String, + pub collection_id: String, + pub contract_address: String, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Attribute { + pub trait_type: String, + pub value: serde_json::Value, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct CollectionsResponse { + pub collections: Vec, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CollectionDetail { + pub id: String, + pub name: String, + pub symbol: Option, + pub description: Option, + pub media: Option, + pub social: Option, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CollectionMedia { + pub url: Option, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CollectionSocial { + pub discord_url: Option, + pub website_url: Option, + pub twitter_url: Option, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TokenDetailResponse { + pub token: TokenDetail, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TokenDetail { + pub token_id: String, + pub name: Option, + #[serde(rename = "mediaV2")] + pub media_v2: Option, + pub collection_id: String, + pub owner: Option, + pub attributes: Option>, + pub description: Option, + pub standard: Option, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct Media { + pub main: Option, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct MediaMain { + pub uri: Option, +} diff --git a/core/crates/nft/src/providers/magiceden/evm/provider.rs b/core/crates/nft/src/providers/magiceden/evm/provider.rs new file mode 100644 index 0000000000..8418f34d06 --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/evm/provider.rs @@ -0,0 +1,91 @@ +use std::error::Error; + +use primitives::{Chain, NFTAsset, NFTAssetId, NFTChain, NFTCollection, NFTCollectionId}; + +use super::client::MagicEdenEvmClient; +use super::mapper::{map_asset, map_assets, map_collection}; +use crate::provider::NFTProvider; + +#[async_trait::async_trait] +impl NFTProvider for MagicEdenEvmClient { + fn name(&self) -> &'static str { + "MagicEdenEVM" + } + + fn chains(&self) -> &'static [NFTChain] { + &[NFTChain::SmartChain] + } + + async fn get_assets(&self, chain: Chain, address: String) -> Result, Box> { + let response = self.get_nfts_by_wallet(chain, &address).await?; + Ok(map_assets(response.assets, chain)) + } + + async fn get_collection(&self, collection_id: NFTCollectionId) -> Result> { + let collection = self.fetch_collection_detail(collection_id.chain, &collection_id.contract_address).await?; + Ok(map_collection(collection, collection_id)) + } + + async fn get_asset(&self, asset_id: NFTAssetId) -> Result> { + let response = self.get_token(asset_id.chain, &asset_id.contract_address, &asset_id.token_id).await?; + Ok(map_asset(response.token, asset_id).ok_or("Asset not found")?) + } +} + +#[cfg(all(test, feature = "nft_integration_tests"))] +mod nft_integration_tests { + use crate::NFTProvider; + use crate::testkit::*; + use primitives::{Chain, NFTAssetId, NFTCollectionId}; + + #[tokio::test] + async fn test_magiceden_evm_get_assets() -> Result<(), Box> { + let client = create_magiceden_evm_test_client(); + + let assets = client.get_assets(Chain::SmartChain, TEST_BSC_ADDRESS.to_string()).await?; + + println!("Found {} MagicEden EVM assets", assets.len()); + assert!(!assets.is_empty()); + + if let Some(asset_id) = assets.first() { + assert_eq!(asset_id.chain, Chain::SmartChain); + assert!(!asset_id.token_id.is_empty()); + assert!(!asset_id.contract_address.is_empty()); + println!("Sample MagicEden EVM asset: {:?}", asset_id); + } + + Ok(()) + } + + #[tokio::test] + async fn test_magiceden_evm_get_collection() -> Result<(), Box> { + let client = create_magiceden_evm_test_client(); + + let collection_id = NFTCollectionId::new(Chain::SmartChain, TEST_BSC_COLLECTION); + let collection = client.get_collection(collection_id).await?; + + println!("MagicEden EVM collection: {:?}", collection); + assert_eq!(collection.chain, Chain::SmartChain); + assert!(!collection.name.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_magiceden_evm_get_asset() -> Result<(), Box> { + let client = create_magiceden_evm_test_client(); + + let asset_id = NFTAssetId::new(Chain::SmartChain, TEST_BSC_COLLECTION, "410"); + + let asset = client.get_asset(asset_id).await?; + println!("MagicEden EVM asset: {:?}", asset); + + assert_eq!(asset.id, NFTAssetId::new(Chain::SmartChain, TEST_BSC_COLLECTION, "410")); + assert_eq!(asset.chain, Chain::SmartChain); + assert!(!asset.name.is_empty()); + assert!(!asset.attributes.is_empty()); + assert_eq!(asset.token_id, "410"); + + Ok(()) + } +} diff --git a/core/crates/nft/src/providers/magiceden/mod.rs b/core/crates/nft/src/providers/magiceden/mod.rs new file mode 100644 index 0000000000..8da2b0e0ee --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/mod.rs @@ -0,0 +1,16 @@ +pub mod evm; +pub mod solana; + +pub use evm::client::MagicEdenEvmClient; +pub use solana::client::MagicEdenSolanaClient; + +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; + +pub const BASE_URL: &str = "https://api-mainnet.magiceden.dev"; + +pub fn create_client(api_key: &str) -> reqwest::Client { + let mut headers = HeaderMap::new(); + let auth_value = format!("Bearer {}", api_key); + headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value).unwrap()); + reqwest::Client::builder().default_headers(headers).build().unwrap() +} diff --git a/core/crates/nft/src/providers/magiceden/solana/client.rs b/core/crates/nft/src/providers/magiceden/solana/client.rs new file mode 100644 index 0000000000..918c417629 --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/solana/client.rs @@ -0,0 +1,42 @@ +use super::model::{Collection, Nft}; +use std::error::Error; + +pub struct MagicEdenSolanaClient { + client: reqwest::Client, +} + +impl MagicEdenSolanaClient { + pub fn new(client: reqwest::Client) -> Self { + Self { client } + } + + pub async fn get_nfts_by_account(&self, address: &str) -> Result, Box> { + Ok(self + .client + .get(format!("{}/v2/wallets/{address}/tokens", super::super::BASE_URL)) + .send() + .await? + .json::>() + .await?) + } + + pub async fn get_collection_id(&self, collection_id: &str) -> Result> { + Ok(self + .client + .get(format!("{}/collections/{collection_id}", super::super::BASE_URL)) + .send() + .await? + .json::() + .await?) + } + + pub async fn get_asset_id(&self, token_mint: &str) -> Result> { + Ok(self + .client + .get(format!("{}/v2/tokens/{token_mint}", super::super::BASE_URL)) + .send() + .await? + .json::() + .await?) + } +} diff --git a/core/crates/nft/src/providers/magiceden/solana/mapper.rs b/core/crates/nft/src/providers/magiceden/solana/mapper.rs new file mode 100644 index 0000000000..acf6ca15d2 --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/solana/mapper.rs @@ -0,0 +1,186 @@ +use primitives::{Chain, LinkType, NFTAsset, NFTAssetId, NFTAttribute, NFTAttributeType, NFTCollectionId, NFTImages, NFTResource, NFTType, VerificationStatus}; + +use crate::providers::attribute::json_attribute_value; + +use super::model::{Collection, Nft, Trait}; + +pub fn map_assets(response: Vec, chain: Chain) -> Vec { + response.into_iter().flat_map(|nft| nft.as_asset_id(chain)).collect() +} + +pub fn map_collection(collection: Collection, collection_id: NFTCollectionId) -> primitives::NFTCollection { + collection.as_primitive(collection_id) +} + +pub fn map_asset(nft: Nft, asset_id: NFTAssetId, owner: String) -> Option { + nft.as_primitive(asset_id, owner) +} + +impl Nft { + pub fn as_asset_id(&self, chain: Chain) -> Option { + Some(NFTAssetId::new(chain, &self.collection, &self.mint_address)) + } + + pub fn as_primitive(&self, asset: NFTAssetId, owner: String) -> Option { + let traits = self.attributes.clone(); + let collection_id = asset.get_collection_id(); + Some(NFTAsset { + chain: asset.chain, + contract_address: Some(owner), + token_id: asset.token_id.clone(), + id: asset, + collection_id, + token_type: NFTType::SPL, + name: self.name.clone(), + description: None, + resource: NFTResource::from_url(&self.image), + images: NFTImages { + preview: NFTResource::from_url(&self.image), + }, + attributes: traits.iter().flat_map(|x| x.as_attribute()).collect(), + }) + } +} + +impl Trait { + pub fn as_attribute(&self) -> Option { + let value = json_attribute_value(&self.value)?; + Some(NFTAttribute::new(self.trait_type.clone(), value, NFTAttributeType::String)) + } +} + +impl Collection { + pub fn as_primitive(&self, collection: NFTCollectionId) -> primitives::NFTCollection { + primitives::NFTCollection { + chain: collection.chain, + contract_address: self.on_chain_collection_address.clone(), + id: collection, + name: self.name.clone(), + symbol: self.symbol.clone(), + description: Some(self.description.clone()), + images: NFTImages { + preview: NFTResource::from_url(&self.image), + }, + status: VerificationStatus::Verified, + links: self.as_links(), + is_verified: true, + } + } + + pub fn as_links(&self) -> Vec { + let mut links = vec![]; + if let Some(x) = self.twitter.clone() { + links.push(primitives::AssetLink::new(x.as_str(), LinkType::X)); + } + if let Some(url) = self.website.clone() { + links.push(primitives::AssetLink::new(url.as_str(), LinkType::Website)); + } + if let Some(discord) = self.discord.clone() { + links.push(primitives::AssetLink::new(discord.as_str(), LinkType::Discord)); + } + + links + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::magiceden::solana::model; + use crate::testkit::{TEST_SOLANA_ADDRESS, TEST_SOLANA_COLLECTION, TEST_SOLANA_COLLECTION_POOKS, TEST_SOLANA_TOKEN_ID}; + use primitives::{Chain, NFTCollectionId}; + + #[test] + fn test_map_assets() { + let response: Vec = serde_json::from_str(include_str!("../../../../testdata/magiceden/assets.json")).unwrap(); + let asset_ids = map_assets(response, Chain::Solana); + + assert!(!asset_ids.is_empty()); + if let Some(first_asset) = asset_ids.first() { + assert_eq!(first_asset.chain, Chain::Solana); + assert!(!first_asset.contract_address.is_empty()); + assert!(!first_asset.token_id.is_empty()); + } + } + + #[test] + fn test_map_collection() { + let collection: model::Collection = serde_json::from_str(include_str!("../../../../testdata/magiceden/collection.json")).unwrap(); + let collection_id = NFTCollectionId::new(Chain::Solana, TEST_SOLANA_COLLECTION); + let nft_collection = map_collection(collection, collection_id); + + assert_eq!(nft_collection.chain, Chain::Solana); + assert_eq!(nft_collection.name, "Okay Bears"); + assert!(nft_collection.description.is_some()); + assert!(nft_collection.description.as_ref().unwrap().contains("10,000 diverse bears")); + assert!(!nft_collection.links.is_empty()); + assert!(nft_collection.links.iter().any(|link| link.url.contains("okaybears.com"))); + } + + #[test] + fn test_map_asset() { + let nft: model::Nft = serde_json::from_str(include_str!("../../../../testdata/magiceden/asset.json")).unwrap(); + let asset_id = NFTAssetId::new(Chain::Solana, TEST_SOLANA_COLLECTION_POOKS, TEST_SOLANA_TOKEN_ID); + let owner = TEST_SOLANA_ADDRESS.to_string(); + let nft_asset = map_asset(nft, asset_id, owner.clone()).expect("Failed to map asset"); + + assert_eq!(nft_asset.chain, Chain::Solana); + assert_eq!(nft_asset.token_id, TEST_SOLANA_TOKEN_ID); + assert_eq!(nft_asset.name, "pooks #3726"); + assert_eq!(nft_asset.contract_address, Some(owner)); + assert!(!nft_asset.attributes.is_empty()); + + let background_trait = nft_asset.attributes.iter().find(|attr| attr.name == "Background"); + assert!(background_trait.is_some()); + assert_eq!(background_trait.unwrap().value, "Dewdrop Delight"); + } + + #[test] + fn test_asset_id_mapping() { + let response: Vec = serde_json::from_str(include_str!("../../../../testdata/magiceden/assets.json")).unwrap(); + let asset_ids: Vec = response.into_iter().flat_map(|nft| nft.as_asset_id(Chain::Solana)).collect(); + + assert!(!asset_ids.is_empty()); + + if let Some(first_asset) = asset_ids.first() { + assert_eq!(first_asset.chain, Chain::Solana); + assert!(!first_asset.contract_address.is_empty()); + assert!(!first_asset.token_id.is_empty()); + } + } + + #[test] + fn test_asset_primitive_mapping() { + let nft: model::Nft = serde_json::from_str(include_str!("../../../../testdata/magiceden/asset.json")).unwrap(); + let asset_id = NFTAssetId::new(Chain::Solana, TEST_SOLANA_COLLECTION_POOKS, TEST_SOLANA_TOKEN_ID); + let owner = TEST_SOLANA_ADDRESS.to_string(); + + let nft_asset = nft.as_primitive(asset_id, owner.clone()).expect("Failed to map asset"); + + assert_eq!(nft_asset.chain, Chain::Solana); + assert_eq!(nft_asset.token_id, TEST_SOLANA_TOKEN_ID); + assert_eq!(nft_asset.name, "pooks #3726"); + assert_eq!(nft_asset.contract_address, Some(owner)); + assert!(!nft_asset.attributes.is_empty()); + + let background_trait = nft_asset.attributes.iter().find(|attr| attr.name == "Background"); + assert!(background_trait.is_some()); + assert_eq!(background_trait.unwrap().value, "Dewdrop Delight"); + } + + #[test] + fn test_collection_primitive_mapping() { + let collection: model::Collection = serde_json::from_str(include_str!("../../../../testdata/magiceden/collection.json")).unwrap(); + let collection_id = NFTCollectionId::new(Chain::Solana, TEST_SOLANA_COLLECTION); + let nft_collection = collection.as_primitive(collection_id); + + assert_eq!(nft_collection.chain, Chain::Solana); + assert_eq!(nft_collection.name, "Okay Bears"); + assert!(nft_collection.description.is_some()); + assert!(nft_collection.description.as_ref().unwrap().contains("10,000 diverse bears")); + + assert!(!nft_collection.links.is_empty()); + assert!(nft_collection.links.iter().any(|link| link.url.contains("okaybears.com"))); + assert!(nft_collection.links.iter().any(|link| link.url.contains("discord.com"))); + } +} diff --git a/core/crates/nft/src/providers/magiceden/solana/mod.rs b/core/crates/nft/src/providers/magiceden/solana/mod.rs new file mode 100644 index 0000000000..0c5082ad9a --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/solana/mod.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod mapper; +pub mod model; +pub mod provider; + +pub use client::MagicEdenSolanaClient; diff --git a/core/crates/nft/src/providers/magiceden/solana/model.rs b/core/crates/nft/src/providers/magiceden/solana/model.rs new file mode 100644 index 0000000000..575086406a --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/solana/model.rs @@ -0,0 +1,31 @@ +use serde::Deserialize; + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Nft { + pub mint_address: String, + pub owner: String, + pub name: String, + pub image: String, + pub collection: String, + pub attributes: Vec, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct Trait { + pub trait_type: String, + pub value: serde_json::Value, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Collection { + pub symbol: Option, + pub name: String, + pub description: String, + pub image: String, + pub on_chain_collection_address: String, + pub twitter: Option, + pub discord: Option, + pub website: Option, +} diff --git a/core/crates/nft/src/providers/magiceden/solana/provider.rs b/core/crates/nft/src/providers/magiceden/solana/provider.rs new file mode 100644 index 0000000000..57e3ef2c49 --- /dev/null +++ b/core/crates/nft/src/providers/magiceden/solana/provider.rs @@ -0,0 +1,90 @@ +use std::error::Error; + +use primitives::{Chain, NFTAsset, NFTAssetId, NFTChain, NFTCollection, NFTCollectionId}; + +use super::client::MagicEdenSolanaClient; +use super::mapper::{map_asset, map_assets, map_collection}; +use crate::provider::NFTProvider; + +#[async_trait::async_trait] +impl NFTProvider for MagicEdenSolanaClient { + fn name(&self) -> &'static str { + "MagicEdenSolana" + } + + fn chains(&self) -> &'static [NFTChain] { + &[NFTChain::Solana] + } + + async fn get_assets(&self, chain: Chain, address: String) -> Result, Box> { + let response = self.get_nfts_by_account(&address).await?; + Ok(map_assets(response, chain)) + } + + async fn get_collection(&self, collection_id: NFTCollectionId) -> Result> { + let collection = self.get_collection_id(&collection_id.contract_address).await?; + Ok(map_collection(collection, collection_id)) + } + + async fn get_asset(&self, asset_id: NFTAssetId) -> Result> { + let nft = self.get_asset_id(&asset_id.token_id).await?; + Ok(map_asset(nft.clone(), asset_id, nft.owner.clone()).ok_or("Asset not found")?) + } +} + +#[cfg(all(test, feature = "nft_integration_tests"))] +mod nft_integration_tests { + use crate::NFTProvider; + use crate::testkit::*; + use primitives::{Chain, NFTAssetId, NFTCollectionId}; + + #[tokio::test] + async fn test_magiceden_get_assets() -> Result<(), Box> { + let client = create_magiceden_solana_test_client(); + + let assets = client.get_assets(Chain::Solana, TEST_SOLANA_ADDRESS.to_string()).await?; + + println!("Found {} MagicEden assets", assets.len()); + assert!(!assets.is_empty()); + + if let Some(asset_id) = assets.first() { + assert_eq!(asset_id.chain, Chain::Solana); + assert!(!asset_id.token_id.is_empty()); + println!("Sample MagicEden asset: {:?}", asset_id); + } + + Ok(()) + } + + #[tokio::test] + async fn test_magiceden_get_collection() -> Result<(), Box> { + let client = create_magiceden_solana_test_client(); + + let collection_id = NFTCollectionId::new(Chain::Solana, TEST_SOLANA_COLLECTION); + let collection = client.get_collection(collection_id).await?; + + println!("MagicEden collection: {:?}", collection); + assert_eq!(collection.chain, Chain::Solana); + assert!(!collection.name.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_magiceden_get_asset() -> Result<(), Box> { + let client = create_magiceden_solana_test_client(); + + let asset_id = NFTAssetId::new(Chain::Solana, TEST_SOLANA_COLLECTION_POOKS, TEST_SOLANA_TOKEN_ID); + + let asset = client.get_asset(asset_id).await?; + println!("MagicEden asset: {:?}", asset); + + assert_eq!(asset.id, NFTAssetId::new(Chain::Solana, TEST_SOLANA_COLLECTION_POOKS, TEST_SOLANA_TOKEN_ID)); + assert_eq!(asset.chain, Chain::Solana); + assert!(!asset.name.is_empty()); + assert!(!asset.attributes.is_empty()); + assert_eq!(asset.token_id, TEST_SOLANA_TOKEN_ID); + + Ok(()) + } +} diff --git a/core/crates/nft/src/providers/mod.rs b/core/crates/nft/src/providers/mod.rs new file mode 100644 index 0000000000..03ec4ea1b5 --- /dev/null +++ b/core/crates/nft/src/providers/mod.rs @@ -0,0 +1,8 @@ +mod attribute; +pub mod magiceden; +pub mod opensea; +pub mod ton; + +pub use magiceden::MagicEdenEvmClient; +pub use magiceden::MagicEdenSolanaClient; +pub use opensea::client::OpenSeaClient; diff --git a/core/crates/nft/src/providers/opensea/client.rs b/core/crates/nft/src/providers/opensea/client.rs new file mode 100644 index 0000000000..2020059b2d --- /dev/null +++ b/core/crates/nft/src/providers/opensea/client.rs @@ -0,0 +1,49 @@ +use super::model::{Collection, Contract, NftResponse, NftsResponse}; +use primitives::Chain; +use std::error::Error; + +pub struct OpenSeaClient { + client: reqwest::Client, +} + +impl OpenSeaClient { + const BASE_URL: &'static str = "https://api.opensea.io"; + + pub fn new(client: reqwest::Client) -> Self { + Self { client } + } + + fn chain_id(chain: Chain) -> Result<&'static str, Box> { + match chain { + Chain::Ethereum => Ok("ethereum"), + Chain::Polygon => Ok("polygon"), + _ => Err(format!("Unsupported chain for OpenSea: {:?}", chain).into()), + } + } + + pub async fn get_nfts_by_account(&self, chain: Chain, account_address: &str) -> Result> { + let url = format!("{}/api/v2/chain/{}/account/{}/nfts", Self::BASE_URL, Self::chain_id(chain)?, account_address); + let query = [("limit", 100)]; + Ok(self.client.get(&url).query(&query).send().await?.json().await?) + } + + pub async fn get_collection_by_contract(&self, chain: Chain, contract_address: &str) -> Result> { + let contract = self.get_contract(chain, contract_address).await?; + self.get_collection_by_slug(&contract.collection).await + } + + pub async fn get_contract(&self, chain: Chain, contract_address: &str) -> Result> { + let url = format!("{}/api/v2/chain/{}/contract/{}", Self::BASE_URL, Self::chain_id(chain)?, contract_address); + Ok(self.client.get(&url).send().await?.json().await?) + } + + pub async fn get_asset_id(&self, chain: Chain, contract_address: &str, token_id: &str) -> Result> { + let url = format!("{}/api/v2/chain/{}/contract/{}/nfts/{}", Self::BASE_URL, Self::chain_id(chain)?, contract_address, token_id); + Ok(self.client.get(&url).send().await?.json().await?) + } + + pub async fn get_collection_by_slug(&self, collection_slug: &str) -> Result> { + let url = format!("{}/api/v2/collections/{}", Self::BASE_URL, collection_slug); + Ok(self.client.get(&url).send().await?.json().await?) + } +} diff --git a/core/crates/nft/src/providers/opensea/mapper.rs b/core/crates/nft/src/providers/opensea/mapper.rs new file mode 100644 index 0000000000..5d4e25ecde --- /dev/null +++ b/core/crates/nft/src/providers/opensea/mapper.rs @@ -0,0 +1,295 @@ +use gem_evm::ethereum_address_checksum; +use primitives::{AssetLink, Chain, LinkType, NFTAsset, NFTAssetId, NFTAttribute, NFTAttributeType, NFTCollectionId, NFTImages, NFTResource, NFTType, VerificationStatus}; + +use crate::providers::attribute::json_attribute_value; + +use super::model::{Collection, Nft, NftAsset, NftResponse, NftsResponse, Trait}; + +pub fn map_assets(response: NftsResponse, chain: Chain) -> Vec { + response.nfts.into_iter().flat_map(|x| x.as_asset_id(chain)).collect() +} + +pub fn map_collection(collection: Collection, collection_id: NFTCollectionId) -> primitives::NFTCollection { + collection.as_primitive(collection_id) +} + +pub fn map_asset(response: NftResponse, asset_id: NFTAssetId) -> Option { + response.nft.as_primitive(asset_id) +} + +impl Nft { + pub fn as_primitive(&self, asset: NFTAssetId) -> Option { + let traits = self.traits.clone().unwrap_or_default(); + let resource_url = self.resource_url(); + let preview_url = self.preview_url(); + let collection_id = asset.get_collection_id(); + let token_type = self.as_type()?; + + Some(NFTAsset { + chain: asset.chain, + contract_address: Some(asset.contract_address.clone()), + token_id: asset.token_id.clone(), + id: asset, + collection_id, + token_type, + name: self.name.clone(), + description: Some(self.description.clone()), + resource: NFTResource::from_url(resource_url), + images: NFTImages { + preview: NFTResource::from_url(preview_url), + }, + attributes: traits.iter().flat_map(|x| x.as_attribute()).collect(), + }) + } + + fn resource_url(&self) -> &str { + self.image_url + .as_deref() + .or(self.original_image_url.as_deref()) + .or(self.display_image_url.as_deref()) + .unwrap_or_default() + } + + fn preview_url(&self) -> &str { + self.display_image_url + .as_deref() + .or(self.image_url.as_deref()) + .or(self.original_image_url.as_deref()) + .unwrap_or_default() + } + + fn as_type(&self) -> Option { + match self.token_standard.as_str() { + "erc1155" => Some(NFTType::ERC1155), + "erc721" => Some(NFTType::ERC721), + _ => None, + } + } +} + +impl NftAsset { + pub fn as_asset_id(&self, chain: Chain) -> Option { + let contract_address = ethereum_address_checksum(&self.contract).ok()?; + Some(NFTAssetId::new(chain, &contract_address, &self.identifier)) + } +} + +impl Trait { + pub fn as_attribute(&self) -> Option { + let value = json_attribute_value(&self.value)?; + if value == "None" { + return None; + } + let value_type = self.attribute_type(&value); + Some(NFTAttribute::new(self.trait_type.clone(), value, value_type)) + } + + fn attribute_type(&self, value: &str) -> NFTAttributeType { + if self.display_type.as_deref().is_some_and(|display_type| display_type.eq_ignore_ascii_case("date")) || self.has_date_name() && is_unix_seconds(value) { + return NFTAttributeType::Timestamp; + } + NFTAttributeType::String + } + + fn has_date_name(&self) -> bool { + let name = self.trait_type.to_ascii_lowercase(); + ["date", "expiry", "expires", "expiration"].iter().any(|hint| name.contains(hint)) + } +} + +fn is_unix_seconds(value: &str) -> bool { + (9..=10).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +impl Collection { + pub fn as_primitive(&self, collection: NFTCollectionId) -> primitives::NFTCollection { + let is_verified = self.safelist_status.as_deref() == Some("verified"); + + primitives::NFTCollection { + chain: collection.chain, + contract_address: collection.contract_address.clone(), + id: collection, + name: self.name.clone(), + symbol: Some(self.collection.clone()), + description: self.description.clone(), + images: NFTImages { + preview: NFTResource::from_url(self.image_url.as_deref().unwrap_or("")), + }, + status: VerificationStatus::from_verified(is_verified), + links: self.as_links(), + is_verified, + } + } + + pub fn as_links(&self) -> Vec { + [ + self.opensea_url.as_deref().map(|u| AssetLink::new(u, LinkType::OpenSea)), + self.project_url.as_deref().map(|u| AssetLink::new(u, LinkType::Website)), + self.discord_url.as_deref().map(|u| AssetLink::new(u, LinkType::Discord)), + self.telegram_url.as_deref().map(|u| AssetLink::new(u, LinkType::Telegram)), + ] + .into_iter() + .flatten() + .filter(|link| !link.url.is_empty()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::opensea::model::{Collection, NftResponse, NftsResponse}; + use crate::testkit::TEST_ETHEREUM_CONTRACT_ADDRESS; + use primitives::{Chain, NFTCollectionId}; + + #[test] + fn test_map_assets() { + let response: NftsResponse = serde_json::from_str(include_str!("../../../testdata/opensea/assets.json")).unwrap(); + let asset_ids = map_assets(response, Chain::Ethereum); + + assert!(!asset_ids.is_empty()); + if let Some(first_asset) = asset_ids.first() { + assert_eq!(first_asset.chain, Chain::Ethereum); + assert!(!first_asset.contract_address.is_empty()); + assert!(!first_asset.token_id.is_empty()); + assert!(first_asset.contract_address.starts_with("0x")); + } + } + + #[test] + fn test_map_collection() { + let collection: Collection = serde_json::from_str(include_str!("../../../testdata/opensea/collection.json")).unwrap(); + let collection_id = NFTCollectionId::new(Chain::Ethereum, TEST_ETHEREUM_CONTRACT_ADDRESS); + let nft_collection = map_collection(collection, collection_id); + + assert_eq!(nft_collection.chain, Chain::Ethereum); + assert_eq!(nft_collection.name, "Bored Ape Yacht Club"); + assert!(nft_collection.description.is_some()); + assert!(nft_collection.description.as_ref().unwrap().contains("10,000 unique Bored Ape NFTs")); + assert!(!nft_collection.links.is_empty()); + assert!(nft_collection.links.iter().any(|link| link.url.contains("opensea.io"))); + } + + #[test] + fn test_map_asset() { + let response: NftResponse = serde_json::from_str(include_str!("../../../testdata/opensea/asset.json")).unwrap(); + let asset_id = NFTAssetId::new(Chain::Ethereum, TEST_ETHEREUM_CONTRACT_ADDRESS, "1"); + let nft_asset = map_asset(response, asset_id).expect("Failed to map asset"); + + assert_eq!(nft_asset.chain, Chain::Ethereum); + assert_eq!(nft_asset.token_id, "1"); + assert_eq!(nft_asset.name, "#1"); + assert!(nft_asset.contract_address.is_some()); + assert!(!nft_asset.attributes.is_empty()); + + let mouth_trait = nft_asset.attributes.iter().find(|attr| attr.name == "Mouth"); + assert!(mouth_trait.is_some()); + assert_eq!(mouth_trait.unwrap().value, "Grin"); + } + + #[test] + fn test_asset_id_mapping() { + let response: NftsResponse = serde_json::from_str(include_str!("../../../testdata/opensea/assets.json")).unwrap(); + + let chain = Chain::Ethereum; + let asset_ids: Vec = response.nfts.into_iter().flat_map(|nft_asset| nft_asset.as_asset_id(chain)).collect(); + + assert!(!asset_ids.is_empty()); + + if let Some(first_asset) = asset_ids.first() { + assert_eq!(first_asset.chain, Chain::Ethereum); + assert!(!first_asset.contract_address.is_empty()); + assert!(!first_asset.token_id.is_empty()); + assert!(first_asset.contract_address.starts_with("0x")); + } + } + + #[test] + fn test_asset_primitive_mapping() { + let response: NftResponse = serde_json::from_str(include_str!("../../../testdata/opensea/asset.json")).unwrap(); + + let asset_id = NFTAssetId::new(Chain::Ethereum, "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", "1"); + + let nft_asset = response.nft.as_primitive(asset_id).unwrap(); + + assert_eq!(nft_asset.chain, Chain::Ethereum); + assert_eq!(nft_asset.token_id, "1"); + assert_eq!(nft_asset.name, "#1"); + assert!(nft_asset.contract_address.is_some()); + assert!(!nft_asset.attributes.is_empty()); + + let mouth_trait = nft_asset.attributes.iter().find(|attr| attr.name == "Mouth"); + assert!(mouth_trait.is_some()); + assert_eq!(mouth_trait.unwrap().value, "Grin"); + } + + #[test] + fn test_collection_primitive_mapping() { + let collection: Collection = serde_json::from_str(include_str!("../../../testdata/opensea/collection.json")).unwrap(); + + let collection_id = NFTCollectionId::new(Chain::Ethereum, "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D"); + let nft_collection = collection.as_primitive(collection_id); + + assert_eq!(nft_collection.chain, Chain::Ethereum); + assert_eq!(nft_collection.name, "Bored Ape Yacht Club"); + assert!(nft_collection.description.is_some()); + assert!(nft_collection.description.as_ref().unwrap().contains("10,000 unique Bored Ape NFTs")); + + assert!(!nft_collection.links.is_empty()); + + assert!(nft_collection.links.iter().any(|link| link.url.contains("opensea.io"))); + assert!(nft_collection.links.iter().any(|link| link.url.contains("boredapeyachtclub.com"))); + assert!(nft_collection.links.iter().any(|link| link.url.contains("discord.gg"))); + } + + #[test] + fn test_map_asset_with_null_image_urls() { + let response: NftResponse = serde_json::from_str(include_str!("../../../testdata/opensea/asset_null_images.json")).unwrap(); + let asset_id = NFTAssetId::new( + Chain::Ethereum, + "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "66972740172774133895361774757009899712806299063970949277266423600598010529206", + ); + + let nft_asset = map_asset(response, asset_id).unwrap(); + + assert_eq!(nft_asset.chain, Chain::Ethereum); + assert_eq!(nft_asset.token_id, "66972740172774133895361774757009899712806299063970949277266423600598010529206"); + assert_eq!(nft_asset.name, "gemdev.eth"); + assert_eq!( + nft_asset.resource.url, + "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/0x94113a45c5bedf735911bf707d70a6c05d9d99e76ece7e904c0eeda6591785b6/image" + ); + assert_eq!(nft_asset.images.preview.url, nft_asset.resource.url); + + let length = nft_asset.attributes.iter().find(|attr| attr.name == "Length").unwrap(); + assert_eq!(length.value_type, Some(NFTAttributeType::String)); + + let created_date = nft_asset.attributes.iter().find(|attr| attr.name == "Created Date").unwrap(); + assert_eq!(created_date.value_type, Some(NFTAttributeType::Timestamp)); + } + + #[test] + fn test_map_ens_date_attributes() { + let response: NftResponse = serde_json::from_str(include_str!("../../../testdata/opensea/asset_ens_dates.json")).unwrap(); + let asset_id = NFTAssetId::new( + Chain::Ethereum, + "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "91780768891665961085574300632320337237649359513314798633242628975887494917390", + ); + + let nft_asset = map_asset(response, asset_id).unwrap(); + + let length = nft_asset.attributes.iter().find(|attr| attr.name == "Length").unwrap(); + assert_eq!(length.value, "9"); + assert_eq!(length.value_type, Some(NFTAttributeType::String)); + + let created_date = nft_asset.attributes.iter().find(|attr| attr.name == "Created Date").unwrap(); + assert_eq!(created_date.value, "1738102775"); + assert_eq!(created_date.value_type, Some(NFTAttributeType::Timestamp)); + + let expiry_date = nft_asset.attributes.iter().find(|attr| attr.name == "Namewrapper Expiry Date").unwrap(); + assert_eq!(expiry_date.value, "1872109175"); + assert_eq!(expiry_date.value_type, Some(NFTAttributeType::Timestamp)); + } +} diff --git a/core/crates/nft/src/providers/opensea/mod.rs b/core/crates/nft/src/providers/opensea/mod.rs new file mode 100644 index 0000000000..4afe97ba27 --- /dev/null +++ b/core/crates/nft/src/providers/opensea/mod.rs @@ -0,0 +1,14 @@ +pub mod client; +pub mod mapper; +pub mod model; +pub mod provider; + +pub use client::OpenSeaClient; + +use reqwest::header::{HeaderMap, HeaderValue}; + +pub fn create_client(api_key: &str) -> reqwest::Client { + let mut headers = HeaderMap::new(); + headers.insert("x-api-key", HeaderValue::from_str(api_key).unwrap()); + reqwest::Client::builder().default_headers(headers).build().unwrap() +} diff --git a/core/crates/nft/src/providers/opensea/model.rs b/core/crates/nft/src/providers/opensea/model.rs new file mode 100644 index 0000000000..bb0ad32009 --- /dev/null +++ b/core/crates/nft/src/providers/opensea/model.rs @@ -0,0 +1,79 @@ +use serde::Deserialize; + +pub type TokenStandard = String; + +#[derive(Deserialize, Debug)] +pub struct Contract { + pub address: String, + pub chain: String, + pub collection: String, + pub contract_standard: String, + pub name: String, + pub total_supply: Option, +} + +#[derive(Deserialize, Debug)] +pub struct NftsResponse { + pub nfts: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct NftResponse { + pub nft: Nft, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct NftAsset { + pub identifier: String, + pub contract: String, + pub token_standard: TokenStandard, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct Nft { + pub identifier: String, + pub collection: String, + pub contract: String, + pub token_standard: TokenStandard, + pub name: String, + pub description: String, + pub image_url: Option, + pub display_image_url: Option, + pub original_image_url: Option, + pub traits: Option>, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct Trait { + pub trait_type: String, + pub display_type: Option, + pub value: serde_json::Value, +} + +#[derive(Deserialize)] +pub struct Collection { + pub collection: String, + pub name: String, + pub description: Option, + pub image_url: Option, + // pub banner_image_url: String, + // pub owner: String, + pub safelist_status: Option, + // pub category: String, + // pub is_disabled: bool, + // pub is_nsfw: bool, + // pub trait_offers_enabled: bool, + // pub collection_offers_enabled: bool, + pub opensea_url: Option, + pub project_url: Option, + // pub wiki_url: String, + pub discord_url: Option, + pub telegram_url: Option, + pub twitter_username: Option, + pub instagram_username: Option, + // pub editors: Vec, + // pub fees: Vec, + // pub rarity: Rarity, + // pub total_supply: u32, + // pub created_date: String, +} diff --git a/core/crates/nft/src/providers/opensea/provider.rs b/core/crates/nft/src/providers/opensea/provider.rs new file mode 100644 index 0000000000..2e254aef24 --- /dev/null +++ b/core/crates/nft/src/providers/opensea/provider.rs @@ -0,0 +1,88 @@ +use std::error::Error; + +use primitives::{Chain, NFTAsset, NFTAssetId, NFTChain, NFTCollection, NFTCollectionId}; + +use super::mapper::{map_asset, map_assets, map_collection}; +use crate::provider::NFTProvider; +use crate::providers::opensea::client::OpenSeaClient; + +#[async_trait::async_trait] +impl NFTProvider for OpenSeaClient { + fn name(&self) -> &'static str { + "OpenSea" + } + + fn chains(&self) -> &'static [NFTChain] { + &[NFTChain::Ethereum, NFTChain::Polygon] + } + + async fn get_assets(&self, chain: Chain, address: String) -> Result, Box> { + Ok(map_assets(self.get_nfts_by_account(chain, &address).await?, chain)) + } + + async fn get_collection(&self, collection_id: NFTCollectionId) -> Result> { + let collection = self.get_collection_by_contract(collection_id.chain, &collection_id.contract_address).await?; + Ok(map_collection(collection, collection_id)) + } + + async fn get_asset(&self, asset_id: NFTAssetId) -> Result> { + map_asset(self.get_asset_id(asset_id.chain, &asset_id.contract_address, &asset_id.token_id).await?, asset_id).ok_or("Asset not found".into()) + } +} + +#[cfg(all(test, feature = "nft_integration_tests"))] +mod nft_integration_tests { + use crate::NFTProvider; + use crate::testkit::*; + use primitives::{Chain, NFTAssetId, NFTCollectionId}; + + #[tokio::test] + async fn test_opensea_get_assets() -> Result<(), Box> { + let client = create_opensea_test_client(); + + let assets = client.get_assets(Chain::Ethereum, TEST_ETHEREUM_ADDRESS.to_string()).await?; + + println!("Found {} OpenSea assets", assets.len()); + assert!(!assets.is_empty()); + + if let Some(asset_id) = assets.first() { + assert_eq!(asset_id.chain, Chain::Ethereum); + assert!(!asset_id.contract_address.is_empty()); + assert!(!asset_id.token_id.is_empty()); + println!("Sample OpenSea asset: {:?}", asset_id); + } + + Ok(()) + } + + #[tokio::test] + async fn test_opensea_get_collection() -> Result<(), Box> { + let client = create_opensea_test_client(); + + let collection_id = NFTCollectionId::new(Chain::Ethereum, TEST_ETHEREUM_CONTRACT_ADDRESS); + let collection = client.get_collection(collection_id).await?; + + println!("OpenSea collection: {:?}", collection); + assert_eq!(collection.chain, Chain::Ethereum); + assert!(!collection.name.is_empty()); + assert!(!collection.contract_address.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_opensea_get_asset() -> Result<(), Box> { + let client = create_opensea_test_client(); + + let asset_id = NFTAssetId::new(Chain::Ethereum, TEST_ETHEREUM_CONTRACT_ADDRESS, "1"); + let asset = client.get_asset(asset_id).await?; + + println!("OpenSea asset: {:?}", asset); + assert_eq!(asset.chain, Chain::Ethereum); + assert!(!asset.name.is_empty()); + assert!(!asset.contract_address.clone().unwrap().is_empty()); + assert_eq!(asset.token_id, "1"); + + Ok(()) + } +} diff --git a/core/crates/nft/src/providers/ton/mapper.rs b/core/crates/nft/src/providers/ton/mapper.rs new file mode 100644 index 0000000000..8eb37ec8b9 --- /dev/null +++ b/core/crates/nft/src/providers/ton/mapper.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; + +use gem_ton::address::Address; +use gem_ton::models::{NftCollectionsResponse, NftItem, NftItemsResponse, TokenInfo, TokenMetadata}; +use primitives::{Address as _, Chain, NFTAsset, NFTAssetId, NFTCollection, NFTCollectionId, NFTData, NFTImages, NFTResource, NFTType, VerificationStatus}; + +use super::verified::is_verified; + +pub fn map_asset_ids(response: &NftItemsResponse) -> Vec { + response.nft_items.iter().filter_map(|item| asset_id_from_item(item, &response.metadata)).collect() +} + +pub fn map_asset(response: NftItemsResponse, asset_id: NFTAssetId) -> Option { + Address::try_parse_base64(&asset_id.contract_address)?; + let item = response.nft_items.into_iter().next()?; + let info = valid_named_token_info(response.metadata.get(&item.address))?; + let collection_image = item + .collection_address + .as_deref() + .and_then(|hex| valid_named_token_info(response.metadata.get(hex))) + .and_then(|i| i.image.as_deref()); + Some(build_asset(asset_id, info, collection_image)) +} + +pub fn map_collection(response: NftCollectionsResponse, collection_id: NFTCollectionId) -> Option { + let address = Address::try_parse_base64(&collection_id.contract_address)?; + let collection = response.nft_collections.into_iter().next()?; + let info = valid_named_token_info(response.metadata.get(&collection.address))?; + Some(build_collection(&collection_id, &address, info)) +} + +pub fn map_nft_data(response: NftItemsResponse) -> Vec { + let NftItemsResponse { nft_items, metadata } = response; + + let collections: HashMap = nft_items + .iter() + .filter_map(|item| { + let hex = item.collection_address.as_deref()?; + let address = Address::try_parse_hex(hex)?; + let info = valid_named_token_info(metadata.get(hex))?; + let collection_id = NFTCollectionId::new(Chain::Ton, &address.encode()); + Some((collection_id.clone(), build_collection(&collection_id, &address, info))) + }) + .collect(); + + nft_items + .into_iter() + .filter_map(|item| { + let asset_id = asset_id_from_item(&item, &metadata)?; + let collection = collections.get(&asset_id.get_collection_id())?; + let info = valid_named_token_info(metadata.get(&item.address))?; + let key = asset_id.get_collection_id(); + let asset = build_asset(asset_id, info, Some(&collection.images.preview.url)); + Some((key, asset)) + }) + .fold(HashMap::>::new(), |mut acc, (key, asset)| { + acc.entry(key).or_default().push(asset); + acc + }) + .into_iter() + .filter_map(|(collection_id, assets)| { + let collection = collections.get(&collection_id)?.clone(); + Some(NFTData { collection, assets }) + }) + .collect() +} + +fn build_asset(asset_id: NFTAssetId, info: &TokenInfo, collection_image: Option<&str>) -> NFTAsset { + let image = info.image.as_deref().or(collection_image).unwrap_or_default(); + let collection_id = asset_id.get_collection_id(); + NFTAsset { + chain: asset_id.chain, + contract_address: Some(asset_id.token_id.clone()), + token_id: asset_id.token_id.clone(), + id: asset_id, + collection_id, + token_type: NFTType::JETTON, + name: token_info_name(info).unwrap_or_default().to_string(), + description: info.description.clone(), + resource: NFTResource::from_url(image), + images: NFTImages { + preview: NFTResource::from_url(image), + }, + attributes: vec![], + } +} + +fn build_collection(collection_id: &NFTCollectionId, address: &Address, info: &TokenInfo) -> NFTCollection { + let image = info.image.clone().unwrap_or_default(); + let is_verified = is_verified(address, info); + NFTCollection { + id: collection_id.clone(), + name: token_info_name(info).unwrap_or_default().to_string(), + symbol: None, + description: info.description.clone(), + chain: collection_id.chain, + contract_address: collection_id.contract_address.clone(), + images: NFTImages { + preview: NFTResource::from_url(&image), + }, + status: VerificationStatus::from_verified(is_verified), + links: vec![], + is_verified, + } +} + +fn valid_named_token_info(metadata: Option<&TokenMetadata>) -> Option<&TokenInfo> { + metadata?.token_info.iter().find(|info| info.valid && token_info_name(info).is_some()) +} + +fn token_info_name(info: &TokenInfo) -> Option<&str> { + info.name + .as_deref() + .or_else(|| info.extra.as_ref().and_then(|e| e.domain.as_deref())) + .filter(|s| !s.is_empty()) +} + +fn asset_id_from_item(item: &NftItem, metadata: &HashMap) -> Option { + let collection_hex = item.collection_address.as_deref()?; + let collection = Address::try_parse_hex(collection_hex)?; + valid_named_token_info(metadata.get(&item.address))?; + let token = Address::try_parse_hex(&item.address)?; + Some(NFTAssetId::new(Chain::Ton, &collection.encode(), &token.encode())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERIFIED_COLLECTION: &str = "EQCA14o1-VWhS2efqoh_9M1b_A9DtKTuoqfmkn83AbJzwnPi"; + const ITEM: &str = "EQCvxJy4eG8hyHBFsZ7eePxrRsUQSEUTP46abUQGAcGY6mOw"; + const NUMBERS_COLLECTION: &str = "EQAOQdwdw8kGftJCSFgOErM1mBjYPe4DBPq8-AhF6vr9si5N"; + const UNVERIFIED_COLLECTION: &str = "EQBBhhF6O-jfi1TEF1rs6pEaynEjhFrcjUCC2DfUwzJ4pRXR"; + const GETGEMS_COLLECTION: &str = "EQCwbUhN1REI4q-N8EV2ordMxXognDW2y0teRiBP0RjgCc6T"; + + #[test] + fn test_map_asset_ids() { + let response: NftItemsResponse = serde_json::from_str(include_str!("../../../testdata/ton/items.json")).unwrap(); + let asset_ids = map_asset_ids(&response); + + assert_eq!(asset_ids.len(), 1); + let first = &asset_ids[0]; + assert_eq!(first.chain, Chain::Ton); + assert_eq!(first.contract_address, VERIFIED_COLLECTION); + assert_eq!(first.token_id, ITEM); + assert_eq!(first.to_string(), format!("ton_{VERIFIED_COLLECTION}::{ITEM}")); + } + + #[test] + fn test_map_asset() { + let response: NftItemsResponse = serde_json::from_str(include_str!("../../../testdata/ton/items.json")).unwrap(); + let asset_id = NFTAssetId::new(Chain::Ton, VERIFIED_COLLECTION, ITEM); + let asset = map_asset(response, asset_id).expect("Failed to map asset"); + + assert_eq!(asset.id.to_string(), format!("ton_{VERIFIED_COLLECTION}::{ITEM}")); + assert_eq!(asset.collection_id.to_string(), format!("ton_{VERIFIED_COLLECTION}")); + assert_eq!(asset.chain, Chain::Ton); + assert_eq!(asset.token_id, ITEM); + assert_eq!(asset.contract_address.as_deref(), Some(ITEM)); + assert_eq!(asset.name, "Resolved Item Name"); + assert_eq!(asset.token_type, NFTType::JETTON); + assert_eq!(asset.images.preview.url, "https://example.com/resolved-item.png"); + } + + #[test] + fn test_map_asset_unverified_collection() { + let response: NftItemsResponse = serde_json::from_str(include_str!("../../../testdata/ton/items_unverified.json")).unwrap(); + let asset_id = NFTAssetId::new(Chain::Ton, UNVERIFIED_COLLECTION, ITEM); + let asset = map_asset(response, asset_id).unwrap(); + + assert_eq!(asset.id, NFTAssetId::new(Chain::Ton, UNVERIFIED_COLLECTION, ITEM)); + assert_eq!(asset.collection_id, NFTCollectionId::new(Chain::Ton, UNVERIFIED_COLLECTION)); + assert_eq!(asset.name, "Unverified Item"); + } + + #[test] + fn test_map_collection() { + let response: NftCollectionsResponse = serde_json::from_str(include_str!("../../../testdata/ton/collections.json")).unwrap(); + let collection_id = NFTCollectionId::new(Chain::Ton, NUMBERS_COLLECTION); + let collection = map_collection(response, collection_id).expect("Failed to map collection"); + + assert_eq!(collection.id.to_string(), format!("ton_{NUMBERS_COLLECTION}")); + assert_eq!(collection.chain, Chain::Ton); + assert_eq!(collection.contract_address, NUMBERS_COLLECTION); + assert_eq!(collection.name, "Anonymous Telegram Numbers"); + assert!(collection.is_verified); + assert_eq!(collection.status, VerificationStatus::Verified); + } + + #[test] + fn test_map_collection_getgems_verified() { + let response: NftCollectionsResponse = serde_json::from_str(include_str!("../../../testdata/ton/collections_getgems.json")).unwrap(); + let collection_id = NFTCollectionId::new(Chain::Ton, GETGEMS_COLLECTION); + let collection = map_collection(response, collection_id).unwrap(); + + assert_eq!(collection.status, VerificationStatus::Verified); + assert!(collection.is_verified); + assert_eq!(collection.links, vec![]); + } + + #[test] + fn test_map_nft_data_includes_unverified_collection() { + let response: NftItemsResponse = serde_json::from_str(include_str!("../../../testdata/ton/items_unverified.json")).unwrap(); + let asset_ids = map_asset_ids(&response); + let data = map_nft_data(response); + + assert_eq!(asset_ids, vec![NFTAssetId::new(Chain::Ton, UNVERIFIED_COLLECTION, ITEM)]); + assert_eq!(data.len(), 1); + assert_eq!(data[0].collection.id, NFTCollectionId::new(Chain::Ton, UNVERIFIED_COLLECTION)); + assert_eq!(data[0].collection.status, VerificationStatus::Unverified); + assert!(!data[0].collection.is_verified); + assert_eq!(data[0].assets.len(), 1); + assert_eq!(data[0].assets[0].id, NFTAssetId::new(Chain::Ton, UNVERIFIED_COLLECTION, ITEM)); + } + + #[test] + fn test_map_collection_rejects_invalid_metadata() { + let response: NftCollectionsResponse = serde_json::from_str(include_str!("../../../testdata/ton/collections_invalid.json")).unwrap(); + let collection_id = NFTCollectionId::new(Chain::Ton, UNVERIFIED_COLLECTION); + assert!(map_collection(response, collection_id).is_none()); + } +} diff --git a/core/crates/nft/src/providers/ton/mod.rs b/core/crates/nft/src/providers/ton/mod.rs new file mode 100644 index 0000000000..2642341f63 --- /dev/null +++ b/core/crates/nft/src/providers/ton/mod.rs @@ -0,0 +1,3 @@ +pub mod mapper; +pub mod provider; +pub mod verified; diff --git a/core/crates/nft/src/providers/ton/provider.rs b/core/crates/nft/src/providers/ton/provider.rs new file mode 100644 index 0000000000..895afeb02c --- /dev/null +++ b/core/crates/nft/src/providers/ton/provider.rs @@ -0,0 +1,39 @@ +use std::error::Error; + +use gem_client::Client; +use gem_ton::rpc::client::TonClient; +use primitives::{Chain, NFTAsset, NFTAssetId, NFTChain, NFTCollection, NFTCollectionId, NFTData}; + +use super::mapper::{map_asset, map_asset_ids, map_collection, map_nft_data}; +use crate::provider::NFTProvider; + +#[async_trait::async_trait] +impl NFTProvider for TonClient { + fn name(&self) -> &'static str { + "Ton" + } + + fn chains(&self) -> &'static [NFTChain] { + &[NFTChain::Ton] + } + + async fn get_assets(&self, _chain: Chain, address: String) -> Result, Box> { + let response = self.get_nft_items_by_owner(&address).await?; + Ok(map_asset_ids(&response)) + } + + async fn get_collection(&self, collection_id: NFTCollectionId) -> Result> { + let response = self.get_nft_collection(&collection_id.contract_address).await?; + map_collection(response, collection_id).ok_or_else(|| "Collection not found".into()) + } + + async fn get_asset(&self, asset_id: NFTAssetId) -> Result> { + let response = self.get_nft_item(&asset_id.token_id).await?; + map_asset(response, asset_id).ok_or_else(|| "Asset not found".into()) + } + + async fn get_nft_data(&self, _chain: Chain, address: String) -> Result, Box> { + let response = self.get_nft_items_by_owner(&address).await?; + Ok(map_nft_data(response)) + } +} diff --git a/core/crates/nft/src/providers/ton/verified.rs b/core/crates/nft/src/providers/ton/verified.rs new file mode 100644 index 0000000000..5d232c71b6 --- /dev/null +++ b/core/crates/nft/src/providers/ton/verified.rs @@ -0,0 +1,58 @@ +use gem_ton::address::Address; +use gem_ton::models::TokenInfo; +use primitives::Address as _; + +// TODO: replace this hardcoded allowlist with a proper spam filter — e.g. a DB-backed verified +// collections table populated from an authoritative source (Getgems / Fragment / TonScan), +// or a heuristic based on collection age / holder count / on-chain verification signals. +const VERIFIED_MARKETPLACES: &[&str] = &["getgems.io"]; +const VERIFIED_COLLECTIONS: &[&str] = &[ + "EQCA14o1-VWhS2efqoh_9M1b_A9DtKTuoqfmkn83AbJzwnPi", // Telegram Usernames + "EQC3dNlesgVD8YbAazcauIrXBPfiVhMMr5YYk2in0Mtsz0Bz", // TON DNS (.ton domains) + "EQAOQdwdw8kGftJCSFgOErM1mBjYPe4DBPq8-AhF6vr9si5N", // Anonymous Telegram Numbers +]; + +pub fn is_verified(address: &Address, info: &TokenInfo) -> bool { + is_verified_collection(address) || is_verified_marketplace(info.extra.as_ref().and_then(|extra| extra.marketplace.as_deref())) +} + +fn is_verified_collection(address: &Address) -> bool { + VERIFIED_COLLECTIONS.contains(&address.encode().as_str()) +} + +fn is_verified_marketplace(marketplace: Option<&str>) -> bool { + match marketplace { + Some(marketplace) => VERIFIED_MARKETPLACES.contains(&marketplace), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_ton::models::TokenInfoExtra; + + #[test] + fn test_is_verified() { + let address = Address::try_parse_hex("0:80D78A35F955A14B679FAA887FF4CD5BFC0F43B4A4EEA2A7E6927F3701B273C2").unwrap(); + let info = mock_token_info(None); + assert!(is_verified(&address, &info)); + + let other = Address::try_parse_hex("0:0000000000000000000000000000000000000000000000000000000000000000").unwrap(); + assert!(is_verified(&other, &mock_token_info(Some("getgems.io")))); + assert!(!is_verified(&other, &mock_token_info(Some("other.io")))); + } + + fn mock_token_info(marketplace: Option<&str>) -> TokenInfo { + TokenInfo { + valid: true, + name: Some("Collection".to_string()), + description: None, + image: None, + extra: Some(TokenInfoExtra { + domain: None, + marketplace: marketplace.map(str::to_string), + }), + } + } +} diff --git a/core/crates/nft/src/testdata/magiceden/evm_asset.json b/core/crates/nft/src/testdata/magiceden/evm_asset.json new file mode 100644 index 0000000000..bc7ab201b8 --- /dev/null +++ b/core/crates/nft/src/testdata/magiceden/evm_asset.json @@ -0,0 +1,79 @@ +{ + "token": { + "chain": "bsc", + "id": "0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba:410", + "collectionId": "0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba", + "owner": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "name": "Reefers by CoralApp #411", + "description": "Welcome to the Reefers collection, a 10,000 unique Coral Reef dwellers, united by their belief in the power of decentralization. Each Reefer is a symbol of individuality and freedom, yet thrives in collaboration within an immersive ecosystem. With the rise of blockchain technology, they merge web3 gaming, digital art, physical infrastructure and ownership into a dynamic space where creativity and community thrives. As they come together, Reefers are more than just inhabitants of a digital reef\u2014they are the architects of a new world. In this multichain ecosystem, freedom, unity, and innovation reign, and the journey is only just beginning. Welcome to The Reef, where the tide is always rising and the possibilities are endless.", + "assetClass": "NFT", + "attributes": [ + { + "traitType": "Aura", + "value": "None" + }, + { + "traitType": "Eyes", + "value": "Pixelated Glasses" + }, + { + "traitType": "Head", + "value": "Cowboy Hat" + }, + { + "traitType": "Skin", + "value": "Gold" + }, + { + "traitType": "Mouth", + "value": "Surprised" + }, + { + "traitType": "Clothes", + "value": "Gear Fin" + }, + { + "traitType": "Accesories", + "value": "Tridents" + }, + { + "traitType": "Background", + "value": "Blue" + }, + { + "traitType": "Sea Friends", + "value": "Octopus" + } + ], + "mediaV2": { + "main": { + "type": "img", + "typeRaw": "img", + "uri": "https://i2c.seadn.io/bsc/0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba/97cbd66a79ea436d7db3224f55be70/4f97cbd66a79ea436d7db3224f55be70.png" + } + }, + "remainingSupply": "1", + "rarity": [], + "contractAddress": "0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba", + "tokenId": "410", + "standard": "ERC721", + "lastSalePrice": { + "amount": { + "raw": "3480000000000000", + "native": "0.00348", + "fiat": { + "usd": "3.082390792468749" + } + }, + "currency": { + "contract": "0x0000000000000000000000000000000000000000", + "symbol": "BNB", + "decimals": 18, + "displayName": "BNB", + "fiatConversion": { + "usd": 885.9128204347875 + } + } + } + } +} diff --git a/core/crates/nft/src/testdata/magiceden/evm_assets.json b/core/crates/nft/src/testdata/magiceden/evm_assets.json new file mode 100644 index 0000000000..c5261ab7fd --- /dev/null +++ b/core/crates/nft/src/testdata/magiceden/evm_assets.json @@ -0,0 +1,84 @@ +{ + "assets": [ + { + "asset": { + "chain": "bsc", + "id": "0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba:410", + "collectionId": "0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba", + "owner": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "name": "Reefers by CoralApp #411", + "description": "Welcome to the Reefers collection, a 10,000 unique Coral Reef dwellers, united by their belief in the power of decentralization. Each Reefer is a symbol of individuality and freedom, yet thrives in collaboration within an immersive ecosystem. With the rise of blockchain technology, they merge web3 gaming, digital art, physical infrastructure and ownership into a dynamic space where creativity and community thrives. As they come together, Reefers are more than just inhabitants of a digital reef\u2014they are the architects of a new world. In this multichain ecosystem, freedom, unity, and innovation reign, and the journey is only just beginning. Welcome to The Reef, where the tide is always rising and the possibilities are endless.", + "assetClass": "NFT", + "attributes": [ + { + "traitType": "Aura", + "value": "None" + }, + { + "traitType": "Eyes", + "value": "Pixelated Glasses" + }, + { + "traitType": "Head", + "value": "Cowboy Hat" + }, + { + "traitType": "Skin", + "value": "Gold" + }, + { + "traitType": "Mouth", + "value": "Surprised" + }, + { + "traitType": "Clothes", + "value": "Gear Fin" + }, + { + "traitType": "Accesories", + "value": "Tridents" + }, + { + "traitType": "Background", + "value": "Blue" + }, + { + "traitType": "Sea Friends", + "value": "Octopus" + } + ], + "mediaV2": { + "main": { + "type": "img", + "typeRaw": "img", + "uri": "https://i2c.seadn.io/bsc/0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba/97cbd66a79ea436d7db3224f55be70/4f97cbd66a79ea436d7db3224f55be70.png" + } + }, + "remainingSupply": "1", + "rarity": [], + "contractAddress": "0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba", + "tokenId": "410", + "standard": "ERC721", + "lastSalePrice": { + "amount": { + "raw": "3480000000000000", + "native": "0.00348", + "fiat": { + "usd": "3.082390792468749" + } + }, + "currency": { + "contract": "0x0000000000000000000000000000000000000000", + "symbol": "BNB", + "decimals": 18, + "displayName": "BNB", + "fiatConversion": { + "usd": 885.9128204347875 + } + } + } + } + } + ], + "continuation": "WzE3NjQzNjc5ODUyNjQsIjB4NmRmYmIwMWVjYjc5OTEzNjZjZDhhY2M0ZDE4ZGNjNjdiYmUzNDViYTo0MTAiXQ==" +} diff --git a/core/crates/nft/src/testdata/magiceden/evm_collection.json b/core/crates/nft/src/testdata/magiceden/evm_collection.json new file mode 100644 index 0000000000..8ed9609e84 --- /dev/null +++ b/core/crates/nft/src/testdata/magiceden/evm_collection.json @@ -0,0 +1,61 @@ +{ + "collections": [ + { + "id": "0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba", + "chain": "bsc", + "name": "Reefers by CoralApp", + "symbol": "CRAPP", + "description": "Welcome to the Reefers collection, a 13,000 unique Coral Reef dwellers, united by their belief in the power of decentralization. Each Reefer is a symbol of individuality and freedom, yet thrives in collaboration within an immersive ecosystem. With the rise of blockchain technology, they merge web3 gaming, digital art, physical infrastructure and ownership into a dynamic space where creativity and community thrives. As they come together, Reefers are more than just inhabitants of a digital reef\u2014they are the architects of a new world. In this multichain ecosystem, freedom, unity, and innovation reign, and the journey is only just beginning. Welcome to The Reef, where the tide is always rising and the possibilities are endless.", + "media": { + "url": "https://img.reservoir.tools/images/v2/bsc/M%2FK47YBCAs6ieLgkYDuIwv46DpQnevL2hmBULPkK3BuBY8BG4GGzXB22H3E0Phb8M9RMHorMwBwK9g1GNRSmHqKVgKxZwKqawUsxLVBU98czgEyHu8JPigR%2B0hTOIEKPQ2fByOR1AiDomIAfUK%2BOjwn70fv7FQbGrZSFpOxFo3IM2D6K7unSJoB%2BIKRyIT38%2Fnx%2F0gpUvVgL3ehrSXBVchEdeTxrzHqQXzaxM9WErHlgdPAGFDVnnuDu62qDy0dJ" + }, + "social": { + "discordUrl": "https://discord.gg/bQYBUHJSkw", + "websiteUrl": "https://nft.coralapp.io" + }, + "verification": "VERIFIED", + "isTradeable": true, + "royalty": { + "recipient": "0x7d87e30dd93eb4466ad0bc9d107d946c4e8ecf92", + "bps": 500, + "isOptional": true + }, + "collectionType": "ERC721", + "isSeaportV16Disabled": false, + "isSeaportV16RoyaltyOptional": true, + "seaportV16ListingCurrencies": [ + { + "address": "0x0000000000000000000000000000000000000000", + "name": "Binance Coin", + "symbol": "BNB", + "decimals": 18 + }, + { + "address": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c", + "name": "Wrapped BNB", + "symbol": "WBNB", + "decimals": 18 + }, + { + "address": "0x2170ed0880ac9a755fd29b2688956bd959f933f8", + "name": "Ethereum Token", + "symbol": "ETH", + "decimals": 18 + }, + { + "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 18 + } + ], + "chainData": { + "contract": "0x6dfbb01ecb7991366cd8acc4d18dcc67bbe345ba", + "transferability": "TRANSFERABLE_TRADABLE", + "collectionBidSupported": true, + "contractDeployedAt": "2025-02-05T06:35:32.000Z", + "isMinting": false + } + } + ] +} diff --git a/core/crates/nft/src/testdata/magiceden/solana_asset.json b/core/crates/nft/src/testdata/magiceden/solana_asset.json new file mode 100644 index 0000000000..5a9a517541 --- /dev/null +++ b/core/crates/nft/src/testdata/magiceden/solana_asset.json @@ -0,0 +1,62 @@ +{ + "mintAddress": "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "supply": 1, + "collection": "pooks", + "collectionName": "pooks", + "name": "pooks #3726", + "updateAuthority": "CKKpdDfiNy5aHe4MVzGBBuAXtxmvGuTxeu4j8APrR4Po", + "primarySaleHappened": true, + "sellerFeeBasisPoints": 500, + "image": "https://bafybeidne5l7l5s6c3zezvcvmdq3o4hksminkbr3emy6maxr3zoomzymha.ipfs.nftstorage.link/3726.png?ext=png", + "attributes": [ + { + "trait_type": "Background", + "value": "Dewdrop Delight" + }, + { + "trait_type": "Body", + "value": "Human" + }, + { + "trait_type": "Clothing", + "value": "White Karate Uniform" + }, + { + "trait_type": "Eyes", + "value": "Angry" + }, + { + "trait_type": "Hair", + "value": "Black Dreads" + }, + { + "trait_type": "Headwear", + "value": "Blue Beanie" + }, + { + "trait_type": "Mouth", + "value": "Big" + } + ], + "properties": { + "files": [ + { + "uri": "https://bafybeidne5l7l5s6c3zezvcvmdq3o4hksminkbr3emy6maxr3zoomzymha.ipfs.nftstorage.link/3726.png?ext=png", + "type": "image/png" + } + ], + "category": "image", + "creators": [ + { + "share": 9, + "address": "RRUMF9KYPcvNSmnicNMAFKx5wDYix3wjNa6bA7R6xqA" + }, + { + "share": 91, + "address": "DFfDFeZ6SzE9Cu3jKiGAiPujXQk7ZJKm76E8KTAYXiv7" + } + ] + }, + "listStatus": "unlisted" +} \ No newline at end of file diff --git a/core/crates/nft/src/testdata/magiceden/solana_assets.json b/core/crates/nft/src/testdata/magiceden/solana_assets.json new file mode 100644 index 0000000000..0db2482de7 --- /dev/null +++ b/core/crates/nft/src/testdata/magiceden/solana_assets.json @@ -0,0 +1,64 @@ +[ + { + "mintAddress": "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "supply": 1, + "collection": "pooks", + "collectionName": "pooks", + "name": "pooks #3726", + "updateAuthority": "CKKpdDfiNy5aHe4MVzGBBuAXtxmvGuTxeu4j8APrR4Po", + "primarySaleHappened": true, + "sellerFeeBasisPoints": 500, + "image": "https://bafybeidne5l7l5s6c3zezvcvmdq3o4hksminkbr3emy6maxr3zoomzymha.ipfs.nftstorage.link/3726.png?ext=png", + "attributes": [ + { + "trait_type": "Background", + "value": "Dewdrop Delight" + }, + { + "trait_type": "Body", + "value": "Human" + }, + { + "trait_type": "Clothing", + "value": "White Karate Uniform" + }, + { + "trait_type": "Eyes", + "value": "Angry" + }, + { + "trait_type": "Hair", + "value": "Black Dreads" + }, + { + "trait_type": "Headwear", + "value": "Blue Beanie" + }, + { + "trait_type": "Mouth", + "value": "Big" + } + ], + "properties": { + "files": [ + { + "uri": "https://bafybeidne5l7l5s6c3zezvcvmdq3o4hksminkbr3emy6maxr3zoomzymha.ipfs.nftstorage.link/3726.png?ext=png", + "type": "image/png" + } + ], + "category": "image", + "creators": [ + { + "share": 9, + "address": "RRUMF9KYPcvNSmnicNMAFKx5wDYix3wjNa6bA7R6xqA" + }, + { + "share": 91, + "address": "DFfDFeZ6SzE9Cu3jKiGAiPujXQk7ZJKm76E8KTAYXiv7" + } + ] + }, + "listStatus": "unlisted" + } +] \ No newline at end of file diff --git a/core/crates/nft/src/testdata/magiceden/solana_collection.json b/core/crates/nft/src/testdata/magiceden/solana_collection.json new file mode 100644 index 0000000000..7985e10920 --- /dev/null +++ b/core/crates/nft/src/testdata/magiceden/solana_collection.json @@ -0,0 +1,62 @@ +{ + "symbol": "okay_bears", + "candyMachineIds": [], + "name": "Okay Bears", + "image": "https://bafybeiedc6mf2vtqv7l5hgz6wyd2juw4q423wfcsegvrnw7u6ixqshuciu.ipfs.w3s.link/okb.jpg", + "categories": [ + "pfps" + ], + "description": "Okay Bears is a culture shift. A clean collection of 10,000 diverse bears building a virtuous community that will transcend the internet into the real world.", + "createdAt": "2022-04-26T20:05:25.338Z", + "enabledAttributesFilters": true, + "enabledVersionFilter": false, + "isDraft": false, + "website": "https://www.okaybears.com", + "twitter": "https://twitter.com/okaybears", + "discord": "https://discord.com/invite/okaybears", + "derivativeDetails": { + "originLink": "", + "originName": "" + }, + "isDerivative": false, + "nftImageType": "", + "onChainCollectionAddress": "", + "rarity": { + "showHowrare": false, + "showMoonrank": true, + "showMagicEden": true + }, + "updatedAt": "2025-08-17T20:53:14.567Z", + "watchlistCount": 10812, + "flagMessage": "", + "isFlagged": false, + "isVerified": true, + "blackListAttributes": [], + "blockedMints": [], + "enabledTotalSupply": true, + "enabledUniqueOwners": true, + "iframe": "", + "sortBy": "", + "stackBy": [], + "mmmStatus": "available", + "isEligibleForDiamonds": false, + "isEligibleForDiamondsForMaker": true, + "mmmAllowlistUpdatedAt": "2025-08-17T20:53:14.564Z", + "caller": "Update-Retool", + "carousel": false, + "efpcUpdatedAt": "2025-07-22T10:41:08.426Z", + "enabledBidding": true, + "excludeFromPopularCollections": false, + "fungibleTokenAddress": "", + "hasInscriptions": false, + "isMIP1": false, + "isOcp": false, + "lmnft": "", + "splTokens": [ + "SOL", + "mSOL", + "USDC" + ], + "tags": [], + "treePubKeys": [] +} \ No newline at end of file diff --git a/core/crates/nft/src/testkit.rs b/core/crates/nft/src/testkit.rs new file mode 100644 index 0000000000..28011d0384 --- /dev/null +++ b/core/crates/nft/src/testkit.rs @@ -0,0 +1,64 @@ +#![cfg(any(test, feature = "nft_integration_tests"))] + +pub const TEST_ETHEREUM_ADDRESS: &str = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; +pub const TEST_ETHEREUM_CONTRACT_ADDRESS: &str = "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D"; +pub const TEST_SOLANA_ADDRESS: &str = "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H"; +pub const TEST_SOLANA_COLLECTION: &str = "okay_bears"; +pub const TEST_SOLANA_COLLECTION_POOKS: &str = "pooks"; +pub const TEST_SOLANA_TOKEN_ID: &str = "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy"; +pub const TEST_BSC_ADDRESS: &str = "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4"; +pub const TEST_BSC_COLLECTION: &str = "0x2e1ced4363f810c7b2f72de9fe675b12b2da1bfa"; +pub const TEST_TON_ADDRESS: &str = "UQAjvBlA6Sqa27K7n7RJdGqfoCyhgIUV_MXDlKMl-j9vdR7G"; +pub const TEST_TON_COLLECTION: &str = "EQDvRFMYLdxmvY3Tk-cfWMLqDnXF_EclO2Fp4wwj33WhlNFT"; +pub const TEST_TON_NFT_ADDRESS: &str = "EQCvxJy4eG8hyHBFsZ7eePxrRsUQSFE_jpptRAYBmcG_DOGS"; + +#[cfg(feature = "nft_integration_tests")] +use crate::providers::magiceden; +#[cfg(feature = "nft_integration_tests")] +use crate::providers::magiceden::evm::client::MagicEdenEvmClient; +#[cfg(feature = "nft_integration_tests")] +use crate::providers::magiceden::solana::client::MagicEdenSolanaClient; +#[cfg(feature = "nft_integration_tests")] +use crate::providers::opensea; +#[cfg(feature = "nft_integration_tests")] +use crate::providers::opensea::client::OpenSeaClient; +#[cfg(feature = "nft_integration_tests")] +use gem_client::ReqwestClient; +#[cfg(feature = "nft_integration_tests")] +use gem_ton::rpc::client::TonClient; +#[cfg(feature = "nft_integration_tests")] +use settings::Settings; + +#[cfg(feature = "nft_integration_tests")] +fn get_test_settings() -> Settings { + let settings_path = std::env::current_dir().expect("Failed to get current directory").join("../../Settings.yaml"); + Settings::new_setting_path(settings_path).expect("Failed to load settings for tests") +} + +#[cfg(feature = "nft_integration_tests")] +pub fn create_opensea_test_client() -> OpenSeaClient { + let settings = get_test_settings(); + let client = opensea::create_client(&settings.nft.opensea.key.secret); + OpenSeaClient::new(client) +} + +#[cfg(feature = "nft_integration_tests")] +pub fn create_magiceden_solana_test_client() -> MagicEdenSolanaClient { + let settings = get_test_settings(); + let client = magiceden::create_client(&settings.nft.magiceden.key.secret); + MagicEdenSolanaClient::new(client) +} + +#[cfg(feature = "nft_integration_tests")] +pub fn create_magiceden_evm_test_client() -> MagicEdenEvmClient { + let settings = get_test_settings(); + let client = magiceden::create_client(&settings.nft.magiceden.key.secret); + MagicEdenEvmClient::new(client) +} + +#[cfg(feature = "nft_integration_tests")] +pub fn create_ton_test_client() -> TonClient { + let settings = get_test_settings(); + let reqwest_client = ReqwestClient::new(settings.chains.ton.url, reqwest::Client::new()); + TonClient::new(reqwest_client) +} diff --git a/core/crates/nft/testdata/magiceden/asset.json b/core/crates/nft/testdata/magiceden/asset.json new file mode 100644 index 0000000000..5a9a517541 --- /dev/null +++ b/core/crates/nft/testdata/magiceden/asset.json @@ -0,0 +1,62 @@ +{ + "mintAddress": "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "supply": 1, + "collection": "pooks", + "collectionName": "pooks", + "name": "pooks #3726", + "updateAuthority": "CKKpdDfiNy5aHe4MVzGBBuAXtxmvGuTxeu4j8APrR4Po", + "primarySaleHappened": true, + "sellerFeeBasisPoints": 500, + "image": "https://bafybeidne5l7l5s6c3zezvcvmdq3o4hksminkbr3emy6maxr3zoomzymha.ipfs.nftstorage.link/3726.png?ext=png", + "attributes": [ + { + "trait_type": "Background", + "value": "Dewdrop Delight" + }, + { + "trait_type": "Body", + "value": "Human" + }, + { + "trait_type": "Clothing", + "value": "White Karate Uniform" + }, + { + "trait_type": "Eyes", + "value": "Angry" + }, + { + "trait_type": "Hair", + "value": "Black Dreads" + }, + { + "trait_type": "Headwear", + "value": "Blue Beanie" + }, + { + "trait_type": "Mouth", + "value": "Big" + } + ], + "properties": { + "files": [ + { + "uri": "https://bafybeidne5l7l5s6c3zezvcvmdq3o4hksminkbr3emy6maxr3zoomzymha.ipfs.nftstorage.link/3726.png?ext=png", + "type": "image/png" + } + ], + "category": "image", + "creators": [ + { + "share": 9, + "address": "RRUMF9KYPcvNSmnicNMAFKx5wDYix3wjNa6bA7R6xqA" + }, + { + "share": 91, + "address": "DFfDFeZ6SzE9Cu3jKiGAiPujXQk7ZJKm76E8KTAYXiv7" + } + ] + }, + "listStatus": "unlisted" +} \ No newline at end of file diff --git a/core/crates/nft/testdata/magiceden/assets.json b/core/crates/nft/testdata/magiceden/assets.json new file mode 100644 index 0000000000..0db2482de7 --- /dev/null +++ b/core/crates/nft/testdata/magiceden/assets.json @@ -0,0 +1,64 @@ +[ + { + "mintAddress": "HP82kPNXnQcozjDrV4dLYfV6wwABQDMVPJXezDbZXHEy", + "owner": "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H", + "supply": 1, + "collection": "pooks", + "collectionName": "pooks", + "name": "pooks #3726", + "updateAuthority": "CKKpdDfiNy5aHe4MVzGBBuAXtxmvGuTxeu4j8APrR4Po", + "primarySaleHappened": true, + "sellerFeeBasisPoints": 500, + "image": "https://bafybeidne5l7l5s6c3zezvcvmdq3o4hksminkbr3emy6maxr3zoomzymha.ipfs.nftstorage.link/3726.png?ext=png", + "attributes": [ + { + "trait_type": "Background", + "value": "Dewdrop Delight" + }, + { + "trait_type": "Body", + "value": "Human" + }, + { + "trait_type": "Clothing", + "value": "White Karate Uniform" + }, + { + "trait_type": "Eyes", + "value": "Angry" + }, + { + "trait_type": "Hair", + "value": "Black Dreads" + }, + { + "trait_type": "Headwear", + "value": "Blue Beanie" + }, + { + "trait_type": "Mouth", + "value": "Big" + } + ], + "properties": { + "files": [ + { + "uri": "https://bafybeidne5l7l5s6c3zezvcvmdq3o4hksminkbr3emy6maxr3zoomzymha.ipfs.nftstorage.link/3726.png?ext=png", + "type": "image/png" + } + ], + "category": "image", + "creators": [ + { + "share": 9, + "address": "RRUMF9KYPcvNSmnicNMAFKx5wDYix3wjNa6bA7R6xqA" + }, + { + "share": 91, + "address": "DFfDFeZ6SzE9Cu3jKiGAiPujXQk7ZJKm76E8KTAYXiv7" + } + ] + }, + "listStatus": "unlisted" + } +] \ No newline at end of file diff --git a/core/crates/nft/testdata/magiceden/collection.json b/core/crates/nft/testdata/magiceden/collection.json new file mode 100644 index 0000000000..7985e10920 --- /dev/null +++ b/core/crates/nft/testdata/magiceden/collection.json @@ -0,0 +1,62 @@ +{ + "symbol": "okay_bears", + "candyMachineIds": [], + "name": "Okay Bears", + "image": "https://bafybeiedc6mf2vtqv7l5hgz6wyd2juw4q423wfcsegvrnw7u6ixqshuciu.ipfs.w3s.link/okb.jpg", + "categories": [ + "pfps" + ], + "description": "Okay Bears is a culture shift. A clean collection of 10,000 diverse bears building a virtuous community that will transcend the internet into the real world.", + "createdAt": "2022-04-26T20:05:25.338Z", + "enabledAttributesFilters": true, + "enabledVersionFilter": false, + "isDraft": false, + "website": "https://www.okaybears.com", + "twitter": "https://twitter.com/okaybears", + "discord": "https://discord.com/invite/okaybears", + "derivativeDetails": { + "originLink": "", + "originName": "" + }, + "isDerivative": false, + "nftImageType": "", + "onChainCollectionAddress": "", + "rarity": { + "showHowrare": false, + "showMoonrank": true, + "showMagicEden": true + }, + "updatedAt": "2025-08-17T20:53:14.567Z", + "watchlistCount": 10812, + "flagMessage": "", + "isFlagged": false, + "isVerified": true, + "blackListAttributes": [], + "blockedMints": [], + "enabledTotalSupply": true, + "enabledUniqueOwners": true, + "iframe": "", + "sortBy": "", + "stackBy": [], + "mmmStatus": "available", + "isEligibleForDiamonds": false, + "isEligibleForDiamondsForMaker": true, + "mmmAllowlistUpdatedAt": "2025-08-17T20:53:14.564Z", + "caller": "Update-Retool", + "carousel": false, + "efpcUpdatedAt": "2025-07-22T10:41:08.426Z", + "enabledBidding": true, + "excludeFromPopularCollections": false, + "fungibleTokenAddress": "", + "hasInscriptions": false, + "isMIP1": false, + "isOcp": false, + "lmnft": "", + "splTokens": [ + "SOL", + "mSOL", + "USDC" + ], + "tags": [], + "treePubKeys": [] +} \ No newline at end of file diff --git a/core/crates/nft/testdata/opensea/asset.json b/core/crates/nft/testdata/opensea/asset.json new file mode 100644 index 0000000000..2e219c7137 --- /dev/null +++ b/core/crates/nft/testdata/opensea/asset.json @@ -0,0 +1,64 @@ +{ + "nft": { + "identifier": "1", + "collection": "boredapeyachtclub", + "contract": "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", + "token_standard": "erc721", + "name": "#1", + "description": "", + "image_url": "https://i2.seadn.io/matic/0x24728cf6f3ca32b95b496628fc0ccbc626e26c2b/399ac6f35935d93930b68fa64cd307/5d399ac6f35935d93930b68fa64cd307.png", + "display_image_url": "https://i2.seadn.io/matic/0x24728cf6f3ca32b95b496628fc0ccbc626e26c2b/399ac6f35935d93930b68fa64cd307/5d399ac6f35935d93930b68fa64cd307.png", + "display_animation_url": null, + "metadata_url": "ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/1", + "opensea_url": "https://opensea.io/assets/ethereum/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d/1", + "updated_at": "2025-09-02T19:24:26.431435", + "is_disabled": false, + "is_nsfw": false, + "animation_url": null, + "is_suspicious": false, + "creator": "", + "traits": [ + { + "trait_type": "Mouth", + "display_type": null, + "max_value": null, + "value": "Grin" + }, + { + "trait_type": "Clothes", + "display_type": null, + "max_value": null, + "value": "Vietnam Jacket" + }, + { + "trait_type": "Background", + "display_type": null, + "max_value": null, + "value": "Orange" + }, + { + "trait_type": "Eyes", + "display_type": null, + "max_value": null, + "value": "Blue Beams" + }, + { + "trait_type": "Fur", + "display_type": null, + "max_value": null, + "value": "Robot" + } + ], + "owners": [ + { + "address": "0x46efbaedc92067e6d60e84ed6395099723252496", + "quantity": 1 + } + ], + "rarity": { + "strategy_id": "openrarity", + "strategy_version": "1.0", + "rank": 2693 + } + } +} \ No newline at end of file diff --git a/core/crates/nft/testdata/opensea/asset_ens_dates.json b/core/crates/nft/testdata/opensea/asset_ens_dates.json new file mode 100644 index 0000000000..432055c5e3 --- /dev/null +++ b/core/crates/nft/testdata/opensea/asset_ens_dates.json @@ -0,0 +1,68 @@ +{ + "nft": { + "identifier": "91780768891665961085574300632320337237649359513314798633242628975887494917390", + "collection": "ens", + "contract": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "token_standard": "erc1155", + "name": "gemtester.eth", + "description": "gemtester.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/fd1463dd6d32a7e5f839b67ffa3ff4/6dfd1463dd6d32a7e5f839b67ffa3ff4.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/fd1463dd6d32a7e5f839b67ffa3ff4/6dfd1463dd6d32a7e5f839b67ffa3ff4.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/91780768891665961085574300632320337237649359513314798633242628975887494917390", + "opensea_url": "https://opensea.io/assets/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/91780768891665961085574300632320337237649359513314798633242628975887494917390", + "updated_at": "2026-04-28T17:06:29.333804", + "is_disabled": false, + "is_nsfw": false, + "original_image_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/0xcaea1304e0bcd42ea9c6111fbcb5a8299547395d7da690b2fd29f3451e93ad0e/image", + "original_animation_url": null, + "traits": [ + { + "trait_type": "Length", + "display_type": "NUMBER", + "max_value": null, + "value": "9" + }, + { + "trait_type": "Created Date", + "display_type": null, + "max_value": null, + "value": "1738102775" + }, + { + "trait_type": "Character Set", + "display_type": "STRING", + "max_value": null, + "value": "letter" + }, + { + "trait_type": "Segment Length", + "display_type": "NUMBER", + "max_value": null, + "value": "9" + }, + { + "trait_type": "Namewrapper State", + "display_type": "STRING", + "max_value": null, + "value": "Emancipated" + }, + { + "trait_type": "Namewrapper Expiry Date", + "display_type": null, + "max_value": null, + "value": "1872109175" + } + ], + "animation_url": null, + "is_suspicious": false, + "creator": "", + "owners": [ + { + "address": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "quantity": 1 + } + ], + "rarity": null + } +} diff --git a/core/crates/nft/testdata/opensea/asset_null_images.json b/core/crates/nft/testdata/opensea/asset_null_images.json new file mode 100644 index 0000000000..4b63d2962e --- /dev/null +++ b/core/crates/nft/testdata/opensea/asset_null_images.json @@ -0,0 +1,56 @@ +{ + "nft": { + "identifier": "66972740172774133895361774757009899712806299063970949277266423600598010529206", + "collection": "ens", + "contract": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "token_standard": "erc1155", + "name": "gemdev.eth", + "description": "gemdev.eth, an ENS name.", + "image_url": null, + "display_image_url": null, + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/66972740172774133895361774757009899712806299063970949277266423600598010529206", + "opensea_url": "https://opensea.io/assets/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/66972740172774133895361774757009899712806299063970949277266423600598010529206", + "updated_at": "2026-03-24T13:17:04.153143", + "is_disabled": false, + "is_nsfw": false, + "original_image_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/0x94113a45c5bedf735911bf707d70a6c05d9d99e76ece7e904c0eeda6591785b6/image", + "original_animation_url": null, + "traits": [ + { + "trait_type": "Length", + "display_type": "NUMBER", + "max_value": null, + "value": "6" + }, + { + "trait_type": "Created Date", + "display_type": null, + "max_value": null, + "value": "1774356395" + }, + { + "trait_type": "Character Set", + "display_type": "STRING", + "max_value": null, + "value": "letter" + }, + { + "trait_type": "Segment Length", + "display_type": "NUMBER", + "max_value": null, + "value": "6" + } + ], + "animation_url": null, + "is_suspicious": false, + "creator": "", + "owners": [ + { + "address": "0x8d7460e51bcf4ed26877cb77e56f3ce7e9f5eb8f", + "quantity": 1 + } + ], + "rarity": null + } +} diff --git a/core/crates/nft/testdata/opensea/assets.json b/core/crates/nft/testdata/opensea/assets.json new file mode 100644 index 0000000000..ceaa9a59a9 --- /dev/null +++ b/core/crates/nft/testdata/opensea/assets.json @@ -0,0 +1,1605 @@ +{ + "nfts": [ + { + "identifier": "7", + "collection": "refractions-xyz", + "contract": "0x8eaaabe11896bd09fb852d3a5248004ec44bc793", + "token_standard": "erc1155", + "name": "Prism", + "description": "🜂🜃🜁🜄 [Prism](https://refractions.xyz/prism) - ⛯ The Lighthouse \r\n **Introducing [refractions.xyz](https://refractions.xyz/)**", + "image_url": "https://i2.seadn.io/ethereum/0x8eaaabe11896bd09fb852d3a5248004ec44bc793/cd121ae43b36896424efc3a2f8ec6e/31cd121ae43b36896424efc3a2f8ec6e.gif", + "display_image_url": "https://i2.seadn.io/ethereum/0x8eaaabe11896bd09fb852d3a5248004ec44bc793/cd121ae43b36896424efc3a2f8ec6e/31cd121ae43b36896424efc3a2f8ec6e.gif", + "display_animation_url": "https://refractions.blob.core.windows.net/refractions/HUB/index.html?fish=1&ui=opensea", + "metadata_url": "https://refractions.azureedge.net/refractions/metadata/refractions/0000000000000000000000000000000000000000000000000000000000000007/metadata.json", + "opensea_url": "https://opensea.io/assets/ethereum/0x8eaaabe11896bd09fb852d3a5248004ec44bc793/7", + "updated_at": "2025-09-02T19:25:19.042471", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "12", + "collection": "glitch-world-two-face-eth", + "contract": "0x650fb957592d51a2d49be6e44b34a6f51b2b9f10", + "token_standard": "erc1155", + "name": "GW #5", + "description": "", + "image_url": "https://i2.seadn.io/ethereum/0x650fb957592d51a2d49be6e44b34a6f51b2b9f10/e05f86cc95c551bb53040832243065/aee05f86cc95c551bb53040832243065.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x650fb957592d51a2d49be6e44b34a6f51b2b9f10/e05f86cc95c551bb53040832243065/aee05f86cc95c551bb53040832243065.png", + "display_animation_url": null, + "metadata_url": "ipfs://QmdfWcrTDJceSuZpVjJq2JDGYpTSm4uPjcexfiw9twSZMM/12", + "opensea_url": "https://opensea.io/assets/ethereum/0x650fb957592d51a2d49be6e44b34a6f51b2b9f10/12", + "updated_at": "2025-09-02T19:25:19.043119", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "44985355616051103256032243057231344854710731794034272365360937473011031231405", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "valhalhaze.eth", + "description": "valhalhaze.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/cd6606f1f8f05bf03ef7f6da0a2346/67cd6606f1f8f05bf03ef7f6da0a2346.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/cd6606f1f8f05bf03ef7f6da0a2346/67cd6606f1f8f05bf03ef7f6da0a2346.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/44985355616051103256032243057231344854710731794034272365360937473011031231405", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/44985355616051103256032243057231344854710731794034272365360937473011031231405", + "updated_at": "2025-09-02T19:25:19.044571", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "10939114709598820520584460923946974343320067617749639634506906914669040320802", + "collection": "ens", + "contract": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "token_standard": "erc1155", + "name": "yapixbt.eth", + "description": "yapixbt.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/84ae85303d530d40dccde8bfb3b2b0/e884ae85303d530d40dccde8bfb3b2b0.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/84ae85303d530d40dccde8bfb3b2b0/e884ae85303d530d40dccde8bfb3b2b0.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/10939114709598820520584460923946974343320067617749639634506906914669040320802", + "opensea_url": "https://opensea.io/assets/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/10939114709598820520584460923946974343320067617749639634506906914669040320802", + "updated_at": "2025-09-02T19:25:19.047762", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "5132", + "collection": "ethereum-puppets", + "contract": "0x3c020f2124b84bd079985c77f93d4a750512448c", + "token_standard": "erc721", + "name": "Ethereum Puppet #5132", + "description": "Ethereum Puppets are the NFT project of Ethereum Blockchain. They are a collection of 5250 master mind manipulators who always come out on top.", + "image_url": "https://i2.seadn.io/ethereum/0x3c020f2124b84bd079985c77f93d4a750512448c/13e553d55b82b7ed68549c3c2859c171.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x3c020f2124b84bd079985c77f93d4a750512448c/13e553d55b82b7ed68549c3c2859c171.png", + "display_animation_url": null, + "metadata_url": "https://www.ethereumpuppets.com/metadata/5132", + "opensea_url": "https://opensea.io/assets/ethereum/0x3c020f2124b84bd079985c77f93d4a750512448c/5132", + "updated_at": "2025-09-02T19:25:19.048869", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "2", + "collection": "ethaliks-v2", + "contract": "0x24aa2b2e2f33bc95288b37101934c63860a4642e", + "token_standard": "erc1155", + "name": "Cyborg Ethalik", + "description": "The planet ETH1.0, inhabited by Ethaliks and their weakening king gaPOW, are dying. Plagued by a scourge of creatures known as \"beGAS\", their FUD army pushes to destroy all life. A hero gaPOS emerges, with plans to seek a new home on ETH2.0 for the last of the Ethaliks. Join our fight!", + "image_url": "https://i2.seadn.io/ethereum/0x24aa2b2e2f33bc95288b37101934c63860a4642e/59208d526ed15fa1962feb801c25e408.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x24aa2b2e2f33bc95288b37101934c63860a4642e/59208d526ed15fa1962feb801c25e408.png", + "display_animation_url": "https://raw2.seadn.io/ethereum/0x24aa2b2e2f33bc95288b37101934c63860a4642e/637d5253926e0d27f130a104ecf341fa.mp4", + "metadata_url": "https://ipfs.io/ipfs/bafkreie4deqfrzgodyhlkt2dpt4wjq5ldqf35k7hvr3upezojkv64xkyju", + "opensea_url": "https://opensea.io/assets/ethereum/0x24aa2b2e2f33bc95288b37101934c63860a4642e/2", + "updated_at": "2025-09-02T19:25:19.042784", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "4", + "collection": "love-1690", + "contract": "0x47ab6a775da8bf63d8ff2d5328921e65e8696ec7", + "token_standard": "erc1155", + "name": "Hope", + "description": "The road to eternity", + "image_url": "https://i2.seadn.io/ethereum/0x47ab6a775da8bf63d8ff2d5328921e65e8696ec7/92b442b8e23b95a2e0ad053d7eea0a/8392b442b8e23b95a2e0ad053d7eea0a.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0x47ab6a775da8bf63d8ff2d5328921e65e8696ec7/92b442b8e23b95a2e0ad053d7eea0a/8392b442b8e23b95a2e0ad053d7eea0a.jpeg", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeidmfanjzy3iinvqw46btu4jm4zaalzekqpvpspi6r2dfgnlwxbbay/4", + "opensea_url": "https://opensea.io/assets/ethereum/0x47ab6a775da8bf63d8ff2d5328921e65e8696ec7/4", + "updated_at": "2025-09-02T19:25:19.048496", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1", + "collection": "angel-of-justice-1", + "contract": "0xc6fc9c9ee01239713a292d5e28f17d4415a3dfa2", + "token_standard": "erc1155", + "name": "Angel of Justice", + "description": "Angel of Justice", + "image_url": "https://i2.seadn.io/ethereum/0xc6fc9c9ee01239713a292d5e28f17d4415a3dfa2/0f61d9f41ffa27619f111c2e0fcc1f/7d0f61d9f41ffa27619f111c2e0fcc1f.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xc6fc9c9ee01239713a292d5e28f17d4415a3dfa2/0f61d9f41ffa27619f111c2e0fcc1f/7d0f61d9f41ffa27619f111c2e0fcc1f.png", + "display_animation_url": "https://raw2.seadn.io/ethereum/0xc6fc9c9ee01239713a292d5e28f17d4415a3dfa2/9650ab152bf42d90b6fd4d1e5d430a/cb9650ab152bf42d90b6fd4d1e5d430a.mp4", + "metadata_url": "https://arweave.net/3hXpJIgYl5Yvhcjoe2Rscxxh-awLTiG9zKxef2Lxz4s/", + "opensea_url": "https://opensea.io/assets/ethereum/0xc6fc9c9ee01239713a292d5e28f17d4415a3dfa2/1", + "updated_at": "2025-09-02T19:25:19.043255", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "20", + "collection": "showdeer-editions", + "contract": "0xfa681d6ee26fc1974cf1f251b3df538c5acb7b85", + "token_standard": "erc1155", + "name": "ETHEREUM 10-YEAR ANNIVERSARY", + "description": "First hand-drawn digital illustration in the editions collection celebrating Ethereum's 10-Year Anniversary! Five minters at random will get receive a limited edition hand-embellished signed and numbered screen printed poster!\n\n", + "image_url": "https://i2.seadn.io/ethereum/0xfa681d6ee26fc1974cf1f251b3df538c5acb7b85/e9da63cb9caa99d7ccfa10630e8ec7/5ce9da63cb9caa99d7ccfa10630e8ec7.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0xfa681d6ee26fc1974cf1f251b3df538c5acb7b85/e9da63cb9caa99d7ccfa10630e8ec7/5ce9da63cb9caa99d7ccfa10630e8ec7.jpeg", + "display_animation_url": null, + "metadata_url": "https://2hosss2vjta7vq5eftg4ylhenqv6dv66tsfensjrogzuzte3x2cq.arweave.net/0d0pS1VMwfrDpCzNzCzkbCvh196cikbJMXGzTMybvoU/", + "opensea_url": "https://opensea.io/assets/ethereum/0xfa681d6ee26fc1974cf1f251b3df538c5acb7b85/20", + "updated_at": "2025-09-02T19:25:19.043488", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "15", + "collection": "proof-of-belief", + "contract": "0x919dc97eeb7f55fe7ae6e55d0726d9f9279ddd61", + "token_standard": "erc1155", + "name": "Proof Of Belief - Day 11 - Ethereum Day – The World Computer Comes Alive", + "description": "Ten years ago today—on July 30, 2015—the Ethereum network went live.\nThe genesis block was mined, and a new era began.\nIf Bitcoin proved that money could be free, Ethereum proved that everything else could be too.\nIt gave the world more than a currency. It gave us a canvas. A programmable world where agreements, organizations, culture, and identity could live on-chain—unstoppable, permissionless, borderless.\nEthereum was the leap from value to vision.\nIt turned blockchains from vaults into universes.\nBut this new world needs both anchors:\nBitcoin, the incorruptible foundation.\nEthereum, the expressive engine of creation.\nOne protects. The other builds.\nTogether, they make the future worth believing in.\nThis NFT is a tribute to that belief.\nTo the moment the world computer came alive.\n#ProofOfBelief #RebellionSeries #DayEleven\nToday, I mint three editions:\n• One I will keep forever.\n• One is reserved only for those who collect the full series.\n• And one is not for sale—it will only ever go to Vitalik, for $0. No offers. No negotiations. Just honor.", + "image_url": "https://i2.seadn.io/ethereum/0x919dc97eeb7f55fe7ae6e55d0726d9f9279ddd61/d109ae4cf4865b9a6852a80407cfdd/06d109ae4cf4865b9a6852a80407cfdd.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x919dc97eeb7f55fe7ae6e55d0726d9f9279ddd61/d109ae4cf4865b9a6852a80407cfdd/06d109ae4cf4865b9a6852a80407cfdd.png", + "display_animation_url": null, + "metadata_url": "ipfs://QmWUMptsjsMnNNQw1H6JGN3rZjhHdcHggHJ9ocDonWqXLg/15", + "opensea_url": "https://opensea.io/assets/ethereum/0x919dc97eeb7f55fe7ae6e55d0726d9f9279ddd61/15", + "updated_at": "2025-09-02T19:25:19.042514", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "4", + "collection": "empress-trash-s-party-time", + "contract": "0xad3d1283eda06d5c8e130e6f7c3e17c57dadbb4d", + "token_standard": "erc1155", + "name": "believe in somETHing", + "description": "eth symbol x neon noir titles.xyz ai model x mosh for eth 10 anniversary", + "image_url": "https://i2.seadn.io/ethereum/0xad3d1283eda06d5c8e130e6f7c3e17c57dadbb4d/f4236e7fb12455b2b1346a1df0f1a5/46f4236e7fb12455b2b1346a1df0f1a5.gif", + "display_image_url": "https://i2.seadn.io/ethereum/0xad3d1283eda06d5c8e130e6f7c3e17c57dadbb4d/f4236e7fb12455b2b1346a1df0f1a5/46f4236e7fb12455b2b1346a1df0f1a5.gif", + "display_animation_url": null, + "metadata_url": null, + "opensea_url": "https://opensea.io/assets/ethereum/0xad3d1283eda06d5c8e130e6f7c3e17c57dadbb4d/4", + "updated_at": "2025-09-02T19:25:19.043303", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "70225864183203228327884457542944114301363402197598404521825396547562495803393", + "collection": "odin-catsamoto", + "contract": "0x495f947276749ce646f68ac8c248420045cb7b5e", + "token_standard": "erc1155", + "name": "rubbing twenty", + "description": "another reason not to use fiat money", + "image_url": "https://i2.seadn.io/ethereum/0x495f947276749ce646f68ac8c248420045cb7b5e/7a2ecad9be12afa3fd874e5c79c2ec/d07a2ecad9be12afa3fd874e5c79c2ec.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x495f947276749ce646f68ac8c248420045cb7b5e/7a2ecad9be12afa3fd874e5c79c2ec/d07a2ecad9be12afa3fd874e5c79c2ec.png", + "display_animation_url": null, + "metadata_url": "https://api.opensea.io/api/v1/metadata/0x495f947276749Ce646f68AC8c248420045cb7b5e/0x9b426e39a82203b98fffa9eb10396ec8893398800000000000000b0000000001", + "opensea_url": "https://opensea.io/assets/ethereum/0x495f947276749ce646f68ac8c248420045cb7b5e/70225864183203228327884457542944114301363402197598404521825396547562495803393", + "updated_at": "2025-09-02T19:25:19.049179", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "3142", + "collection": "negatives-nft", + "contract": "0x05ffbb90241d2f9596ebd352323695127aedca30", + "token_standard": "erc721", + "name": "Negative 3142", + "description": "Generated by code. Arranged and photographed by hand.", + "image_url": "https://i2.seadn.io/ethereum/0x05ffbb90241d2f9596ebd352323695127aedca30/dbb4e2bbe9999d41058d0385e1d49bf0.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x05ffbb90241d2f9596ebd352323695127aedca30/dbb4e2bbe9999d41058d0385e1d49bf0.png", + "display_animation_url": null, + "metadata_url": "https://ipfs.io/ipfs/bafybeibhecnykgrppt3f6rh6a6vphyn3pzyqzxzceyto623bfxv4srdsda/3142", + "opensea_url": "https://opensea.io/assets/ethereum/0x05ffbb90241d2f9596ebd352323695127aedca30/3142", + "updated_at": "2025-09-02T19:25:19.048728", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "53332", + "collection": "rarible", + "contract": "0xd07dc4262bcdbf85190c01c996b4c06a461d2430", + "token_standard": "erc1155", + "name": "Vitalik B.: The Programmer Supreme ", + "description": "Digitized masterpiece of the real crypto gang. ", + "image_url": "https://i2.seadn.io/ethereum/0xd07dc4262bcdbf85190c01c996b4c06a461d2430/3d8387010109aa17aed64892d4a74eda.gif", + "display_image_url": "https://i2.seadn.io/ethereum/0xd07dc4262bcdbf85190c01c996b4c06a461d2430/3d8387010109aa17aed64892d4a74eda.gif", + "display_animation_url": null, + "metadata_url": "https://opensea-private.mypinata.cloud/ipfs/QmYgT71pTuopuEkTUJKgkg2xFU59qPURzVFv7fFumnuBZd/", + "opensea_url": "https://opensea.io/assets/ethereum/0xd07dc4262bcdbf85190c01c996b4c06a461d2430/53332", + "updated_at": "2025-09-02T19:25:19.048168", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "0", + "collection": "mythics-of-the-multichain-ethereum", + "contract": "0x413f774bf08b3c2c9cd414c9e3739645502a5b19", + "token_standard": "erc721", + "name": "Slash the Lightbringer (Ethereum)", + "description": "Mythics of the Multichain is an experimental PFP collection destined to be deployed on every major blockchain and layer. On compatible chains, each Mythic PFP accrues Mythic Points between transfers, and royalties from Mythic sales are sent to the Mythic Treasury contract. These points can be converted into Mythic Treasure ($MYTH) on the Mythic Treasury contract. $MYTH can be HODLed or burnt in exchange for a portion of the pooled royalties. Refresh this token's metadata for the most up to date information, or check the contract on an explorer. Note: there is a 7 day cooldown period after claims and transfers where Mythic Points accrue but are not claimable.\n\nOfficial Deployments:\nEthereum - 0x413F774BF08b3c2c9cd414C9E3739645502A5B19\nOptimism - 0x413F774BF08b3c2c9cd414C9E3739645502A5B19\nBase - 0x413F774BF08b3c2c9cd414C9E3739645502A5B19\nArbitrum - 0x413F774BF08b3c2c9cd414C9E3739645502A5B19\nPolygon - 0x413F774BF08b3c2c9cd414C9E3739645502A5B19\nZora - 0x413F774BF08b3c2c9cd414C9E3739645502A5B19\n\nCooldown time remaining: 604800 seconds", + "image_url": "https://raw2.seadn.io/ethereum/0x413f774bf08b3c2c9cd414c9e3739645502a5b19/c1d73e30a6688ff137aba930eece83f4.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x413f774bf08b3c2c9cd414c9e3739645502a5b19/c1d73e30a6688ff137aba930eece83f4.svg", + "display_animation_url": null, + "metadata_url": "data:application/json;base64,eyJhcnRpc3QiOiAiTWF0dG8iLCAibmFtZSI6ICJTbGFzaCB0aGUgTGlnaHRicmluZ2VyIChFdGhlcmV1bSkiLCAiZGVzY3JpcHRpb24iOiAiTXl0aGljcyBvZiB0aGUgTXVsdGljaGFpbiBpcyBhbiBleHBlcmltZW50YWwgUEZQIGNvbGxlY3Rpb24gZGVzdGluZWQgdG8gYmUgZGVwbG95ZWQgb24gZXZlcnkgbWFqb3IgYmxvY2tjaGFpbiBhbmQgbGF5ZXIuIE9uIGNvbXBhdGlibGUgY2hhaW5zLCBlYWNoIE15dGhpYyBQRlAgYWNjcnVlcyBNeXRoaWMgUG9pbnRzIGJldHdlZW4gdHJhbnNmZXJzLCBhbmQgcm95YWx0aWVzIGZyb20gTXl0aGljIHNhbGVzIGFyZSBzZW50IHRvIHRoZSBNeXRoaWMgVHJlYXN1cnkgY29udHJhY3QuIFRoZXNlIHBvaW50cyBjYW4gYmUgY29udmVydGVkIGludG8gTXl0aGljIFRyZWFzdXJlICgkTVlUSCkgb24gdGhlIE15dGhpYyBUcmVhc3VyeSBjb250cmFjdC4gJE1ZVEggY2FuIGJlIEhPRExlZCBvciBidXJudCBpbiBleGNoYW5nZSBmb3IgYSBwb3J0aW9uIG9mIHRoZSBwb29sZWQgcm95YWx0aWVzLiBSZWZyZXNoIHRoaXMgdG9rZW4ncyBtZXRhZGF0YSBmb3IgdGhlIG1vc3QgdXAgdG8gZGF0ZSBpbmZvcm1hdGlvbiwgb3IgY2hlY2sgdGhlIGNvbnRyYWN0IG9uIGFuIGV4cGxvcmVyLiBOb3RlOiB0aGVyZSBpcyBhIDcgZGF5IGNvb2xkb3duIHBlcmlvZCBhZnRlciBjbGFpbXMgYW5kIHRyYW5zZmVycyB3aGVyZSBNeXRoaWMgUG9pbnRzIGFjY3J1ZSBidXQgYXJlIG5vdCBjbGFpbWFibGUuXG5cbk9mZmljaWFsIERlcGxveW1lbnRzOlxuRXRoZXJldW0gLSAweDQxM0Y3NzRCRjA4YjNjMmM5Y2Q0MTRDOUUzNzM5NjQ1NTAyQTVCMTlcbk9wdGltaXNtIC0gMHg0MTNGNzc0QkYwOGIzYzJjOWNkNDE0QzlFMzczOTY0NTUwMkE1QjE5XG5CYXNlIC0gMHg0MTNGNzc0QkYwOGIzYzJjOWNkNDE0QzlFMzczOTY0NTUwMkE1QjE5XG5BcmJpdHJ1bSAtIDB4NDEzRjc3NEJGMDhiM2MyYzljZDQxNEM5RTM3Mzk2NDU1MDJBNUIxOVxuUG9seWdvbiAtIDB4NDEzRjc3NEJGMDhiM2MyYzljZDQxNEM5RTM3Mzk2NDU1MDJBNUIxOVxuWm9yYSAtIDB4NDEzRjc3NEJGMDhiM2MyYzljZDQxNEM5RTM3Mzk2NDU1MDJBNUIxOVxuXG5Db29sZG93biB0aW1lIHJlbWFpbmluZzogNjA0ODAwIHNlY29uZHMiLCAiZXh0ZXJuYWxfdXJsIjogImh0dHBzOi8vbXl0aGljcy5hcnQiLCAiaW1hZ2UiOiAiZGF0YTppbWFnZS9zdmcreG1sO2Jhc2U2NCxQRDk0Yld3Z2RtVnljMmx2YmowaU1TNHdJaUJsYm1OdlpHbHVaejBpZFhSbUxUZ2lQejQ4YzNabklIaHRiRzV6UFNKb2RIUndPaTh2ZDNkM0xuY3pMbTl5Wnk4eU1EQXdMM04yWnlJZ2RtbGxkMEp2ZUQwaU1DQXdJREUyTURBZ01UWXdNQ0krUEdSbGMyTStUWGwwYUdsamN5QnZaaUIwYUdVZ1RYVnNkR2xqYUdGcGJpd2dZbmtnVFdGMGRHOHNJRlJ2YTJWdUlFbEVPaUF3TENCT1lXMWxPaUJUYkdGemFDQjBhR1VnVEdsbmFIUmljbWx1WjJWeUlDaEZkR2hsY21WMWJTa3NJRkJ5YjJwbFkzUWdWMlZpYzJsMFpUb2dhSFIwY0hNNkx5OXRlWFJvYVdOekxtRnlkRHd2WkdWell6NDhaR1ZtY3o0OFptbHNkR1Z5SUdsa1BTSmpiMnh2Y2xScGJuUWlJSGc5SWpBaUlIazlJakFpSUhkcFpIUm9QU0l4TURBbElpQm9aV2xuYUhROUlqRXdNQ1VpUGp4bVpVWnNiMjlrSUdac2IyOWtMV052Ykc5eVBTSWpNREF3TURBd0lpQnlaWE4xYkhROUltWnNiMjlrUm1sc2JDSXZQanhtWlVKc1pXNWtJR2x1UFNKVGIzVnlZMlZIY21Gd2FHbGpJaUJwYmpJOUltWnNiMjlrUm1sc2JDSWdiVzlrWlQwaWMyTnlaV1Z1SWk4K1BDOW1hV3gwWlhJK1BDOWtaV1p6UGp4bklHWnBiSFJsY2owaWRYSnNLQ05qYjJ4dmNsUnBiblFwSWo0OGNtVmpkQ0I0UFNJd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTWpBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTXpBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTkRBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTlRBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTmpBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpT0RBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpT1RBd0lpQjVQU0l3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRBd01DSWdlVDBpTUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU13TURBaUlITjBjbTlyWlQwaUl6QXdNQ0l2UGp4eVpXTjBJSGc5SWpFeE1EQWlJSGs5SWpBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqTURBd0lpQnpkSEp2YTJVOUlpTXdNREFpTHo0OGNtVmpkQ0I0UFNJeE1qQXdJaUI1UFNJd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UTXdNQ0lnZVQwaU1DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNd01EQWlJSE4wY205clpUMGlJekF3TUNJdlBqeHlaV04wSUhnOUlqRTBNREFpSUhrOUlqQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJak1EQXdJaUJ6ZEhKdmEyVTlJaU13TURBaUx6NDhjbVZqZENCNFBTSXhOVEF3SWlCNVBTSXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU1UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSXhNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJakV3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU13TURBaUlITjBjbTlyWlQwaUl6QXdNQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpTVRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSTJKaVlpSWdjM1J5YjJ0bFBTSWpZbUppSWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJeE1EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJak1EQXdJaUJ6ZEhKdmEyVTlJaU13TURBaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqRXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlNVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0l4TURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqWW1KaUlpQnpkSEp2YTJVOUlpTmlZbUlpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWpFd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNNE9EZ2lJSE4wY205clpUMGlJemc0T0NJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU1UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6ZzRPQ0lnYzNSeWIydGxQU0lqT0RnNElpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlNVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJemc0T0NJZ2MzUnliMnRsUFNJak9EZzRJaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpTVRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU1UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlNVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpTVRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU1UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU1qQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSXlNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJakl3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU5rWkdRaUlITjBjbTlyWlQwaUkyUmtaQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpTWpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSTJSa1pDSWdjM1J5YjJ0bFBTSWpaR1JrSWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJeU1EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJalpHUmtJaUJ6ZEhKdmEyVTlJaU5rWkdRaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqSXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlNakF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJMlJrWkNJZ2MzUnliMnRsUFNJalpHUmtJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0l5TURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqWkdSa0lpQnpkSEp2YTJVOUlpTmtaR1FpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWpJd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlOaVltSWlJSE4wY205clpUMGlJMkppWWlJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU1qQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUkySmlZaUlnYzNSeWIydGxQU0lqWW1KaUlpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlNakF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJemc0T0NJZ2MzUnliMnRsUFNJak9EZzRJaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpTWpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpnNE9DSWdjM1J5YjJ0bFBTSWpPRGc0SWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU1qQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlNakF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpTWpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU1qQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU16QXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSXpNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJak13TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU5rWkdRaUlITjBjbTlyWlQwaUkyUmtaQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpTXpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSTJabVppSWdjM1J5YjJ0bFBTSWpabVptSWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJek1EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJalpHUmtJaUJ6ZEhKdmEyVTlJaU5rWkdRaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqTXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlNekF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJMlJrWkNJZ2MzUnliMnRsUFNJalpHUmtJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0l6TURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqWkdSa0lpQnpkSEp2YTJVOUlpTmtaR1FpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWpNd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlOaVltSWlJSE4wY205clpUMGlJMkppWWlJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU16QXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUkySmlZaUlnYzNSeWIydGxQU0lqWW1KaUlpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlNekF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJMkppWWlJZ2MzUnliMnRsUFNJalltSmlJaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpTXpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpnNE9DSWdjM1J5YjJ0bFBTSWpPRGc0SWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU16QXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6YzNOeUlnYzNSeWIydGxQU0lqTnpjM0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlNekF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpTXpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU16QXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU5EQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSTBNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJalF3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU5rWkdRaUlITjBjbTlyWlQwaUkyUmtaQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpTkRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSTJabVppSWdjM1J5YjJ0bFBTSWpabVptSWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJME1EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJalpHUmtJaUJ6ZEhKdmEyVTlJaU5rWkdRaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqUXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlOREF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJMlJrWkNJZ2MzUnliMnRsUFNJalpHUmtJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0kwTURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqWW1KaUlpQnpkSEp2YTJVOUlpTmlZbUlpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWpRd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNd01EQWlJSE4wY205clpUMGlJekF3TUNJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU5EQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlOREF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpTkRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpjM055SWdjM1J5YjJ0bFBTSWpOemMzSWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU5EQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6YzNOeUlnYzNSeWIydGxQU0lqTnpjM0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlOREF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpTkRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU5EQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU5UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSTFNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJalV3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU13TURBaUlITjBjbTlyWlQwaUl6QXdNQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpTlRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSTJSa1pDSWdjM1J5YjJ0bFBTSWpaR1JrSWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJMU1EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJak1EQXdJaUJ6ZEhKdmEyVTlJaU13TURBaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqVXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlOVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJMkppWWlJZ2MzUnliMnRsUFNJalltSmlJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0kxTURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqTURBd0lpQnpkSEp2YTJVOUlpTXdNREFpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWpVd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlObVptWWlJSE4wY205clpUMGlJMlptWmlJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU5UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlOVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJMlptWmlJZ2MzUnliMnRsUFNJalptWm1JaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpTlRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU5UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6YzNOeUlnYzNSeWIydGxQU0lqTnpjM0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlOVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpTlRBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU5UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU5qQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSTJNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJall3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU13TURBaUlITjBjbTlyWlQwaUl6QXdNQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpTmpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpNek15SWdjM1J5YjJ0bFBTSWpNek16SWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJMk1EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJak1EQXdJaUJ6ZEhKdmEyVTlJaU13TURBaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqWXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlOakF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJemc0T0NJZ2MzUnliMnRsUFNJak9EZzRJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0kyTURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqT0RnNElpQnpkSEp2YTJVOUlpTTRPRGdpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWpZd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNd01EQWlJSE4wY205clpUMGlJekF3TUNJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU5qQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlOakF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpTmpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpjM055SWdjM1J5YjJ0bFBTSWpOemMzSWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU5qQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlOakF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpTmpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU5qQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU56QXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSTNNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJamN3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU13TURBaUlITjBjbTlyWlQwaUl6QXdNQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpTnpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpnNE9DSWdjM1J5YjJ0bFBTSWpPRGc0SWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJM01EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJak1EQXdJaUJ6ZEhKdmEyVTlJaU13TURBaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqY3dNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlOekF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0kzTURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqTnpjM0lpQnpkSEp2YTJVOUlpTTNOemNpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWpjd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNM056Y2lJSE4wY205clpUMGlJemMzTnlJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU56QXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6YzNOeUlnYzNSeWIydGxQU0lqTnpjM0lpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlOekF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJemMzTnlJZ2MzUnliMnRsUFNJak56YzNJaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpTnpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpjM055SWdjM1J5YjJ0bFBTSWpOemMzSWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU56QXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlOekF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpTnpBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU56QXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU9EQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSTRNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJamd3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU13TURBaUlITjBjbTlyWlQwaUl6QXdNQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpT0RBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJNE1EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJak1EQXdJaUJ6ZEhKdmEyVTlJaU13TURBaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqZ3dNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlPREF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJemMzTnlJZ2MzUnliMnRsUFNJak56YzNJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0k0TURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqT0RnNElpQnpkSEp2YTJVOUlpTTRPRGdpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWpnd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNM056Y2lJSE4wY205clpUMGlJemMzTnlJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU9EQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6YzNOeUlnYzNSeWIydGxQU0lqTnpjM0lpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlPREF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJemMzTnlJZ2MzUnliMnRsUFNJak56YzNJaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpT0RBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpnNE9DSWdjM1J5YjJ0bFBTSWpPRGc0SWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU9EQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlPREF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpT0RBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU9EQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU9UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVEF3SWlCNVBTSTVNREFpSUhkcFpIUm9QU0l4TURBaUlHaGxhV2RvZEQwaU1UQXdJaUJtYVd4c1BTSWpNREF3SWlCemRISnZhMlU5SWlNd01EQWlMejQ4Y21WamRDQjRQU0l5TURBaUlIazlJamt3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU5rWkdRaUlITjBjbTlyWlQwaUkyUmtaQ0l2UGp4eVpXTjBJSGc5SWpNd01DSWdlVDBpT1RBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSTJSa1pDSWdjM1J5YjJ0bFBTSWpaR1JrSWk4K1BISmxZM1FnZUQwaU5EQXdJaUI1UFNJNU1EQWlJSGRwWkhSb1BTSXhNREFpSUdobGFXZG9kRDBpTVRBd0lpQm1hV3hzUFNJak1EQXdJaUJ6ZEhKdmEyVTlJaU13TURBaUx6NDhjbVZqZENCNFBTSTFNREFpSUhrOUlqa3dNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJall3TUNJZ2VUMGlPVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJemMzTnlJZ2MzUnliMnRsUFNJak56YzNJaTgrUEhKbFkzUWdlRDBpTnpBd0lpQjVQU0k1TURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqT0RnNElpQnpkSEp2YTJVOUlpTTRPRGdpTHo0OGNtVmpkQ0I0UFNJNE1EQWlJSGs5SWprd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNNE9EZ2lJSE4wY205clpUMGlJemc0T0NJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU9UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6ZzRPQ0lnYzNSeWIydGxQU0lqT0RnNElpOCtQSEpsWTNRZ2VEMGlNVEF3TUNJZ2VUMGlPVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJemc0T0NJZ2MzUnliMnRsUFNJak9EZzRJaTgrUEhKbFkzUWdlRDBpTVRFd01DSWdlVDBpT1RBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpjM055SWdjM1J5YjJ0bFBTSWpOemMzSWk4K1BISmxZM1FnZUQwaU1USXdNQ0lnZVQwaU9UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNVE13TUNJZ2VUMGlPVEF3SWlCM2FXUjBhRDBpTVRBd0lpQm9aV2xuYUhROUlqRXdNQ0lnWm1sc2JEMGlJekF3TUNJZ2MzUnliMnRsUFNJak1EQXdJaTgrUEhKbFkzUWdlRDBpTVRRd01DSWdlVDBpT1RBd0lpQjNhV1IwYUQwaU1UQXdJaUJvWldsbmFIUTlJakV3TUNJZ1ptbHNiRDBpSXpBd01DSWdjM1J5YjJ0bFBTSWpNREF3SWk4K1BISmxZM1FnZUQwaU1UVXdNQ0lnZVQwaU9UQXdJaUIzYVdSMGFEMGlNVEF3SWlCb1pXbG5hSFE5SWpFd01DSWdabWxzYkQwaUl6QXdNQ0lnYzNSeWIydGxQU0lqTURBd0lpOCtQSEpsWTNRZ2VEMGlNQ0lnZVQwaU1UQXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJakV3TUNJZ2VUMGlNVEF3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU13TURBaUlITjBjbTlyWlQwaUl6QXdNQ0l2UGp4eVpXTjBJSGc5SWpJd01DSWdlVDBpTVRBd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlOa1pHUWlJSE4wY205clpUMGlJMlJrWkNJdlBqeHlaV04wSUhnOUlqTXdNQ0lnZVQwaU1UQXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTmtaR1FpSUhOMGNtOXJaVDBpSTJSa1pDSXZQanh5WldOMElIZzlJalF3TUNJZ2VUMGlNVEF3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU13TURBaUlITjBjbTlyWlQwaUl6QXdNQ0l2UGp4eVpXTjBJSGc5SWpVd01DSWdlVDBpTVRBd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNd01EQWlJSE4wY205clpUMGlJekF3TUNJdlBqeHlaV04wSUhnOUlqWXdNQ0lnZVQwaU1UQXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQanh5WldOMElIZzlJamN3TUNJZ2VUMGlNVEF3TUNJZ2QybGtkR2c5SWpFd01DSWdhR1ZwWjJoMFBTSXhNREFpSUdacGJHdzlJaU00T0RnaUlITjBjbTlyWlQwaUl6ZzRPQ0l2UGp4eVpXTjBJSGc5SWpnd01DSWdlVDBpTVRBd01DSWdkMmxrZEdnOUlqRXdNQ0lnYUdWcFoyaDBQU0l4TURBaUlHWnBiR3c5SWlNNE9EZ2lJSE4wY205clpUMGlJemc0T0NJdlBqeHlaV04wSUhnOUlqa3dNQ0lnZVQwaU1UQXdNQ0lnZDJsa2RHZzlJakV3TUNJZ2FHVnBaMmgwUFNJeE1EQWlJR1pwYkd3OUlpTTRPRGdpSUhOMGNtOXJaVDBpSXpnNE9DSXZQanh5WldOMElIZzlJakV3TURBaUlIazlJakV3TURBaUlIZHBaSFJvUFNJeE1EQWlJR2hsYVdkb2REMGlNVEF3SWlCbWFXeHNQU0lqT0RnNElpQnpkSEp2YTJVOUlpTTRPRGdpTHo", + "opensea_url": "https://opensea.io/assets/ethereum/0x413f774bf08b3c2c9cd414c9e3739645502a5b19/0", + "updated_at": "2025-09-02T19:25:19.042233", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "336", + "collection": "metadragonz", + "contract": "0x2e543d056da8f1e4bc7b01c0320e64fc5d656e97", + "token_standard": "erc721", + "name": "Qitess", + "description": "Qitess is one of the primal dragons born from Ymnala, the creator of earth. A great Fire Dragon that is seen as one of the most powerful and feared of all on earth. Her molten fire breath can melt anything known to man and is known to spread fire and death wherever she resides. Qitess can draw extraordinary power from heat and her lair is situated in a molten sea of lava somewhere unbeknown to man.", + "image_url": "https://i2.seadn.io/ethereum/0x2e543d056da8f1e4bc7b01c0320e64fc5d656e97/65630b774318a5ec6a60c5c21091d97c.gif", + "display_image_url": "https://i2.seadn.io/ethereum/0x2e543d056da8f1e4bc7b01c0320e64fc5d656e97/65630b774318a5ec6a60c5c21091d97c.gif", + "display_animation_url": "https://metadragonz.b-cdn.net/animations/b1/24AB110S204/", + "metadata_url": "https://metadragonz.b-cdn.net/QmX2yvKAiscVF3HPLvF8RM9UCmW9Q2Y52yTP9AtcmfTpvm/336.json", + "opensea_url": "https://opensea.io/assets/ethereum/0x2e543d056da8f1e4bc7b01c0320e64fc5d656e97/336", + "updated_at": "2025-09-02T19:25:19.048672", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "2131", + "collection": "toadrunnerz", + "contract": "0x1e038a99aac19162633dcc4d215e5a27e6eb0355", + "token_standard": "erc721", + "name": "Racing", + "description": "ArcadeNFT x CrypToadz by GREMPLIN\n\nTo start the game, click anywhere on the NFT and use the W-A-S-D keys to move your Toad.\n\nFor the best playing experience, visit our website.\n\nStory:\nWhen the Evil King Gremplin imprisoned all the CrypToadz, he exiled their leader Colonel Floorbin to Gooch Island. While the Cryptoadz were freed during minting, but their leader remains in exile. Now that the Toadz are free, you must guide them as they cross deserts, race over highways, and traverse space and sea to find and rescue their exiled leader. Good luck!\n\nArcade NFT - a disruptive 3D art and gaming studio. We produce unique NFTs in collaboration with artists, brands & crypto protocols. This collection merges classic games with forward-thinking design, giving the past a second life. Each NFT will provide its’ viewer with the inventive possibility to play the game through the NFT display interface.\n\nJoin the Retro gaming NFT revolution - www.arcadenfts.com", + "image_url": "https://i2.seadn.io/ethereum/0x1e038a99aac19162633dcc4d215e5a27e6eb0355/cb03ba0fb8279ae596da67c4937a99/d7cb03ba0fb8279ae596da67c4937a99.gif", + "display_image_url": "https://i2.seadn.io/ethereum/0x1e038a99aac19162633dcc4d215e5a27e6eb0355/cb03ba0fb8279ae596da67c4937a99/d7cb03ba0fb8279ae596da67c4937a99.gif", + "display_animation_url": "https://ipfs2.seadn.io/ipfs/bafybeibe2jzuumpwqk3yedvipbznlsiwmcb5dq7b67rcbnmw5avpipa5pa/", + "metadata_url": "https://arcadetoads.mypinata.cloud/ipfs/QmW9xBP7hSeCRPgecKaHWCn7PPC9VQoz4WMWe7a5KECKqM/2131.json", + "opensea_url": "https://opensea.io/assets/ethereum/0x1e038a99aac19162633dcc4d215e5a27e6eb0355/2131", + "updated_at": "2025-09-02T19:25:19.047481", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "3393", + "collection": "party-polar-bears", + "contract": "0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274", + "token_standard": "erc721", + "name": "Party Polar Bear #3393", + "description": "Party Polar Bears is an NFT collection of 6,060 unique polar bears. They’ve joined their Penguin friends and the party is still just getting started! Visit [www.partypenguins.club/party-polar-bears](https://partypenguins.club/party-polar-bears) to learn more.", + "image_url": "https://i2.seadn.io/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/952dac45282f7d185119f0f33c3dfb90.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/952dac45282f7d185119f0f33c3dfb90.png", + "display_animation_url": null, + "metadata_url": "https://partypenguins.club/api/polarbear/3393", + "opensea_url": "https://opensea.io/assets/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/3393", + "updated_at": "2025-09-02T19:25:19.043723", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "2441", + "collection": "party-polar-bears", + "contract": "0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274", + "token_standard": "erc721", + "name": "Party Polar Bear #2441", + "description": "Party Polar Bears is an NFT collection of 6,060 unique polar bears. They’ve joined their Penguin friends and the party is still just getting started! Visit [www.partypenguins.club/party-polar-bears](https://partypenguins.club/party-polar-bears) to learn more.", + "image_url": "https://i2.seadn.io/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/2e3eb075e445cba3b551b9c72708bab0.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/2e3eb075e445cba3b551b9c72708bab0.png", + "display_animation_url": null, + "metadata_url": "https://partypenguins.club/api/polarbear/2441", + "opensea_url": "https://opensea.io/assets/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/2441", + "updated_at": "2025-09-02T19:25:19.043645", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "148", + "collection": "party-polar-bears", + "contract": "0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274", + "token_standard": "erc721", + "name": "Party Polar Bear #148", + "description": "Party Polar Bears is an NFT collection of 6,060 unique polar bears. They’ve joined their Penguin friends and the party is still just getting started! Visit [www.partypenguins.club/party-polar-bears](https://partypenguins.club/party-polar-bears) to learn more.", + "image_url": "https://i2.seadn.io/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/ed302b2dabf615ec662e8cf7a8cbaa4e.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/ed302b2dabf615ec662e8cf7a8cbaa4e.png", + "display_animation_url": null, + "metadata_url": "https://partypenguins.club/api/polarbear/148", + "opensea_url": "https://opensea.io/assets/ethereum/0x2b00ef8f7545dba5b6ac39c0737eeb89a4c9e274/148", + "updated_at": "2025-09-02T19:25:19.043538", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "497", + "collection": "the-degenaissance", + "contract": "0x5f5541c618e76ab98361cdb10c67d1de28740cc3", + "token_standard": "erc721", + "name": "The Degenaissance", + "description": "A revolution is here.\nOne you are an essential part of.\nOur culture is changing.\nWe can all feel it.\nNow is the time to lead that change.\n\nToday...\n\nWe announce that 50 billion $LIT has been bequeathed to the $LIT community to fund and administer a Cultural DAO.\nDesigned to lead the arts & entertainment into the future.\nWith the world's first cultural currency.\n\n*Degenaissance by rata_yonqui*", + "image_url": "https://i2.seadn.io/ethereum/0x5f5541c618e76ab98361cdb10c67d1de28740cc3/38bb8a5cbb9e182be59ccf5106d778e7.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x5f5541c618e76ab98361cdb10c67d1de28740cc3/38bb8a5cbb9e182be59ccf5106d778e7.png", + "display_animation_url": null, + "metadata_url": "https://opensea-private.mypinata.cloud/ipfs/QmdPQRVCDvDjapV6SNGMxNbp31NeNwneM54Y861MGjTYMR/", + "opensea_url": "https://opensea.io/assets/ethereum/0x5f5541c618e76ab98361cdb10c67d1de28740cc3/497", + "updated_at": "2025-09-02T19:25:19.047974", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "28", + "collection": "eth-chan-daily", + "contract": "0xef3291bedf3890f413bb4a4aff98064aa9dc55ff", + "token_standard": "erc1155", + "name": "Eth-chan day thirty-five", + "description": "Day thirty-five of drawing eth chan until 10k\n\nSpecial eth chan raise event in occasion of upcoming Roman Storm's trial next monday. \nI will be donating the proceeds from today's OE sales to Roman Storm Legal defense fund juicebox. \nEth chan justice, love army. Privacy is normal and writing code is not a crime <3 ", + "image_url": "https://i2.seadn.io/ethereum/0xef3291bedf3890f413bb4a4aff98064aa9dc55ff/5b0cdd5b3df0ae974764664e7fbb44/4e5b0cdd5b3df0ae974764664e7fbb44.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xef3291bedf3890f413bb4a4aff98064aa9dc55ff/5b0cdd5b3df0ae974764664e7fbb44/4e5b0cdd5b3df0ae974764664e7fbb44.png", + "display_animation_url": null, + "metadata_url": "https://arweave.net/bGIFa9r4pufUjMhg4ydzjxcR522umfENNQM-OZSQKcA/", + "opensea_url": "https://opensea.io/assets/ethereum/0xef3291bedf3890f413bb4a4aff98064aa9dc55ff/28", + "updated_at": "2025-09-02T19:25:19.042916", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "56", + "collection": "internetspirits", + "contract": "0x0b8afc24682e49a3704ea5776606ae16d8f2a20d", + "token_standard": "erc721", + "name": "Internet Spirits #57", + "description": "INTERNET SPIRITS LIVE FOREVER", + "image_url": "https://i2.seadn.io/ethereum/0x0b8afc24682e49a3704ea5776606ae16d8f2a20d/44fc6eed7b5d3af34a2760b999170b/6a44fc6eed7b5d3af34a2760b999170b.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x0b8afc24682e49a3704ea5776606ae16d8f2a20d/44fc6eed7b5d3af34a2760b999170b/6a44fc6eed7b5d3af34a2760b999170b.png", + "display_animation_url": null, + "metadata_url": "ipfs://QmPtqWq2BoHRnoxNmVr7S9G3kondUwVfeaJ9afy5MU6oPq/56", + "opensea_url": "https://opensea.io/assets/ethereum/0x0b8afc24682e49a3704ea5776606ae16d8f2a20d/56", + "updated_at": "2025-09-02T19:25:19.042336", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1", + "collection": "17-jan-25-classical-rug", + "contract": "0x1fb2edd77323aa3640b153eca7d76692bb1d6c15", + "token_standard": "erc1155", + "name": "17 January 25 – Buy, Buy, Buy!", + "description": "They said it was a great opportunity... and it was – for me! It's MY HISTORY! Trust me. (c)", + "image_url": "https://i2.seadn.io/ethereum/0x1fb2edd77323aa3640b153eca7d76692bb1d6c15/33d07e9ea3b9838e55ef1f1cbad80c/5133d07e9ea3b9838e55ef1f1cbad80c.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x1fb2edd77323aa3640b153eca7d76692bb1d6c15/33d07e9ea3b9838e55ef1f1cbad80c/5133d07e9ea3b9838e55ef1f1cbad80c.png", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeido72lghvpuvjnk2qr6fef5hdm5so4bkma3nfn6quuypcqmj52v4a/1", + "opensea_url": "https://opensea.io/assets/ethereum/0x1fb2edd77323aa3640b153eca7d76692bb1d6c15/1", + "updated_at": "2025-09-02T19:25:19.049063", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "5171772707277143938465356394285512428265642042561088929118464644520024504672", + "collection": "ens", + "contract": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "token_standard": "erc1155", + "name": "$tmnt.eth", + "description": "$tmnt.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/02a8ab8aa48a9ca20a07e6676d3646/e802a8ab8aa48a9ca20a07e6676d3646.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/02a8ab8aa48a9ca20a07e6676d3646/e802a8ab8aa48a9ca20a07e6676d3646.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/5171772707277143938465356394285512428265642042561088929118464644520024504672", + "opensea_url": "https://opensea.io/assets/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/5171772707277143938465356394285512428265642042561088929118464644520024504672", + "updated_at": "2025-09-02T19:25:19.047803", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "38555349915997071952505657663994956094608149987657223004232531459609221661952", + "collection": "ens", + "contract": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "token_standard": "erc1155", + "name": "agenticpsyments.eth", + "description": "agenticpsyments.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/bf33f37c05356d6e9ffbd4f73e4731/2fbf33f37c05356d6e9ffbd4f73e4731.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/bf33f37c05356d6e9ffbd4f73e4731/2fbf33f37c05356d6e9ffbd4f73e4731.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/38555349915997071952505657663994956094608149987657223004232531459609221661952", + "opensea_url": "https://opensea.io/assets/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/38555349915997071952505657663994956094608149987657223004232531459609221661952", + "updated_at": "2025-09-02T19:25:19.047419", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1111", + "collection": "canvas-163", + "contract": "0x32f279f94918f5eca6a75fb0f0a597d91319a111", + "token_standard": "erc1155", + "name": "CANVAS", + "description": "CANVAS\nMultichain Editable Media First NFT Collection\n\nInstead of the old standard artNFT collections, where rarity pre-set by collection creator, with meNFT CANVAS you can:\n1. Claim 50%+1 parts of ID and \"paint\"* it as you want, create unique ID\n& after that you can:\n2. \"Sell\"** remaining 50%-1 parts via Follow button to your Followers\n\nUsing this new Media Web4 standard Allmint, you can publish NFTs in the form of Content (GroupChat, News, Video, etc.) and monetize them through donations such as: Likes, CustomLikes**, Comments\\Reply, CustomComments** on your ID in Social Exchange Economy\n\n* Limited to 2 editable changes, subject to approval by the Creator Collection (rules: no illegal content).\n**Set your own price", + "image_url": "https://i2.seadn.io/base/0x32f279f94918f5eca6a75fb0f0a597d91319a111/7db55b8ca6f6a2af3b4204ab2ba253/037db55b8ca6f6a2af3b4204ab2ba253.png", + "display_image_url": "https://i2.seadn.io/base/0x32f279f94918f5eca6a75fb0f0a597d91319a111/7db55b8ca6f6a2af3b4204ab2ba253/037db55b8ca6f6a2af3b4204ab2ba253.png", + "display_animation_url": null, + "metadata_url": "ipfs://QmSidBeqzbAdE65sSGwEbvnmEZCnLSmLyuX8F6Nau6fAaG", + "opensea_url": "https://opensea.io/assets/ethereum/0x32f279f94918f5eca6a75fb0f0a597d91319a111/1111", + "updated_at": "2025-09-02T19:25:19.048077", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "5867954843562181473074471286518668608066670271534576009032654000184559370320", + "collection": "ens", + "contract": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "token_standard": "erc1155", + "name": "argotcollective.eth", + "description": "argotcollective.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/cd3215fdcca0d2658e8cd81e7afb7c/3ccd3215fdcca0d2658e8cd81e7afb7c.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/cd3215fdcca0d2658e8cd81e7afb7c/3ccd3215fdcca0d2658e8cd81e7afb7c.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/5867954843562181473074471286518668608066670271534576009032654000184559370320", + "opensea_url": "https://opensea.io/assets/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/5867954843562181473074471286518668608066670271534576009032654000184559370320", + "updated_at": "2025-09-02T19:25:19.047705", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "45835309184281846165206506519738530626224806028168763942621363733894680090250", + "collection": "ens", + "contract": "0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401", + "token_standard": "erc1155", + "name": "vitalik.bankrbot.eth", + "description": "vitalik.bankrbot.eth, an ENS name. [ ⚠️ ATTENTION: THE NFT FOR THIS NAME CAN BE REVOKED AT ANY TIME WHILE IT IS IN THE WRAPPED STATE ]", + "image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/271e52270d6a19c9d703b998638560/f4271e52270d6a19c9d703b998638560.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/271e52270d6a19c9d703b998638560/f4271e52270d6a19c9d703b998638560.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/45835309184281846165206506519738530626224806028168763942621363733894680090250", + "opensea_url": "https://opensea.io/assets/ethereum/0xd4416b13d2b3a9abae7acd5d6c2bbdbe25686401/45835309184281846165206506519738530626224806028168763942621363733894680090250", + "updated_at": "2025-09-02T19:25:19.047660", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "56676808776084849312921453203194454214931518365048844375578840363623683981313", + "collection": "joe-s-apes", + "contract": "0x495f947276749ce646f68ac8c248420045cb7b5e", + "token_standard": "erc1155", + "name": "216", + "description": "A special collection of 1000 NFTS gang apes , 40 of which are exclusive. 1000 gang apes who live on the ethereum network and who are waiting for you.", + "image_url": "https://i2.seadn.io/ethereum/0x495f947276749ce646f68ac8c248420045cb7b5e/2cdef13005a94e0d5f18781e7b320c/0a2cdef13005a94e0d5f18781e7b320c.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x495f947276749ce646f68ac8c248420045cb7b5e/2cdef13005a94e0d5f18781e7b320c/0a2cdef13005a94e0d5f18781e7b320c.png", + "display_animation_url": null, + "metadata_url": null, + "opensea_url": "https://opensea.io/assets/ethereum/0x495f947276749ce646f68ac8c248420045cb7b5e/56676808776084849312921453203194454214931518365048844375578840363623683981313", + "updated_at": "2025-09-02T19:25:19.045117", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "8", + "collection": "frogee-flee", + "contract": "0xe66e9857a3ab8b8c1286b4e5c60bd96f6099ea6b", + "token_standard": "erc1155", + "name": "Frogee & Flee #008", + "description": "\"Frogee & Flee #008\" features striking Frogee with large, curious eyes, captured in a peaceful moment atop a lush green surface. The pixelated background, rich with dark greens and subtle textures, evokes a dense, natural setting - perhaps a forest floor or jungle glade. Frogee's best friend Flee hovers nearby, adding a playful and dynamic element to the scene. This NFT blends retro pixel art charm with vibrant character design, perfect for collectors who love bold colors and whimsical storytelling.", + "image_url": "https://i2.seadn.io/ethereum/0xe66e9857a3ab8b8c1286b4e5c60bd96f6099ea6b/f1ee062633effd0d87c0c02db2ba35/67f1ee062633effd0d87c0c02db2ba35.gif", + "display_image_url": "https://i2.seadn.io/ethereum/0xe66e9857a3ab8b8c1286b4e5c60bd96f6099ea6b/f1ee062633effd0d87c0c02db2ba35/67f1ee062633effd0d87c0c02db2ba35.gif", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeiamfvgj4kk7xkvod2ybf7ac3it5mltgo3bhxxxhfhiwid6nftuuyu/8", + "opensea_url": "https://opensea.io/assets/ethereum/0xe66e9857a3ab8b8c1286b4e5c60bd96f6099ea6b/8", + "updated_at": "2025-09-02T19:25:19.048940", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "2", + "collection": "ucpiethofficial", + "contract": "0x56e272c0d1b37b677130b915f410f5447142f538", + "token_standard": "erc721", + "name": "vitalik@eth", + "description": "Unified Crypto Payments Identity", + "image_url": "https://raw2.seadn.io/ethereum/0x56e272c0d1b37b677130b915f410f5447142f538/48a2c26e333286d2fdf91c62ca8ea3/a448a2c26e333286d2fdf91c62ca8ea3.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x56e272c0d1b37b677130b915f410f5447142f538/48a2c26e333286d2fdf91c62ca8ea3/a448a2c26e333286d2fdf91c62ca8ea3.svg", + "display_animation_url": null, + "metadata_url": null, + "opensea_url": "https://opensea.io/assets/ethereum/0x56e272c0d1b37b677130b915f410f5447142f538/2", + "updated_at": "2025-09-02T19:25:19.043597", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "748", + "collection": "moodymelons", + "contract": "0xba162d0bc3b06250c550102208a52ac4ac58ffb3", + "token_standard": "erc721", + "name": "Moody Melons #748", + "description": "Welcome to the Moody Melons collection, where 10,000 uniquely generated avatars await to steal your heart with their overwhelming cuteness! Each Moody Melon is crafted with a distinct personality and charm, ensuring no two are alike. From joyful smiles to thoughtful gazes, these avatars make the perfect addition to any digital collection. Get ready to meet your new adorable companions in the world of Moody Melons!", + "image_url": "https://i2.seadn.io/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/dd2db893494519d1150eaf6fd33b5d/58dd2db893494519d1150eaf6fd33b5d.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/dd2db893494519d1150eaf6fd33b5d/58dd2db893494519d1150eaf6fd33b5d.png", + "display_animation_url": null, + "metadata_url": "https://moody-melons.s3.us-west-2.amazonaws.com/metadata-302dbafd-1f65-4e07-b810-0a0a673c3b31/json/748.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/748", + "updated_at": "2025-09-02T19:25:19.048458", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "750", + "collection": "moodymelons", + "contract": "0xba162d0bc3b06250c550102208a52ac4ac58ffb3", + "token_standard": "erc721", + "name": "Moody Melons #750", + "description": "Welcome to the Moody Melons collection, where 10,000 uniquely generated avatars await to steal your heart with their overwhelming cuteness! Each Moody Melon is crafted with a distinct personality and charm, ensuring no two are alike. From joyful smiles to thoughtful gazes, these avatars make the perfect addition to any digital collection. Get ready to meet your new adorable companions in the world of Moody Melons!", + "image_url": "https://i2.seadn.io/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/ac6b7fca46a57850026d203d076497/beac6b7fca46a57850026d203d076497.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/ac6b7fca46a57850026d203d076497/beac6b7fca46a57850026d203d076497.png", + "display_animation_url": null, + "metadata_url": "https://moody-melons.s3.us-west-2.amazonaws.com/metadata-302dbafd-1f65-4e07-b810-0a0a673c3b31/json/750.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/750", + "updated_at": "2025-09-02T19:25:19.048320", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "749", + "collection": "moodymelons", + "contract": "0xba162d0bc3b06250c550102208a52ac4ac58ffb3", + "token_standard": "erc721", + "name": "Moody Melons #749", + "description": "Welcome to the Moody Melons collection, where 10,000 uniquely generated avatars await to steal your heart with their overwhelming cuteness! Each Moody Melon is crafted with a distinct personality and charm, ensuring no two are alike. From joyful smiles to thoughtful gazes, these avatars make the perfect addition to any digital collection. Get ready to meet your new adorable companions in the world of Moody Melons!", + "image_url": "https://i2.seadn.io/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/17d33ed39130c990197aa03af3cf00/9317d33ed39130c990197aa03af3cf00.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/17d33ed39130c990197aa03af3cf00/9317d33ed39130c990197aa03af3cf00.png", + "display_animation_url": null, + "metadata_url": "https://moody-melons.s3.us-west-2.amazonaws.com/metadata-302dbafd-1f65-4e07-b810-0a0a673c3b31/json/749.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/749", + "updated_at": "2025-09-02T19:25:19.048051", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "751", + "collection": "moodymelons", + "contract": "0xba162d0bc3b06250c550102208a52ac4ac58ffb3", + "token_standard": "erc721", + "name": "Moody Melons #751", + "description": "Welcome to the Moody Melons collection, where 10,000 uniquely generated avatars await to steal your heart with their overwhelming cuteness! Each Moody Melon is crafted with a distinct personality and charm, ensuring no two are alike. From joyful smiles to thoughtful gazes, these avatars make the perfect addition to any digital collection. Get ready to meet your new adorable companions in the world of Moody Melons!", + "image_url": "https://i2.seadn.io/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/bd5057306ada80b24938aad47e833b/45bd5057306ada80b24938aad47e833b.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/bd5057306ada80b24938aad47e833b/45bd5057306ada80b24938aad47e833b.png", + "display_animation_url": null, + "metadata_url": "https://moody-melons.s3.us-west-2.amazonaws.com/metadata-302dbafd-1f65-4e07-b810-0a0a673c3b31/json/751.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xba162d0bc3b06250c550102208a52ac4ac58ffb3/751", + "updated_at": "2025-09-02T19:25:19.047932", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "14", + "collection": "bitcoin-pizza-day-limited-edition-2", + "contract": "0xdc1089182978d83ba447233e9624fc9f13679bda", + "token_standard": "erc721", + "name": "Bitcoin Pizza Day limited edition", + "description": "May 22, 2010:\nKnown as”Bitcoin Pizza Day,” a programmer named Laszlo Hanyecz completed the first real-world transaction by purchasing two pizzas for 10,000 BTC.", + "image_url": "https://i2.seadn.io/ethereum/0xdc1089182978d83ba447233e9624fc9f13679bda/37c5b667eef59a41314e24d2c5a0ee/6c37c5b667eef59a41314e24d2c5a0ee.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0xdc1089182978d83ba447233e9624fc9f13679bda/37c5b667eef59a41314e24d2c5a0ee/6c37c5b667eef59a41314e24d2c5a0ee.jpeg", + "display_animation_url": null, + "metadata_url": "ipfs://bafkreiam7k7hbwd26teq2h5lr7d6s7h5ufjxpvjztbga633dshyja7ypje", + "opensea_url": "https://opensea.io/assets/ethereum/0xdc1089182978d83ba447233e9624fc9f13679bda/14", + "updated_at": "2025-09-02T19:25:19.043861", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "19088840088169429423961250207825929538443100004698748160656855377082994349034", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "1867000000000.eth", + "description": "1867000000000.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/4a4c60e5be529290f3beadf9201819/ee4a4c60e5be529290f3beadf9201819.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/4a4c60e5be529290f3beadf9201819/ee4a4c60e5be529290f3beadf9201819.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/19088840088169429423961250207825929538443100004698748160656855377082994349034", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/19088840088169429423961250207825929538443100004698748160656855377082994349034", + "updated_at": "2025-09-02T19:25:19.047124", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "99258736707026034876228462085604239021758797390522818561548033694878081408849", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "186700000.eth", + "description": "186700000.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/aba636030bf9ca27b0863c300510d2/61aba636030bf9ca27b0863c300510d2.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/aba636030bf9ca27b0863c300510d2/61aba636030bf9ca27b0863c300510d2.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/99258736707026034876228462085604239021758797390522818561548033694878081408849", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/99258736707026034876228462085604239021758797390522818561548033694878081408849", + "updated_at": "2025-09-02T19:25:19.046167", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "48395596242948380028349866559808752468709811379259901318613588716482625833263", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "18-67.eth", + "description": "18-67.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/c6f2bf37b0de069b1d0b9b9f0ea8f3/96c6f2bf37b0de069b1d0b9b9f0ea8f3.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/c6f2bf37b0de069b1d0b9b9f0ea8f3/96c6f2bf37b0de069b1d0b9b9f0ea8f3.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/48395596242948380028349866559808752468709811379259901318613588716482625833263", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/48395596242948380028349866559808752468709811379259901318613588716482625833263", + "updated_at": "2025-09-02T19:25:19.045519", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "92323594748518873038655986651367906923278196051370300399013999192046734239561", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "1867000000.eth", + "description": "1867000000.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/fec458cb8fb232636db71a7a6af4f4/5efec458cb8fb232636db71a7a6af4f4.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/fec458cb8fb232636db71a7a6af4f4/5efec458cb8fb232636db71a7a6af4f4.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/92323594748518873038655986651367906923278196051370300399013999192046734239561", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/92323594748518873038655986651367906923278196051370300399013999192046734239561", + "updated_at": "2025-09-02T19:25:19.044902", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "29680961943887315554306346415336257344775728283600378360535186455408735357308", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "11886677.eth", + "description": "11886677.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/ebf84d2f155f3260db85c25c081b0d/d6ebf84d2f155f3260db85c25c081b0d.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/ebf84d2f155f3260db85c25c081b0d/d6ebf84d2f155f3260db85c25c081b0d.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/29680961943887315554306346415336257344775728283600378360535186455408735357308", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/29680961943887315554306346415336257344775728283600378360535186455408735357308", + "updated_at": "2025-09-02T19:25:19.044848", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "53605054030129315593153243884156435135111154515755964087073167712065766640568", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "18670000000.eth", + "description": "18670000000.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/29c8ae4827be1d0c8d784652af29a0/bb29c8ae4827be1d0c8d784652af29a0.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/29c8ae4827be1d0c8d784652af29a0/bb29c8ae4827be1d0c8d784652af29a0.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/53605054030129315593153243884156435135111154515755964087073167712065766640568", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/53605054030129315593153243884156435135111154515755964087073167712065766640568", + "updated_at": "2025-09-02T19:25:19.044526", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "52887306957244631584880723072171701059311184909789445624531333031035583557393", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "18670000.eth", + "description": "18670000.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/36d75a59ab8ef69a5586bb6ed0265f/6436d75a59ab8ef69a5586bb6ed0265f.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/36d75a59ab8ef69a5586bb6ed0265f/6436d75a59ab8ef69a5586bb6ed0265f.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52887306957244631584880723072171701059311184909789445624531333031035583557393", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52887306957244631584880723072171701059311184909789445624531333031035583557393", + "updated_at": "2025-09-02T19:25:19.044233", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "23041161661141031809555234143090578314904313155899848932067181877935116743532", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "1867000.eth", + "description": "1867000.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/9aa89d613cb7075e6d5253f5b1b8a2/689aa89d613cb7075e6d5253f5b1b8a2.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/9aa89d613cb7075e6d5253f5b1b8a2/689aa89d613cb7075e6d5253f5b1b8a2.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/23041161661141031809555234143090578314904313155899848932067181877935116743532", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/23041161661141031809555234143090578314904313155899848932067181877935116743532", + "updated_at": "2025-09-02T19:25:19.044182", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "44398263572017982926433840183192913262748050930773259785690388920829194304384", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "1-8-6-7.eth", + "description": "1-8-6-7.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/6ff2fd09759bfe3d2a6e1cc023ced4/796ff2fd09759bfe3d2a6e1cc023ced4.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/6ff2fd09759bfe3d2a6e1cc023ced4/796ff2fd09759bfe3d2a6e1cc023ced4.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/44398263572017982926433840183192913262748050930773259785690388920829194304384", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/44398263572017982926433840183192913262748050930773259785690388920829194304384", + "updated_at": "2025-09-02T19:25:19.044029", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "105086993081432592277800220436187944584912662162190849371791699691149166938668", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "186700000000.eth", + "description": "186700000000.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/961f0c6f04340fe6dfc117594d46fa/f6961f0c6f04340fe6dfc117594d46fa.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/961f0c6f04340fe6dfc117594d46fa/f6961f0c6f04340fe6dfc117594d46fa.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/105086993081432592277800220436187944584912662162190849371791699691149166938668", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/105086993081432592277800220436187944584912662162190849371791699691149166938668", + "updated_at": "2025-09-02T19:25:19.043969", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "107239383477093139706917401769082373757628069105030420472001473338733011165596", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "1️⃣3️⃣0️⃣5️⃣.eth ⚠", + "description": "1️⃣3️⃣0️⃣5️⃣.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/bb4b47355e92cb5ea625c022205ada/39bb4b47355e92cb5ea625c022205ada.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/bb4b47355e92cb5ea625c022205ada/39bb4b47355e92cb5ea625c022205ada.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/107239383477093139706917401769082373757628069105030420472001473338733011165596", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/107239383477093139706917401769082373757628069105030420472001473338733011165596", + "updated_at": "2025-09-02T19:25:19.043832", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "488", + "collection": "cryptohonors", + "contract": "0x5c3c16f79e870f096fb669df4aac40f8f378823d", + "token_standard": "erc721", + "name": "CryptoHonors 11580.1293987", + "description": "CryptoHonors 11580.1293987Crypto pioneers have triumphed in bringing cryptocurrencies into the mainstream of global finance.Through knowledge, innovation, and perseverance, they have proven that crypto is not only secure but also an essential alternative to traditional financial systems and shaping the future of money.In March 2025, their efforts over the past few years received official recognition.As a memorial to this milestone and in honor of all crypto pioneers, especially the legendary Satoshi Nakamoto, the CryptoHonors NFT Collection is being created.61B5175539AF0591FB3358961F008754DE9684560190D61898D6DD932C99213FE59EA10B1B22FA4D689914D855F3C05C471F7ED73F25C754A88DE30616B061A7", + "image_url": "https://i2.seadn.io/ethereum/0x5c3c16f79e870f096fb669df4aac40f8f378823d/dd432689733b089dacf32d824102a2/a7dd432689733b089dacf32d824102a2.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0x5c3c16f79e870f096fb669df4aac40f8f378823d/dd432689733b089dacf32d824102a2/a7dd432689733b089dacf32d824102a2.jpeg", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeidznfzwn4stq5opw2dak2znshloyexxbudjrtmtbew7xp4vyovsh4/488", + "opensea_url": "https://opensea.io/assets/ethereum/0x5c3c16f79e870f096fb669df4aac40f8f378823d/488", + "updated_at": "2025-09-02T19:25:19.043361", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "60917031090482105493723878337335466647767334074543332034139190096906957201296", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "clang.eth", + "description": "clang.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/277b144eb162694d9d856581713009/bc277b144eb162694d9d856581713009.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/277b144eb162694d9d856581713009/bc277b144eb162694d9d856581713009.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/60917031090482105493723878337335466647767334074543332034139190096906957201296", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/60917031090482105493723878337335466647767334074543332034139190096906957201296", + "updated_at": "2025-09-02T19:25:19.046940", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "17253050737823713272931985976053663617084923371395978719625348059227653570631", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "stony.eth", + "description": "stony.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/7ee591665a40388f7814775ac1a129/d37ee591665a40388f7814775ac1a129.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/7ee591665a40388f7814775ac1a129/d37ee591665a40388f7814775ac1a129.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/17253050737823713272931985976053663617084923371395978719625348059227653570631", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/17253050737823713272931985976053663617084923371395978719625348059227653570631", + "updated_at": "2025-09-02T19:25:19.046501", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "30879618706845814584303138060709639187472618683478122340491919803649646699309", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "counts.eth", + "description": "counts.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/38c03ae2b0c3193353ad99c70855e6/af38c03ae2b0c3193353ad99c70855e6.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/38c03ae2b0c3193353ad99c70855e6/af38c03ae2b0c3193353ad99c70855e6.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/30879618706845814584303138060709639187472618683478122340491919803649646699309", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/30879618706845814584303138060709639187472618683478122340491919803649646699309", + "updated_at": "2025-09-02T19:25:19.045666", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "32542531444191294544657297720575478788504759369814581659908853786109193150130", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "dogie.eth", + "description": "dogie.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/b0c235fdccd39e2d3b755ad7abb896/4fb0c235fdccd39e2d3b755ad7abb896.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/b0c235fdccd39e2d3b755ad7abb896/4fb0c235fdccd39e2d3b755ad7abb896.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/32542531444191294544657297720575478788504759369814581659908853786109193150130", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/32542531444191294544657297720575478788504759369814581659908853786109193150130", + "updated_at": "2025-09-02T19:25:19.044698", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "18474237470298662301036716053088854980154103760142298210559459485287746085208", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "prong.eth", + "description": "prong.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/66a4e37f9ae9401a03102c9d88db9f/9a66a4e37f9ae9401a03102c9d88db9f.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/66a4e37f9ae9401a03102c9d88db9f/9a66a4e37f9ae9401a03102c9d88db9f.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/18474237470298662301036716053088854980154103760142298210559459485287746085208", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/18474237470298662301036716053088854980154103760142298210559459485287746085208", + "updated_at": "2025-09-02T19:25:19.044072", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "52914002144490083309842028577478480187705204367332580272419640845897306702471", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "femur.eth", + "description": "femur.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/126d67cd2642b255ec5ec88511d725/20126d67cd2642b255ec5ec88511d725.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/126d67cd2642b255ec5ec88511d725/20126d67cd2642b255ec5ec88511d725.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52914002144490083309842028577478480187705204367332580272419640845897306702471", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52914002144490083309842028577478480187705204367332580272419640845897306702471", + "updated_at": "2025-09-02T19:25:19.043769", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1", + "collection": "vitalik-rgb", + "contract": "0x010d1b562b3515839563f84d0aeba146fd92df7a", + "token_standard": "erc721", + "name": "Vitalik RGB #1", + "description": "Make Communism Great Again", + "image_url": "https://i2.seadn.io/ethereum/0x010d1b562b3515839563f84d0aeba146fd92df7a/4dd895638552bc5f0fa584afe66833/3d4dd895638552bc5f0fa584afe66833.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x010d1b562b3515839563f84d0aeba146fd92df7a/4dd895638552bc5f0fa584afe66833/3d4dd895638552bc5f0fa584afe66833.png", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeiejxbr4vprols223o4dbddmu62phsyqpfxn3u7zkrjijyz3w7rooi/1", + "opensea_url": "https://opensea.io/assets/ethereum/0x010d1b562b3515839563f84d0aeba146fd92df7a/1", + "updated_at": "2025-09-02T19:25:19.047546", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "595", + "collection": "milady", + "contract": "0x5af0d9827e0c53e4799bb226655a1de152a425a5", + "token_standard": "erc721", + "name": "Milady 595", + "description": "Milady Maker is a collection of 10,000 generative pfpNFT's in a neochibi aesthetic inspired by street style tribes.", + "image_url": "https://i2.seadn.io/ethereum/0x5af0d9827e0c53e4799bb226655a1de152a425a5/584cb88a9d69a77d2054e7fecee67bbe.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x5af0d9827e0c53e4799bb226655a1de152a425a5/584cb88a9d69a77d2054e7fecee67bbe.png", + "display_animation_url": null, + "metadata_url": "https://www.miladymaker.net/milady/json/595", + "opensea_url": "https://opensea.io/assets/ethereum/0x5af0d9827e0c53e4799bb226655a1de152a425a5/595", + "updated_at": "2025-09-02T19:25:19.042651", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "66132546023544945726839426127452836577000596283723223895609500545496026089099", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "therm.eth", + "description": "therm.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52f34aefadce64b61ec4f60c07c59c/f152f34aefadce64b61ec4f60c07c59c.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52f34aefadce64b61ec4f60c07c59c/f152f34aefadce64b61ec4f60c07c59c.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/66132546023544945726839426127452836577000596283723223895609500545496026089099", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/66132546023544945726839426127452836577000596283723223895609500545496026089099", + "updated_at": "2025-09-02T19:25:19.047357", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "98521790342703974005488692715387800281756524522204247164841510333935204292151", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "intermediate.eth", + "description": "intermediate.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/17323934c81b1e496b2744c3e16c30/2a17323934c81b1e496b2744c3e16c30.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/17323934c81b1e496b2744c3e16c30/2a17323934c81b1e496b2744c3e16c30.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/98521790342703974005488692715387800281756524522204247164841510333935204292151", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/98521790342703974005488692715387800281756524522204247164841510333935204292151", + "updated_at": "2025-09-02T19:25:19.046562", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "35336863524815525227524174466276764360602136958960457202686576146482999194796", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "swung.eth", + "description": "swung.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/9725633748c87dc7afb3613efbfb33/9e9725633748c87dc7afb3613efbfb33.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/9725633748c87dc7afb3613efbfb33/9e9725633748c87dc7afb3613efbfb33.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/35336863524815525227524174466276764360602136958960457202686576146482999194796", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/35336863524815525227524174466276764360602136958960457202686576146482999194796", + "updated_at": "2025-09-02T19:25:19.046233", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "111683139019403760337073215106295164332294948847210895734205284492354714047149", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "detected.eth", + "description": "detected.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/991260c1799bc66afee31425691768/6b991260c1799bc66afee31425691768.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/991260c1799bc66afee31425691768/6b991260c1799bc66afee31425691768.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/111683139019403760337073215106295164332294948847210895734205284492354714047149", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/111683139019403760337073215106295164332294948847210895734205284492354714047149", + "updated_at": "2025-09-02T19:25:19.046048", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "107688245557828530359313813246549251782748069941846661146284467406189572493530", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "located.eth", + "description": "located.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/944fedf0da4f4c41ba4aa3f147b19e/ea944fedf0da4f4c41ba4aa3f147b19e.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/944fedf0da4f4c41ba4aa3f147b19e/ea944fedf0da4f4c41ba4aa3f147b19e.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/107688245557828530359313813246549251782748069941846661146284467406189572493530", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/107688245557828530359313813246549251782748069941846661146284467406189572493530", + "updated_at": "2025-09-02T19:25:19.045394", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "30283937130590526984730381139607560840733345640165354553488496990773677972055", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "coset.eth", + "description": "coset.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/c9cc60cc03db815a5acb3998c15749/c7c9cc60cc03db815a5acb3998c15749.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/c9cc60cc03db815a5acb3998c15749/c7c9cc60cc03db815a5acb3998c15749.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/30283937130590526984730381139607560840733345640165354553488496990773677972055", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/30283937130590526984730381139607560840733345640165354553488496990773677972055", + "updated_at": "2025-09-02T19:25:19.047311", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "21116290316591418875256705112224195883292500442900555047785233254732512411077", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "cheapgas.eth", + "description": "cheapgas.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/fc92aedb7abcb251b7ef0746ac89a3/0bfc92aedb7abcb251b7ef0746ac89a3.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/fc92aedb7abcb251b7ef0746ac89a3/0bfc92aedb7abcb251b7ef0746ac89a3.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/21116290316591418875256705112224195883292500442900555047785233254732512411077", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/21116290316591418875256705112224195883292500442900555047785233254732512411077", + "updated_at": "2025-09-02T19:25:19.047186", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "90101326745191772916898067338854478259182542758920854682167918712366263095298", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "expanded.eth", + "description": "expanded.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/9ae73fad00da11cdc30b27592a1724/949ae73fad00da11cdc30b27592a1724.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/9ae73fad00da11cdc30b27592a1724/949ae73fad00da11cdc30b27592a1724.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/90101326745191772916898067338854478259182542758920854682167918712366263095298", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/90101326745191772916898067338854478259182542758920854682167918712366263095298", + "updated_at": "2025-09-02T19:25:19.047070", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "73381393680021944740943164541219215842433145869445608297651255981757991749599", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "tells.eth", + "description": "tells.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/361ac158b36118a3ecee3442793033/0c361ac158b36118a3ecee3442793033.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/361ac158b36118a3ecee3442793033/0c361ac158b36118a3ecee3442793033.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/73381393680021944740943164541219215842433145869445608297651255981757991749599", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/73381393680021944740943164541219215842433145869445608297651255981757991749599", + "updated_at": "2025-09-02T19:25:19.046814", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "75162928556078043116911251534094120500472268477968248043610144216657200627816", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "configured.eth", + "description": "configured.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/b6e20cda7478e334c4ccf05319bc5d/22b6e20cda7478e334c4ccf05319bc5d.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/b6e20cda7478e334c4ccf05319bc5d/22b6e20cda7478e334c4ccf05319bc5d.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/75162928556078043116911251534094120500472268477968248043610144216657200627816", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/75162928556078043116911251534094120500472268477968248043610144216657200627816", + "updated_at": "2025-09-02T19:25:19.046721", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "48455319309002117050107155188357652617953698022270382684602595782928083142594", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "mated.eth", + "description": "mated.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/1b22a64e1203c0318b70923fc457b7/431b22a64e1203c0318b70923fc457b7.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/1b22a64e1203c0318b70923fc457b7/431b22a64e1203c0318b70923fc457b7.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/48455319309002117050107155188357652617953698022270382684602595782928083142594", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/48455319309002117050107155188357652617953698022270382684602595782928083142594", + "updated_at": "2025-09-02T19:25:19.046655", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "52196526185935166981203740746573854735181200619052350217793483092733471565056", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "issued.eth", + "description": "issued.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/aa82a8e6b678716cd4309279071307/78aa82a8e6b678716cd4309279071307.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/aa82a8e6b678716cd4309279071307/78aa82a8e6b678716cd4309279071307.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52196526185935166981203740746573854735181200619052350217793483092733471565056", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52196526185935166981203740746573854735181200619052350217793483092733471565056", + "updated_at": "2025-09-02T19:25:19.046413", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "52983100885896095301952616956693299151699018482419643348370378163627622340929", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "swats.eth", + "description": "swats.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/1928bc67bdfc239212306e15598cae/8a1928bc67bdfc239212306e15598cae.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/1928bc67bdfc239212306e15598cae/8a1928bc67bdfc239212306e15598cae.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52983100885896095301952616956693299151699018482419643348370378163627622340929", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52983100885896095301952616956693299151699018482419643348370378163627622340929", + "updated_at": "2025-09-02T19:25:19.046351", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "90701798248597213524639149310682016168926038908662515049512432858068319071544", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "dipso.eth", + "description": "dipso.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/9a7c8ff7c639d65d59ce7cea53da42/fc9a7c8ff7c639d65d59ce7cea53da42.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/9a7c8ff7c639d65d59ce7cea53da42/fc9a7c8ff7c639d65d59ce7cea53da42.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/90701798248597213524639149310682016168926038908662515049512432858068319071544", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/90701798248597213524639149310682016168926038908662515049512432858068319071544", + "updated_at": "2025-09-02T19:25:19.045922", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "26844376626940419731075799478450352468391881933215536884339778488475439413457", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "fagot.eth", + "description": "fagot.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/5fa1dcf237e72eb53343106dbbb542/685fa1dcf237e72eb53343106dbbb542.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/5fa1dcf237e72eb53343106dbbb542/685fa1dcf237e72eb53343106dbbb542.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/26844376626940419731075799478450352468391881933215536884339778488475439413457", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/26844376626940419731075799478450352468391881933215536884339778488475439413457", + "updated_at": "2025-09-02T19:25:19.045726", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "60107993821033884570992154918060342556186815785921881640357134839688353525584", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "extending.eth", + "description": "extending.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/5b0d88f0ca3380fbc80ad8d7880e91/435b0d88f0ca3380fbc80ad8d7880e91.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/5b0d88f0ca3380fbc80ad8d7880e91/435b0d88f0ca3380fbc80ad8d7880e91.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/60107993821033884570992154918060342556186815785921881640357134839688353525584", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/60107993821033884570992154918060342556186815785921881640357134839688353525584", + "updated_at": "2025-09-02T19:25:19.045577", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "23980175485063362676536022560470578842693567787793004031959473001739346521434", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "washy.eth", + "description": "washy.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/578186bf5d83aa35a0654f22970e43/31578186bf5d83aa35a0654f22970e43.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/578186bf5d83aa35a0654f22970e43/31578186bf5d83aa35a0654f22970e43.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/23980175485063362676536022560470578842693567787793004031959473001739346521434", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/23980175485063362676536022560470578842693567787793004031959473001739346521434", + "updated_at": "2025-09-02T19:25:19.045451", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "82958770614045013393025916867060860313796670549124236726621683672344811242733", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "retch.eth", + "description": "retch.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/a57823e359714b2555aec17e371e85/50a57823e359714b2555aec17e371e85.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/a57823e359714b2555aec17e371e85/50a57823e359714b2555aec17e371e85.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/82958770614045013393025916867060860313796670549124236726621683672344811242733", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/82958770614045013393025916867060860313796670549124236726621683672344811242733", + "updated_at": "2025-09-02T19:25:19.045307", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "33264434571428187140772807253308839000448713815651835750449448669135420015634", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "maced.eth", + "description": "maced.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/2eccbdb3d95e6e99d110777c8fe7c9/f62eccbdb3d95e6e99d110777c8fe7c9.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/2eccbdb3d95e6e99d110777c8fe7c9/f62eccbdb3d95e6e99d110777c8fe7c9.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/33264434571428187140772807253308839000448713815651835750449448669135420015634", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/33264434571428187140772807253308839000448713815651835750449448669135420015634", + "updated_at": "2025-09-02T19:25:19.045248", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "97914380387295426274654064556740076361988142055594722278328802978183883407275", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "spied.eth", + "description": "spied.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/87105016325aa618522f4c688ab063/2387105016325aa618522f4c688ab063.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/87105016325aa618522f4c688ab063/2387105016325aa618522f4c688ab063.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/97914380387295426274654064556740076361988142055594722278328802978183883407275", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/97914380387295426274654064556740076361988142055594722278328802978183883407275", + "updated_at": "2025-09-02T19:25:19.045040", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "103964794940929910165200571902986709772220119808995046189752452960195651219999", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "dicke.eth", + "description": "dicke.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/787f4d93b322e3cafb5198ba596b91/bb787f4d93b322e3cafb5198ba596b91.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/787f4d93b322e3cafb5198ba596b91/bb787f4d93b322e3cafb5198ba596b91.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/103964794940929910165200571902986709772220119808995046189752452960195651219999", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/103964794940929910165200571902986709772220119808995046189752452960195651219999", + "updated_at": "2025-09-02T19:25:19.044984", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "81134237530476765547240454414430705175833305806489705368966225866678986000186", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "molto.eth", + "description": "molto.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/adb0eb95dc4c63a820c36150c2ef10/c8adb0eb95dc4c63a820c36150c2ef10.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/adb0eb95dc4c63a820c36150c2ef10/c8adb0eb95dc4c63a820c36150c2ef10.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/81134237530476765547240454414430705175833305806489705368966225866678986000186", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/81134237530476765547240454414430705175833305806489705368966225866678986000186", + "updated_at": "2025-09-02T19:25:19.044777", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "59508112466375452006971992558782366957107304572019696987346363324177565023084", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "operated.eth", + "description": "operated.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/779d853fc80a22509047c05b57c6cc/73779d853fc80a22509047c05b57c6cc.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/779d853fc80a22509047c05b57c6cc/73779d853fc80a22509047c05b57c6cc.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/59508112466375452006971992558782366957107304572019696987346363324177565023084", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/59508112466375452006971992558782366957107304572019696987346363324177565023084", + "updated_at": "2025-09-02T19:25:19.044479", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "21771468941976773124944432922628675686671244594664316236089334349416184991115", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "runny.eth", + "description": "runny.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/c6a29550f0645f5d111861cba26fb6/acc6a29550f0645f5d111861cba26fb6.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/c6a29550f0645f5d111861cba26fb6/acc6a29550f0645f5d111861cba26fb6.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/21771468941976773124944432922628675686671244594664316236089334349416184991115", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/21771468941976773124944432922628675686671244594664316236089334349416184991115", + "updated_at": "2025-09-02T19:25:19.044393", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "67974996086548532624182522992520171802065841979743127221282075762822637534634", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "booman.eth", + "description": "booman.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/81b2c6a0bc0d1d0a4e0f319e808004/e681b2c6a0bc0d1d0a4e0f319e808004.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/81b2c6a0bc0d1d0a4e0f319e808004/e681b2c6a0bc0d1d0a4e0f319e808004.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/67974996086548532624182522992520171802065841979743127221282075762822637534634", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/67974996086548532624182522992520171802065841979743127221282075762822637534634", + "updated_at": "2025-09-02T19:25:19.044349", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "65903275372490541962739674932164734700883947232940554570823375633230784575209", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "speaks.eth", + "description": "speaks.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/6ad26c4bb250be72cef326935572df/436ad26c4bb250be72cef326935572df.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/6ad26c4bb250be72cef326935572df/436ad26c4bb250be72cef326935572df.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/65903275372490541962739674932164734700883947232940554570823375633230784575209", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/65903275372490541962739674932164734700883947232940554570823375633230784575209", + "updated_at": "2025-09-02T19:25:19.044278", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "52028882428184970649723061209014465064127179203370567417606575447362115291813", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "encouraged.eth", + "description": "encouraged.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/a96916ed21e787b8d441c12880f1d2/6da96916ed21e787b8d441c12880f1d2.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/a96916ed21e787b8d441c12880f1d2/6da96916ed21e787b8d441c12880f1d2.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52028882428184970649723061209014465064127179203370567417606575447362115291813", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/52028882428184970649723061209014465064127179203370567417606575447362115291813", + "updated_at": "2025-09-02T19:25:19.044135", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "88144287895315765601003471792029883794156951169455416299887281721119044694682", + "collection": "ens", + "contract": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + "token_standard": "erc721", + "name": "disposition.eth", + "description": "disposition.eth, an ENS name.", + "image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/b6d3945a6465b8607f608225015720/e3b6d3945a6465b8607f608225015720.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/b6d3945a6465b8607f608225015720/e3b6d3945a6465b8607f608225015720.svg", + "display_animation_url": null, + "metadata_url": "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/88144287895315765601003471792029883794156951169455416299887281721119044694682", + "opensea_url": "https://opensea.io/assets/ethereum/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/88144287895315765601003471792029883794156951169455416299887281721119044694682", + "updated_at": "2025-09-02T19:25:19.043927", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "21", + "collection": "maze-16", + "contract": "0xc7d8f33ce2f789539696ef5a916e0fc17bf58e9f", + "token_standard": "erc721", + "name": "Maze #21", + "description": "", + "image_url": "https://i2.seadn.io/ethereum/0xc7d8f33ce2f789539696ef5a916e0fc17bf58e9f/935a77dab4663670cf31bdfc7a2f1642.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xc7d8f33ce2f789539696ef5a916e0fc17bf58e9f/935a77dab4663670cf31bdfc7a2f1642.png", + "display_animation_url": null, + "metadata_url": "https://ipfs.io/ipfs/bafybeicnbfdp65pvenpm6tjyx73x6yxj326adp63azxhfebvl3bblvov7i/21.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xc7d8f33ce2f789539696ef5a916e0fc17bf58e9f/21", + "updated_at": "2025-09-02T19:25:19.042947", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "20", + "collection": "maze-16", + "contract": "0xc7d8f33ce2f789539696ef5a916e0fc17bf58e9f", + "token_standard": "erc721", + "name": "Maze #20", + "description": "", + "image_url": "https://i2.seadn.io/ethereum/0xc7d8f33ce2f789539696ef5a916e0fc17bf58e9f/5b46693a5d2735678edc082555ae3bc1.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xc7d8f33ce2f789539696ef5a916e0fc17bf58e9f/5b46693a5d2735678edc082555ae3bc1.png", + "display_animation_url": null, + "metadata_url": "https://ipfs.io/ipfs/bafybeicnbfdp65pvenpm6tjyx73x6yxj326adp63azxhfebvl3bblvov7i/20.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xc7d8f33ce2f789539696ef5a916e0fc17bf58e9f/20", + "updated_at": "2025-09-02T19:25:19.042821", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "773", + "collection": "the-room-of-infinite-paintings", + "contract": "0x4325ac3371f5526fb4190e5b426355c141b85018", + "token_standard": "erc721", + "name": "Infinite Painting #773", + "description": "The Room of Infinite Paintings: a simulated mind's infinite attempt for meaning.", + "image_url": "https://raw2.seadn.io/ethereum/0x4325ac3371f5526fb4190e5b426355c141b85018/62409f24ab030db0d90cbb9573dc0c67.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x4325ac3371f5526fb4190e5b426355c141b85018/62409f24ab030db0d90cbb9573dc0c67.svg", + "display_animation_url": null, + "metadata_url": null, + "opensea_url": "https://opensea.io/assets/ethereum/0x4325ac3371f5526fb4190e5b426355c141b85018/773", + "updated_at": "2025-09-02T19:25:19.048583", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "97", + "collection": "deggkies", + "contract": "0xeb5a14a0a5a9e36ad20cffffa4a8797015f25eae", + "token_standard": "erc721", + "name": "Deggkies #97", + "description": "Deggkies were born from the laziest ducks in the world. The rarity of Deggkies is based on the level of laziness.\nThe properties of the Deggkies will decide the laziness of Egg, \"SIMPLE PROPERTY = MORE RARITY\".\nDeggkies collection is 5,555 digital cards.", + "image_url": "https://i2.seadn.io/ethereum/0xeb5a14a0a5a9e36ad20cffffa4a8797015f25eae/023026233afcc0abf03c1968dae799cc.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xeb5a14a0a5a9e36ad20cffffa4a8797015f25eae/023026233afcc0abf03c1968dae799cc.png", + "display_animation_url": null, + "metadata_url": "https://ipfs.io/ipfs/bafybeiathznzz3vtotq3l6ljnndipv5yerwyaotqhzvx7rhrcdrnvujgbe/97.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xeb5a14a0a5a9e36ad20cffffa4a8797015f25eae/97", + "updated_at": "2025-09-02T19:25:19.048802", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "363", + "collection": "steamboat-willie-1928-3", + "contract": "0xa31600dde45f2fc5c0f13a602790461c31d00bed", + "token_standard": "erc721", + "name": "Steamboat Willie 1928 #363", + "description": "Steamboat Willie 1928 Art by MfersGif", + "image_url": "https://i2.seadn.io/ethereum/0xa31600dde45f2fc5c0f13a602790461c31d00bed/702616932e8115234b5cd69729468d/a1702616932e8115234b5cd69729468d.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xa31600dde45f2fc5c0f13a602790461c31d00bed/702616932e8115234b5cd69729468d/a1702616932e8115234b5cd69729468d.png", + "display_animation_url": null, + "metadata_url": "https://app.bueno.art/api/contract/rbD2mkztPqYYVe4Q_Fe6k/chain/1/metadata/363", + "opensea_url": "https://opensea.io/assets/ethereum/0xa31600dde45f2fc5c0f13a602790461c31d00bed/363", + "updated_at": "2025-09-02T19:25:19.047872", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "65", + "collection": "sprotoliks-xatarrer", + "contract": "0xaf41c115b10c15727ef6887b725dbf2ef042ea57", + "token_standard": "erc721", + "name": "sprotalik form #65", + "description": "some autistic sprotoliks hidden back Xatarrer buterin cards", + "image_url": "https://i2.seadn.io/ethereum/0xaf41c115b10c15727ef6887b725dbf2ef042ea57/3a3455ce392988acbc27d420e73438/cb3a3455ce392988acbc27d420e73438.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xaf41c115b10c15727ef6887b725dbf2ef042ea57/3a3455ce392988acbc27d420e73438/cb3a3455ce392988acbc27d420e73438.png", + "display_animation_url": null, + "metadata_url": "https://ipfs.io/ipfs/bafybeias4hwcggyo3t2diqt5xw4vogegjh3nhro5mm2nv6ibgrmnqqwysa/65.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xaf41c115b10c15727ef6887b725dbf2ef042ea57/65", + "updated_at": "2025-09-02T19:25:19.047223", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "438", + "collection": "blockscription", + "contract": "0x8a30e6a2cfba4baa61cbc6753ba3bb9a9b89f179", + "token_standard": "erc721", + "name": "#438", + "description": "A social experiment to practice inscription & blocks in the ethereum.", + "image_url": "https://raw2.seadn.io/ethereum/0x8a30e6a2cfba4baa61cbc6753ba3bb9a9b89f179/93675f6d40b29293e663c3bd199aa7ce.svg", + "display_image_url": "https://raw2.seadn.io/ethereum/0x8a30e6a2cfba4baa61cbc6753ba3bb9a9b89f179/93675f6d40b29293e663c3bd199aa7ce.svg", + "display_animation_url": null, + "metadata_url": null, + "opensea_url": "https://opensea.io/assets/ethereum/0x8a30e6a2cfba4baa61cbc6753ba3bb9a9b89f179/438", + "updated_at": "2025-09-02T19:25:19.043156", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "66", + "collection": "sprotoliks-xatarrer", + "contract": "0xaf41c115b10c15727ef6887b725dbf2ef042ea57", + "token_standard": "erc721", + "name": "sprotalik form #66", + "description": "some autistic sprotoliks hidden back Xatarrer buterin cards", + "image_url": "https://i2.seadn.io/ethereum/0xaf41c115b10c15727ef6887b725dbf2ef042ea57/5e7373abcd98872ad1f8bee4f6a614/f85e7373abcd98872ad1f8bee4f6a614.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xaf41c115b10c15727ef6887b725dbf2ef042ea57/5e7373abcd98872ad1f8bee4f6a614/f85e7373abcd98872ad1f8bee4f6a614.png", + "display_animation_url": null, + "metadata_url": "https://ipfs.io/ipfs/bafybeias4hwcggyo3t2diqt5xw4vogegjh3nhro5mm2nv6ibgrmnqqwysa/66.json", + "opensea_url": "https://opensea.io/assets/ethereum/0xaf41c115b10c15727ef6887b725dbf2ef042ea57/66", + "updated_at": "2025-09-02T19:25:19.045161", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1288", + "collection": "steamboat2024", + "contract": "0x0c78641c67fe50a344725c67151d49c6aecc5d10", + "token_standard": "erc721", + "name": "Steamboat Willie 2024 #1288", + "description": "Steamboat Willie 2024: Bronze, silver and gold coin.", + "image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/39835e3329e4000dba3d3d12831279/fc39835e3329e4000dba3d3d12831279.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/39835e3329e4000dba3d3d12831279/fc39835e3329e4000dba3d3d12831279.jpeg", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeihininj6mi6twls6end7i7wmma23mmdafc5xzykunlvnaqwdyw7iq/1288", + "opensea_url": "https://opensea.io/assets/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/1288", + "updated_at": "2025-09-02T19:25:19.042888", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "364", + "collection": "steamboat-willie-1928-3", + "contract": "0xa31600dde45f2fc5c0f13a602790461c31d00bed", + "token_standard": "erc721", + "name": "Steamboat Willie 1928 #364", + "description": "Steamboat Willie 1928 Art by MfersGif", + "image_url": "https://i2.seadn.io/ethereum/0xa31600dde45f2fc5c0f13a602790461c31d00bed/0f561e8a0317f13ea9dcb8b3954ff6/b00f561e8a0317f13ea9dcb8b3954ff6.png", + "display_image_url": "https://i2.seadn.io/ethereum/0xa31600dde45f2fc5c0f13a602790461c31d00bed/0f561e8a0317f13ea9dcb8b3954ff6/b00f561e8a0317f13ea9dcb8b3954ff6.png", + "display_animation_url": null, + "metadata_url": "https://app.bueno.art/api/contract/rbD2mkztPqYYVe4Q_Fe6k/chain/1/metadata/364", + "opensea_url": "https://opensea.io/assets/ethereum/0xa31600dde45f2fc5c0f13a602790461c31d00bed/364", + "updated_at": "2025-09-02T19:25:19.047596", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "167", + "collection": "steamboat-willie-picasso", + "contract": "0x3199abc08b192817303ba7fcd34bd49a948c09f8", + "token_standard": "erc721", + "name": "Steamboat Willie Picasso", + "description": "The first public domain Mickey Picasso NFT and official pfp of Steamboat Willie Picasso ", + "image_url": "https://i2.seadn.io/ethereum/0x3199abc08b192817303ba7fcd34bd49a948c09f8/1f117a20e99ed0c37e69f65dd0fd9a47.png", + "display_image_url": "https://i2.seadn.io/ethereum/0x3199abc08b192817303ba7fcd34bd49a948c09f8/1f117a20e99ed0c37e69f65dd0fd9a47.png", + "display_animation_url": null, + "metadata_url": "https://opensea-private.mypinata.cloud/ipfs/QmPZceSLbBWBwZ2ujHGMEnx4a1k4wnreih2etDSVmQ9NoY/", + "opensea_url": "https://opensea.io/assets/ethereum/0x3199abc08b192817303ba7fcd34bd49a948c09f8/167", + "updated_at": "2025-09-02T19:25:19.048544", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1378", + "collection": "steamboat2024", + "contract": "0x0c78641c67fe50a344725c67151d49c6aecc5d10", + "token_standard": "erc721", + "name": "Steamboat Willie 2024 #1378", + "description": "Steamboat Willie 2024: Bronze, silver and gold coin.", + "image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/33f86b0cdb06774908ca291bc0504e/e433f86b0cdb06774908ca291bc0504e.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/33f86b0cdb06774908ca291bc0504e/e433f86b0cdb06774908ca291bc0504e.jpeg", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeihininj6mi6twls6end7i7wmma23mmdafc5xzykunlvnaqwdyw7iq/1378", + "opensea_url": "https://opensea.io/assets/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/1378", + "updated_at": "2025-09-02T19:25:19.043073", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1375", + "collection": "steamboat2024", + "contract": "0x0c78641c67fe50a344725c67151d49c6aecc5d10", + "token_standard": "erc721", + "name": "Steamboat Willie 2024 #1375", + "description": "Steamboat Willie 2024: Bronze, silver and gold coin.", + "image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/a886b7250c886eb984f3913d09d9f3/50a886b7250c886eb984f3913d09d9f3.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/a886b7250c886eb984f3913d09d9f3/50a886b7250c886eb984f3913d09d9f3.jpeg", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeihininj6mi6twls6end7i7wmma23mmdafc5xzykunlvnaqwdyw7iq/1375", + "opensea_url": "https://opensea.io/assets/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/1375", + "updated_at": "2025-09-02T19:25:19.042739", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1377", + "collection": "steamboat2024", + "contract": "0x0c78641c67fe50a344725c67151d49c6aecc5d10", + "token_standard": "erc721", + "name": "Steamboat Willie 2024 #1377", + "description": "Steamboat Willie 2024: Bronze, silver and gold coin.", + "image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/484853f1006b5eb25fbee75240ba3f/7e484853f1006b5eb25fbee75240ba3f.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/484853f1006b5eb25fbee75240ba3f/7e484853f1006b5eb25fbee75240ba3f.jpeg", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeihininj6mi6twls6end7i7wmma23mmdafc5xzykunlvnaqwdyw7iq/1377", + "opensea_url": "https://opensea.io/assets/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/1377", + "updated_at": "2025-09-02T19:25:19.042686", + "is_disabled": false, + "is_nsfw": false + }, + { + "identifier": "1376", + "collection": "steamboat2024", + "contract": "0x0c78641c67fe50a344725c67151d49c6aecc5d10", + "token_standard": "erc721", + "name": "Steamboat Willie 2024 #1376", + "description": "Steamboat Willie 2024: Bronze, silver and gold coin.", + "image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/154f906f4e58ae81a52bfd0f683cc7/14154f906f4e58ae81a52bfd0f683cc7.jpeg", + "display_image_url": "https://i2.seadn.io/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/154f906f4e58ae81a52bfd0f683cc7/14154f906f4e58ae81a52bfd0f683cc7.jpeg", + "display_animation_url": null, + "metadata_url": "ipfs://bafybeihininj6mi6twls6end7i7wmma23mmdafc5xzykunlvnaqwdyw7iq/1376", + "opensea_url": "https://opensea.io/assets/ethereum/0x0c78641c67fe50a344725c67151d49c6aecc5d10/1376", + "updated_at": "2025-09-02T19:25:19.042550", + "is_disabled": false, + "is_nsfw": false + } + ], + "next": "WyIyMDI1LTA0LTA2VDA0OjUxOjM1WiIsIjExY2Q2ZmZhLWFlMmQtMzhmMC05NGExLTM4Y2U4YTM3MGJjMiJd" +} \ No newline at end of file diff --git a/core/crates/nft/testdata/opensea/collection.json b/core/crates/nft/testdata/opensea/collection.json new file mode 100644 index 0000000000..cabd225462 --- /dev/null +++ b/core/crates/nft/testdata/opensea/collection.json @@ -0,0 +1,54 @@ +{ + "collection": "boredapeyachtclub", + "name": "Bored Ape Yacht Club", + "description": "The Bored Ape Yacht Club is a collection of 10,000 unique Bored Ape NFTs— unique digital collectibles living on the Ethereum blockchain. Your Bored Ape doubles as your Yacht Club membership card, and grants access to members-only benefits, the first of which is access to THE BATHROOM, a collaborative graffiti board. Future areas and perks can be unlocked by the community through roadmap activation. Visit www.BoredApeYachtClub.com for more details.", + "image_url": "https://i.seadn.io/gae/Ju9CkWtV-1Okvf45wo8UctR-M9He2PjILP0oOvxE89AyiPPGtrR3gysu1Zgy0hjd2xKIgjJJtWIc0ybj4Vd7wv8t3pxDGHoJBzDB?w=500&auto=format", + "banner_image_url": "https://i.seadn.io/gae/i5dYZRkVCUK97bfprQ3WXyrT9BnLSZtVKGJlKQ919uaUB0sxbngVCioaiyu9r6snqfi2aaTyIvv6DHm4m2R3y7hMajbsv14pSZK8mhs?fit=inside", + "owner": "0xa858ddc0445d8131dac4d1de01f834ffcba52ef1", + "safelist_status": "verified", + "category": "pfps", + "is_disabled": false, + "is_nsfw": false, + "trait_offers_enabled": true, + "collection_offers_enabled": true, + "opensea_url": "https://opensea.io/collection/boredapeyachtclub", + "project_url": "http://www.boredapeyachtclub.com/", + "wiki_url": "", + "discord_url": "https://discord.gg/3P5K3dzgdB", + "telegram_url": "", + "twitter_username": "BoredApeYC", + "instagram_username": "", + "contracts": [ + { + "address": "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", + "chain": "ethereum" + } + ], + "editors": [ + "0x0000000000000000000000000000000000000000", + "0xc489b60e161737c64ac2ee4e85d055cfbf10bad5", + "0xa858ddc0445d8131dac4d1de01f834ffcba52ef1" + ], + "fees": [ + { + "fee": 0.5, + "recipient": "0x0000a26b00c1f0df003000390027140000faa719", + "required": true + }, + { + "fee": 1.0, + "recipient": "0xa858ddc0445d8131dac4d1de01f834ffcba52ef1", + "required": false + } + ], + "rarity": { + "calculated_at": "2025-09-02T19:22:04.185369", + "max_rank": 9998, + "total_supply": 9998, + "strategy_id": "openrarity", + "strategy_version": "1.0" + }, + "total_supply": 9998, + "created_date": "2021-04-22", + "payment_tokens": [] +} \ No newline at end of file diff --git a/core/crates/nft/testdata/ton/collections.json b/core/crates/nft/testdata/ton/collections.json new file mode 100644 index 0000000000..73ca485b26 --- /dev/null +++ b/core/crates/nft/testdata/ton/collections.json @@ -0,0 +1,22 @@ +{ + "nft_collections": [ + { + "address": "0:0E41DC1DC3C9067ED24248580E12B3359818D83DEE0304FABCF80845EAFAFDB2", + "collection_content": { + "uri": "https://nft.fragment.com/numbers.json" + } + } + ], + "metadata": { + "0:0E41DC1DC3C9067ED24248580E12B3359818D83DEE0304FABCF80845EAFAFDB2": { + "token_info": [ + { + "valid": true, + "name": "Anonymous Telegram Numbers", + "description": "These anonymous numbers can be used to create Telegram accounts that are not tied to SIM cards.", + "image": "https://nft.fragment.com/numbers.svg" + } + ] + } + } +} diff --git a/core/crates/nft/testdata/ton/collections_getgems.json b/core/crates/nft/testdata/ton/collections_getgems.json new file mode 100644 index 0000000000..a671efb686 --- /dev/null +++ b/core/crates/nft/testdata/ton/collections_getgems.json @@ -0,0 +1,22 @@ +{ + "nft_collections": [ + { + "address": "0:B06D484DD51108E2AF8DF04576A2B74CC57A209C35B6CB4B5E46204FD118E009" + } + ], + "metadata": { + "0:B06D484DD51108E2AF8DF04576A2B74CC57A209C35B6CB4B5E46204FD118E009": { + "token_info": [ + { + "valid": true, + "name": "Getgems Collection", + "description": "A TON NFT collection listed on Getgems.", + "image": "https://example.com/getgems.png", + "extra": { + "marketplace": "getgems.io" + } + } + ] + } + } +} diff --git a/core/crates/nft/testdata/ton/collections_invalid.json b/core/crates/nft/testdata/ton/collections_invalid.json new file mode 100644 index 0000000000..dce7a42831 --- /dev/null +++ b/core/crates/nft/testdata/ton/collections_invalid.json @@ -0,0 +1,19 @@ +{ + "nft_collections": [ + { + "address": "0:4186117A3BE8DF8B54C4175AECEA911ACA7123845ADC8D4082D837D4C33278A5", + "collection_content": { + "uri": "https://curvesocial.link/collection-fragment3.json" + } + } + ], + "metadata": { + "0:4186117A3BE8DF8B54C4175AECEA911ACA7123845ADC8D4082D837D4C33278A5": { + "token_info": [ + { + "valid": false + } + ] + } + } +} diff --git a/core/crates/nft/testdata/ton/items.json b/core/crates/nft/testdata/ton/items.json new file mode 100644 index 0000000000..77af182eb2 --- /dev/null +++ b/core/crates/nft/testdata/ton/items.json @@ -0,0 +1,22 @@ +{ + "nft_items": [ + { + "address": "0:AFC49CB8786F21C87045B19EDE78FC6B46C5104845133F8E9A6D440601C198EA", + "collection_address": "0:80D78A35F955A14B679FAA887FF4CD5BFC0F43B4A4EEA2A7E6927F3701B273C2", + "index": "42", + "content": null + } + ], + "metadata": { + "0:AFC49CB8786F21C87045B19EDE78FC6B46C5104845133F8E9A6D440601C198EA": { + "token_info": [ + { + "valid": true, + "name": "Resolved Item Name", + "description": "A TON NFT", + "image": "https://example.com/resolved-item.png" + } + ] + } + } +} diff --git a/core/crates/nft/testdata/ton/items_unverified.json b/core/crates/nft/testdata/ton/items_unverified.json new file mode 100644 index 0000000000..a9a6694cc7 --- /dev/null +++ b/core/crates/nft/testdata/ton/items_unverified.json @@ -0,0 +1,33 @@ +{ + "nft_items": [ + { + "address": "0:AFC49CB8786F21C87045B19EDE78FC6B46C5104845133F8E9A6D440601C198EA", + "collection_address": "0:4186117A3BE8DF8B54C4175AECEA911ACA7123845ADC8D4082D837D4C33278A5" + } + ], + "metadata": { + "0:4186117A3BE8DF8B54C4175AECEA911ACA7123845ADC8D4082D837D4C33278A5": { + "token_info": [ + { + "valid": true, + "name": "Unverified Collection", + "description": "A TON NFT collection without a verified marketplace.", + "image": "https://example.com/unverified-collection.png", + "extra": { + "marketplace": "other.io" + } + } + ] + }, + "0:AFC49CB8786F21C87045B19EDE78FC6B46C5104845133F8E9A6D440601C198EA": { + "token_info": [ + { + "valid": true, + "name": "Unverified Item", + "description": "A TON NFT", + "image": "https://example.com/unverified-item.png" + } + ] + } + } +} diff --git a/core/crates/number_formatter/Cargo.toml b/core/crates/number_formatter/Cargo.toml new file mode 100644 index 0000000000..297b944c72 --- /dev/null +++ b/core/crates/number_formatter/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "number_formatter" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +num-bigint = { workspace = true } +bigdecimal = { workspace = true } +rust_decimal = { version = "1.42.0", features = ["serde-with-str"] } diff --git a/core/crates/number_formatter/src/big_number_formatter.rs b/core/crates/number_formatter/src/big_number_formatter.rs new file mode 100644 index 0000000000..a9bea27de2 --- /dev/null +++ b/core/crates/number_formatter/src/big_number_formatter.rs @@ -0,0 +1,214 @@ +use bigdecimal::{BigDecimal, RoundingMode, ToPrimitive}; +use num_bigint::{BigInt, BigUint}; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq)] +pub enum NumberFormatterError { + InvalidNumber(String), + ConversionError(String), +} + +impl std::fmt::Display for NumberFormatterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidNumber(msg) => write!(f, "Invalid number: {}", msg), + Self::ConversionError(msg) => write!(f, "Conversion error: {}", msg), + } + } +} + +impl std::error::Error for NumberFormatterError {} + +impl From for String { + fn from(error: NumberFormatterError) -> Self { + error.to_string() + } +} + +pub struct BigNumberFormatter {} + +impl BigNumberFormatter { + pub fn big_decimal_value(value: &str, decimals: u32) -> Result { + let mut decimal = BigDecimal::from_str(value).map_err(|e| NumberFormatterError::InvalidNumber(e.to_string()))?; + let exp = BigInt::from(10).pow(decimals); + decimal = decimal / BigDecimal::from(exp); + Ok(decimal) + } + + pub fn value_as_f64(value: &str, decimals: u32) -> Result { + Self::big_decimal_value(value, decimals)? + .to_f64() + .ok_or_else(|| NumberFormatterError::ConversionError("Cannot convert to f64".to_string())) + } + + pub fn value_as_u64(value: &str, decimals: u32) -> Result { + Self::big_decimal_value(value, decimals)? + .to_u64() + .ok_or_else(|| NumberFormatterError::ConversionError("Cannot convert to u64".to_string())) + } + + pub fn value(value: &str, decimals: i32) -> Result { + let decimal = Self::big_decimal_value(value, decimals as u32)?; + Ok(decimal.to_string()) + } + + pub fn value_from_amount(amount: &str, decimals: u32) -> Result { + let big_decimal = BigDecimal::from_str(amount).map_err(|_| NumberFormatterError::InvalidNumber(amount.to_string()))?; + let multiplier = BigInt::from(10).pow(decimals); + let multiplier_decimal = BigDecimal::from(multiplier); + let scaled_value = big_decimal * multiplier_decimal; + Ok(scaled_value.with_scale(0).to_string()) + } + + pub fn value_from_amount_truncated(amount: &str, decimals: u32) -> Result { + let big_decimal = BigDecimal::from_str(amount).map_err(|_| NumberFormatterError::InvalidNumber(amount.to_string()))?; + if big_decimal < 0 { + return Err(NumberFormatterError::InvalidNumber(amount.to_string())); + } + let truncated = big_decimal.with_scale_round(i64::from(decimals), RoundingMode::Down); + Self::value_from_amount(&truncated.to_string(), decimals) + } + + pub fn f64_as_value(amount: f64, decimals: u32) -> Option { + Self::value_from_amount(&amount.to_string(), decimals).ok() + } + + pub fn value_from_amount_biguint(amount: &str, decimals: u32) -> Result { + let big_decimal = BigDecimal::from_str(amount).map_err(|_| NumberFormatterError::InvalidNumber(amount.to_string()))?; + let multiplier = BigInt::from(10).pow(decimals); + let multiplier_decimal = BigDecimal::from(multiplier); + let scaled_value = big_decimal * multiplier_decimal; + let scaled_string = scaled_value.with_scale(0).to_string(); + scaled_string.parse::().map_err(|_| NumberFormatterError::ConversionError(scaled_string)) + } + + pub fn decimal_to_string(value: &BigDecimal, max_scale: u32) -> String { + value.round(max_scale as i64).normalized().to_string() + } + + pub fn ratio(numerator: &BigUint, denominator: &BigUint) -> f64 { + if *denominator == BigUint::from(0u32) { + return 0.0; + } + let precision = BigUint::from(1_000_000u64); + let scaled = numerator * &precision / denominator; + let scaled_u64 = u64::try_from(&scaled).unwrap_or(u64::MAX); + scaled_u64 as f64 / 1_000_000.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bigdecimal::BigDecimal; + use std::str::FromStr; + + #[test] + fn test_value() { + // Test case 1: Valid input + let result = BigNumberFormatter::value("123456", 3).unwrap(); + assert_eq!(result, "123.456"); + + // Test case 2: Input with more decimals than specified + let result = BigNumberFormatter::value("789123456", 4).unwrap(); + assert_eq!(result, "78912.3456"); + + // Test case 3: Input with fewer decimals than specified + let result = BigNumberFormatter::value("4567", 4).unwrap(); + assert_eq!(result, "0.4567"); + + // Test case 4: u256 input + let result = BigNumberFormatter::value("115792089237316195423570985008687907853269984665640564039457000000000000000000", 18).unwrap(); + assert_eq!(result, "115792089237316195423570985008687907853269984665640564039457"); + + let result = BigNumberFormatter::value("abc", 2); + assert!(result.is_err()); + + // Test case 6: Output return small value + let result = BigNumberFormatter::value("1640000000000000", 18).unwrap(); + assert_eq!(result, "0.00164"); + } + + #[test] + fn test_value_from_amount() { + // Test case 1: Valid input + let result = BigNumberFormatter::value_from_amount("1.123", 3).unwrap(); + assert_eq!(result, "1123"); + + let result = BigNumberFormatter::value_from_amount("332131212.2321312", 8).unwrap(); + assert_eq!(result, "33213121223213120"); + + let result = BigNumberFormatter::value_from_amount("0", 0).unwrap(); + assert_eq!(result, "0"); + + // Test case 2: Invalid input + let result = BigNumberFormatter::value_from_amount("invalid", 3); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), NumberFormatterError::InvalidNumber("invalid".to_string())); + } + + #[test] + fn test_value_from_amount_truncated() { + assert_eq!(BigNumberFormatter::value_from_amount_truncated("1.183818719", 8).unwrap(), "118381871"); + assert_eq!( + BigNumberFormatter::value_from_amount_truncated("123456789012345678.123456789", 18).unwrap(), + "123456789012345678123456789000000000" + ); + assert_eq!( + BigNumberFormatter::value_from_amount_truncated("-1", 9), + Err(NumberFormatterError::InvalidNumber("-1".to_string())) + ); + } + + #[test] + fn test_decimal_to_string() { + let decimal = BigDecimal::from_str("1.123456789").unwrap(); + assert_eq!(BigNumberFormatter::decimal_to_string(&decimal, 6), "1.123457"); + + let decimal = BigDecimal::from_str("0.000001234").unwrap(); + assert_eq!(BigNumberFormatter::decimal_to_string(&decimal, 6), "0.000001"); + + let decimal = BigDecimal::from_str("2.5").unwrap(); + assert_eq!(BigNumberFormatter::decimal_to_string(&decimal, 6), "2.5"); + + let decimal = BigDecimal::from_str("10").unwrap(); + assert_eq!(BigNumberFormatter::decimal_to_string(&decimal, 6), "10"); + } + + #[test] + fn test_ratio() { + assert_eq!(BigNumberFormatter::ratio(&BigUint::from(1u32), &BigUint::from(2u32)), 0.5); + assert_eq!(BigNumberFormatter::ratio(&BigUint::from(1u32), &BigUint::from(4u32)), 0.25); + assert_eq!(BigNumberFormatter::ratio(&BigUint::from(0u32), &BigUint::from(100u32)), 0.0); + assert_eq!(BigNumberFormatter::ratio(&BigUint::from(100u32), &BigUint::from(0u32)), 0.0); + assert_eq!(BigNumberFormatter::ratio(&BigUint::from(1u32), &BigUint::from(100u32)), 0.01); + assert_eq!(BigNumberFormatter::ratio(&BigUint::from(3u32), &BigUint::from(100u32)), 0.03); + + let large_num = BigUint::from(1_000_000_000u64); + let large_den = BigUint::from(10_000_000_000u64); + assert_eq!(BigNumberFormatter::ratio(&large_num, &large_den), 0.1); + } + + #[test] + fn test_value_from_amount_biguint() { + // Test case 1: Valid input + let result = BigNumberFormatter::value_from_amount_biguint("1.123", 3).unwrap(); + assert_eq!(result, BigUint::from(1123u32)); + + let result = BigNumberFormatter::value_from_amount_biguint("332131212.2321312", 8).unwrap(); + assert_eq!(result, BigUint::from(33213121223213120_u64)); + + let result = BigNumberFormatter::value_from_amount_biguint("0", 0).unwrap(); + assert_eq!(result, BigUint::from(0u32)); + + // Test case 2: Large numbers + let result = BigNumberFormatter::value_from_amount_biguint("1000000000000", 18).unwrap(); + let expected = "1000000000000000000000000000000".parse::().unwrap(); + assert_eq!(result, expected); + + // Test case 3: Invalid input + let result = BigNumberFormatter::value_from_amount_biguint("invalid", 3); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), NumberFormatterError::InvalidNumber("invalid".to_string())); + } +} diff --git a/core/crates/number_formatter/src/currency.rs b/core/crates/number_formatter/src/currency.rs new file mode 100644 index 0000000000..9be341c7a0 --- /dev/null +++ b/core/crates/number_formatter/src/currency.rs @@ -0,0 +1,554 @@ +use rust_decimal::Decimal; +use std::str::FromStr; + +// Currency formatting inspired by https://github.com/paupino/rust-decimal + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Currency { + pub iso_alpha_code: &'static str, + pub symbol: &'static str, + pub name: &'static str, + pub decimal_places: u8, +} +/// ISO 4217 currency definitions +pub mod iso { + use super::Currency; + pub const USD: Currency = Currency { + iso_alpha_code: "USD", + symbol: "$", + name: "US Dollar", + decimal_places: 2, + }; + pub const EUR: Currency = Currency { + iso_alpha_code: "EUR", + symbol: "€", + name: "Euro", + decimal_places: 2, + }; + pub const GBP: Currency = Currency { + iso_alpha_code: "GBP", + symbol: "£", + name: "British Pound", + decimal_places: 2, + }; + pub const JPY: Currency = Currency { + iso_alpha_code: "JPY", + symbol: "¥", + name: "Japanese Yen", + decimal_places: 0, + }; + pub const CNY: Currency = Currency { + iso_alpha_code: "CNY", + symbol: "¥", + name: "Chinese Yuan", + decimal_places: 2, + }; + pub const CAD: Currency = Currency { + iso_alpha_code: "CAD", + symbol: "$", + name: "Canadian Dollar", + decimal_places: 2, + }; + pub const AUD: Currency = Currency { + iso_alpha_code: "AUD", + symbol: "$", + name: "Australian Dollar", + decimal_places: 2, + }; + pub const CHF: Currency = Currency { + iso_alpha_code: "CHF", + symbol: "CHF", + name: "Swiss Franc", + decimal_places: 2, + }; + pub const KRW: Currency = Currency { + iso_alpha_code: "KRW", + symbol: "₩", + name: "South Korean Won", + decimal_places: 0, + }; + pub const INR: Currency = Currency { + iso_alpha_code: "INR", + symbol: "₹", + name: "Indian Rupee", + decimal_places: 2, + }; + + pub const BRL: Currency = Currency { + iso_alpha_code: "BRL", + symbol: "R$", + name: "Brazilian Real", + decimal_places: 2, + }; + pub const RUB: Currency = Currency { + iso_alpha_code: "RUB", + symbol: "₽", + name: "Russian Ruble", + decimal_places: 2, + }; + pub const MXN: Currency = Currency { + iso_alpha_code: "MXN", + symbol: "$", + name: "Mexican Peso", + decimal_places: 2, + }; + pub const ZAR: Currency = Currency { + iso_alpha_code: "ZAR", + symbol: "R", + name: "South African Rand", + decimal_places: 2, + }; + pub const SGD: Currency = Currency { + iso_alpha_code: "SGD", + symbol: "$", + name: "Singapore Dollar", + decimal_places: 2, + }; + pub const HKD: Currency = Currency { + iso_alpha_code: "HKD", + symbol: "$", + name: "Hong Kong Dollar", + decimal_places: 2, + }; + pub const NOK: Currency = Currency { + iso_alpha_code: "NOK", + symbol: "kr", + name: "Norwegian Krone", + decimal_places: 2, + }; + pub const SEK: Currency = Currency { + iso_alpha_code: "SEK", + symbol: "kr", + name: "Swedish Krona", + decimal_places: 2, + }; + pub const DKK: Currency = Currency { + iso_alpha_code: "DKK", + symbol: "kr", + name: "Danish Krone", + decimal_places: 2, + }; + pub const PLN: Currency = Currency { + iso_alpha_code: "PLN", + symbol: "zł", + name: "Polish Zloty", + decimal_places: 2, + }; + + pub const AED: Currency = Currency { + iso_alpha_code: "AED", + symbol: "د.إ", + name: "UAE Dirham", + decimal_places: 2, + }; + pub const SAR: Currency = Currency { + iso_alpha_code: "SAR", + symbol: "﷼", + name: "Saudi Riyal", + decimal_places: 2, + }; + pub const EGP: Currency = Currency { + iso_alpha_code: "EGP", + symbol: "£", + name: "Egyptian Pound", + decimal_places: 2, + }; + pub const ILS: Currency = Currency { + iso_alpha_code: "ILS", + symbol: "₪", + name: "Israeli Shekel", + decimal_places: 2, + }; + pub const TRY: Currency = Currency { + iso_alpha_code: "TRY", + symbol: "₺", + name: "Turkish Lira", + decimal_places: 2, + }; + + pub const THB: Currency = Currency { + iso_alpha_code: "THB", + symbol: "฿", + name: "Thai Baht", + decimal_places: 2, + }; + pub const MYR: Currency = Currency { + iso_alpha_code: "MYR", + symbol: "RM", + name: "Malaysian Ringgit", + decimal_places: 2, + }; + pub const IDR: Currency = Currency { + iso_alpha_code: "IDR", + symbol: "Rp", + name: "Indonesian Rupiah", + decimal_places: 2, + }; + pub const PHP: Currency = Currency { + iso_alpha_code: "PHP", + symbol: "₱", + name: "Philippine Peso", + decimal_places: 2, + }; + pub const VND: Currency = Currency { + iso_alpha_code: "VND", + symbol: "₫", + name: "Vietnamese Dong", + decimal_places: 0, + }; + pub const TWD: Currency = Currency { + iso_alpha_code: "TWD", + symbol: "$", + name: "Taiwan Dollar", + decimal_places: 2, + }; + pub const NZD: Currency = Currency { + iso_alpha_code: "NZD", + symbol: "$", + name: "New Zealand Dollar", + decimal_places: 2, + }; + + pub const ARS: Currency = Currency { + iso_alpha_code: "ARS", + symbol: "$", + name: "Argentine Peso", + decimal_places: 2, + }; + pub const CLP: Currency = Currency { + iso_alpha_code: "CLP", + symbol: "$", + name: "Chilean Peso", + decimal_places: 0, + }; + pub const COP: Currency = Currency { + iso_alpha_code: "COP", + symbol: "$", + name: "Colombian Peso", + decimal_places: 2, + }; + pub const PEN: Currency = Currency { + iso_alpha_code: "PEN", + symbol: "S/", + name: "Peruvian Sol", + decimal_places: 2, + }; + + pub const CZK: Currency = Currency { + iso_alpha_code: "CZK", + symbol: "Kč", + name: "Czech Koruna", + decimal_places: 2, + }; + pub const HUF: Currency = Currency { + iso_alpha_code: "HUF", + symbol: "Ft", + name: "Hungarian Forint", + decimal_places: 2, + }; + pub const RON: Currency = Currency { + iso_alpha_code: "RON", + symbol: "lei", + name: "Romanian Leu", + decimal_places: 2, + }; + pub const BGN: Currency = Currency { + iso_alpha_code: "BGN", + symbol: "лв", + name: "Bulgarian Lev", + decimal_places: 2, + }; + pub const HRK: Currency = Currency { + iso_alpha_code: "HRK", + symbol: "kn", + name: "Croatian Kuna", + decimal_places: 2, + }; + + pub const ISK: Currency = Currency { + iso_alpha_code: "ISK", + symbol: "kr", + name: "Icelandic Krona", + decimal_places: 0, + }; + pub const UAH: Currency = Currency { + iso_alpha_code: "UAH", + symbol: "₴", + name: "Ukrainian Hryvnia", + decimal_places: 2, + }; + pub const BYN: Currency = Currency { + iso_alpha_code: "BYN", + symbol: "Br", + name: "Belarusian Ruble", + decimal_places: 2, + }; + pub const KZT: Currency = Currency { + iso_alpha_code: "KZT", + symbol: "₸", + name: "Kazakhstani Tenge", + decimal_places: 2, + }; + + pub fn find(code: &str) -> Option { + match code.to_uppercase().as_str() { + "USD" => Some(USD), + "EUR" => Some(EUR), + "GBP" => Some(GBP), + "JPY" => Some(JPY), + "CNY" => Some(CNY), + "CAD" => Some(CAD), + "AUD" => Some(AUD), + "CHF" => Some(CHF), + "KRW" => Some(KRW), + "INR" => Some(INR), + + "BRL" => Some(BRL), + "RUB" => Some(RUB), + "MXN" => Some(MXN), + "ZAR" => Some(ZAR), + "SGD" => Some(SGD), + "HKD" => Some(HKD), + "NOK" => Some(NOK), + "SEK" => Some(SEK), + "DKK" => Some(DKK), + "PLN" => Some(PLN), + + "AED" => Some(AED), + "SAR" => Some(SAR), + "EGP" => Some(EGP), + "ILS" => Some(ILS), + "TRY" => Some(TRY), + + "THB" => Some(THB), + "MYR" => Some(MYR), + "IDR" => Some(IDR), + "PHP" => Some(PHP), + "VND" => Some(VND), + "TWD" => Some(TWD), + "NZD" => Some(NZD), + + "ARS" => Some(ARS), + "CLP" => Some(CLP), + "COP" => Some(COP), + "PEN" => Some(PEN), + + "CZK" => Some(CZK), + "HUF" => Some(HUF), + "RON" => Some(RON), + "BGN" => Some(BGN), + "HRK" => Some(HRK), + + "ISK" => Some(ISK), + "UAH" => Some(UAH), + "BYN" => Some(BYN), + "KZT" => Some(KZT), + + _ => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct Money { + amount: Decimal, + currency: Currency, +} + +impl Money { + pub fn from_str(amount: &str, currency: Currency) -> Result { + let amount = Decimal::from_str(amount)?; + Ok(Money { amount, currency }) + } + + pub fn new(amount: Decimal, currency: Currency) -> Self { + Money { amount, currency } + } + + pub fn currency(&self) -> &Currency { + &self.currency + } + + pub fn amount(&self) -> Decimal { + self.amount + } +} + +/// Formatting parameters for money display +#[derive(Debug, Default)] +pub struct Params { + pub symbol: Option<&'static str>, + pub code: Option<&'static str>, + pub rounding: Option, + pub thousands_separator: Option, + pub decimal_separator: Option, +} + +/// Formatter for money values +pub struct Formatter; + +impl Formatter { + /// Format a Money value with the given parameters + pub fn format(money: &Money, params: Params) -> String { + let amount = money.amount(); + let currency = money.currency(); + + let decimal_places = params.rounding.unwrap_or(currency.decimal_places); + let explicit_rounding = params.rounding.is_some(); + + let rounded = amount.round_dp(decimal_places.into()); + + let amount_str = Self::format_decimal(rounded, decimal_places, explicit_rounding); + + let formatted_amount = add_thousands_separator(&amount_str, params.thousands_separator.unwrap_or(','), params.decimal_separator.unwrap_or('.')); + + let symbol = params.symbol.unwrap_or(currency.symbol); + format!("{}{}", symbol, formatted_amount) + } + + fn format_decimal(decimal: Decimal, max_decimal_places: u8, _explicit_rounding: bool) -> String { + let formatted = format!("{:.prec$}", decimal, prec = max_decimal_places as usize); + + if max_decimal_places == 2 { + formatted + } else if formatted.contains('.') { + let trimmed = formatted.trim_end_matches('0'); + if trimmed.ends_with('.') { + trimmed.trim_end_matches('.').to_string() + } else { + trimmed.to_string() + } + } else { + formatted + } + } +} + +pub fn add_thousands_separator(amount_str: &str, thousands_sep: char, decimal_sep: char) -> String { + let (integer_part, decimal_part) = amount_str.split_once('.').map_or((amount_str, None), |(int, dec)| (int, Some(dec))); + + let (sign, integer_part) = if let Some(stripped) = integer_part.strip_prefix('-') { + ("-", stripped) + } else { + ("", integer_part) + }; + + let chars: Vec = integer_part.chars().collect(); + let mut formatted_integer = String::new(); + + for (i, &ch) in chars.iter().enumerate() { + if i > 0 && (chars.len() - i).is_multiple_of(3) { + formatted_integer.push(thousands_sep); + } + formatted_integer.push(ch); + } + + let mut result = format!("{}{}", sign, formatted_integer); + if let Some(decimal) = decimal_part + && !decimal.is_empty() + { + result.push(decimal_sep); + result.push_str(decimal); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal::Decimal; + use std::str::FromStr; + + #[test] + fn test_currency_find() { + assert_eq!(iso::find("USD"), Some(iso::USD)); + assert_eq!(iso::find("usd"), Some(iso::USD)); + assert_eq!(iso::find("EUR"), Some(iso::EUR)); + assert_eq!(iso::find("INVALID"), None); + } + + #[test] + fn test_money_creation() { + let money = Money::from_str("100.50", iso::USD).unwrap(); + assert_eq!(money.amount(), Decimal::from_str("100.50").unwrap()); + assert_eq!(money.currency(), &iso::USD); + } + + #[test] + fn test_thousands_separator() { + assert_eq!(add_thousands_separator("1000", ',', '.'), "1,000"); + assert_eq!(add_thousands_separator("1000000", ',', '.'), "1,000,000"); + assert_eq!(add_thousands_separator("1000.50", ',', '.'), "1,000.50"); + assert_eq!(add_thousands_separator("-1000.50", ',', '.'), "-1,000.50"); + assert_eq!(add_thousands_separator("100", ',', '.'), "100"); + } + + #[test] + fn test_formatter() { + let money = Money::from_str("1000.50", iso::USD).unwrap(); + let params = Params { + symbol: Some(iso::USD.symbol), + rounding: Some(2), + ..Default::default() + }; + + let formatted = Formatter::format(&money, params); + assert_eq!(formatted, "$1,000.50"); + } + + #[test] + fn test_formatter_with_different_currencies() { + let money_eur = Money::from_str("9999.99", iso::EUR).unwrap(); + let params = Params { + symbol: Some(iso::EUR.symbol), + rounding: Some(2), + ..Default::default() + }; + + let formatted = Formatter::format(&money_eur, params); + assert_eq!(formatted, "€9,999.99"); + } + + #[test] + fn test_formatter_custom_precision() { + let money = Money::from_str("0.123456", iso::USD).unwrap(); + + let params_4_digits = Params { + symbol: Some(iso::USD.symbol), + rounding: Some(4), + ..Default::default() + }; + + let formatted = Formatter::format(&money, params_4_digits); + assert_eq!(formatted, "$0.1235"); + + let params_9_digits = Params { + symbol: Some(iso::USD.symbol), + rounding: Some(9), + ..Default::default() + }; + + let formatted = Formatter::format(&money, params_9_digits); + assert_eq!(formatted, "$0.123456"); + } + + #[test] + fn test_invalid_money_creation() { + assert!(Money::from_str("invalid", iso::USD).is_err()); + } + + #[test] + fn test_zero_value_formatting() { + let zero_money = Money::from_str("0", iso::USD).unwrap(); + let formatted = Formatter::format(&zero_money, Params::default()); + assert_eq!(formatted, "$0.00"); + } + + #[test] + fn test_large_number_thousands_separators() { + let very_large = Money::from_str("999999999999.99", iso::USD).unwrap(); + let formatted = Formatter::format(&very_large, Params::default()); + assert_eq!(formatted, "$999,999,999,999.99"); + } +} diff --git a/core/crates/number_formatter/src/lib.rs b/core/crates/number_formatter/src/lib.rs new file mode 100644 index 0000000000..4e62a98aa2 --- /dev/null +++ b/core/crates/number_formatter/src/lib.rs @@ -0,0 +1,8 @@ +pub mod big_number_formatter; +pub use big_number_formatter::{BigNumberFormatter, NumberFormatterError}; +pub mod currency; +pub mod number_formatter; +pub use number_formatter::NumberFormatter; +pub mod price_suggestion; +pub mod value_formatter; +pub use value_formatter::{ValueFormatter, ValueStyle}; diff --git a/core/crates/number_formatter/src/number_formatter.rs b/core/crates/number_formatter/src/number_formatter.rs new file mode 100644 index 0000000000..26c0c4b550 --- /dev/null +++ b/core/crates/number_formatter/src/number_formatter.rs @@ -0,0 +1,72 @@ +use crate::currency::{Formatter, Money, Params, iso}; + +pub struct NumberFormatter {} + +impl NumberFormatter { + pub fn new() -> Self { + NumberFormatter {} + } + + pub fn currency(&self, value: f64, currency: &str) -> Option { + let iso_currency = iso::find(currency).unwrap_or(iso::USD); + let money = Money::from_str(&value.to_string(), iso_currency).ok()?; + + let rounding = if value < 0.0001 { + 9 + } else if value < 0.01 { + 6 + } else if value < 1.0 { + 4 + } else { + 2 + }; + + let params = Params { + symbol: Some(iso_currency.symbol), + code: Some(iso_currency.iso_alpha_code), + rounding: Some(rounding), + ..Default::default() + }; + Some(Formatter::format(&money, params)) + } + + pub fn percent(&self, value: f64, _locale: &str) -> String { + format!("{value:.2}%") + } +} + +impl Default for NumberFormatter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_currency() { + let formatter = NumberFormatter::new(); + assert_eq!(formatter.currency(1000.0, "USD"), Some("$1,000.00".to_string())); + assert_eq!(formatter.currency(60127.9263, "USD"), Some("$60,127.93".to_string())); + assert_eq!(formatter.currency(0.123456, "USD"), Some("$0.1235".to_string())); + assert_eq!(formatter.currency(0.00000123456, "USD"), Some("$0.000001235".to_string())); + assert_eq!(formatter.currency(9999.99, "USD"), Some("$9,999.99".to_string())); + assert_eq!(formatter.currency(9999.99, "EUR"), Some("€9,999.99".to_string())); + assert_eq!(formatter.currency(9999.99, "CNY"), Some("¥9,999.99".to_string())); + assert_eq!(formatter.currency(01.99, "GBP"), Some("£1.99".to_string())); + assert_eq!(formatter.currency(19.01, "JPY"), Some("¥19.01".to_string())); + assert_eq!(formatter.currency(0.39, "USD"), Some("$0.39".to_string())); + assert_eq!(formatter.currency(0.0039, "USD"), Some("$0.0039".to_string())); + assert_eq!(formatter.currency(69.420, "USD"), Some("$69.42".to_string())); + } + + #[test] + fn test_number() { + let formatter = NumberFormatter::new(); + assert_eq!(formatter.percent(0.12, "en"), "0.12%"); + assert_eq!(formatter.percent(-6.12, "en"), "-6.12%"); + assert_eq!(formatter.percent(129.99, "en"), "129.99%"); + } +} diff --git a/core/crates/number_formatter/src/price_suggestion.rs b/core/crates/number_formatter/src/price_suggestion.rs new file mode 100644 index 0000000000..1d1e5922d7 --- /dev/null +++ b/core/crates/number_formatter/src/price_suggestion.rs @@ -0,0 +1,64 @@ +pub fn percentage_suggestions(price: f64) -> Vec { + let base = match price { + p if p < 100.0 => 5, + p if p < 10_000.0 => 3, + _ => 2, + }; + vec![base, base * 2, base * 3] +} + +pub fn price_rounded_values(price: f64, by_percent: f64) -> Vec { + if price < 0.01 || by_percent <= 0.0 { + return vec![]; + } + + let lower_target = price * (1.0 - by_percent / 100.0); + let upper_target = price * (1.0 + by_percent / 100.0); + let step = price_step(lower_target); + + let lower = (lower_target / step).floor() * step; + let upper = if step > 1.0 { + (upper_target / step).round() * step + } else { + (upper_target / step).ceil() * step + }; + + vec![lower, upper] +} + +fn price_step(value: f64) -> f64 { + match value { + v if v < 1.0 => 0.01, + v if v < 100.0 => 1.0, + v if v < 500.0 => 10.0, + v if v < 10_000.0 => 50.0, + _ => 1000.0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_rounded(price: f64, by_percent: f64, expected: [f64; 2]) { + let result = price_rounded_values(price, by_percent); + assert_eq!(result, expected); + } + + #[test] + fn test_percentage_suggestions() { + assert_eq!(percentage_suggestions(50.0), vec![5, 10, 15]); + assert_eq!(percentage_suggestions(500.0), vec![3, 6, 9]); + assert_eq!(percentage_suggestions(10_000.0), vec![2, 4, 6]); + } + + #[test] + fn test_price_rounded_values() { + assert_rounded(0.2829, 5.0, [0.26, 0.30]); + assert_rounded(767.55, 5.0, [700.0, 800.0]); + assert_rounded(95_432.0, 5.0, [90_000.0, 100_000.0]); + + assert!(price_rounded_values(0.0, 5.0).is_empty()); + assert!(price_rounded_values(100.0, -5.0).is_empty()); + } +} diff --git a/core/crates/number_formatter/src/value_formatter.rs b/core/crates/number_formatter/src/value_formatter.rs new file mode 100644 index 0000000000..f2d59a2ff3 --- /dev/null +++ b/core/crates/number_formatter/src/value_formatter.rs @@ -0,0 +1,189 @@ +use bigdecimal::BigDecimal; +use num_bigint::Sign; + +use crate::big_number_formatter::{BigNumberFormatter, NumberFormatterError}; +use crate::currency::add_thousands_separator; + +pub enum ValueStyle { + Full, + Auto, +} + +pub struct ValueFormatter; + +impl ValueFormatter { + pub fn format(style: ValueStyle, value: &str, decimals: i32) -> Result { + let decimal = BigNumberFormatter::big_decimal_value(value, decimals as u32)?; + match style { + ValueStyle::Full => Ok(format_full(&decimal)), + ValueStyle::Auto => Ok(format_auto(&decimal)), + } + } + + pub fn format_f64(style: ValueStyle, value: f64) -> String { + let decimal: BigDecimal = value.to_string().parse().unwrap_or_default(); + match style { + ValueStyle::Full => format_full(&decimal), + ValueStyle::Auto => format_auto(&decimal), + } + } + + pub fn format_f64_currency(style: ValueStyle, value: f64, symbol: &str) -> String { + format!("{}{}", symbol, Self::format_f64(style, value)) + } + + pub fn format_with_symbol(style: ValueStyle, value: &str, decimals: i32, symbol: &str) -> Result { + let formatted = Self::format(style, value, decimals)?; + Ok(format!("{} {}", formatted, symbol)) + } +} + +fn bigdecimal_to_plain_string(decimal: &BigDecimal) -> String { + let (bigint, scale) = decimal.as_bigint_and_exponent(); + let is_negative = bigint.sign() == Sign::Minus; + let digits = bigint.magnitude().to_string(); + + let result = if scale <= 0 { + let zeros = "0".repeat((-scale) as usize); + format!("{}{}", digits, zeros) + } else { + let scale = scale as usize; + if digits.len() <= scale { + let zeros = "0".repeat(scale - digits.len()); + format!("0.{}{}", zeros, digits) + } else { + let (integer, fraction) = digits.split_at(digits.len() - scale); + format!("{}.{}", integer, fraction) + } + }; + + if is_negative { format!("-{}", result) } else { result } +} + +fn format_full(decimal: &BigDecimal) -> String { + let plain = bigdecimal_to_plain_string(&decimal.normalized()); + let plain = strip_trailing_zeros(&plain); + apply_thousands_separator(&plain) +} + +fn format_auto(decimal: &BigDecimal) -> String { + if decimal.sign() == Sign::NoSign { + return "0".to_string(); + } + + let abs = decimal.abs(); + let one = BigDecimal::from(1); + let threshold = BigDecimal::new(1.into(), 4); // 0.0001 + + if abs >= one { + format_short(decimal) + } else if abs >= threshold { + format_middle(decimal) + } else { + format_full(decimal) + } +} + +fn format_short(decimal: &BigDecimal) -> String { + let plain = bigdecimal_to_plain_string(decimal); + let (integer, fraction) = plain.split_once('.').unwrap_or((&plain, "")); + let fraction = if fraction.len() > 2 { &fraction[..2] } else { fraction }; + apply_thousands_separator(&format!("{}.{:0<2}", integer, fraction)) +} + +fn format_middle(decimal: &BigDecimal) -> String { + let plain = bigdecimal_to_plain_string(&decimal.normalized()); + truncate_significant(&plain, 4) +} + +fn truncate_significant(value_str: &str, max_sig: usize) -> String { + let (is_negative, abs_str) = if let Some(stripped) = value_str.strip_prefix('-') { + (true, stripped) + } else { + (false, value_str) + }; + + let (_, fraction) = abs_str.split_once('.').unwrap_or((abs_str, "")); + let leading_zeros = fraction.chars().take_while(|&c| c == '0').count(); + let sig_end = (leading_zeros + max_sig).min(fraction.len()); + + let prefix = if is_negative { "-" } else { "" }; + format!("{}0.{}", prefix, &fraction[..sig_end]) +} + +fn strip_trailing_zeros(value: &str) -> String { + if !value.contains('.') { + return value.to_string(); + } + let trimmed = value.trim_end_matches('0'); + if let Some(stripped) = trimmed.strip_suffix('.') { + stripped.to_string() + } else { + trimmed.to_string() + } +} + +fn apply_thousands_separator(value: &str) -> String { + add_thousands_separator(value, ',', '.') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_full_style() { + assert_eq!(ValueFormatter::format(ValueStyle::Full, "123", 0).unwrap(), "123"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "0", 0).unwrap(), "0"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "1000000", 0).unwrap(), "1,000,000"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "12344", 6).unwrap(), "0.012344"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "1", 4).unwrap(), "0.0001"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "1", 6).unwrap(), "0.000001"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "12345678910111213", 18).unwrap(), "0.012345678910111213"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "1", 18).unwrap(), "0.000000000000000001"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "18761627355200464162", 18).unwrap(), "18.761627355200464162"); + assert_eq!(ValueFormatter::format(ValueStyle::Full, "4162", 18).unwrap(), "0.000000000000004162"); + } + + #[test] + fn test_full_with_symbol() { + assert_eq!(ValueFormatter::format_with_symbol(ValueStyle::Full, "2737071", 8, "BTC").unwrap(), "0.02737071 BTC"); + } + + #[test] + fn test_auto_style() { + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "123", 0).unwrap(), "123.00"); + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "1000000", 0).unwrap(), "1,000,000.00"); + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "18761627355200464162", 18).unwrap(), "18.76"); + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "12344", 6).unwrap(), "0.01234"); + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "11112344", 10).unwrap(), "0.001111"); + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "1", 4).unwrap(), "0.0001"); + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "1", 5).unwrap(), "0.00001"); + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "4162", 18).unwrap(), "0.000000000000004162"); + assert_eq!(ValueFormatter::format(ValueStyle::Auto, "0", 0).unwrap(), "0"); + } + + #[test] + fn test_invalid_input() { + assert!(ValueFormatter::format(ValueStyle::Auto, "abc", 0).is_err()); + } + + #[test] + fn test_format_f64() { + assert_eq!(ValueFormatter::format_f64(ValueStyle::Auto, 25432.50), "25,432.50"); + assert_eq!(ValueFormatter::format_f64(ValueStyle::Auto, 0.0), "0"); + assert_eq!(ValueFormatter::format_f64(ValueStyle::Auto, 1.5), "1.50"); + assert_eq!(ValueFormatter::format_f64(ValueStyle::Auto, 100000.0), "100,000.00"); + assert_eq!(ValueFormatter::format_f64(ValueStyle::Auto, 0.005), "0.005"); + assert_eq!(ValueFormatter::format_f64(ValueStyle::Auto, -123.45), "-123.45"); + assert_eq!(ValueFormatter::format_f64(ValueStyle::Auto, -1500.0), "-1,500.00"); + assert_eq!(ValueFormatter::format_f64(ValueStyle::Full, -123.456), "-123.456"); + } + + #[test] + fn test_format_f64_currency() { + assert_eq!(ValueFormatter::format_f64_currency(ValueStyle::Auto, 25432.50, "$"), "$25,432.50"); + assert_eq!(ValueFormatter::format_f64_currency(ValueStyle::Auto, -123.45, "$"), "$-123.45"); + assert_eq!(ValueFormatter::format_f64_currency(ValueStyle::Auto, 0.0, "$"), "$0"); + } +} diff --git a/core/crates/portfolio/Cargo.toml b/core/crates/portfolio/Cargo.toml new file mode 100644 index 0000000000..7dac427042 --- /dev/null +++ b/core/crates/portfolio/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "portfolio" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +chrono = { workspace = true } + +primitives = { path = "../primitives" } +number_formatter = { path = "../number_formatter" } +storage = { path = "../storage" } diff --git a/core/crates/portfolio/src/lib.rs b/core/crates/portfolio/src/lib.rs new file mode 100644 index 0000000000..a35b884665 --- /dev/null +++ b/core/crates/portfolio/src/lib.rs @@ -0,0 +1,2 @@ +mod portfolio_client; +pub use portfolio_client::PortfolioClient; diff --git a/core/crates/portfolio/src/portfolio_client.rs b/core/crates/portfolio/src/portfolio_client.rs new file mode 100644 index 0000000000..f3a48d1f04 --- /dev/null +++ b/core/crates/portfolio/src/portfolio_client.rs @@ -0,0 +1,109 @@ +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::error::Error; + +use number_formatter::BigNumberFormatter; +use primitives::{ChartPeriod, ChartValue, ChartValuePercentage, PortfolioAllocation, PortfolioAsset, PortfolioAssets, PriceConfig}; +use storage::{AssetsRepository, ChartsRepository, Database, PricesRepository}; + +pub struct PortfolioClient { + database: Database, + config: PriceConfig, +} + +struct ResolvedAsset { + asset: PortfolioAsset, + balance: f64, + price_id: String, + current_value: f64, +} + +impl PortfolioClient { + pub fn new(database: Database, config: PriceConfig) -> Self { + Self { database, config } + } + + pub fn get_portfolio_charts(&self, assets: Vec, period: ChartPeriod) -> Result> { + let assets: Vec = assets.into_iter().filter_map(|input| self.resolve_asset(input)).collect(); + let chart_data = self.get_chart_values(&assets, &period); + Ok(Self::build_portfolio(assets, chart_data)) + } + + fn get_chart_values(&self, assets: &[ResolvedAsset], period: &ChartPeriod) -> BTreeMap { + assets + .iter() + .flat_map(|r| { + self.database + .charts() + .ok() + .and_then(|mut db| db.get_charts(&r.price_id, period).ok()) + .unwrap_or_default() + .into_iter() + .map(|(ts, price)| (ts.and_utc().timestamp(), r.balance * price)) + }) + .fold(BTreeMap::new(), |mut acc, (ts, value)| { + *acc.entry(ts).or_default() += value; + acc + }) + } + + fn build_portfolio(assets: Vec, chart_data: BTreeMap) -> PortfolioAssets { + let values: Vec = chart_data + .into_iter() + .map(|(ts, value)| ChartValue { + timestamp: ts as i32, + value: value as f32, + }) + .collect(); + + let cmp = |a: &&ChartValue, b: &&ChartValue| a.value.partial_cmp(&b.value).unwrap_or(Ordering::Equal); + let all_time_high = values.iter().max_by(cmp).cloned(); + let all_time_low = values.iter().min_by(cmp).cloned(); + + let total_value: f64 = assets.iter().map(|r| r.current_value).sum(); + let total_value_f32 = total_value as f32; + + let to_percentage = |cv: &ChartValue| ChartValuePercentage { + date: chrono::DateTime::from_timestamp(cv.timestamp as i64, 0).unwrap_or_default(), + value: cv.value, + percentage: if total_value_f32 > 0.0 { + (cv.value - total_value_f32) / total_value_f32 * 100.0 + } else { + 0.0 + }, + }; + + let allocation: Vec = assets + .into_iter() + .map(|r| PortfolioAllocation { + asset_id: r.asset.asset_id, + value: r.current_value as f32, + percentage: if total_value > 0.0 { (r.current_value / total_value) as f32 } else { 0.0 }, + }) + .collect(); + + PortfolioAssets { + total_value: total_value_f32, + values, + all_time_high: all_time_high.as_ref().map(to_percentage), + all_time_low: all_time_low.as_ref().map(to_percentage), + allocation, + } + } + + fn resolve_asset(&self, input: PortfolioAsset) -> Option { + let asset_id = &input.asset_id; + let asset = self.database.assets().ok()?.get_asset(asset_id).ok()?; + let balance = BigNumberFormatter::value_as_f64(&input.value, asset.decimals as u32).ok()?; + let key = self.database.prices().ok()?.get_primary_price_key(asset_id, self.config.primary_price_max_age).ok()?; + let price_id = key.id(); + let price = self.database.prices().ok()?.get_price_by_id(&price_id).map(|p| p.price).unwrap_or_default(); + + Some(ResolvedAsset { + asset: input, + balance, + price_id, + current_value: balance * price, + }) + } +} diff --git a/core/crates/pricer/Cargo.toml b/core/crates/pricer/Cargo.toml new file mode 100644 index 0000000000..24cf0eb71e --- /dev/null +++ b/core/crates/pricer/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pricer" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +serde_json = { workspace = true } +chrono = { workspace = true } + +primitives = { path = "../primitives" } +prices = { path = "../prices" } +number_formatter = { path = "../number_formatter" } +storage = { path = "../storage" } +localizer = { path = "../../crates/localizer" } +cacher = { path = "../../crates/cacher" } +gem_tracing = { path = "../tracing" } + +[dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } diff --git a/core/crates/pricer/src/chart_client.rs b/core/crates/pricer/src/chart_client.rs new file mode 100644 index 0000000000..80718916ae --- /dev/null +++ b/core/crates/pricer/src/chart_client.rs @@ -0,0 +1,33 @@ +use primitives::{AssetId, ChartPeriod, ChartValue, DEFAULT_FIAT_CURRENCY, PriceConfig}; +use std::error::Error; +use storage::{ChartsRepository, Database, PricesRepository}; + +#[derive(Clone)] +pub struct ChartClient { + database: Database, + config: PriceConfig, +} + +impl ChartClient { + pub fn new(database: Database, config: PriceConfig) -> Self { + Self { database, config } + } + + pub async fn get_charts_prices(&self, asset_id: &AssetId, period: ChartPeriod, currency: &str) -> Result, Box> { + let base_rate = self.database.fiat()?.get_fiat_rate(DEFAULT_FIAT_CURRENCY)?.as_primitive(); + let rate = self.database.fiat()?.get_fiat_rate(currency)?.as_primitive(); + let rate_multiplier = rate.multiplier(base_rate.rate); + + let key = self.database.prices()?.get_primary_price_key(asset_id, self.config.primary_price_max_age)?; + Ok(self + .database + .charts()? + .get_charts(&key.id(), &period)? + .into_iter() + .map(|(ts, price)| ChartValue { + timestamp: ts.and_utc().timestamp() as i32, + value: (price * rate_multiplier) as f32, + }) + .collect()) + } +} diff --git a/core/crates/pricer/src/lib.rs b/core/crates/pricer/src/lib.rs new file mode 100644 index 0000000000..fd569a4ed6 --- /dev/null +++ b/core/crates/pricer/src/lib.rs @@ -0,0 +1,20 @@ +pub mod chart_client; +pub mod markets_client; +pub mod price_alert_client; +pub mod price_client; + +use prices::{PriceAssetsProvider, PriceProviderEndpoints}; +use primitives::PriceProvider; +use std::collections::HashMap; +use std::sync::Arc; + +pub use chart_client::ChartClient; +pub use markets_client::MarketsClient; +pub use price_alert_client::{PriceAlertClient, PriceAlertNotification, PriceAlertRules}; +pub use price_client::PriceClient; + +pub type PriceProviders = HashMap>; + +pub fn build_price_providers(endpoints: &PriceProviderEndpoints, providers: impl IntoIterator) -> PriceProviders { + providers.into_iter().map(|provider| (provider, endpoints.provider(provider))).collect() +} diff --git a/core/crates/pricer/src/markets_client.rs b/core/crates/pricer/src/markets_client.rs new file mode 100644 index 0000000000..f4803d40c6 --- /dev/null +++ b/core/crates/pricer/src/markets_client.rs @@ -0,0 +1,55 @@ +use std::error::Error; + +use cacher::{CacheError, CacheKey, CacherClient}; +use primitives::{AssetId, AssetTag, Markets, MarketsAssets, PriceId, PriceProvider}; +use storage::{Database, PricesRepository, TagRepository}; + +#[derive(Clone)] +pub struct MarketsClient { + database: Database, + cacher: CacherClient, +} + +impl MarketsClient { + pub fn new(database: Database, cacher: CacherClient) -> Self { + Self { database, cacher } + } + + pub async fn get_markets(&self) -> Result> { + match self.cacher.get_cached_optional(CacheKey::Markets).await? { + Some(markets) => Ok(markets), + None => Err(Box::new(CacheError::not_found_resource("Markets"))), + } + } + + pub async fn set_markets(&self, markets: Markets) -> Result<(), Box> { + self.cacher.set_cached(CacheKey::Markets, &markets).await + } + + pub async fn get_asset_ids_for_provider_price_ids(&self, provider: PriceProvider, provider_price_ids: Vec) -> Result, Box> { + let price_ids: Vec = provider_price_ids.iter().map(|id| PriceId::id_for(provider, id)).collect(); + let assets = self.database.prices()?.get_prices_assets_for_price_ids(price_ids.clone())?; + let asset_map: std::collections::HashMap<_, _> = assets.into_iter().map(|x| (x.price_id.to_string(), x.asset_id)).collect(); + Ok(price_ids + .into_iter() + .filter_map(|price_id| asset_map.get(&price_id).map(|asset_id| asset_id.0.clone())) + .collect()) + } + + pub fn set_asset_ids_for_tag(&self, tag: AssetTag, asset_ids: Vec) -> Result> { + Ok(self.database.tag()?.set_assets_tags_for_tag(tag.as_ref(), asset_ids)?) + } + + pub fn get_asset_ids_for_tag(&self, tag: AssetTag) -> Result, Box> { + Ok(self.database.tag()?.get_assets_tags_for_tag(tag.as_ref())?.into_iter().map(|x| x.asset_id.0).collect()) + } + + pub fn get_market_assets(&self) -> Result> { + let assets = MarketsAssets { + trending: self.get_asset_ids_for_tag(AssetTag::Trending)?, + gainers: self.get_asset_ids_for_tag(AssetTag::Gainers)?, + losers: self.get_asset_ids_for_tag(AssetTag::Losers)?, + }; + Ok(assets) + } +} diff --git a/core/crates/pricer/src/price_alert_client.rs b/core/crates/pricer/src/price_alert_client.rs new file mode 100644 index 0000000000..3ab587bc9a --- /dev/null +++ b/core/crates/pricer/src/price_alert_client.rs @@ -0,0 +1,292 @@ +use chrono::{Duration, Utc}; +use gem_tracing::info_with_fields; +use localizer::LanguageLocalizer; +use number_formatter::NumberFormatter; +use primitives::{ + Asset, AssetId, DEFAULT_FIAT_CURRENCY, Device, GorushNotification, Price, PriceAlert, PriceAlertDirection, PriceAlertType, PriceAlerts, PriceData, PushNotification, + PushNotificationAsset, PushNotificationTypes, +}; +use std::collections::HashSet; +use std::error::Error; +use std::time::Duration as StdDuration; +use storage::{AssetsRepository, Database, PriceAlertsRepository}; + +const DEFAULT_RANK: i32 = 1000; + +#[derive(Clone)] +pub struct PriceAlertClient { + database: Database, +} + +#[derive(Clone, Debug)] +pub struct PriceAlertNotification { + pub device: Device, + pub asset: Asset, + pub price: Price, + pub alert_type: PriceAlertType, + pub price_alert: PriceAlert, + pub milestone: Option, +} + +#[derive(Clone, Debug)] +pub struct PriceAlertRules { + pub notification_cooldown: StdDuration, + pub price_change_threshold: f64, + pub rank_divisor: f64, + pub milestones: Vec, +} + +struct AlertResult { + alert_type: PriceAlertType, + milestone: Option, +} + +impl AlertResult { + fn new(alert_type: PriceAlertType) -> Self { + Self { alert_type, milestone: None } + } + + fn with_milestone(alert_type: PriceAlertType, milestone: f64) -> Self { + Self { + alert_type, + milestone: Some(milestone), + } + } +} + +impl PriceAlertRules { + fn calculate_threshold(&self, rank: i32) -> f64 { + let rank = if rank > 0 { rank } else { DEFAULT_RANK }; + self.price_change_threshold * (1.0 + (rank as f64).ln() / self.rank_divisor) + } + + fn find_crossed_milestone(&self, price_24h_ago: f64, current_price: f64) -> Option { + if current_price <= price_24h_ago { + return None; + } + + self.milestones.iter().find(|&&milestone| price_24h_ago < milestone && current_price >= milestone).copied() + } +} + +fn calculate_price_24h_ago(current_price: f64, change_percent: f64) -> f64 { + let divisor = 1.0 + change_percent / 100.0; + if divisor <= 0.0 { + return current_price; + } + current_price / divisor +} + +impl PriceAlertClient { + pub fn new(database: Database) -> Self { + Self { database } + } + + pub async fn get_price_alerts(&self, device_id: &str, asset_id: Option<&AssetId>) -> Result> { + Ok(self + .database + .price_alerts()? + .get_price_alerts_for_device_id(device_id, asset_id)? + .into_iter() + .map(|x| x.price_alert) + .collect()) + } + + pub async fn add_price_alerts(&self, device_id: &str, price_alerts: PriceAlerts) -> Result> { + Ok(self.database.price_alerts()?.add_price_alerts(device_id, price_alerts)?) + } + + pub async fn delete_price_alerts(&self, device_id: &str, price_alerts: PriceAlerts) -> Result> { + let ids = price_alerts.iter().map(|x| x.id()).collect::>().into_iter().collect(); + Ok(self.database.price_alerts()?.delete_price_alerts(device_id, ids)?) + } + + pub async fn get_devices_to_alert(&self, rules: PriceAlertRules, max_age: StdDuration) -> Result, Box> { + let now = Utc::now(); + let cooldown = Duration::seconds(rules.notification_cooldown.as_secs() as i64); + let after_notified_at = now - cooldown; + let price_alerts = self.database.price_alerts()?.get_price_alerts(after_notified_at.naive_utc(), max_age)?; + + let mut results: Vec = Vec::new(); + let mut price_alert_ids: HashSet = HashSet::new(); + + for (price_alert, price_data, device) in price_alerts { + if let Some(alert_result) = self.get_price_alert_type(&price_alert, &price_data, &rules) { + let notification = self.price_alert_notification(device, &price_data, price_alert.clone(), alert_result.alert_type, alert_result.milestone)?; + price_alert_ids.insert(price_alert.id()); + results.push(notification); + } + } + + self.database + .price_alerts()? + .update_price_alerts_set_notified_at(price_alert_ids.into_iter().collect(), now.naive_utc())?; + Ok(results) + } + + fn get_price_alert_type(&self, price_alert: &PriceAlert, price_data: &PriceData, rules: &PriceAlertRules) -> Option { + // User-defined price target + if let Some(target_price) = price_alert.price { + let direction = price_alert.price_direction.clone()?; + let alert_type = match direction { + PriceAlertDirection::Up if price_data.price >= target_price => Some(PriceAlertType::PriceUp), + PriceAlertDirection::Down if price_data.price <= target_price => Some(PriceAlertType::PriceDown), + _ => None, + }; + return alert_type.map(AlertResult::new); + } + + // User-defined percent change + if let Some(target_percent) = price_alert.price_percent_change { + let direction = price_alert.price_direction.clone()?; + let alert_type = match direction { + PriceAlertDirection::Up if price_data.price_change_percentage_24h >= target_percent => Some(PriceAlertType::PricePercentChangeUp), + PriceAlertDirection::Down if price_data.price_change_percentage_24h <= -target_percent => Some(PriceAlertType::PricePercentChangeDown), + _ => None, + }; + return alert_type.map(AlertResult::new); + } + + // All-time high check + if price_data.all_time_high > 0.0 && price_data.price > price_data.all_time_high { + return Some(AlertResult::new(PriceAlertType::AllTimeHigh)); + } + + // Price milestone check + let price_24h_ago = calculate_price_24h_ago(price_data.price, price_data.price_change_percentage_24h); + if let Some(milestone) = rules.find_crossed_milestone(price_24h_ago, price_data.price) { + return Some(AlertResult::with_milestone(PriceAlertType::PriceMilestone, milestone)); + } + + // Dynamic threshold based on rank + let threshold = rules.calculate_threshold(price_data.market_cap_rank.unwrap_or(0)); + if price_data.price_change_percentage_24h > threshold { + return Some(AlertResult::new(PriceAlertType::PriceChangesUp)); + } + if price_data.price_change_percentage_24h < -threshold { + return Some(AlertResult::new(PriceAlertType::PriceChangesDown)); + } + + None + } + + fn price_alert_notification( + &self, + device: Device, + price_data: &PriceData, + price_alert: PriceAlert, + alert_type: PriceAlertType, + milestone: Option, + ) -> Result> { + let asset = self.database.assets()?.get_asset(&price_alert.asset_id)?; + let base_rate = self.database.fiat()?.get_fiat_rate(DEFAULT_FIAT_CURRENCY)?; + let rate = self.database.fiat()?.get_fiat_rate(&device.currency)?; + + let price = Price::new(price_data.price, price_data.price_change_percentage_24h, price_data.last_updated_at, price_data.provider); + let price = price.new_with_rate(base_rate.rate, rate.rate); + + Ok(PriceAlertNotification { + device, + asset, + price, + alert_type, + price_alert, + milestone, + }) + } + + pub fn get_notifications_for_price_alerts(&self, notifications: Vec) -> Vec { + let mut results = vec![]; + let formatter = NumberFormatter::new(); + + for alert in notifications { + if !alert.device.can_receive_price_alerts() { + continue; + } + + let current_price = match formatter.currency(alert.price.price, &alert.device.currency) { + Some(p) => p, + None => { + info_with_fields!("unknown_currency_symbol", currency = &alert.device.currency); + continue; + } + }; + + let change = formatter.percent(alert.price.price_change_percentage_24h, alert.device.locale.as_str()); + let localizer = LanguageLocalizer::new_with_language(alert.device.locale.as_str()); + + let message = match &alert.alert_type { + PriceAlertType::PriceUp | PriceAlertType::PriceDown | PriceAlertType::PriceMilestone => { + let Some(target_value) = Self::price_alert_target_value(&alert) else { + continue; + }; + let Some(target_price) = formatter.currency(target_value, &alert.device.currency) else { + continue; + }; + localizer.price_alert_target(&alert.asset.full_name(), &target_price, ¤t_price, &change) + } + PriceAlertType::PriceChangesUp | PriceAlertType::PricePercentChangeUp => localizer.price_alert_up(&alert.asset.full_name(), ¤t_price, &change), + PriceAlertType::PriceChangesDown | PriceAlertType::PricePercentChangeDown => localizer.price_alert_down(&alert.asset.full_name(), ¤t_price, &change), + PriceAlertType::AllTimeHigh => localizer.price_alert_all_time_high(&alert.asset.name, ¤t_price), + }; + + let data = PushNotification { + data: serde_json::to_value(&PushNotificationAsset { asset_id: alert.asset.id.clone() }).ok(), + notification_type: PushNotificationTypes::PriceAlert, + }; + + results.extend(GorushNotification::from_device(alert.device.clone(), message.title, message.description, data)); + } + + results + } + + fn price_alert_target_value(alert: &PriceAlertNotification) -> Option { + match &alert.alert_type { + PriceAlertType::PriceUp | PriceAlertType::PriceDown => alert.price_alert.price, + PriceAlertType::PriceMilestone => alert.milestone, + PriceAlertType::PriceChangesUp + | PriceAlertType::PriceChangesDown + | PriceAlertType::PricePercentChangeUp + | PriceAlertType::PricePercentChangeDown + | PriceAlertType::AllTimeHigh => None, + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use primitives::{Chain, PriceProvider}; + + use super::*; + + #[test] + fn test_price_alert_target_value() { + let asset = Asset::from_chain(Chain::Bitcoin); + let alert = PriceAlertNotification { + device: Device::mock(), + asset: asset.clone(), + price: Price::new(80_954.0, -0.27, Utc::now(), PriceProvider::Coingecko), + alert_type: PriceAlertType::PriceDown, + price_alert: PriceAlert::new_price(asset.id.clone(), "USD".to_string(), 81_000.0, PriceAlertDirection::Down), + milestone: None, + }; + + assert_eq!(PriceAlertClient::price_alert_target_value(&alert), Some(81_000.0)); + + let milestone_alert = PriceAlertNotification { + alert_type: PriceAlertType::PriceMilestone, + price_alert: PriceAlert::new_auto(asset.id, "USD".to_string()), + milestone: Some(100_000.0), + ..alert.clone() + }; + assert_eq!(PriceAlertClient::price_alert_target_value(&milestone_alert), Some(100_000.0)); + + let automatic_alert = PriceAlertNotification { + alert_type: PriceAlertType::PriceChangesUp, + ..alert + }; + assert_eq!(PriceAlertClient::price_alert_target_value(&automatic_alert), None); + } +} diff --git a/core/crates/pricer/src/price_client.rs b/core/crates/pricer/src/price_client.rs new file mode 100644 index 0000000000..6c0186e826 --- /dev/null +++ b/core/crates/pricer/src/price_client.rs @@ -0,0 +1,205 @@ +use crate::PriceProviders; +use cacher::{CacheError, CacheKey, CacherClient}; +use gem_tracing::error_with_fields; +use prices::{AssetPriceFull, AssetPriceMapping, PriceAssetsProvider}; +use primitives::{AssetId, AssetMarketPrice, AssetPriceInfo, AssetPrices, ChartTimeframe, FiatRate, PriceData, PriceId, PriceProvider}; +use std::collections::HashSet; +use std::error::Error; +use storage::models::{NewPriceRow, PriceAssetRow}; +use storage::{AssetsRepository, ChartsRepository, Database, PricesRepository}; + +#[derive(Clone)] +pub struct PriceClient { + database: Database, + cacher_client: CacherClient, +} + +impl PriceClient { + pub fn new(database: Database, cacher_client: CacherClient) -> Self { + Self { database, cacher_client } + } + + pub async fn set_fiat_rates(&self, rates: Vec) -> Result> { + let count = self + .database + .fiat()? + .set_fiat_rates(rates.clone().into_iter().map(storage::models::FiatRateRow::from_primitive).collect())?; + + self.set_cache_fiat_rates(rates).await?; + + Ok(count) + } + + pub fn get_fiat_rates(&self) -> Result, Box> { + Ok(self.database.fiat()?.get_fiat_rates()?.into_iter().map(|r| r.as_primitive()).collect()) + } + + pub fn get_fiat_rate(&self, symbol: &str) -> Result> { + Ok(self.database.fiat()?.get_fiat_rate(symbol)?.as_primitive()) + } + + pub async fn get_asset_price(&self, asset_id: &AssetId, currency: &str) -> Result> { + let rate = self.get_fiat_rate(currency)?.rate; + let price = self.get_cache_price(asset_id).await?; + let prices = self + .database + .prices()? + .get_prices_for_asset(asset_id)? + .into_iter() + .map(|row| row.as_primitive().with_rate(rate)) + .collect(); + Ok(AssetMarketPrice { + price: Some(price.as_price_primitive_with_rate(rate)), + market: Some(price.as_market_with_rate(rate)), + prices: Some(prices), + }) + } + + pub async fn set_cache_fiat_rates(&self, rates: Vec) -> Result<(), Box> { + self.cacher_client.set_cached(CacheKey::FiatRates, &rates).await + } + + pub async fn get_cache_fiat_rates(&self) -> Result, Box> { + match self.cacher_client.get_cached_optional::>(CacheKey::FiatRates).await? { + Some(rates) => Ok(rates), + None => Err(Box::new(CacheError::not_found_resource("FiatRates"))), + } + } + + pub async fn set_cache_prices(&self, prices: Vec, ttl_seconds: i64) -> Result> { + let values: Vec<(String, String)> = prices + .iter() + .map(|x| (CacheKey::Price(&x.asset_id.to_string()).key(), serde_json::to_string(&x).unwrap())) + .collect(); + + self.cacher_client.set_values_with_publish(values, ttl_seconds).await + } + + pub async fn get_cache_prices(&self, asset_ids: Vec) -> Result, Box> { + let keys: Vec = asset_ids.iter().map(|x| CacheKey::Price(&x.to_string()).key()).collect(); + self.cacher_client.get_values(keys).await + } + + pub async fn get_cache_price(&self, asset_id: &AssetId) -> Result> { + let id = asset_id.to_string(); + match self.cacher_client.get_cached_optional::(CacheKey::Price(&id)).await? { + Some(price) => Ok(price), + None => Err(Box::new(CacheError::not_found("Price", id))), + } + } + + pub async fn get_asset_prices(&self, currency: &str, asset_ids: Vec) -> Result> { + let rate = self.get_fiat_rate(currency)?.rate; + let prices = self + .get_cache_prices(asset_ids) + .await + .unwrap_or_default() + .into_iter() + .map(|x| x.as_asset_price_primitive_with_rate(rate)) + .collect(); + + Ok(AssetPrices { + currency: currency.to_string(), + prices, + }) + } + + pub async fn aggregate_charts(&self, timeframe: ChartTimeframe) -> Result> { + Ok(self.database.charts()?.aggregate_charts(timeframe)?) + } + + pub async fn delete_charts(&self, timeframe: ChartTimeframe, before: chrono::NaiveDateTime) -> Result> { + Ok(self.database.charts()?.delete_charts(timeframe, before)?) + } + + pub async fn track_observed_assets(&self, asset_ids: &[AssetId]) -> Result<(), Box> { + let key = CacheKey::ObservedAssets; + let ids: Vec = asset_ids.iter().map(|id| id.to_string()).collect(); + self.cacher_client.sorted_set_incr_with_expire(&key.key(), &ids, key.ttl() as i64).await + } + + pub async fn add_prices(&self, provider: &dyn PriceAssetsProvider, mappings: Vec) -> Result, Box> { + let mappings = self.filter_existing_assets(mappings)?; + if mappings.is_empty() { + return Ok(vec![]); + } + let prices = provider.get_prices(mappings).await?; + if prices.is_empty() { + return Ok(vec![]); + } + self.save_prices(provider.provider(), &prices)?; + Ok(prices.iter().map(AssetPriceFull::as_price_data).collect()) + } + + pub async fn add_prices_for_asset_id(&self, providers: &PriceProviders, asset_id: &AssetId) -> Result> { + let asset_id_str = asset_id.to_string(); + let mut count = 0; + for provider in providers.values() { + match self.add_prices_with_mappings(provider.as_ref(), provider.get_mappings_for_asset_id(asset_id).await).await { + Ok(added) => count += added, + Err(err) => { + let kind = provider.provider(); + error_with_fields!("fetch prices provider failed", &*err, provider = kind.id(), asset_id = asset_id_str.as_str()); + } + } + } + Ok(count) + } + + pub async fn add_prices_for_price_id(&self, providers: &PriceProviders, price_id: &PriceId) -> Result> { + let Some(provider) = providers.get(&price_id.provider) else { + return Ok(0); + }; + match self + .add_prices_with_mappings(provider.as_ref(), provider.get_mappings_for_price_id(&price_id.provider_price_id).await) + .await + { + Ok(added) => Ok(added), + Err(err) => { + let kind = provider.provider(); + let price_id_str = price_id.to_string(); + error_with_fields!("fetch prices provider failed", &*err, provider = kind.id(), price_id = price_id_str.as_str()); + Ok(0) + } + } + } + + async fn add_prices_with_mappings( + &self, + provider: &dyn PriceAssetsProvider, + mappings: Result, Box>, + ) -> Result> { + Ok(self.add_prices(provider, mappings?).await?.len()) + } + + pub fn filter_existing_assets(&self, mappings: Vec) -> Result, Box> { + if mappings.is_empty() { + return Ok(vec![]); + } + let asset_ids: Vec = mappings.iter().map(|m| m.asset_id.clone()).collect(); + let existing: HashSet = self.database.assets()?.get_assets_rows(asset_ids)?.into_iter().map(|a| a.as_asset_id()).collect(); + Ok(mappings.into_iter().filter(|m| existing.contains(&m.asset_id)).collect()) + } + + pub fn save_prices(&self, provider: PriceProvider, prices: &[AssetPriceFull]) -> Result> { + let new_prices: Vec = prices + .iter() + .map(|p| { + NewPriceRow::with_market_data( + provider, + p.mapping.provider_price_id.clone(), + p.market.as_ref(), + Some(p.price.price), + Some(p.price.price_change_percentage_24h), + ) + }) + .collect(); + let asset_rows: Vec = prices + .iter() + .map(|p| PriceAssetRow::new(p.mapping.asset_id.clone(), provider, &p.mapping.provider_price_id)) + .collect(); + self.database.prices()?.add_prices(new_prices)?; + self.database.prices()?.set_prices_assets(asset_rows)?; + Ok(prices.len()) + } +} diff --git a/core/crates/prices/Cargo.toml b/core/crates/prices/Cargo.toml new file mode 100644 index 0000000000..38d1873065 --- /dev/null +++ b/core/crates/prices/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "prices" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +chain_primitives = { path = "../chain_primitives" } +coingecko = { path = "../coingecko" } +gem_client = { path = "../gem_client", features = ["reqwest"] } +primitives = { path = "../primitives" } +serde_serializers = { path = "../serde_serializers" } + +[dev-dependencies] +coingecko = { path = "../coingecko", features = ["testkit"] } +settings = { path = "../settings", features = ["testkit"] } +tokio.workspace = true + +[features] +default = [] +price_integration_tests = [] diff --git a/core/crates/prices/src/lib.rs b/core/crates/prices/src/lib.rs new file mode 100644 index 0000000000..1c09bcdbe0 --- /dev/null +++ b/core/crates/prices/src/lib.rs @@ -0,0 +1,57 @@ +pub mod model; +pub mod providers; + +use async_trait::async_trait; +use gem_client::ReqwestClient; +use primitives::{AssetId, ChartValue}; +use std::error::Error; +use std::sync::Arc; +use std::time::Duration; + +pub use model::{AssetPriceFull, AssetPriceMapping, PriceProviderAsset, PriceProviderAssetMetadata}; +pub use primitives::PriceProvider; +pub use providers::coingecko::provider::CoinGeckoPricesProvider; +pub use providers::defillama::provider::DefiLlamaProvider; +pub use providers::pyth::provider::PythProvider; + +pub use providers::jupiter::provider::JupiterProvider; + +#[derive(Clone, Debug)] +pub struct PriceProviderEndpoints { + pub coingecko_api_key: String, + pub pyth_url: String, + pub jupiter_url: String, + pub defillama_url: String, +} + +impl PriceProviderEndpoints { + pub fn provider(&self, provider: PriceProvider) -> Arc { + match provider { + PriceProvider::Coingecko => Arc::new(CoinGeckoPricesProvider::new(&self.coingecko_api_key)), + PriceProvider::Pyth => Arc::new(PythProvider::new(ReqwestClient::new(self.pyth_url.clone(), reqwest::Client::new()))), + PriceProvider::Jupiter => Arc::new(JupiterProvider::new(ReqwestClient::new(self.jupiter_url.clone(), reqwest::Client::new()))), + PriceProvider::DefiLlama => Arc::new(DefiLlamaProvider::new(ReqwestClient::new(self.defillama_url.clone(), reqwest::Client::new()))), + } + } +} + +#[async_trait] +pub trait PriceAssetsProvider: Send + Sync { + fn provider(&self) -> PriceProvider; + async fn get_assets(&self) -> Result, Box>; + async fn get_mappings_for_asset_id(&self, asset_id: &AssetId) -> Result, Box>; + async fn get_mappings_for_price_id(&self, provider_price_id: &str) -> Result, Box>; + async fn get_assets_new(&self) -> Result, Box> { + Ok(vec![]) + } + async fn get_assets_metadata(&self, _mappings: Vec) -> Result, Box> { + Ok(vec![]) + } + async fn get_prices(&self, mappings: Vec) -> Result, Box>; + async fn get_charts_daily(&self, _provider_price_id: &str) -> Result, Box> { + Ok(vec![]) + } + async fn get_charts_hourly(&self, _provider_price_id: &str, _duration: Duration) -> Result, Box> { + Ok(vec![]) + } +} diff --git a/core/crates/prices/src/model.rs b/core/crates/prices/src/model.rs new file mode 100644 index 0000000000..fa14c18d1c --- /dev/null +++ b/core/crates/prices/src/model.rs @@ -0,0 +1,98 @@ +use chrono::Utc; +use primitives::{AssetId, AssetLink, AssetMarket, Price, PriceData, PriceId, PriceProvider}; + +#[derive(Debug, Clone)] +pub struct AssetPriceMapping { + pub asset_id: AssetId, + pub provider_price_id: String, +} + +impl AssetPriceMapping { + pub fn new(asset_id: AssetId, provider_price_id: String) -> Self { + Self { asset_id, provider_price_id } + } +} + +#[derive(Debug, Clone)] +pub struct PriceProviderAsset { + pub mapping: AssetPriceMapping, + pub market: Option, + pub price: Option, + pub price_change_percentage_24h: Option, +} + +impl PriceProviderAsset { + pub fn new(mapping: AssetPriceMapping, market: Option) -> Self { + Self { + mapping, + market, + price: None, + price_change_percentage_24h: None, + } + } + + pub fn with_price(mapping: AssetPriceMapping, market: Option, price: Option, price_change_percentage_24h: Option) -> Self { + Self { + mapping, + market, + price, + price_change_percentage_24h, + } + } +} + +#[derive(Debug, Clone)] +pub struct PriceProviderAssetMetadata { + pub asset_id: AssetId, + pub rank: i32, + pub links: Vec, +} + +impl PriceProviderAssetMetadata { + pub fn new(asset_id: AssetId, rank: i32, links: Vec) -> Self { + Self { asset_id, rank, links } + } +} + +#[derive(Debug, Clone)] +pub struct AssetPriceFull { + pub mapping: AssetPriceMapping, + pub price: Price, + pub market: Option, +} + +impl AssetPriceFull { + pub fn new(mapping: AssetPriceMapping, price: Price, market: Option) -> Self { + Self { mapping, price, market } + } + + pub fn simple(mapping: AssetPriceMapping, price: f64, price_change_percentage_24h: f64, provider: PriceProvider) -> Self { + Self::new(mapping, Price::new(price, price_change_percentage_24h, Utc::now(), provider), None) + } + + pub fn from_provider_asset(asset: PriceProviderAsset, provider: PriceProvider) -> Self { + Self::new( + asset.mapping, + Price::new(asset.price.unwrap_or_default(), asset.price_change_percentage_24h.unwrap_or_default(), Utc::now(), provider), + asset.market, + ) + } + + pub fn as_price_data(&self) -> PriceData { + let market = self.market.clone().unwrap_or_default(); + PriceData { + id: PriceId::new(self.price.provider, self.mapping.provider_price_id.clone()), + provider: self.price.provider, + provider_price_id: self.mapping.provider_price_id.clone(), + price: self.price.price, + price_change_percentage_24h: self.price.price_change_percentage_24h, + all_time_high: market.all_time_high.unwrap_or_default(), + all_time_high_date: market.all_time_high_date, + all_time_low: market.all_time_low.unwrap_or_default(), + all_time_low_date: market.all_time_low_date, + market_cap_rank: market.market_cap_rank, + total_volume: market.total_volume, + last_updated_at: self.price.updated_at, + } + } +} diff --git a/core/crates/prices/src/providers/coingecko/mapper.rs b/core/crates/prices/src/providers/coingecko/mapper.rs new file mode 100644 index 0000000000..c88bd3ae19 --- /dev/null +++ b/core/crates/prices/src/providers/coingecko/mapper.rs @@ -0,0 +1,250 @@ +use std::collections::HashMap; + +use chain_primitives::format_token_id; +use chrono::{DateTime, Utc}; +use coingecko::{Coin, CoinInfo, CoinMarket, get_chain_for_coingecko_platform_id, get_chains_for_coingecko_market_id, model::MarketChart}; +use primitives::{AssetId, AssetLink, AssetMarket, ChartValue, ChartValuePercentage, LinkType, Price, PriceProvider}; + +use crate::{AssetPriceFull, AssetPriceMapping, PriceProviderAsset, PriceProviderAssetMetadata}; + +pub fn map_market_chart(chart: MarketChart) -> Vec { + chart + .prices + .into_iter() + .filter_map(|p| { + let ts_ms = *p.first()?; + let value = *p.get(1)?; + let ts = DateTime::::from_timestamp_millis(ts_ms as i64)?.timestamp() as i32; + Some(ChartValue { + timestamp: ts, + value: value as f32, + }) + }) + .collect() +} + +pub fn map_coin_mappings(id: &str, platforms: &HashMap>) -> Vec { + let chains = get_chains_for_coingecko_market_id(id) + .into_iter() + .map(|chain| AssetPriceMapping::new(chain.as_asset_id(), id.to_string())); + let tokens = platforms.iter().filter_map(|(platform_id, contract)| { + let chain = get_chain_for_coingecko_platform_id(platform_id)?; + let contract_address = contract.as_ref().filter(|a| !a.is_empty())?; + let token_id = format_token_id(chain, contract_address.clone())?; + Some(AssetPriceMapping::new(AssetId::from(chain, Some(token_id)), id.to_string())) + }); + chains.chain(tokens).collect() +} + +pub fn map_coins_to_mappings(coins: Vec) -> Vec { + coins.iter().flat_map(|coin| map_coin_mappings(&coin.id, &coin.platforms)).collect() +} + +pub fn map_coins_to_assets(coins: Vec, markets_by_id: HashMap) -> Vec { + coins + .iter() + .flat_map(|coin| { + let raw = markets_by_id.get(&coin.id); + let market = raw.map(coin_market_to_asset_market); + let price = raw.and_then(|m| m.current_price); + let price_change_24h = raw.and_then(|m| m.price_change_percentage_24h); + map_coin_mappings(&coin.id, &coin.platforms) + .into_iter() + .map(move |mapping| PriceProviderAsset::with_price(mapping, market.clone(), price, price_change_24h)) + }) + .collect() +} + +pub fn map_coin_markets(markets: Vec, by_id: &HashMap>) -> Vec { + markets + .into_iter() + .flat_map(|market| { + by_id + .get(&market.id) + .cloned() + .unwrap_or_default() + .into_iter() + .map(move |mapping| map_coin_market(market.clone(), mapping)) + }) + .collect() +} + +pub fn map_coin_market(market: CoinMarket, mapping: AssetPriceMapping) -> AssetPriceFull { + let updated_at = market.last_updated.unwrap_or_else(Utc::now); + let price = market.current_price.unwrap_or_default(); + let market_data = coin_market_to_asset_market(&market); + AssetPriceFull::new( + mapping, + Price::new(price, market.price_change_percentage_24h.unwrap_or_default(), updated_at, PriceProvider::Coingecko), + Some(market_data), + ) +} + +pub fn coin_market_to_asset_market(market: &CoinMarket) -> AssetMarket { + let price = market.current_price.unwrap_or_default(); + let ath = market.ath.unwrap_or_default(); + let atl = market.atl.unwrap_or_default(); + let ath_percentage = (ath != 0.0).then(|| (price - ath) / ath * 100.0); + let atl_percentage = (atl != 0.0).then(|| (price - atl) / atl * 100.0); + + AssetMarket { + market_cap: market.market_cap, + market_cap_fdv: market.fully_diluted_valuation, + market_cap_rank: market.effective_market_cap_rank().filter(|&r| r > 0), + total_volume: market.total_volume, + circulating_supply: market.circulating_supply, + total_supply: market.total_supply, + max_supply: market.max_supply, + all_time_high: market.ath, + all_time_high_date: market.ath_date, + all_time_high_change_percentage: ath_percentage, + all_time_low: market.atl, + all_time_low_date: market.atl_date, + all_time_low_change_percentage: atl_percentage, + all_time_high_value: market.ath_date.map(|date| ChartValuePercentage { + date, + value: ath as f32, + percentage: ath_percentage.unwrap_or_default() as f32, + }), + all_time_low_value: market.atl_date.map(|date| ChartValuePercentage { + date, + value: atl as f32, + percentage: atl_percentage.unwrap_or_default() as f32, + }), + } +} + +pub fn map_coin_info_metadata(mappings: Vec, coin_info: CoinInfo) -> Vec { + let links = map_coin_info_links(&coin_info); + mappings + .into_iter() + .map(|mapping| { + let rank = compute_asset_rank(&mapping.asset_id, &coin_info); + PriceProviderAssetMetadata::new(mapping.asset_id, rank, links.clone()) + }) + .collect() +} + +fn compute_asset_rank(asset_id: &AssetId, coin_info: &CoinInfo) -> i32 { + if asset_id.token_id.is_none() { + return asset_id.chain.rank(); + } + + let mut rank = 12; + rank += market_cap_rank_score(coin_info.effective_market_cap_rank().unwrap_or_default()); + rank += platform_diversity_score(coin_info.platforms.len()); + rank += social_score(coin_info); + rank += asset_id.chain.rank() / 20; + rank.max(16) +} + +fn market_cap_rank_score(market_cap_rank: i32) -> i32 { + match market_cap_rank { + 1..25 => 15, + 25..50 => 12, + 50..100 => 10, + 100..250 => 8, + 250..500 => 6, + 500..1000 => 4, + 1000..2000 => 2, + 2000..4000 => 0, + 4000..5000 => -1, + _ => -2, + } +} + +fn platform_diversity_score(platform_count: usize) -> i32 { + if platform_count > 6 { + 2 + } else if platform_count > 3 { + 1 + } else { + 0 + } +} + +fn social_score(coin_info: &CoinInfo) -> i32 { + let twitter_score = coin_info + .community_data + .as_ref() + .filter(|d| d.twitter_followers.unwrap_or_default() > 128_000) + .map(|_| 1) + .unwrap_or_default(); + + let watchlist = coin_info.watchlist_portfolio_users.unwrap_or_default() as i32; + let watchlist_score = if watchlist > 1_000_000 { + 2 + } else if watchlist > 250_000 { + 1 + } else { + 0 + }; + + twitter_score + watchlist_score +} + +fn map_coin_info_links(coin_info: &CoinInfo) -> Vec { + let links = &coin_info.links; + let mut results = vec![AssetLink::new( + &format!("https://www.coingecko.com/coins/{}", coin_info.id.to_lowercase()), + LinkType::Coingecko, + )]; + + if let Some(value) = links.twitter_screen_name.as_ref().filter(|v| !v.is_empty()) { + results.push(AssetLink::new(&format!("https://x.com/{value}"), LinkType::X)); + } + + if let Some(value) = links.homepage.iter().find(|x| !x.is_empty()).filter(|v| !v.starts_with("https://t.me")) { + results.push(AssetLink::new(value, LinkType::Website)); + } + + if let Some(value) = links.telegram_channel_identifier.as_ref().filter(|v| !v.is_empty()) { + results.push(AssetLink::new(&format!("https://t.me/{value}"), LinkType::Telegram)); + } + + if let Some(value) = links.chat_url.iter().find(|x| x.contains("discord.com")) { + results.push(AssetLink::new(value, LinkType::Discord)); + } + + if let Some(value) = links.repos_url.get("github").and_then(|urls| urls.iter().find(|x| !x.is_empty())) { + results.push(AssetLink::new(value, LinkType::GitHub)); + } + + results +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use coingecko::CoinMarket; + use primitives::Chain; + + use super::*; + + #[test] + fn test_map_coin_markets_preserves_platform_mappings() { + let provider_price_id = "atua-ai".to_string(); + let ethereum = AssetPriceMapping::new( + AssetId::from_token(Chain::Ethereum, "0x791A5c2261823dBF69b27B63E851B7745532Cfa2"), + provider_price_id.clone(), + ); + let smartchain = AssetPriceMapping::new( + AssetId::from_token(Chain::SmartChain, "0x36b2269FD151208a4bfc3DEA503E0a6F2485fA78"), + provider_price_id.clone(), + ); + let by_id = HashMap::from([(provider_price_id.clone(), vec![ethereum.clone(), smartchain.clone()])]); + + let prices = map_coin_markets(vec![CoinMarket::mock_with_id(&provider_price_id)], &by_id); + + assert_eq!(prices.len(), 2); + assert_eq!(prices[0].mapping.asset_id, ethereum.asset_id); + assert_eq!(prices[0].mapping.provider_price_id, ethereum.provider_price_id); + assert_eq!(prices[1].mapping.asset_id, smartchain.asset_id); + assert_eq!(prices[1].mapping.provider_price_id, smartchain.provider_price_id); + assert_eq!(prices[0].price.price, 0.12); + assert_eq!(prices[1].price.price, 0.12); + assert_eq!(prices[0].market.as_ref().and_then(|m| m.total_volume), Some(10.0)); + assert_eq!(prices[1].market.as_ref().and_then(|m| m.total_volume), Some(10.0)); + } +} diff --git a/core/crates/prices/src/providers/coingecko/mod.rs b/core/crates/prices/src/providers/coingecko/mod.rs new file mode 100644 index 0000000000..f4c9d31d65 --- /dev/null +++ b/core/crates/prices/src/providers/coingecko/mod.rs @@ -0,0 +1,2 @@ +pub mod mapper; +pub mod provider; diff --git a/core/crates/prices/src/providers/coingecko/provider.rs b/core/crates/prices/src/providers/coingecko/provider.rs new file mode 100644 index 0000000000..8c8f3ed85a --- /dev/null +++ b/core/crates/prices/src/providers/coingecko/provider.rs @@ -0,0 +1,131 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::time::Duration; + +use async_trait::async_trait; +use coingecko::{client::CoinGeckoClient, get_coingecko_market_id_for_chain, get_coingecko_platform_id_for_chain}; +use gem_client::ReqwestClient; +use primitives::{AssetId, Chain, ChartValue, DurationExt}; + +use crate::{AssetPriceFull, AssetPriceMapping, PriceAssetsProvider, PriceProvider, PriceProviderAsset, PriceProviderAssetMetadata}; + +use super::mapper::{map_coin_info_metadata, map_coin_mappings, map_coin_markets, map_coins_to_assets, map_coins_to_mappings, map_market_chart}; + +const MAX_MARKETS_PER_PAGE: usize = 250; +const MAX_RANKED_PAGES: usize = 20; + +pub struct CoinGeckoPricesProvider { + client: CoinGeckoClient, +} + +impl CoinGeckoPricesProvider { + pub fn new(api_key: &str) -> Self { + Self { + client: CoinGeckoClient::new(api_key), + } + } +} + +#[async_trait] +impl PriceAssetsProvider for CoinGeckoPricesProvider { + fn provider(&self) -> PriceProvider { + PriceProvider::Coingecko + } + + async fn get_assets(&self) -> Result, Box> { + let mut markets_by_id: HashMap = self + .client + .get_all_coin_markets(None, MAX_MARKETS_PER_PAGE, MAX_RANKED_PAGES) + .await? + .into_iter() + .map(|m| (m.id.clone(), m)) + .collect(); + + let native_ids: Vec = Chain::all() + .into_iter() + .map(get_coingecko_market_id_for_chain) + .map(str::to_string) + .collect::>() + .into_iter() + .filter(|id| !markets_by_id.contains_key(id)) + .collect(); + if !native_ids.is_empty() { + let native_markets = self.client.get_coin_markets_ids(native_ids, MAX_MARKETS_PER_PAGE).await?; + markets_by_id.extend(native_markets.into_iter().map(|market| (market.id.clone(), market))); + } + + let coins = self.client.get_coin_list().await?.into_iter().filter(|c| markets_by_id.contains_key(&c.id)).collect(); + Ok(map_coins_to_assets(coins, markets_by_id)) + } + + async fn get_assets_new(&self) -> Result, Box> { + let ids: HashSet = self + .client + .get_search_trending() + .await? + .get_coins_ids() + .into_iter() + .chain(self.client.get_coin_list_new().await?.ids()) + .collect(); + if ids.is_empty() { + return Ok(vec![]); + } + let coins = self.client.get_coin_list().await?.into_iter().filter(|c| ids.contains(&c.id)).collect(); + Ok(map_coins_to_mappings(coins).into_iter().map(|m| PriceProviderAsset::new(m, None)).collect()) + } + + async fn get_mappings_for_asset_id(&self, asset_id: &AssetId) -> Result, Box> { + let (Some(platform_id), Some(token_id)) = (get_coingecko_platform_id_for_chain(asset_id.chain), asset_id.token_id.as_deref()) else { + return Ok(vec![]); + }; + let coin_info = self.client.get_coin_by_contract(platform_id, token_id).await?; + Ok(vec![AssetPriceMapping::new(asset_id.clone(), coin_info.id)]) + } + + async fn get_mappings_for_price_id(&self, provider_price_id: &str) -> Result, Box> { + let coin_info = self.client.get_coin(provider_price_id).await?; + Ok(map_coin_mappings(&coin_info.id, &coin_info.platforms)) + } + + async fn get_assets_metadata(&self, mappings: Vec) -> Result, Box> { + let grouped = mappings.into_iter().fold(HashMap::new(), |mut grouped, mapping| { + grouped.entry(mapping.provider_price_id.clone()).or_insert_with(Vec::new).push(mapping); + grouped + }); + let mut metadata = Vec::new(); + for (provider_price_id, mappings) in grouped { + let coin_info = self.client.get_coin(&provider_price_id).await?; + metadata.extend(map_coin_info_metadata(mappings, coin_info)); + } + Ok(metadata) + } + + async fn get_prices(&self, mappings: Vec) -> Result, Box> { + if mappings.is_empty() { + return Ok(vec![]); + } + + let by_id = mappings.into_iter().fold(HashMap::>::new(), |mut by_id, mapping| { + by_id.entry(mapping.provider_price_id.clone()).or_default().push(mapping); + by_id + }); + let ids: Vec = by_id.keys().cloned().collect(); + let mut out = Vec::with_capacity(by_id.len()); + for chunk in ids.chunks(MAX_MARKETS_PER_PAGE) { + let coin_markets = self.client.get_coin_markets_ids(chunk.to_vec(), MAX_MARKETS_PER_PAGE).await?; + out.extend(map_coin_markets(coin_markets, &by_id)); + } + Ok(out) + } + + async fn get_charts_daily(&self, provider_price_id: &str) -> Result, Box> { + let chart = self.client.get_market_chart(provider_price_id, "daily", "max").await?; + Ok(map_market_chart(chart)) + } + + async fn get_charts_hourly(&self, provider_price_id: &str, duration: Duration) -> Result, Box> { + let days = duration.as_days_ceil().max(1).to_string(); + let chart = self.client.get_market_chart(provider_price_id, "hourly", &days).await?; + Ok(map_market_chart(chart)) + } +} diff --git a/core/crates/prices/src/providers/defillama/client.rs b/core/crates/prices/src/providers/defillama/client.rs new file mode 100644 index 0000000000..64854f24f3 --- /dev/null +++ b/core/crates/prices/src/providers/defillama/client.rs @@ -0,0 +1,20 @@ +use std::error::Error; + +use gem_client::{ClientExt, ReqwestClient}; + +use super::model::PricesResponse; + +pub struct DefiLlamaClient { + client: ReqwestClient, +} + +impl DefiLlamaClient { + pub fn new(client: ReqwestClient) -> Self { + Self { client } + } + + pub async fn get_prices(&self, coins: &[String]) -> Result> { + let path = format!("/prices/current/{}", coins.join(",")); + Ok(self.client.get::(&path).await?) + } +} diff --git a/core/crates/prices/src/providers/defillama/mapper.rs b/core/crates/prices/src/providers/defillama/mapper.rs new file mode 100644 index 0000000000..088e63147a --- /dev/null +++ b/core/crates/prices/src/providers/defillama/mapper.rs @@ -0,0 +1,112 @@ +use coingecko::get_coingecko_market_id_for_chain; +use primitives::{AssetId, Chain, PriceProvider}; + +use crate::{AssetPriceFull, AssetPriceMapping}; + +use super::model::CoinPrice; + +const DEFILLAMA_CHAIN_SLUGS: &[(Chain, &str)] = &[ + (Chain::Ethereum, "ethereum"), + (Chain::Polygon, "polygon"), + (Chain::Arbitrum, "arbitrum"), + (Chain::Optimism, "optimism"), + (Chain::Base, "base"), + (Chain::SmartChain, "bsc"), + (Chain::AvalancheC, "avax"), + (Chain::Solana, "solana"), + (Chain::Tron, "tron"), + (Chain::Fantom, "fantom"), + (Chain::Gnosis, "xdai"), + (Chain::Blast, "blast"), + (Chain::Linea, "linea"), + (Chain::ZkSync, "era"), + (Chain::Mantle, "mantle"), + (Chain::Celo, "celo"), + (Chain::Sonic, "sonic"), + (Chain::Berachain, "berachain"), + (Chain::Unichain, "unichain"), + (Chain::OpBNB, "op_bnb"), + (Chain::Manta, "manta"), + (Chain::Ink, "ink"), +]; + +pub fn defillama_id_for_asset_id(asset_id: &AssetId) -> Option { + if asset_id.is_native() { + let coingecko_id = get_coingecko_market_id_for_chain(asset_id.chain); + if coingecko_id.is_empty() { + return None; + } + return Some(format!("coingecko:{coingecko_id}")); + } + let slug = chain_to_defillama_slug(asset_id.chain)?; + let token_id = asset_id.token_id.as_ref()?; + Some(format!("{slug}:{token_id}")) +} + +pub fn asset_ids_for_defillama_id(provider_price_id: &str) -> Vec { + if let Some(coingecko_id) = provider_price_id.strip_prefix("coingecko:") { + return coingecko::get_chains_for_coingecko_market_id(coingecko_id).into_iter().map(AssetId::from_chain).collect(); + } + + let Some((slug, token_id)) = provider_price_id.split_once(':') else { + return vec![]; + }; + chain_from_defillama_slug(slug) + .map(|chain| vec![AssetId::from(chain, Some(token_id.to_string()))]) + .unwrap_or_default() +} + +pub fn map_price(mapping: AssetPriceMapping, coin: &CoinPrice) -> AssetPriceFull { + AssetPriceFull::simple(mapping, coin.price, 0.0, PriceProvider::DefiLlama) +} + +fn chain_to_defillama_slug(chain: Chain) -> Option<&'static str> { + DEFILLAMA_CHAIN_SLUGS.iter().find_map(|(candidate, slug)| (*candidate == chain).then_some(*slug)) +} + +fn chain_from_defillama_slug(slug: &str) -> Option { + DEFILLAMA_CHAIN_SLUGS.iter().find_map(|(chain, candidate)| (*candidate == slug).then_some(*chain)) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::Chain; + + #[test] + fn test_defillama_id_for_asset_id() { + let native_eth = AssetId::from_chain(Chain::Ethereum); + assert_eq!(defillama_id_for_asset_id(&native_eth).as_deref(), Some("coingecko:ethereum")); + + let usdc_eth = AssetId::from_token(Chain::Ethereum, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); + assert_eq!(defillama_id_for_asset_id(&usdc_eth).as_deref(), Some("ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")); + + let bsc_token = AssetId::from_token(Chain::SmartChain, "0x55d398326f99059fF775485246999027B3197955"); + assert_eq!(defillama_id_for_asset_id(&bsc_token).as_deref(), Some("bsc:0x55d398326f99059fF775485246999027B3197955")); + + let unsupported = AssetId::from_token(Chain::Aptos, "0x1::aptos_coin::AptosCoin"); + assert_eq!(defillama_id_for_asset_id(&unsupported), None); + + assert_eq!(asset_ids_for_defillama_id("bsc:0x55d398326f99059fF775485246999027B3197955"), vec![bsc_token]); + assert_eq!(asset_ids_for_defillama_id("unknown:0x1"), Vec::::new()); + + for (chain, slug) in DEFILLAMA_CHAIN_SLUGS { + assert_eq!(chain_to_defillama_slug(*chain), Some(*slug)); + assert_eq!(chain_from_defillama_slug(slug), Some(*chain)); + } + } + + #[test] + fn test_map_price() { + let response: super::super::model::PricesResponse = serde_json::from_str(include_str!("../../../testdata/defillama/prices.json")).unwrap(); + let coin = response.coins.get("coingecko:bitcoin").unwrap(); + let mapping = AssetPriceMapping::new(AssetId::from_chain(Chain::Bitcoin), "coingecko:bitcoin".to_string()); + + let full = map_price(mapping, coin); + + assert_eq!(full.price.price, 67000.0); + assert_eq!(full.price.price_change_percentage_24h, 0.0); + assert_eq!(full.price.provider, PriceProvider::DefiLlama); + assert!(full.market.is_none()); + } +} diff --git a/core/crates/prices/src/providers/defillama/mod.rs b/core/crates/prices/src/providers/defillama/mod.rs new file mode 100644 index 0000000000..1c7669b8a6 --- /dev/null +++ b/core/crates/prices/src/providers/defillama/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod mapper; +pub mod model; +pub mod provider; diff --git a/core/crates/prices/src/providers/defillama/model.rs b/core/crates/prices/src/providers/defillama/model.rs new file mode 100644 index 0000000000..169f87c627 --- /dev/null +++ b/core/crates/prices/src/providers/defillama/model.rs @@ -0,0 +1,13 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct PricesResponse { + pub coins: HashMap, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct CoinPrice { + pub price: f64, +} diff --git a/core/crates/prices/src/providers/defillama/provider.rs b/core/crates/prices/src/providers/defillama/provider.rs new file mode 100644 index 0000000000..ec4ef3890c --- /dev/null +++ b/core/crates/prices/src/providers/defillama/provider.rs @@ -0,0 +1,67 @@ +use std::error::Error; + +use async_trait::async_trait; +use gem_client::ReqwestClient; +use primitives::AssetId; + +use crate::{AssetPriceFull, AssetPriceMapping, PriceAssetsProvider, PriceProvider, PriceProviderAsset}; + +use super::client::DefiLlamaClient; +use super::mapper::{asset_ids_for_defillama_id, defillama_id_for_asset_id, map_price}; + +const COINS_PER_REQUEST: usize = 100; + +pub struct DefiLlamaProvider { + client: DefiLlamaClient, +} + +impl DefiLlamaProvider { + pub fn new(client: ReqwestClient) -> Self { + Self { + client: DefiLlamaClient::new(client), + } + } +} + +#[async_trait] +impl PriceAssetsProvider for DefiLlamaProvider { + fn provider(&self) -> PriceProvider { + PriceProvider::DefiLlama + } + + async fn get_assets(&self) -> Result, Box> { + Ok(vec![]) + } + + async fn get_mappings_for_asset_id(&self, asset_id: &AssetId) -> Result, Box> { + Ok(defillama_id_for_asset_id(asset_id) + .map(|provider_price_id| AssetPriceMapping::new(asset_id.clone(), provider_price_id)) + .into_iter() + .collect()) + } + + async fn get_mappings_for_price_id(&self, provider_price_id: &str) -> Result, Box> { + Ok(asset_ids_for_defillama_id(provider_price_id) + .into_iter() + .map(|asset_id| AssetPriceMapping::new(asset_id, provider_price_id.to_string())) + .collect()) + } + + async fn get_prices(&self, mappings: Vec) -> Result, Box> { + if mappings.is_empty() { + return Ok(vec![]); + } + + let mut results = Vec::with_capacity(mappings.len()); + for chunk in mappings.chunks(COINS_PER_REQUEST) { + let coins: Vec = chunk.iter().map(|m| m.provider_price_id.clone()).collect(); + let response = self.client.get_prices(&coins).await?; + for mapping in chunk { + if let Some(coin) = response.coins.get(&mapping.provider_price_id) { + results.push(map_price(mapping.clone(), coin)); + } + } + } + Ok(results) + } +} diff --git a/core/crates/prices/src/providers/jupiter/client.rs b/core/crates/prices/src/providers/jupiter/client.rs new file mode 100644 index 0000000000..6f7e0db2d4 --- /dev/null +++ b/core/crates/prices/src/providers/jupiter/client.rs @@ -0,0 +1,19 @@ +use std::error::Error; + +use super::model::{VerifiedToken, VerifiedTokensResponse}; +use gem_client::{ClientExt, ReqwestClient}; + +pub struct JupiterClient { + client: ReqwestClient, +} + +impl JupiterClient { + pub fn new(client: ReqwestClient) -> Self { + Self { client } + } + + pub async fn get_verified_tokens(&self) -> Result, Box> { + let query = vec![("query".to_string(), "verified".to_string())]; + Ok(self.client.get_with_query::("/tokens/v2/tag", &query).await?) + } +} diff --git a/core/crates/prices/src/providers/jupiter/mapper.rs b/core/crates/prices/src/providers/jupiter/mapper.rs new file mode 100644 index 0000000000..fdb9d5b551 --- /dev/null +++ b/core/crates/prices/src/providers/jupiter/mapper.rs @@ -0,0 +1,101 @@ +use primitives::{AssetId, AssetMarket, Chain, Price, PriceProvider, contract_constants::SOLANA_WRAPPED_SOL_TOKEN_ADDRESS}; + +use crate::{AssetPriceFull, AssetPriceMapping, PriceProviderAsset}; + +use super::model::VerifiedToken; + +pub fn to_asset_price_mapping(jupiter_token_id: &str) -> AssetPriceMapping { + if jupiter_token_id == SOLANA_WRAPPED_SOL_TOKEN_ADDRESS { + AssetPriceMapping::new(AssetId::from_chain(Chain::Solana), Chain::Solana.as_ref().to_string()) + } else { + AssetPriceMapping::new(AssetId::from(Chain::Solana, Some(jupiter_token_id.to_string())), jupiter_token_id.to_string()) + } +} + +pub fn to_jupiter_token_id(provider_price_id: &str) -> String { + if provider_price_id == Chain::Solana.as_ref() { + SOLANA_WRAPPED_SOL_TOKEN_ADDRESS.to_string() + } else { + provider_price_id.to_string() + } +} + +pub fn map_token_asset(token: VerifiedToken) -> PriceProviderAsset { + PriceProviderAsset::with_price( + to_asset_price_mapping(&token.id), + Some(map_token_market(&token)), + Some(token.usd_price), + Some(token.stats24h.price_change), + ) +} + +pub fn map_token_price(mapping: AssetPriceMapping, token: &VerifiedToken) -> AssetPriceFull { + AssetPriceFull::new( + mapping, + Price::new(token.usd_price, token.stats24h.price_change, chrono::Utc::now(), PriceProvider::Jupiter), + Some(map_token_market(token)), + ) +} + +fn map_token_market(token: &VerifiedToken) -> AssetMarket { + AssetMarket { + market_cap: token.mcap, + market_cap_fdv: token.fdv, + total_volume: token + .stats24h + .buy_volume + .zip(token.stats24h.sell_volume) + .map(|(buy_volume, sell_volume)| buy_volume + sell_volume), + circulating_supply: token.circ_supply, + total_supply: token.total_supply, + ..AssetMarket::default() + } +} + +#[cfg(test)] +mod tests { + use super::super::model::TokenStats; + use super::*; + + #[test] + fn test_jupiter_price_id_mapping() { + let mapping = to_asset_price_mapping(SOLANA_WRAPPED_SOL_TOKEN_ADDRESS); + assert_eq!(mapping.asset_id, AssetId::from_chain(Chain::Solana)); + assert_eq!(mapping.provider_price_id, Chain::Solana.as_ref()); + assert_eq!(to_jupiter_token_id(&mapping.provider_price_id), SOLANA_WRAPPED_SOL_TOKEN_ADDRESS); + + let token = "BPxxfRCXkUVhig4HS1Lh7kZqV6SPJhzfEk4x6fVBjPCy"; + let mapping = to_asset_price_mapping(token); + assert_eq!(mapping.asset_id, AssetId::from_token(Chain::Solana, token)); + assert_eq!(mapping.provider_price_id, token); + assert_eq!(to_jupiter_token_id(&mapping.provider_price_id), token); + } + + #[test] + fn test_map_token_price_maps_market_data() { + let token = VerifiedToken { + id: "token".to_string(), + usd_price: 2.0, + mcap: Some(10.0), + fdv: Some(20.0), + circ_supply: Some(5.0), + total_supply: Some(10.0), + stats24h: TokenStats { + price_change: 3.0, + buy_volume: Some(30.0), + sell_volume: Some(40.0), + }, + }; + + let price = map_token_price(AssetPriceMapping::new(AssetId::from_token(Chain::Solana, "token"), "token".to_string()), &token); + let market = price.market.unwrap(); + + assert_eq!(price.price.price, 2.0); + assert_eq!(price.price.price_change_percentage_24h, 3.0); + assert_eq!(market.market_cap, Some(10.0)); + assert_eq!(market.market_cap_fdv, Some(20.0)); + assert_eq!(market.total_volume, Some(70.0)); + assert_eq!(market.circulating_supply, Some(5.0)); + assert_eq!(market.total_supply, Some(10.0)); + } +} diff --git a/core/crates/prices/src/providers/jupiter/mod.rs b/core/crates/prices/src/providers/jupiter/mod.rs new file mode 100644 index 0000000000..180fe6cd3c --- /dev/null +++ b/core/crates/prices/src/providers/jupiter/mod.rs @@ -0,0 +1,7 @@ +pub mod client; +pub mod mapper; +pub mod model; +pub mod provider; + +#[cfg(all(test, feature = "price_integration_tests"))] +pub mod testkit; diff --git a/core/crates/prices/src/providers/jupiter/model.rs b/core/crates/prices/src/providers/jupiter/model.rs new file mode 100644 index 0000000000..44a10ff786 --- /dev/null +++ b/core/crates/prices/src/providers/jupiter/model.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VerifiedToken { + pub id: String, + #[serde(default)] + pub usd_price: f64, + pub mcap: Option, + pub fdv: Option, + pub circ_supply: Option, + pub total_supply: Option, + #[serde(default)] + pub stats24h: TokenStats, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct TokenStats { + #[serde(default)] + pub price_change: f64, + pub buy_volume: Option, + pub sell_volume: Option, +} + +pub type VerifiedTokensResponse = Vec; diff --git a/core/crates/prices/src/providers/jupiter/provider.rs b/core/crates/prices/src/providers/jupiter/provider.rs new file mode 100644 index 0000000000..7f7312462d --- /dev/null +++ b/core/crates/prices/src/providers/jupiter/provider.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; +use std::error::Error; + +use async_trait::async_trait; +use gem_client::ReqwestClient; +use primitives::AssetId; + +use crate::{AssetPriceFull, AssetPriceMapping, PriceAssetsProvider, PriceProvider, PriceProviderAsset}; + +use super::client::JupiterClient; +use super::mapper::{map_token_asset, map_token_price, to_asset_price_mapping, to_jupiter_token_id}; +use super::model::VerifiedToken; + +pub struct JupiterProvider { + jupiter_client: JupiterClient, +} + +impl JupiterProvider { + pub fn new(client: ReqwestClient) -> Self { + Self { + jupiter_client: JupiterClient::new(client), + } + } + + async fn verified_tokens(&self) -> Result, Box> { + self.jupiter_client.get_verified_tokens().await + } +} + +#[async_trait] +impl PriceAssetsProvider for JupiterProvider { + fn provider(&self) -> PriceProvider { + PriceProvider::Jupiter + } + + async fn get_assets(&self) -> Result, Box> { + Ok(self.verified_tokens().await?.into_iter().map(map_token_asset).collect()) + } + + async fn get_mappings_for_asset_id(&self, asset_id: &AssetId) -> Result, Box> { + Ok(asset_id + .token_id + .clone() + .map(|token_id| AssetPriceMapping::new(asset_id.clone(), token_id)) + .into_iter() + .collect()) + } + + async fn get_mappings_for_price_id(&self, provider_price_id: &str) -> Result, Box> { + Ok(vec![to_asset_price_mapping(provider_price_id)]) + } + + async fn get_prices(&self, mappings: Vec) -> Result, Box> { + if mappings.is_empty() { + return Ok(vec![]); + } + let tokens: HashMap = self.verified_tokens().await?.into_iter().map(|t| (t.id.clone(), t)).collect(); + Ok(mappings + .into_iter() + .filter_map(|mapping| tokens.get(&to_jupiter_token_id(&mapping.provider_price_id)).map(|token| map_token_price(mapping, token))) + .collect()) + } +} + +#[cfg(all(test, feature = "price_integration_tests"))] +mod integration_tests { + use super::super::testkit::create_jupiter_test_provider; + use crate::{PriceAssetsProvider, PriceProvider}; + + #[tokio::test] + async fn test_jupiter_provider_basic() { + let provider = create_jupiter_test_provider(); + assert_eq!(provider.provider(), PriceProvider::Jupiter); + + let supported = provider.get_assets().await.unwrap(); + assert!(!supported.is_empty()); + for asset in &supported { + assert!(!asset.mapping.provider_price_id.is_empty()); + } + } +} diff --git a/core/crates/prices/src/providers/jupiter/testkit.rs b/core/crates/prices/src/providers/jupiter/testkit.rs new file mode 100644 index 0000000000..7a3fe9605e --- /dev/null +++ b/core/crates/prices/src/providers/jupiter/testkit.rs @@ -0,0 +1,10 @@ +#[cfg(feature = "price_integration_tests")] +use crate::JupiterProvider; +#[cfg(feature = "price_integration_tests")] +use gem_client::ReqwestClient; + +#[cfg(feature = "price_integration_tests")] +pub fn create_jupiter_test_provider() -> JupiterProvider { + let settings = settings::testkit::get_test_settings(); + JupiterProvider::new(ReqwestClient::new_test_client(settings.prices.jupiter.url)) +} diff --git a/core/crates/prices/src/providers/mod.rs b/core/crates/prices/src/providers/mod.rs new file mode 100644 index 0000000000..99b8d8645b --- /dev/null +++ b/core/crates/prices/src/providers/mod.rs @@ -0,0 +1,4 @@ +pub mod coingecko; +pub mod defillama; +pub mod jupiter; +pub mod pyth; diff --git a/core/crates/prices/src/providers/pyth/client.rs b/core/crates/prices/src/providers/pyth/client.rs new file mode 100644 index 0000000000..7a42a04a9f --- /dev/null +++ b/core/crates/prices/src/providers/pyth/client.rs @@ -0,0 +1,42 @@ +use std::error::Error; + +use super::model::{HermesResponse, Price, PriceFeed}; +use gem_client::{ClientExt, ReqwestClient}; + +pub struct PythClient { + client: ReqwestClient, +} + +impl PythClient { + pub fn new(client: ReqwestClient) -> Self { + Self { client } + } + + pub async fn get_price_feeds(&self) -> Result, Box> { + Ok(self.client.get("/v2/price_feeds").await?) + } + + pub async fn get_asset_prices(&self, price_ids: Vec) -> Result, Box> { + const CHUNK_SIZE: usize = 5; + let mut all_prices = Vec::new(); + + for chunk in price_ids.chunks(CHUNK_SIZE) { + let query: Vec<(String, String)> = chunk.iter().map(|id| ("ids[]".to_string(), id.clone())).collect(); + + let response = self.client.get_with_query::("/v2/updates/price/latest", &query).await?; + + let prices: Vec = response + .parsed + .into_iter() + .map(|feed| { + let scaled_price = feed.price.price as f64 * 10f64.powi(feed.price.expo); + Price { price: scaled_price } + }) + .collect(); + + all_prices.extend(prices); + } + + Ok(all_prices) + } +} diff --git a/core/crates/prices/src/providers/pyth/mapper.rs b/core/crates/prices/src/providers/pyth/mapper.rs new file mode 100644 index 0000000000..8350e7fc5f --- /dev/null +++ b/core/crates/prices/src/providers/pyth/mapper.rs @@ -0,0 +1,79 @@ +use primitives::{AssetId, Chain}; + +pub fn asset_ids_for_feed_id(feed_id: &str) -> Vec { + Chain::all() + .into_iter() + .filter(|&chain| price_feed_id_for_chain(chain) == feed_id) + .map(|c| c.as_asset_id()) + .collect() +} + +// https://www.pyth.network/price-feeds +// Hermes API feed IDs for each chain's native asset +pub fn price_feed_id_for_chain(chain: Chain) -> &'static str { + match chain { + Chain::Bitcoin => "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", + Chain::BitcoinCash => "3dd2b63686a450ec7290df3a1e0b583c0481f651351edfa7636f39aed55cf8a3", + Chain::Litecoin => "6e3f3fa8253588df9326580180233eb791e03b443a3ba7a1d892e73874e19a54", + Chain::Ethereum + | Chain::Arbitrum + | Chain::Optimism + | Chain::Base + | Chain::Linea + | Chain::Manta + | Chain::ZkSync + | Chain::Abstract + | Chain::Ink + | Chain::Unichain + | Chain::Blast + | Chain::World + | Chain::Plasma => "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + Chain::SmartChain | Chain::OpBNB => "2f95862b045670cd22bee3114c39763a4a08beeb663b145d283c31d7d1101c4f", + Chain::Solana => "ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", + Chain::Polygon => "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + Chain::Thorchain => "5fcf71143bb70d41af4fa9aa1287e2efd3c5911cee59f909f915c9f61baacb1e", + Chain::Cosmos => "b00b60f88b03a6a625a8d1c048c3f66653edf217439983d037e7222c4e612819", + Chain::Osmosis => "5867f5683c757393a0670ef0f701490950fe93fdb006d181c8265a831ac0c5c6", + Chain::Ton => "8963217838ab4cf5cadc172203c1f0b763fbaa45f346d8ee50ba994bbcac3026", + Chain::Tron => "67aed5a24fdad045475e7195c98a98aea119c763f272d4523f5bac93a4f33c2b", + Chain::Doge => "dcef50dd0a4cd2dcc17e45df1676dcb336a11a61c69df7a0299b0150c672d25c", + Chain::Zcash => "be9b59d178f0d6a97ab4c343bff2aa69caa1eaae3e9048a65788c529b125bb24", + Chain::Aptos => "03ae4db29ed4ae33d323568895aa00337e658e348b37509f5372ae51f0af00d5", + Chain::AvalancheC => "93da3352f9f1d105fdfe4971cfa80e9dd777bfc5d0f683ebb6e1294b92137bb7", + Chain::Sui => "23d7315113f5b1d3ba7a83604c44b94d79f4fd69af77f804fc7f920a6dc65744", + Chain::Xrp => "ec5d399846a9209f3fe5881d70aae9268c94339ff9817e8d18ff19fa05eea1c8", + Chain::Celestia => "09f7c1d7dfbb7df2b8fe3d3d87ee94a2259d212da4f30c1f0540d066dfa44723", + Chain::Injective => "7a5bc1d2b56ad029048cd63964b3ad2776eadf812edc1a43a31406cb54bff592", + Chain::Sei => "53614f1cb0c031d4af66c04cb9c756234adad0e1cee85303795091499a4084eb", + Chain::SeiEvm => "53614f1cb0c031d4af66c04cb9c756234adad0e1cee85303795091499a4084eb", + Chain::Noble => "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + Chain::Mantle => "4e3037c822d852d79af3ac80e35eb420ee3b870dca49f9344a38ef4773fb0585", + Chain::Celo => "7d669ddcdd23d9ef1fa9a9cc022ba055ec900e91c4cb960f3c20429d4447a411", + Chain::Near => "c415de8d2eba7db216527dff4b60e8f3a5311c740dadb233e13e12547e226750", + Chain::Stellar => "b7a8eba68a997cd0210c2e1e4ee811ad2d174b3611c22d9ebf16f4cb7e9ba850", + Chain::Algorand => "fa17ceaf30d19ba51112fdcc750cc83454776f47fb0112e4af07f15f4bb1ebc0", + Chain::Polkadot => "ca3eed9b267293f6595901c734c7525ce8ef49adafe8284606ceb307afa2ca5b", + Chain::Cardano => "2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d", + Chain::Berachain => "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + Chain::Hyperliquid | Chain::HyperCore => "4279e31cc369bbcc2faf022b382b080e32a8e689ff20fbc530d2a603eb6cd98b", + Chain::Fantom | Chain::Sonic => "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + Chain::Gnosis => "c5f60d00d926ee369ded32a38a6bd5c1e0faa936f91b987a5d0dcf3c5d8afab0", + Chain::Monad => "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + Chain::XLayer => "d6f83dfeaff95d596ddec26af2ee32f391c206a183b161b7980821860eeef2f5", + Chain::Stable => "2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pyth_price_id_mapping() { + let eth_feed = price_feed_id_for_chain(Chain::Ethereum); + let chains = asset_ids_for_feed_id(eth_feed); + assert!(chains.contains(&AssetId::from_chain(Chain::Ethereum))); + assert!(chains.contains(&AssetId::from_chain(Chain::Arbitrum))); + assert!(asset_ids_for_feed_id("missing").is_empty()); + } +} diff --git a/core/crates/prices/src/providers/pyth/mod.rs b/core/crates/prices/src/providers/pyth/mod.rs new file mode 100644 index 0000000000..180fe6cd3c --- /dev/null +++ b/core/crates/prices/src/providers/pyth/mod.rs @@ -0,0 +1,7 @@ +pub mod client; +pub mod mapper; +pub mod model; +pub mod provider; + +#[cfg(all(test, feature = "price_integration_tests"))] +pub mod testkit; diff --git a/core/crates/prices/src/providers/pyth/model.rs b/core/crates/prices/src/providers/pyth/model.rs new file mode 100644 index 0000000000..0b87657341 --- /dev/null +++ b/core/crates/prices/src/providers/pyth/model.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug)] +pub struct Price { + pub price: f64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HermesResponse { + pub parsed: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ParsedPriceFeed { + pub id: String, + pub price: PriceData, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PriceData { + #[serde(deserialize_with = "serde_serializers::deserialize_u64_from_str")] + pub price: u64, + pub expo: i32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PriceFeed { + pub id: String, +} diff --git a/core/crates/prices/src/providers/pyth/provider.rs b/core/crates/prices/src/providers/pyth/provider.rs new file mode 100644 index 0000000000..0c38b099fb --- /dev/null +++ b/core/crates/prices/src/providers/pyth/provider.rs @@ -0,0 +1,111 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; + +use async_trait::async_trait; +use gem_client::ReqwestClient; +use primitives::AssetId; + +use super::{ + client::PythClient, + mapper::{asset_ids_for_feed_id, price_feed_id_for_chain}, +}; +use crate::{AssetPriceFull, AssetPriceMapping, PriceAssetsProvider, PriceProvider, PriceProviderAsset}; + +pub struct PythProvider { + pyth_client: PythClient, +} + +impl PythProvider { + pub fn new(client: ReqwestClient) -> Self { + Self { + pyth_client: PythClient::new(client), + } + } +} + +#[async_trait] +impl PriceAssetsProvider for PythProvider { + fn provider(&self) -> PriceProvider { + PriceProvider::Pyth + } + + async fn get_assets(&self) -> Result, Box> { + let feeds = self.pyth_client.get_price_feeds().await?; + Ok(feeds + .into_iter() + .flat_map(|feed| { + asset_ids_for_feed_id(&feed.id) + .into_iter() + .map(move |asset_id| AssetPriceMapping::new(asset_id, feed.id.clone())) + }) + .map(|m| PriceProviderAsset::new(m, None)) + .collect()) + } + + async fn get_mappings_for_asset_id(&self, asset_id: &AssetId) -> Result, Box> { + Ok(asset_id + .is_native() + .then(|| AssetPriceMapping::new(asset_id.clone(), price_feed_id_for_chain(asset_id.chain).to_string())) + .into_iter() + .collect()) + } + + async fn get_mappings_for_price_id(&self, provider_price_id: &str) -> Result, Box> { + Ok(asset_ids_for_feed_id(provider_price_id) + .into_iter() + .map(|asset_id| AssetPriceMapping::new(asset_id, provider_price_id.to_string())) + .collect()) + } + + async fn get_prices(&self, mappings: Vec) -> Result, Box> { + let feed_ids: Vec = mappings + .iter() + .map(|mapping| mapping.provider_price_id.clone()) + .collect::>() + .into_iter() + .collect(); + if feed_ids.is_empty() { + return Ok(vec![]); + } + + let prices = self.pyth_client.get_asset_prices(feed_ids.clone()).await?; + let prices_by_feed_id: HashMap = feed_ids.into_iter().zip(prices).map(|(id, price)| (id, price.price)).collect(); + + Ok(mappings + .into_iter() + .filter_map(|mapping| { + prices_by_feed_id + .get(&mapping.provider_price_id) + .map(|price| AssetPriceFull::simple(mapping, *price, 0.0, PriceProvider::Pyth)) + }) + .collect()) + } +} + +#[cfg(all(test, feature = "price_integration_tests"))] +mod tests { + use super::super::mapper::price_feed_id_for_chain; + use super::super::testkit::create_pyth_test_provider; + use crate::{AssetPriceMapping, PriceAssetsProvider, PriceProvider}; + use primitives::Chain; + + #[tokio::test] + async fn test_pyth_provider_basic() { + let provider = create_pyth_test_provider(); + assert_eq!(provider.provider(), PriceProvider::Pyth); + + let supported = provider.get_assets().await.unwrap(); + assert!(!supported.is_empty()); + for asset in &supported { + assert!(!asset.mapping.provider_price_id.is_empty()); + } + + let mappings: Vec = Chain::all() + .iter() + .map(|chain| AssetPriceMapping::new(chain.as_asset_id(), price_feed_id_for_chain(*chain).to_string())) + .collect(); + let prices = provider.get_prices(mappings).await.unwrap(); + assert!(!prices.is_empty()); + assert_eq!(prices.len(), Chain::all().len()); + } +} diff --git a/core/crates/prices/src/providers/pyth/testkit.rs b/core/crates/prices/src/providers/pyth/testkit.rs new file mode 100644 index 0000000000..85060b9c47 --- /dev/null +++ b/core/crates/prices/src/providers/pyth/testkit.rs @@ -0,0 +1,10 @@ +#[cfg(feature = "price_integration_tests")] +use crate::PythProvider; +#[cfg(feature = "price_integration_tests")] +use gem_client::ReqwestClient; + +#[cfg(feature = "price_integration_tests")] +pub fn create_pyth_test_provider() -> PythProvider { + let settings = settings::testkit::get_test_settings(); + PythProvider::new(ReqwestClient::new_test_client(settings.prices.pyth.url)) +} diff --git a/core/crates/prices/testdata/defillama/prices.json b/core/crates/prices/testdata/defillama/prices.json new file mode 100644 index 0000000000..78880b7500 --- /dev/null +++ b/core/crates/prices/testdata/defillama/prices.json @@ -0,0 +1,17 @@ +{ + "coins": { + "coingecko:bitcoin": { + "price": 67000.0, + "symbol": "BTC", + "timestamp": 1712345678, + "confidence": 0.99 + }, + "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { + "price": 1.0, + "symbol": "USDC", + "timestamp": 1712345678, + "confidence": 0.99, + "decimals": 6 + } + } +} diff --git a/core/crates/primitives/Cargo.toml b/core/crates/primitives/Cargo.toml new file mode 100644 index 0000000000..a25cbe054d --- /dev/null +++ b/core/crates/primitives/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "primitives" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +testkit = [] + +[dependencies] +typeshare = { version = "1.0.5" } +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +strum = { workspace = true } +url = { workspace = true } +num-bigint = { workspace = true } +num-traits = { workspace = true } +hex = { workspace = true } diff --git a/core/crates/primitives/src/account.rs b/core/crates/primitives/src/account.rs new file mode 100644 index 0000000000..0885be5411 --- /dev/null +++ b/core/crates/primitives/src/account.rs @@ -0,0 +1,13 @@ +use crate::Chain; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub chain: Chain, + pub address: String, + pub derivation_path: String, + pub extended_public_key: Option, +} diff --git a/core/crates/primitives/src/address/error.rs b/core/crates/primitives/src/address/error.rs new file mode 100644 index 0000000000..339da082ab --- /dev/null +++ b/core/crates/primitives/src/address/error.rs @@ -0,0 +1,26 @@ +use std::fmt; + +#[derive(Debug)] +pub struct AddressError { + pub message: String, +} + +impl AddressError { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +impl fmt::Display for AddressError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for AddressError {} + +impl From for String { + fn from(err: AddressError) -> Self { + err.message + } +} diff --git a/core/crates/primitives/src/address/mod.rs b/core/crates/primitives/src/address/mod.rs new file mode 100644 index 0000000000..be5383391a --- /dev/null +++ b/core/crates/primitives/src/address/mod.rs @@ -0,0 +1,20 @@ +mod error; + +pub use error::AddressError; + +/// Common trait for blockchain addresses. +pub trait Address: Sized { + fn try_parse(address: &str) -> Option; + + fn as_bytes(&self) -> &[u8]; + + fn encode(&self) -> String; + + fn from_str(address: &str) -> Result { + Self::try_parse(address).ok_or_else(|| AddressError::new("invalid address")) + } + + fn is_valid(address: &str) -> bool { + Self::try_parse(address).is_some() + } +} diff --git a/core/crates/primitives/src/address_formatter.rs b/core/crates/primitives/src/address_formatter.rs new file mode 100644 index 0000000000..d38e34db07 --- /dev/null +++ b/core/crates/primitives/src/address_formatter.rs @@ -0,0 +1,93 @@ +use crate::{Chain, ChainType}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddressFormatStyle { + Short, + Full, + Extra { extra: u32 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AddressFormatter; + +impl AddressFormatter { + const CONNECTOR: &str = "..."; + const EDGE_CHARS: usize = 5; + + pub fn format(address: &str, chain: Option, style: AddressFormatStyle) -> String { + match style { + AddressFormatStyle::Short => Self::truncate(address, chain, 0), + AddressFormatStyle::Full => address.to_string(), + AddressFormatStyle::Extra { extra } => Self::truncate(address, chain, extra as usize), + } + } + + fn truncate(address: &str, chain: Option, extra: usize) -> String { + let leading = Self::leading_chars(chain) + extra; + let trailing = Self::EDGE_CHARS + extra; + let chars = address.chars().collect::>(); + let char_count = chars.len(); + + if char_count <= leading + trailing { + return address.to_string(); + } + + let start = chars.iter().take(leading).copied().collect::(); + let end = chars.iter().skip(char_count - trailing).copied().collect::(); + format!("{start}{}{end}", Self::CONNECTOR) + } + + fn leading_chars(chain: Option) -> usize { + match chain.map(|chain| chain.chain_type()) { + Some(ChainType::Ethereum) => Self::EDGE_CHARS + "0x".len(), + Some(ChainType::Bitcoin | ChainType::Aptos) => Self::EDGE_CHARS + 1, + Some(_) | None => Self::EDGE_CHARS, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_address_formatter() { + assert_eq!( + AddressFormatter::format("0x12312321321312", Some(Chain::Ethereum), AddressFormatStyle::Short), + "0x12312...21312" + ); + assert_eq!( + AddressFormatter::format("0x12312321321312", Some(Chain::Aptos), AddressFormatStyle::Short), + "0x1231...21312" + ); + assert_eq!( + AddressFormatter::format("GLNvG5Ly4cK512oQeJqnwLftwfoPZ4skyDwZWzxorYQ9", Some(Chain::Solana), AddressFormatStyle::Short), + "GLNvG...orYQ9" + ); + assert_eq!( + AddressFormatter::format( + "bc1qx2x5cqhymfcnjtg902ky6u5t5htmt7fvqztdsm028hkrvxcl4t2sjtpd9l", + Some(Chain::Bitcoin), + AddressFormatStyle::Short + ), + "bc1qx2...tpd9l" + ); + assert_eq!( + AddressFormatter::format("0x1231232221321312", Some(Chain::Ethereum), AddressFormatStyle::Extra { extra: 2 }), + "0x1231232...1321312" + ); + assert_eq!( + AddressFormatter::format("0x12313332321321312", Some(Chain::Aptos), AddressFormatStyle::Extra { extra: 2 }), + "0x123133...1321312" + ); + assert_eq!( + AddressFormatter::format("0x1231232221321312", Some(Chain::Ethereum), AddressFormatStyle::Full), + "0x1231232221321312" + ); + assert_eq!(AddressFormatter::format("bc1short", Some(Chain::Bitcoin), AddressFormatStyle::Short), "bc1short"); + assert_eq!( + AddressFormatter::format("abcXXmiddleXXcba", Some(Chain::Solana), AddressFormatStyle::Short), + "abcXX...XXcba" + ); + } +} diff --git a/core/crates/primitives/src/address_name.rs b/core/crates/primitives/src/address_name.rs new file mode 100644 index 0000000000..20b21bcbd5 --- /dev/null +++ b/core/crates/primitives/src/address_name.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{Chain, VerificationStatus, scan::AddressType}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct AddressName { + pub chain: Chain, + pub address: String, + pub name: String, + #[serde(rename = "type")] + pub address_type: AddressType, + pub status: VerificationStatus, +} diff --git a/core/crates/primitives/src/address_status.rs b/core/crates/primitives/src/address_status.rs new file mode 100644 index 0000000000..3c73e90e22 --- /dev/null +++ b/core/crates/primitives/src/address_status.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum AddressStatus { + MultiSignature, +} diff --git a/core/crates/primitives/src/app_constants.rs b/core/crates/primitives/src/app_constants.rs new file mode 100644 index 0000000000..926101522a --- /dev/null +++ b/core/crates/primitives/src/app_constants.rs @@ -0,0 +1,2 @@ +pub const GEM_ANDROID_PACKAGE_ID: &str = "com.gemwallet.android"; +pub const GEM_IOS_BUNDLE_ID: &str = "com.gemwallet.ios"; diff --git a/core/crates/primitives/src/asset.rs b/core/crates/primitives/src/asset.rs new file mode 100644 index 0000000000..56deefbe97 --- /dev/null +++ b/core/crates/primitives/src/asset.rs @@ -0,0 +1,175 @@ +use std::{collections::HashSet, error::Error}; + +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{AssetBasic, AssetProperties, AssetScore, Chain, asset_id::AssetId, asset_type::AssetType}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct Asset { + pub id: AssetId, + #[typeshare(skip)] + pub chain: Chain, + #[typeshare(skip)] + pub token_id: Option, + pub name: String, + pub symbol: String, + pub decimals: i32, + #[serde(rename = "type")] + pub asset_type: AssetType, +} + +impl Chain { + pub fn new_asset(&self, name: String, symbol: String, decimals: i32, asset_type: AssetType) -> Asset { + Asset { + id: self.as_asset_id(), + chain: *self, + token_id: None, + name, + symbol, + decimals, + asset_type, + } + } +} + +impl Asset { + pub fn new(id: AssetId, name: String, symbol: String, decimals: i32, asset_type: AssetType) -> Asset { + Asset { + id: id.clone(), + chain: id.chain, + token_id: id.token_id.clone(), + name, + symbol, + decimals, + asset_type, + } + } + + pub fn chain(&self) -> Chain { + self.id.chain + } + + pub fn full_name(&self) -> String { + format!("{} ({})", self.name, self.symbol) + } + + pub fn as_basic_primitive(&self) -> AssetBasic { + AssetBasic::new(self.clone(), AssetProperties::default(self.id.clone()), AssetScore::default()) + } + + pub fn from_chain(chain: Chain) -> Asset { + match chain { + Chain::Ethereum => chain.new_asset("Ethereum".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Bitcoin => chain.new_asset("Bitcoin".to_string(), "BTC".to_string(), 8, AssetType::NATIVE), + Chain::BitcoinCash => chain.new_asset("Bitcoin Cash".to_string(), "BCH".to_string(), 8, AssetType::NATIVE), + Chain::Litecoin => chain.new_asset("Litecoin".to_string(), "LTC".to_string(), 8, AssetType::NATIVE), + Chain::SmartChain => chain.new_asset("BNB Chain".to_string(), "BNB".to_string(), 18, AssetType::NATIVE), + Chain::Polygon => chain.new_asset("Polygon".to_string(), "POL".to_string(), 18, AssetType::NATIVE), + Chain::AvalancheC => chain.new_asset("Avalanche".to_string(), "AVAX".to_string(), 18, AssetType::NATIVE), + Chain::Solana => chain.new_asset("Solana".to_string(), "SOL".to_string(), 9, AssetType::NATIVE), + Chain::Thorchain => chain.new_asset("Thorchain".to_string(), "RUNE".to_string(), 8, AssetType::NATIVE), + Chain::Cosmos => chain.new_asset("Cosmos".to_string(), "ATOM".to_string(), 6, AssetType::NATIVE), + Chain::Osmosis => chain.new_asset("Osmosis".to_string(), "OSMO".to_string(), 6, AssetType::NATIVE), + Chain::Celestia => chain.new_asset("Celestia".to_string(), "TIA".to_string(), 6, AssetType::NATIVE), + Chain::Arbitrum => chain.new_asset("Arbitrum ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Ton => chain.new_asset("TON".to_string(), "TON".to_string(), 9, AssetType::NATIVE), + Chain::Tron => chain.new_asset("TRON".to_string(), "TRX".to_string(), 6, AssetType::NATIVE), + Chain::Doge => chain.new_asset("Dogecoin".to_string(), "DOGE".to_string(), 8, AssetType::NATIVE), + Chain::Zcash => chain.new_asset("Zcash".to_string(), "ZEC".to_string(), 8, AssetType::NATIVE), + Chain::Optimism => chain.new_asset("Optimism ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Aptos => chain.new_asset("Aptos".to_string(), "APT".to_string(), 8, AssetType::NATIVE), + Chain::Base => chain.new_asset("Base ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Sui => chain.new_asset("Sui".to_string(), "SUI".to_string(), 9, AssetType::NATIVE), + Chain::Xrp => chain.new_asset("XRP".to_string(), "XRP".to_string(), 6, AssetType::NATIVE), + Chain::OpBNB => chain.new_asset("opBNB".to_string(), "BNB".to_string(), 18, AssetType::NATIVE), + Chain::Fantom => chain.new_asset("Fantom".to_string(), "FTM".to_string(), 18, AssetType::NATIVE), + Chain::Gnosis => chain.new_asset("Gnosis Chain".to_string(), "xDai".to_string(), 18, AssetType::NATIVE), + Chain::Injective => chain.new_asset("Injective".to_string(), "INJ".to_string(), 18, AssetType::NATIVE), + Chain::Sei => chain.new_asset("Sei".to_string(), "SEI".to_string(), 6, AssetType::NATIVE), + Chain::SeiEvm => chain.new_asset("Sei EVM".to_string(), "SEI".to_string(), 18, AssetType::NATIVE), + Chain::Manta => chain.new_asset("Manta ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Blast => chain.new_asset("Blast ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Noble => chain.new_asset("Noble".to_string(), "USDC".to_string(), 6, AssetType::NATIVE), + Chain::ZkSync => chain.new_asset("zkSync ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Linea => chain.new_asset("Linea ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Mantle => chain.new_asset("Mantle".to_string(), "MNT".to_string(), 18, AssetType::NATIVE), + Chain::Celo => chain.new_asset("Celo".to_string(), "CELO".to_string(), 18, AssetType::NATIVE), + Chain::Near => chain.new_asset("Near".to_string(), "NEAR".to_string(), 24, AssetType::NATIVE), + Chain::World => chain.new_asset("World ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Stellar => chain.new_asset("Stellar".to_string(), "XLM".to_string(), 7, AssetType::NATIVE), + Chain::Sonic => chain.new_asset("Sonic".to_string(), "S".to_string(), 18, AssetType::NATIVE), + Chain::Algorand => chain.new_asset("Algorand".to_string(), "ALGO".to_string(), 6, AssetType::NATIVE), + Chain::Polkadot => chain.new_asset("Polkadot".to_string(), "DOT".to_string(), 10, AssetType::NATIVE), + Chain::Plasma => chain.new_asset("Plasma".to_string(), "XPL".to_string(), 18, AssetType::NATIVE), + Chain::Cardano => chain.new_asset("Cardano".to_string(), "ADA".to_string(), 6, AssetType::NATIVE), + Chain::Abstract => chain.new_asset("Abstract".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Berachain => chain.new_asset("Berachain".to_string(), "BERA".to_string(), 18, AssetType::NATIVE), + Chain::Ink => chain.new_asset("Ink ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Unichain => chain.new_asset("Unichain ETH".to_string(), "ETH".to_string(), 18, AssetType::NATIVE), + Chain::Hyperliquid => chain.new_asset("HyperEVM".to_string(), "HYPE".to_string(), 18, AssetType::NATIVE), + Chain::HyperCore => chain.new_asset("Hyperliquid".to_string(), "HYPE".to_string(), 8, AssetType::NATIVE), + Chain::Monad => chain.new_asset("Monad".to_string(), "MON".to_string(), 18, AssetType::NATIVE), + Chain::XLayer => chain.new_asset("X Layer".to_string(), "OKB".to_string(), 18, AssetType::NATIVE), + Chain::Stable => chain.new_asset("Stable".to_string(), "gUSDT".to_string(), 18, AssetType::NATIVE), + } + } +} + +pub trait AssetVecExt { + fn ids(&self) -> Vec; + fn ids_set(&self) -> HashSet; + fn asset(&self, asset_id: AssetId) -> Option; + fn asset_result(&self, asset_id: AssetId) -> Result<&Asset, Box>; +} + +impl AssetVecExt for Vec { + fn ids(&self) -> Vec { + self.iter().map(|x| x.id.clone()).collect() + } + + fn ids_set(&self) -> HashSet { + self.iter().map(|x| x.id.clone()).collect() + } + + fn asset(&self, asset_id: AssetId) -> Option { + self.iter().find(|x| x.id == asset_id).cloned() + } + + fn asset_result(&self, asset_id: AssetId) -> Result<&Asset, Box> { + self.iter().find(|x| x.id == asset_id).ok_or("Asset not found".into()) + } +} + +pub trait AssetHashSetExt { + fn ids(&self) -> Vec; +} + +impl AssetHashSetExt for HashSet { + fn ids(&self) -> Vec { + self.iter().map(|x| x.to_string()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_asset_id() { + let asset = Asset::from_chain(Chain::Gnosis); + + assert_eq!(asset.symbol, "xDai"); + } + + #[test] + fn test_sei_evm_asset() { + let asset = Asset::from_chain(Chain::SeiEvm); + + assert_eq!(asset.name, "Sei EVM"); + assert_eq!(asset.symbol, "SEI"); + assert_eq!(asset.decimals, 18); + } +} diff --git a/core/crates/primitives/src/asset_address.rs b/core/crates/primitives/src/asset_address.rs new file mode 100644 index 0000000000..b9477fce7e --- /dev/null +++ b/core/crates/primitives/src/asset_address.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +use crate::AssetId; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct AssetAddress { + pub asset_id: AssetId, + pub address: String, + pub value: Option, +} + +impl AssetAddress { + pub fn new(asset_id: AssetId, address: String, value: Option) -> Self { + Self { asset_id, address, value } + } +} diff --git a/core/crates/primitives/src/asset_balance.rs b/core/crates/primitives/src/asset_balance.rs new file mode 100644 index 0000000000..3b083f5a17 --- /dev/null +++ b/core/crates/primitives/src/asset_balance.rs @@ -0,0 +1,169 @@ +use crate::AssetId; +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddressBalances { + pub coin: AssetBalance, + pub staking: Option, + pub assets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AssetBalance { + pub asset_id: AssetId, + pub balance: Balance, + pub is_active: bool, +} + +impl AssetBalance { + pub fn new(asset_id: AssetId, balance: BigUint) -> Self { + Self { + asset_id, + balance: Balance::coin_balance(balance), + is_active: true, + } + } + + pub fn new_zero_balance(asset_id: AssetId) -> Self { + Self::new(asset_id, BigUint::from(0u32)) + } + + pub fn new_balance(asset_id: AssetId, balance: Balance) -> Self { + Self { + asset_id, + balance, + is_active: true, + } + } + + pub fn new_with_active(asset_id: AssetId, balance: Balance, is_active: bool) -> Self { + Self { asset_id, balance, is_active } + } + + pub fn new_staking(asset_id: AssetId, staked: BigUint, pending: BigUint, rewards: BigUint) -> Self { + Self { + asset_id, + balance: Balance::stake_balance(staked, pending, Some(rewards)), + is_active: true, + } + } + pub fn new_earn(asset_id: AssetId, earn: BigUint) -> Self { + Self { + asset_id, + balance: Balance { + earn, + ..Balance::coin_balance(BigUint::from(0u32)) + }, + is_active: true, + } + } + + pub fn new_staking_with_metadata(asset_id: AssetId, staked: BigUint, pending: BigUint, rewards: BigUint, metadata: BalanceMetadata) -> Self { + Self { + asset_id, + balance: Balance::stake_balance_with_metadata(staked, pending, Some(rewards), Some(metadata)), + is_active: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Balance { + pub available: BigUint, + pub frozen: BigUint, + pub locked: BigUint, + pub staked: BigUint, + pub pending: BigUint, + pub pending_unconfirmed: BigUint, + pub rewards: BigUint, + pub reserved: BigUint, + pub earn: BigUint, + pub withdrawable: BigUint, + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct BalanceMetadata { + pub votes: u32, + pub energy_available: u32, + pub energy_total: u32, + pub bandwidth_available: u32, + pub bandwidth_total: u32, +} + +impl Balance { + pub fn coin_balance(available: BigUint) -> Self { + Self { + available, + frozen: BigUint::from(0u32), + locked: BigUint::from(0u32), + staked: BigUint::from(0u32), + pending: BigUint::from(0u32), + pending_unconfirmed: BigUint::from(0u32), + rewards: BigUint::from(0u32), + reserved: BigUint::from(0u32), + earn: BigUint::from(0u32), + withdrawable: BigUint::from(0u32), + metadata: None, + } + } + + pub fn with_pending_unconfirmed(available: BigUint, pending_unconfirmed: BigUint) -> Self { + Self { + available, + pending_unconfirmed, + frozen: BigUint::from(0u32), + locked: BigUint::from(0u32), + staked: BigUint::from(0u32), + pending: BigUint::from(0u32), + rewards: BigUint::from(0u32), + reserved: BigUint::from(0u32), + earn: BigUint::from(0u32), + withdrawable: BigUint::from(0u32), + metadata: None, + } + } + + pub fn with_reserved(available: BigUint, reserved: BigUint) -> Self { + Self { + available, + reserved, + frozen: BigUint::from(0u32), + locked: BigUint::from(0u32), + staked: BigUint::from(0u32), + pending: BigUint::from(0u32), + pending_unconfirmed: BigUint::from(0u32), + rewards: BigUint::from(0u32), + earn: BigUint::from(0u32), + withdrawable: BigUint::from(0u32), + metadata: None, + } + } + + pub fn stake_balance(staked: BigUint, pending: BigUint, rewards: Option) -> Self { + Self::stake_balance_with_metadata(staked, pending, rewards, None) + } + + pub fn stake_balance_with_metadata(staked: BigUint, pending: BigUint, rewards: Option, metadata: Option) -> Self { + Self { + available: BigUint::from(0u32), + frozen: BigUint::from(0u32), + locked: BigUint::from(0u32), + staked, + pending, + pending_unconfirmed: BigUint::from(0u32), + rewards: rewards.unwrap_or(BigUint::from(0u32)), + reserved: BigUint::from(0u32), + earn: BigUint::from(0u32), + withdrawable: BigUint::from(0u32), + metadata, + } + } +} diff --git a/core/crates/primitives/src/asset_constants.rs b/core/crates/primitives/src/asset_constants.rs new file mode 100644 index 0000000000..1d3fa59131 --- /dev/null +++ b/core/crates/primitives/src/asset_constants.rs @@ -0,0 +1,344 @@ +use std::sync::LazyLock; + +use crate::{AssetId, Chain}; + +pub const ARBITRUM_ACX_TOKEN_ID: &str = "0x53691596d1BCe8CEa565b84d4915e69e03d9C99d"; +pub static ARBITRUM_ACX_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Arbitrum, ARBITRUM_ACX_TOKEN_ID)); + +pub const ETHEREUM_ACX_TOKEN_ID: &str = "0x44108f0223A3C3028F5Fe7AEC7f9bb2E66beF82F"; +pub static ETHEREUM_ACX_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_ACX_TOKEN_ID)); + +pub const OPTIMISM_ACX_TOKEN_ID: &str = "0xFf733b2A3557a7ed6697007ab5D11B79FdD1b76B"; +pub static OPTIMISM_ACX_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Optimism, OPTIMISM_ACX_TOKEN_ID)); + +pub const POLYGON_ACX_TOKEN_ID: &str = "0xF328b73B6c685831F238c30a23Fc19140CB4D8FC"; +pub static POLYGON_ACX_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Polygon, POLYGON_ACX_TOKEN_ID)); + +pub const ARBITRUM_ARB_TOKEN_ID: &str = "0x912CE59144191C1204E64559FE8253a0e49E6548"; +pub static ARBITRUM_ARB_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Arbitrum, ARBITRUM_ARB_TOKEN_ID)); + +pub const ETHEREUM_ARB_TOKEN_ID: &str = "0x44108f0223A3C3028F5Fe7AEC7f9bb2E66beF82F"; +pub static ETHEREUM_ARB_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_ARB_TOKEN_ID)); + +pub const SMARTCHAIN_CAKE_TOKEN_ID: &str = "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82"; +pub static SMARTCHAIN_CAKE_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::SmartChain, SMARTCHAIN_CAKE_TOKEN_ID)); + +pub const ARBITRUM_DAI_TOKEN_ID: &str = "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"; +pub static ARBITRUM_DAI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Arbitrum, ARBITRUM_DAI_TOKEN_ID)); + +pub const BASE_DAI_TOKEN_ID: &str = "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"; +pub static BASE_DAI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Base, BASE_DAI_TOKEN_ID)); + +pub const ETHEREUM_DAI_TOKEN_ID: &str = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; +pub static ETHEREUM_DAI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_DAI_TOKEN_ID)); + +pub const LINEA_DAI_TOKEN_ID: &str = "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"; +pub static LINEA_DAI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Linea, LINEA_DAI_TOKEN_ID)); + +pub const OPTIMISM_DAI_TOKEN_ID: &str = "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"; +pub static OPTIMISM_DAI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Optimism, OPTIMISM_DAI_TOKEN_ID)); + +pub const POLYGON_DAI_TOKEN_ID: &str = "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063"; +pub static POLYGON_DAI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Polygon, POLYGON_DAI_TOKEN_ID)); + +pub const ZKSYNC_DAI_TOKEN_ID: &str = "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"; +pub static ZKSYNC_DAI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::ZkSync, ZKSYNC_DAI_TOKEN_ID)); + +pub const UNICHAIN_DAI_TOKEN_ID: &str = "0x20CAb320A855b39F724131C69424240519573f81"; +pub static UNICHAIN_DAI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Unichain, UNICHAIN_DAI_TOKEN_ID)); + +pub const SMARTCHAIN_ETH_TOKEN_ID: &str = "0x2170Ed0880ac9A755fd29B2688956BD959F933F8"; +pub static SMARTCHAIN_ETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::SmartChain, SMARTCHAIN_ETH_TOKEN_ID)); + +pub const ARBITRUM_USDC_TOKEN_ID: &str = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; +pub static ARBITRUM_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Arbitrum, ARBITRUM_USDC_TOKEN_ID)); + +pub const BASE_USDC_TOKEN_ID: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +pub static BASE_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Base, BASE_USDC_TOKEN_ID)); + +pub const ETHEREUM_USDC_TOKEN_ID: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +pub static ETHEREUM_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_USDC_TOKEN_ID)); + +pub const OPTIMISM_USDC_TOKEN_ID: &str = "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"; +pub static OPTIMISM_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Optimism, OPTIMISM_USDC_TOKEN_ID)); + +pub const POLYGON_USDC_TOKEN_ID: &str = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"; +pub static POLYGON_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Polygon, POLYGON_USDC_TOKEN_ID)); + +pub const GNOSIS_USDC_TOKEN_ID: &str = "0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0"; +pub static GNOSIS_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Gnosis, GNOSIS_USDC_TOKEN_ID)); + +pub const SOLANA_USDC_TOKEN_ID: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +pub static SOLANA_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Solana, SOLANA_USDC_TOKEN_ID)); + +pub const NEAR_USDC_TOKEN_ID: &str = "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1"; +pub static NEAR_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Near, NEAR_USDC_TOKEN_ID)); + +pub const UNICHAIN_USDC_TOKEN_ID: &str = "0x078D782b760474a361dDA0AF3839290b0EF57AD6"; +pub static UNICHAIN_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Unichain, UNICHAIN_USDC_TOKEN_ID)); + +pub const HYPEREVM_USDC_TOKEN_ID: &str = "0xb88339CB7199b77E23DB6E890353E22632Ba630f"; +pub static HYPEREVM_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Hyperliquid, HYPEREVM_USDC_TOKEN_ID)); + +pub const MONAD_USDC_TOKEN_ID: &str = "0x754704Bc059F8C67012fEd69BC8A327a5aafb603"; +pub static MONAD_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Monad, MONAD_USDC_TOKEN_ID)); + +pub const SMARTCHAIN_USDC_TOKEN_ID: &str = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d"; +pub static SMARTCHAIN_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::SmartChain, SMARTCHAIN_USDC_TOKEN_ID)); + +pub const AVALANCHE_USDC_TOKEN_ID: &str = "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"; +pub static AVALANCHE_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::AvalancheC, AVALANCHE_USDC_TOKEN_ID)); + +pub const SUI_USDC_TOKEN_ID: &str = "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC"; +pub static SUI_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Sui, SUI_USDC_TOKEN_ID)); + +pub const ARBITRUM_USDC_E_TOKEN_ID: &str = "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8"; +pub static ARBITRUM_USDC_E_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Arbitrum, ARBITRUM_USDC_E_TOKEN_ID)); + +pub const BASE_USDC_E_TOKEN_ID: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +pub static BASE_USDC_E_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Base, BASE_USDC_E_TOKEN_ID)); + +pub const ETHEREUM_USDC_E_TOKEN_ID: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +pub static ETHEREUM_USDC_E_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_USDC_E_TOKEN_ID)); + +pub const LINEA_USDC_E_TOKEN_ID: &str = "0x176211869cA2b568f2A7D4EE941E073a821EE1ff"; +pub static LINEA_USDC_E_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Linea, LINEA_USDC_E_TOKEN_ID)); + +pub const OPTIMISM_USDC_E_TOKEN_ID: &str = "0x7F5c764cBc14f9669B88837ca1490cCa17c31607"; +pub static OPTIMISM_USDC_E_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Optimism, OPTIMISM_USDC_E_TOKEN_ID)); + +pub const POLYGON_USDC_E_TOKEN_ID: &str = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"; +pub static POLYGON_USDC_E_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Polygon, POLYGON_USDC_E_TOKEN_ID)); + +pub const WORLD_USDC_E_TOKEN_ID: &str = "0x79A02482A880bCE3F13e09Da970dC34db4CD24d1"; +pub static WORLD_USDC_E_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::World, WORLD_USDC_E_TOKEN_ID)); + +pub const ZKSYNC_USDC_E_TOKEN_ID: &str = "0x3355df6D4c9C3035724Fd0e3914dE96A5a83aaf4"; +pub static ZKSYNC_USDC_E_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::ZkSync, ZKSYNC_USDC_E_TOKEN_ID)); + +pub const SEIEVM_USDC_TOKEN_ID: &str = "0xe15fC38F6D8c56aF07bbCBe3BAf5708A2Bf42392"; +pub static SEIEVM_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::SeiEvm, SEIEVM_USDC_TOKEN_ID)); + +pub const ARBITRUM_USDT_TOKEN_ID: &str = "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"; +pub static ARBITRUM_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Arbitrum, ARBITRUM_USDT_TOKEN_ID)); + +pub const ETHEREUM_USDT_TOKEN_ID: &str = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; +pub static ETHEREUM_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_USDT_TOKEN_ID)); + +pub const LINEA_USDT_TOKEN_ID: &str = "0xA219439258ca9da29E9Cc4cE5596924745e12B93"; +pub static LINEA_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Linea, LINEA_USDT_TOKEN_ID)); + +pub const OPTIMISM_USDT_TOKEN_ID: &str = "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58"; +pub static OPTIMISM_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Optimism, OPTIMISM_USDT_TOKEN_ID)); + +pub const POLYGON_USDT_TOKEN_ID: &str = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; +pub static POLYGON_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Polygon, POLYGON_USDT_TOKEN_ID)); + +pub const ZKSYNC_USDT_TOKEN_ID: &str = "0x493257fD37EDB34451f62EDf8D2a0C418852bA4C"; +pub static ZKSYNC_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::ZkSync, ZKSYNC_USDT_TOKEN_ID)); + +pub const SMARTCHAIN_USDT_TOKEN_ID: &str = "0x55d398326f99059fF775485246999027B3197955"; +pub static SMARTCHAIN_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::SmartChain, SMARTCHAIN_USDT_TOKEN_ID)); + +pub const AVALANCHE_USDT_TOKEN_ID: &str = "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7"; +pub static AVALANCHE_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::AvalancheC, AVALANCHE_USDT_TOKEN_ID)); + +pub const CELO_USDT_TOKEN_ID: &str = "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e"; +pub static CELO_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Celo, CELO_USDT_TOKEN_ID)); + +pub const SOLANA_USDT_TOKEN_ID: &str = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"; +pub static SOLANA_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Solana, SOLANA_USDT_TOKEN_ID)); + +pub const SOLANA_PYUSD_TOKEN_ID: &str = "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo"; +pub static SOLANA_PYUSD_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Solana, SOLANA_PYUSD_TOKEN_ID)); + +pub const TRON_USDT_TOKEN_ID: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +pub static TRON_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Tron, TRON_USDT_TOKEN_ID)); + +pub const TON_USDT_TOKEN_ID: &str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"; +pub static TON_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ton, TON_USDT_TOKEN_ID)); + +pub const NEAR_USDT_TOKEN_ID: &str = "usdt.tether-token.near"; +pub static NEAR_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Near, NEAR_USDT_TOKEN_ID)); + +pub const INK_USDT_TOKEN_ID: &str = "0x3baD7AD0728f9917d1Bf08af5782dCbD516cDd96"; +pub static INK_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ink, INK_USDT_TOKEN_ID)); + +pub const HYPEREVM_USDT_TOKEN_ID: &str = "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb"; +pub static HYPEREVM_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Hyperliquid, HYPEREVM_USDT_TOKEN_ID)); + +pub const SEIEVM_USDT_TOKEN_ID: &str = "0x9151434b16b9763660705744891fA906F660EcC5"; +pub static SEIEVM_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::SeiEvm, SEIEVM_USDT_TOKEN_ID)); + +pub const PLASMA_USDT_TOKEN_ID: &str = "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb"; +pub static PLASMA_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Plasma, PLASMA_USDT_TOKEN_ID)); + +pub const MONAD_USDT_TOKEN_ID: &str = "0xe7cd86e13AC4309349F30B3435a9d337750fC82D"; +pub static MONAD_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Monad, MONAD_USDT_TOKEN_ID)); + +pub const APTOS_USDT_TOKEN_ID: &str = "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"; +pub static APTOS_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Aptos, APTOS_USDT_TOKEN_ID)); + +pub const ARBITRUM_WBTC_TOKEN_ID: &str = "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"; +pub static ARBITRUM_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Arbitrum, ARBITRUM_WBTC_TOKEN_ID)); + +pub const BLAST_WBTC_TOKEN_ID: &str = "0xF7bc58b8D8f97ADC129cfC4c9f45Ce3C0E1D2692"; +pub static BLAST_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Blast, BLAST_WBTC_TOKEN_ID)); + +pub const ETHEREUM_WBTC_TOKEN_ID: &str = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"; +pub static ETHEREUM_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_WBTC_TOKEN_ID)); + +pub const LINEA_WBTC_TOKEN_ID: &str = "0x3aAB2285ddcDdaD8edf438C1bAB47e1a9D05a9b4"; +pub static LINEA_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Linea, LINEA_WBTC_TOKEN_ID)); + +pub const OPTIMISM_WBTC_TOKEN_ID: &str = "0x68f180fcCe6836688e9084f035309E29Bf0A2095"; +pub static OPTIMISM_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Optimism, OPTIMISM_WBTC_TOKEN_ID)); + +pub const POLYGON_WBTC_TOKEN_ID: &str = "0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6"; +pub static POLYGON_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Polygon, POLYGON_WBTC_TOKEN_ID)); + +pub const SONIC_WBTC_TOKEN_ID: &str = "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c"; +pub static SONIC_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Sonic, SONIC_WBTC_TOKEN_ID)); + +pub const WORLD_WBTC_TOKEN_ID: &str = "0x03C7054BCB39f7b2e5B2c7AcB37583e32D70Cfa3"; +pub static WORLD_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::World, WORLD_WBTC_TOKEN_ID)); + +pub const ZKSYNC_WBTC_TOKEN_ID: &str = "0xBBeB516fb02a01611cBBE0453Fe3c580D7281011"; +pub static ZKSYNC_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::ZkSync, ZKSYNC_WBTC_TOKEN_ID)); + +pub const ARBITRUM_WETH_TOKEN_ID: &str = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"; +pub static ARBITRUM_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Arbitrum, ARBITRUM_WETH_TOKEN_ID)); + +pub const BLAST_WETH_TOKEN_ID: &str = "0x4300000000000000000000000000000000000004"; +pub static BLAST_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Blast, BLAST_WETH_TOKEN_ID)); + +pub const ETHEREUM_WETH_TOKEN_ID: &str = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +pub static ETHEREUM_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_WETH_TOKEN_ID)); + +pub const LINEA_WETH_TOKEN_ID: &str = "0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f"; +pub static LINEA_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Linea, LINEA_WETH_TOKEN_ID)); + +pub const BASE_WETH_TOKEN_ID: &str = "0x4200000000000000000000000000000000000006"; +pub static BASE_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Base, BASE_WETH_TOKEN_ID)); + +pub const OPTIMISM_WETH_TOKEN_ID: &str = "0x4200000000000000000000000000000000000006"; +pub static OPTIMISM_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Optimism, OPTIMISM_WETH_TOKEN_ID)); + +pub const OPBNB_WETH_TOKEN_ID: &str = "0x4200000000000000000000000000000000000006"; +pub static OPBNB_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::OpBNB, OPBNB_WETH_TOKEN_ID)); + +pub const POLYGON_WETH_TOKEN_ID: &str = "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619"; +pub static POLYGON_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Polygon, POLYGON_WETH_TOKEN_ID)); + +pub const WORLD_WETH_TOKEN_ID: &str = "0x4200000000000000000000000000000000000006"; +pub static WORLD_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::World, WORLD_WETH_TOKEN_ID)); + +pub const INK_WETH_TOKEN_ID: &str = "0x4200000000000000000000000000000000000006"; +pub static INK_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ink, INK_WETH_TOKEN_ID)); + +pub const ZKSYNC_WETH_TOKEN_ID: &str = "0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91"; +pub static ZKSYNC_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::ZkSync, ZKSYNC_WETH_TOKEN_ID)); + +pub const UNICHAIN_WETH_TOKEN_ID: &str = "0x4200000000000000000000000000000000000006"; +pub static UNICHAIN_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Unichain, UNICHAIN_WETH_TOKEN_ID)); + +pub const CELO_WETH_TOKEN_ID: &str = "0x471EcE3750Da237f93B8E339c536989b8978a438"; +pub static CELO_WETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Celo, CELO_WETH_TOKEN_ID)); + +pub const BASE_CBBTC_TOKEN_ID: &str = "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf"; +pub static BASE_CBBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Base, BASE_CBBTC_TOKEN_ID)); + +pub const ETHEREUM_CBBTC_TOKEN_ID: &str = "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf"; +pub static ETHEREUM_CBBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_CBBTC_TOKEN_ID)); + +pub const ETHEREUM_LINK_TOKEN_ID: &str = "0x514910771AF9Ca656af840dff83E8264EcF986CA"; +pub static ETHEREUM_LINK_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_LINK_TOKEN_ID)); + +pub const ETHEREUM_UNI_TOKEN_ID: &str = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984"; +pub static ETHEREUM_UNI_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_UNI_TOKEN_ID)); + +pub const ETHEREUM_AAVE_TOKEN_ID: &str = "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9"; +pub static ETHEREUM_AAVE_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_AAVE_TOKEN_ID)); + +pub const OPTIMISM_OP_TOKEN_ID: &str = "0x4200000000000000000000000000000000000042"; +pub static OPTIMISM_OP_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Optimism, OPTIMISM_OP_TOKEN_ID)); + +pub const BERACHAIN_USDT_TOKEN_ID: &str = "0x779Ded0c9e1022225f8e0630b35a9b54be713736"; +pub static BERACHAIN_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Berachain, BERACHAIN_USDT_TOKEN_ID)); + +pub const GNOSIS_USDT_TOKEN_ID: &str = "0x4ECaBa5870353805a9F068101A40E0f32ed605C6"; +pub static GNOSIS_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Gnosis, GNOSIS_USDT_TOKEN_ID)); + +pub const STELLAR_USDC_TOKEN_ID: &str = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN::USDC"; +pub static STELLAR_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Stellar, STELLAR_USDC_TOKEN_ID)); + +pub const XLAYER_USDC_TOKEN_ID: &str = "0x74b7f16337b8972027F6196A17a631aC6dE26d22"; +pub static XLAYER_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::XLayer, XLAYER_USDC_TOKEN_ID)); + +pub const XLAYER_USDT_TOKEN_ID: &str = "0x779Ded0c9e1022225f8e0630b35a9b54be713736"; +pub static XLAYER_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::XLayer, XLAYER_USDT_TOKEN_ID)); + +pub const ETHEREUM_STETH_TOKEN_ID: &str = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"; +pub static ETHEREUM_STETH_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_STETH_TOKEN_ID)); + +pub const ETHEREUM_USDS_TOKEN_ID: &str = "0xdC035D45d973E3EC169d2276DDab16f1e407384F"; +pub static ETHEREUM_USDS_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_USDS_TOKEN_ID)); + +pub const ETHEREUM_FLIP_TOKEN_ID: &str = "0x826180541412D574cf1336d22c0C0a287822678A"; +pub static ETHEREUM_FLIP_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Ethereum, ETHEREUM_FLIP_TOKEN_ID)); + +pub const BASE_USDS_TOKEN_ID: &str = "0x820C137fa70C8691f0e44Dc420a5e53c168921Dc"; +pub static BASE_USDS_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Base, BASE_USDS_TOKEN_ID)); + +pub const BASE_WBTC_TOKEN_ID: &str = "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c"; +pub static BASE_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Base, BASE_WBTC_TOKEN_ID)); + +pub const SMARTCHAIN_WBTC_TOKEN_ID: &str = "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c"; +pub static SMARTCHAIN_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::SmartChain, SMARTCHAIN_WBTC_TOKEN_ID)); + +pub const SOLANA_USDS_TOKEN_ID: &str = "USDSwr9ApdHk5bvJKMjzff41FfuX8bSxdKcR81vTwcA"; +pub static SOLANA_USDS_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Solana, SOLANA_USDS_TOKEN_ID)); + +pub const SOLANA_WBTC_TOKEN_ID: &str = "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh"; +pub static SOLANA_WBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Solana, SOLANA_WBTC_TOKEN_ID)); + +pub const SOLANA_CBBTC_TOKEN_ID: &str = "cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij"; +pub static SOLANA_CBBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Solana, SOLANA_CBBTC_TOKEN_ID)); + +pub const SOLANA_JITO_SOL_TOKEN_ID: &str = "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn"; +pub static SOLANA_JITO_SOL_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Solana, SOLANA_JITO_SOL_TOKEN_ID)); + +pub const HYPERCORE_SPOT_HYPE_TOKEN_ID: &str = "HYPE::0x0d01dc56dcaaca66ad901c959b4011ec::150"; +pub static HYPERCORE_SPOT_HYPE_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::HyperCore, HYPERCORE_SPOT_HYPE_TOKEN_ID)); + +pub const HYPERCORE_SPOT_USDC_TOKEN_ID: &str = "USDC::0x6d1e7cde53ba9467b783cb7c530ce054::0"; +pub static HYPERCORE_SPOT_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::HyperCore, HYPERCORE_SPOT_USDC_TOKEN_ID)); + +pub const HYPERCORE_SPOT_UBTC_TOKEN_ID: &str = "UBTC::0x8f254b963e8468305d409b33aa137c67::197"; +pub static HYPERCORE_SPOT_UBTC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::HyperCore, HYPERCORE_SPOT_UBTC_TOKEN_ID)); + +pub const HYPERCORE_CORE_HYPE_TOKEN_ID: &str = "HYPE:0x0d01dc56dcaaca66ad901c959b4011ec"; + +pub const SUI_WAL_TOKEN_ID: &str = "0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL"; +pub static SUI_WAL_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Sui, SUI_WAL_TOKEN_ID)); + +pub const SUI_SBUSDT_TOKEN_ID: &str = "0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT"; +pub static SUI_SBUSDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Sui, SUI_SBUSDT_TOKEN_ID)); + +pub const THORCHAIN_TCY_TOKEN_ID: &str = "tcy"; +pub static THORCHAIN_TCY_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Thorchain, THORCHAIN_TCY_TOKEN_ID)); + +pub const COSMOS_USDC_TOKEN_ID: &str = "ibc/F663521BF1836B00F5F177680F74BFB9A8B5654A694D0D2BC249E03CF2509013"; +pub static COSMOS_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Cosmos, COSMOS_USDC_TOKEN_ID)); + +pub const OSMOSIS_USDC_TOKEN_ID: &str = "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"; +pub static OSMOSIS_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Osmosis, OSMOSIS_USDC_TOKEN_ID)); + +pub const OSMOSIS_USDT_TOKEN_ID: &str = "ibc/4ABBEF4C8926DDDB320AE5188CFD63267ABBCEFC0583E4AE05D6E5AA2401DDAB"; +pub static OSMOSIS_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Osmosis, OSMOSIS_USDT_TOKEN_ID)); + +pub const INJECTIVE_USDC_TOKEN_ID: &str = "ibc/7E1AF94AD246BE522892751046F0C959B768642E5671CC3742264068D49553C0"; +pub static INJECTIVE_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)); + +pub const SEI_USDC_TOKEN_ID: &str = "ibc/CA6FBFAF399474A06263E10D0CE5AEBBE15189D6D4B2DD9ADE61007E68EB9DB0"; +pub static SEI_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)); diff --git a/core/crates/primitives/src/asset_details.rs b/core/crates/primitives/src/asset_details.rs new file mode 100644 index 0000000000..3fca71f7c9 --- /dev/null +++ b/core/crates/primitives/src/asset_details.rs @@ -0,0 +1,172 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{Asset, AssetId, AssetMarket, AssetScore, LinkType, Price, perpetual::PerpetualBasic}; + +#[typeshare(swift = "Sendable")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetFull { + pub asset: Asset, + pub properties: AssetProperties, + pub score: AssetScore, + pub tags: Vec, + pub links: Vec, + pub perpetuals: Vec, + pub price: Option, + pub market: Option, +} + +impl AssetFull { + pub fn with_rate(self, rate: f64) -> Self { + Self { + asset: self.asset, + properties: self.properties, + score: self.score, + tags: self.tags, + links: self.links, + perpetuals: self.perpetuals, + price: self.price.map(|p| p.with_rate(rate)), + market: self.market.map(|m| m.with_rate(rate)), + } + } +} + +#[typeshare(swift = "Sendable")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetBasic { + pub asset: Asset, + pub properties: AssetProperties, + pub score: AssetScore, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, +} + +impl AssetBasic { + pub fn new(asset: Asset, properties: AssetProperties, score: AssetScore) -> Self { + Self { + asset, + properties, + score, + price: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct AssetPriceMetadata { + pub asset: AssetBasic, + pub price: Option, +} + +impl AssetPriceMetadata { + pub fn asset_basic_with_rate(self, rate: f64) -> AssetBasic { + AssetBasic { + price: self.price.map(|price| price.with_rate(rate)), + ..self.asset + } + } +} + +#[typeshare(swift = "Sendable")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetMarketPrice { + pub price: Option, + pub market: Option, + #[typeshare(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prices: Option>, +} + +#[typeshare(swift = "Sendable")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetProperties { + pub is_enabled: bool, + pub is_buyable: bool, + pub is_sellable: bool, + pub is_swapable: bool, + pub is_stakeable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub staking_apr: Option, + pub is_earnable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub earn_apr: Option, + pub has_image: bool, + #[typeshare(skip)] + pub has_price: bool, +} + +impl AssetProperties { + pub fn default(asset_id: AssetId) -> Self { + let is_stakeable = asset_id.is_native() && asset_id.chain.is_stake_supported(); + Self { + is_enabled: true, + is_buyable: false, + is_sellable: false, + is_swapable: asset_id.chain.is_swap_supported(), + is_stakeable, + staking_apr: None, + is_earnable: false, + earn_apr: None, + has_image: false, + has_price: false, + } + } +} + +#[typeshare(swift = "Sendable, Equatable, Hashable")] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AssetLink { + pub name: String, + pub url: String, +} + +impl AssetLink { + pub fn new(url: &str, link_type: LinkType) -> Self { + Self { + name: link_type.name(), + url: url.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + use crate::{Asset, Chain, PriceProvider}; + + #[test] + fn test_asset_basic_with_rate() { + let asset = Asset::from_chain(Chain::Bitcoin); + let price = Price::new(100.0, 5.0, Utc::now(), PriceProvider::Coingecko); + + let base = AssetPriceMetadata { + asset: AssetBasic::new(asset.clone(), AssetProperties::default(asset.id.clone()), AssetScore::default()), + price: Some(price), + } + .asset_basic_with_rate(1.0); + let converted = AssetPriceMetadata { + asset: AssetBasic::new(asset.clone(), AssetProperties::default(asset.id.clone()), AssetScore::default()), + price: Some(price), + } + .asset_basic_with_rate(2.0); + let missing = AssetPriceMetadata { + asset: AssetBasic::new(asset.clone(), AssetProperties::default(asset.id.clone()), AssetScore::default()), + price: None, + } + .asset_basic_with_rate(2.0); + + assert_eq!(base.asset, asset); + assert_eq!(base.price.as_ref().unwrap().price, price.price); + assert_eq!(base.price.as_ref().unwrap().price_change_percentage_24h, price.price_change_percentage_24h); + assert_eq!(converted.price.as_ref().unwrap().price, price.price * 2.0); + assert_eq!(converted.price.as_ref().unwrap().price_change_percentage_24h, price.price_change_percentage_24h); + assert!(missing.price.is_none()); + } +} diff --git a/core/crates/primitives/src/asset_fiat_value.rs b/core/crates/primitives/src/asset_fiat_value.rs new file mode 100644 index 0000000000..d83758bc6c --- /dev/null +++ b/core/crates/primitives/src/asset_fiat_value.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct AssetFiatValue { + pub amount: f64, + pub price: f64, + pub price_change_percentage_24h: f64, +} diff --git a/core/crates/primitives/src/asset_id.rs b/core/crates/primitives/src/asset_id.rs new file mode 100644 index 0000000000..380f333bca --- /dev/null +++ b/core/crates/primitives/src/asset_id.rs @@ -0,0 +1,249 @@ +use std::{collections::HashSet, fmt}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{AssetSubtype, chain::Chain}; + +pub const CHAIN_SEPARATOR: &str = "_"; +pub const TOKEN_ID_SEPARATOR: &str = "::"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AssetId { + pub chain: Chain, + pub token_id: Option, +} + +impl Serialize for AssetId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for AssetId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum AssetIdInput { + String(String), + Object { + chain: Chain, + #[serde(alias = "tokenId")] + token_id: Option, + }, + } + + match AssetIdInput::deserialize(deserializer)? { + AssetIdInput::String(value) => AssetId::new(&value).ok_or_else(|| de::Error::custom("Invalid AssetId")), + AssetIdInput::Object { chain, token_id } => Ok(AssetId { chain, token_id }), + } + } +} + +impl From<&str> for AssetId { + fn from(value: &str) -> Self { + AssetId::new(value).unwrap() + } +} + +impl fmt::Display for AssetId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let str = match &self.token_id { + Some(token_id) => { + format!("{}{CHAIN_SEPARATOR}{}", self.chain.as_ref(), token_id) + } + None => self.chain.as_ref().to_owned(), + }; + write!(f, "{str}") + } +} + +impl From for String { + fn from(value: AssetId) -> Self { + value.to_string() + } +} + +impl AssetId { + pub fn new(asset_id: &str) -> Option { + match asset_id.split_once(CHAIN_SEPARATOR) { + Some((chain, token_id)) => Some(AssetId { + chain: chain.parse().ok()?, + token_id: Some(token_id.to_string()), + }), + None => Some(AssetId { + chain: asset_id.parse().ok()?, + token_id: None, + }), + } + } + + pub fn from(chain: Chain, token_id: Option) -> AssetId { + AssetId { chain, token_id } + } + + pub fn from_token(chain: Chain, token_id: &str) -> AssetId { + AssetId { + chain, + token_id: Some(token_id.to_string()), + } + } + + pub fn token(chain: Chain, token_id: impl Into) -> AssetId { + AssetId { + chain, + token_id: Some(token_id.into()), + } + } + + pub fn from_chain(chain: Chain) -> AssetId { + AssetId { chain, token_id: None } + } + + pub fn sub_token_id(ids: &[String]) -> String { + ids.join(TOKEN_ID_SEPARATOR) + } + + pub fn decode_token_id(token_id: &str) -> Vec { + token_id.split(TOKEN_ID_SEPARATOR).map(|s| s.to_string()).collect() + } + + pub fn split_token_id(token_id: &str, separator: char) -> Vec { + token_id.split(separator).map(|s| s.to_string()).collect() + } + + pub fn get_token_id(&self) -> Result<&String, crate::SignerError> { + self.token_id.as_ref().ok_or_else(|| crate::SignerError::InvalidInput("Token ID required".to_string())) + } + + pub fn split_token_parts(&self, separator: char) -> Result<(String, String), crate::SignerError> { + let token_id = self.get_token_id()?; + let parts: Vec<&str> = token_id.split(separator).collect(); + if parts.len() < 2 { + return Err(crate::SignerError::InvalidInput(format!("Invalid token ID format: {}", token_id))); + } + Ok((parts[0].to_string(), parts[1].to_string())) + } + + pub fn split_sub_token_parts(&self) -> Option<(String, String)> { + let token_id = self.token_id.as_ref()?; + let (first, second) = token_id.split_once(TOKEN_ID_SEPARATOR)?; + Some((first.to_string(), second.to_string())) + } + + pub fn is_native(&self) -> bool { + self.token_id.is_none() + } + pub fn is_token(&self) -> bool { + self.token_id.is_some() + } + + pub fn token_subtype(&self) -> AssetSubtype { + if self.is_native() { AssetSubtype::NATIVE } else { AssetSubtype::TOKEN } + } + + pub fn token_components(&self) -> Option<(String, Option, Option)> { + let token_id = self.token_id.as_ref()?; + let parts = AssetId::decode_token_id(token_id); + if parts.is_empty() { + return None; + } + + let symbol = parts[0].clone(); + let contract = parts.get(1).and_then(|value| (!value.is_empty()).then(|| value.clone())); + let index = parts.get(2).and_then(|value| value.parse::().ok()); + + Some((symbol, contract, index)) + } +} + +pub trait AssetIdVecExt { + fn ids(&self) -> Vec; + fn ids_set(&self) -> HashSet; +} + +impl AssetIdVecExt for Vec { + fn ids(&self) -> Vec { + self.iter().map(|x| x.to_string()).collect() + } + + fn ids_set(&self) -> HashSet { + self.iter().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_asset_id_with_coin() { + let asset_id = AssetId::new("ethereum").unwrap(); + assert_eq!(asset_id.chain, Chain::Ethereum); + assert_eq!(asset_id.token_id, None); + } + + #[test] + fn test_new_asset_id_with_coin_and_token() { + let asset_id = AssetId::new("ethereum_0x1234567890abcdef").unwrap(); + assert_eq!(asset_id.chain, Chain::Ethereum); + assert_eq!(asset_id.token_id, Some("0x1234567890abcdef".to_owned())); + } + + #[test] + fn test_new_asset_id_with_coin_and_token_extra_underscore() { + let asset_id = AssetId::new("ton_EQAvlWFDxGF2lXm67y4yzC17wYKD9A0guwPkMs1gOsM__NOT").unwrap(); + assert_eq!(asset_id.chain, Chain::Ton); + assert_eq!(asset_id.token_id, Some("EQAvlWFDxGF2lXm67y4yzC17wYKD9A0guwPkMs1gOsM__NOT".to_owned())); + } + + #[test] + fn test_deserialize_asset_id_object_camel_case() { + let asset_id: AssetId = serde_json::from_str(r#"{"chain":"solana","tokenId":"BPxxfRCXkUVhig4HS1Lh7kZqV6SPJhzfEk4x6fVBjPCy"}"#).unwrap(); + assert_eq!(asset_id.chain, Chain::Solana); + assert_eq!(asset_id.token_id, Some("BPxxfRCXkUVhig4HS1Lh7kZqV6SPJhzfEk4x6fVBjPCy".to_string())); + } + + #[test] + fn test_deserialize_asset_id_object_native() { + let asset_id: AssetId = serde_json::from_str(r#"{"chain":"ethereum"}"#).unwrap(); + assert_eq!(asset_id.chain, Chain::Ethereum); + assert_eq!(asset_id.token_id, None); + } + + #[test] + fn test_sub_token_id() { + let result = AssetId::sub_token_id(&["test".to_string()]); + assert_eq!(result, "test"); + + let result = AssetId::sub_token_id(&["perpetual".to_string(), "BTC".to_string()]); + assert_eq!(result, "perpetual::BTC"); + + let result = AssetId::sub_token_id(&["type".to_string(), "subtype".to_string(), "coin".to_string()]); + assert_eq!(result, "type::subtype::coin"); + + let result = AssetId::sub_token_id(&[]); + assert_eq!(result, ""); + + let asset_id = AssetId::from(Chain::HyperCore, Some(AssetId::sub_token_id(&["perpetual".to_string(), "ETH".to_string()]))); + assert_eq!(asset_id.chain, Chain::HyperCore); + assert_eq!(asset_id.token_id, Some("perpetual::ETH".to_string())); + assert_eq!(asset_id.to_string(), "hypercore_perpetual::ETH"); + } + + #[test] + fn test_decode_token_id() { + assert_eq!(AssetId::decode_token_id("USDC"), vec!["USDC"]); + assert_eq!( + AssetId::decode_token_id("USDC::0x6d1e7cde53ba9467b783cb7c530ce054::0"), + vec!["USDC", "0x6d1e7cde53ba9467b783cb7c530ce054", "0"] + ); + assert_eq!(AssetId::decode_token_id("perpetual::BTC"), vec!["perpetual", "BTC"]); + assert_eq!(AssetId::decode_token_id(""), vec![""]); + } +} diff --git a/core/crates/primitives/src/asset_metadata.rs b/core/crates/primitives/src/asset_metadata.rs new file mode 100644 index 0000000000..4c90de872d --- /dev/null +++ b/core/crates/primitives/src/asset_metadata.rs @@ -0,0 +1,27 @@ +#[typeshare(swift = "Equatable, Hashable, Sendable")] +struct AssetMetaData { + #[serde(rename = "isEnabled")] + is_enabled: bool, + #[serde(rename = "isBalanceEnabled")] + is_balance_enabled: bool, + #[serde(rename = "isBuyEnabled")] + is_buy_enabled: bool, + #[serde(rename = "isSellEnabled")] + is_sell_enabled: bool, + #[serde(rename = "isSwapEnabled")] + is_swap_enabled: bool, + #[serde(rename = "isStakeEnabled")] + is_stake_enabled: bool, + #[serde(rename = "isEarnEnabled")] + is_earn_enabled: bool, + #[serde(rename = "isPinned")] + is_pinned: bool, + #[serde(rename = "isActive")] + is_active: bool, + #[serde(rename = "stakingApr")] + staking_apr: Option, + #[serde(rename = "earnApr")] + earn_apr: Option, + #[serde(rename = "rankScore")] + rank_score: i32, +} diff --git a/core/crates/primitives/src/asset_order.rs b/core/crates/primitives/src/asset_order.rs new file mode 100644 index 0000000000..9b9c06b356 --- /dev/null +++ b/core/crates/primitives/src/asset_order.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AssetOrder { + PriceChange24hAsc, + PriceChange24hDesc, +} diff --git a/core/crates/primitives/src/asset_price.rs b/core/crates/primitives/src/asset_price.rs new file mode 100644 index 0000000000..b4e5bd6878 --- /dev/null +++ b/core/crates/primitives/src/asset_price.rs @@ -0,0 +1,161 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::portfolio::ChartValuePercentage; +use crate::{AssetId, Price}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Sendable, Equatable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct AssetPrice { + pub asset_id: AssetId, + pub price: f64, + pub price_change_percentage_24h: f64, + pub updated_at: DateTime, +} + +impl AssetPrice { + pub fn new(asset_id: AssetId, price: f64, price_change_percentage_24h: f64, updated_at: DateTime) -> Self { + Self { + asset_id, + price, + price_change_percentage_24h, + updated_at, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct AssetMarket { + pub market_cap: Option, + pub market_cap_fdv: Option, + pub market_cap_rank: Option, + pub total_volume: Option, + pub circulating_supply: Option, + pub total_supply: Option, + pub max_supply: Option, + #[typeshare(skip)] + pub all_time_high: Option, + #[typeshare(skip)] + pub all_time_high_date: Option>, + #[typeshare(skip)] + pub all_time_high_change_percentage: Option, + #[typeshare(skip)] + pub all_time_low: Option, + #[typeshare(skip)] + pub all_time_low_date: Option>, + #[typeshare(skip)] + pub all_time_low_change_percentage: Option, + pub all_time_high_value: Option, + pub all_time_low_value: Option, +} + +impl AssetMarket { + pub fn with_rate(self, rate: f64) -> Self { + Self { + market_cap: self.market_cap.map(|x| x * rate), + market_cap_fdv: self.market_cap_fdv.map(|x| x * rate), + market_cap_rank: self.market_cap_rank, + total_volume: self.total_volume.map(|x| x * rate), + circulating_supply: self.circulating_supply, + total_supply: self.total_supply, + max_supply: self.max_supply, + all_time_high: self.all_time_high.map(|x| x * rate), + all_time_high_date: self.all_time_high_date, + all_time_high_change_percentage: self.all_time_high_change_percentage, + all_time_low: self.all_time_low.map(|x| x * rate), + all_time_low_date: self.all_time_low_date, + all_time_low_change_percentage: self.all_time_low_change_percentage, + all_time_high_value: self.all_time_high_value.map(|v| v.with_rate(rate)), + all_time_low_value: self.all_time_low_value.map(|v| v.with_rate(rate)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct AssetPrices { + pub currency: String, + pub prices: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct AssetPricesRequest { + pub currency: Option, + pub asset_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct Charts { + pub price: Option, + pub market: Option, + pub prices: Vec, + pub market_caps: Vec, + pub total_volumes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ChartValue { + pub timestamp: i32, + pub value: f32, +} + +impl PartialEq for ChartValue { + fn eq(&self, other: &Self) -> bool { + self.timestamp == other.timestamp && self.value == other.value + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "lowercase")] +pub enum ChartPeriod { + Hour, + Day, + Week, + Month, + Year, + All, +} + +#[derive(Debug, Clone, Copy)] +pub enum ChartTimeframe { + Raw, + Hourly, + Daily, +} + +impl ChartPeriod { + pub fn new(period: String) -> Option { + match period.to_lowercase().as_str() { + "hour" => Some(Self::Hour), + "day" => Some(Self::Day), + "week" => Some(Self::Week), + "month" => Some(Self::Month), + "year" => Some(Self::Year), + "all" => Some(Self::All), + _ => None, + } + } + + pub fn minutes(&self) -> i32 { + match self { + ChartPeriod::Hour => 60, + ChartPeriod::Day => 1440, + ChartPeriod::Week => 10_080, + ChartPeriod::Month => 43_200, + ChartPeriod::Year => 525_600, + ChartPeriod::All => 10_525_600, + } + } +} diff --git a/core/crates/primitives/src/asset_price_info.rs b/core/crates/primitives/src/asset_price_info.rs new file mode 100644 index 0000000000..5169daa269 --- /dev/null +++ b/core/crates/primitives/src/asset_price_info.rs @@ -0,0 +1,111 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::portfolio::ChartValuePercentage; +use crate::{AssetId, AssetMarket, AssetPrice, Price}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +#[typeshare(skip)] +pub struct AssetPriceInfo { + pub asset_id: AssetId, + pub price: Price, + pub market: AssetMarket, +} + +impl AssetPriceInfo { + pub fn as_price_primitive(&self) -> Price { + self.price + } + + pub fn as_price_primitive_with_rate(&self, rate: f64) -> Price { + self.price.with_rate(rate) + } + + pub fn as_asset_price_primitive(&self) -> AssetPrice { + self.as_asset_price_primitive_with_rate(1.0) + } + + pub fn as_asset_price_primitive_with_rate(&self, rate: f64) -> AssetPrice { + let price = self.price.with_rate(rate); + AssetPrice { + asset_id: self.asset_id.clone(), + price: price.price, + price_change_percentage_24h: price.price_change_percentage_24h, + updated_at: price.updated_at, + } + } + + pub fn as_market(&self) -> AssetMarket { + self.as_market_with_rate(1.0) + } + + pub fn as_market_with_rate(&self, rate: f64) -> AssetMarket { + let current_price = self.price.price; + let ath_percentage = self.market.all_time_high.map(|ath| (current_price - ath) / ath * 100.0); + let atl_percentage = self.market.all_time_low.map(|atl| (current_price - atl) / atl * 100.0); + AssetMarket { + market_cap: self.market.market_cap.map(|x| x * rate), + market_cap_fdv: self.market.market_cap_fdv.map(|x| x * rate), + market_cap_rank: self.market.market_cap_rank, + total_volume: self.market.total_volume.map(|x| x * rate), + circulating_supply: self.market.circulating_supply, + total_supply: self.market.total_supply, + max_supply: self.market.max_supply, + all_time_high: self.market.all_time_high.map(|x| x * rate), + all_time_high_date: self.market.all_time_high_date, + all_time_high_change_percentage: ath_percentage, + all_time_low: self.market.all_time_low.map(|x| x * rate), + all_time_low_date: self.market.all_time_low_date, + all_time_low_change_percentage: atl_percentage, + all_time_high_value: self.market.all_time_high.zip(self.market.all_time_high_date).map(|(value, date)| ChartValuePercentage { + date, + value: (value * rate) as f32, + percentage: ath_percentage.unwrap_or_default() as f32, + }), + all_time_low_value: self.market.all_time_low.zip(self.market.all_time_low_date).map(|(value, date)| ChartValuePercentage { + date, + value: (value * rate) as f32, + percentage: atl_percentage.unwrap_or_default() as f32, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Chain; + use chrono::Utc; + + #[test] + fn test_all_time_change_percentage() { + let info = AssetPriceInfo { + asset_id: AssetId::from(Chain::Bitcoin, None), + price: Price::new(80.0, 5.0, Utc::now(), crate::PriceProvider::Coingecko), + market: AssetMarket { + market_cap: None, + market_cap_fdv: None, + market_cap_rank: None, + total_volume: None, + circulating_supply: None, + total_supply: None, + max_supply: None, + all_time_high: Some(100.0), + all_time_high_date: None, + all_time_high_change_percentage: None, + all_time_low: Some(40.0), + all_time_low_date: None, + all_time_low_change_percentage: None, + all_time_high_value: None, + all_time_low_value: None, + }, + }; + + let market = info.as_market(); + + assert_eq!(market.all_time_high_change_percentage, Some(-20.0)); + assert_eq!(market.all_time_low_change_percentage, Some(100.0)); + } +} diff --git a/core/crates/primitives/src/asset_score.rs b/core/crates/primitives/src/asset_score.rs new file mode 100644 index 0000000000..8f57eac181 --- /dev/null +++ b/core/crates/primitives/src/asset_score.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; +use strum::EnumIter; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +pub struct AssetScore { + pub rank: i32, + #[typeshare(skip)] + #[serde(rename = "type")] + pub rank_type: AssetRank, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum AssetScoreType { + Verified, + Unverified, + Suspicious, +} + +impl AssetScore { + pub fn new(rank: i32) -> Self { + let rank_type = AssetRank::from_rank(rank); + Self { rank, rank_type } + } + + pub fn rank_type(&self) -> AssetRank { + AssetRank::from_rank(self.rank) + } +} + +impl Default for AssetScore { + fn default() -> Self { + Self::new(15) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, EnumIter)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum AssetRank { + High, + Medium, + Low, + Trivial, + Inactive, + Abandoned, + Suspended, + Migrated, + Deprecated, + Spam, + Fraudulent, + Unknown, +} + +impl AssetRank { + pub fn threshold(&self) -> i32 { + match self { + AssetRank::High => 100, + AssetRank::Medium => 50, + AssetRank::Low => 25, + AssetRank::Trivial => 15, + AssetRank::Unknown => 0, + AssetRank::Inactive => -2, + AssetRank::Abandoned => -5, + AssetRank::Suspended => -8, + AssetRank::Migrated => -10, + AssetRank::Deprecated => -12, + AssetRank::Spam => -15, + AssetRank::Fraudulent => -20, + } + } + + pub fn from_rank(rank: i32) -> Self { + use strum::IntoEnumIterator; + AssetRank::iter().find(|variant| rank >= variant.threshold()).unwrap_or(AssetRank::Unknown) + } +} diff --git a/core/crates/primitives/src/asset_type.rs b/core/crates/primitives/src/asset_type.rs new file mode 100644 index 0000000000..35b0937099 --- /dev/null +++ b/core/crates/primitives/src/asset_type.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, EnumString, EnumIter, PartialEq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +pub enum AssetType { + NATIVE, + ERC20, // EVM + BEP20, // BNB + SPL, // Solana + SPL2022, // Solana Token 2022 + TRC20, // Tron + TOKEN, // Sui, Aptos + IBC, // COSMOS + JETTON, // Ton + SYNTH, // Thorchain + ASA, // Algorand + PERPETUAL, + SPOT, +} + +impl AssetType { + pub fn all() -> Vec { + Self::iter().collect::>() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "UPPERCASE")] +pub enum AssetSubtype { + NATIVE, + TOKEN, +} diff --git a/core/crates/primitives/src/auth.rs b/core/crates/primitives/src/auth.rs new file mode 100644 index 0000000000..d41aa90398 --- /dev/null +++ b/core/crates/primitives/src/auth.rs @@ -0,0 +1,39 @@ +use crate::Chain; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct AuthNonce { + pub nonce: String, + pub timestamp: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthMessage { + pub chain: Chain, + pub address: String, + pub auth_nonce: AuthNonce, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct AuthPayload { + pub device_id: String, + pub chain: Chain, + pub address: String, + pub nonce: String, + pub signature: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[typeshare(swiftGenericConstraints = "T: Sendable")] +#[serde(rename_all = "camelCase")] +pub struct AuthenticatedRequest { + pub auth: AuthPayload, + pub data: T, +} diff --git a/core/crates/primitives/src/auth_status.rs b/core/crates/primitives/src/auth_status.rs new file mode 100644 index 0000000000..305fe9cc10 --- /dev/null +++ b/core/crates/primitives/src/auth_status.rs @@ -0,0 +1,8 @@ +use strum::AsRefStr; + +#[derive(AsRefStr)] +#[strum(serialize_all = "lowercase")] +pub enum AuthStatus { + Valid, + Invalid, +} diff --git a/core/crates/primitives/src/balance_type.rs b/core/crates/primitives/src/balance_type.rs new file mode 100644 index 0000000000..e453947662 --- /dev/null +++ b/core/crates/primitives/src/balance_type.rs @@ -0,0 +1,12 @@ +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +pub enum BalanceType { + available, + locked, + frozen, + staked, + pending, + pendingUnconfirmed, + rewards, + reserved, + earn, +} diff --git a/core/crates/primitives/src/banner.rs b/core/crates/primitives/src/banner.rs new file mode 100644 index 0000000000..a87453d214 --- /dev/null +++ b/core/crates/primitives/src/banner.rs @@ -0,0 +1,30 @@ +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct Banner { + wallet: Option, + asset: Option, + chain: Option, + event: BannerEvent, + state: BannerState, +} + +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum BannerEvent { + Stake, + AccountActivation, + EnableNotifications, + AccountBlockedMultiSignature, + ActivateAsset, + SuspiciousAsset, + Onboarding, + TradePerpetuals, +} + +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum BannerState { + Active, + Cancelled, + AlwaysActive, +} diff --git a/core/crates/primitives/src/block_explorer.rs b/core/crates/primitives/src/block_explorer.rs new file mode 100644 index 0000000000..10dcc47b35 --- /dev/null +++ b/core/crates/primitives/src/block_explorer.rs @@ -0,0 +1,149 @@ +use crate::chain::Chain; +use crate::chain_evm::EVMChain; +use crate::explorers::{ + AlgorandAllo, AlgorandPera, BlockScout, BlockVision, Blocksec, Cardanocan, EtherScan, Explorer, FlowScan, HyperliquidExplorer, HypurrScan, MantleExplorer, Metadata, + NearBlocks, OkxExplorer, RouteScan, RuneScan, SubScan, TonScan, TronScan, Viewblock, XrpScan, ZkSync, aptos, blockchair, mempool, mintscan, solana, stellar_expert, sui, + threexpl, ton, +}; +use std::str::FromStr; +use typeshare::typeshare; + +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[derive(Debug, Default, Clone)] +pub struct ExplorerInput { + pub hash: String, + pub recipient: Option, + pub memo: Option, +} + +impl ExplorerInput { + pub fn new_recipient(recipient: impl Into) -> Self { + Self { + hash: String::new(), + recipient: Some(recipient.into()), + memo: None, + } + } + + pub fn new_memo(recipient: impl Into, memo: impl Into) -> Self { + Self { + hash: String::new(), + recipient: Some(recipient.into()), + memo: Some(memo.into()), + } + } +} + +impl> From for ExplorerInput { + fn from(hash: T) -> Self { + Self { + hash: hash.into(), + recipient: None, + memo: None, + } + } +} + +pub trait BlockExplorer: Send + Sync { + fn name(&self) -> String; + fn get_tx_url(&self, hash: &str) -> String; + fn get_address_url(&self, address: &str) -> String; + fn get_token_url(&self, _token: &str) -> Option { + None + } + fn get_nft_url(&self, _contract: &str, _token_id: &str) -> Option { + None + } + fn get_validator_url(&self, _validator: &str) -> Option { + None + } + + fn get_swap_tx_url(&self, input: &ExplorerInput) -> String { + self.get_tx_url(&input.hash) + } + + fn new() -> Box + where + Self: Default + Sized, + { + Box::new(Self::default()) + } +} + +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[allow(dead_code)] +struct BlockExplorerLink { + name: String, + link: String, +} + +pub fn get_block_explorers_by_chain(chain: &str) -> Vec> { + let Ok(chain) = Chain::from_str(chain) else { + return vec![]; + }; + get_block_explorers(chain) +} + +pub fn get_block_explorer(chain: Chain, name: &str) -> Box { + get_block_explorers(chain).into_iter().find(|x| x.name() == name).unwrap() +} + +pub fn get_block_explorers(chain: Chain) -> Vec> { + match chain { + Chain::Bitcoin => vec![blockchair::new_bitcoin(), mempool::new(), threexpl::new_bitcoin()], + Chain::BitcoinCash => vec![blockchair::new_bitcoin_cash(), threexpl::new_bitcoin_cash()], + Chain::Litecoin => vec![blockchair::new_litecoin(), threexpl::new_litecoin()], + Chain::Doge => vec![blockchair::new_doge(), threexpl::new_doge()], + Chain::Zcash => vec![blockchair::new_zcash(), threexpl::new_zcash()], + + Chain::Ethereum => vec![EtherScan::boxed(EVMChain::Ethereum), blockchair::new_ethereum(), Blocksec::new_ethereum()], + Chain::SmartChain => vec![EtherScan::boxed(EVMChain::SmartChain), blockchair::new_bnb(), Blocksec::new_bsc()], + Chain::Polygon => vec![EtherScan::boxed(EVMChain::Polygon), blockchair::new_polygon(), Blocksec::new_polygon()], + Chain::Arbitrum => vec![EtherScan::boxed(EVMChain::Arbitrum), blockchair::new_arbitrum(), Blocksec::new_arbitrum()], + Chain::Optimism => vec![EtherScan::boxed(EVMChain::Optimism), blockchair::new_optimism(), Blocksec::new_optimism()], + Chain::Base => vec![EtherScan::boxed(EVMChain::Base), blockchair::new_base(), Blocksec::new_base()], + Chain::AvalancheC => vec![EtherScan::boxed(EVMChain::AvalancheC), RouteScan::new_avax(), blockchair::new_avalanche()], + Chain::OpBNB => vec![EtherScan::boxed(EVMChain::OpBNB), blockchair::new_opbnb()], + Chain::Fantom => vec![EtherScan::boxed(EVMChain::Fantom), blockchair::new_fantom()], + Chain::Gnosis => vec![EtherScan::boxed(EVMChain::Gnosis), blockchair::new_gnosis()], + Chain::Manta => vec![BlockScout::new_manta(), EtherScan::boxed(EVMChain::Manta)], + Chain::Blast => vec![EtherScan::boxed(EVMChain::Blast)], + Chain::Linea => vec![EtherScan::boxed(EVMChain::Linea), blockchair::new_linea()], + Chain::Celo => vec![BlockScout::new_celo(), EtherScan::boxed(EVMChain::Celo)], + Chain::ZkSync => vec![ZkSync::boxed(), EtherScan::boxed(EVMChain::ZkSync)], + Chain::World => vec![EtherScan::boxed(EVMChain::World)], + Chain::Plasma => vec![EtherScan::boxed(EVMChain::Plasma)], + Chain::Solana => vec![solana::new_solscan(), solana::new_solana_fm(), blockchair::new_solana()], + Chain::Thorchain => vec![RuneScan::boxed(), Viewblock::boxed()], + + Chain::Cosmos => vec![mintscan::new_cosmos()], + Chain::Osmosis => vec![mintscan::new_osmosis()], + Chain::Celestia => vec![mintscan::new_celestia()], + Chain::Injective => vec![mintscan::new_injective()], + Chain::Sei => vec![mintscan::new_sei()], + Chain::SeiEvm => vec![Explorer::boxed(Metadata::with_token("Seiscan", "https://seiscan.io"))], + Chain::Noble => vec![mintscan::new_noble()], + Chain::Mantle => vec![MantleExplorer::boxed(), EtherScan::boxed(EVMChain::Mantle)], + + Chain::Ton => vec![ton::new_ton_viewer(), TonScan::boxed(), blockchair::new_ton()], + Chain::Tron => vec![TronScan::boxed(), blockchair::new_tron()], + Chain::Xrp => vec![XrpScan::boxed(), blockchair::new_xrp()], + Chain::Aptos => vec![aptos::new_aptos_scan(), aptos::new_aptos_explorer(), blockchair::new_aptos()], + Chain::Sui => vec![sui::new_sui_scan(), BlockVision::new_sui()], + Chain::Near => vec![NearBlocks::boxed()], + Chain::Stellar => vec![stellar_expert::new(), blockchair::new_stellar()], + Chain::Sonic => vec![EtherScan::boxed(EVMChain::Sonic), RouteScan::new_sonic()], + Chain::Algorand => vec![AlgorandAllo::boxed(), AlgorandPera::boxed()], + Chain::Polkadot => vec![SubScan::new_polkadot(), blockchair::new_polkadot()], + Chain::Cardano => vec![Cardanocan::boxed()], + Chain::Abstract => vec![EtherScan::boxed(EVMChain::Abstract)], + Chain::Berachain => vec![EtherScan::boxed(EVMChain::Berachain)], + Chain::Ink => vec![RouteScan::new_ink(), BlockScout::new_ink(), OkxExplorer::new_ink()], + Chain::Unichain => vec![EtherScan::boxed(EVMChain::Unichain)], + Chain::Hyperliquid => vec![EtherScan::boxed(EVMChain::Hyperliquid), BlockScout::new_hyperliquid()], + Chain::HyperCore => vec![HyperliquidExplorer::boxed(), HypurrScan::boxed(), FlowScan::boxed()], + Chain::Monad => vec![EtherScan::boxed(EVMChain::Monad), BlockVision::new_monad()], + Chain::XLayer => vec![OkxExplorer::new_xlayer()], + Chain::Stable => vec![EtherScan::boxed(EVMChain::Stable)], + } +} diff --git a/core/crates/primitives/src/broadcast_options.rs b/core/crates/primitives/src/broadcast_options.rs new file mode 100644 index 0000000000..52442891e4 --- /dev/null +++ b/core/crates/primitives/src/broadcast_options.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct BroadcastOptions { + pub skip_preflight: bool, +} + +impl BroadcastOptions { + pub fn new(skip_preflight: bool) -> Self { + Self { skip_preflight } + } +} diff --git a/core/crates/primitives/src/chain.rs b/core/crates/primitives/src/chain.rs new file mode 100644 index 0000000000..97201f8318 --- /dev/null +++ b/core/crates/primitives/src/chain.rs @@ -0,0 +1,162 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +use crate::chain_config::{ChainConfig, get_chain_config}; +use crate::{AssetId, AssetType, ChainType}; + +#[derive(Copy, Clone, Serialize, Deserialize, EnumIter, AsRefStr, EnumString, PartialEq, Ord, PartialOrd, Eq, Hash)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable, Hashable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Chain { + Bitcoin, + BitcoinCash, + Litecoin, + Ethereum, + SmartChain, + Solana, + Polygon, + Thorchain, + Cosmos, + Osmosis, + Arbitrum, + Ton, + Tron, + Doge, + Zcash, + Optimism, + Aptos, + Base, + AvalancheC, + Sui, + Xrp, + OpBNB, + Fantom, + Gnosis, + Celestia, + Injective, + Sei, + SeiEvm, + Manta, + Blast, + Noble, + ZkSync, + Linea, + Mantle, + Celo, + Near, + World, + Stellar, + Sonic, + Algorand, + Polkadot, + Plasma, + Cardano, + Abstract, + Berachain, + Ink, + Unichain, + Hyperliquid, // HyperEVM + HyperCore, // HyperCore native chain + Monad, + XLayer, + Stable, +} + +impl fmt::Debug for Chain { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.as_ref()) + } +} + +impl fmt::Display for Chain { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.as_ref()) + } +} + +impl Chain { + pub fn config(&self) -> &'static ChainConfig { + get_chain_config(*self) + } + + pub fn as_denom(&self) -> Option<&str> { + self.config().denom.as_deref() + } + + pub fn as_asset_id(&self) -> AssetId { + AssetId::from_chain(*self) + } + + pub fn network_id(&self) -> &str { + self.config().network_id + } + + pub fn from_chain_id(chain_id: u64) -> Option { + Self::iter().find(|&x| x.network_id() == chain_id.to_string()) + } + + pub fn is_utxo(&self) -> bool { + self.config().is_utxo + } + + pub fn as_slip44(&self) -> i64 { + self.config().slip44 + } + + pub fn chain_type(&self) -> ChainType { + self.config().chain_type.clone() + } + + pub fn default_asset_type(&self) -> Option { + self.config().default_asset_type.clone() + } + + pub fn account_activation_fee(&self) -> Option { + self.config().account_activation_fee + } + + pub fn token_activation_fee(&self) -> Option { + self.config().token_activation_fee + } + + pub fn minimum_account_balance(&self) -> Option { + self.config().minimum_account_balance + } + + pub fn is_swap_supported(&self) -> bool { + self.config().is_swap_supported + } + + pub fn is_stake_supported(&self) -> bool { + self.config().stake.is_some() + } + + pub fn is_nft_supported(&self) -> bool { + self.config().is_nft_supported + } + + // milliseconds + pub fn block_time(&self) -> u32 { + self.config().block_time + } + + pub fn rank(&self) -> i32 { + self.config().rank + } + + pub fn all() -> Vec { + Self::iter().collect::>() + } + + pub fn stakeable() -> Vec { + Self::all().into_iter().filter(|x| x.is_stake_supported()).collect() + } + + pub fn perpetual_chains() -> Vec { + vec![Self::HyperCore] + } +} diff --git a/core/crates/primitives/src/chain_address.rs b/core/crates/primitives/src/chain_address.rs new file mode 100644 index 0000000000..cc0f06ec52 --- /dev/null +++ b/core/crates/primitives/src/chain_address.rs @@ -0,0 +1,33 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::Chain; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +pub struct ChainAddress { + pub chain: Chain, + pub address: String, +} + +impl ChainAddress { + pub fn new(chain: Chain, address: String) -> Self { + Self { chain, address } + } + + pub fn address(&self) -> &str { + &self.address + } + + pub fn chain(&self) -> Chain { + self.chain + } +} + +impl fmt::Display for ChainAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.chain, self.address) + } +} diff --git a/core/crates/primitives/src/chain_bitcoin.rs b/core/crates/primitives/src/chain_bitcoin.rs new file mode 100644 index 0000000000..3fb5b375aa --- /dev/null +++ b/core/crates/primitives/src/chain_bitcoin.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use strum::{AsRefStr, EnumIter, EnumString}; +use typeshare::typeshare; + +use crate::Chain; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum BitcoinChain { + Bitcoin, + BitcoinCash, + Litecoin, + Doge, + Zcash, +} + +impl BitcoinChain { + pub fn from_chain(chain: Chain) -> Option { + BitcoinChain::from_str(chain.as_ref()).ok() + } + pub fn get_chain(&self) -> Chain { + match self { + BitcoinChain::Bitcoin => Chain::Bitcoin, + BitcoinChain::BitcoinCash => Chain::BitcoinCash, + BitcoinChain::Litecoin => Chain::Litecoin, + BitcoinChain::Doge => Chain::Doge, + BitcoinChain::Zcash => Chain::Zcash, + } + } + + pub fn minimum_byte_fee(&self) -> i32 { + match self { + BitcoinChain::Bitcoin => 1, + BitcoinChain::BitcoinCash => 5, + BitcoinChain::Litecoin => 5, + BitcoinChain::Doge => 1000, + BitcoinChain::Zcash => 1, + } + } + + pub fn get_blocks_fee_priority(&self) -> BlocksFeePriority { + match self { + BitcoinChain::Bitcoin => BlocksFeePriority { slow: 6, normal: 3, fast: 1 }, + BitcoinChain::BitcoinCash => BlocksFeePriority { slow: 6, normal: 3, fast: 1 }, + BitcoinChain::Litecoin => BlocksFeePriority { slow: 6, normal: 3, fast: 1 }, + BitcoinChain::Doge => BlocksFeePriority { slow: 8, normal: 4, fast: 2 }, + BitcoinChain::Zcash => BlocksFeePriority { slow: 6, normal: 3, fast: 1 }, + } + } +} + +#[derive(Copy, Clone, Debug)] +pub struct BlocksFeePriority { + pub normal: i32, + pub slow: i32, + pub fast: i32, +} diff --git a/core/crates/primitives/src/chain_config.rs b/core/crates/primitives/src/chain_config.rs new file mode 100644 index 0000000000..d40383acd1 --- /dev/null +++ b/core/crates/primitives/src/chain_config.rs @@ -0,0 +1,1256 @@ +use crate::chain_cosmos::CosmosChain; +use crate::{AssetType, Chain, ChainType, asset_constants::*}; +use std::sync::LazyLock; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ChainStack { + Native, + Optimism, + ZkSync, +} + +#[derive(Debug, Clone)] +pub struct EvmChainConfig { + pub min_priority_fee: u64, + pub chain_stack: ChainStack, + pub is_ethereum_layer2: bool, + pub weth_contract: Option<&'static str>, +} + +#[derive(Debug, Clone)] +pub struct StakeChainConfig { + pub lock_time: u64, + pub min_stake_amount: u64, + pub change_amount_on_unstake: bool, + pub can_redelegate: bool, + pub can_withdraw: bool, + pub can_claim_rewards: bool, + pub can_claim_all_rewards: bool, + pub reserved_for_fees: u64, +} + +#[derive(Debug, Clone)] +pub struct ChainConfig { + pub chain: Chain, + pub network_id: &'static str, + pub denom: Option, + pub slip44: i64, + pub chain_type: ChainType, + pub default_asset_type: Option, + pub account_activation_fee: Option, + pub token_activation_fee: Option, + pub minimum_account_balance: Option, + pub block_time: u32, + pub rank: i32, + pub is_swap_supported: bool, + pub is_nft_supported: bool, + pub is_utxo: bool, + pub evm: Option, + pub stake: Option, +} + +// Centralized chain configurations. Add new chains here. +static CHAIN_CONFIGS: LazyLock> = LazyLock::new(|| { + vec![ + ChainConfig { + chain: Chain::Bitcoin, + network_id: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + denom: None, + slip44: 0, + chain_type: ChainType::Bitcoin, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 600_000, + rank: 100, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: true, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::BitcoinCash, + network_id: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + denom: None, + slip44: 145, + chain_type: ChainType::Bitcoin, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 600_000, + rank: 40, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: true, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Litecoin, + network_id: "12a765e31ffd4059bada1e25190f6e98c99d9714d334efa41a195a7e7e04bfe2", + denom: None, + slip44: 2, + chain_type: ChainType::Bitcoin, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 120_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: true, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Ethereum, + network_id: "1", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 12_000, + rank: 85, + is_swap_supported: true, + is_nft_supported: true, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 100_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some(ETHEREUM_WETH_TOKEN_ID), + }), + stake: Some(StakeChainConfig { + lock_time: 259200, + min_stake_amount: 100_000_000_000_000_000, + change_amount_on_unstake: true, + can_redelegate: false, + can_withdraw: true, + can_claim_rewards: false, + can_claim_all_rewards: false, + reserved_for_fees: 5_000_000_000_000_000, + }), + }, + ChainConfig { + chain: Chain::SmartChain, + network_id: "56", + denom: None, + slip44: 9006, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::BEP20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 80, + is_swap_supported: true, + is_nft_supported: true, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 50_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"), + }), + stake: Some(StakeChainConfig { + lock_time: 604_800, + min_stake_amount: 1_000_000_000_000_000_000, + change_amount_on_unstake: true, + can_redelegate: true, + can_withdraw: true, + can_claim_rewards: false, + can_claim_all_rewards: false, + reserved_for_fees: 250_000_000_000_000, + }), + }, + ChainConfig { + chain: Chain::Solana, + network_id: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", + denom: None, + slip44: 501, + chain_type: ChainType::Solana, + default_asset_type: Some(AssetType::SPL), + account_activation_fee: None, + token_activation_fee: Some(2_039_280), + minimum_account_balance: Some(890_880), + block_time: 500, + rank: 80, + is_swap_supported: true, + is_nft_supported: true, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 259_200, + min_stake_amount: 10_000_000, + change_amount_on_unstake: false, + can_redelegate: false, + can_withdraw: true, + can_claim_rewards: false, + can_claim_all_rewards: false, + reserved_for_fees: 5_000_000, + }), + }, + ChainConfig { + chain: Chain::Polygon, + network_id: "137", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 3_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: true, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 30_000_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Thorchain, + network_id: "thorchain-1", + denom: Some(CosmosChain::Thorchain.denom().as_ref().into()), + slip44: 931, + chain_type: ChainType::Cosmos, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Cosmos, + network_id: "cosmoshub-4", + denom: Some(CosmosChain::Cosmos.denom().as_ref().into()), + slip44: 118, + chain_type: ChainType::Cosmos, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 6_000, + rank: 40, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 1_814_400, + min_stake_amount: 0, + change_amount_on_unstake: true, + can_redelegate: true, + can_withdraw: false, + can_claim_rewards: true, + can_claim_all_rewards: true, + reserved_for_fees: 25_000, + }), + }, + ChainConfig { + chain: Chain::Osmosis, + network_id: "osmosis-1", + denom: Some(CosmosChain::Osmosis.denom().as_ref().into()), + slip44: 118, + chain_type: ChainType::Cosmos, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 6_000, + rank: 50, + is_swap_supported: false, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 1_209_600, + min_stake_amount: 0, + change_amount_on_unstake: true, + can_redelegate: true, + can_withdraw: false, + can_claim_rewards: true, + can_claim_all_rewards: true, + reserved_for_fees: 10_000, + }), + }, + ChainConfig { + chain: Chain::Arbitrum, + network_id: "42161", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 10_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: true, + weth_contract: Some(ARBITRUM_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Ton, + network_id: "F6OpKZKqvqeFp6CQmFomXNMfMj2EnaUSOXN+Mh+wVWk=", + denom: None, + slip44: 607, + chain_type: ChainType::Ton, + default_asset_type: Some(AssetType::JETTON), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 5_000, + rank: 50, + is_swap_supported: true, + is_nft_supported: true, + is_utxo: false, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Tron, + network_id: "0x2b6653dc", + denom: None, + slip44: 195, + chain_type: ChainType::Tron, + default_asset_type: Some(AssetType::TRC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 3_000, + rank: 70, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 1_209_600, + min_stake_amount: 1_000_000, + change_amount_on_unstake: true, + can_redelegate: true, + can_withdraw: true, + can_claim_rewards: true, + can_claim_all_rewards: true, + reserved_for_fees: 10_000_000, + }), + }, + ChainConfig { + chain: Chain::Doge, + network_id: "1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691", + denom: None, + slip44: 3, + chain_type: ChainType::Bitcoin, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 60_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: true, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Zcash, + network_id: "00040fe8ec8471911baa1db1266ea15dd06b4a8a5c453883c000b031973dce08", + denom: None, + slip44: 133, + chain_type: ChainType::Bitcoin, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 75_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: true, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Optimism, + network_id: "10", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000, + chain_stack: ChainStack::Optimism, + is_ethereum_layer2: true, + weth_contract: Some(OPTIMISM_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Aptos, + network_id: "1", + denom: Some("0x1::aptos_coin::AptosCoin".into()), + slip44: 637, + chain_type: ChainType::Aptos, + default_asset_type: Some(AssetType::TOKEN), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 500, + rank: 40, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 2_592_000, + min_stake_amount: 1_100_000_000, + change_amount_on_unstake: false, + can_redelegate: false, + can_withdraw: true, + can_claim_rewards: false, + can_claim_all_rewards: false, + reserved_for_fees: 1_000_000, + }), + }, + ChainConfig { + chain: Chain::Base, + network_id: "8453", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 5_000_000, + chain_stack: ChainStack::Optimism, + is_ethereum_layer2: true, + weth_contract: Some(BASE_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::AvalancheC, + network_id: "43114", + denom: None, + slip44: 9005, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 25_000_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Sui, + network_id: "35834a8a", + denom: Some("0x2::sui::SUI".into()), + slip44: 784, + chain_type: ChainType::Sui, + default_asset_type: Some(AssetType::TOKEN), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 500, + rank: 40, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 86_400, + min_stake_amount: 1_000_000_000, + change_amount_on_unstake: false, + can_redelegate: false, + can_withdraw: false, + can_claim_rewards: false, + can_claim_all_rewards: false, + reserved_for_fees: 100_000_000, + }), + }, + ChainConfig { + chain: Chain::Xrp, + network_id: "", + denom: None, + slip44: 144, + chain_type: ChainType::Xrp, + default_asset_type: None, + account_activation_fee: Some(1_000_000), + token_activation_fee: Some(200_000), + minimum_account_balance: None, + block_time: 4_000, + rank: 40, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::OpBNB, + network_id: "204", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::BEP20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000, + chain_stack: ChainStack::Optimism, + is_ethereum_layer2: false, + weth_contract: Some(OPBNB_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Fantom, + network_id: "250", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 3_500_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Gnosis, + network_id: "100", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 5_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 3_000_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Celestia, + network_id: "celestia", + denom: Some(CosmosChain::Celestia.denom().as_ref().into()), + slip44: 118, + chain_type: ChainType::Cosmos, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 6_000, + rank: 40, + is_swap_supported: false, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 1_814_400, + min_stake_amount: 0, + change_amount_on_unstake: true, + can_redelegate: true, + can_withdraw: false, + can_claim_rewards: true, + can_claim_all_rewards: true, + reserved_for_fees: 100_000, + }), + }, + ChainConfig { + chain: Chain::Injective, + network_id: "injective-1", + denom: Some(CosmosChain::Injective.denom().as_ref().into()), + slip44: 60, + chain_type: ChainType::Cosmos, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 6_000, + rank: 40, + is_swap_supported: false, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 1_814_400, + min_stake_amount: 0, + change_amount_on_unstake: true, + can_redelegate: true, + can_withdraw: false, + can_claim_rewards: true, + can_claim_all_rewards: true, + reserved_for_fees: 10_000_000_000_000_000, + }), + }, + ChainConfig { + chain: Chain::Sei, + network_id: "pacific-1", + denom: Some(CosmosChain::Sei.denom().as_ref().into()), + slip44: 118, + chain_type: ChainType::Cosmos, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: false, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 1_814_400, + min_stake_amount: 0, + change_amount_on_unstake: true, + can_redelegate: true, + can_withdraw: false, + can_claim_rewards: true, + can_claim_all_rewards: true, + reserved_for_fees: 100_000, + }), + }, + ChainConfig { + chain: Chain::SeiEvm, + network_id: "1329", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 400, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0xE30feDd158A2e3b13e9badaeABaFc5516e95e8C7"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Manta, + network_id: "169", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 10_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x0dc808adce2099a9f62aa87d9670745aba741746"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Blast, + network_id: "81457", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 200_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: true, + weth_contract: Some(BLAST_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Noble, + network_id: "noble-1", + denom: Some(CosmosChain::Noble.denom().as_ref().into()), + slip44: 118, + chain_type: ChainType::Cosmos, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 6_000, + rank: 20, + is_swap_supported: false, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::ZkSync, + network_id: "324", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 20_000_000, + chain_stack: ChainStack::ZkSync, + is_ethereum_layer2: true, + weth_contract: Some(ZKSYNC_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Linea, + network_id: "59144", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 50_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: true, + weth_contract: Some(LINEA_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Mantle, + network_id: "5000", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 10_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: true, + weth_contract: Some("0x78c1b0C915c4FAA5FffA6CAbf0219DA63d7f4cb8"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Celo, + network_id: "42220", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 10_000_000, + chain_stack: ChainStack::Optimism, + is_ethereum_layer2: true, + weth_contract: Some(CELO_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Near, + network_id: "mainnet", + denom: None, + slip44: 397, + chain_type: ChainType::Near, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::World, + network_id: "480", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000, + chain_stack: ChainStack::Optimism, + is_ethereum_layer2: true, + weth_contract: Some(WORLD_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Stellar, + network_id: "Public Global Stellar Network ; September 2015", + denom: None, + slip44: 148, + chain_type: ChainType::Stellar, + default_asset_type: None, + account_activation_fee: Some(10_000_000), + token_activation_fee: None, + minimum_account_balance: None, + block_time: 6_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Sonic, + network_id: "146", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 500, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 10_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x039e2fB66102314Ce7b64Ce5Ce3E5183bc94aD38"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Algorand, + network_id: "mainnet-v1.0", + denom: None, + slip44: 283, + chain_type: ChainType::Algorand, + default_asset_type: Some(AssetType::ASA), + account_activation_fee: Some(100_000), + token_activation_fee: None, + minimum_account_balance: None, + block_time: 4_000, + rank: 30, + is_swap_supported: false, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Polkadot, + network_id: "Polkadot Asset Hub", + denom: None, + slip44: 354, + chain_type: ChainType::Polkadot, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: Some(10_000_000_000), + block_time: 5_000, + rank: 40, + is_swap_supported: false, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Plasma, + network_id: "9745", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 100_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x6100E367285b01F48D07953803A2d8dCA5D19873"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Cardano, + network_id: "764824073", + denom: None, + slip44: 1_815, + chain_type: ChainType::Cardano, + default_asset_type: None, + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 20_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: true, + evm: None, + stake: None, + }, + ChainConfig { + chain: Chain::Abstract, + network_id: "2741", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 35, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000, + chain_stack: ChainStack::ZkSync, + is_ethereum_layer2: true, + weth_contract: Some("0x3439153EB7AF838Ad19d56E1571FBD09333C2809"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Berachain, + network_id: "80094", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 35, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x6969696969696969696969696969696969696969"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Ink, + network_id: "57073", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 35, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000, + chain_stack: ChainStack::Optimism, + is_ethereum_layer2: true, + weth_contract: Some(INK_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Unichain, + network_id: "130", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 35, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000, + chain_stack: ChainStack::Optimism, + is_ethereum_layer2: true, + weth_contract: Some(UNICHAIN_WETH_TOKEN_ID), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Hyperliquid, + network_id: "999", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 40, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x5555555555555555555555555555555555555555"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::HyperCore, + network_id: "1337", + denom: None, + slip44: 60, + chain_type: ChainType::HyperCore, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 40, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: None, + stake: Some(StakeChainConfig { + lock_time: 604_800, + min_stake_amount: 1_000_000, + change_amount_on_unstake: true, + can_redelegate: false, + can_withdraw: false, + can_claim_rewards: false, + can_claim_all_rewards: false, + reserved_for_fees: 0, + }), + }, + ChainConfig { + chain: Chain::Monad, + network_id: "143", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 500, + rank: 40, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A"), + }), + stake: Some(StakeChainConfig { + lock_time: 86_400, + min_stake_amount: 100_000_000_000_000_000, + change_amount_on_unstake: true, + can_redelegate: false, + can_withdraw: true, + can_claim_rewards: true, + can_claim_all_rewards: false, + reserved_for_fees: 50_000_000_000_000_000, + }), + }, + ChainConfig { + chain: Chain::XLayer, + network_id: "196", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 2_000, + rank: 30, + is_swap_supported: true, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: true, + weth_contract: Some("0xe538905cf8410324e03a5a23c1c177a474d59b2b"), + }), + stake: None, + }, + ChainConfig { + chain: Chain::Stable, + network_id: "988", + denom: None, + slip44: 60, + chain_type: ChainType::Ethereum, + default_asset_type: Some(AssetType::ERC20), + account_activation_fee: None, + token_activation_fee: None, + minimum_account_balance: None, + block_time: 1_000, + rank: 30, + is_swap_supported: false, + is_nft_supported: false, + is_utxo: false, + evm: Some(EvmChainConfig { + min_priority_fee: 1_000_000, + chain_stack: ChainStack::Native, + is_ethereum_layer2: false, + weth_contract: Some("0x779Ded0c9e1022225f8E0630b35a9b54bE713736"), + }), + stake: None, + }, + ] +}); + +pub fn get_chain_config(chain: Chain) -> &'static ChainConfig { + CHAIN_CONFIGS + .iter() + .find(|config| config.chain == chain) + .unwrap_or_else(|| panic!("Missing chain config for {chain}")) +} diff --git a/core/crates/primitives/src/chain_cosmos.rs b/core/crates/primitives/src/chain_cosmos.rs new file mode 100644 index 0000000000..3bec3be249 --- /dev/null +++ b/core/crates/primitives/src/chain_cosmos.rs @@ -0,0 +1,73 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +use crate::Chain; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumString, EnumIter, AsRefStr, PartialEq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum CosmosChain { + Cosmos, + Osmosis, + Celestia, + Thorchain, + Injective, + Sei, + Noble, +} + +impl CosmosChain { + pub fn all() -> impl Iterator { + Self::iter() + } + + pub fn from_chain(chain: Chain) -> Option { + CosmosChain::from_str(chain.as_ref()).ok() + } + + pub fn as_chain(&self) -> Chain { + Chain::from_str(self.as_ref()).unwrap() + } + + pub fn hrp(&self) -> &str { + match self { + Self::Cosmos => "cosmos", + Self::Osmosis => "osmo", + Self::Celestia => "celestia", + Self::Thorchain => "thor", + Self::Injective => "inj", + Self::Sei => "sei", + Self::Noble => "noble", + } + } + + pub fn denom(&self) -> CosmosDenom { + match self { + Self::Cosmos => CosmosDenom::Uatom, + Self::Osmosis => CosmosDenom::Uosmo, + Self::Celestia => CosmosDenom::Utia, + Self::Thorchain => CosmosDenom::Rune, + Self::Injective => CosmosDenom::Inj, + Self::Sei => CosmosDenom::Usei, + Self::Noble => CosmosDenom::Uusdc, + } + } +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum CosmosDenom { + Rune, + Uatom, + Uosmo, + Utia, + Inj, + Usei, + Uusdc, +} diff --git a/core/crates/primitives/src/chain_evm.rs b/core/crates/primitives/src/chain_evm.rs new file mode 100644 index 0000000000..06383dd4e7 --- /dev/null +++ b/core/crates/primitives/src/chain_evm.rs @@ -0,0 +1,113 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +use crate::Chain; +use crate::chain_config::EvmChainConfig; + +pub use crate::chain_config::ChainStack; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Hashable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum EVMChain { + Ethereum, + SmartChain, + Polygon, + Plasma, + Arbitrum, + Optimism, + Base, + AvalancheC, + OpBNB, + Fantom, + Gnosis, + Manta, + Blast, + ZkSync, + Linea, + Mantle, + Celo, + World, + Sonic, + SeiEvm, + Abstract, + Berachain, + Ink, + Unichain, + Hyperliquid, + Monad, + XLayer, + Stable, +} + +impl EVMChain { + fn config(&self) -> &'static EvmChainConfig { + let chain = self.to_chain(); + let config = chain.config(); + config.evm.as_ref().unwrap_or_else(|| panic!("Missing EVM config for {chain}")) + } + + pub fn all() -> Vec { + Self::iter().collect::>() + } + + pub fn min_priority_fee(&self) -> u64 { + self.config().min_priority_fee + } + + pub fn chain_id(&self) -> u64 { + self.to_chain().network_id().parse().unwrap_or_else(|_| panic!("Invalid network id for {}", self.as_ref())) + } + + pub fn chain_stack(&self) -> ChainStack { + self.config().chain_stack + } + + pub fn is_ethereum_layer2(&self) -> bool { + self.config().is_ethereum_layer2 + } + + // https://docs.optimism.io/stack/getting-started + pub fn is_opstack(&self) -> bool { + self.chain_stack() == ChainStack::Optimism + } + + // https://docs.zksync.io/zk-stack/running/quickstart + pub fn is_zkstack(&self) -> bool { + self.chain_stack() == ChainStack::ZkSync + } + + pub fn weth_contract(&self) -> Option<&str> { + self.config().weth_contract + } + + pub fn from_chain(chain: Chain) -> Option { + EVMChain::from_str(chain.as_ref()).ok() + } + + pub fn to_chain(&self) -> Chain { + Chain::from_str(self.as_ref()).unwrap() + } +} + +#[cfg(test)] +mod tests { + use crate::{Chain, EVMChain}; + + #[test] + fn test_from_chain() { + assert_eq!(EVMChain::from_chain(Chain::Ethereum), Some(EVMChain::Ethereum)); + assert_eq!(EVMChain::from_chain(Chain::SeiEvm), Some(EVMChain::SeiEvm)); + assert_eq!(EVMChain::from_chain(Chain::Bitcoin), None); + } + + #[test] + fn test_sei_evm_chain_id() { + assert_eq!(EVMChain::SeiEvm.chain_id(), 1329); + assert_eq!(Chain::from_chain_id(1329), Some(Chain::SeiEvm)); + } +} diff --git a/core/crates/primitives/src/chain_nft.rs b/core/crates/primitives/src/chain_nft.rs new file mode 100644 index 0000000000..5fd90a0a9b --- /dev/null +++ b/core/crates/primitives/src/chain_nft.rs @@ -0,0 +1,32 @@ +use crate::Chain; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum NFTChain { + Ethereum, + Polygon, + Solana, + SmartChain, + Ton, +} + +impl NFTChain { + pub fn all() -> Vec { + NFTChain::iter().collect() + } +} + +impl From for Chain { + fn from(chain: NFTChain) -> Self { + match chain { + NFTChain::Ethereum => Chain::Ethereum, + NFTChain::Polygon => Chain::Polygon, + NFTChain::Solana => Chain::Solana, + NFTChain::SmartChain => Chain::SmartChain, + NFTChain::Ton => Chain::Ton, + } + } +} diff --git a/core/crates/primitives/src/chain_request.rs b/core/crates/primitives/src/chain_request.rs new file mode 100644 index 0000000000..fd145b00bc --- /dev/null +++ b/core/crates/primitives/src/chain_request.rs @@ -0,0 +1,41 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainRequestProtocol { + JsonRpc, + Http, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainRequestType { + Unknown, + Broadcast, +} + +#[derive(Debug, Clone, Copy)] +pub struct ChainRequest<'a> { + pub protocol: ChainRequestProtocol, + pub method: &'a str, + pub path: &'a str, + pub body: &'a [u8], +} + +impl<'a> ChainRequest<'a> { + pub fn new(protocol: ChainRequestProtocol, method: &'a str, path: &'a str, body: &'a [u8]) -> Self { + Self { protocol, method, path, body } + } + + pub fn is_json_rpc_method(&self, method: &str) -> bool { + self.protocol == ChainRequestProtocol::JsonRpc && self.method == method + } + + pub fn is_http_path(&self, method: &str, path: &str) -> bool { + self.protocol == ChainRequestProtocol::Http && self.method == method && self.path == path + } + + pub fn is_http_post_path(&self, path: &str) -> bool { + self.is_http_path("POST", path) + } + + pub fn body_utf8(&self) -> Option<&'a str> { + std::str::from_utf8(self.body).ok() + } +} diff --git a/core/crates/primitives/src/chain_signer.rs b/core/crates/primitives/src/chain_signer.rs new file mode 100644 index 0000000000..caeb0d211f --- /dev/null +++ b/core/crates/primitives/src/chain_signer.rs @@ -0,0 +1,51 @@ +use crate::{SignerError, SignerInput}; + +pub trait ChainSigner: Send + Sync { + fn sign_transfer(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("sign_transfer not implemented".to_string())) + } + + fn sign_token_transfer(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("sign_token_transfer not implemented".to_string())) + } + + fn sign_nft_transfer(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("sign_nft_transfer not implemented".to_string())) + } + + fn sign_swap(&self, _input: &SignerInput, _private_key: &[u8]) -> Result, SignerError> { + Err(SignerError::SigningError("sign_swap not implemented".to_string())) + } + + fn sign_token_approval(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("sign_token_approval not implemented".to_string())) + } + + fn sign_stake(&self, _input: &SignerInput, _private_key: &[u8]) -> Result, SignerError> { + Err(SignerError::SigningError("sign_stake not implemented".to_string())) + } + + fn sign_message(&self, _message: &[u8], _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("sign_message not implemented".to_string())) + } + + fn sign_account_action(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("sign_account_action not implemented".to_string())) + } + + fn sign_perpetual(&self, _input: &SignerInput, _private_key: &[u8]) -> Result, SignerError> { + Err(SignerError::SigningError("sign_perpetual not implemented".to_string())) + } + + fn sign_withdrawal(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("sign_withdrawal not implemented".to_string())) + } + + fn sign_data(&self, _input: &SignerInput, _private_key: &[u8]) -> Result { + Err(SignerError::SigningError("sign_data not implemented".to_string())) + } + + fn sign_earn(&self, _input: &SignerInput, _private_key: &[u8]) -> Result, SignerError> { + Err(SignerError::SigningError("sign_earn not implemented".to_string())) + } +} diff --git a/core/crates/primitives/src/chain_stake.rs b/core/crates/primitives/src/chain_stake.rs new file mode 100644 index 0000000000..99f3894950 --- /dev/null +++ b/core/crates/primitives/src/chain_stake.rs @@ -0,0 +1,76 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString}; +use typeshare::typeshare; + +use crate::Chain; +use crate::chain_config::StakeChainConfig; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum StakeChain { + Cosmos, + Osmosis, + Injective, + Sei, + Celestia, + Ethereum, + Solana, + Sui, + SmartChain, + Monad, + Tron, + Aptos, + HyperCore, +} + +impl StakeChain { + fn config(&self) -> &'static StakeChainConfig { + let chain = self.chain(); + let config = chain.config(); + config.stake.as_ref().unwrap_or_else(|| panic!("Missing stake config for {chain}")) + } + + pub fn chain(&self) -> Chain { + Chain::from_str(self.as_ref()).unwrap() + } + + /// Get the lock time in seconds + pub fn get_lock_time(&self) -> u64 { + self.config().lock_time + } + + /// Get the minimum stake amount + pub fn get_min_stake_amount(&self) -> u64 { + self.config().min_stake_amount + } + + /// Get if chain support ability to change amount on unstake + pub fn get_change_amount_on_unstake(&self) -> bool { + self.config().change_amount_on_unstake + } + + /// Get if chain support redelegate + pub fn get_can_redelegate(&self) -> bool { + self.config().can_redelegate + } + + pub fn get_can_withdraw(&self) -> bool { + self.config().can_withdraw + } + + pub fn get_can_claim_rewards(&self) -> bool { + self.config().can_claim_rewards + } + + pub fn get_can_claim_all_rewards(&self) -> bool { + self.config().can_claim_all_rewards + } + + pub fn get_reserved_for_fees(&self) -> u64 { + self.config().reserved_for_fees + } +} diff --git a/core/crates/primitives/src/chain_transaction_timeout.rs b/core/crates/primitives/src/chain_transaction_timeout.rs new file mode 100644 index 0000000000..0dc050ce2c --- /dev/null +++ b/core/crates/primitives/src/chain_transaction_timeout.rs @@ -0,0 +1,58 @@ +use crate::{Chain, ChainType, DAY}; + +pub fn chain_transaction_timeout(chain: Chain) -> u32 { + match chain.chain_type() { + ChainType::Bitcoin => 1_209_600_000, + ChainType::Solana => chain.block_time() * 150, + ChainType::Ethereum => chain.block_time() * 120, + ChainType::Cosmos + | ChainType::Ton + | ChainType::Tron + | ChainType::Aptos + | ChainType::Sui + | ChainType::Xrp + | ChainType::Near + | ChainType::Stellar + | ChainType::Algorand + | ChainType::Polkadot + | ChainType::Cardano + | ChainType::HyperCore => chain.block_time() * 600, + } +} + +pub fn swap_transaction_timeout(source_chain: Chain, destination_chain: Chain) -> u64 { + let source_timeout = u64::from(chain_transaction_timeout(source_chain)); + if source_chain == destination_chain { + return source_timeout; + } + + let destination_timeout = u64::from(chain_transaction_timeout(destination_chain)); + ((source_timeout + destination_timeout) * 3).max(DAY.as_millis() as u64) +} + +#[cfg(test)] +mod tests { + use super::{chain_transaction_timeout, swap_transaction_timeout}; + use crate::{Chain, DAY}; + + #[test] + fn test_chain_transaction_timeout() { + assert_eq!(chain_transaction_timeout(Chain::Bitcoin), 1_209_600_000); + assert_eq!(chain_transaction_timeout(Chain::Solana), Chain::Solana.block_time() * 150); + assert_eq!(chain_transaction_timeout(Chain::Ethereum), Chain::Ethereum.block_time() * 120); + assert_eq!(chain_transaction_timeout(Chain::Cosmos), Chain::Cosmos.block_time() * 600); + } + + #[test] + fn test_swap_transaction_timeout() { + assert_eq!( + swap_transaction_timeout(Chain::Ethereum, Chain::Ethereum), + u64::from(chain_transaction_timeout(Chain::Ethereum)) + ); + assert_eq!(swap_transaction_timeout(Chain::Ethereum, Chain::Solana), DAY.as_millis() as u64); + assert_eq!( + swap_transaction_timeout(Chain::Bitcoin, Chain::Ethereum), + (u64::from(chain_transaction_timeout(Chain::Bitcoin)) + u64::from(chain_transaction_timeout(Chain::Ethereum))) * 3 + ); + } +} diff --git a/core/crates/primitives/src/chain_type.rs b/core/crates/primitives/src/chain_type.rs new file mode 100644 index 0000000000..cea3efa0a5 --- /dev/null +++ b/core/crates/primitives/src/chain_type.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, CaseIterable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ChainType { + Ethereum, + Bitcoin, + Solana, + Cosmos, + Ton, + Tron, + Aptos, + Sui, + Xrp, + Near, + Stellar, + Algorand, + Polkadot, + Cardano, + HyperCore, +} diff --git a/core/crates/primitives/src/chart.rs b/core/crates/primitives/src/chart.rs new file mode 100644 index 0000000000..cf0114a24a --- /dev/null +++ b/core/crates/primitives/src/chart.rs @@ -0,0 +1,51 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ChartCandleStick { + pub date: DateTime, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ChartCandleUpdate { + pub coin: String, + pub interval: String, + pub candle: ChartCandleStick, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct ChartDateValue { + pub date: DateTime, + pub value: f64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum ChartLineType { + TakeProfit, + StopLoss, + Entry, + Liquidation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ChartLine { + #[serde(rename = "type")] + pub line_type: ChartLineType, + pub price: f64, +} diff --git a/core/crates/primitives/src/config.rs b/core/crates/primitives/src/config.rs new file mode 100644 index 0000000000..02bdd4f10f --- /dev/null +++ b/core/crates/primitives/src/config.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::PlatformStore; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct ConfigResponse { + pub releases: Vec, + pub versions: ConfigVersions, + pub swap: SwapConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct SwapConfig { + pub enabled_providers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct Release { + pub version: String, + pub store: PlatformStore, + pub upgrade_required: bool, +} + +impl Release { + pub fn new(store: PlatformStore, version: String, upgrade_required: bool) -> Self { + Self { version, store, upgrade_required } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct ConfigVersions { + pub fiat_on_ramp_assets: i32, + pub fiat_off_ramp_assets: i32, + pub swap_assets: i32, +} diff --git a/core/crates/primitives/src/config_key.rs b/core/crates/primitives/src/config_key.rs new file mode 100644 index 0000000000..ce9ebee834 --- /dev/null +++ b/core/crates/primitives/src/config_key.rs @@ -0,0 +1,402 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; + +#[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, EnumString, EnumIter, PartialEq, Eq, Hash)] +#[strum(serialize_all = "camelCase")] +pub enum ConfigKey { + // Referral + ReferralPerDeviceDaily, + ReferralPerIpDaily, + ReferralPerIpWeekly, + ReferralPerCountryDaily, + ReferralPerUserDaily, + ReferralPerUserWeekly, + ReferralPerUserHourly, + ReferralVerifiedMultiplier, + ReferralTrustedMultiplier, + ReferralCooldown, + ReferralUseDailyLimit, + ReferralIneligibleCountries, + ReferralVerificationDelay, + ReferralEligibility, + + // Username + UsernameCreationPerIp, + UsernameCreationPerDevice, + UsernameCreationGlobalDailyLimit, + UsernameCreationPerCountryDailyLimit, + + // Redemption + RedemptionPerUserDaily, + RedemptionPerUserWeekly, + RedemptionMinAccountAge, + RedemptionCooldownAfterReferral, + RedemptionRetryMaxRetries, + RedemptionRetryDelay, + RedemptionRetryErrors, + + // Referral IP + ReferralIpConfidenceScoreThreshold, + ReferralBlockedIpTypes, + ReferralBlockedIpTypePenalty, + ReferralMaxAbuseScore, + ReferralPenaltyIsps, + ReferralPenaltyIspsScore, + ReferralIpTorAllowed, + + // Referral Risk Scoring (global cross-referrer penalties) + ReferralRiskScoreFingerprintMatchPerReferrer, + ReferralRiskScoreFingerprintMatchMaxPenalty, + ReferralRiskScoreIpReuse, + ReferralRiskScoreIspModelMatch, + ReferralRiskScoreDeviceIdReusePerReferrer, + ReferralRiskScoreDeviceIdReuseMaxPenalty, + ReferralRiskScoreIneligibleIpType, + ReferralRiskScoreVerifiedUserReduction, + ReferralRiskScoreEarlyReferralReductionInitial, + ReferralRiskScoreEarlyReferralReductionStep, + ReferralRiskScoreMaxAllowed, + ReferralRiskScoreLookback, + ReferralRiskScoreSameReferrerPatternThreshold, + ReferralRiskScoreSameReferrerPatternPenalty, + ReferralRiskScoreSameReferrerFingerprintThreshold, + ReferralRiskScoreSameReferrerFingerprintPenalty, + ReferralRiskScoreSameReferrerDeviceModelThreshold, + ReferralRiskScoreSameReferrerDeviceModelPenalty, + ReferralRiskScoreDeviceModelRingThreshold, + ReferralRiskScoreDeviceModelRingPenaltyPerMember, + ReferralRiskScoreHighRiskPlatformStores, + ReferralRiskScoreHighRiskPlatformStorePenalty, + ReferralRiskScoreHighRiskCountries, + ReferralRiskScoreHighRiskCountryPenalty, + ReferralRiskScoreHighRiskLocales, + ReferralRiskScoreHighRiskLocalePenalty, + ReferralRiskScoreHighRiskDeviceModels, + ReferralRiskScoreHighRiskDeviceModelPenalty, + ReferralRiskScoreHighRiskUserAgents, + ReferralRiskScoreHighRiskUserAgentPenalty, + ReferralRiskScoreIpHistoryPenaltyPerAbuser, + ReferralRiskScoreIpHistoryMaxPenalty, + ReferralRiskScoreCrossReferrerDevicePenalty, + ReferralRiskScoreCrossReferrerFingerprintThreshold, + ReferralRiskScoreCrossReferrerFingerprintPenalty, + ReferralRiskScoreCountryDiversityThreshold, + ReferralRiskScoreCountryDiversityPenaltyPerCountry, + ReferralRiskScoreDeviceFarmingThreshold, + ReferralRiskScoreDeviceFarmingPenaltyPerDevice, + + // Referral Abuse Detection + ReferralAbuseDisableThreshold, + ReferralAbuseAttemptPenalty, + ReferralAbuseVerifiedThresholdMultiplier, + ReferralAbuseLookback, + ReferralAbuseMinReferralsToEvaluate, + + ReferralAbuseCountryRotationThreshold, + ReferralAbuseCountryRotationPenalty, + ReferralAbuseRingReferrersPerDeviceThreshold, + ReferralAbuseRingReferrersPerFingerprintThreshold, + ReferralAbuseRingPenalty, + ReferralAbuseDeviceFarmingThreshold, + ReferralAbuseDeviceFarmingPenalty, + ReferralAbuseVelocityWindow, + ReferralAbuseVelocityDivisor, + ReferralAbuseVelocityPenaltyPerSignal, + ReferralAbuseDisabledReferrerPenalty, + + // Fiat + FiatValidateSubscription, + + // Transactions + TransactionsMinAmountUsd, + TransactionsOutdatedBlockCount, + TransactionsOutdatedMinTimeout, + + // Alerter + AlerterPriceAlertsTimer, + AlerterPriceAlertsCooldown, + AlerterPriceAlertsThreshold, + AlerterPriceAlertsRankDivisor, + AlerterPriceAlertsMilestones, + AlerterStakeRewardsTimer, + AlerterStakeRewardsThreshold, + AlerterStakeRewardsLookback, + + // Price + PriceTimerTopMarketCap, + PriceTimerHighMarketCap, + PriceTimerLowMarketCap, + PriceTimerFiatRates, + PriceTimerChartsHourly, + PriceTimerChartsDaily, + PriceTimerMarkets, + PriceTimerPrices, + PriceTimerMetrics, + PriceTimerAssets, + PriceTimerAssetsNew, + PriceTimerAssetsMetadata, + PriceTimerChartsHistory, + PriceTimerCleanOutdated, + PriceTimerCleanupChartsRaw, + PriceTimerCleanupChartsHourly, + PriceChartsRetentionRaw, + PriceChartsRetentionHourly, + PriceChartsRetentionDaily, + PriceOutdated, + PricePrimaryMaxAge, + PriceMissingPublishInterval, + + // Assets + AssetsTimerUpdateSuspicious, + AssetsTimerUpdateStakeApy, + AssetsTimerUpdatePerpetuals, + AssetsTimerUpdateUsageRank, + AssetsTimerUpdateImages, + AssetsTimerUpdateHasPrice, + + // Fiat + FiatTimerUpdateAssets, + FiatTimerUpdateProviderCountries, + FiatTimerUpdateBuyableAssets, + FiatTimerUpdateSellableAssets, + FiatTimerUpdateTrending, + + // Scan + ScanTimerUpdateValidators, + ScanTimerUpdateValidatorsStatic, + + // Rewards + RewardsTimerAbuseChecker, + RewardsTimerEligibilityChecker, + RewardsEligibilityActiveDuration, + RewardsEligibilityTransactionsCount, + RewardsEligibilityPromotionLimit, + + // Device + DeviceTimerUpdater, + DeviceTimerInactiveObserver, + + // Version + VersionTimerUpdateStoreVersions, + + // Transaction + TransactionTimerCleanup, + TransactionTimerInTransitUpdate, + TransactionTimerPendingUpdate, + TransactionTimerSwapVaultAddresses, + TransactionInTransitTimeout, + TransactionInTransitQueryLimit, + TransactionSwapOutdatedTimeout, + TransactionCleanupAddressMaxCount, + TransactionCleanupAddressLimit, + TransactionCleanupLookback, + + // Perpetuals + PerpetualClassifierInterval, + PerpetualObserverInterval, + PerpetualAddressRefreshInterval, + PerpetualPriorityObserverInterval, + PerpetualPriorityTriggerBps, + PerpetualPriorityLiquidationBps, + + // Search + SearchAssetsUpdateInterval, + SearchPerpetualsUpdateInterval, + SearchNftsUpdateInterval, + SearchAssetsLastUpdatedAt, + SearchPerpetualsLastUpdatedAt, + SearchNftsLastUpdatedAt, + + // Parser + ParserCatchupReloadInterval, + ParserMinCheckInterval, + ParserMaxCheckInterval, + ParserErrorInterval, + + // Price Observed (WebSocket) + PriceObservedFetchInterval, + PriceObservedMaxAssets, + PriceObservedMinObservers, +} + +impl ConfigKey { + pub fn all() -> Vec { + Self::iter().collect() + } + + pub fn default_value(&self) -> &'static str { + match self { + Self::ReferralPerDeviceDaily => "1", + Self::ReferralPerIpDaily => "3", + Self::ReferralPerIpWeekly => "10", + Self::ReferralPerCountryDaily => "100", + Self::ReferralPerUserDaily => "5", + Self::ReferralPerUserWeekly => "15", + Self::ReferralPerUserHourly => "2", + Self::ReferralVerifiedMultiplier => "2", + Self::ReferralTrustedMultiplier => "3", + Self::ReferralCooldown => "1m", + Self::ReferralUseDailyLimit => "1000", + Self::ReferralIneligibleCountries => "[]", + Self::ReferralVerificationDelay => "24h", + Self::ReferralEligibility => "7d", + Self::UsernameCreationPerIp => "10", + Self::UsernameCreationPerDevice => "1", + Self::UsernameCreationGlobalDailyLimit => "1000", + Self::UsernameCreationPerCountryDailyLimit => "100", + Self::RedemptionPerUserDaily => "1", + Self::RedemptionPerUserWeekly => "3", + Self::RedemptionMinAccountAge => "1h", + Self::RedemptionCooldownAfterReferral => "1m", + Self::RedemptionRetryMaxRetries => "1", + Self::RedemptionRetryDelay => "15s", + Self::RedemptionRetryErrors => r#"["transaction gas price below minimum"]"#, + Self::ReferralIpConfidenceScoreThreshold => "10", + Self::ReferralBlockedIpTypes => r#"["dataCenter", "hosting"]"#, + Self::ReferralBlockedIpTypePenalty => "100", + Self::ReferralMaxAbuseScore => "60", + Self::ReferralPenaltyIsps => r#"[]"#, + Self::ReferralPenaltyIspsScore => "30", + Self::ReferralIpTorAllowed => "false", + Self::ReferralRiskScoreFingerprintMatchPerReferrer => "50", + Self::ReferralRiskScoreFingerprintMatchMaxPenalty => "200", + Self::ReferralRiskScoreIpReuse => "50", + Self::ReferralRiskScoreIspModelMatch => "30", + Self::ReferralRiskScoreDeviceIdReusePerReferrer => "50", + Self::ReferralRiskScoreDeviceIdReuseMaxPenalty => "200", + Self::ReferralRiskScoreIneligibleIpType => "100", + Self::ReferralRiskScoreVerifiedUserReduction => "20", + Self::ReferralRiskScoreEarlyReferralReductionInitial => "20", + Self::ReferralRiskScoreEarlyReferralReductionStep => "10", + Self::ReferralRiskScoreMaxAllowed => "60", + Self::ReferralRiskScoreLookback => "90d", + Self::ReferralRiskScoreSameReferrerPatternThreshold => "3", + Self::ReferralRiskScoreSameReferrerPatternPenalty => "40", + Self::ReferralRiskScoreSameReferrerFingerprintThreshold => "2", + Self::ReferralRiskScoreSameReferrerFingerprintPenalty => "60", + Self::ReferralRiskScoreSameReferrerDeviceModelThreshold => "3", + Self::ReferralRiskScoreSameReferrerDeviceModelPenalty => "50", + Self::ReferralRiskScoreDeviceModelRingThreshold => "2", + Self::ReferralRiskScoreDeviceModelRingPenaltyPerMember => "40", + Self::ReferralRiskScoreHighRiskPlatformStores => "[]", + Self::ReferralRiskScoreHighRiskPlatformStorePenalty => "20", + Self::ReferralRiskScoreHighRiskCountries => "[]", + Self::ReferralRiskScoreHighRiskCountryPenalty => "15", + Self::ReferralRiskScoreHighRiskLocales => "[]", + Self::ReferralRiskScoreHighRiskLocalePenalty => "10", + Self::ReferralRiskScoreHighRiskDeviceModels => r#"["sdk_gphone", "(?i)emulator", "(?i)simulator"]"#, + Self::ReferralRiskScoreHighRiskDeviceModelPenalty => "50", + Self::ReferralRiskScoreHighRiskUserAgents => r#"["(?i)python", "(?i)curl", "(?i)httpie", "(?i)postman", "(?i)insomnia"]"#, + Self::ReferralRiskScoreHighRiskUserAgentPenalty => "100", + Self::ReferralRiskScoreIpHistoryPenaltyPerAbuser => "30", + Self::ReferralRiskScoreIpHistoryMaxPenalty => "150", + Self::ReferralRiskScoreCrossReferrerDevicePenalty => "500", + Self::ReferralRiskScoreCrossReferrerFingerprintThreshold => "2", + Self::ReferralRiskScoreCrossReferrerFingerprintPenalty => "100", + Self::ReferralRiskScoreCountryDiversityThreshold => "5", + Self::ReferralRiskScoreCountryDiversityPenaltyPerCountry => "5", + Self::ReferralRiskScoreDeviceFarmingThreshold => "10", + Self::ReferralRiskScoreDeviceFarmingPenaltyPerDevice => "3", + Self::ReferralAbuseDisableThreshold => "200", + Self::ReferralAbuseAttemptPenalty => "15", + Self::ReferralAbuseVerifiedThresholdMultiplier => "2", + Self::ReferralAbuseLookback => "7d", + Self::ReferralAbuseMinReferralsToEvaluate => "2", + Self::ReferralAbuseCountryRotationThreshold => "2", + Self::ReferralAbuseCountryRotationPenalty => "50", + Self::ReferralAbuseRingReferrersPerDeviceThreshold => "2", + Self::ReferralAbuseRingReferrersPerFingerprintThreshold => "2", + Self::ReferralAbuseRingPenalty => "80", + Self::ReferralAbuseDeviceFarmingThreshold => "5", + Self::ReferralAbuseDeviceFarmingPenalty => "10", + Self::ReferralAbuseVelocityWindow => "5m", + Self::ReferralAbuseVelocityDivisor => "2", + Self::ReferralAbuseVelocityPenaltyPerSignal => "100", + Self::ReferralAbuseDisabledReferrerPenalty => "80", + Self::FiatValidateSubscription => "false", + Self::TransactionsMinAmountUsd => "0.05", + Self::TransactionsOutdatedBlockCount => "12", + Self::TransactionsOutdatedMinTimeout => "15m", + Self::AlerterPriceAlertsTimer => "60s", + Self::AlerterPriceAlertsCooldown => "24h", + Self::AlerterPriceAlertsThreshold => "5.0", + Self::AlerterPriceAlertsRankDivisor => "5.0", + Self::AlerterPriceAlertsMilestones => "[1, 5, 10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000, 500000, 1000000]", + Self::PriceTimerTopMarketCap => "60s", + Self::PriceTimerHighMarketCap => "5m", + Self::PriceTimerLowMarketCap => "15m", + Self::PriceTimerFiatRates => "6m", + Self::PriceTimerChartsHourly => "60s", + Self::PriceTimerChartsDaily => "6m", + Self::PriceTimerMarkets => "1h", + Self::PriceTimerPrices => "60s", + Self::PriceTimerMetrics => "5m", + Self::PriceTimerAssets => "1d", + Self::PriceTimerAssetsNew => "15m", + Self::PriceTimerAssetsMetadata => "30d", + Self::PriceTimerChartsHistory => "1d", + Self::PriceTimerCleanOutdated => "1d", + Self::PriceTimerCleanupChartsRaw => "1d", + Self::PriceTimerCleanupChartsHourly => "1d", + Self::PriceChartsRetentionRaw => "7d", + Self::PriceChartsRetentionHourly => "31d", + Self::PriceChartsRetentionDaily => "10000d", + Self::PriceOutdated => "7d", + Self::PricePrimaryMaxAge => "24h", + Self::PriceMissingPublishInterval => "1h", + Self::AssetsTimerUpdateSuspicious => "1h", + Self::AssetsTimerUpdateStakeApy => "1d", + Self::AssetsTimerUpdatePerpetuals => "1h", + Self::AssetsTimerUpdateUsageRank => "1h", + Self::AssetsTimerUpdateImages => "8h", + Self::AssetsTimerUpdateHasPrice => "1h", + Self::FiatTimerUpdateAssets => "1h", + Self::FiatTimerUpdateProviderCountries => "1h", + Self::FiatTimerUpdateBuyableAssets => "1h", + Self::FiatTimerUpdateSellableAssets => "1h", + Self::FiatTimerUpdateTrending => "1h", + Self::ScanTimerUpdateValidators => "1d", + Self::ScanTimerUpdateValidatorsStatic => "1h", + Self::RewardsTimerAbuseChecker => "60s", + Self::RewardsTimerEligibilityChecker => "60s", + Self::RewardsEligibilityActiveDuration => "7d", + Self::RewardsEligibilityTransactionsCount => "3", + Self::RewardsEligibilityPromotionLimit => "1", + Self::AlerterStakeRewardsTimer => "6h", + Self::AlerterStakeRewardsThreshold => "0.01", + Self::AlerterStakeRewardsLookback => "30d", + Self::DeviceTimerUpdater => "1d", + Self::DeviceTimerInactiveObserver => "1d", + Self::VersionTimerUpdateStoreVersions => "1h", + Self::TransactionTimerCleanup => "1d", + Self::TransactionTimerInTransitUpdate => "60s", + Self::TransactionTimerPendingUpdate => "30s", + Self::TransactionTimerSwapVaultAddresses => "5m", + Self::TransactionInTransitTimeout => "12h", + Self::TransactionInTransitQueryLimit => "100", + Self::TransactionSwapOutdatedTimeout => "2h", + Self::TransactionCleanupAddressMaxCount => "5000", + Self::TransactionCleanupAddressLimit => "200", + Self::TransactionCleanupLookback => "90d", + Self::PerpetualClassifierInterval => "15m", + Self::PerpetualObserverInterval => "1m", + Self::PerpetualAddressRefreshInterval => "1h", + Self::PerpetualPriorityObserverInterval => "10s", + Self::PerpetualPriorityTriggerBps => "100", + Self::PerpetualPriorityLiquidationBps => "300", + Self::SearchAssetsUpdateInterval => "30m", + Self::SearchPerpetualsUpdateInterval => "30m", + Self::SearchNftsUpdateInterval => "30m", + Self::SearchAssetsLastUpdatedAt => "0", + Self::SearchPerpetualsLastUpdatedAt => "0", + Self::SearchNftsLastUpdatedAt => "0", + Self::ParserCatchupReloadInterval => "50", + Self::ParserMinCheckInterval => "1s", + Self::ParserMaxCheckInterval => "8s", + Self::ParserErrorInterval => "30s", + Self::PriceObservedFetchInterval => "30s", + Self::PriceObservedMaxAssets => "100", + Self::PriceObservedMinObservers => "2", + } + } +} diff --git a/core/crates/primitives/src/config_param_key.rs b/core/crates/primitives/src/config_param_key.rs new file mode 100644 index 0000000000..a543c95c0f --- /dev/null +++ b/core/crates/primitives/src/config_param_key.rs @@ -0,0 +1,63 @@ +use crate::{PriceProvider, SwapProvider}; +use strum::AsRefStr; + +#[derive(Debug, AsRefStr)] +#[strum(serialize_all = "camelCase")] +pub enum ConfigParamKey { + SwapperVaultAddresses(SwapProvider), + PriceProviderAssetsDuration(PriceProvider), + PriceProviderAssetsNewDuration(PriceProvider), + PriceProviderAssetsMetadataDuration(PriceProvider), + PriceProviderPricesDuration(PriceProvider), + PriceProviderChartsHourlyDuration(PriceProvider), + PriceProviderMetricsDuration(PriceProvider), + PriceProviderCleanOutdatedDuration(PriceProvider), +} + +impl ConfigParamKey { + pub fn all() -> Vec { + let swapper = SwapProvider::cross_chain_providers().into_iter().map(Self::SwapperVaultAddresses); + let assets = PriceProvider::all().into_iter().map(Self::PriceProviderAssetsDuration); + let assets_new = PriceProvider::all().into_iter().map(Self::PriceProviderAssetsNewDuration); + let assets_metadata = PriceProvider::all().into_iter().map(Self::PriceProviderAssetsMetadataDuration); + let prices = PriceProvider::all().into_iter().map(Self::PriceProviderPricesDuration); + let charts_hourly = PriceProvider::all().into_iter().map(Self::PriceProviderChartsHourlyDuration); + let metrics = PriceProvider::all().into_iter().map(Self::PriceProviderMetricsDuration); + let clean_outdated = PriceProvider::all().into_iter().map(Self::PriceProviderCleanOutdatedDuration); + swapper + .chain(assets) + .chain(assets_new) + .chain(assets_metadata) + .chain(prices) + .chain(charts_hourly) + .chain(metrics) + .chain(clean_outdated) + .collect() + } + + pub fn key(&self) -> String { + match self { + Self::SwapperVaultAddresses(provider) => format!("{}.{}", self.as_ref(), provider.as_ref()), + Self::PriceProviderAssetsDuration(provider) => format!("{}.{}", self.as_ref(), provider.as_ref()), + Self::PriceProviderAssetsNewDuration(provider) => format!("{}.{}", self.as_ref(), provider.as_ref()), + Self::PriceProviderAssetsMetadataDuration(provider) => format!("{}.{}", self.as_ref(), provider.as_ref()), + Self::PriceProviderPricesDuration(provider) => format!("{}.{}", self.as_ref(), provider.as_ref()), + Self::PriceProviderChartsHourlyDuration(provider) => format!("{}.{}", self.as_ref(), provider.as_ref()), + Self::PriceProviderMetricsDuration(provider) => format!("{}.{}", self.as_ref(), provider.as_ref()), + Self::PriceProviderCleanOutdatedDuration(provider) => format!("{}.{}", self.as_ref(), provider.as_ref()), + } + } + + pub fn default_value(&self) -> &str { + match self { + Self::SwapperVaultAddresses(_) => "5m", + Self::PriceProviderAssetsDuration(_) => "1d", + Self::PriceProviderAssetsNewDuration(_) => "15m", + Self::PriceProviderAssetsMetadataDuration(_) => "30d", + Self::PriceProviderPricesDuration(_) => "60s", + Self::PriceProviderChartsHourlyDuration(_) => "7d", + Self::PriceProviderMetricsDuration(_) => "5m", + Self::PriceProviderCleanOutdatedDuration(_) => "1d", + } + } +} diff --git a/core/crates/primitives/src/contact.rs b/core/crates/primitives/src/contact.rs new file mode 100644 index 0000000000..399f44ae97 --- /dev/null +++ b/core/crates/primitives/src/contact.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::Chain; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable, Identifiable")] +#[serde(rename_all = "camelCase")] +pub struct Contact { + pub id: String, + pub name: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable, Identifiable")] +#[serde(rename_all = "camelCase")] +pub struct ContactAddress { + pub id: String, + pub contact_id: String, + pub address: String, + pub chain: Chain, + pub memo: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ContactData { + pub contact: Contact, + pub addresses: Vec, +} diff --git a/core/crates/primitives/src/contract_call_data.rs b/core/crates/primitives/src/contract_call_data.rs new file mode 100644 index 0000000000..8da3af6b3b --- /dev/null +++ b/core/crates/primitives/src/contract_call_data.rs @@ -0,0 +1,13 @@ +use crate::swap::ApprovalData; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct ContractCallData { + pub contract_address: String, + pub call_data: String, + pub approval: Option, + pub gas_limit: Option, +} diff --git a/core/crates/primitives/src/contract_constants.rs b/core/crates/primitives/src/contract_constants.rs new file mode 100644 index 0000000000..8d3d12241d --- /dev/null +++ b/core/crates/primitives/src/contract_constants.rs @@ -0,0 +1,66 @@ +pub const EVM_ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; +pub const EVM_ZERO_BLOCK_HASH: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; +pub const UNISWAP_PERMIT2_CONTRACT: &str = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; +pub const ZKSYNC_UNISWAP_PERMIT2_CONTRACT: &str = "0x0000000000225e31d15943971f47ad3022f714fa"; +pub const ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT: &str = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; +pub const BASE_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT: &str = ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT; +pub const OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT: &str = "0xCb1355ff08Ab38bBCE60111F1bb2B784bE25D7e8"; +pub const UNICHAIN_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT: &str = "0xEf740bf23aCaE26f6492B10de645D6B98dC8Eaf3"; +pub const UNICHAIN_UNISWAP_V4_UNIVERSAL_ROUTER_CONTRACT: &str = "0xEf740bf23aCaE26f6492B10de645D6B98dC8Eaf3"; +pub const OPTIMISM_UNISWAP_V4_QUOTER_CONTRACT: &str = "0x1f3131a13296fb91c90870043742c3cdbff1a8d7"; +pub const UNICHAIN_UNISWAP_V4_QUOTER_CONTRACT: &str = "0x333E3C607B141b18fF6de9f258db6e77fE7491E0"; +pub const ETHEREUM_ACROSS_CONFIG_STORE_CONTRACT: &str = "0x3B03509645713718B78951126E0A6de6f10043f5"; +pub const ETHEREUM_ACROSS_HUB_POOL_CONTRACT: &str = "0xc186fA914353c44b2E33eBE05f21846F1048bEda"; +pub const ETHEREUM_ACROSS_MULTICALL_HANDLER_CONTRACT: &str = "0x924a9f036260DdD5808007E1AA95f08eD08aA569"; +pub const ETHEREUM_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5"; +pub const ARBITRUM_ACROSS_SPOKE_POOL_CONTRACT: &str = "0xe35e9842fceaca96570b734083f4a58e8f7c5f2a"; +pub const BASE_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64"; +pub const BLAST_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1"; +pub const LINEA_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x7E63A5f1a8F0B4d0934B2f2327DAED3F6bb2ee75"; +pub const OPTIMISM_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x6f26Bf09B1C792e3228e5467807a900A503c0281"; +pub const POLYGON_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x9295ee1d8C5b022Be115A2AD3c30C72E34e7F096"; +pub const WORLD_ACROSS_SPOKE_POOL_CONTRACT: &str = BASE_ACROSS_SPOKE_POOL_CONTRACT; +pub const ZKSYNC_ACROSS_SPOKE_POOL_CONTRACT: &str = "0xE0B015E54d54fc84a6cB9B666099c46adE9335FF"; +pub const INK_ACROSS_SPOKE_POOL_CONTRACT: &str = "0xeF684C38F94F48775959ECf2012D7E864ffb9dd4"; +pub const UNICHAIN_ACROSS_SPOKE_POOL_CONTRACT: &str = BASE_ACROSS_SPOKE_POOL_CONTRACT; +pub const MONAD_ACROSS_SPOKE_POOL_CONTRACT: &str = "0xd2ecb3afe598b746F8123CaE365a598DA831A449"; +pub const SMARTCHAIN_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x4e8E101924eDE233C13e2D8622DC8aED2872d505"; +pub const HYPEREVM_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x35E63eA3eb0fb7A3bc543C71FB66412e1F6B0E04"; +pub const PLASMA_ACROSS_SPOKE_POOL_CONTRACT: &str = "0x50039fAEfebef707cFD94D6d462fE6D10B39207a"; +pub const LINEA_ACROSS_MULTICALL_HANDLER_CONTRACT: &str = "0x1015c58894961F4F7Dd7D68ba033e28Ed3ee1cDB"; +pub const ZKSYNC_ACROSS_MULTICALL_HANDLER_CONTRACT: &str = "0x863859ef502F0Ee9676626ED5B418037252eFeb2"; +pub const SMARTCHAIN_ACROSS_MULTICALL_HANDLER_CONTRACT: &str = "0xAC537C12fE8f544D712d71ED4376a502EEa944d7"; +pub const MONAD_ACROSS_MULTICALL_HANDLER_CONTRACT: &str = "0xeC41F75c686e376Ab2a4F18bde263ab5822c4511"; +pub const HYPEREVM_ACROSS_MULTICALL_HANDLER_CONTRACT: &str = "0x5E7840E06fAcCb6d1c3b5F5E0d1d3d07F2829bba"; +pub const PLASMA_ACROSS_MULTICALL_HANDLER_CONTRACT: &str = HYPEREVM_ACROSS_MULTICALL_HANDLER_CONTRACT; +pub const ETHEREUM_CHAINLINK_ETH_USD_FEED_CONTRACT: &str = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"; +pub const MONAD_CHAINLINK_USD_FEED_CONTRACT: &str = "0xBcD78f76005B7515837af6b50c7C52BCf73822fb"; +pub const MONAD_STAKING_CONTRACT: &str = "0x0000000000000000000000000000000000001000"; +pub const MONAD_STAKING_LENS_CONTRACT: &str = "0x830295C0ABE7358f7E24bC38408095621474280b"; +pub const OPTIMISM_GAS_PRICE_ORACLE_CONTRACT: &str = "0x420000000000000000000000000000000000000F"; +pub const ETHEREUM_YO_PROTOCOL_CONTRACT: &str = "0xF1EeE0957267b1A474323Ff9CfF7719E964969FA"; +pub const HYPERCORE_SYSTEM_ADDRESS: &str = "0x2222222222222222222222222222222222222222"; + +pub const SOLANA_WRAPPED_SOL_TOKEN_ADDRESS: &str = "So11111111111111111111111111111111111111112"; +pub const SOLANA_METAPLEX_PROGRAM_ID: &str = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"; +pub const SOLANA_METAPLEX_CORE_PROGRAM_ID: &str = "CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d"; +pub const SOLANA_METAPLEX_AUTH_RULES_PROGRAM_ID: &str = "auth9SigNpDKz4sJJ1DfCTuZrZNSAgh9sFD3rboVmgg"; +pub const SOLANA_TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +pub const SOLANA_TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; +pub const SOLANA_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; +pub const SOLANA_SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111"; +pub const SOLANA_COMPUTE_BUDGET_PROGRAM_ID: &str = "ComputeBudget111111111111111111111111111111"; +pub const SOLANA_VOTE_PROGRAM_ID: &str = "Vote111111111111111111111111111111111111111"; +pub const SOLANA_STAKE_PROGRAM_ID: &str = "Stake11111111111111111111111111111111111111"; +pub const SOLANA_SYSVAR_CLOCK_ID: &str = "SysvarC1ock11111111111111111111111111111111"; +pub const SOLANA_SYSVAR_RENT_ID: &str = "SysvarRent111111111111111111111111111111111"; +pub const SOLANA_SYSVAR_INSTRUCTIONS_ID: &str = "Sysvar1nstructions1111111111111111111111111"; +pub const SOLANA_BPF_LOADER_PROGRAM_ID: &str = "BPFLoaderUpgradeab1e11111111111111111111111"; +pub const SOLANA_MEMO_PROGRAM_ID: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; +pub const SOLANA_JITO_TIP_PROGRAM_ID: &str = "9H6tua7jkLhdm3w8BvgpTn5LZNU7g4ZynDmCiNN3q6Rp"; +pub const SOLANA_JUPITER_PROGRAM_ID: &str = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"; +pub const SOLANA_OKX_DEX_V2_PROGRAM_ID: &str = "proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u"; +pub const SOLANA_ALLDOMAINS_ANS_PROGRAM_ID: &str = "ALTNSZ46uaAUU7XUV6awvdorLGqAsPwa9shm7h4uP2FK"; +pub const SOLANA_ALLDOMAINS_TLD_HOUSE_PROGRAM_ID: &str = "TLDHkysf5pCnKsVA4gXpNvmy7psXLPEu4LAdDJthT9S"; +pub const SOLANA_ALLDOMAINS_NAME_HOUSE_PROGRAM_ID: &str = "NH3uX6FtVE2fNREAioP7hm5RaozotZxeL6khU1EHx51"; +pub const SOLANA_ALLDOMAINS_ROOT_PUBLIC_KEY: &str = "3mX9b4AZaQehNoQGfckVcmgmA6bkBoFcbLj9RMmMyNcU"; diff --git a/core/crates/primitives/src/currency.rs b/core/crates/primitives/src/currency.rs new file mode 100644 index 0000000000..56e10232a9 --- /dev/null +++ b/core/crates/primitives/src/currency.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, AsRefStr, EnumString, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[strum(serialize_all = "UPPERCASE")] +pub enum Currency { + MXN, + CHF, + CNY, + THB, + HUF, + AUD, + IDR, + RUB, + ZAR, + EUR, + NZD, + SAR, + SGD, + BMD, + KWD, + HKD, + JPY, + GBP, + DKK, + KRW, + PHP, + CLP, + TWD, + PKR, + BRL, + CAD, + BHD, + MMK, + VEF, + VND, + CZK, + TRY, + INR, + ARS, + BDT, + NOK, + USD, + LKR, + ILS, + PLN, + NGN, + UAH, + XDR, + MYR, + AED, + SEK, +} + +impl std::fmt::Display for Currency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/core/crates/primitives/src/date_ext.rs b/core/crates/primitives/src/date_ext.rs new file mode 100644 index 0000000000..a9cb9836a9 --- /dev/null +++ b/core/crates/primitives/src/date_ext.rs @@ -0,0 +1,91 @@ +use chrono::{Duration, NaiveDateTime, Utc}; +use std::time::Duration as StdDuration; + +pub fn now() -> NaiveDateTime { + Utc::now().naive_utc() +} + +pub trait DurationExt { + fn as_days(&self) -> i64; + fn as_days_ceil(&self) -> i64; +} + +impl DurationExt for StdDuration { + fn as_days(&self) -> i64 { + (self.as_secs() / crate::duration::SECONDS_PER_DAY) as i64 + } + + fn as_days_ceil(&self) -> i64 { + let seconds_days = self.as_secs().div_ceil(crate::duration::SECONDS_PER_DAY); + let extra_day = u64::from(self.subsec_nanos() > 0 && self.as_secs().is_multiple_of(crate::duration::SECONDS_PER_DAY)); + (seconds_days + extra_day) as i64 + } +} + +pub trait NaiveDateTimeExt { + fn is_within_days(&self, days: i64) -> bool; + fn is_older_than_days(&self, days: i64) -> bool; + fn days_ago(&self, days: i64) -> NaiveDateTime; + fn hours_ago(&self, hours: i64) -> NaiveDateTime; + fn ago(&self, duration: StdDuration) -> NaiveDateTime; + fn is_within_duration(&self, duration: StdDuration) -> bool; +} + +impl NaiveDateTimeExt for NaiveDateTime { + fn is_within_days(&self, days: i64) -> bool { + *self > Utc::now().naive_utc() - Duration::days(days) + } + + fn is_within_duration(&self, duration: StdDuration) -> bool { + let chrono_duration = Duration::seconds(duration.as_secs() as i64); + *self > Utc::now().naive_utc() - chrono_duration + } + + fn is_older_than_days(&self, days: i64) -> bool { + !self.is_within_days(days) + } + + fn days_ago(&self, days: i64) -> NaiveDateTime { + *self - Duration::days(days) + } + + fn hours_ago(&self, hours: i64) -> NaiveDateTime { + *self - Duration::hours(hours) + } + + fn ago(&self, duration: StdDuration) -> NaiveDateTime { + let chrono_duration = Duration::seconds(duration.as_secs() as i64); + *self - chrono_duration + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_within_days() { + let now = Utc::now().naive_utc(); + assert!((now - Duration::days(6)).is_within_days(7)); + assert!(!(now - Duration::days(8)).is_within_days(7)); + assert!(!(now - Duration::days(7)).is_within_days(7)); + } + + #[test] + fn test_is_older_than_days() { + let now = Utc::now().naive_utc(); + assert!((now - Duration::days(8)).is_older_than_days(7)); + assert!(!(now - Duration::days(6)).is_older_than_days(7)); + assert!((now - Duration::days(7)).is_older_than_days(7)); + } + + #[test] + fn test_as_days_ceil() { + assert_eq!(StdDuration::from_secs(0).as_days_ceil(), 0); + assert_eq!(StdDuration::from_secs(1).as_days_ceil(), 1); + assert_eq!(StdDuration::from_secs(12 * 60 * 60).as_days_ceil(), 1); + assert_eq!(StdDuration::from_secs(36 * 60 * 60).as_days_ceil(), 2); + assert_eq!(StdDuration::from_secs(7 * 24 * 60 * 60).as_days_ceil(), 7); + assert_eq!(StdDuration::from_secs_f64(7.1 * 24.0 * 60.0 * 60.0).as_days_ceil(), 8); + } +} diff --git a/core/crates/primitives/src/deeplink.rs b/core/crates/primitives/src/deeplink.rs new file mode 100644 index 0000000000..f0365e02b4 --- /dev/null +++ b/core/crates/primitives/src/deeplink.rs @@ -0,0 +1,181 @@ +use url::Url; + +use crate::AssetId; + +const DEEPLINK_HOST: &str = "gemwallet.com"; +const DEEPLINK_WEB_SCHEME: &str = "https"; +const DEEPLINK_GEM_SCHEME: &str = "gem"; + +const PATH_TOKENS: &str = "tokens"; +const PATH_PERPETUALS: &str = "perpetuals"; +const PATH_REWARDS: &str = "rewards"; +const PATH_JOIN: &str = "join"; + +const QUERY_CODE: &str = "code"; + +#[derive(Debug, Clone, PartialEq)] +pub enum Deeplink { + Asset { asset_id: AssetId }, + Perpetuals, + Rewards { code: Option }, +} + +impl Deeplink { + pub fn to_url(&self) -> String { + format!("{DEEPLINK_WEB_SCHEME}://{DEEPLINK_HOST}{}", self.path()) + } + + pub fn to_gem_url(&self) -> String { + format!("{DEEPLINK_GEM_SCHEME}://{}", self.path().trim_start_matches('/')) + } + + pub fn from_url(url: &str) -> Option { + let url = Url::parse(url).ok()?; + let segments = url_segments(&url)?; + let (component, params) = segments.split_first()?; + + let deeplink = match component.as_str() { + PATH_TOKENS => Deeplink::Asset { + asset_id: AssetId::from(params.first()?.parse().ok()?, params.get(1).cloned()), + }, + PATH_PERPETUALS => Deeplink::Perpetuals, + PATH_REWARDS | PATH_JOIN => Deeplink::Rewards { + code: params.first().cloned().or_else(|| query_value(&url, QUERY_CODE)), + }, + _ => return None, + }; + Some(deeplink) + } + + fn path(&self) -> String { + match self { + Deeplink::Asset { asset_id } => match &asset_id.token_id { + Some(token_id) => format!("/{PATH_TOKENS}/{}/{token_id}", asset_id.chain.as_ref()), + None => format!("/{PATH_TOKENS}/{}", asset_id.chain.as_ref()), + }, + Deeplink::Perpetuals => format!("/{PATH_PERPETUALS}"), + Deeplink::Rewards { code } => path_with_query(PATH_REWARDS, QUERY_CODE, code.clone()), + } + } +} + +fn path_with_query(component: &str, query_key: &str, query_value: Option) -> String { + match query_value { + Some(value) => format!("/{component}?{query_key}={value}"), + None => format!("/{component}"), + } +} + +fn url_segments(url: &Url) -> Option> { + let mut segments: Vec = url + .path_segments() + .map(|parts| parts.filter(|part| !part.is_empty()).map(String::from).collect()) + .unwrap_or_default(); + + match url.scheme() { + DEEPLINK_WEB_SCHEME => { + if url.host_str()? != DEEPLINK_HOST { + return None; + } + } + DEEPLINK_GEM_SCHEME => { + if let Some(host) = url.host_str().filter(|host| !host.is_empty()) { + segments.insert(0, host.to_string()); + } + } + _ => return None, + } + Some(segments) +} + +fn query_value(url: &Url, key: &str) -> Option { + url.query_pairs().find(|(query_key, _)| query_key.as_ref() == key).map(|(_, value)| value.into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Chain; + + #[test] + fn test_to_url() { + assert_eq!( + Deeplink::Asset { + asset_id: AssetId::from_chain(Chain::Bitcoin) + } + .to_url(), + "https://gemwallet.com/tokens/bitcoin" + ); + assert_eq!( + Deeplink::Asset { + asset_id: AssetId::token(Chain::Ethereum, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + } + .to_url(), + "https://gemwallet.com/tokens/ethereum/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + ); + assert_eq!(Deeplink::Perpetuals.to_url(), "https://gemwallet.com/perpetuals"); + assert_eq!(Deeplink::Rewards { code: None }.to_url(), "https://gemwallet.com/rewards"); + assert_eq!( + Deeplink::Rewards { + code: Some("gemcoder".to_string()), + } + .to_url(), + "https://gemwallet.com/rewards?code=gemcoder" + ); + } + + #[test] + fn test_to_gem_url() { + assert_eq!(Deeplink::Rewards { code: None }.to_gem_url(), "gem://rewards"); + assert_eq!(Deeplink::Perpetuals.to_gem_url(), "gem://perpetuals"); + assert_eq!( + Deeplink::Asset { + asset_id: AssetId::from_chain(Chain::Bitcoin) + } + .to_gem_url(), + "gem://tokens/bitcoin" + ); + } + + #[test] + fn test_from_url() { + assert_eq!( + Deeplink::from_url("https://gemwallet.com/tokens/bitcoin"), + Some(Deeplink::Asset { + asset_id: AssetId::from_chain(Chain::Bitcoin) + }) + ); + assert_eq!( + Deeplink::from_url("https://gemwallet.com/tokens/ethereum/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + Some(Deeplink::Asset { + asset_id: AssetId::token(Chain::Ethereum, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + }) + ); + assert_eq!( + Deeplink::from_url("gem://tokens/bitcoin"), + Some(Deeplink::Asset { + asset_id: AssetId::from_chain(Chain::Bitcoin) + }) + ); + assert_eq!(Deeplink::from_url("https://gemwallet.com/perpetuals"), Some(Deeplink::Perpetuals)); + assert_eq!(Deeplink::from_url("gem://perpetuals"), Some(Deeplink::Perpetuals)); + assert_eq!( + Deeplink::from_url("https://gemwallet.com/rewards?code=gemcoder"), + Some(Deeplink::Rewards { + code: Some("gemcoder".to_string()), + }) + ); + assert_eq!( + Deeplink::from_url("https://gemwallet.com/join/gemcoder"), + Some(Deeplink::Rewards { + code: Some("gemcoder".to_string()), + }) + ); + assert_eq!(Deeplink::from_url("https://gemwallet.com/join"), Some(Deeplink::Rewards { code: None })); + assert_eq!(Deeplink::from_url("https://gemwallet.com/tokens"), None); + assert_eq!(Deeplink::from_url("https://gemwallet.com/tokens/notachain"), None); + assert_eq!(Deeplink::from_url("https://example.com/tokens/bitcoin"), None); + assert_eq!(Deeplink::from_url("https://gemwallet.com/unknown"), None); + assert_eq!(Deeplink::from_url("not a url"), None); + } +} diff --git a/core/crates/primitives/src/delegation.rs b/core/crates/primitives/src/delegation.rs new file mode 100644 index 0000000000..9f702aefa5 --- /dev/null +++ b/core/crates/primitives/src/delegation.rs @@ -0,0 +1,100 @@ +use chrono::{DateTime, Utc}; +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, Display, EnumString}; +use typeshare::typeshare; + +use crate::stake_provider_type::StakeProviderType; +use crate::{AssetId, Chain, Price, StakeValidator}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct Delegation { + pub base: DelegationBase, + pub validator: DelegationValidator, + pub price: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct DelegationBase { + pub asset_id: AssetId, + pub state: DelegationState, + pub balance: BigUint, + pub shares: BigUint, + pub rewards: BigUint, + pub completion_date: Option>, + pub delegation_id: String, + pub validator_id: String, +} + +impl DelegationBase { + pub fn total_active_balance(delegations: &[Self]) -> BigUint { + delegations + .iter() + .filter(|d| d.state == DelegationState::Active) + .fold(BigUint::from(0u32), |acc, d| acc + &d.balance) + } + + pub fn total_active_rewards(delegations: &[Self]) -> BigUint { + delegations + .iter() + .filter(|d| d.state == DelegationState::Active) + .fold(BigUint::from(0u32), |acc, d| acc + &d.rewards) + } +} + +impl From for StakeValidator { + fn from(value: DelegationValidator) -> Self { + StakeValidator::new(value.id, value.name) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct DelegationValidator { + pub chain: Chain, + pub id: String, + pub name: String, + pub is_active: bool, + pub commission: f64, + pub apr: f64, + pub provider_type: StakeProviderType, +} + +impl DelegationValidator { + pub const SYSTEM_ID: &str = "system"; + pub const SYSTEM_NAME: &str = "Unstaking"; + + pub fn stake(chain: Chain, id: String, name: String, is_active: bool, commission: f64, apr: f64) -> Self { + Self { + chain, + id, + name, + is_active, + commission, + apr, + provider_type: StakeProviderType::Stake, + } + } + + pub fn system(chain: Chain) -> Self { + Self::stake(chain, Self::SYSTEM_ID.to_string(), Self::SYSTEM_NAME.to_string(), true, 0.0, 0.0) + } +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, Display, AsRefStr, EnumString, PartialEq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum DelegationState { + Active, + Pending, + Inactive, + Activating, + Deactivating, + AwaitingWithdrawal, +} diff --git a/core/crates/primitives/src/device.rs b/core/crates/primitives/src/device.rs new file mode 100644 index 0000000000..3046f94ba2 --- /dev/null +++ b/core/crates/primitives/src/device.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{PlatformStore, platform::Platform}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct Device { + pub id: String, + pub platform: Platform, + pub platform_store: PlatformStore, + pub os: String, + pub model: String, + pub token: String, + pub locale: String, + pub version: String, + pub currency: String, + pub is_push_enabled: bool, + pub is_price_alerts_enabled: Option, + pub subscriptions_version: i32, +} + +impl Device { + pub fn can_receive_push_notification(&self) -> bool { + self.is_push_enabled && !self.token.is_empty() + } + + pub fn can_receive_price_alerts(&self) -> bool { + self.can_receive_push_notification() && self.is_price_alerts_enabled == Some(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_receive_push_notification() { + assert!(Device::mock().can_receive_push_notification()); + assert!(!Device::mock_with(false, "token".to_string(), Some(true)).can_receive_push_notification()); + assert!(!Device::mock_with(true, "".to_string(), Some(true)).can_receive_push_notification()); + } + + #[test] + fn can_receive_price_alerts() { + assert!(Device::mock().can_receive_price_alerts()); + assert!(!Device::mock_with(true, "token".to_string(), Some(false)).can_receive_price_alerts()); + assert!(!Device::mock_with(true, "token".to_string(), None).can_receive_price_alerts()); + assert!(!Device::mock_with(false, "token".to_string(), Some(true)).can_receive_price_alerts()); + assert!(!Device::mock_with(true, "".to_string(), Some(true)).can_receive_price_alerts()); + } +} diff --git a/core/crates/primitives/src/device_token.rs b/core/crates/primitives/src/device_token.rs new file mode 100644 index 0000000000..1fe2e28b5d --- /dev/null +++ b/core/crates/primitives/src/device_token.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceToken { + pub token: String, + pub expires_at: u64, +} diff --git a/core/crates/primitives/src/diff.rs b/core/crates/primitives/src/diff.rs new file mode 100644 index 0000000000..3ac0e36e4b --- /dev/null +++ b/core/crates/primitives/src/diff.rs @@ -0,0 +1,38 @@ +use std::collections::HashSet; +use std::hash::Hash; + +pub struct Diff; + +#[derive(Debug, Clone)] +pub struct DiffResult { + pub different: Vec, + pub missing: Vec, +} + +impl Diff { + pub fn compare(source: Vec, target: Vec) -> DiffResult { + let source_set: HashSet = source.iter().cloned().collect(); + let target_set: HashSet = target.iter().cloned().collect(); + + let different: Vec = source_set.difference(&target_set).cloned().collect(); + let missing: Vec = target_set.difference(&source_set).cloned().collect(); + + DiffResult { different, missing } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compare_strings() { + let source = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let target = vec!["b".to_string(), "c".to_string(), "d".to_string()]; + + let result = Diff::compare(source, target); + + assert_eq!(result.different, vec!["a".to_string()]); + assert_eq!(result.missing, vec!["d".to_string()]); + } +} diff --git a/core/crates/primitives/src/duration.rs b/core/crates/primitives/src/duration.rs new file mode 100644 index 0000000000..542020cc40 --- /dev/null +++ b/core/crates/primitives/src/duration.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +pub const SECONDS_PER_MINUTE: u64 = 60; +pub const SECONDS_PER_HOUR: u64 = 60 * SECONDS_PER_MINUTE; +pub const SECONDS_PER_DAY: u64 = 24 * SECONDS_PER_HOUR; + +pub const MINUTE: Duration = Duration::from_secs(SECONDS_PER_MINUTE); +pub const HOUR: Duration = Duration::from_secs(SECONDS_PER_HOUR); +pub const DAY: Duration = Duration::from_secs(SECONDS_PER_DAY); + +pub fn parse_duration(raw: &str) -> Option { + let value = raw.trim(); + if value.is_empty() { + return None; + } + + if let Ok(seconds) = value.parse::() { + return Some(Duration::from_secs_f64(seconds)); + } + + let split_index = value.find(|c: char| !c.is_ascii_digit() && c != '.')?; + let (number, unit) = value.split_at(split_index); + if number.is_empty() || unit.is_empty() || !unit.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + + let amount = number.parse::().ok()?; + match unit { + "ns" => Some(Duration::from_nanos(amount as u64)), + "us" => Some(Duration::from_micros(amount as u64)), + "ms" => Some(Duration::from_millis(amount as u64)), + "s" => Some(Duration::from_secs_f64(amount)), + "m" => Some(Duration::from_secs_f64(amount * 60.0)), + "h" => Some(Duration::from_secs_f64(amount * 3_600.0)), + "d" => Some(Duration::from_secs_f64(amount * 86_400.0)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_seconds() { + assert_eq!(parse_duration("3"), Some(Duration::from_secs(3))); + assert_eq!(parse_duration("1.5"), Some(Duration::from_millis(1500))); + assert_eq!(parse_duration("3s"), Some(Duration::from_secs(3))); + } + + #[test] + fn parse_units() { + assert_eq!(parse_duration("1m"), Some(Duration::from_secs(60))); + assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600))); + assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86400))); + assert_eq!(parse_duration("500ms"), Some(Duration::from_millis(500))); + } + + #[test] + fn parse_invalid() { + assert_eq!(parse_duration(""), None); + assert_eq!(parse_duration("abc"), None); + assert_eq!(parse_duration("1x"), None); + assert_eq!(parse_duration("1s!"), None); + } +} diff --git a/core/crates/primitives/src/earn_type.rs b/core/crates/primitives/src/earn_type.rs new file mode 100644 index 0000000000..999f34a8e1 --- /dev/null +++ b/core/crates/primitives/src/earn_type.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{Delegation, DelegationValidator}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +pub enum EarnType { + Deposit(DelegationValidator), + Withdraw(Delegation), +} + +impl EarnType { + pub fn provider(&self) -> &DelegationValidator { + match self { + EarnType::Deposit(provider) => provider, + EarnType::Withdraw(delegation) => &delegation.validator, + } + } + + pub fn provider_id(&self) -> &str { + &self.provider().id + } +} diff --git a/core/crates/primitives/src/explorers/algorand.rs b/core/crates/primitives/src/explorers/algorand.rs new file mode 100644 index 0000000000..73f59af765 --- /dev/null +++ b/core/crates/primitives/src/explorers/algorand.rs @@ -0,0 +1,34 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ACCOUNT_PATH, Explorer, Metadata, TX_PATH}; + +pub struct AlgorandAllo; + +impl AlgorandAllo { + pub fn boxed() -> Box { + Explorer::boxed(Metadata { + name: "Allo", + base_url: "https://allo.info", + tx_path: TX_PATH, + address_path: ACCOUNT_PATH, + token_path: None, + nft_path: None, + validator_path: Some(ACCOUNT_PATH), + }) + } +} + +pub struct AlgorandPera; + +impl AlgorandPera { + pub fn boxed() -> Box { + Explorer::boxed(Metadata { + name: "Pera", + base_url: "https://explorer.perawallet.app", + tx_path: TX_PATH, + address_path: ACCOUNT_PATH, + token_path: Some("/assets"), + nft_path: None, + validator_path: Some(ACCOUNT_PATH), + }) + } +} diff --git a/core/crates/primitives/src/explorers/aptos.rs b/core/crates/primitives/src/explorers/aptos.rs new file mode 100644 index 0000000000..af71cd089f --- /dev/null +++ b/core/crates/primitives/src/explorers/aptos.rs @@ -0,0 +1,26 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ACCOUNT_PATH, COIN_PATH, Explorer, Metadata, TRANSACTION_PATH, TXN_PATH}; + +pub fn new_aptos_scan() -> Box { + Explorer::boxed(Metadata { + name: "AptosScan", + base_url: "https://aptoscan.com", + tx_path: TRANSACTION_PATH, + address_path: ACCOUNT_PATH, + token_path: Some(COIN_PATH), + nft_path: None, + validator_path: None, + }) +} + +pub fn new_aptos_explorer() -> Box { + Explorer::boxed(Metadata { + name: "AptosExplorer", + base_url: "https://explorer.aptoslabs.com", + tx_path: TXN_PATH, + address_path: ACCOUNT_PATH, + token_path: Some(COIN_PATH), + nft_path: None, + validator_path: None, + }) +} diff --git a/core/crates/primitives/src/explorers/blockchair.rs b/core/crates/primitives/src/explorers/blockchair.rs new file mode 100644 index 0000000000..6b86ec6eec --- /dev/null +++ b/core/crates/primitives/src/explorers/blockchair.rs @@ -0,0 +1,151 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Metadata, MultiChainExplorer}; +use std::sync::LazyLock; + +static BLOCKCHAIR_FACTORY: LazyLock = LazyLock::new(|| { + MultiChainExplorer::new() + .add_chain("bitcoin", Metadata::blockchair("Blockchair", "https://blockchair.com/bitcoin")) + .add_chain("bitcoin_cash", Metadata::blockchair("Blockchair", "https://blockchair.com/bitcoin-cash")) + .add_chain("litecoin", Metadata::blockchair("Blockchair", "https://blockchair.com/litecoin")) + .add_chain("dogecoin", Metadata::blockchair("Blockchair", "https://blockchair.com/dogecoin")) + .add_chain("zcash", Metadata::blockchair("Blockchair", "https://blockchair.com/zcash")) + .add_chain("ethereum", Metadata::blockchair("Blockchair", "https://blockchair.com/ethereum")) + .add_chain("base", Metadata::blockchair("Blockchair", "https://blockchair.com/base")) + .add_chain("polygon", Metadata::blockchair("Blockchair", "https://blockchair.com/polygon")) + .add_chain("arbitrum", Metadata::blockchair("Blockchair", "https://blockchair.com/arbitrum-one")) + .add_chain("optimism", Metadata::blockchair("Blockchair", "https://blockchair.com/optimism")) + .add_chain("avalanche", Metadata::blockchair("Blockchair", "https://blockchair.com/avalanche")) + .add_chain("solana", Metadata::blockchair("Blockchair", "https://blockchair.com/solana")) + .add_chain("stellar", Metadata::blockchair("Blockchair", "https://blockchair.com/stellar")) + .add_chain("bnb", Metadata::blockchair("Blockchair", "https://blockchair.com/bnb")) + .add_chain("opbnb", Metadata::blockchair("Blockchair", "https://blockchair.com/opbnb")) + .add_chain("fantom", Metadata::blockchair("Blockchair", "https://blockchair.com/fantom")) + .add_chain("gnosis", Metadata::blockchair("Blockchair", "https://blockchair.com/gnosis-chain")) + .add_chain("linea", Metadata::blockchair("Blockchair", "https://blockchair.com/linea")) + .add_chain("ton", Metadata::blockchair("Blockchair", "https://blockchair.com/ton")) + .add_chain("tron", Metadata::blockchair("Blockchair", "https://blockchair.com/tron")) + .add_chain("xrp", Metadata::blockchair("Blockchair", "https://blockchair.com/xrp-ledger")) + .add_chain("aptos", Metadata::blockchair("Blockchair", "https://blockchair.com/aptos")) + .add_chain("polkadot", Metadata::blockchair("Blockchair", "https://blockchair.com/polkadot")) +}); + +pub fn new_bitcoin() -> Box { + BLOCKCHAIR_FACTORY.for_chain("bitcoin").unwrap() +} + +pub fn new_bitcoin_cash() -> Box { + BLOCKCHAIR_FACTORY.for_chain("bitcoin_cash").unwrap() +} + +pub fn new_litecoin() -> Box { + BLOCKCHAIR_FACTORY.for_chain("litecoin").unwrap() +} + +pub fn new_doge() -> Box { + BLOCKCHAIR_FACTORY.for_chain("dogecoin").unwrap() +} + +pub fn new_zcash() -> Box { + BLOCKCHAIR_FACTORY.for_chain("zcash").unwrap() +} + +pub fn new_ethereum() -> Box { + BLOCKCHAIR_FACTORY.for_chain("ethereum").unwrap() +} + +pub fn new_base() -> Box { + BLOCKCHAIR_FACTORY.for_chain("base").unwrap() +} + +pub fn new_polygon() -> Box { + BLOCKCHAIR_FACTORY.for_chain("polygon").unwrap() +} + +pub fn new_arbitrum() -> Box { + BLOCKCHAIR_FACTORY.for_chain("arbitrum").unwrap() +} + +pub fn new_optimism() -> Box { + BLOCKCHAIR_FACTORY.for_chain("optimism").unwrap() +} + +pub fn new_avalanche() -> Box { + BLOCKCHAIR_FACTORY.for_chain("avalanche").unwrap() +} + +pub fn new_solana() -> Box { + BLOCKCHAIR_FACTORY.for_chain("solana").unwrap() +} + +pub fn new_stellar() -> Box { + BLOCKCHAIR_FACTORY.for_chain("stellar").unwrap() +} + +pub fn new_bnb() -> Box { + BLOCKCHAIR_FACTORY.for_chain("bnb").unwrap() +} + +pub fn new_opbnb() -> Box { + BLOCKCHAIR_FACTORY.for_chain("opbnb").unwrap() +} + +pub fn new_fantom() -> Box { + BLOCKCHAIR_FACTORY.for_chain("fantom").unwrap() +} + +pub fn new_gnosis() -> Box { + BLOCKCHAIR_FACTORY.for_chain("gnosis").unwrap() +} + +pub fn new_linea() -> Box { + BLOCKCHAIR_FACTORY.for_chain("linea").unwrap() +} + +pub fn new_ton() -> Box { + BLOCKCHAIR_FACTORY.for_chain("ton").unwrap() +} + +pub fn new_tron() -> Box { + BLOCKCHAIR_FACTORY.for_chain("tron").unwrap() +} + +pub fn new_xrp() -> Box { + BLOCKCHAIR_FACTORY.for_chain("xrp").unwrap() +} + +pub fn new_aptos() -> Box { + BLOCKCHAIR_FACTORY.for_chain("aptos").unwrap() +} + +pub fn new_polkadot() -> Box { + BLOCKCHAIR_FACTORY.for_chain("polkadot").unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_blockchair_bitcoin() { + let explorer = new_bitcoin(); + assert_eq!(explorer.name(), "Blockchair"); + assert_eq!(explorer.get_tx_url("abc123"), "https://blockchair.com/bitcoin/transaction/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://blockchair.com/bitcoin/address/addr123"); + } + + #[test] + fn test_blockchair_ethereum() { + let explorer = new_ethereum(); + assert_eq!(explorer.name(), "Blockchair"); + assert_eq!(explorer.get_tx_url("abc123"), "https://blockchair.com/ethereum/transaction/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://blockchair.com/ethereum/address/addr123"); + } + + #[test] + fn test_blockchair_stellar() { + let explorer = new_stellar(); + assert_eq!(explorer.name(), "Blockchair"); + assert_eq!(explorer.get_tx_url("abc123"), "https://blockchair.com/stellar/transaction/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://blockchair.com/stellar/address/addr123"); + } +} diff --git a/core/crates/primitives/src/explorers/blockscout.rs b/core/crates/primitives/src/explorers/blockscout.rs new file mode 100644 index 0000000000..59597f6521 --- /dev/null +++ b/core/crates/primitives/src/explorers/blockscout.rs @@ -0,0 +1,22 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct BlockScout; + +impl BlockScout { + pub fn new_celo() -> Box { + Explorer::boxed(Metadata::with_token("BlockScout", "https://celo.blockscout.com")) + } + + pub fn new_manta() -> Box { + Explorer::boxed(Metadata::with_token("Pacific Explorer", "https://pacific-explorer.manta.network")) + } + + pub fn new_ink() -> Box { + Explorer::boxed(Metadata::with_token("Ink Explorer", "https://explorer.inkonchain.com")) + } + + pub fn new_hyperliquid() -> Box { + Explorer::boxed(Metadata::with_token("BlockScout", "https://hyperliquid.cloud.blockscout.com")) + } +} diff --git a/core/crates/primitives/src/explorers/blocksec.rs b/core/crates/primitives/src/explorers/blocksec.rs new file mode 100644 index 0000000000..291593458f --- /dev/null +++ b/core/crates/primitives/src/explorers/blocksec.rs @@ -0,0 +1,99 @@ +use crate::block_explorer::BlockExplorer; +use crate::chain_evm::EVMChain; + +use super::EtherScan; + +const BLOCKSEC_NAME: &str = "Blocksec Phalcon"; + +pub struct Blocksec { + pub chain: EVMChain, + pub tx_suffix: Option<&'static str>, +} + +impl Blocksec { + pub fn new(chain: EVMChain, tx_suffix: Option<&'static str>) -> Box { + Box::new(Self { chain, tx_suffix }) + } + + pub fn new_ethereum() -> Box { + Self::new(EVMChain::Ethereum, Some("eth")) + } + + pub fn new_bsc() -> Box { + Self::new(EVMChain::SmartChain, Some("bsc")) + } + + pub fn new_polygon() -> Box { + Self::new(EVMChain::Polygon, None) + } + + pub fn new_arbitrum() -> Box { + Self::new(EVMChain::Arbitrum, None) + } + + pub fn new_optimism() -> Box { + Self::new(EVMChain::Optimism, None) + } + pub fn new_base() -> Box { + Self::new(EVMChain::Base, None) + } +} + +impl BlockExplorer for Blocksec { + fn name(&self) -> String { + BLOCKSEC_NAME.into() + } + fn get_tx_url(&self, hash: &str) -> String { + format!("https://app.blocksec.com/explorer/tx/{}/{}", self.tx_suffix.unwrap_or_else(|| self.chain.as_ref()), hash) + } + fn get_address_url(&self, _address: &str) -> String { + // delegate to etherscan + EtherScan::boxed(self.chain).get_address_url(_address) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_get_ethereum_tx_url() { + let explorer = Blocksec::new_ethereum(); + assert_eq!( + explorer.get_tx_url("0x08729ee8d311df87fe9ad5f73e60d8740b7688f19932ecbe8ebe3fac8321284e"), + "https://app.blocksec.com/explorer/tx/eth/0x08729ee8d311df87fe9ad5f73e60d8740b7688f19932ecbe8ebe3fac8321284e" + ); + } + + #[test] + fn test_get_optimism_tx_url() { + let explorer = Blocksec::new_optimism(); + assert_eq!( + explorer.get_tx_url("0x4a81ba47adfb9720f792eb08cef9a4d444db7f6ff574c9adc4870188acb1cb18"), + "https://app.blocksec.com/explorer/tx/optimism/0x4a81ba47adfb9720f792eb08cef9a4d444db7f6ff574c9adc4870188acb1cb18" + ); + } + + #[test] + fn test_get_bsc_tx_url() { + let explorer = Blocksec::new_bsc(); + + assert_eq!( + explorer.get_tx_url("0xa9fe9d47f5130e3aa622b1f1c9a7af04f68a297a126e9210c671b3afb5df2816"), + "https://app.blocksec.com/explorer/tx/bsc/0xa9fe9d47f5130e3aa622b1f1c9a7af04f68a297a126e9210c671b3afb5df2816" + ); + assert_eq!( + explorer.get_address_url("0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4"), + "https://bscscan.com/address/0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4" + ) + } + + #[test] + fn test_get_base_url() { + let explorer = Blocksec::new_base(); + + assert_eq!( + explorer.get_tx_url("0xa9fe9d47f5130e3aa622b1f1c9a7af04f68a297a126e9210c671b3afb5df2816"), + "https://app.blocksec.com/explorer/tx/base/0xa9fe9d47f5130e3aa622b1f1c9a7af04f68a297a126e9210c671b3afb5df2816" + ); + } +} diff --git a/core/crates/primitives/src/explorers/blockvision.rs b/core/crates/primitives/src/explorers/blockvision.rs new file mode 100644 index 0000000000..af959a7775 --- /dev/null +++ b/core/crates/primitives/src/explorers/blockvision.rs @@ -0,0 +1,62 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ACCOUNT_PATH, COIN_PATH, Explorer, Metadata, VALIDATORS_PATH}; + +pub struct BlockVision; + +impl BlockVision { + pub fn new_monad() -> Box { + Explorer::boxed(Metadata::full("MonadVision", "https://monadvision.com")) + } + + pub fn new_sui() -> Box { + Explorer::boxed(Metadata { + name: "SuiVision", + base_url: "https://suivision.xyz", + tx_path: "/txblock", + address_path: ACCOUNT_PATH, + token_path: Some(COIN_PATH), + nft_path: None, + validator_path: Some(VALIDATORS_PATH), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asset_constants::MONAD_USDC_TOKEN_ID; + + #[test] + fn test_monad_urls() { + let explorer = BlockVision::new_monad(); + + assert_eq!(explorer.name(), "MonadVision"); + assert_eq!(explorer.get_address_url("0xabc"), "https://monadvision.com/address/0xabc"); + assert_eq!(explorer.get_tx_url("0x123"), "https://monadvision.com/tx/0x123"); + assert_eq!( + explorer.get_token_url(MONAD_USDC_TOKEN_ID), + Some(format!("https://monadvision.com/token/{MONAD_USDC_TOKEN_ID}")) + ); + assert_eq!( + explorer.get_validator_url("0xC11Ae71884A76744Fa7976e09AC5441F1233Ef6F"), + Some("https://monadvision.com/validator/0xC11Ae71884A76744Fa7976e09AC5441F1233Ef6F".to_string()) + ); + } + + #[test] + fn test_sui_vision_urls() { + let explorer = BlockVision::new_sui(); + + assert_eq!(explorer.name(), "SuiVision"); + assert_eq!( + explorer.get_address_url("0x6f02af629f66a13c5b8cb857cddf43804422d205b0bb9bda9db98b2635fe59bb"), + "https://suivision.xyz/account/0x6f02af629f66a13c5b8cb857cddf43804422d205b0bb9bda9db98b2635fe59bb" + ); + assert_eq!( + explorer.get_tx_url("ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos"), + "https://suivision.xyz/txblock/ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos" + ); + assert_eq!(explorer.get_token_url("token123"), Some("https://suivision.xyz/coin/token123".to_string())); + assert_eq!(explorer.get_validator_url("val123"), Some("https://suivision.xyz/validators/val123".to_string())); + } +} diff --git a/core/crates/primitives/src/explorers/cardano.rs b/core/crates/primitives/src/explorers/cardano.rs new file mode 100644 index 0000000000..4e13ce5568 --- /dev/null +++ b/core/crates/primitives/src/explorers/cardano.rs @@ -0,0 +1,10 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct Cardanocan; + +impl Cardanocan { + pub fn boxed() -> Box { + Explorer::boxed(Metadata::blockchair("CardanoScan", "https://cardanoscan.io")) + } +} diff --git a/core/crates/primitives/src/explorers/chainflip.rs b/core/crates/primitives/src/explorers/chainflip.rs new file mode 100644 index 0000000000..89ef0cd761 --- /dev/null +++ b/core/crates/primitives/src/explorers/chainflip.rs @@ -0,0 +1,34 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata, TX_PATH}; + +pub struct ChainflipScan; + +impl ChainflipScan { + pub fn boxed() -> Box { + Explorer::boxed(Metadata { + name: "Chainflip", + base_url: "https://scan.chainflip.io", + tx_path: TX_PATH, + address_path: "", + token_path: None, + nft_path: None, + validator_path: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chainflip_scan() { + let chainflip_scan = ChainflipScan::boxed(); + let tx = "54qsbkVUPoQUbwfuQeDXmNyodPWVX8VcK6sSSFyfezkg8t5XbduthFisKBcGxGjSab8QsKaPoEWEnzsK9xsFXrMF"; + assert_eq!(chainflip_scan.name(), "Chainflip"); + assert_eq!( + chainflip_scan.get_tx_url(tx), + "https://scan.chainflip.io/tx/54qsbkVUPoQUbwfuQeDXmNyodPWVX8VcK6sSSFyfezkg8t5XbduthFisKBcGxGjSab8QsKaPoEWEnzsK9xsFXrMF" + ); + } +} diff --git a/core/crates/primitives/src/explorers/etherscan.rs b/core/crates/primitives/src/explorers/etherscan.rs new file mode 100644 index 0000000000..9785fa1a97 --- /dev/null +++ b/core/crates/primitives/src/explorers/etherscan.rs @@ -0,0 +1,39 @@ +use crate::block_explorer::BlockExplorer; +use crate::chain_evm::EVMChain; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct EtherScan; + +impl EtherScan { + pub fn boxed(chain: EVMChain) -> Box { + match chain { + EVMChain::Ethereum => Explorer::boxed(Metadata::with_token("Etherscan", "https://etherscan.io")), + EVMChain::SmartChain => Explorer::boxed(Metadata::with_token("BscScan", "https://bscscan.com")), + EVMChain::Polygon => Explorer::boxed(Metadata::with_token("PolygonScan", "https://polygonscan.com")), + EVMChain::Plasma => Explorer::boxed(Metadata::with_token("PlasmaScan", "https://plasmascan.to")), + EVMChain::Arbitrum => Explorer::boxed(Metadata::with_token("ArbiScan", "https://arbiscan.io")), + EVMChain::Optimism => Explorer::boxed(Metadata::with_token("Etherscan", "https://optimistic.etherscan.io")), + EVMChain::Base => Explorer::boxed(Metadata::with_token("BaseScan", "https://basescan.org")), + EVMChain::AvalancheC => Explorer::boxed(Metadata::with_token("SnowScan", "https://snowscan.xyz")), + EVMChain::OpBNB => Explorer::boxed(Metadata::with_token("opBNBScan", "https://opbnb.bscscan.com")), + EVMChain::Fantom => Explorer::boxed(Metadata::with_token("FTMScan", "https://ftmscan.com")), + EVMChain::Gnosis => Explorer::boxed(Metadata::with_token("GnosisScan", "https://gnosisscan.io")), + EVMChain::Manta => Explorer::boxed(Metadata::with_token("Socialscan", "https://manta.socialscan.io")), + EVMChain::Blast => Explorer::boxed(Metadata::with_token("BlastScan", "https://blastscan.io")), + EVMChain::Linea => Explorer::boxed(Metadata::with_token("LineaScan", "https://lineascan.build")), + EVMChain::ZkSync => Explorer::boxed(Metadata::with_token("zkSync Era Explorer", "https://era.zksync.network")), + EVMChain::Celo => Explorer::boxed(Metadata::with_token("CeloScan", "https://celoscan.io")), + EVMChain::Mantle => Explorer::boxed(Metadata::with_token("MantleScan", "https://mantlescan.xyz")), + EVMChain::World => Explorer::boxed(Metadata::with_token("WorldScan", "https://worldscan.org")), + EVMChain::Sonic => Explorer::boxed(Metadata::with_token("SonicScan", "https://sonicscan.org")), + EVMChain::SeiEvm => Explorer::boxed(Metadata::with_token("Seiscan", "https://seiscan.io")), + EVMChain::Abstract => Explorer::boxed(Metadata::with_token("Abscan", "https://abscan.org")), + EVMChain::Berachain => Explorer::boxed(Metadata::with_token("Berascan", "https://berascan.com")), + EVMChain::Unichain => Explorer::boxed(Metadata::with_token("Uniscan", "https://uniscan.xyz")), + EVMChain::Monad => Explorer::boxed(Metadata::with_token("Monadscan", "https://monadscan.com")), + EVMChain::Hyperliquid => Explorer::boxed(Metadata::with_token("HyperEvmScan", "https://hyperevmscan.io")), + EVMChain::Stable => Explorer::boxed(Metadata::with_token("Stablescan", "https://stablescan.xyz")), + _ => todo!(), + } + } +} diff --git a/core/crates/primitives/src/explorers/hypercore.rs b/core/crates/primitives/src/explorers/hypercore.rs new file mode 100644 index 0000000000..9a89d9774d --- /dev/null +++ b/core/crates/primitives/src/explorers/hypercore.rs @@ -0,0 +1,91 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct HyperliquidExplorer; +pub struct HypurrScan; +pub struct FlowScan; + +impl HyperliquidExplorer { + pub fn boxed() -> Box { + Explorer::boxed(Metadata::with_token("Hyperliquid", "https://app.hyperliquid.xyz/explorer")) + } +} + +impl HypurrScan { + pub fn boxed() -> Box { + Explorer::boxed(Metadata::new("HypurrScan", "https://hypurrscan.io")) + } +} + +impl FlowScan { + pub fn boxed() -> Box { + Explorer::boxed(Metadata::new("FlowScan", "https://www.flowscan.xyz")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hyperliquid_explorer_tx_url() { + let explorer = HyperliquidExplorer::boxed(); + let tx_hash = "0x144bb14b70b1ea80c06a0427e862140000ea2b7bf051872ce50dd920fd547b86"; + let result = explorer.get_tx_url(tx_hash); + + assert_eq!( + result, + "https://app.hyperliquid.xyz/explorer/tx/0x144bb14b70b1ea80c06a0427e862140000ea2b7bf051872ce50dd920fd547b86" + ); + } + + #[test] + fn test_hyperliquid_explorer_address_url() { + let explorer = HyperliquidExplorer::boxed(); + let address = "0x953cb34f310cdef2ec0351e8c20e87bd53bd3bce"; + let result = explorer.get_address_url(address); + + assert_eq!(result, "https://app.hyperliquid.xyz/explorer/address/0x953cb34f310cdef2ec0351e8c20e87bd53bd3bce"); + } + + #[test] + fn test_hyperliquid_explorer_token_url() { + let explorer = HyperliquidExplorer::boxed(); + let token = "0x0d01dc56dcaaca66ad901c959b4011ec"; + let result = explorer.get_token_url(token).unwrap(); + + assert_eq!(result, "https://app.hyperliquid.xyz/explorer/token/0x0d01dc56dcaaca66ad901c959b4011ec"); + } + + #[test] + fn test_hypurrscan_urls() { + let explorer = HypurrScan::boxed(); + let tx_hash = "0x90effdc0864193549269042ff91b3702038900a62144b22634b8a91345456d3f"; + let address = "0xE4bfadD038B5ec2cab0e5F0354F2249cCF5d38eE"; + + assert_eq!( + explorer.get_tx_url(tx_hash), + "https://hypurrscan.io/tx/0x90effdc0864193549269042ff91b3702038900a62144b22634b8a91345456d3f" + ); + assert_eq!( + explorer.get_address_url(address), + "https://hypurrscan.io/address/0xE4bfadD038B5ec2cab0e5F0354F2249cCF5d38eE" + ); + } + + #[test] + fn test_flowscan_urls() { + let explorer = FlowScan::boxed(); + let tx_hash = "0x09f4a204b1230fbd0b6e043023ef7200002fb9ea4c262e8fadbd4d577026e9a7"; + let address = "0xE4bfadD038B5ec2cab0e5F0354F2249cCF5d38eE"; + + assert_eq!( + explorer.get_tx_url(tx_hash), + "https://www.flowscan.xyz/tx/0x09f4a204b1230fbd0b6e043023ef7200002fb9ea4c262e8fadbd4d577026e9a7" + ); + assert_eq!( + explorer.get_address_url(address), + "https://www.flowscan.xyz/address/0xE4bfadD038B5ec2cab0e5F0354F2249cCF5d38eE" + ); + } +} diff --git a/core/crates/primitives/src/explorers/mantle.rs b/core/crates/primitives/src/explorers/mantle.rs new file mode 100644 index 0000000000..3256c86737 --- /dev/null +++ b/core/crates/primitives/src/explorers/mantle.rs @@ -0,0 +1,10 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct MantleExplorer; + +impl MantleExplorer { + pub fn boxed() -> Box { + Explorer::boxed(Metadata::with_token("Mantle Explorer", "https://explorer.mantle.xyz")) + } +} diff --git a/core/crates/primitives/src/explorers/mayanscan.rs b/core/crates/primitives/src/explorers/mayanscan.rs new file mode 100644 index 0000000000..7219aa0d3d --- /dev/null +++ b/core/crates/primitives/src/explorers/mayanscan.rs @@ -0,0 +1,10 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct MayanScan; + +impl MayanScan { + pub fn boxed() -> Box { + Explorer::boxed(Metadata::new("Mayan Explorer", "https://explorer.mayan.finance")) + } +} diff --git a/core/crates/primitives/src/explorers/mempool.rs b/core/crates/primitives/src/explorers/mempool.rs new file mode 100644 index 0000000000..20ec9fc9fa --- /dev/null +++ b/core/crates/primitives/src/explorers/mempool.rs @@ -0,0 +1,6 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub fn new() -> Box { + Explorer::boxed(Metadata::new("Mempool", "https://mempool.space")) +} diff --git a/core/crates/primitives/src/explorers/metadata.rs b/core/crates/primitives/src/explorers/metadata.rs new file mode 100644 index 0000000000..1dab6aa8d7 --- /dev/null +++ b/core/crates/primitives/src/explorers/metadata.rs @@ -0,0 +1,292 @@ +use crate::block_explorer::BlockExplorer; +use std::collections::HashMap; + +pub const TX_PATH: &str = "/tx"; +pub const TXN_PATH: &str = "/txn"; +pub const TXNS_PATH: &str = "/txns"; +pub const TRANSACTION_PATH: &str = "/transaction"; +pub const ADDRESS_PATH: &str = "/address"; +pub const ACCOUNT_PATH: &str = "/account"; +pub const TOKEN_PATH: &str = "/token"; +pub const NFT_PATH: &str = "/nft"; +pub const COIN_PATH: &str = "/coin"; +pub const VALIDATOR_PATH: &str = "/validator"; +pub const VALIDATORS_PATH: &str = "/validators"; +pub const ASSETS_PATH: &str = "/assets"; +pub const ASSET_PATH: &str = "/asset"; + +#[derive(Debug, Clone)] +pub struct Metadata { + pub name: &'static str, + pub base_url: &'static str, + pub tx_path: &'static str, + pub address_path: &'static str, + pub token_path: Option<&'static str>, + pub nft_path: Option<&'static str>, + pub validator_path: Option<&'static str>, +} + +impl Metadata { + /// Create a common explorer with /tx and /address paths (most common pattern) + pub fn new(name: &'static str, base_url: &'static str) -> Self { + Self { + name, + base_url, + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: None, + nft_path: None, + validator_path: None, + } + } + + /// Create a common explorer with /tx, /address, /token, and /nft paths + pub fn with_token(name: &'static str, base_url: &'static str) -> Self { + Self { + name, + base_url, + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: Some(TOKEN_PATH), + nft_path: Some(NFT_PATH), + validator_path: None, + } + } + + /// Create a validator-enabled explorer with /tx, /address, and /validator paths + pub fn with_validator(name: &'static str, base_url: &'static str) -> Self { + Self { + name, + base_url, + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: None, + nft_path: None, + validator_path: Some(VALIDATOR_PATH), + } + } + + /// Create a full-featured explorer with all standard paths + pub fn full(name: &'static str, base_url: &'static str) -> Self { + Self { + name, + base_url, + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: Some(TOKEN_PATH), + nft_path: Some(NFT_PATH), + validator_path: Some(VALIDATOR_PATH), + } + } + + /// Create an explorer using /transaction path instead of /tx (Blockchair style) + pub fn blockchair(name: &'static str, base_url: &'static str) -> Self { + Self { + name, + base_url, + tx_path: TRANSACTION_PATH, + address_path: ADDRESS_PATH, + token_path: None, + nft_path: None, + validator_path: None, + } + } + + /// Create a Mintscan-style explorer with assets and validators + pub fn mintscan(name: &'static str, base_url: &'static str) -> Self { + Self { + name, + base_url, + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: Some(ASSETS_PATH), + nft_path: None, + validator_path: Some(VALIDATORS_PATH), + } + } +} + +pub struct Explorer { + config: Metadata, +} + +impl Explorer { + pub fn boxed(config: Metadata) -> Box { + Box::new(Self { config }) as Box + } +} + +impl BlockExplorer for Explorer { + fn name(&self) -> String { + self.config.name.into() + } + + fn get_tx_url(&self, hash: &str) -> String { + format!("{}{}/{}", self.config.base_url, self.config.tx_path, hash) + } + + fn get_address_url(&self, address: &str) -> String { + format!("{}{}/{}", self.config.base_url, self.config.address_path, address) + } + + fn get_token_url(&self, token: &str) -> Option { + self.config.token_path.map(|path| format!("{}{}/{}", self.config.base_url, path, token)) + } + + fn get_nft_url(&self, contract: &str, token_id: &str) -> Option { + self.config.nft_path.map(|path| format!("{}{}/{}/{}", self.config.base_url, path, contract, token_id)) + } + + fn get_validator_url(&self, validator: &str) -> Option { + self.config.validator_path.map(|path| format!("{}{}/{}", self.config.base_url, path, validator)) + } +} + +pub struct MultiChainExplorer { + configs: HashMap<&'static str, Metadata>, +} + +impl Default for MultiChainExplorer { + fn default() -> Self { + Self::new() + } +} + +impl MultiChainExplorer { + pub fn new() -> Self { + Self { configs: HashMap::new() } + } + + pub fn add_chain(mut self, chain: &'static str, config: Metadata) -> Self { + self.configs.insert(chain, config); + self + } + + pub fn for_chain(&self, chain: &'static str) -> Option> { + self.configs + .get(chain) + .map(|config| Box::new(Explorer { config: config.clone() }) as Box) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generic_explorer_basic_urls() { + let config = Metadata { + name: "TestExplorer", + base_url: "https://test.com", + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: Some(TOKEN_PATH), + nft_path: Some(NFT_PATH), + validator_path: Some(VALIDATOR_PATH), + }; + let explorer = Explorer::boxed(config); + + assert_eq!(explorer.name(), "TestExplorer"); + assert_eq!(explorer.get_tx_url("abc123"), "https://test.com/tx/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://test.com/address/addr123"); + assert_eq!(explorer.get_token_url("token123"), Some("https://test.com/token/token123".to_string())); + assert_eq!( + explorer.get_nft_url("contract123", "token123"), + Some("https://test.com/nft/contract123/token123".to_string()) + ); + assert_eq!(explorer.get_validator_url("val123"), Some("https://test.com/validator/val123".to_string())); + } + + #[test] + fn test_generic_explorer_optional_urls() { + let config = Metadata { + name: "SimpleExplorer", + base_url: "https://simple.com", + tx_path: TRANSACTION_PATH, + address_path: ACCOUNT_PATH, + token_path: None, + nft_path: None, + validator_path: None, + }; + let explorer = Explorer::boxed(config); + + assert_eq!(explorer.get_token_url("token123"), None); + assert_eq!(explorer.get_nft_url("contract123", "token123"), None); + assert_eq!(explorer.get_validator_url("val123"), None); + } + + #[test] + fn test_metadata_helpers() { + let simple = Metadata::new("Simple", "https://simple.com"); + assert_eq!(simple.name, "Simple"); + assert_eq!(simple.base_url, "https://simple.com"); + assert_eq!(simple.tx_path, TX_PATH); + assert_eq!(simple.address_path, ADDRESS_PATH); + assert_eq!(simple.token_path, None); + assert_eq!(simple.validator_path, None); + + let with_token = Metadata::with_token("WithToken", "https://token.com"); + assert_eq!(with_token.token_path, Some(TOKEN_PATH)); + assert_eq!(with_token.nft_path, Some(NFT_PATH)); + assert_eq!(with_token.validator_path, None); + + let with_validator = Metadata::with_validator("WithValidator", "https://validator.com"); + assert_eq!(with_validator.token_path, None); + assert_eq!(with_validator.nft_path, None); + assert_eq!(with_validator.validator_path, Some(VALIDATOR_PATH)); + + let full = Metadata::full("Full", "https://full.com"); + assert_eq!(full.token_path, Some(TOKEN_PATH)); + assert_eq!(full.nft_path, Some(NFT_PATH)); + assert_eq!(full.validator_path, Some(VALIDATOR_PATH)); + + let transaction_style = Metadata::blockchair("Transaction", "https://transaction.com"); + assert_eq!(transaction_style.tx_path, TRANSACTION_PATH); + assert_eq!(transaction_style.address_path, ADDRESS_PATH); + assert_eq!(transaction_style.token_path, None); + assert_eq!(transaction_style.nft_path, None); + + let cosmos_style = Metadata::mintscan("Cosmos", "https://cosmos.com"); + assert_eq!(cosmos_style.token_path, Some(ASSETS_PATH)); + assert_eq!(cosmos_style.nft_path, None); + assert_eq!(cosmos_style.validator_path, Some(VALIDATORS_PATH)); + } + + #[test] + fn test_multi_chain_explorer() { + let multi_explorer = MultiChainExplorer::new() + .add_chain( + "chain1", + Metadata { + name: "MultiTest", + base_url: "https://chain1.com", + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: None, + nft_path: None, + validator_path: None, + }, + ) + .add_chain( + "chain2", + Metadata { + name: "MultiTest", + base_url: "https://chain2.com", + tx_path: TRANSACTION_PATH, + address_path: ACCOUNT_PATH, + token_path: Some(TOKEN_PATH), + nft_path: None, + validator_path: None, + }, + ); + + let chain1_explorer = multi_explorer.for_chain("chain1").unwrap(); + let chain2_explorer = multi_explorer.for_chain("chain2").unwrap(); + + assert_eq!(chain1_explorer.get_tx_url("hash"), "https://chain1.com/tx/hash"); + assert_eq!(chain2_explorer.get_tx_url("hash"), "https://chain2.com/transaction/hash"); + assert_eq!(chain2_explorer.get_token_url("token"), Some("https://chain2.com/token/token".to_string())); + + assert!(multi_explorer.for_chain("nonexistent").is_none()); + } +} diff --git a/core/crates/primitives/src/explorers/mintscan.rs b/core/crates/primitives/src/explorers/mintscan.rs new file mode 100644 index 0000000000..1a8ec16a24 --- /dev/null +++ b/core/crates/primitives/src/explorers/mintscan.rs @@ -0,0 +1,72 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Metadata, MultiChainExplorer}; +use std::sync::LazyLock; + +static MINTSCAN_FACTORY: LazyLock = LazyLock::new(|| { + MultiChainExplorer::new() + .add_chain("cosmos", Metadata::mintscan("Mintscan", "https://www.mintscan.io/cosmos")) + .add_chain("osmosis", Metadata::mintscan("Mintscan", "https://www.mintscan.io/osmosis")) + .add_chain("celestia", Metadata::mintscan("Mintscan", "https://www.mintscan.io/celestia")) + .add_chain("injective", Metadata::mintscan("Mintscan", "https://www.mintscan.io/injective")) + .add_chain("sei", Metadata::mintscan("Mintscan", "https://www.mintscan.io/sei")) + .add_chain("noble", Metadata::mintscan("Mintscan", "https://www.mintscan.io/noble")) +}); + +pub fn new_cosmos() -> Box { + MINTSCAN_FACTORY.for_chain("cosmos").unwrap() +} + +pub fn new_osmosis() -> Box { + MINTSCAN_FACTORY.for_chain("osmosis").unwrap() +} + +pub fn new_celestia() -> Box { + MINTSCAN_FACTORY.for_chain("celestia").unwrap() +} + +pub fn new_injective() -> Box { + MINTSCAN_FACTORY.for_chain("injective").unwrap() +} + +pub fn new_sei() -> Box { + MINTSCAN_FACTORY.for_chain("sei").unwrap() +} + +pub fn new_noble() -> Box { + MINTSCAN_FACTORY.for_chain("noble").unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mintscan_cosmos() { + let explorer = new_cosmos(); + assert_eq!(explorer.name(), "Mintscan"); + assert_eq!(explorer.get_tx_url("abc123"), "https://www.mintscan.io/cosmos/tx/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://www.mintscan.io/cosmos/address/addr123"); + assert_eq!(explorer.get_validator_url("val123"), Some("https://www.mintscan.io/cosmos/validators/val123".to_string())); + } + + #[test] + fn test_mintscan_injective() { + let explorer = new_injective(); + assert_eq!(explorer.name(), "Mintscan"); + assert_eq!(explorer.get_tx_url("abc123"), "https://www.mintscan.io/injective/tx/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://www.mintscan.io/injective/address/addr123"); + assert_eq!( + explorer.get_validator_url("val123"), + Some("https://www.mintscan.io/injective/validators/val123".to_string()) + ); + } + + #[test] + fn test_mintscan_osmosis() { + let explorer = new_osmosis(); + assert_eq!(explorer.name(), "Mintscan"); + assert_eq!(explorer.get_tx_url("abc123"), "https://www.mintscan.io/osmosis/tx/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://www.mintscan.io/osmosis/address/addr123"); + assert_eq!(explorer.get_validator_url("val123"), Some("https://www.mintscan.io/osmosis/validators/val123".to_string())); + } +} diff --git a/core/crates/primitives/src/explorers/mod.rs b/core/crates/primitives/src/explorers/mod.rs new file mode 100644 index 0000000000..5b4dfc0612 --- /dev/null +++ b/core/crates/primitives/src/explorers/mod.rs @@ -0,0 +1,56 @@ +pub mod aptos; +pub mod blockchair; +pub mod etherscan; +pub mod mempool; +pub mod mintscan; +pub mod solana; +pub mod sui; +pub mod thorchain; +pub mod threexpl; +pub mod ton; +pub mod tronscan; +pub mod xrpscan; +pub use etherscan::EtherScan; +pub use thorchain::{RuneScan, Viewblock}; +pub use ton::TonScan; +pub use tronscan::TronScan; +pub use xrpscan::XrpScan; +pub mod mantle; +pub use mantle::MantleExplorer; +pub mod zksync; +pub use zksync::ZkSync; +pub mod blockscout; +pub use blockscout::BlockScout; +pub mod near; +pub use near::NearBlocks; +pub mod near_intents; +pub use near_intents::NearIntents; +mod blocksec; +pub use blocksec::Blocksec; +mod algorand; +pub use algorand::{AlgorandAllo, AlgorandPera}; +pub mod blockvision; +pub use blockvision::BlockVision; +mod subscan; +pub use subscan::SubScan; +mod cardano; +pub use cardano::Cardanocan; +mod okx; +pub use okx::OkxExplorer; +mod routescan; +pub use routescan::RouteScan; +pub mod mayanscan; +pub use mayanscan::MayanScan; +pub mod socketscan; +pub use socketscan::SocketScan; +pub mod chainflip; +pub use chainflip::ChainflipScan; +pub mod relay; +pub use relay::RelayScan; +pub mod skip; +pub use skip::SkipExplorer; +pub mod hypercore; +pub use hypercore::{FlowScan, HyperliquidExplorer, HypurrScan}; +pub mod metadata; +pub mod stellar_expert; +pub use metadata::{Explorer, Metadata, MultiChainExplorer}; diff --git a/core/crates/primitives/src/explorers/near.rs b/core/crates/primitives/src/explorers/near.rs new file mode 100644 index 0000000000..b63a6994a4 --- /dev/null +++ b/core/crates/primitives/src/explorers/near.rs @@ -0,0 +1,20 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ADDRESS_PATH, Explorer, Metadata, TXNS_PATH}; + +pub const NEAR_BLOCKS_BASE_URL: &str = "https://nearblocks.io"; + +pub struct NearBlocks; + +impl NearBlocks { + pub fn boxed() -> Box { + Explorer::boxed(Metadata { + name: "Near", + base_url: NEAR_BLOCKS_BASE_URL, + tx_path: TXNS_PATH, + address_path: ADDRESS_PATH, + token_path: None, + nft_path: None, + validator_path: None, + }) + } +} diff --git a/core/crates/primitives/src/explorers/near_intents.rs b/core/crates/primitives/src/explorers/near_intents.rs new file mode 100644 index 0000000000..6f75c62aa1 --- /dev/null +++ b/core/crates/primitives/src/explorers/near_intents.rs @@ -0,0 +1,54 @@ +use super::near::NEAR_BLOCKS_BASE_URL; +use crate::block_explorer::{BlockExplorer, ExplorerInput}; + +pub struct NearIntents; + +impl NearIntents { + const BASE_URL: &'static str = "https://explorer.near-intents.org"; + + pub fn boxed() -> Box { + Box::new(Self) + } +} + +impl BlockExplorer for NearIntents { + fn name(&self) -> String { + "NEAR Intents".to_string() + } + + fn get_tx_url(&self, hash: &str) -> String { + format!("{}/transactions/{}", Self::BASE_URL, hash) + } + + fn get_address_url(&self, address: &str) -> String { + format!("{NEAR_BLOCKS_BASE_URL}/address/{address}") + } + + fn get_swap_tx_url(&self, input: &ExplorerInput) -> String { + let base = self.get_tx_url(input.recipient.as_deref().unwrap_or_default()); + if let Some(memo) = input.memo.as_deref() + && !memo.is_empty() + { + format!("{base}?depositMemo={memo}") + } else { + base + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_swap_tx_url() { + let recipient = "GDJ4JZXZELZD737NVFORH4PSSQDWFDZTKW3AIDKHYQG23ZXBPDGGQBJK"; + let base = format!("{}/transactions/{recipient}", NearIntents::BASE_URL); + + assert_eq!(NearIntents.get_swap_tx_url(&ExplorerInput::new_recipient(recipient)), base); + assert_eq!( + NearIntents.get_swap_tx_url(&ExplorerInput::new_memo(recipient, "48694126")), + format!("{base}?depositMemo=48694126") + ); + } +} diff --git a/core/crates/primitives/src/explorers/okx.rs b/core/crates/primitives/src/explorers/okx.rs new file mode 100644 index 0000000000..18925b501d --- /dev/null +++ b/core/crates/primitives/src/explorers/okx.rs @@ -0,0 +1,61 @@ +use crate::block_explorer::BlockExplorer; + +const OKX_BASE_URL: &str = "https://www.okx.com/web3/explorer"; + +pub struct OkxExplorer; + +impl OkxExplorer { + pub fn new_ink() -> Box { + Box::new(OkxChainExplorer { chain: "inkchain" }) + } + + pub fn new_xlayer() -> Box { + Box::new(OkxChainExplorer { chain: "xlayer" }) + } +} + +struct OkxChainExplorer { + chain: &'static str, +} + +impl BlockExplorer for OkxChainExplorer { + fn name(&self) -> String { + "OKX Explorer".into() + } + fn get_tx_url(&self, hash: &str) -> String { + format!("{}/{}/tx/{}", OKX_BASE_URL, self.chain, hash) + } + fn get_address_url(&self, address: &str) -> String { + format!("{}/{}/address/{}", OKX_BASE_URL, self.chain, address) + } + fn get_token_url(&self, token: &str) -> Option { + Some(format!("{}/{}/token/{}", OKX_BASE_URL, self.chain, token)) + } +} + +#[cfg(test)] +mod tests { + use super::OkxExplorer; + + #[test] + fn test_ink_tx_url() { + let explorer = OkxExplorer::new_ink(); + let tx_id = "0x37a2d85b95d881be32fb806a5c50bfac320565019e408cc6e3aa2072a8929cf5"; + + assert_eq!( + explorer.get_tx_url(tx_id), + "https://www.okx.com/web3/explorer/inkchain/tx/0x37a2d85b95d881be32fb806a5c50bfac320565019e408cc6e3aa2072a8929cf5" + ) + } + + #[test] + fn test_xlayer_tx_url() { + let explorer = OkxExplorer::new_xlayer(); + let tx_id = "0x37a2d85b95d881be32fb806a5c50bfac320565019e408cc6e3aa2072a8929cf5"; + + assert_eq!( + explorer.get_tx_url(tx_id), + "https://www.okx.com/web3/explorer/xlayer/tx/0x37a2d85b95d881be32fb806a5c50bfac320565019e408cc6e3aa2072a8929cf5" + ) + } +} diff --git a/core/crates/primitives/src/explorers/relay.rs b/core/crates/primitives/src/explorers/relay.rs new file mode 100644 index 0000000000..21360ba92f --- /dev/null +++ b/core/crates/primitives/src/explorers/relay.rs @@ -0,0 +1,46 @@ +use crate::block_explorer::BlockExplorer; + +pub struct RelayScan; + +impl RelayScan { + pub fn boxed() -> Box { + Box::new(RelayExplorer) + } +} + +// Custom implementation needed for query parameter pattern +struct RelayExplorer; + +impl BlockExplorer for RelayExplorer { + fn name(&self) -> String { + "Relay".into() + } + fn get_tx_url(&self, hash: &str) -> String { + format!("https://relay.link/transaction/{}", hash) + } + fn get_address_url(&self, address: &str) -> String { + format!("https://relay.link/transaction?address={}", address) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relay_scan() { + let relay_scan = RelayScan::boxed(); + let address = "0x4dece432bd65b664b9f92b983231dac48eccfa19"; + let tx = "0x1d2a1cc47871b3779457dacd61db6e122ded1d5875e0c71650337386ef95d9b4"; + + assert_eq!(relay_scan.name(), "Relay"); + assert_eq!( + relay_scan.get_tx_url(tx), + "https://relay.link/transaction/0x1d2a1cc47871b3779457dacd61db6e122ded1d5875e0c71650337386ef95d9b4" + ); + assert_eq!( + relay_scan.get_address_url(address), + "https://relay.link/transaction?address=0x4dece432bd65b664b9f92b983231dac48eccfa19" + ); + } +} diff --git a/core/crates/primitives/src/explorers/routescan.rs b/core/crates/primitives/src/explorers/routescan.rs new file mode 100644 index 0000000000..fd641b712e --- /dev/null +++ b/core/crates/primitives/src/explorers/routescan.rs @@ -0,0 +1,18 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct RouteScan; + +impl RouteScan { + pub fn new_avax() -> Box { + Explorer::boxed(Metadata::with_token("SnowTrace", "https://snowtrace.io")) + } + + pub fn new_sonic() -> Box { + Explorer::boxed(Metadata::with_token("RouteScan", "https://146.routescan.io")) + } + + pub fn new_ink() -> Box { + Explorer::boxed(Metadata::with_token("RouteScan", "https://57073.routescan.io")) + } +} diff --git a/core/crates/primitives/src/explorers/skip.rs b/core/crates/primitives/src/explorers/skip.rs new file mode 100644 index 0000000000..a21a341a09 --- /dev/null +++ b/core/crates/primitives/src/explorers/skip.rs @@ -0,0 +1,51 @@ +use crate::block_explorer::BlockExplorer; +use crate::chain::Chain; + +pub struct SkipExplorer { + chain_id: String, +} + +impl SkipExplorer { + pub fn boxed(chain: Chain) -> Box { + Box::new(Self { + chain_id: chain.network_id().to_string(), + }) + } +} + +impl BlockExplorer for SkipExplorer { + fn name(&self) -> String { + "Skip Explorer".into() + } + + fn get_tx_url(&self, hash: &str) -> String { + format!("https://explorer.skip.build/?tx_hash={hash}&chain_id={}", self.chain_id) + } + + fn get_address_url(&self, _address: &str) -> String { + String::new() + } + + fn get_token_url(&self, _token_id: &str) -> Option { + None + } + + fn get_validator_url(&self, _address: &str) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_skip_explorer_url() { + let explorer = SkipExplorer::boxed(Chain::Osmosis); + assert_eq!(explorer.name(), "Skip Explorer"); + assert_eq!( + explorer.get_tx_url("1FE2FF8C64062136544C35451E5AE292229A156E174C0EFF5B67970E629A8B1C"), + "https://explorer.skip.build/?tx_hash=1FE2FF8C64062136544C35451E5AE292229A156E174C0EFF5B67970E629A8B1C&chain_id=osmosis-1" + ); + } +} diff --git a/core/crates/primitives/src/explorers/socketscan.rs b/core/crates/primitives/src/explorers/socketscan.rs new file mode 100644 index 0000000000..a6f8cbef20 --- /dev/null +++ b/core/crates/primitives/src/explorers/socketscan.rs @@ -0,0 +1,10 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct SocketScan; + +impl SocketScan { + pub fn boxed() -> Box { + Explorer::boxed(Metadata::new("SocketScan", "https://socketscan.io")) + } +} diff --git a/core/crates/primitives/src/explorers/solana.rs b/core/crates/primitives/src/explorers/solana.rs new file mode 100644 index 0000000000..6e79907440 --- /dev/null +++ b/core/crates/primitives/src/explorers/solana.rs @@ -0,0 +1,101 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ACCOUNT_PATH, ADDRESS_PATH, TOKEN_PATH, TX_PATH}; + +struct SolanaExplorer { + name: &'static str, + base_url: &'static str, + address_path: &'static str, + token_path: &'static str, +} + +impl SolanaExplorer { + fn boxed(name: &'static str, base_url: &'static str, address_path: &'static str, token_path: &'static str) -> Box { + Box::new(Self { + name, + base_url, + address_path, + token_path, + }) + } +} + +impl BlockExplorer for SolanaExplorer { + fn name(&self) -> String { + self.name.into() + } + + fn get_tx_url(&self, hash: &str) -> String { + format!("{}{}/{}", self.base_url, TX_PATH, hash) + } + + fn get_address_url(&self, address: &str) -> String { + format!("{}{}/{}", self.base_url, self.address_path, address) + } + + fn get_token_url(&self, token: &str) -> Option { + Some(format!("{}{}/{}", self.base_url, self.token_path, token)) + } + + fn get_nft_url(&self, _contract: &str, token_id: &str) -> Option { + self.get_token_url(token_id) + } +} + +pub fn new_solscan() -> Box { + SolanaExplorer::boxed("Solscan", "https://solscan.io", ACCOUNT_PATH, TOKEN_PATH) +} + +pub fn new_solana_fm() -> Box { + SolanaExplorer::boxed("SolanaFM", "https://solana.fm", ADDRESS_PATH, ADDRESS_PATH) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_solscan_urls() { + let explorer = new_solscan(); + + assert_eq!(explorer.name(), "Solscan"); + assert_eq!( + explorer.get_tx_url("ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos"), + "https://solscan.io/tx/ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos" + ); + assert_eq!( + explorer.get_address_url("GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"), + "https://solscan.io/account/GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2" + ); + assert_eq!( + explorer.get_token_url("GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"), + Some("https://solscan.io/token/GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2".to_string()) + ); + assert_eq!( + explorer.get_nft_url("ignored-contract", "GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"), + Some("https://solscan.io/token/GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2".to_string()) + ); + } + + #[test] + fn test_solana_fm_urls() { + let explorer = new_solana_fm(); + + assert_eq!(explorer.name(), "SolanaFM"); + assert_eq!( + explorer.get_tx_url("ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos"), + "https://solana.fm/tx/ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos" + ); + assert_eq!( + explorer.get_address_url("GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"), + "https://solana.fm/address/GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2" + ); + assert_eq!( + explorer.get_token_url("GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"), + Some("https://solana.fm/address/GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2".to_string()) + ); + assert_eq!( + explorer.get_nft_url("ignored-contract", "GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"), + Some("https://solana.fm/address/GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2".to_string()) + ); + } +} diff --git a/core/crates/primitives/src/explorers/stellar_expert.rs b/core/crates/primitives/src/explorers/stellar_expert.rs new file mode 100644 index 0000000000..a364e9e0b7 --- /dev/null +++ b/core/crates/primitives/src/explorers/stellar_expert.rs @@ -0,0 +1,52 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ACCOUNT_PATH, ASSET_PATH, Explorer, Metadata, TX_PATH}; + +pub fn new() -> Box { + Explorer::boxed(Metadata { + name: "StellarExpert", + base_url: "https://stellar.expert/explorer/public", + tx_path: TX_PATH, + address_path: ACCOUNT_PATH, + token_path: Some(ASSET_PATH), + nft_path: None, + validator_path: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stellar_expert_account_url() { + let explorer = new(); + let address = "GBRCDFSBJFC4K3ZJV7WQVGDCROU6MCF3FDR3XGAMBG3ENKTGLNZHFKGJ"; + let url = explorer.get_address_url(address); + assert_eq!( + url, + "https://stellar.expert/explorer/public/account/GBRCDFSBJFC4K3ZJV7WQVGDCROU6MCF3FDR3XGAMBG3ENKTGLNZHFKGJ" + ); + } + + #[test] + fn test_stellar_expert_token_url() { + let explorer = new(); + let token = "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + let url = explorer.get_token_url(token).unwrap(); + assert_eq!( + url, + "https://stellar.expert/explorer/public/asset/USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + ); + } + + #[test] + fn test_stellar_expert_tx_url() { + let explorer = new(); + let tx_hash = "249f71606fce57f3325e7abd8df7c8815594fe0fa986a2b6a838921190347bf6"; + let url = explorer.get_tx_url(tx_hash); + assert_eq!( + url, + "https://stellar.expert/explorer/public/tx/249f71606fce57f3325e7abd8df7c8815594fe0fa986a2b6a838921190347bf6" + ); + } +} diff --git a/core/crates/primitives/src/explorers/subscan.rs b/core/crates/primitives/src/explorers/subscan.rs new file mode 100644 index 0000000000..a2fab331a5 --- /dev/null +++ b/core/crates/primitives/src/explorers/subscan.rs @@ -0,0 +1,18 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ACCOUNT_PATH, Explorer, Metadata}; + +pub struct SubScan; + +impl SubScan { + pub fn new_polkadot() -> Box { + Explorer::boxed(Metadata { + name: "SubScan", + base_url: "https://assethub-polkadot.subscan.io", + tx_path: "/extrinsic", + address_path: ACCOUNT_PATH, + token_path: None, + nft_path: None, + validator_path: None, + }) + } +} diff --git a/core/crates/primitives/src/explorers/sui.rs b/core/crates/primitives/src/explorers/sui.rs new file mode 100644 index 0000000000..1600978fea --- /dev/null +++ b/core/crates/primitives/src/explorers/sui.rs @@ -0,0 +1,28 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ACCOUNT_PATH, COIN_PATH, Explorer, Metadata, TX_PATH, VALIDATOR_PATH}; + +pub fn new_sui_scan() -> Box { + Explorer::boxed(Metadata { + name: "SuiScan", + base_url: "https://suiscan.xyz/mainnet", + tx_path: TX_PATH, + address_path: ACCOUNT_PATH, + token_path: Some(COIN_PATH), + nft_path: None, + validator_path: Some(VALIDATOR_PATH), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sui_scan_urls() { + let explorer = new_sui_scan(); + + assert_eq!(explorer.get_token_url("token123"), Some("https://suiscan.xyz/mainnet/coin/token123".to_string())); + + assert_eq!(explorer.get_validator_url("val123"), Some("https://suiscan.xyz/mainnet/validator/val123".to_string())); + } +} diff --git a/core/crates/primitives/src/explorers/thorchain.rs b/core/crates/primitives/src/explorers/thorchain.rs new file mode 100644 index 0000000000..5d59939864 --- /dev/null +++ b/core/crates/primitives/src/explorers/thorchain.rs @@ -0,0 +1,33 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Explorer, Metadata}; + +pub struct Viewblock; + +impl Viewblock { + pub fn boxed() -> Box { + Explorer::boxed(Metadata::new("Viewblock", "https://viewblock.io/thorchain")) + } +} + +pub struct RuneScan; + +impl RuneScan { + pub fn boxed() -> Box { + Box::new(RuneScanExplorer) + } +} + +// Custom implementation needed for hash trimming +struct RuneScanExplorer; + +impl BlockExplorer for RuneScanExplorer { + fn name(&self) -> String { + "RuneScan".into() + } + fn get_tx_url(&self, hash: &str) -> String { + format!("https://runescan.io/tx/{}", hash.trim_start_matches("0x")) + } + fn get_address_url(&self, address: &str) -> String { + format!("https://runescan.io/address/{}", address) + } +} diff --git a/core/crates/primitives/src/explorers/threexpl.rs b/core/crates/primitives/src/explorers/threexpl.rs new file mode 100644 index 0000000000..1d04ec979b --- /dev/null +++ b/core/crates/primitives/src/explorers/threexpl.rs @@ -0,0 +1,53 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{Metadata, MultiChainExplorer}; +use std::sync::LazyLock; + +static THREE_XPL_FACTORY: LazyLock = LazyLock::new(|| { + MultiChainExplorer::new() + .add_chain("bitcoin", Metadata::blockchair("3xpl", "https://3xpl.com/bitcoin")) + .add_chain("bitcoin_cash", Metadata::blockchair("3xpl", "https://3xpl.com/bitcoin-cash")) + .add_chain("litecoin", Metadata::blockchair("3xpl", "https://3xpl.com/litecoin")) + .add_chain("dogecoin", Metadata::blockchair("3xpl", "https://3xpl.com/dogecoin")) + .add_chain("zcash", Metadata::blockchair("3xpl", "https://3xpl.com/zcash")) +}); + +pub fn new_bitcoin() -> Box { + THREE_XPL_FACTORY.for_chain("bitcoin").unwrap() +} + +pub fn new_bitcoin_cash() -> Box { + THREE_XPL_FACTORY.for_chain("bitcoin_cash").unwrap() +} + +pub fn new_litecoin() -> Box { + THREE_XPL_FACTORY.for_chain("litecoin").unwrap() +} + +pub fn new_doge() -> Box { + THREE_XPL_FACTORY.for_chain("dogecoin").unwrap() +} + +pub fn new_zcash() -> Box { + THREE_XPL_FACTORY.for_chain("zcash").unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_three_xpl_bitcoin() { + let explorer = new_bitcoin(); + assert_eq!(explorer.name(), "3xpl"); + assert_eq!(explorer.get_tx_url("abc123"), "https://3xpl.com/bitcoin/transaction/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://3xpl.com/bitcoin/address/addr123"); + } + + #[test] + fn test_three_xpl_litecoin() { + let explorer = new_litecoin(); + assert_eq!(explorer.name(), "3xpl"); + assert_eq!(explorer.get_tx_url("abc123"), "https://3xpl.com/litecoin/transaction/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://3xpl.com/litecoin/address/addr123"); + } +} diff --git a/core/crates/primitives/src/explorers/ton.rs b/core/crates/primitives/src/explorers/ton.rs new file mode 100644 index 0000000000..e4b5897501 --- /dev/null +++ b/core/crates/primitives/src/explorers/ton.rs @@ -0,0 +1,30 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ADDRESS_PATH, Explorer, Metadata, TRANSACTION_PATH, TX_PATH}; + +pub fn new_ton_viewer() -> Box { + Explorer::boxed(Metadata { + name: "TonViewer", + base_url: "https://tonviewer.com", + tx_path: TRANSACTION_PATH, + address_path: "", + token_path: Some(""), + nft_path: None, + validator_path: Some(""), + }) +} + +pub struct TonScan; + +impl TonScan { + pub fn boxed() -> Box { + Explorer::boxed(Metadata { + name: "Tonscan", + base_url: "https://tonscan.org", + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: Some("/jetton"), + nft_path: None, + validator_path: Some(ADDRESS_PATH), + }) + } +} diff --git a/core/crates/primitives/src/explorers/tronscan.rs b/core/crates/primitives/src/explorers/tronscan.rs new file mode 100644 index 0000000000..65a8a7df41 --- /dev/null +++ b/core/crates/primitives/src/explorers/tronscan.rs @@ -0,0 +1,18 @@ +use crate::block_explorer::BlockExplorer; +use crate::explorers::metadata::{ADDRESS_PATH, Explorer, Metadata, TRANSACTION_PATH}; + +pub struct TronScan; + +impl TronScan { + pub fn boxed() -> Box { + Explorer::boxed(Metadata { + name: "TRONSCAN", + base_url: "https://tronscan.org/#", + tx_path: TRANSACTION_PATH, + address_path: ADDRESS_PATH, + token_path: Some("/token20"), + nft_path: None, + validator_path: Some(ADDRESS_PATH), + }) + } +} diff --git a/core/crates/primitives/src/explorers/xrpscan.rs b/core/crates/primitives/src/explorers/xrpscan.rs new file mode 100644 index 0000000000..4217bc2994 --- /dev/null +++ b/core/crates/primitives/src/explorers/xrpscan.rs @@ -0,0 +1,19 @@ +use super::metadata::{ACCOUNT_PATH, Explorer, Metadata, TX_PATH}; +use crate::block_explorer::BlockExplorer; + +pub struct XrpScan; + +impl XrpScan { + pub fn boxed() -> Box { + let config = Metadata { + name: "XrpScan", + base_url: "https://xrpscan.com", + tx_path: TX_PATH, + address_path: ACCOUNT_PATH, + token_path: Some(ACCOUNT_PATH), + nft_path: None, + validator_path: None, + }; + Explorer::boxed(config) + } +} diff --git a/core/crates/primitives/src/explorers/zksync.rs b/core/crates/primitives/src/explorers/zksync.rs new file mode 100644 index 0000000000..36b608e3f5 --- /dev/null +++ b/core/crates/primitives/src/explorers/zksync.rs @@ -0,0 +1,19 @@ +use super::metadata::{ADDRESS_PATH, Explorer, Metadata, TX_PATH}; +use crate::block_explorer::BlockExplorer; + +pub struct ZkSync; + +impl ZkSync { + pub fn boxed() -> Box { + let config = Metadata { + name: "zkSync.io", + base_url: "https://explorer.zksync.io", + tx_path: TX_PATH, + address_path: ADDRESS_PATH, + token_path: Some(ADDRESS_PATH), // ZkSync uses address path for tokens + nft_path: None, + validator_path: None, + }; + Explorer::boxed(config) + } +} diff --git a/core/crates/primitives/src/fee.rs b/core/crates/primitives/src/fee.rs new file mode 100644 index 0000000000..cac78fbb62 --- /dev/null +++ b/core/crates/primitives/src/fee.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString}; +use typeshare::typeshare; + +pub use crate::gas_price_type::GasPriceType; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, AsRefStr, EnumString, EnumIter, PartialEq, Eq)] +#[typeshare(swift = "Equatable, Sendable, CaseIterable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum FeePriority { + Slow, + Normal, + Fast, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, AsRefStr, EnumString, EnumIter)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +#[typeshare(swift = "Equatable, Sendable")] +pub enum FeeUnitType { + SatVb, + Gwei, + Native, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeeRate { + pub priority: FeePriority, + pub gas_price_type: GasPriceType, +} + +impl FeeRate { + pub fn new(priority: FeePriority, gas_price_type: GasPriceType) -> Self { + Self { priority, gas_price_type } + } + + pub fn find(rates: &[FeeRate], priority: FeePriority) -> Option<&FeeRate> { + rates.iter().find(|r| r.priority == priority) + } +} diff --git a/core/crates/primitives/src/fee_priority_value.rs b/core/crates/primitives/src/fee_priority_value.rs new file mode 100644 index 0000000000..8aad5df256 --- /dev/null +++ b/core/crates/primitives/src/fee_priority_value.rs @@ -0,0 +1,8 @@ +use crate::fee::FeePriority; +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PriorityFeeValue { + pub priority: FeePriority, + pub value: BigInt, +} diff --git a/core/crates/primitives/src/fiat_assets.rs b/core/crates/primitives/src/fiat_assets.rs new file mode 100644 index 0000000000..1c4b495bd4 --- /dev/null +++ b/core/crates/primitives/src/fiat_assets.rs @@ -0,0 +1,41 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::currency::Currency; +use crate::{AssetId, FiatProviderName, PaymentType}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct FiatAssets { + pub version: u32, + pub asset_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FiatAssetLimits { + pub currency: Currency, + pub payment_type: PaymentType, + pub min_amount: Option, + pub max_amount: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FiatAsset { + pub id: String, + pub asset_id: Option, + pub provider: FiatProviderName, + pub symbol: String, + pub network: Option, + pub token_id: Option, + pub enabled: bool, + pub is_buy_enabled: bool, + pub is_sell_enabled: bool, + pub unsupported_countries: HashMap>, + pub buy_limits: Vec, + pub sell_limits: Vec, +} diff --git a/core/crates/primitives/src/fiat_provider.rs b/core/crates/primitives/src/fiat_provider.rs new file mode 100644 index 0000000000..cac3a67b95 --- /dev/null +++ b/core/crates/primitives/src/fiat_provider.rs @@ -0,0 +1,108 @@ +use crate::{PaymentType, PrioritizedProvider}; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct FiatProvider { + #[typeshare(serialized_as = "String")] + pub id: FiatProviderName, + pub name: String, + pub image_url: Option, + #[serde(skip)] + #[typeshare(skip)] + pub priority: Option, + #[serde(skip)] + #[typeshare(skip)] + pub threshold_bps: Option, + #[serde(skip)] + #[typeshare(skip)] + pub enabled: bool, + #[serde(skip)] + #[typeshare(skip)] + pub buy_enabled: bool, + #[serde(skip)] + #[typeshare(skip)] + pub sell_enabled: bool, + pub payment_methods: Vec, +} + +impl FiatProvider { + pub fn is_buy_enabled(&self) -> bool { + self.enabled && self.buy_enabled + } + + pub fn is_sell_enabled(&self) -> bool { + self.enabled && self.sell_enabled + } +} + +impl PrioritizedProvider for FiatProvider { + fn provider_id(&self) -> &str { + self.id.as_ref() + } + + fn priority(&self) -> i32 { + self.priority.unwrap_or(0) + } + + fn threshold_bps(&self) -> i32 { + self.threshold_bps.unwrap_or(0) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, AsRefStr, EnumString, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum FiatProviderName { + Mercuryo, + Transak, + MoonPay, + Banxa, + Paybis, + Flashnet, +} + +impl FiatProviderName { + pub fn id(&self) -> &str { + self.as_ref() + } + + pub fn name(&self) -> &'static str { + match self { + FiatProviderName::Mercuryo => "Mercuryo", + FiatProviderName::Transak => "Transak", + FiatProviderName::MoonPay => "MoonPay", + FiatProviderName::Banxa => "Banxa", + FiatProviderName::Paybis => "Paybis", + FiatProviderName::Flashnet => "Cash App", + } + } + pub fn as_fiat_provider(&self) -> FiatProvider { + FiatProvider { + id: *self, + name: self.name().to_owned(), + image_url: None, + priority: None, + threshold_bps: None, + enabled: true, + buy_enabled: true, + sell_enabled: true, + payment_methods: vec![], + } + } + + pub fn all() -> Vec { + vec![Self::Mercuryo, Self::Transak, Self::MoonPay, Self::Banxa, Self::Paybis, Self::Flashnet] + } +} + +#[derive(Debug, Clone)] +pub struct FiatProviderCountry { + pub provider: FiatProviderName, + pub alpha2: String, + pub is_allowed: bool, +} diff --git a/core/crates/primitives/src/fiat_provider_id.rs b/core/crates/primitives/src/fiat_provider_id.rs new file mode 100644 index 0000000000..a3c8931445 --- /dev/null +++ b/core/crates/primitives/src/fiat_provider_id.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +use crate::FiatProviderName; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FiatProviderId { + pub provider: FiatProviderName, + pub symbol: String, +} + +impl FiatProviderId { + pub fn new(provider: impl Into, symbol: impl Into) -> Self { + Self { + provider: provider.into(), + symbol: symbol.into(), + } + } + + pub fn id(&self) -> String { + format!("{}_{}", self.provider.id(), self.symbol).to_lowercase() + } +} diff --git a/core/crates/primitives/src/fiat_quote.rs b/core/crates/primitives/src/fiat_quote.rs new file mode 100644 index 0000000000..bb75bdebc6 --- /dev/null +++ b/core/crates/primitives/src/fiat_quote.rs @@ -0,0 +1,118 @@ +use crate::{Asset, FiatQuoteType, PaymentType, fiat_provider::FiatProvider}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct FiatQuote { + pub id: String, + #[typeshare(skip)] + pub asset: Asset, + pub provider: FiatProvider, + #[serde(rename = "type")] + pub quote_type: FiatQuoteType, + pub fiat_amount: f64, + pub fiat_currency: String, + pub crypto_amount: f64, + #[typeshare(skip)] + pub value: String, + #[typeshare(skip)] + pub latency: u64, + pub payment_methods: Vec, +} + +impl FiatQuote { + pub fn new( + id: String, + asset: Asset, + provider: FiatProvider, + quote_type: FiatQuoteType, + fiat_amount: f64, + fiat_currency: String, + crypto_amount: f64, + value: String, + latency: u64, + payment_methods: Vec, + ) -> Self { + Self { + id, + asset, + provider, + quote_type, + fiat_amount, + fiat_currency, + crypto_amount, + value, + latency, + payment_methods, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +pub struct FiatQuotes { + pub quotes: Vec, + #[typeshare(skip)] + pub errors: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct FiatQuoteUrl { + pub redirect_url: String, + #[serde(skip_serializing)] + #[typeshare(skip)] + pub provider_transaction_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FiatQuoteError { + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + pub error: String, +} + +impl FiatQuoteError { + pub fn new(provider: Option, error: String) -> Self { + Self { provider, error } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FiatQuoteResponse { + pub quote_id: String, + pub fiat_amount: f64, + pub crypto_amount: f64, + pub payment_methods: Vec, +} + +impl FiatQuoteResponse { + pub fn new(quote_id: String, fiat_amount: f64, crypto_amount: f64) -> Self { + Self { + quote_id, + fiat_amount, + crypto_amount, + payment_methods: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FiatAssetSymbol { + pub symbol: String, + pub network: Option, +} + +#[derive(Debug, Clone)] +pub struct FiatQuoteUrlData { + pub quote: FiatQuote, + pub asset_symbol: FiatAssetSymbol, + pub wallet_address: String, + pub ip_address: String, + pub locale: String, +} diff --git a/core/crates/primitives/src/fiat_quote_request.rs b/core/crates/primitives/src/fiat_quote_request.rs new file mode 100644 index 0000000000..8ea03e8fc6 --- /dev/null +++ b/core/crates/primitives/src/fiat_quote_request.rs @@ -0,0 +1,21 @@ +use crate::{AssetId, FiatQuoteType}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct FiatQuoteRequest { + #[typeshare(skip)] + pub asset_id: AssetId, + #[serde(rename = "type")] + #[typeshare(skip)] + pub quote_type: FiatQuoteType, + pub amount: f64, + pub currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[typeshare(skip)] + pub provider_id: Option, + #[typeshare(skip)] + pub ip_address: String, +} diff --git a/core/crates/primitives/src/fiat_rate.rs b/core/crates/primitives/src/fiat_rate.rs new file mode 100644 index 0000000000..6db211b21a --- /dev/null +++ b/core/crates/primitives/src/fiat_rate.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[typeshare(swift = "Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FiatRate { + pub symbol: String, + pub rate: f64, +} + +impl FiatRate { + pub fn multiplier(&self, base: f64) -> f64 { + self.rate * base + } +} diff --git a/core/crates/primitives/src/fiat_transaction.rs b/core/crates/primitives/src/fiat_transaction.rs new file mode 100644 index 0000000000..31df1e44dd --- /dev/null +++ b/core/crates/primitives/src/fiat_transaction.rs @@ -0,0 +1,103 @@ +use crate::{Asset, AssetId, FiatProviderName, FiatQuoteUrlData}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct FiatTransaction { + pub id: String, + pub asset_id: AssetId, + pub transaction_type: FiatQuoteType, + pub provider: FiatProviderName, + #[typeshare(skip)] + #[serde(skip_serializing)] + pub provider_transaction_id: Option, + pub status: FiatTransactionStatus, + #[typeshare(skip)] + #[serde(skip_serializing)] + pub country: Option, + pub fiat_amount: f64, + pub fiat_currency: String, + pub value: String, + #[typeshare(skip)] + #[serde(skip_serializing)] + pub transaction_hash: Option, + pub created_at: DateTime, + #[typeshare(skip)] + #[serde(skip_serializing)] + pub updated_at: DateTime, +} + +impl FiatTransaction { + pub fn new_pending(data: &FiatQuoteUrlData, country: Option, provider_transaction_id: Option) -> Self { + let quote = &data.quote; + let now = Utc::now(); + + Self { + id: quote.id.clone(), + asset_id: quote.asset.id.clone(), + transaction_type: quote.quote_type.clone(), + provider: quote.provider.id, + provider_transaction_id, + status: FiatTransactionStatus::Pending, + country, + fiat_amount: quote.fiat_amount, + fiat_currency: quote.fiat_currency.clone(), + value: quote.value.clone(), + transaction_hash: None, + created_at: now, + updated_at: now, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FiatTransactionUpdate { + pub transaction_id: String, + pub provider_transaction_id: Option, + pub status: FiatTransactionStatus, + pub transaction_hash: Option, + pub fiat_amount: Option, + pub fiat_currency: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct FiatTransactionData { + pub transaction: FiatTransaction, + pub details_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct FiatTransactionAssetData { + pub transaction: FiatTransaction, + pub asset: Asset, + pub details_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum FiatTransactionStatus { + Complete, + Pending, + Failed, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum FiatQuoteType { + Buy, + Sell, +} diff --git a/core/crates/primitives/src/gas_price_type.rs b/core/crates/primitives/src/gas_price_type.rs new file mode 100644 index 0000000000..252e24a655 --- /dev/null +++ b/core/crates/primitives/src/gas_price_type.rs @@ -0,0 +1,105 @@ +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum GasPriceType { + Regular { gas_price: BigInt }, + Eip1559 { gas_price: BigInt, priority_fee: BigInt }, + Solana { gas_price: BigInt, priority_fee: BigInt, unit_price: BigInt }, +} + +impl GasPriceType { + pub fn regular>(gas_price: T) -> Self { + Self::Regular { gas_price: gas_price.into() } + } + + pub fn eip1559, U: Into>(gas_price: T, priority_fee: U) -> Self { + Self::Eip1559 { + gas_price: gas_price.into(), + priority_fee: priority_fee.into(), + } + } + + pub fn solana, U: Into, V: Into>(gas_price: T, priority_fee: U, unit_price: V) -> Self { + Self::Solana { + gas_price: gas_price.into(), + priority_fee: priority_fee.into(), + unit_price: unit_price.into(), + } + } + + pub fn gas_price(&self) -> BigInt { + match self { + GasPriceType::Regular { gas_price } => gas_price.clone(), + GasPriceType::Eip1559 { gas_price, .. } => gas_price.clone(), + GasPriceType::Solana { gas_price, .. } => gas_price.clone(), + } + } + + pub fn priority_fee(&self) -> BigInt { + match self { + GasPriceType::Regular { .. } => BigInt::from(0), + GasPriceType::Eip1559 { priority_fee, .. } => priority_fee.clone(), + GasPriceType::Solana { priority_fee, .. } => priority_fee.clone(), + } + } + + pub fn unit_price(&self) -> BigInt { + match self { + GasPriceType::Regular { .. } => BigInt::from(0), + GasPriceType::Eip1559 { .. } => BigInt::from(0), + GasPriceType::Solana { unit_price, .. } => unit_price.clone(), + } + } + + pub fn total_fee(&self) -> BigInt { + self.gas_price() + self.priority_fee() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gas_price() { + let regular = GasPriceType::regular(BigInt::from(1000u64)); + assert_eq!(regular.gas_price(), BigInt::from(1000u64)); + + let eip1559 = GasPriceType::eip1559(BigInt::from(2000u64), BigInt::from(500u64)); + assert_eq!(eip1559.gas_price(), BigInt::from(2000u64)); + } + + #[test] + fn priority_fee() { + let regular = GasPriceType::regular(BigInt::from(1000u64)); + assert_eq!(regular.priority_fee(), BigInt::from(0)); + + let eip1559 = GasPriceType::eip1559(BigInt::from(2000u64), BigInt::from(500u64)); + assert_eq!(eip1559.priority_fee(), BigInt::from(500u64)); + } + + #[test] + fn unit_price() { + let regular = GasPriceType::regular(BigInt::from(1000u64)); + assert_eq!(regular.unit_price(), BigInt::from(0)); + + let eip1559 = GasPriceType::eip1559(BigInt::from(2000u64), BigInt::from(500u64)); + assert_eq!(eip1559.unit_price(), BigInt::from(0)); + + let solana = GasPriceType::solana(BigInt::from(5000u64), BigInt::from(1000u64), BigInt::from(200u64)); + assert_eq!(solana.unit_price(), BigInt::from(200u64)); + } + + #[test] + fn total_fee() { + let regular = GasPriceType::regular(BigInt::from(1000u64)); + assert_eq!(regular.total_fee(), BigInt::from(1000u64)); + + let eip1559 = GasPriceType::eip1559(BigInt::from(2000u64), BigInt::from(500u64)); + assert_eq!(eip1559.total_fee(), BigInt::from(2500u64)); + + let solana = GasPriceType::solana(BigInt::from(5000u64), BigInt::from(1000u64), BigInt::from(200u64)); + assert_eq!(solana.total_fee(), BigInt::from(6000u64)); // 5000 + 1000 + } +} diff --git a/core/crates/primitives/src/gorush.rs b/core/crates/primitives/src/gorush.rs new file mode 100644 index 0000000000..895085446b --- /dev/null +++ b/core/crates/primitives/src/gorush.rs @@ -0,0 +1,134 @@ +use crate::{Device, PushNotification}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GorushNotifications { + pub notifications: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GorushNotification { + pub tokens: Vec, + pub platform: i32, + pub title: String, + pub message: String, + pub topic: Option, + pub data: PushNotification, + pub device_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub dry_run: Option, +} + +impl GorushNotification { + pub fn from_device(device: Device, title: String, message: String, data: PushNotification) -> Option { + if !device.can_receive_push_notification() { + return None; + } + Some(Self { + tokens: vec![device.token.clone()], + platform: device.platform.as_i32(), + title, + message, + topic: None, + data, + device_id: device.id, + dry_run: None, + }) + } + + pub fn for_token_validation(token: String, platform: i32) -> Self { + Self { + tokens: vec![token], + platform, + title: String::new(), + message: String::new(), + topic: None, + data: PushNotification { + notification_type: crate::PushNotificationTypes::Test, + data: None, + }, + device_id: String::new(), + dry_run: Some(true), + } + } + + pub fn with_topic(mut self, topic: Option) -> Self { + self.topic = topic; + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FailedNotification { + pub notification: GorushNotification, + pub error: PushErrorLog, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PushErrorLog { + pub token: String, + pub error: String, +} + +impl PushErrorLog { + pub fn new(token: String, error: String) -> Self { + Self { token, error } + } + + pub fn is_device_invalid(&self) -> bool { + const ERROR_PATTERNS: &[&str] = &[ + "notregistered", + "unregistered", + "invalidregistration", + "baddevicetoken", + "devicetokennotfortopic", + "mismatchsenderid", + "requested entity was not found", + ]; + + let error_lower = self.error.to_lowercase(); + ERROR_PATTERNS.iter().any(|pattern| error_lower.contains(pattern)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_push_data() -> PushNotification { + PushNotification { + notification_type: crate::PushNotificationTypes::Test, + data: None, + } + } + + #[test] + fn from_device() { + let device = Device::mock(); + let result = GorushNotification::from_device(device.clone(), "title".to_string(), "msg".to_string(), mock_push_data()); + assert!(result.is_some()); + + let notification = result.unwrap(); + assert_eq!(notification.tokens, vec!["test-token-123"]); + assert_eq!(notification.title, "title"); + assert_eq!(notification.message, "msg"); + assert_eq!(notification.device_id, "test-device-id"); + + let disabled = Device::mock_with(false, "token".to_string(), None); + assert!(GorushNotification::from_device(disabled, "t".to_string(), "m".to_string(), mock_push_data()).is_none()); + + let empty_token = Device::mock_with(true, "".to_string(), None); + assert!(GorushNotification::from_device(empty_token, "t".to_string(), "m".to_string(), mock_push_data()).is_none()); + } + + #[test] + fn is_device_invalid() { + assert!(PushErrorLog::new("test".to_string(), "Unregistered".to_string()).is_device_invalid()); + assert!(PushErrorLog::new("test".to_string(), "InvalidRegistration".to_string()).is_device_invalid()); + assert!(PushErrorLog::new("test".to_string(), "BadDeviceToken".to_string()).is_device_invalid()); + assert!(PushErrorLog::new("test".to_string(), "Devicetokennotfortopic".to_string()).is_device_invalid()); + assert!(PushErrorLog::new("test".to_string(), "Mismatchsenderid".to_string()).is_device_invalid()); + + assert!(!PushErrorLog::new("".to_string(), "Good".to_string()).is_device_invalid()); + } +} diff --git a/core/crates/primitives/src/graphql.rs b/core/crates/primitives/src/graphql.rs new file mode 100644 index 0000000000..7e06b4d2fa --- /dev/null +++ b/core/crates/primitives/src/graphql.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphqlRequest { + pub operation_name: String, + pub variables: HashMap, + pub query: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphqlData { + pub data: Option, + pub errors: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphqlError { + pub message: String, +} diff --git a/core/crates/primitives/src/hex.rs b/core/crates/primitives/src/hex.rs new file mode 100644 index 0000000000..ea47dd0dae --- /dev/null +++ b/core/crates/primitives/src/hex.rs @@ -0,0 +1,87 @@ +use std::borrow::Cow; +use std::fmt; + +#[derive(Debug, Clone)] +pub struct HexError(String); + +impl fmt::Display for HexError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for HexError {} + +impl From for HexError { + fn from(err: hex::FromHexError) -> Self { + Self(err.to_string()) + } +} + +pub fn encode_with_0x(data: &[u8]) -> String { + format!("0x{}", hex::encode(data)) +} + +pub fn parse_u64_from_hex_or_decimal(value: &str) -> Result { + let number_text = value.trim(); + if let Some(hex_number_text) = number_text.strip_prefix("0x").or_else(|| number_text.strip_prefix("0X")) { + return u64::from_str_radix(hex_number_text, 16).map_err(|error| HexError(error.to_string())); + } + number_text.parse::().map_err(|error| HexError(error.to_string())) +} + +pub fn decode_hex_utf8(value: &str) -> Option { + let bytes = decode_hex(value).ok()?; + String::from_utf8(bytes).ok() +} + +pub fn decode_hex(value: &str) -> Result, HexError> { + let stripped = value.trim().strip_prefix("0x").unwrap_or(value.trim()); + if stripped.is_empty() { + return Ok(vec![]); + } + let normalized: Cow = if stripped.len() % 2 == 1 { + Cow::Owned(format!("0{stripped}")) + } else { + Cow::Borrowed(stripped) + }; + Ok(hex::decode(&*normalized)?) +} + +pub fn decode_hex_array(value: &str) -> Result<[u8; N], HexError> { + let bytes = decode_hex(value)?; + let length = bytes.len(); + bytes.try_into().map_err(|_| HexError(format!("hex value must be {N} bytes, got {length}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_hex_trims_and_strips_prefix() { + let bytes = decode_hex(" 0x0a0b ").expect("decode"); + assert_eq!(bytes, vec![0x0a, 0x0b]); + } + + #[test] + fn decode_hex_pads_odd_length() { + let bytes = decode_hex("0xa").expect("decode"); + assert_eq!(bytes, vec![0x0a]); + } + + #[test] + fn decode_hex_array_validates_length() { + assert_eq!(decode_hex_array::<2>("0x0a0b").expect("decode"), [0x0a, 0x0b]); + assert_eq!(decode_hex_array::<2>("a0b").expect("decode"), [0x0a, 0x0b]); + assert!(decode_hex_array::<2>("0x0a").is_err()); + } + + #[test] + fn test_parse_u64_from_hex_or_decimal() { + assert_eq!(parse_u64_from_hex_or_decimal("0x1f").unwrap(), 31); + assert_eq!(parse_u64_from_hex_or_decimal("0X2A").unwrap(), 42); + assert_eq!(parse_u64_from_hex_or_decimal("255").unwrap(), 255); + assert!(parse_u64_from_hex_or_decimal("0xZZ").is_err()); + } +} diff --git a/core/crates/primitives/src/image_formatter.rs b/core/crates/primitives/src/image_formatter.rs new file mode 100644 index 0000000000..d7d72ba606 --- /dev/null +++ b/core/crates/primitives/src/image_formatter.rs @@ -0,0 +1,72 @@ +use crate::AssetId; + +pub struct ImageFormatter {} + +impl ImageFormatter { + pub fn get_asset_url(url: &str, chain: &str, token_id: Option<&str>) -> String { + match token_id { + Some(token_id) => format!("{url}/blockchains/{chain}/assets/{token_id}/logo.png"), + None => format!("{url}/blockchains/{chain}/logo.png"), + } + } + + pub fn get_asset_url_for_asset_id(url: &str, asset_id: AssetId) -> String { + Self::get_asset_url(url, asset_id.chain.as_ref(), asset_id.token_id.as_deref()) + } + + pub fn get_validator_url(url: &str, chain: &str, id: &str) -> String { + format!("{url}/blockchains/{chain}/validators/{id}/logo.png") + } + + pub fn get_nft_asset_url(url: &str, id: &str) -> String { + format!("{url}/assets/{id}/preview") + } + + pub fn get_nft_asset_resource_url(url: &str, id: &str) -> String { + format!("{url}/assets/{id}/resource") + } + + pub fn get_nft_collection_url(url: &str, id: &str) -> String { + format!("{url}/collections/{id}/preview") + } +} +#[cfg(test)] +mod tests { + const URL: &str = "https://example.com"; + + use crate::Chain; + + use super::*; + + #[test] + fn test_get_asset_url() { + assert_eq!( + ImageFormatter::get_asset_url_for_asset_id(URL, AssetId::from_chain(Chain::Ethereum)), + "https://example.com/blockchains/ethereum/logo.png" + ); + + assert_eq!( + ImageFormatter::get_asset_url_for_asset_id(URL, AssetId::from(Chain::Ethereum, Some(String::from("1")))), + "https://example.com/blockchains/ethereum/assets/1/logo.png" + ); + } + + #[test] + fn test_get_validator_url() { + assert_eq!( + ImageFormatter::get_validator_url(URL, Chain::Ethereum.as_ref(), "1"), + "https://example.com/blockchains/ethereum/validators/1/logo.png" + ); + } + + #[test] + fn test_get_nft_urls() { + let id = "ethereum_0xabc::1"; + assert_eq!(ImageFormatter::get_nft_asset_url(URL, id), "https://example.com/assets/ethereum_0xabc::1/preview"); + assert_eq!(ImageFormatter::get_nft_asset_resource_url(URL, id), "https://example.com/assets/ethereum_0xabc::1/resource"); + assert_eq!( + ImageFormatter::get_nft_collection_url(URL, "ethereum_0xabc"), + "https://example.com/collections/ethereum_0xabc/preview" + ); + } +} diff --git a/core/crates/primitives/src/ip_usage_type.rs b/core/crates/primitives/src/ip_usage_type.rs new file mode 100644 index 0000000000..4eed611089 --- /dev/null +++ b/core/crates/primitives/src/ip_usage_type.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use strum::{AsRefStr, Display}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, AsRefStr, Display)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum IpUsageType { + DataCenter, + Hosting, + Isp, + Mobile, + Business, + Education, + Government, + #[default] + Unknown, +} + +impl IpUsageType { + pub fn is_datacenter(&self) -> bool { + matches!(self, Self::DataCenter | Self::Hosting) + } +} + +impl FromStr for IpUsageType { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + let lower = s.to_lowercase(); + let result = match lower.as_str() { + "datacenter" | "data_center" => Self::DataCenter, + "hosting" => Self::Hosting, + "isp" => Self::Isp, + "mobile" => Self::Mobile, + "business" => Self::Business, + "education" => Self::Education, + "government" => Self::Government, + _ if lower.contains("data center") || lower.contains("web hosting") || lower.contains("transit") => Self::DataCenter, + _ if lower.contains("hosting") => Self::Hosting, + _ if lower.contains("mobile") => Self::Mobile, + _ if lower.contains("isp") || lower.contains("fixed line") => Self::Isp, + _ if lower.contains("commercial") || lower.contains("business") => Self::Business, + _ if lower.contains("university") || lower.contains("college") || lower.contains("school") || lower.contains("education") => Self::Education, + _ if lower.contains("government") || lower.contains("military") => Self::Government, + _ => Self::Unknown, + }; + Ok(result) + } +} diff --git a/core/crates/primitives/src/job_configuration.rs b/core/crates/primitives/src/job_configuration.rs new file mode 100644 index 0000000000..847590f827 --- /dev/null +++ b/core/crates/primitives/src/job_configuration.rs @@ -0,0 +1,22 @@ +use crate::Chain; + +const INITIAL_CAP_MS: u32 = 5_000; +const MAX_INTERVAL_MS: u32 = 15_000; +const STEP_FACTOR: f32 = 1.1; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct JobConfiguration { + pub initial_interval_ms: u32, + pub max_interval_ms: u32, + pub step_factor: f32, +} + +impl JobConfiguration { + pub fn transaction_state(chain: Chain) -> Self { + Self { + initial_interval_ms: chain.block_time().clamp(1, INITIAL_CAP_MS), + max_interval_ms: MAX_INTERVAL_MS, + step_factor: STEP_FACTOR, + } + } +} diff --git a/core/crates/primitives/src/json_rpc.rs b/core/crates/primitives/src/json_rpc.rs new file mode 100644 index 0000000000..15e7a731c0 --- /dev/null +++ b/core/crates/primitives/src/json_rpc.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResult { + pub result: T, +} diff --git a/core/crates/primitives/src/known_assets.rs b/core/crates/primitives/src/known_assets.rs new file mode 100644 index 0000000000..f4bb02cc51 --- /dev/null +++ b/core/crates/primitives/src/known_assets.rs @@ -0,0 +1,109 @@ +use std::sync::LazyLock; + +use crate::{Asset, AssetId, AssetType, Chain, asset_constants::*}; + +const USDT_NAME: &str = "Tether"; +const USDT_SYMBOL: &str = "USDT"; +const USDC_NAME: &str = "USDC"; +pub const USDC_SYMBOL: &str = "USDC"; +const WBTC_SYMBOL: &str = "WBTC"; +const WBTC_NAME: &str = "Wrapped BTC"; +const DAI_NAME: &str = "Dai Stablecoin"; +const DAI_SYMBOL: &str = "DAI"; +const WETH_NAME: &str = "Wrapped Ether"; +const WETH_SYMBOL: &str = "WETH"; +const CBBTC_NAME: &str = "Coinbase BTC"; +const CBBTC_SYMBOL: &str = "cbBTC"; +const USDS_NAME: &str = "USDS Stablecoin"; +const USDS_SYMBOL: &str = "USDS"; + +fn token_asset(chain: Chain, token_id: &str, name: &str, symbol: &str, decimals: i32, asset_type: AssetType) -> Asset { + Asset::new(AssetId::from_token(chain, token_id), name.to_string(), symbol.to_string(), decimals, asset_type) +} + +pub static ETHEREUM_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); +pub static ETHEREUM_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static ETHEREUM_WBTC: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_WBTC_TOKEN_ID, WBTC_NAME, WBTC_SYMBOL, 8, AssetType::ERC20)); +pub static ETHEREUM_DAI: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_DAI_TOKEN_ID, DAI_NAME, DAI_SYMBOL, 18, AssetType::ERC20)); +pub static ETHEREUM_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static ETHEREUM_USDS: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_USDS_TOKEN_ID, USDS_NAME, USDS_SYMBOL, 18, AssetType::ERC20)); +pub static ETHEREUM_STETH: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_STETH_TOKEN_ID, "stETH", "stETH", 18, AssetType::ERC20)); +pub static ETHEREUM_CBBTC: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_CBBTC_TOKEN_ID, CBBTC_NAME, CBBTC_SYMBOL, 8, AssetType::ERC20)); +pub static ETHEREUM_FLIP: LazyLock = LazyLock::new(|| token_asset(Chain::Ethereum, ETHEREUM_FLIP_TOKEN_ID, "Chainflip", "FLIP", 18, AssetType::ERC20)); + +pub static ARBITRUM_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Arbitrum, ARBITRUM_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static ARBITRUM_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Arbitrum, ARBITRUM_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static ARBITRUM_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Arbitrum, ARBITRUM_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static BASE_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Base, BASE_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static BASE_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Base, BASE_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static BASE_CBBTC: LazyLock = LazyLock::new(|| token_asset(Chain::Base, BASE_CBBTC_TOKEN_ID, CBBTC_NAME, CBBTC_SYMBOL, 8, AssetType::ERC20)); +pub static BASE_USDS: LazyLock = LazyLock::new(|| token_asset(Chain::Base, BASE_USDS_TOKEN_ID, USDS_NAME, USDS_SYMBOL, 18, AssetType::ERC20)); +pub static BASE_WBTC: LazyLock = LazyLock::new(|| token_asset(Chain::Base, BASE_WBTC_TOKEN_ID, WBTC_NAME, WBTC_SYMBOL, 8, AssetType::ERC20)); + +pub static BLAST_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Blast, BLAST_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); + +pub static LINEA_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Linea, LINEA_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static LINEA_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Linea, LINEA_USDC_E_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static LINEA_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Linea, LINEA_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static OPTIMISM_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Optimism, OPTIMISM_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static OPTIMISM_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Optimism, OPTIMISM_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static OPTIMISM_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Optimism, OPTIMISM_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static POLYGON_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Polygon, POLYGON_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static POLYGON_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Polygon, POLYGON_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static POLYGON_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Polygon, POLYGON_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static ZKSYNC_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::ZkSync, ZKSYNC_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static ZKSYNC_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::ZkSync, ZKSYNC_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static WORLD_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::World, WORLD_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); + +pub static SMARTCHAIN_ETH: LazyLock = LazyLock::new(|| token_asset(Chain::SmartChain, SMARTCHAIN_ETH_TOKEN_ID, "Binance-Peg Ethereum", "ETH", 18, AssetType::ERC20)); +pub static SMARTCHAIN_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::SmartChain, SMARTCHAIN_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 18, AssetType::BEP20)); +pub static SMARTCHAIN_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::SmartChain, SMARTCHAIN_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 18, AssetType::BEP20)); +pub static SMARTCHAIN_WBTC: LazyLock = LazyLock::new(|| token_asset(Chain::SmartChain, SMARTCHAIN_WBTC_TOKEN_ID, WBTC_NAME, WBTC_SYMBOL, 8, AssetType::BEP20)); + +pub static AVALANCHE_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::AvalancheC, AVALANCHE_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); +pub static AVALANCHE_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::AvalancheC, AVALANCHE_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); + +pub static INK_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Ink, INK_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static INK_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Ink, INK_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static UNICHAIN_WETH: LazyLock = LazyLock::new(|| token_asset(Chain::Unichain, UNICHAIN_WETH_TOKEN_ID, WETH_NAME, WETH_SYMBOL, 18, AssetType::ERC20)); +pub static UNICHAIN_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Unichain, UNICHAIN_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static UNICHAIN_DAI: LazyLock = LazyLock::new(|| token_asset(Chain::Unichain, UNICHAIN_DAI_TOKEN_ID, DAI_NAME, DAI_SYMBOL, 18, AssetType::ERC20)); + +pub static MONAD_MON: LazyLock = LazyLock::new(|| Asset::from_chain(Chain::Monad)); +pub static MONAD_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Monad, MONAD_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static MONAD_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Monad, MONAD_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static HYPERCORE_HYPE: LazyLock = LazyLock::new(|| Asset::from_chain(Chain::HyperCore)); +pub static HYPERCORE_SPOT_HYPE: LazyLock = + LazyLock::new(|| Asset::new(HYPERCORE_SPOT_HYPE_ASSET_ID.clone(), "Hyperliquid".to_string(), "HYPE".to_string(), 8, AssetType::TOKEN)); +pub static HYPERCORE_SPOT_USDC: LazyLock = + LazyLock::new(|| Asset::new(HYPERCORE_SPOT_USDC_ASSET_ID.clone(), USDC_NAME.to_string(), USDC_SYMBOL.to_string(), 8, AssetType::TOKEN)); +pub static HYPERCORE_SPOT_UBTC: LazyLock = + LazyLock::new(|| Asset::new(HYPERCORE_SPOT_UBTC_ASSET_ID.clone(), "Bitcoin".to_string(), "UBTC".to_string(), 10, AssetType::TOKEN)); + +pub static HYPEREVM_HYPE: LazyLock = LazyLock::new(|| Asset::from_chain(Chain::Hyperliquid)); +pub static HYPEREVM_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Hyperliquid, HYPEREVM_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::ERC20)); +pub static HYPEREVM_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Hyperliquid, HYPEREVM_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static PLASMA_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Plasma, PLASMA_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::ERC20)); + +pub static SOLANA_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Solana, SOLANA_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::SPL)); +pub static SOLANA_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Solana, SOLANA_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::SPL)); +pub static SOLANA_USDS: LazyLock = LazyLock::new(|| token_asset(Chain::Solana, SOLANA_USDS_TOKEN_ID, USDS_NAME, USDS_SYMBOL, 6, AssetType::SPL)); +pub static SOLANA_WBTC: LazyLock = LazyLock::new(|| token_asset(Chain::Solana, SOLANA_WBTC_TOKEN_ID, WBTC_NAME, WBTC_SYMBOL, 8, AssetType::SPL)); +pub static SOLANA_CBBTC: LazyLock = LazyLock::new(|| token_asset(Chain::Solana, SOLANA_CBBTC_TOKEN_ID, CBBTC_NAME, CBBTC_SYMBOL, 8, AssetType::SPL)); +pub static SOLANA_JITO_SOL: LazyLock = LazyLock::new(|| token_asset(Chain::Solana, SOLANA_JITO_SOL_TOKEN_ID, "Jito Staked SOL", "JitoSOL", 9, AssetType::SPL)); + +pub static SUI_USDC: LazyLock = LazyLock::new(|| token_asset(Chain::Sui, SUI_USDC_TOKEN_ID, USDC_NAME, USDC_SYMBOL, 6, AssetType::TOKEN)); +pub static SUI_SBUSDT: LazyLock = LazyLock::new(|| token_asset(Chain::Sui, SUI_SBUSDT_TOKEN_ID, "Sui Bridged USDT", "sbUSDT", 6, AssetType::TOKEN)); +pub static SUI_WAL: LazyLock = LazyLock::new(|| token_asset(Chain::Sui, SUI_WAL_TOKEN_ID, "Walrus", "WAL", 9, AssetType::TOKEN)); + +pub static THORCHAIN_TCY: LazyLock = LazyLock::new(|| token_asset(Chain::Thorchain, THORCHAIN_TCY_TOKEN_ID, "TCY", "TCY", 8, AssetType::TOKEN)); + +pub static TRON_USDT: LazyLock = LazyLock::new(|| token_asset(Chain::Tron, TRON_USDT_TOKEN_ID, USDT_NAME, USDT_SYMBOL, 6, AssetType::TRC20)); diff --git a/core/crates/primitives/src/latency_type.rs b/core/crates/primitives/src/latency_type.rs new file mode 100644 index 0000000000..d83cb664b5 --- /dev/null +++ b/core/crates/primitives/src/latency_type.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum LatencyType { + Fast, + Normal, + Slow, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] + +pub struct Latency { + pub latency_type: LatencyType, + pub value: f64, +} diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs new file mode 100644 index 0000000000..2fcc692236 --- /dev/null +++ b/core/crates/primitives/src/lib.rs @@ -0,0 +1,328 @@ +// lib.rs + +pub type UInt64 = u64; + +#[macro_use] +pub mod string_serde; + +pub mod localize; +pub use self::localize::Localize; + +pub mod auth; +pub use self::auth::{AuthMessage, AuthNonce, AuthPayload, AuthenticatedRequest}; +pub mod app_constants; +pub use self::app_constants::{GEM_ANDROID_PACKAGE_ID, GEM_IOS_BUNDLE_ID}; +pub mod auth_status; +pub use self::auth_status::AuthStatus; +pub mod chain; +pub use self::chain::Chain; +pub mod chain_request; +pub use self::chain_request::{ChainRequest, ChainRequestProtocol, ChainRequestType}; +pub mod chain_config; +pub mod chain_stake; +pub use self::chain_stake::StakeChain; +pub mod chain_nft; +pub use self::chain_nft::NFTChain; +pub mod chain_type; +pub use self::chain_type::ChainType; +pub mod chain_transaction_timeout; +pub use self::chain_transaction_timeout::{chain_transaction_timeout, swap_transaction_timeout}; +pub mod chain_evm; +pub use self::chain_evm::EVMChain; +pub mod chain_bitcoin; +pub use self::chain_bitcoin::BitcoinChain; +pub mod name; +pub use self::name::NameProvider; +pub mod node; +pub use self::node::{Node, NodeType}; +pub mod node_status; +pub use self::node_status::NodeStatus; +pub mod node_sync_status; +pub use self::node_sync_status::{NodeStatusState, NodeSyncStatus}; +pub mod latency_type; +pub use self::latency_type::{Latency, LatencyType}; +pub mod price; +pub use self::price::Price; +pub mod price_config; +pub use self::price_config::PriceConfig; +pub mod price_data; +pub use self::price_data::PriceData; +pub mod price_provider; +pub use self::price_provider::PriceProvider; +pub mod price_id; +pub use self::price_id::PriceId; +pub mod asset; +pub mod config; +pub use self::config::{ConfigResponse, ConfigVersions, Release, SwapConfig}; +pub mod config_key; +pub use self::config_key::ConfigKey; +pub mod config_param_key; +pub use self::config_param_key::ConfigParamKey; +pub mod duration; +pub use self::duration::{DAY, HOUR, MINUTE, SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE, parse_duration}; +pub mod currency; +pub use self::asset::{Asset, AssetVecExt}; +pub mod asset_id; +pub use self::asset_id::{AssetId, AssetIdVecExt, CHAIN_SEPARATOR, TOKEN_ID_SEPARATOR}; +pub use crate::asset::AssetHashSetExt; +pub mod asset_score; +pub use self::asset_score::AssetScore; +pub mod asset_type; +pub use self::asset_type::{AssetSubtype, AssetType}; +pub mod asset_price; +pub use self::asset_price::{AssetMarket, AssetPrice, AssetPrices, AssetPricesRequest, ChartPeriod, ChartTimeframe, ChartValue, Charts}; +pub mod asset_fiat_value; +pub use self::asset_fiat_value::AssetFiatValue; +pub mod total_value_type; +pub use self::total_value_type::TotalValueType; +pub mod asset_price_info; +pub use self::asset_price_info::AssetPriceInfo; +pub mod asset_details; +pub use self::asset_details::{AssetBasic, AssetFull, AssetLink, AssetMarketPrice, AssetPriceMetadata, AssetProperties}; +pub mod asset_constants; +pub mod asset_order; +pub mod contract_constants; +pub mod known_assets; +pub use self::asset_order::AssetOrder; +pub mod fiat_assets; +pub mod fiat_quote; +pub use self::fiat_quote::{FiatAssetSymbol, FiatQuote, FiatQuoteError, FiatQuoteResponse, FiatQuoteUrl, FiatQuoteUrlData, FiatQuotes}; +pub mod fiat_transaction; +pub use self::fiat_assets::FiatAsset; +pub use self::fiat_assets::FiatAssets; +pub use self::fiat_transaction::{FiatQuoteType, FiatTransaction, FiatTransactionAssetData, FiatTransactionData, FiatTransactionStatus, FiatTransactionUpdate}; +pub mod fiat_provider; +pub use self::fiat_provider::{FiatProvider, FiatProviderCountry, FiatProviderName}; +pub mod fiat_quote_request; +pub use self::fiat_quote_request::FiatQuoteRequest; +pub mod fiat_rate; +pub use self::fiat_rate::FiatRate; +pub mod fiat_provider_id; +pub use self::fiat_provider_id::FiatProviderId; +pub mod platform; +pub use self::platform::Platform; +pub mod platform_store; +pub use self::platform_store::PlatformStore; +pub mod payment_type; +pub use self::payment_type::PaymentType; +pub mod contact; +pub use self::contact::Contact; +pub mod device; +pub use self::device::Device; +pub mod device_token; +pub use self::device_token::DeviceToken; +pub mod transaction; +pub use self::transaction::{Transaction, TransactionsResponse}; +pub mod transaction_type; +pub use self::transaction_type::TransactionType; +pub mod time; +pub use self::time::unix_timestamp; +pub mod transaction_state; +pub use self::transaction_state::TransactionState; +pub mod job_configuration; +pub use self::job_configuration::JobConfiguration; +pub mod username_status; +pub use self::username_status::UsernameStatus; +pub mod recent_activity_type; +pub use self::recent_activity_type::RecentActivityType; +pub mod transaction_direction; +pub use self::transaction_direction::TransactionDirection; +pub mod subscription; +pub mod transaction_utxo; +pub use self::subscription::{AddressChains, DeviceSubscription, WalletSubscription, WalletSubscriptionChains, WalletSubscriptionLegacy}; +pub use self::transaction_utxo::TransactionUtxoInput; +pub mod address; +pub use self::address::{Address, AddressError}; +pub mod address_formatter; +pub use self::address_formatter::{AddressFormatStyle, AddressFormatter}; +pub mod address_name; +pub use self::address_name::AddressName; +pub mod verification_status; +pub use self::verification_status::VerificationStatus; +pub mod address_status; +pub use self::address_status::AddressStatus; +pub mod wallet_configuration; +pub use self::wallet_configuration::{WalletConfiguration, WalletConfigurationResult}; +pub mod utxo; +pub use self::utxo::UTXO; +pub mod push_notification; +pub use self::push_notification::{PushNotification, PushNotificationAsset, PushNotificationReward, PushNotificationSupport, PushNotificationTransaction, PushNotificationTypes}; +pub mod gorush; +pub use self::gorush::{FailedNotification, GorushNotification, GorushNotifications, PushErrorLog}; +pub mod scan; +pub use self::scan::{AddressType, ScanAddress, ScanAddressTarget, ScanTransaction, ScanTransactionPayload}; +pub mod hex; +pub use self::hex::{HexError, decode_hex, decode_hex_array}; +pub mod transaction_metadata_types; +pub use self::transaction_metadata_types::{ + TransactionNFTTransferMetadata, TransactionPerpetualMetadata, TransactionResourceTypeMetadata, TransactionSmartContractMetadata, TransactionSwapMetadata, +}; +pub mod wallet_connect_namespace; +pub use self::wallet_connect_namespace::WalletConnectCAIP2; +pub mod wallet_connect; +pub use self::wallet_connect::{WCEthereumTransaction, WCTonMessage, WalletConnectLink, WalletConnectRequest}; +pub mod account; +pub use self::account::Account; +pub mod wallet; +pub use self::wallet::{Wallet, WalletSource}; +pub mod wallet_type; +pub use self::wallet_type::WalletType; +pub mod webhook_kind; +pub use self::webhook_kind::WebhookKind; +pub mod wallet_id; +pub use self::wallet_id::WalletId; +pub mod wallet_connector; +pub use self::wallet_connector::{ + WCPairingProposal, WalletConnection, WalletConnectionEvents, WalletConnectionMethods, WalletConnectionSession, WalletConnectionSessionAppMetadata, + WalletConnectionSessionProposal, WalletConnectionState, WalletConnectionVerificationStatus, +}; +pub mod nft; +pub use self::nft::{MIME_TYPE_PNG, NFTAsset, NFTAssetId, NFTAttribute, NFTAttributeType, NFTCollection, NFTCollectionId, NFTData, NFTImages, NFTResource, NFTType, ReportNft}; +pub mod price_alert; +pub use self::price_alert::{DevicePriceAlert, PriceAlert, PriceAlertDirection, PriceAlertType, PriceAlerts}; +pub mod rewards; +pub use self::rewards::{ReferralCode, ReferralLeader, ReferralLeaderboard, RewardEvent, RewardEventType, RewardLevel, RewardStatus, Rewards}; +pub mod tag; +pub use self::tag::AssetTag; +pub mod chain_cosmos; +pub use self::chain_cosmos::CosmosDenom; +pub mod payment_decoder; +pub use self::payment_decoder::{DecodedLinkType, PaymentURLDecoder}; + +pub const DEFAULT_FIAT_CURRENCY: &str = "USD"; +pub mod image_formatter; +pub use self::image_formatter::ImageFormatter; +pub mod block_explorer; +pub mod explorers; +pub mod validator; +pub use self::validator::StakeValidator; +pub mod solana_nft; +pub use self::solana_nft::SolanaNftStandard; +pub mod solana_token_program; +pub use self::solana_token_program::SolanaTokenProgramId; +pub mod solana_types; +pub use self::solana_types::{SolanaAccountMeta, SolanaInstruction}; +pub mod fee; +pub mod fee_priority_value; +pub mod gas_price_type; +pub use self::fee::{FeePriority, FeeRate, FeeUnitType, GasPriceType}; +pub use self::fee_priority_value::PriorityFeeValue; +pub mod response; +pub use self::response::{ResponseError, ResponseResult}; +pub mod link_type; +pub use self::link_type::LinkType; +pub mod markets; +pub use self::markets::{MarketDominance, Markets, MarketsAssets}; +pub mod diff; +pub use self::diff::Diff; +pub mod priority; +pub use self::priority::{PrioritizedProvider, sort_by_priority_then_amount}; +pub mod swap_provider; +pub use self::swap_provider::SwapProvider; +pub mod swap; +pub mod websocket; +pub use self::websocket::{WebSocketPriceAction, WebSocketPriceActionType, WebSocketPricePayload}; +pub mod stream; +pub use self::stream::{StreamBalanceUpdate, StreamEvent, StreamMessage, StreamMessagePrices, StreamTransactionsUpdate, StreamWalletUpdate, device_stream_channel}; +pub mod asset_balance; +pub use self::asset_balance::{AddressBalances, AssetBalance, Balance}; +pub mod chain_address; +pub use self::chain_address::ChainAddress; +pub mod json_rpc; +pub use self::json_rpc::JsonRpcResult; +pub mod node_config; +pub mod transaction_id; +pub use self::transaction_id::TransactionId; +pub mod asset_address; +pub use self::asset_address::AssetAddress; +pub mod graphql; +pub mod perpetual; +pub use self::perpetual::{ + AccountDataType, CancelOrderData, Perpetual, PerpetualBalance, PerpetualBasic, PerpetualConfirmData, PerpetualDirection, PerpetualMarketData, PerpetualModifyConfirmData, + PerpetualModifyPositionType, PerpetualPositionData, PerpetualPositionsSummary, PerpetualReduceData, PerpetualSearchData, PerpetualType, TPSLOrderData, +}; +pub mod search; +pub use self::search::SearchResponse; +pub mod perpetual_id; +pub use self::perpetual_id::PerpetualId; +pub mod perpetual_provider; +pub use self::perpetual_provider::PerpetualProvider; +pub mod perpetual_position; +pub use self::perpetual_position::{PerpetualMarginType, PerpetualOrderType, PerpetualPosition, PerpetualTriggerOrder}; +pub mod portfolio; +pub use self::portfolio::{ + ChartValuePercentage, PerpetualAccountSummary, PerpetualPortfolio, PerpetualPortfolioTimeframeData, PortfolioAllocation, PortfolioAsset, PortfolioAssets, + PortfolioAssetsRequest, PortfolioChartData, PortfolioChartType, PortfolioData, PortfolioMarginUsage, PortfolioStatistic, PortfolioType, +}; +pub use chrono; +pub mod tpsl_type; +pub use self::tpsl_type::TpslType; +pub mod chart; +pub use self::chart::{ChartCandleStick, ChartDateValue}; +pub mod delegation; +pub use self::delegation::{Delegation, DelegationBase, DelegationState, DelegationValidator}; +pub mod contract_call_data; +pub use self::contract_call_data::ContractCallData; +pub mod stake_type; +pub use self::stake_type::{RedelegateData, Resource, StakeType, TronStakeData, TronUnfreeze, TronVote}; +pub mod stake_provider_type; +pub use self::stake_provider_type::StakeProviderType; +pub mod yield_provider; +pub use self::yield_provider::YieldProvider; +pub mod earn_type; +pub use self::earn_type::EarnType; +pub mod transaction_state_request; +pub use self::transaction_state_request::{TransactionStateRequest, TransactionSwapStateRequest}; +pub mod transaction_update; +pub use self::transaction_update::{TransactionChange, TransactionMetadata, TransactionUpdate}; +pub mod transaction_preload_input; +pub use self::transaction_preload_input::TransactionPreloadInput; +pub mod transaction_fee; +pub use self::transaction_fee::{FeeOption, TransactionFee}; +pub mod transaction_load_metadata; +pub use self::transaction_load_metadata::{HyperliquidOrder, TransactionLoadMetadata}; +pub mod transaction_input_type; +pub use self::transaction_input_type::{SignerInput, TransactionInputType, TransactionLoadData, TransactionLoadInput}; +pub mod transfer_data_extra; +pub use self::transfer_data_extra::TransferDataExtra; +pub mod transaction_data_output; +pub use self::transaction_data_output::{TransferDataOutputAction, TransferDataOutputType}; +pub mod broadcast_options; +pub use self::broadcast_options::BroadcastOptions; +pub mod secure_preferences; +pub use self::secure_preferences::{InMemoryPreferences, Preferences, PreferencesExt, SecurePreferences}; + +pub mod signer_error; +pub use self::signer_error::SignerError; +pub mod date_ext; +pub use self::date_ext::{DurationExt, NaiveDateTimeExt, now}; +pub mod number_incrementer; +pub use self::number_incrementer::NumberIncrementer; +pub mod chain_signer; +pub use self::chain_signer::ChainSigner; +pub mod notification_type; +pub use self::notification_type::NotificationType; +pub mod notification_data; +pub use self::notification_data::{NotificationData, NotificationRewardsMetadata, NotificationRewardsRedeemMetadata}; +pub mod deeplink; +pub use self::deeplink::Deeplink; +pub mod url_action; +pub use self::url_action::UrlAction; +pub mod list_item; +pub use self::list_item::{CoreEmoji, CoreListItem, CoreListItemBadge, CoreListItemIcon}; +pub mod notification; +pub use self::notification::InAppNotification; +pub mod simulation; +pub use self::simulation::{ + SimulationBalanceChange, SimulationHeader, SimulationPayloadField, SimulationPayloadFieldDisplay, SimulationPayloadFieldKind, SimulationPayloadFieldType, SimulationResult, + SimulationSeverity, SimulationWarning, SimulationWarningApproval, SimulationWarningType, promote_single_secondary_payload_field, +}; +pub mod ip_usage_type; +pub use self::ip_usage_type::IpUsageType; +pub mod metrics; +pub use self::metrics::{ConsumerStatus, ParserStatus, ReportedError}; +pub mod value_access; +pub use self::value_access::{JsonDecode, ValueAccess}; + +#[cfg(any(test, feature = "testkit"))] +pub mod testkit; diff --git a/core/crates/primitives/src/link_type.rs b/core/crates/primitives/src/link_type.rs new file mode 100644 index 0000000000..ffad93b629 --- /dev/null +++ b/core/crates/primitives/src/link_type.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +#[derive(Debug, Serialize, Deserialize, Clone, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum LinkType { + X, + Discord, + Reddit, + Telegram, + GitHub, + YouTube, + Facebook, + Website, + Coingecko, + OpenSea, + Instagram, + MagicEden, + CoinMarketCap, + TikTok, +} + +impl LinkType { + pub fn name(&self) -> String { + self.as_ref().to_string() + } +} + +impl LinkType { + pub fn all() -> Vec { + Self::iter().collect::>() + } +} diff --git a/core/crates/primitives/src/list_item.rs b/core/crates/primitives/src/list_item.rs new file mode 100644 index 0000000000..e5b27bb194 --- /dev/null +++ b/core/crates/primitives/src/list_item.rs @@ -0,0 +1,52 @@ +use crate::AssetId; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, AsRefStr, EnumString)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Equatable")] +pub enum CoreEmoji { + Gift, + Gem, + Party, + Warning, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase", tag = "type", content = "value")] +pub enum CoreListItemIcon { + Emoji(CoreEmoji), + Asset(AssetId), + Image(String), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, AsRefStr, EnumString)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Equatable")] +pub enum CoreListItemBadge { + New, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct CoreListItem { + pub id: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub subtitle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subvalue: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub badge: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} diff --git a/core/crates/primitives/src/localize.rs b/core/crates/primitives/src/localize.rs new file mode 100644 index 0000000000..adddd40a1a --- /dev/null +++ b/core/crates/primitives/src/localize.rs @@ -0,0 +1,3 @@ +pub trait Localize { + fn localize(&self, locale: &str) -> String; +} diff --git a/core/crates/primitives/src/markets.rs b/core/crates/primitives/src/markets.rs new file mode 100644 index 0000000000..35f29907f2 --- /dev/null +++ b/core/crates/primitives/src/markets.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::AssetId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +pub struct Markets { + pub market_cap: f32, + pub market_cap_change_percentage_24h: f32, + + pub assets: MarketsAssets, + pub dominance: Vec, + + pub total_volume_24h: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +pub struct MarketsAssets { + pub trending: Vec, + pub gainers: Vec, + pub losers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +pub struct MarketDominance { + pub asset_id: String, + pub dominance: f32, +} diff --git a/core/crates/primitives/src/metrics.rs b/core/crates/primitives/src/metrics.rs new file mode 100644 index 0000000000..de73270c3e --- /dev/null +++ b/core/crates/primitives/src/metrics.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportedError { + pub message: String, + pub count: u64, + pub timestamp: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ParserStatus { + pub errors: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ConsumerStatus { + pub total_processed: u64, + pub total_errors: u64, + pub last_success: Option, + pub last_result: Option, + pub avg_duration: u64, + pub errors: Vec, +} diff --git a/core/crates/primitives/src/name.rs b/core/crates/primitives/src/name.rs new file mode 100644 index 0000000000..845e759e1a --- /dev/null +++ b/core/crates/primitives/src/name.rs @@ -0,0 +1,36 @@ +use crate::chain::Chain; +use serde::Serialize; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Serialize)] +#[typeshare(swift = "Sendable, Hashable")] +pub struct NameRecord { + pub name: String, + pub chain: Chain, + pub address: String, + pub provider: NameProvider, +} + +#[derive(Clone, Debug, PartialEq, Serialize, AsRefStr, EnumString)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum NameProvider { + Ud, + Ens, + Sns, + Ton, + Tree, + Spaceid, + Eths, + Did, + Suins, + Aptos, + Injective, + Icns, + Lens, + Basenames, + Hyperliquid, + AllDomains, +} diff --git a/core/crates/primitives/src/nft.rs b/core/crates/primitives/src/nft.rs new file mode 100644 index 0000000000..2d23060b3d --- /dev/null +++ b/core/crates/primitives/src/nft.rs @@ -0,0 +1,364 @@ +use std::fmt; +use std::{ + hash::{Hash, Hasher}, + str::FromStr, +}; + +pub const MIME_TYPE_PNG: &str = "image/png"; +pub const MIME_TYPE_JPG: &str = "image/jpeg"; +pub const MIME_TYPE_SVG: &str = "image/svg+xml"; + +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +use crate::{AssetLink, CHAIN_SEPARATOR, Chain, TOKEN_ID_SEPARATOR, VerificationStatus}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +pub struct NFTData { + pub collection: NFTCollection, + pub assets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable, Identifiable")] +pub struct NFTCollection { + pub id: NFTCollectionId, + pub name: String, + #[typeshare(skip)] + pub symbol: Option, + pub description: Option, + pub chain: Chain, + pub contract_address: String, + pub images: NFTImages, + // TODO: Remove after all Rust callers and downstream indexes migrate to `status`. + #[serde(default)] + #[typeshare(skip)] + pub is_verified: bool, + pub status: VerificationStatus, + pub links: Vec, +} + +impl Hash for NFTCollection { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + +impl NFTCollection { + pub fn images(&self) -> NFTImages { + let image = format!("{}/{}/collection_original.png", self.chain.as_ref(), self.contract_address); + NFTImages { + preview: NFTResource::from_url(&image), + } + } + + pub fn with_preview_url(self, url: String) -> Self { + Self { + images: NFTImages { + preview: NFTResource { url, ..self.images.preview }, + }, + ..self + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable, Identifiable")] +pub struct NFTAsset { + pub id: NFTAssetId, + pub collection_id: NFTCollectionId, + pub contract_address: Option, + pub token_id: String, + pub token_type: NFTType, + pub name: String, + pub description: Option, + pub chain: Chain, + pub resource: NFTResource, + pub images: NFTImages, + pub attributes: Vec, +} + +impl NFTAsset { + pub fn get_contract_address(&self) -> Result<&str, &'static str> { + self.contract_address.as_deref().ok_or("missing NFT contract address") + } + + pub fn with_urls(self, preview_url: String, resource_url: String) -> Self { + Self { + images: NFTImages { + preview: NFTResource { + url: preview_url, + ..self.images.preview + }, + }, + resource: NFTResource { + url: resource_url, + ..self.resource + }, + ..self + } + } +} + +impl From for NFTAssetId { + fn from(asset: NFTAsset) -> Self { + NFTAssetId::new(asset.chain, asset.contract_address.as_deref().unwrap_or_default(), &asset.token_id) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +pub struct NFTAssetData { + pub collection: NFTCollection, + pub asset: NFTAsset, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct NFTAssetId { + pub chain: Chain, + pub contract_address: String, + pub token_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct NFTCollectionId { + pub chain: Chain, + pub contract_address: String, +} + +impl NFTCollectionId { + pub fn new(chain: Chain, contract_address: &str) -> Self { + Self { + chain, + contract_address: contract_address.to_string(), + } + } +} + +impl NFTAssetId { + pub fn new(chain: Chain, contract_address: &str, token_id: &str) -> Self { + Self { + chain, + contract_address: contract_address.to_string(), + token_id: token_id.to_string(), + } + } + + pub fn get_collection_id(&self) -> NFTCollectionId { + NFTCollectionId::new(self.chain, &self.contract_address) + } +} + +impl fmt::Display for NFTAssetId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}{CHAIN_SEPARATOR}{}{TOKEN_ID_SEPARATOR}{}", self.chain.as_ref(), self.contract_address, self.token_id) + } +} + +impl fmt::Display for NFTCollectionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}{CHAIN_SEPARATOR}{}", self.chain.as_ref(), self.contract_address) + } +} + +impl FromStr for NFTAssetId { + type Err = String; + + fn from_str(s: &str) -> Result { + let (chain, rest) = s.split_once(CHAIN_SEPARATOR).ok_or_else(|| format!("Invalid NFTAssetId: {s}"))?; + let (contract_address, token_id) = rest.split_once(TOKEN_ID_SEPARATOR).ok_or_else(|| format!("Invalid NFTAssetId: {s}"))?; + Ok(Self { + chain: Chain::from_str(chain).map_err(|_| format!("Unknown chain: {chain}"))?, + contract_address: contract_address.to_string(), + token_id: token_id.to_string(), + }) + } +} + +impl FromStr for NFTCollectionId { + type Err = String; + + fn from_str(s: &str) -> Result { + let (chain, contract_address) = s.split_once(CHAIN_SEPARATOR).ok_or_else(|| format!("Invalid NFTCollectionId: {s}"))?; + Ok(Self { + chain: Chain::from_str(chain).map_err(|_| format!("Unknown chain: {chain}"))?, + contract_address: contract_address.to_string(), + }) + } +} + +crate::impl_string_serde!(NFTAssetId); +crate::impl_string_serde!(NFTCollectionId); + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +pub struct NFTResource { + pub url: String, + pub mime_type: String, +} + +impl NFTResource { + pub fn new(url: String, mime_type: String) -> Self { + Self { url, mime_type } + } + + pub fn from_url(url: &str) -> Self { + Self { + url: url.to_string(), + mime_type: mime_type_for_image_url(url), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +pub struct NFTImages { + pub preview: NFTResource, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +#[serde(rename_all = "lowercase")] +pub enum NFTAttributeType { + String, + Timestamp, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +pub struct NFTAttribute { + pub name: String, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub percentage: Option, +} + +impl NFTAttribute { + pub fn new(name: impl Into, value: impl Into, value_type: NFTAttributeType) -> Self { + Self { + name: name.into(), + value: value.into(), + value_type: Some(value_type), + percentage: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Sendable, Hashable, Equatable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum NFTType { + ERC721, + ERC1155, + SPL, + JETTON, +} + +impl NFTType { + pub fn all() -> Vec { + Self::iter().collect::>() + } +} + +fn mime_type_for_image_url(url: &str) -> String { + if url.ends_with(".jpeg") || url.ends_with(".jpg") { + MIME_TYPE_JPG.to_string() + } else if url.ends_with(".svg") { + MIME_TYPE_SVG.to_string() + } else { + MIME_TYPE_PNG.to_string() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Sendable, CaseIterable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ReportReason { + Spam, + Malicious, + Inappropriate, + Copyright, + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub struct ReportNft { + #[typeshare(skip)] + pub device_id: String, + pub collection_id: String, + pub asset_id: Option, + pub reason: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + const TON_COLLECTION: &str = "EQC3dNlesgVD8YbAazcauIrXBPfiVhMMr5YYk2in0Mtsz0Bz"; + const TON_TOKEN: &str = "EQAqmedq_nTBz7rX6TvASY_kwXxbKexQap_qnsfS4E-qF0dI"; + + #[test] + fn test_collection_id() { + let eth = NFTCollectionId::new(Chain::Ethereum, "0xabc"); + assert_eq!(eth.to_string(), "ethereum_0xabc"); + assert_eq!(eth.to_string().parse::().ok(), Some(eth)); + + let ton = NFTCollectionId::new(Chain::Ton, TON_COLLECTION); + assert_eq!(ton.to_string(), format!("ton_{TON_COLLECTION}")); + assert_eq!(ton.to_string().parse::().ok(), Some(ton)); + + assert!("just-chain".parse::().is_err()); + assert!("not_a_real_chain".parse::().is_err()); + } + + #[test] + fn test_asset_id() { + let eth = NFTAssetId::new(Chain::Ethereum, "0xabc", "42"); + assert_eq!(eth.to_string(), "ethereum_0xabc::42"); + assert_eq!("ethereum_0xabc::42".parse::().ok(), Some(eth)); + + let ton = NFTAssetId::new(Chain::Ton, TON_COLLECTION, TON_TOKEN); + assert_eq!(ton.to_string(), format!("ton_{TON_COLLECTION}::{TON_TOKEN}")); + assert_eq!(ton.to_string().parse::().ok(), Some(ton.clone())); + assert_eq!(ton.get_collection_id(), NFTCollectionId::new(Chain::Ton, TON_COLLECTION)); + + assert!("ethereum_0xabc".parse::().is_err()); + assert!("nonsense".parse::().is_err()); + assert!("ton_short".parse::().is_err()); + } + + #[test] + fn test_nft_attribute_skips_empty_optional_fields() { + let value = serde_json::to_value(NFTAttribute { + name: "Length".to_string(), + value: "9".to_string(), + value_type: None, + percentage: None, + }) + .unwrap(); + assert_eq!( + value, + serde_json::json!({ + "name": "Length", + "value": "9" + }) + ); + + let value = serde_json::to_value(NFTAttribute::new("Created Date", "1738102775", NFTAttributeType::Timestamp)).unwrap(); + assert_eq!(value["valueType"], "timestamp"); + } +} diff --git a/core/crates/primitives/src/node.rs b/core/crates/primitives/src/node.rs new file mode 100644 index 0000000000..248c5eb527 --- /dev/null +++ b/core/crates/primitives/src/node.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +pub struct Node { + pub url: String, + #[typeshare(skip)] + pub node_type: NodeType, + pub status: NodeState, + pub priority: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +pub struct ChainNode { + pub chain: String, + pub node: Node, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +pub struct ChainNodes { + pub chain: String, + pub nodes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodesResponse { + pub version: i32, + pub nodes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, EnumString, PartialEq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +#[derive(Default)] +pub enum NodeState { + #[default] + Active, + Inactive, +} + +#[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, EnumString, PartialEq)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +#[derive(Default)] +pub enum NodeType { + #[default] + Default, + Archival, +} diff --git a/core/crates/primitives/src/node_config.rs b/core/crates/primitives/src/node_config.rs new file mode 100644 index 0000000000..595595f904 --- /dev/null +++ b/core/crates/primitives/src/node_config.rs @@ -0,0 +1,169 @@ +use super::chain::Chain; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq)] +pub struct Node { + pub url: String, + pub priority: NodePriority, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum NodePriority { + High = 10, + Medium = 5, + Low = 1, + Inactive = -1, +} + +impl Node { + pub fn new(url: &str, priority: NodePriority) -> Self { + Node { url: url.to_string(), priority } + } +} + +pub fn get_nodes() -> HashMap> { + Chain::all().into_iter().map(|chain| (chain.to_string(), get_nodes_for_chain(chain))).collect() +} + +pub fn get_nodes_for_chain(chain: Chain) -> Vec { + match chain { + Chain::Bitcoin | Chain::Litecoin | Chain::BitcoinCash => vec![], + Chain::Ethereum => vec![ + Node::new("https://ethereum.publicnode.com", NodePriority::High), + Node::new("https://ethereum-rpc.polkachu.com", NodePriority::High), + Node::new("https://eth.merkle.io", NodePriority::High), + ], + Chain::SmartChain => vec![ + Node::new("https://bsc.publicnode.com", NodePriority::High), + Node::new("https://bsc.merkle.io", NodePriority::High), + ], + Chain::Solana => vec![Node::new("https://api.mainnet-beta.solana.com", NodePriority::High)], + Chain::Polygon => vec![ + Node::new("https://polygon.llamarpc.com", NodePriority::High), + Node::new("https://polygon-rpc.com", NodePriority::High), + ], + Chain::Thorchain => vec![Node::new("https://daemon.thorchain.shapeshift.com/lcd", NodePriority::High)], + Chain::Cosmos => vec![ + Node::new("https://cosmos-rest.publicnode.com", NodePriority::High), + Node::new("https://cosmos-api.polkachu.com", NodePriority::High), + Node::new("https://rest.cosmos.directory/cosmoshub", NodePriority::High), + ], + Chain::Osmosis => vec![ + Node::new("https://osmosis-rest.publicnode.com", NodePriority::High), + Node::new("https://osmosis-api.polkachu.com", NodePriority::High), + ], + Chain::Arbitrum => vec![ + Node::new("https://arb1.arbitrum.io/rpc", NodePriority::High), + Node::new("https://arbitrum-rpc.polkachu.com", NodePriority::High), + Node::new("https://arbitrum-one.publicnode.com", NodePriority::High), + ], + Chain::Ton => vec![Node::new("https://toncenter.com", NodePriority::High)], + Chain::Tron => vec![ + Node::new("https://api.trongrid.io", NodePriority::High), + Node::new("https://api.frankfurt.trongrid.io", NodePriority::High), + Node::new("https://tron-rpc.publicnode.com", NodePriority::High), + ], + Chain::Doge => vec![], + Chain::Zcash => vec![], + Chain::Optimism => vec![ + Node::new("https://mainnet.optimism.io", NodePriority::High), + Node::new("https://optimism-rpc.polkachu.com", NodePriority::High), + ], + Chain::Aptos => vec![ + Node::new("https://fullnode.mainnet.aptoslabs.com", NodePriority::High), + Node::new("https://aptos-fullnode.polkachu.com", NodePriority::High), + ], + Chain::Base => vec![ + Node::new("https://mainnet.base.org", NodePriority::High), + Node::new("https://base-rpc.polkachu.com", NodePriority::High), + Node::new("https://base.merkle.io", NodePriority::High), + ], + Chain::AvalancheC => vec![Node::new("https://avalanche.drpc.org", NodePriority::High)], + Chain::Sui => vec![Node::new("https://fullnode.mainnet.sui.io", NodePriority::High)], + Chain::Xrp => vec![ + Node::new("https://s1.ripple.com:51234", NodePriority::High), + Node::new("https://s2.ripple.com:51234", NodePriority::High), + Node::new("https://xrplcluster.com", NodePriority::High), + ], + Chain::OpBNB => vec![ + Node::new("https://opbnb.drpc.org", NodePriority::High), + Node::new("https://opbnb-mainnet-rpc.bnbchain.org", NodePriority::High), + ], + Chain::Fantom => vec![ + Node::new("https://fantom.drpc.org", NodePriority::High), + Node::new("https://rpc.fantom.network", NodePriority::High), + ], + Chain::Gnosis => vec![ + Node::new("https://gnosis.drpc.org", NodePriority::High), + Node::new("https://rpc.gnosischain.com", NodePriority::High), + ], + Chain::Celestia => vec![ + Node::new("https://celestia-rest.publicnode.com", NodePriority::High), + Node::new("https://celestia-api.polkachu.com", NodePriority::High), + ], + Chain::Injective => vec![ + Node::new("https://injective-rest.publicnode.com", NodePriority::High), + Node::new("https://injective-api.polkachu.com", NodePriority::High), + ], + Chain::Sei => vec![ + Node::new("https://rest.sei-apis.com", NodePriority::High), + Node::new("https://api-sei.stingray.plus", NodePriority::High), + Node::new("https://sei-api.polkachu.com", NodePriority::High), + ], + Chain::SeiEvm => vec![ + Node::new("https://evm-rpc.sei-apis.com", NodePriority::High), + Node::new("https://evm-rpc-sei.stingray.plus", NodePriority::High), + Node::new("https://sei-evm-rpc.publicnode.com", NodePriority::High), + ], + Chain::Manta => vec![ + Node::new("https://pacific-rpc.manta.network/http", NodePriority::High), + Node::new("https://manta-pacific.drpc.org", NodePriority::High), + ], + Chain::Blast => vec![Node::new("https://blast-rpc.polkachu.com", NodePriority::High)], + Chain::Noble => vec![ + Node::new("https://rest.cosmos.directory/noble", NodePriority::High), + Node::new("https://noble-api.polkachu.com", NodePriority::High), + ], + Chain::ZkSync => vec![ + Node::new("https://zksync.drpc.org", NodePriority::High), + Node::new("https://mainnet.era.zksync.io", NodePriority::High), + ], + Chain::Linea => vec![Node::new("https://linea-rpc.polkachu.com", NodePriority::High)], + Chain::Mantle => vec![Node::new("https://rpc.mantle.xyz", NodePriority::High)], + Chain::Celo => vec![], + Chain::Near => vec![Node::new("https://rpc.mainnet.near.org", NodePriority::High)], + Chain::World => vec![Node::new("https://worldchain-mainnet.gateway.tenderly.co", NodePriority::High)], + Chain::Stellar => vec![Node::new("https://horizon.stellar.org", NodePriority::High)], + Chain::Sonic => vec![Node::new("https://rpc.soniclabs.com", NodePriority::High)], + Chain::Algorand => vec![Node::new("https://mainnet-api.algonode.cloud", NodePriority::High)], + Chain::Polkadot => vec![Node::new("https://polkadot-public-sidecar.parity-chains.parity.io", NodePriority::High)], + Chain::Plasma => vec![Node::new("https://rpc.plasma.to", NodePriority::High)], + Chain::Cardano => vec![], + Chain::Abstract => vec![Node::new("https://api.mainnet.abs.xyz", NodePriority::High)], + Chain::Berachain => vec![Node::new("https://rpc.berachain.com", NodePriority::High)], + Chain::Ink => vec![ + Node::new("https://rpc-qnd.inkonchain.com", NodePriority::High), + Node::new("https://rpc-gel.inkonchain.com", NodePriority::High), + ], + Chain::Unichain => vec![ + Node::new("https://mainnet.unichain.org", NodePriority::High), + Node::new("https://unichain-rpc.publicnode.com", NodePriority::High), + ], + Chain::Hyperliquid => vec![ + Node::new("https://rpc.hyperliquid.xyz/evm", NodePriority::High), + Node::new("https://rpc.hypurrscan.io", NodePriority::High), + Node::new("https://rpc.hyperlend.finance", NodePriority::High), + Node::new("https://hyperliquid-json-rpc.stakely.io", NodePriority::High), + ], + Chain::HyperCore => vec![Node::new("https://api.hyperliquid.xyz", NodePriority::High)], + Chain::Monad => vec![ + Node::new("https://rpc.monad.xyz", NodePriority::High), + Node::new("https://rpc1.monad.xyz", NodePriority::Medium), + ], + Chain::XLayer => vec![ + Node::new("https://rpc.xlayer.tech", NodePriority::High), + Node::new("https://xlayerrpc.okx.com", NodePriority::High), + ], + Chain::Stable => vec![Node::new("https://rpc.stable.xyz", NodePriority::High)], + } +} diff --git a/core/crates/primitives/src/node_status.rs b/core/crates/primitives/src/node_status.rs new file mode 100644 index 0000000000..2caf5189b0 --- /dev/null +++ b/core/crates/primitives/src/node_status.rs @@ -0,0 +1,10 @@ +use crate::UInt64; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeStatus { + pub chain_id: String, + pub latest_block_number: UInt64, + pub latency_ms: UInt64, +} diff --git a/core/crates/primitives/src/node_sync_status.rs b/core/crates/primitives/src/node_sync_status.rs new file mode 100644 index 0000000000..8061474e89 --- /dev/null +++ b/core/crates/primitives/src/node_sync_status.rs @@ -0,0 +1,66 @@ +use crate::UInt64; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeSyncStatus { + pub in_sync: bool, + pub latest_block_number: Option, + pub current_block_number: Option, +} + +impl NodeSyncStatus { + pub fn new(in_sync: bool, latest_block_number: Option, current_block_number: Option) -> Self { + Self { + in_sync, + latest_block_number, + current_block_number, + } + } + + pub fn in_sync() -> Self { + Self::new(true, None, None) + } + + pub fn synced(block: u64) -> Self { + Self::new(true, Some(block), Some(block)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum NodeStatusState { + Healthy(NodeSyncStatus), + Error { message: String }, +} + +impl NodeStatusState { + pub fn healthy(status: NodeSyncStatus) -> Self { + Self::Healthy(status) + } + + pub fn error(message: impl Into) -> Self { + Self::Error { message: message.into() } + } + + pub fn is_healthy(&self) -> bool { + match self { + Self::Healthy(status) => status.in_sync, + Self::Error { .. } => false, + } + } + + pub fn as_status(&self) -> Option<&NodeSyncStatus> { + match self { + Self::Healthy(status) => Some(status), + Self::Error { .. } => None, + } + } + + pub fn error_message(&self) -> Option<&str> { + match self { + Self::Error { message } => Some(message.as_str()), + _ => None, + } + } +} diff --git a/core/crates/primitives/src/notification.rs b/core/crates/primitives/src/notification.rs new file mode 100644 index 0000000000..3e3120e1fe --- /dev/null +++ b/core/crates/primitives/src/notification.rs @@ -0,0 +1,15 @@ +use crate::{CoreListItem, WalletId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct InAppNotification { + pub wallet_id: WalletId, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_at: Option>, + pub created_at: DateTime, + pub item: CoreListItem, +} diff --git a/core/crates/primitives/src/notification_data.rs b/core/crates/primitives/src/notification_data.rs new file mode 100644 index 0000000000..9e55e447a1 --- /dev/null +++ b/core/crates/primitives/src/notification_data.rs @@ -0,0 +1,34 @@ +use crate::{Asset, NotificationType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationData { + pub id: i32, + pub wallet_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub asset: Option, + pub notification_type: NotificationType, + pub is_read: bool, + pub metadata: Option, + pub read_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationRewardsMetadata { + #[serde(default)] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub points: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationRewardsRedeemMetadata { + pub transaction_id: String, + pub points: i32, + pub value: String, +} diff --git a/core/crates/primitives/src/notification_type.rs b/core/crates/primitives/src/notification_type.rs new file mode 100644 index 0000000000..5aaf01964b --- /dev/null +++ b/core/crates/primitives/src/notification_type.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, AsRefStr, EnumString)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum NotificationType { + ReferralJoined, + RewardsEnabled, + RewardsCodeDisabled, + RewardsRedeemed, + RewardsCreateUsername, + RewardsInvite, +} diff --git a/core/crates/primitives/src/number_incrementer.rs b/core/crates/primitives/src/number_incrementer.rs new file mode 100644 index 0000000000..9d1831c6bb --- /dev/null +++ b/core/crates/primitives/src/number_incrementer.rs @@ -0,0 +1,34 @@ +pub struct NumberIncrementer { + value: u64, +} + +impl NumberIncrementer { + pub fn new(initial_value: u64) -> Self { + Self { value: initial_value } + } + + pub fn next_val(&mut self) -> u64 { + let current = self.value; + self.value = self.value.wrapping_add(1); + current + } + + pub fn current(&self) -> u64 { + self.value + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn number_incrementer() { + let mut incrementer = NumberIncrementer::new(10); + + assert_eq!(incrementer.current(), 10); + assert_eq!(incrementer.next_val(), 10); + assert_eq!(incrementer.next_val(), 11); + assert_eq!(incrementer.current(), 12); + } +} diff --git a/core/crates/primitives/src/payment_decoder/decoder.rs b/core/crates/primitives/src/payment_decoder/decoder.rs new file mode 100644 index 0000000000..0cd86ebefe --- /dev/null +++ b/core/crates/primitives/src/payment_decoder/decoder.rs @@ -0,0 +1,351 @@ +use super::error::{PaymentDecoderError, Result}; +use crate::{Chain, asset_id::AssetId}; +use std::{collections::HashMap, fmt, str::FromStr}; + +use super::{ + erc681::{ETHEREUM_SCHEME, TransactionRequest}, + solana_pay::{self, PayTransfer as SolanaPayTransfer, SOLANA_PAY_SCHEME}, + ton_pay::{self, TON_PAY_SCHEME}, +}; + +#[derive(Debug, PartialEq)] +pub struct Payment { + pub address: String, + pub amount: Option, + pub memo: Option, + pub asset_id: Option, + pub link: Option, +} + +impl Payment { + pub fn new_address(address: &str) -> Self { + Self { + address: address.to_string(), + amount: None, + memo: None, + asset_id: None, + link: None, + } + } + + pub fn new_link(link: DecodedLinkType) -> Self { + Self { + address: "".to_string(), + amount: None, + memo: None, + asset_id: None, + link: Some(link), + } + } +} + +#[derive(Debug, PartialEq)] +pub enum DecodedLinkType { + SolanaPay(String), +} + +impl fmt::Display for DecodedLinkType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DecodedLinkType::SolanaPay(link) => write!(f, "{link}"), + } + } +} + +#[derive(Debug)] +pub struct PaymentURLDecoder; + +impl PaymentURLDecoder { + pub fn decode(string: &str) -> Result { + let chunks: Vec<&str> = string.split(':').collect(); + + match chunks.len() { + // Handle case with no scheme + 1 => { + // Check for query parameters + if string.contains('?') { + let parts: Vec<&str> = string.splitn(2, '?').collect(); + if parts.len() == 2 { + let address = parts[0].to_string(); + let params = Self::decode_query_string(parts[1]); + return Ok(Payment { + address, + amount: params.get("amount").cloned(), + memo: params.get("memo").cloned(), + asset_id: None, + link: None, + }); + } + } + // No scheme and no query parameters + Ok(Payment::new_address(string)) + } + // Handle case with scheme + 2 => { + let scheme = chunks[0]; + if scheme == ETHEREUM_SCHEME { + let transaction_request = TransactionRequest::parse(string)?; + return Ok(transaction_request.into()); + } + if scheme == SOLANA_PAY_SCHEME { + let pay_request = solana_pay::parse(string)?; + match pay_request { + solana_pay::RequestType::Transfer(transfer) => { + return Ok(transfer.into()); + } + solana_pay::RequestType::Transaction(link) => { + return Ok(Payment { + address: "".to_string(), + amount: None, + memo: None, + asset_id: None, + link: Some(DecodedLinkType::SolanaPay(link)), + }); + } + } + } + if scheme == TON_PAY_SCHEME { + let ton_payment = ton_pay::parse(string)?; + return Ok(Payment { + address: ton_payment.recipient, + amount: None, + memo: None, + asset_id: Some(ton_payment.asset_id), + link: None, + }); + } + + let path: &str = chunks[1]; + let path_chunks: Vec<&str> = path.split('?').collect(); + let address = path_chunks[0].to_string(); + let asset_id = Self::decode_scheme(scheme); + + if path_chunks.len() == 1 { + Ok(Payment { + address, + amount: None, + memo: None, + asset_id, + link: None, + }) + } else if path_chunks.len() == 2 { + let query = path_chunks[1]; + let params = Self::decode_query_string(query); + let amount = params.get("amount").cloned(); + let memo = params.get("memo").cloned(); + + Ok(Payment { + address, + amount, + memo, + asset_id, + link: None, + }) + } else { + Err(PaymentDecoderError::InvalidFormat("BIP21 format is incorrect".to_string())) + } + } + // Handle any other case (shouldn't normally happen) + _ => Ok(Payment::new_address(string)), + } + } + + fn decode_query_string(query_string: &str) -> HashMap { + query_string + .split('&') + .filter_map(|pair| { + let components: Vec<&str> = pair.split('=').collect(); + if components.len() == 2 { + Some((components[0].to_string(), components[1].to_string())) + } else { + None + } + }) + .collect() + } + + fn decode_scheme(scheme: &str) -> Option { + let chain = Chain::from_str(scheme).ok()?; + Some(AssetId::from(chain, None)) + } +} + +impl From for Payment { + fn from(val: TransactionRequest) -> Self { + let address: String; + let mut amount: Option; + let asset_id: Option; + let memo = val.parameters.get("memo").map(|x| x.to_string()); + let mut chain = Chain::Ethereum; + if let Some(chain_id) = val.chain_id { + chain = Chain::from_chain_id(chain_id).unwrap_or(Chain::Ethereum); + } + + // ERC20 + if val.function_name == Some("transfer".to_string()) { + address = val.parameters.get("address").map(|x| x.to_string()).unwrap_or("".to_string()); + amount = val.parameters.get("uint256").map(|x| x.to_string()); + asset_id = Some(AssetId::from(chain, Some(val.target_address))); + } else { + address = val.target_address; + amount = val.parameters.get("value").map(|x| x.to_string()); + if amount.is_none() { + amount = val.parameters.get("amount").map(|x| x.to_string()); + } + asset_id = Some(AssetId::from(chain, None)); + }; + Self { + address, + amount, + memo, + asset_id, + link: None, + } + } +} + +impl From for Payment { + fn from(val: SolanaPayTransfer) -> Self { + Self { + address: val.recipient, + amount: val.amount, + memo: val.memo, + asset_id: Some(AssetId::from(Chain::Solana, val.spl_token.map(|x| x.to_string()))), + link: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Chain; + + #[test] + fn test_address() { + assert_eq!( + PaymentURLDecoder::decode("0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326").unwrap(), + Payment::new_address("0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326") + ); + } + + #[test] + fn test_solana() { + assert_eq!( + PaymentURLDecoder::decode("HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5").unwrap(), + Payment::new_address("HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5") + ); + assert_eq!( + PaymentURLDecoder::decode("solana:HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5?amount=0.266232").unwrap(), + Payment { + address: "HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5".to_string(), + amount: Some("0.266232".to_string()), + memo: None, + asset_id: Some(AssetId::from_chain(Chain::Solana)), + link: None, + } + ); + assert_eq!( + PaymentURLDecoder::decode("solana:https%3A%2F%2Fapi.spherepay.co%2Fv1%2Fpublic%2FpaymentLink%2Fpay%2FpaymentLink_1df6564b6b4d43eaa077b732ad9b6ab9%3Fstate%3DAlabama%26country%3DUSA%26lineItems%3D%255B%257B%2522id%2522%253A%2522lineItem_82032b8ea67244e692cd322051e35689%2522%252C%2522quantity%2522%253A500%257D%255D%26solanaPayReference%3D4Vqsq8WhoTbFu8Lw2DbbtnCiHXXmBRN6afF8HkgxrXs7%26paymentReference%3DOZ_UxaOrU_F8fM5GhlrE2%26network%3Dsol%26skipPreflight%3Dfalse").unwrap(), + Payment::new_link(DecodedLinkType::SolanaPay("https://api.spherepay.co/v1/public/paymentLink/pay/paymentLink_1df6564b6b4d43eaa077b732ad9b6ab9?state=Alabama&country=USA&lineItems=%5B%7B%22id%22%3A%22lineItem_82032b8ea67244e692cd322051e35689%22%2C%22quantity%22%3A500%7D%5D&solanaPayReference=4Vqsq8WhoTbFu8Lw2DbbtnCiHXXmBRN6afF8HkgxrXs7&paymentReference=OZ_UxaOrU_F8fM5GhlrE2&network=sol&skipPreflight=false".to_string())), + ); + } + + #[test] + fn test_bip21() { + assert_eq!( + PaymentURLDecoder::decode("bitcoin:bc1pn6pua8a566z7t822kphpd2el45ntm23354c3krfmpe3nnn33lkcskuxrdl?amount=0.00001").unwrap(), + Payment { + address: "bc1pn6pua8a566z7t822kphpd2el45ntm23354c3krfmpe3nnn33lkcskuxrdl".to_string(), + amount: Some("0.00001".to_string()), + memo: None, + asset_id: Some(AssetId::from_chain(Chain::Bitcoin)), + link: None, + } + ); + + assert_eq!( + PaymentURLDecoder::decode("ethereum:0xA20d8935d61812b7b052E08f0768cFD6D81cB088?amount=0.01233&memo=test").unwrap(), + Payment { + address: "0xA20d8935d61812b7b052E08f0768cFD6D81cB088".to_string(), + amount: Some("0.01233".to_string()), + memo: Some("test".to_string()), + asset_id: Some(AssetId::from_chain(Chain::Ethereum)), + link: None, + } + ); + + assert_eq!( + PaymentURLDecoder::decode("solana:3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw?amount=0.42301").unwrap(), + Payment { + address: "3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw".to_string(), + amount: Some("0.42301".to_string()), + memo: None, + asset_id: Some(AssetId::from_chain(Chain::Solana)), + link: None, + } + ); + } + + #[test] + fn test_erc681() { + assert_eq!( + PaymentURLDecoder::decode("ethereum:0xcB3028d6120802148f03d6c884D6AD6A210Df62A@1").unwrap(), + Payment { + address: "0xcB3028d6120802148f03d6c884D6AD6A210Df62A".to_string(), + amount: None, + memo: None, + asset_id: Some(AssetId::from_chain(Chain::Ethereum)), + link: None, + } + ); + assert_eq!( + PaymentURLDecoder::decode("ethereum:0xcB3028d6120802148f03d6c884D6AD6A210Df62A@0x38?amount=1.23").unwrap(), + Payment { + address: "0xcB3028d6120802148f03d6c884D6AD6A210Df62A".to_string(), + amount: Some("1.23".to_string()), + memo: None, + asset_id: Some(AssetId::from_chain(Chain::SmartChain)), + link: None, + } + ); + } + + #[test] + fn test_ton_address() { + assert_eq!( + PaymentURLDecoder::decode("UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA").unwrap(), + Payment { + address: "UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA".to_string(), + amount: None, + memo: None, + asset_id: None, + link: None, + } + ); + assert_eq!( + PaymentURLDecoder::decode("ton://transfer/UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA").unwrap(), + Payment { + address: "UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA".to_string(), + amount: None, + memo: None, + asset_id: Some(AssetId::from_chain(Chain::Ton)), + link: None, + } + ); + } + + #[test] + fn test_address_with_amount() { + assert_eq!( + PaymentURLDecoder::decode("0x25851Bf7D35293A89F710eBFbD4718322eF7B174?amount=50.72").unwrap(), + Payment { + address: "0x25851Bf7D35293A89F710eBFbD4718322eF7B174".to_string(), + amount: Some("50.72".to_string()), + memo: None, + asset_id: None, + link: None, + } + ); + } +} diff --git a/core/crates/primitives/src/payment_decoder/erc681.rs b/core/crates/primitives/src/payment_decoder/erc681.rs new file mode 100644 index 0000000000..3050582aa6 --- /dev/null +++ b/core/crates/primitives/src/payment_decoder/erc681.rs @@ -0,0 +1,148 @@ +use super::error::{PaymentDecoderError, Result}; +use std::collections::HashMap; + +pub const ETHEREUM_SCHEME: &str = "ethereum"; +pub const PAY_PREFIX: &str = "pay-"; + +#[derive(Debug)] +pub struct TransactionRequest { + pub target_address: String, + pub prefix: Option, + pub chain_id: Option, + pub function_name: Option, + pub parameters: HashMap, +} + +impl TransactionRequest { + pub fn parse(uri: &str) -> Result { + // Split the URI into the scheme and the main part + let splits = uri.split(':').collect::>(); + if splits.len() != 2 { + return Err(PaymentDecoderError::InvalidFormat("Invalid uri without expected ':'".to_string())); + } + + // Validate the scheme + let prefix = splits[0]; + if !prefix.eq(ETHEREUM_SCHEME) { + return Err(PaymentDecoderError::InvalidScheme); + } + + // Split the main part and the query string + let parts: Vec<&str> = splits[1].split('?').collect(); + let query_string = if parts.len() > 1 { parts[1] } else { "" }; + + // Split the main part by '/' + let main_parts: Vec<&str> = parts[0].split('/').collect(); + + // The first part should be the target address with optional chain id and pay prefix + let mut target_address = main_parts.first().ok_or(PaymentDecoderError::MissingField("target address".to_string()))?.to_string(); + + // Parse chain id in integer and 0x format + let target_parts = target_address.split('@').collect::>(); + let mut chain_id = None; + if target_parts.len() == 2 { + if target_parts[1].starts_with("0x") { + chain_id = u64::from_str_radix(target_parts[1].replace("0x", "").as_str(), 16).ok(); + } else { + chain_id = target_parts[1].parse().ok(); + } + target_address = target_parts[0].to_string(); + } + + let mut prefix = None; + let prefix_parts = target_address.split('-').collect::>(); + if prefix_parts.len() == 2 { + prefix = Some(prefix_parts[0].to_string()); + target_address = prefix_parts[1].to_string(); + } + + // The second part (if exists) is the function name + let function_name = if main_parts.len() > 1 { Some(main_parts[1].to_string()) } else { None }; + + // Parse the query string into key-value pairs + let mut parameters = HashMap::new(); + for pair in query_string.split('&') { + if let Some((key, value)) = pair.split_once('=') { + parameters.insert(key.to_string(), value.to_string()); + } + } + + Ok(TransactionRequest { + target_address, + prefix, + chain_id, + function_name, + parameters, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_minimal_uri() { + let uri = "ethereum:0x32Be343B94f860124dC4fEe278FDCBD38C102D88"; + let erc681 = TransactionRequest::parse(uri).unwrap(); + assert_eq!(erc681.target_address, "0x32Be343B94f860124dC4fEe278FDCBD38C102D88"); + assert_eq!(erc681.prefix, None); + assert_eq!(erc681.chain_id, None); + assert_eq!(erc681.function_name, None); + assert_eq!(erc681.parameters.len(), 0); + } + + #[test] + fn test_invalid_uri() { + let uri = "bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W"; + let erc681 = TransactionRequest::parse(uri); + assert!(erc681.is_err()); + } + + #[test] + fn test_ens_name_uri() { + let uri = "ethereum:pay-gemwallet.eth@1"; + let erc681 = TransactionRequest::parse(uri).unwrap(); + assert_eq!(erc681.target_address, "gemwallet.eth"); + assert_eq!(erc681.prefix.unwrap(), "pay"); + assert_eq!(erc681.chain_id, Some(1)); + assert_eq!(erc681.function_name, None); + assert_eq!(erc681.parameters.len(), 0); + } + + #[test] + fn test_chain_id_uri() { + let uri = "ethereum:pay-0x32Be343B94f860124dC4fEe278FDCBD38C102D88@0x38"; + let erc681 = TransactionRequest::parse(uri).unwrap(); + assert_eq!(erc681.target_address, "0x32Be343B94f860124dC4fEe278FDCBD38C102D88"); + assert_eq!(erc681.prefix.unwrap(), "pay"); + assert_eq!(erc681.chain_id, Some(56)); + assert_eq!(erc681.function_name, None); + assert_eq!(erc681.parameters.len(), 0); + } + + #[test] + fn test_eth_transfer_uri() { + let uri = "ethereum:0x32Be343B94f860124dC4fEe278FDCBD38C102D88?value=10&gas=200000&gasPrice=20000000000"; + let erc681 = TransactionRequest::parse(uri).unwrap(); + assert_eq!(erc681.target_address, "0x32Be343B94f860124dC4fEe278FDCBD38C102D88"); + assert_eq!(erc681.prefix, None); + assert_eq!(erc681.chain_id, None); + assert_eq!(erc681.function_name, None); + assert_eq!(erc681.parameters.get("value").unwrap(), "10"); + assert_eq!(erc681.parameters.get("gas").unwrap(), "200000"); + assert_eq!(erc681.parameters.get("gasPrice").unwrap(), "20000000000"); + } + + #[test] + fn test_erc20_transfer_uri() { + let uri = "ethereum:0x89205a3a3b2a69de6dbf7f01ed13b2108b2c43e7/transfer?address=0x8e23ee67d1332ad560396262c48ffbb01f93d052&uint256=1"; + let erc681 = TransactionRequest::parse(uri).unwrap(); + assert_eq!(erc681.target_address, "0x89205a3a3b2a69de6dbf7f01ed13b2108b2c43e7"); + assert_eq!(erc681.prefix, None); + assert_eq!(erc681.chain_id, None); + assert_eq!(erc681.function_name.unwrap(), "transfer"); + assert_eq!(erc681.parameters.get("address").unwrap(), "0x8e23ee67d1332ad560396262c48ffbb01f93d052"); + assert_eq!(erc681.parameters.get("uint256").unwrap(), "1"); + } +} diff --git a/core/crates/primitives/src/payment_decoder/error.rs b/core/crates/primitives/src/payment_decoder/error.rs new file mode 100644 index 0000000000..06516573f7 --- /dev/null +++ b/core/crates/primitives/src/payment_decoder/error.rs @@ -0,0 +1,30 @@ +use std::fmt; + +#[derive(Debug)] +pub enum PaymentDecoderError { + InvalidScheme, + InvalidFormat(String), + MissingField(String), + UrlParse(String), +} + +impl fmt::Display for PaymentDecoderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PaymentDecoderError::InvalidScheme => write!(f, "Invalid or unsupported scheme"), + PaymentDecoderError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg), + PaymentDecoderError::MissingField(field) => write!(f, "Missing field: {}", field), + PaymentDecoderError::UrlParse(msg) => write!(f, "URL parse error: {}", msg), + } + } +} + +impl std::error::Error for PaymentDecoderError {} + +impl From for PaymentDecoderError { + fn from(err: url::ParseError) -> Self { + PaymentDecoderError::UrlParse(err.to_string()) + } +} + +pub type Result = std::result::Result; diff --git a/core/crates/primitives/src/payment_decoder/mod.rs b/core/crates/primitives/src/payment_decoder/mod.rs new file mode 100644 index 0000000000..2b672ee002 --- /dev/null +++ b/core/crates/primitives/src/payment_decoder/mod.rs @@ -0,0 +1,8 @@ +pub mod decoder; +pub mod erc681; +pub mod error; +pub mod solana_pay; +pub mod ton_pay; + +pub use self::decoder::{DecodedLinkType, Payment, PaymentURLDecoder}; +pub use self::error::{PaymentDecoderError, Result}; diff --git a/core/crates/primitives/src/payment_decoder/solana_pay.rs b/core/crates/primitives/src/payment_decoder/solana_pay.rs new file mode 100644 index 0000000000..5bdaa5a692 --- /dev/null +++ b/core/crates/primitives/src/payment_decoder/solana_pay.rs @@ -0,0 +1,129 @@ +use super::error::{PaymentDecoderError, Result}; +use std::collections::HashMap; +use url::Url; +use url::form_urlencoded; +pub const SOLANA_PAY_SCHEME: &str = "solana"; +pub const SOLANA_PAY_USDC_SPL_TOKEN: &str = crate::asset_constants::SOLANA_USDC_TOKEN_ID; + +#[derive(Debug, Clone)] +pub enum RequestType { + Transfer(PayTransfer), + Transaction(String), +} + +#[derive(Debug, Clone)] +pub struct PayTransfer { + pub recipient: String, + pub amount: Option, + pub spl_token: Option, + pub reference: Option>, + pub label: Option, + pub message: Option, + pub memo: Option, +} + +pub fn parse(uri: &str) -> Result { + let scheme = format!("{SOLANA_PAY_SCHEME}:"); + if !uri.starts_with(&scheme) { + return Err(PaymentDecoderError::InvalidScheme); + } + + let query_part = uri.replace(&scheme, ""); + if query_part.starts_with("https") { + let encoded = format!("value={query_part}"); + let decoded = form_urlencoded::parse(encoded.as_bytes()) + .next() + .map(|(_, v)| v.into_owned()) + .ok_or_else(|| PaymentDecoderError::InvalidFormat("Invalid percent encoding".to_string()))?; + let url = Url::parse(&decoded)?; + return Ok(RequestType::Transaction(url.to_string())); + } + + // Handle Transfer Request + let (recipient, query) = query_part + .split_once('?') + .ok_or_else(|| PaymentDecoderError::InvalidFormat("Invalid URL query string".to_string()))?; + + let query_params: HashMap = form_urlencoded::parse(query.as_bytes()).into_owned().collect(); + + let amount = query_params.get("amount").cloned(); + let spl_token = query_params.get("spl-token").cloned(); + let reference = query_params.get("reference").map(|v| v.split(',').map(String::from).collect()); + let label = query_params.get("label").cloned(); + let message = query_params.get("message").cloned(); + let memo = query_params.get("memo").cloned(); + + Ok(RequestType::Transfer(PayTransfer { + recipient: recipient.to_string(), + amount, + spl_token, + reference, + label, + message, + memo, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_transaction_encoded_https() { + let uri = "solana:https%3A%2F%2Fmy.site%2Fpay%3Fcheckout%3D1"; + let link = match parse(uri).unwrap() { + RequestType::Transaction(pay_url) => pay_url, + _ => panic!("Wrong type"), + }; + + assert_eq!(link, "https://my.site/pay?checkout=1"); + } + + #[test] + fn test_parse_transaction_plain_https() { + let uri = "solana:https://another.example/pay"; + let link = match parse(uri).unwrap() { + RequestType::Transaction(pay_url) => pay_url, + _ => panic!("Wrong type"), + }; + + assert_eq!(link, "https://another.example/pay"); + } + + #[test] + fn test_parse_transfer() { + let uri = format!( + "solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=1&spl-token={SOLANA_PAY_USDC_SPL_TOKEN}&reference=82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny&label=Michael&message=Thanks%20for%20all%20the%20fish&memo=OrderId5678" + ); + let pay_url = match parse(&uri).unwrap() { + RequestType::Transfer(pay_url) => pay_url, + _ => panic!("Wrong type"), + }; + assert_eq!(pay_url.recipient, "mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN"); + assert_eq!(pay_url.amount.unwrap(), "1"); + assert_eq!(pay_url.spl_token.unwrap(), SOLANA_PAY_USDC_SPL_TOKEN); + assert_eq!(pay_url.reference.unwrap(), vec!["82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny".to_string()]); + assert_eq!(pay_url.label.unwrap(), "Michael"); + assert_eq!(pay_url.message.unwrap(), "Thanks for all the fish"); + assert_eq!(pay_url.memo.unwrap(), "OrderId5678"); + } + + #[test] + fn test_parse_transaction() { + let uri = "solana:https://example.com/solana-pay"; + let link = match parse(uri).unwrap() { + RequestType::Transaction(pay_url) => pay_url, + _ => panic!("Wrong type"), + }; + + assert_eq!(link, "https://example.com/solana-pay"); + + let uri = "solana:https%3A%2F%2Fexample.com%2Fsolana-pay%3Forder%3D12345"; + let link = match parse(uri).unwrap() { + RequestType::Transaction(pay_url) => pay_url, + _ => panic!("Wrong type"), + }; + + assert_eq!(link, "https://example.com/solana-pay?order=12345"); + } +} diff --git a/core/crates/primitives/src/payment_decoder/ton_pay.rs b/core/crates/primitives/src/payment_decoder/ton_pay.rs new file mode 100644 index 0000000000..8b0a3be9e3 --- /dev/null +++ b/core/crates/primitives/src/payment_decoder/ton_pay.rs @@ -0,0 +1,62 @@ +use super::error::{PaymentDecoderError, Result}; + +use crate::{AssetId, Chain}; + +pub const TON_PAY_SCHEME: &str = "ton"; +pub const TON_PAY_TYPE_TRANSFER: &str = "transfer"; + +#[derive(Debug, Clone)] +pub struct TonPayment { + pub recipient: String, + pub asset_id: AssetId, +} + +pub fn parse(uri: &str) -> Result { + let scheme = format!("{TON_PAY_SCHEME}:"); + if !uri.starts_with(&scheme) { + return Err(PaymentDecoderError::InvalidScheme); + } + let query_part = &uri[scheme.len()..]; + let recipient = extract_address(query_part)?; + + Ok(TonPayment { + recipient, + asset_id: AssetId::from_chain(Chain::Ton), + }) +} + +fn extract_address(query_part: &str) -> Result { + let parts: Vec<&str> = query_part.split('/').filter(|s| !s.is_empty()).collect(); + if parts.len() == 2 && parts[0] == TON_PAY_TYPE_TRANSFER { + Ok(parts[1].to_string()) + } else if parts.len() == 1 { + Ok(parts[0].to_string()) + } else { + Err(PaymentDecoderError::InvalidFormat(format!("Invalid URI format: {}", query_part))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_with_transfer() { + let uri = "ton://transfer/UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA"; + let payment = parse(uri).unwrap(); + assert_eq!(payment.recipient, "UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA"); + } + + #[test] + fn test_parse_without_transfer() { + let uri = "ton://UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA"; + let payment = parse(uri).unwrap(); + assert_eq!(payment.recipient, "UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA"); + } + + #[test] + fn test_parse_invalid_uri() { + let uri = "ton://invalid/format"; + assert!(parse(uri).is_err()); + } +} diff --git a/core/crates/primitives/src/payment_type.rs b/core/crates/primitives/src/payment_type.rs new file mode 100644 index 0000000000..2190b490db --- /dev/null +++ b/core/crates/primitives/src/payment_type.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, EnumString, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +#[derive(Default)] +pub enum PaymentType { + #[default] + Card, + GooglePay, + ApplePay, + CashApp, + Venmo, + Sepa, + Ach, + Wire, +} diff --git a/core/crates/primitives/src/perpetual.rs b/core/crates/primitives/src/perpetual.rs new file mode 100644 index 0000000000..b6fc1b56cf --- /dev/null +++ b/core/crates/primitives/src/perpetual.rs @@ -0,0 +1,210 @@ +use crate::{Asset, AssetId, PerpetualId, PerpetualMarginType, PerpetualPosition, PerpetualProvider, UInt64}; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct Perpetual { + pub id: PerpetualId, + pub name: String, + pub provider: PerpetualProvider, + pub asset_id: AssetId, + pub identifier: String, + pub price: f64, + pub price_percent_change_24h: f64, + pub open_interest: f64, + pub volume_24h: f64, + pub funding: f64, + pub max_leverage: u8, + pub is_isolated_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualMarketData { + pub coin: String, + pub price: f64, + pub price_percent_change_24h: f64, + pub open_interest: f64, + pub volume_24h: f64, + pub funding: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualBasic { + pub asset_id: AssetId, + pub perpetual_id: PerpetualId, + pub provider: PerpetualProvider, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +pub struct PerpetualSearchData { + pub perpetual: Perpetual, + pub asset: Asset, +} + +impl Perpetual { + pub fn as_basic(&self) -> PerpetualBasic { + PerpetualBasic { + asset_id: self.asset_id.clone(), + perpetual_id: self.id.clone(), + provider: self.provider.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum PerpetualDirection { + Short, + Long, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub struct PerpetualPositionData { + pub perpetual: Perpetual, + pub asset: Asset, + pub position: PerpetualPosition, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub struct PerpetualData { + pub perpetual: Perpetual, + pub asset: Asset, + pub metadata: PerpetualMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub struct PerpetualPositionsSummary { + pub positions: Vec, + pub balance: PerpetualBalance, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub struct PerpetualBalance { + pub available: f64, + pub reserved: f64, + pub withdrawable: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualMetadata { + pub is_pinned: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualConfirmData { + pub direction: PerpetualDirection, + pub margin_type: PerpetualMarginType, + pub base_asset: Asset, + pub asset_index: i32, + pub price: String, + pub fiat_value: f64, + pub size: String, + pub slippage: f64, + pub leverage: u8, + pub pnl: Option, + pub entry_price: Option, + pub market_price: f64, + pub margin_amount: f64, + pub take_profit: Option, + pub stop_loss: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub enum AccountDataType { + Activate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct CancelOrderData { + pub asset_index: i32, + pub order_id: UInt64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct TPSLOrderData { + pub direction: PerpetualDirection, + #[serde(skip_serializing_if = "Option::is_none")] + pub take_profit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_loss: Option, + pub size: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(tag = "type", content = "content")] +pub enum PerpetualModifyPositionType { + Tpsl(TPSLOrderData), + Cancel(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualModifyConfirmData { + pub base_asset: Asset, + pub asset_index: i32, + pub modify_types: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub take_profit_order_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_loss_order_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualReduceData { + pub data: PerpetualConfirmData, + pub position_direction: PerpetualDirection, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct AutocloseOpenData { + pub asset_id: AssetId, + pub symbol: String, + pub direction: PerpetualDirection, + pub market_price: f64, + pub leverage: u8, + pub size: f64, + pub asset_decimals: i32, + pub take_profit: Option, + pub stop_loss: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(tag = "type", content = "content")] +pub enum PerpetualType { + Open(PerpetualConfirmData), + Close(PerpetualConfirmData), + Modify(PerpetualModifyConfirmData), + Increase(PerpetualConfirmData), + Reduce(PerpetualReduceData), +} diff --git a/core/crates/primitives/src/perpetual_id.rs b/core/crates/primitives/src/perpetual_id.rs new file mode 100644 index 0000000000..81933aee48 --- /dev/null +++ b/core/crates/primitives/src/perpetual_id.rs @@ -0,0 +1,78 @@ +use std::fmt; +use std::str::FromStr; + +use crate::CHAIN_SEPARATOR; +use crate::perpetual_provider::PerpetualProvider; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PerpetualId { + pub provider: PerpetualProvider, + pub symbol: String, +} + +crate::impl_string_serde!(PerpetualId); + +impl PerpetualId { + pub fn new(provider: PerpetualProvider, symbol: &str) -> Self { + Self { + provider, + symbol: symbol.to_string(), + } + } + + pub fn id(&self) -> String { + self.to_string() + } + + pub fn from_id(id: &str) -> Option { + id.parse().ok() + } +} + +impl fmt::Display for PerpetualId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}{CHAIN_SEPARATOR}{}", self.provider.as_ref(), self.symbol) + } +} + +impl FromStr for PerpetualId { + type Err = String; + + fn from_str(s: &str) -> Result { + let (provider_str, symbol) = s + .split_once(CHAIN_SEPARATOR) + .ok_or_else(|| format!("invalid perpetual identifier format: expected 2 parts separated by '{CHAIN_SEPARATOR}', got: {s}"))?; + let provider: PerpetualProvider = provider_str.parse().map_err(|_| format!("invalid perpetual provider: {provider_str}"))?; + Ok(Self { + provider, + symbol: symbol.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_id_round_trip() { + let id = PerpetualId::new(PerpetualProvider::Hypercore, "BTC"); + assert_eq!(id.id(), "hypercore_BTC"); + assert_eq!(PerpetualId::from_id("hypercore_BTC"), Some(id)); + } + + #[test] + fn test_from_id_invalid() { + assert!(PerpetualId::from_id("invalid").is_none()); + assert!(PerpetualId::from_id("unknown_BTC").is_none()); + } + + #[test] + fn test_serde() { + let id = PerpetualId::new(PerpetualProvider::Hypercore, "ETH"); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"hypercore_ETH\""); + let parsed: PerpetualId = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, id); + } +} diff --git a/core/crates/primitives/src/perpetual_position.rs b/core/crates/primitives/src/perpetual_position.rs new file mode 100644 index 0000000000..fed3cacb02 --- /dev/null +++ b/core/crates/primitives/src/perpetual_position.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +use crate::{AssetId, PerpetualDirection, PerpetualId}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum PerpetualMarginType { + Cross, + Isolated, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum PerpetualOrderType { + Market, + Limit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub struct PerpetualTriggerOrder { + pub price: f64, + pub order_type: PerpetualOrderType, + pub order_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualPosition { + pub id: String, + pub perpetual_id: PerpetualId, + pub asset_id: AssetId, + pub size: f64, + pub size_value: f64, + pub leverage: u8, + pub entry_price: f64, + pub liquidation_price: Option, + pub margin_type: PerpetualMarginType, + pub direction: PerpetualDirection, + pub margin_amount: f64, + pub take_profit: Option, + pub stop_loss: Option, + pub pnl: f64, + pub funding: Option, +} diff --git a/core/crates/primitives/src/perpetual_provider.rs b/core/crates/primitives/src/perpetual_provider.rs new file mode 100644 index 0000000000..83c0086bce --- /dev/null +++ b/core/crates/primitives/src/perpetual_provider.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use strum::{AsRefStr, EnumIter, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum PerpetualProvider { + Hypercore, +} + +impl fmt::Display for PerpetualProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PerpetualProvider::Hypercore => write!(f, "hypercore"), + } + } +} diff --git a/core/crates/primitives/src/platform.rs b/core/crates/primitives/src/platform.rs new file mode 100644 index 0000000000..ec17dfb209 --- /dev/null +++ b/core/crates/primitives/src/platform.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString}; +use typeshare::typeshare; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, AsRefStr, EnumString, EnumIter, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Platform { + IOS, + Android, +} + +impl Platform { + pub fn as_str(&self) -> &'static str { + match self { + Platform::IOS => "ios", + Platform::Android => "android", + } + } + + pub fn as_i32(&self) -> i32 { + match self { + Platform::IOS => 1, + Platform::Android => 2, + } + } + + pub fn new(s: &str) -> Option { + match s { + "ios" => Some(Platform::IOS), + "android" => Some(Platform::Android), + _ => None, + } + } +} diff --git a/core/crates/primitives/src/platform_store.rs b/core/crates/primitives/src/platform_store.rs new file mode 100644 index 0000000000..6fcf3b79ba --- /dev/null +++ b/core/crates/primitives/src/platform_store.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum PlatformStore { + AppStore, + GooglePlay, + Fdroid, + Huawei, + SolanaStore, + SamsungStore, + ApkUniversal, + Emerald, + Local, +} + +impl PlatformStore { + pub fn all() -> Vec { + Self::iter().collect() + } +} diff --git a/core/crates/primitives/src/portfolio.rs b/core/crates/primitives/src/portfolio.rs new file mode 100644 index 0000000000..c9f611c1d1 --- /dev/null +++ b/core/crates/primitives/src/portfolio.rs @@ -0,0 +1,145 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::asset_id::AssetId; +use crate::asset_price::{ChartPeriod, ChartValue}; +use crate::chart::ChartDateValue; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Sendable, CaseIterable, Identifiable, Hashable")] +#[serde(rename_all = "camelCase")] +pub enum PortfolioType { + Wallet, + Perpetuals, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Sendable, CaseIterable, Identifiable, Hashable")] +#[serde(rename_all = "camelCase")] +pub enum PortfolioChartType { + Value, + Pnl, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PortfolioChartData { + pub chart_type: PortfolioChartType, + pub values: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualAccountSummary { + pub account_value: f64, + pub account_leverage: f64, + pub margin_usage: f64, + pub unrealized_pnl: f64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualPortfolioTimeframeData { + pub account_value_history: Vec, + pub pnl_history: Vec, + pub volume: f64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PerpetualPortfolio { + pub day: Option, + pub week: Option, + pub month: Option, + pub all_time: Option, + pub account_summary: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct PortfolioAsset { + pub asset_id: AssetId, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct PortfolioAssetsRequest { + pub assets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PortfolioAllocation { + pub asset_id: AssetId, + pub percentage: f32, + pub value: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct ChartValuePercentage { + pub date: DateTime, + pub value: f32, + pub percentage: f32, +} + +impl ChartValuePercentage { + pub fn with_rate(&self, rate: f64) -> Self { + Self { + date: self.date, + value: self.value * rate as f32, + percentage: self.percentage, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PortfolioAssets { + pub total_value: f32, + pub values: Vec, + pub all_time_high: Option, + pub all_time_low: Option, + pub allocation: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct PortfolioMarginUsage { + pub account_value: f64, + pub usage: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(tag = "type", content = "content", rename_all = "camelCase")] +pub enum PortfolioStatistic { + AllTimeHigh(ChartValuePercentage), + AllTimeLow(ChartValuePercentage), + UnrealizedPnl(f64), + AccountLeverage(f64), + MarginUsage(PortfolioMarginUsage), + AllTimePnl(f64), + Volume(f64), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct PortfolioData { + pub charts: Vec, + pub statistics: Vec, + pub available_periods: Vec, +} diff --git a/core/crates/primitives/src/price.rs b/core/crates/primitives/src/price.rs new file mode 100644 index 0000000000..931bdb5d14 --- /dev/null +++ b/core/crates/primitives/src/price.rs @@ -0,0 +1,68 @@ +use crate::{Asset, AssetLink, AssetMarket, PriceAlert, PriceProvider}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct Price { + pub price: f64, + pub price_change_percentage_24h: f64, + pub updated_at: DateTime, + #[typeshare(skip)] + pub provider: PriceProvider, +} + +impl Price { + pub fn new(price: f64, price_change_percentage_24h: f64, updated_at: DateTime, provider: PriceProvider) -> Self { + Price { + price, + price_change_percentage_24h, + updated_at, + provider, + } + } + + pub fn with_rate(self, rate: f64) -> Self { + Price { price: self.price * rate, ..self } + } + + pub fn new_with_rate(&self, base_rate: f64, rate: f64) -> Self { + self.with_rate(rate * base_rate) + } +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable, Equatable")] +struct PriceData { + asset: Asset, + price: Option, + price_alerts: Vec, + market: Option, + links: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_with_rate() { + let price = Price::new(100.0, 5.0, DateTime::default(), PriceProvider::Coingecko); + + let new_price = price.new_with_rate(1.0, 2.0); + assert_eq!(new_price.price, 200.0); + assert_eq!(new_price.price_change_percentage_24h, 5.0); + + let new_price = price.new_with_rate(2.0, 1.0); + assert_eq!(new_price.price, 200.0); + assert_eq!(new_price.price_change_percentage_24h, 5.0); + + let new_price = price.new_with_rate(1.0, 0.5); + assert_eq!(new_price.price, 50.0); + assert_eq!(new_price.price_change_percentage_24h, 5.0); + } +} diff --git a/core/crates/primitives/src/price_alert.rs b/core/crates/primitives/src/price_alert.rs new file mode 100644 index 0000000000..a80ab80c8c --- /dev/null +++ b/core/crates/primitives/src/price_alert.rs @@ -0,0 +1,194 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +use crate::{Asset, AssetId, DEFAULT_FIAT_CURRENCY, Device, Price}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct PriceAlert { + pub asset_id: AssetId, + #[serde(default = "default_currency")] + pub currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price_percent_change: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price_direction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_notified_at: Option>, + #[typeshare(skip)] + #[serde(skip)] + pub identifier: String, +} + +fn default_currency() -> String { + DEFAULT_FIAT_CURRENCY.to_string() +} + +impl PriceAlert { + pub fn new_auto(asset_id: AssetId, currency: String) -> Self { + Self { + identifier: asset_id.to_string(), + asset_id, + currency, + price: None, + price_percent_change: None, + price_direction: None, + last_notified_at: None, + } + } + + pub fn new_price(asset_id: AssetId, currency: String, price: f64, direction: PriceAlertDirection) -> Self { + Self { + identifier: Self::generate_id(&asset_id, ¤cy, Some(price), None, Some(&direction)), + asset_id, + currency, + price: Some(price), + price_percent_change: None, + price_direction: Some(direction), + last_notified_at: None, + } + } + + pub fn new_price_percent(asset_id: AssetId, currency: String, percent_change: f64, direction: PriceAlertDirection) -> Self { + Self { + identifier: Self::generate_id(&asset_id, ¤cy, None, Some(percent_change), Some(&direction)), + asset_id, + currency, + price: None, + price_percent_change: Some(percent_change), + price_direction: Some(direction), + last_notified_at: None, + } + } + + pub fn id(&self) -> String { + if !self.identifier.is_empty() { + return self.identifier.clone(); + } + Self::generate_id(&self.asset_id, &self.currency, self.price, self.price_percent_change, self.price_direction.as_ref()) + } + + fn generate_id(asset_id: &AssetId, currency: &str, price: Option, price_percent_change: Option, price_direction: Option<&PriceAlertDirection>) -> String { + if price.is_none() && price_percent_change.is_none() && price_direction.is_none() { + return asset_id.to_string(); + } + [ + Some(asset_id.to_string()), + Some(currency.to_string()), + price.map(|p| p.to_string()), + price_percent_change.map(|p| p.to_string()), + price_direction.map(|d| d.as_ref().to_string()), + ] + .into_iter() + .flatten() + .collect::>() + .join("_") + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct PriceAlertData { + pub asset: Asset, + pub price: Option, + pub price_alert: PriceAlert, +} + +#[derive(Clone, Debug, Serialize, Deserialize, AsRefStr, EnumString, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum PriceAlertDirection { + Up, + Down, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum PriceAlertType { + PriceChangesUp, + PriceChangesDown, + PriceUp, + PriceDown, + PricePercentChangeUp, + PricePercentChangeDown, + AllTimeHigh, + PriceMilestone, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum PriceAlertNotificationType { + Auto, + Price, + PricePercentChange, +} + +pub type PriceAlerts = Vec; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DevicePriceAlert { + pub device: Device, + pub price_alert: PriceAlert, +} + +#[cfg(test)] +mod tests { + use crate::Chain; + + use super::*; + + #[test] + fn test_generate_id() { + let eth = AssetId::from_chain(Chain::Ethereum); + assert_eq!(PriceAlert::generate_id(ð, "USD", None, None, None), "ethereum"); + assert_eq!( + PriceAlert::generate_id(ð, "USD", Some(100.0), None, Some(&PriceAlertDirection::Up)), + "ethereum_USD_100_up" + ); + assert_eq!( + PriceAlert::generate_id(ð, "USD", Some(1.12344), None, Some(&PriceAlertDirection::Down)), + "ethereum_USD_1.12344_down" + ); + assert_eq!(PriceAlert::generate_id(ð, "USD", None, Some(5.0), Some(&PriceAlertDirection::Up)), "ethereum_USD_5_up"); + assert_eq!( + PriceAlert::generate_id(ð, "USD", None, Some(10_000.10), Some(&PriceAlertDirection::Down)), + "ethereum_USD_10000.1_down" + ); + } + + #[test] + fn test_new_auto_price_percent() { + let eth = AssetId::from_chain(Chain::Ethereum); + assert_eq!(PriceAlert::new_auto(eth.clone(), "USD".to_string()).identifier, "ethereum"); + assert_eq!( + PriceAlert::new_price(eth.clone(), "USD".to_string(), 100.0, PriceAlertDirection::Up).identifier, + "ethereum_USD_100_up" + ); + assert_eq!( + PriceAlert::new_price_percent(eth, "USD".to_string(), 5.0, PriceAlertDirection::Down).identifier, + "ethereum_USD_5_down" + ); + } + + #[test] + fn test_id_returns_stored_identifier() { + let alert = PriceAlert { + asset_id: AssetId::from_chain(Chain::Ethereum), + currency: "USD".to_string(), + price: Some(100.0), + price_percent_change: None, + price_direction: Some(PriceAlertDirection::Up), + last_notified_at: None, + identifier: "stored_from_db".to_string(), + }; + assert_eq!(alert.id(), "stored_from_db"); + } +} diff --git a/core/crates/primitives/src/price_config.rs b/core/crates/primitives/src/price_config.rs new file mode 100644 index 0000000000..dd088dd1b2 --- /dev/null +++ b/core/crates/primitives/src/price_config.rs @@ -0,0 +1,6 @@ +use std::time::Duration; + +#[derive(Clone, Copy)] +pub struct PriceConfig { + pub primary_price_max_age: Duration, +} diff --git a/core/crates/primitives/src/price_data.rs b/core/crates/primitives/src/price_data.rs new file mode 100644 index 0000000000..fd2af5a7fb --- /dev/null +++ b/core/crates/primitives/src/price_data.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{PriceId, PriceProvider}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PriceData { + pub id: PriceId, + pub provider: PriceProvider, + pub provider_price_id: String, + pub price: f64, + pub price_change_percentage_24h: f64, + pub all_time_high: f64, + pub all_time_high_date: Option>, + pub all_time_low: f64, + pub all_time_low_date: Option>, + pub market_cap_rank: Option, + pub total_volume: Option, + pub last_updated_at: DateTime, +} diff --git a/core/crates/primitives/src/price_id.rs b/core/crates/primitives/src/price_id.rs new file mode 100644 index 0000000000..af977a9d15 --- /dev/null +++ b/core/crates/primitives/src/price_id.rs @@ -0,0 +1,50 @@ +use std::fmt; +use std::str::FromStr; + +use crate::{CHAIN_SEPARATOR, PriceProvider}; + +/// The resolved (provider, provider_price_id) pair used to key prices, charts and any other +/// provider-scoped data. `id()` produces the synthetic `prices.id` used across the schema. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PriceId { + pub provider: PriceProvider, + pub provider_price_id: String, +} + +impl PriceId { + pub fn new(provider: PriceProvider, provider_price_id: String) -> Self { + Self { provider, provider_price_id } + } + + pub fn id(&self) -> String { + self.to_string() + } + + pub fn id_for(provider: PriceProvider, provider_price_id: &str) -> String { + format!("{provider}{CHAIN_SEPARATOR}{provider_price_id}") + } +} + +impl fmt::Display for PriceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&Self::id_for(self.provider, &self.provider_price_id)) + } +} + +impl FromStr for PriceId { + type Err = String; + + fn from_str(s: &str) -> Result { + let (provider, provider_price_id) = s.split_once(CHAIN_SEPARATOR).ok_or_else(|| format!("Invalid price_id: {s}"))?; + if provider_price_id.is_empty() { + return Err(format!("Invalid price_id: {s}")); + } + let provider = provider.parse().map_err(|_| format!("Unknown provider: {provider}"))?; + Ok(Self { + provider, + provider_price_id: provider_price_id.to_string(), + }) + } +} + +crate::impl_string_serde!(PriceId); diff --git a/core/crates/primitives/src/price_provider.rs b/core/crates/primitives/src/price_provider.rs new file mode 100644 index 0000000000..954e394567 --- /dev/null +++ b/core/crates/primitives/src/price_provider.rs @@ -0,0 +1,49 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, EnumIter, AsRefStr, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum PriceProvider { + Coingecko, + Pyth, + Jupiter, + DefiLlama, +} + +impl PriceProvider { + pub fn all() -> Vec { + Self::iter().collect() + } + + pub fn primary() -> Self { + Self::Coingecko + } + + pub fn id(&self) -> &str { + self.as_ref() + } + + pub fn priority(&self) -> i32 { + match self { + Self::Coingecko => 0, + Self::Pyth => 1, + Self::Jupiter => 2, + Self::DefiLlama => 3, + } + } + + pub fn supports_price_change_24h(&self) -> bool { + match self { + Self::Coingecko | Self::Jupiter => true, + Self::Pyth | Self::DefiLlama => false, + } + } +} + +impl fmt::Display for PriceProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_ref()) + } +} diff --git a/core/crates/primitives/src/priority.rs b/core/crates/primitives/src/priority.rs new file mode 100644 index 0000000000..f6ed34f707 --- /dev/null +++ b/core/crates/primitives/src/priority.rs @@ -0,0 +1,143 @@ +use std::cmp::Ordering; +use std::ops::{Mul, Sub}; + +pub trait PrioritizedProvider { + fn provider_id(&self) -> &str; + fn priority(&self) -> i32; + fn threshold_bps(&self) -> i32; +} + +pub fn sort_by_priority_then_amount(a_id: &str, b_id: &str, a_amount: &A, b_amount: &A, providers: &[P], ascending: bool) -> Ordering +where + P: PrioritizedProvider, + A: PartialOrd + Clone + Sub + Mul + From, +{ + let a_provider = providers.iter().find(|p| p.provider_id() == a_id); + let b_provider = providers.iter().find(|p| p.provider_id() == b_id); + let a_pri = a_provider.map(|p| p.priority()).filter(|&p| p > 0); + let b_pri = b_provider.map(|p| p.priority()).filter(|&p| p > 0); + + let cmp_amount = |x: &A, y: &A| x.partial_cmp(y).unwrap_or(Ordering::Equal); + + let by_amount = || { + let ord = cmp_amount(a_amount, b_amount); + if ascending { ord } else { ord.reverse() } + }; + + match (a_pri, b_pri) { + (Some(a), Some(b)) if a != b => { + let higher_pri = if a < b { a_provider } else { b_provider }.unwrap(); + if exceeds_threshold(higher_pri, a_amount, b_amount, ascending) { + by_amount() + } else { + a.cmp(&b) + } + } + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + _ => by_amount(), + } +} + +fn exceeds_threshold(provider: &P, a: &A, b: &A, ascending: bool) -> bool +where + P: PrioritizedProvider, + A: PartialOrd + Clone + Sub + Mul + From, +{ + let bps = provider.threshold_bps(); + if bps == 0 { + return false; + } + let better = if ascending == (a < b) { a } else { b }.clone(); + let diff = if a > b { a.clone() - b.clone() } else { b.clone() - a.clone() }; + diff * A::from(10000) > A::from(bps) * better +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockProvider { + id: String, + priority: i32, + threshold_bps: i32, + } + + impl MockProvider { + fn new(id: &str, priority: i32, threshold_bps: i32) -> Self { + Self { + id: id.to_string(), + priority, + threshold_bps, + } + } + } + + impl PrioritizedProvider for MockProvider { + fn provider_id(&self) -> &str { + &self.id + } + fn priority(&self) -> i32 { + self.priority + } + fn threshold_bps(&self) -> i32 { + self.threshold_bps + } + } + + #[test] + fn test_no_priority_sorts_by_amount_desc() { + let providers: Vec = vec![]; + let result = sort_by_priority_then_amount("a", "b", &100.0, &200.0, &providers, false); + assert_eq!(result, Ordering::Greater); + } + + #[test] + fn test_priority_wins_over_amount() { + let providers = vec![MockProvider::new("a", 1, 0), MockProvider::new("b", 2, 0)]; + let result = sort_by_priority_then_amount("a", "b", &100.0, &200.0, &providers, false); + assert_eq!(result, Ordering::Less); + } + + #[test] + fn test_threshold_override() { + let providers = vec![MockProvider::new("a", 1, 500), MockProvider::new("b", 2, 0)]; + let result = sort_by_priority_then_amount("a", "b", &100.0, &200.0, &providers, false); + assert_eq!(result, Ordering::Greater); + } + + #[test] + fn test_threshold_not_exceeded() { + let providers = vec![MockProvider::new("a", 1, 5000), MockProvider::new("b", 2, 0)]; + let result = sort_by_priority_then_amount("a", "b", &100.0, &110.0, &providers, false); + assert_eq!(result, Ordering::Less); + } + + #[test] + fn test_unprioritized_sorted_after_prioritized() { + let providers = vec![MockProvider::new("a", 1, 0)]; + let result = sort_by_priority_then_amount("a", "b", &50.0, &200.0, &providers, false); + assert_eq!(result, Ordering::Less); + } + + #[test] + fn test_ascending_order() { + let providers: Vec = vec![]; + let result = sort_by_priority_then_amount("a", "b", &100.0, &200.0, &providers, true); + assert_eq!(result, Ordering::Less); + } + + #[test] + fn test_same_priority_sorts_by_amount() { + let providers = vec![MockProvider::new("a", 1, 0), MockProvider::new("b", 1, 0)]; + let result = sort_by_priority_then_amount("a", "b", &200.0, &100.0, &providers, false); + assert_eq!(result, Ordering::Less); + } + + #[test] + fn test_priority_zero_treated_as_unranked() { + let providers = vec![MockProvider::new("a", 0, 0), MockProvider::new("b", 1, 0)]; + let result = sort_by_priority_then_amount("a", "b", &200.0, &100.0, &providers, false); + assert_eq!(result, Ordering::Greater); + } +} diff --git a/core/crates/primitives/src/push_notification.rs b/core/crates/primitives/src/push_notification.rs new file mode 100644 index 0000000000..dc2fdc772c --- /dev/null +++ b/core/crates/primitives/src/push_notification.rs @@ -0,0 +1,106 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +use crate::{AssetId, Transaction, WalletId}; + +#[typeshare(swift = "Equatable, Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone, AsRefStr, EnumString)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum PushNotificationTypes { + Test, // Test payload + Transaction, // PushNotificationTransaction (Transaction) + Asset, + PriceAlert, // PriceAlert payload + BuyAsset, // PushNotificationBuyAsset payload + SwapAsset, // PushNotificationSwapAsset payload + Support, // PushNotificationSupport payload + Rewards, // PushNotificationReward payload + Stake, // PushNotificationWalletAsset payload + FiatTransaction, // PushNotificationWalletAsset payload +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PushNotification { + #[serde(rename = "type")] + pub notification_type: PushNotificationTypes, + pub data: Option, +} + +impl PushNotification { + pub fn new_buy_asset(asset_id: AssetId) -> Self { + Self { + notification_type: PushNotificationTypes::BuyAsset, + data: serde_json::to_value(PushNotificationAsset { asset_id }).ok(), + } + } + + pub fn new_fiat_transaction(wallet_id: WalletId, asset_id: AssetId) -> Self { + Self { + notification_type: PushNotificationTypes::FiatTransaction, + data: serde_json::to_value(PushNotificationWalletAsset { wallet_id, asset_id }).ok(), + } + } + + pub fn new_stake(wallet_id: WalletId, asset_id: AssetId) -> Self { + Self { + notification_type: PushNotificationTypes::Stake, + data: serde_json::to_value(PushNotificationWalletAsset { wallet_id, asset_id }).ok(), + } + } +} + +// Only used to decode notification type +#[typeshare(swift = "Equatable, Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PushNotificationPayloadType { + #[serde(rename = "type")] + pub notification_type: PushNotificationTypes, +} + +#[typeshare(swift = "Equatable, Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PushNotificationTransaction { + pub wallet_id: WalletId, + pub asset_id: AssetId, + #[typeshare(skip)] + pub transaction_id: String, + pub transaction: Transaction, +} + +#[typeshare(swift = "Equatable, Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PushNotificationAsset { + pub asset_id: AssetId, +} + +#[typeshare(swift = "Equatable, Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PushNotificationSwapAsset { + pub from_asset_id: AssetId, + pub to_asset_id: AssetId, +} + +#[typeshare(swift = "Equatable, Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PushNotificationSupport {} + +#[typeshare(swift = "Equatable, Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PushNotificationReward { + pub wallet_id: String, +} + +#[typeshare(swift = "Equatable, Sendable")] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PushNotificationWalletAsset { + pub wallet_id: WalletId, + pub asset_id: AssetId, +} diff --git a/core/crates/primitives/src/recent_activity_type.rs b/core/crates/primitives/src/recent_activity_type.rs new file mode 100644 index 0000000000..2d6bf46b4b --- /dev/null +++ b/core/crates/primitives/src/recent_activity_type.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum RecentActivityType { + Search, + Transfer, + Receive, + FiatBuy, + FiatSell, + Swap, + Perpetual, +} diff --git a/core/crates/primitives/src/response.rs b/core/crates/primitives/src/response.rs new file mode 100644 index 0000000000..d9c2e2c337 --- /dev/null +++ b/core/crates/primitives/src/response.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseResult { + Success(T), + Error(ResponseError), +} + +impl ResponseResult { + pub fn new(data: T) -> Self { + ResponseResult::Success(data) + } + + pub fn error(message: String) -> Self { + ResponseResult::Error(ResponseError { + error: ErrorDetail { message, data: None }, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseError { + pub error: ErrorDetail, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorDetail { + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseResultNew { + pub data: T, +} + +impl ResponseResultNew { + pub fn new(data: T) -> Self { + Self { data } + } +} diff --git a/core/crates/primitives/src/rewards.rs b/core/crates/primitives/src/rewards.rs new file mode 100644 index 0000000000..92e1f51601 --- /dev/null +++ b/core/crates/primitives/src/rewards.rs @@ -0,0 +1,263 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +use crate::Asset; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, EnumIter, AsRefStr, PartialEq)] +//#[typeshare(swift = "Equatable, Hashable, Sendable, CaseIterable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum RewardLevel {} + +impl RewardLevel { + pub fn all() -> Vec { + Self::iter().collect() + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, EnumIter, EnumString, AsRefStr, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Hashable, Sendable, CaseIterable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum RewardRedemptionType { + Asset, + GiftAsset, +} + +impl RewardRedemptionType { + pub fn all() -> Vec { + Self::iter().collect() + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, EnumIter, EnumString, AsRefStr, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable, CaseIterable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +#[derive(Default)] +pub enum RewardStatus { + #[default] + Unverified, + Pending, + Verified, + Trusted, + Disabled, +} + +impl RewardStatus { + pub fn all() -> Vec { + Self::iter().collect() + } + + pub fn is_verified(&self) -> bool { + match self { + Self::Verified | Self::Trusted => true, + Self::Unverified | Self::Pending | Self::Disabled => false, + } + } + + pub fn is_disabled(&self) -> bool { + match self { + Self::Disabled => true, + Self::Unverified | Self::Pending | Self::Verified | Self::Trusted => false, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, EnumIter, EnumString, AsRefStr, PartialEq)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum RewardEventType { + CreateUsername, + InvitePending, + InviteNew, + Joined, + Enabled, + Disabled, + Redeemed, +} + +impl RewardEventType { + pub fn all() -> Vec { + Self::iter().collect() + } + + pub fn points(&self) -> i32 { + match self { + Self::CreateUsername => 25, + Self::InvitePending => 0, + Self::InviteNew => 100, + Self::Joined => 10, + Self::Enabled => 50, + Self::Disabled => 0, + Self::Redeemed => 0, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ReferralQuota { + pub limit: i32, + pub available: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ReferralAllowance { + pub daily: ReferralQuota, + pub weekly: ReferralQuota, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct Rewards { + pub code: Option, + #[typeshare(skip)] + pub invite_reward_points: i32, + pub referral_count: i32, + pub points: i32, + pub used_referral_code: Option, + pub status: RewardStatus, + #[typeshare(skip)] + #[serde(skip)] + pub created_at: chrono::NaiveDateTime, + pub verify_after: Option>, + pub redemption_options: Vec, + pub disable_reason: Option, + #[typeshare(skip)] + pub referral_allowance: ReferralAllowance, +} + +impl Default for Rewards { + fn default() -> Self { + Self { + code: None, + invite_reward_points: RewardEventType::InviteNew.points(), + referral_count: 0, + points: 0, + used_referral_code: None, + status: RewardStatus::Unverified, + created_at: chrono::DateTime::::UNIX_EPOCH.naive_utc(), + verify_after: None, + redemption_options: vec![], + disable_reason: None, + referral_allowance: ReferralAllowance::default(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ReferralCode { + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct RewardEvent { + #[typeshare(skip)] + #[serde(skip)] + pub username: String, + #[typeshare(skip)] + pub event: RewardEventType, + pub points: i32, + pub created_at: DateTime, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct RewardRedemption { + pub id: i32, + pub option: RewardRedemptionOption, + pub status: RedemptionStatus, + pub transaction_id: Option, + pub created_at: DateTime, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, EnumString, EnumIter, AsRefStr, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, CaseIterable, Sendable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum RedemptionStatus { + Pending, + Processing, + Completed, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct RewardRedemptionOption { + pub id: String, + pub redemption_type: RewardRedemptionType, + pub points: i32, + pub asset: Option, + pub value: String, + pub remaining: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct RedemptionRequest { + pub id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct RedemptionResult { + pub redemption: RewardRedemption, +} + +#[derive(Clone, Debug)] +pub struct RedemptionResponse { + pub result: RedemptionResult, + pub redemption_id: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ReferralLeader { + pub username: String, + pub referrals: i32, + pub points: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ReferralLeaderboard { + pub daily: Vec, + pub weekly: Vec, + pub monthly: Vec, +} + +#[cfg(test)] +mod tests { + use super::RewardStatus; + + #[test] + fn test_reward_status() { + assert!(!RewardStatus::Unverified.is_verified()); + assert!(!RewardStatus::Pending.is_verified()); + assert!(RewardStatus::Verified.is_verified()); + assert!(RewardStatus::Trusted.is_verified()); + assert!(!RewardStatus::Disabled.is_verified()); + + assert!(!RewardStatus::Unverified.is_disabled()); + assert!(!RewardStatus::Pending.is_disabled()); + assert!(!RewardStatus::Verified.is_disabled()); + assert!(!RewardStatus::Trusted.is_disabled()); + assert!(RewardStatus::Disabled.is_disabled()); + } +} diff --git a/core/crates/primitives/src/scan.rs b/core/crates/primitives/src/scan.rs new file mode 100644 index 0000000000..381beab276 --- /dev/null +++ b/core/crates/primitives/src/scan.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +use crate::{AssetId, Chain, TransactionType}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ScanTransactionPayload { + pub origin: ScanAddressTarget, + pub target: ScanAddressTarget, + pub website: Option, + #[serde(rename = "type")] + pub transaction_type: TransactionType, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ScanTransaction { + pub is_malicious: bool, + pub is_memo_required: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ScanAddressTarget { + pub asset_id: AssetId, + pub address: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum AddressType { + Address, + Contract, + Validator, + Contact, + InternalWallet, +} + +impl AddressType { + pub fn all() -> Vec { + Self::iter().collect::>() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanAddress { + pub chain: Chain, + pub address: String, + pub name: Option, + #[serde(rename = "type")] + pub address_type: Option, + pub is_malicious: Option, + pub is_memo_required: Option, + pub is_verified: Option, +} + +impl ScanAddress { + pub fn contract(chain: Chain, address: impl Into, name: impl Into) -> Self { + Self { + chain, + address: address.into(), + name: Some(name.into()), + address_type: Some(AddressType::Contract), + is_malicious: Some(false), + is_memo_required: Some(false), + is_verified: Some(true), + } + } +} diff --git a/core/crates/primitives/src/search.rs b/core/crates/primitives/src/search.rs new file mode 100644 index 0000000000..85bfbdd121 --- /dev/null +++ b/core/crates/primitives/src/search.rs @@ -0,0 +1,20 @@ +use crate::{AssetBasic, NFTCollection, PerpetualSearchData}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Codable, Sendable")] +pub struct SearchResponse { + pub assets: Vec, + pub perpetuals: Vec, + pub nfts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Codable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SearchItemType { + Asset, + Perpetual, + Nft, +} diff --git a/core/crates/primitives/src/secure_preferences.rs b/core/crates/primitives/src/secure_preferences.rs new file mode 100644 index 0000000000..ee944d3b17 --- /dev/null +++ b/core/crates/primitives/src/secure_preferences.rs @@ -0,0 +1,110 @@ +use std::{ + collections::HashMap, + error::Error, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub trait SecurePreferences: Send + Sync { + fn get(&self, key: String) -> Result, Box>; + fn set(&self, key: String, value: String) -> Result<(), Box>; + fn remove(&self, key: String) -> Result<(), Box>; +} + +pub trait Preferences: Send + Sync { + fn get(&self, key: String) -> Result, Box>; + fn set(&self, key: String, value: String) -> Result<(), Box>; + fn remove(&self, key: String) -> Result<(), Box>; +} + +#[derive(Debug)] +pub struct InMemoryPreferences { + data: Mutex>, +} + +impl Default for InMemoryPreferences { + fn default() -> Self { + Self::new() + } +} + +impl InMemoryPreferences { + pub fn new() -> Self { + Self { data: Mutex::new(HashMap::new()) } + } +} + +impl Preferences for InMemoryPreferences { + fn get(&self, key: String) -> Result, Box> { + Ok(self.data.lock().unwrap().get(&key).cloned()) + } + + fn set(&self, key: String, value: String) -> Result<(), Box> { + self.data.lock().unwrap().insert(key, value); + Ok(()) + } + + fn remove(&self, key: String) -> Result<(), Box> { + self.data.lock().unwrap().remove(&key); + Ok(()) + } +} + +pub trait PreferencesExt { + fn get_i64(&self, key: &str) -> Result, Box>; + fn set_i64(&self, key: &str, value: i64) -> Result<(), Box>; + fn get_bool(&self, key: &str) -> Result, Box>; + fn set_bool(&self, key: &str, value: bool) -> Result<(), Box>; + fn get_i64_with_ttl(&self, key: &str, ttl_seconds: u64) -> Result, Box>; + fn set_i64_with_ttl(&self, key: &str, value: i64, ttl_seconds: u64) -> Result<(), Box>; +} + +impl PreferencesExt for T { + fn get_i64(&self, key: &str) -> Result, Box> { + if let Some(value) = self.get(key.to_string())? { + Ok(Some(value.parse()?)) + } else { + Ok(None) + } + } + + fn set_i64(&self, key: &str, value: i64) -> Result<(), Box> { + self.set(key.to_string(), value.to_string()) + } + + fn get_bool(&self, key: &str) -> Result, Box> { + if let Some(value) = self.get(key.to_string())? { + Ok(Some(value.parse()?)) + } else { + Ok(None) + } + } + + fn set_bool(&self, key: &str, value: bool) -> Result<(), Box> { + self.set(key.to_string(), value.to_string()) + } + + fn get_i64_with_ttl(&self, key: &str, ttl_seconds: u64) -> Result, Box> { + let timestamp_key = format!("{}_timestamp", key); + + if let (Some(value), Some(timestamp)) = (self.get(key.to_string())?, self.get(timestamp_key)?) { + let cached_time: u64 = timestamp.parse()?; + let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + if current_time - cached_time < ttl_seconds { + return Ok(Some(value.parse()?)); + } + } + + Ok(None) + } + + fn set_i64_with_ttl(&self, key: &str, value: i64, _ttl_seconds: u64) -> Result<(), Box> { + let timestamp_key = format!("{}_timestamp", key); + let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + self.set(key.to_string(), value.to_string())?; + self.set(timestamp_key, current_time.to_string())?; + Ok(()) + } +} diff --git a/core/crates/primitives/src/signer_error.rs b/core/crates/primitives/src/signer_error.rs new file mode 100644 index 0000000000..fe201ac87f --- /dev/null +++ b/core/crates/primitives/src/signer_error.rs @@ -0,0 +1,72 @@ +use crate::{AddressError, HexError}; + +#[derive(Debug, Clone)] +pub enum SignerError { + InvalidInput(String), + SigningError(String), +} + +impl std::fmt::Display for SignerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SignerError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), + SignerError::SigningError(msg) => write!(f, "Signing error: {}", msg), + } + } +} + +impl std::error::Error for SignerError {} + +impl SignerError { + pub fn invalid_input(message: impl Into) -> Self { + Self::InvalidInput(message.into()) + } + + pub fn invalid_input_err(message: impl Into) -> Result { + Err(Self::invalid_input(message)) + } + + pub fn signing_error(message: impl Into) -> Self { + Self::SigningError(message.into()) + } + + pub fn from_display(e: impl std::fmt::Display) -> Self { + Self::InvalidInput(e.to_string()) + } +} + +impl From for SignerError { + fn from(error: serde_json::Error) -> Self { + SignerError::InvalidInput(error.to_string()) + } +} + +impl From for SignerError { + fn from(error: HexError) -> Self { + SignerError::InvalidInput(error.to_string()) + } +} + +impl From for SignerError { + fn from(error: AddressError) -> Self { + SignerError::InvalidInput(error.to_string()) + } +} + +impl From<&str> for SignerError { + fn from(error: &str) -> Self { + SignerError::InvalidInput(error.to_string()) + } +} + +impl From> for SignerError { + fn from(error: Box) -> Self { + SignerError::InvalidInput(error.to_string()) + } +} + +impl From for SignerError { + fn from(error: num_bigint::ParseBigIntError) -> Self { + SignerError::InvalidInput(error.to_string()) + } +} diff --git a/core/crates/primitives/src/simulation.rs b/core/crates/primitives/src/simulation.rs new file mode 100644 index 0000000000..48fc56604f --- /dev/null +++ b/core/crates/primitives/src/simulation.rs @@ -0,0 +1,421 @@ +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::AssetId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SimulationSeverity { + Low, + Warning, + Critical, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SimulationWarningApproval { + pub asset_id: AssetId, + pub value: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(tag = "type", content = "content", rename_all = "camelCase")] +pub enum SimulationWarningType { + TokenApproval(SimulationWarningApproval), + SuspiciousSpender, + ExternallyOwnedSpender, + NftCollectionApproval(AssetId), + PermitApproval(SimulationWarningApproval), + PermitBatchApproval(Option), + ValidationError, +} + +impl SimulationWarningType { + fn requires_spender_verification(&self) -> bool { + match self { + Self::SuspiciousSpender | Self::ExternallyOwnedSpender | Self::ValidationError => false, + Self::TokenApproval(_) | Self::NftCollectionApproval(_) | Self::PermitApproval(_) | Self::PermitBatchApproval(_) => true, + } + } + + fn approval_value(&self) -> Option<&Option> { + match self { + Self::TokenApproval(a) | Self::PermitApproval(a) => Some(&a.value), + Self::PermitBatchApproval(value) => Some(value), + Self::SuspiciousSpender | Self::ExternallyOwnedSpender | Self::NftCollectionApproval(_) | Self::ValidationError => None, + } + } + + fn collapse_priority(&self, severity: SimulationSeverity) -> u8 { + match self { + Self::ExternallyOwnedSpender => 2, + Self::ValidationError if severity == SimulationSeverity::Critical => 2, + _ if self.approval_value().is_some_and(Option::is_none) => 1, + _ => 0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SimulationWarning { + pub severity: SimulationSeverity, + pub warning: SimulationWarningType, + pub message: Option, +} + +impl SimulationWarning { + pub fn new(severity: SimulationSeverity, warning: SimulationWarningType, message: Option) -> Self { + Self { severity, warning, message } + } + + fn collapse_priority(&self) -> u8 { + self.warning.collapse_priority(self.severity) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SimulationBalanceChange { + pub asset_id: AssetId, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SimulationPayloadFieldType { + Text, + Address, + Timestamp, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SimulationPayloadFieldDisplay { + Primary, + Secondary, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SimulationPayloadFieldKind { + Contract, + Method, + Token, + Spender, + Value, + Custom, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SimulationPayloadField { + pub kind: SimulationPayloadFieldKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + pub value: String, + pub field_type: SimulationPayloadFieldType, + pub display: SimulationPayloadFieldDisplay, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SimulationHeader { + pub asset_id: AssetId, + pub value: String, + pub is_unlimited: bool, +} + +impl SimulationPayloadField { + pub fn standard(kind: SimulationPayloadFieldKind, value: impl Into, field_type: SimulationPayloadFieldType, display: SimulationPayloadFieldDisplay) -> Self { + debug_assert!(kind != SimulationPayloadFieldKind::Custom); + Self { + kind, + label: None, + value: value.into(), + field_type, + display, + } + } + + pub fn custom(label: impl Into, value: impl Into, field_type: SimulationPayloadFieldType, display: SimulationPayloadFieldDisplay) -> Self { + let label = label.into(); + debug_assert!(!label.is_empty()); + Self { + kind: SimulationPayloadFieldKind::Custom, + label: Some(label), + value: value.into(), + field_type, + display, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SimulationResult { + pub warnings: Vec, + pub balance_changes: Vec, + pub payload: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub header: Option, +} + +impl Default for SimulationResult { + fn default() -> Self { + Self::new(vec![], vec![]) + } +} + +impl SimulationResult { + pub fn new(warnings: Vec, payload: Vec) -> Self { + Self { + warnings: Self::collapse_warnings(warnings), + balance_changes: vec![], + payload: promote_single_secondary_payload_field(payload), + header: None, + } + } + + pub fn prepend_warnings(mut self, warnings: Vec) -> Self { + self.warnings = Self::collapse_warnings(warnings.into_iter().chain(self.warnings).collect()); + self + } + + pub fn requires_spender_verification(&self) -> bool { + self.warnings.iter().any(|warning| warning.warning.requires_spender_verification()) + } + + fn collapse_warnings(warnings: Vec) -> Vec { + if let Some(warning) = warnings.iter().find(|warning| warning.collapse_priority() == 2).cloned() { + return vec![warning]; + } + + if let Some(warning) = warnings.iter().find(|warning| warning.collapse_priority() == 1).cloned() { + return vec![warning]; + } + + warnings + } +} + +pub fn promote_single_secondary_payload_field(payload: Vec) -> Vec { + let secondary_count = payload.iter().filter(|field| field.display == SimulationPayloadFieldDisplay::Secondary).count(); + + if secondary_count != 1 { + return payload; + } + + payload + .into_iter() + .map(|field| { + if field.display == SimulationPayloadFieldDisplay::Secondary { + SimulationPayloadField { + display: SimulationPayloadFieldDisplay::Primary, + ..field + } + } else { + field + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{ + SimulationPayloadField, SimulationPayloadFieldDisplay, SimulationPayloadFieldKind, SimulationPayloadFieldType, SimulationResult, SimulationSeverity, SimulationWarning, + SimulationWarningApproval, SimulationWarningType, promote_single_secondary_payload_field, + }; + use num_bigint::BigInt; + + #[test] + fn simulation_result_keeps_only_blocking_warning() { + let result = SimulationResult::new( + vec![ + SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::PermitApproval(SimulationWarningApproval { + asset_id: "ethereum_0x123".into(), + value: Some(BigInt::from(100)), + }), + None, + ), + SimulationWarning::new(SimulationSeverity::Critical, SimulationWarningType::ExternallyOwnedSpender, None), + ], + Vec::::new(), + ); + + assert_eq!( + result.warnings, + vec![SimulationWarning::new(SimulationSeverity::Critical, SimulationWarningType::ExternallyOwnedSpender, None,)] + ); + } + + #[test] + fn approval_simulation_requires_spender_verification() { + let result = SimulationResult::new( + vec![SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::PermitApproval(SimulationWarningApproval { + asset_id: "ethereum_0x123".into(), + value: Some(BigInt::from(100)), + }), + None, + )], + vec![], + ); + + assert!(result.requires_spender_verification()); + } + + #[test] + fn validation_warning_suppresses_secondary_warnings() { + let result = SimulationResult::new( + vec![ + SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::PermitApproval(SimulationWarningApproval { + asset_id: "ethereum_0x123".into(), + value: Some(BigInt::from(100)), + }), + None, + ), + SimulationWarning::new( + SimulationSeverity::Critical, + SimulationWarningType::ValidationError, + Some("Unable to verify spender is a contract".to_string()), + ), + ], + Vec::::new(), + ); + + assert_eq!( + result.warnings, + vec![SimulationWarning::new( + SimulationSeverity::Critical, + SimulationWarningType::ValidationError, + Some("Unable to verify spender is a contract".to_string()), + )] + ); + } + + #[test] + fn unlimited_warning_wins_when_present() { + let result = SimulationResult::new( + vec![SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::PermitApproval(SimulationWarningApproval { + asset_id: "ethereum_0x123".into(), + value: None, + }), + None, + )], + Vec::::new(), + ); + + assert_eq!( + result.warnings, + vec![SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::PermitApproval(SimulationWarningApproval { + asset_id: "ethereum_0x123".into(), + value: None, + }), + None, + )] + ); + } + + #[test] + fn unlimited_secondary_warning_suppresses_redundant_token_approval_warning() { + let result = SimulationResult::new( + vec![ + SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::TokenApproval(SimulationWarningApproval { + asset_id: "ethereum_0x123".into(), + value: Some(BigInt::from(1000)), + }), + None, + ), + SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::TokenApproval(SimulationWarningApproval { + asset_id: "ethereum_0x123".into(), + value: None, + }), + None, + ), + ], + Vec::::new(), + ); + + assert_eq!( + result.warnings, + vec![SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::TokenApproval(SimulationWarningApproval { + asset_id: "ethereum_0x123".into(), + value: None, + }), + None, + )] + ); + } + + #[test] + fn single_secondary_payload_field_is_promoted_to_primary() { + let payload = promote_single_secondary_payload_field(vec![ + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Contract, + "0x123", + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + ), + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Value, + "Unlimited", + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Secondary, + ), + ]); + + assert_eq!(payload.len(), 2); + assert!(payload.iter().all(|field| field.display == SimulationPayloadFieldDisplay::Primary)); + } + + #[test] + fn multiple_secondary_payload_fields_stay_secondary() { + let payload = promote_single_secondary_payload_field(vec![ + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Contract, + "0x123", + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + ), + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Value, + "Unlimited", + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Secondary, + ), + SimulationPayloadField::custom("expiration", "123", SimulationPayloadFieldType::Timestamp, SimulationPayloadFieldDisplay::Secondary), + ]); + + assert_eq!(payload[1].display, SimulationPayloadFieldDisplay::Secondary); + assert_eq!(payload[2].display, SimulationPayloadFieldDisplay::Secondary); + } +} diff --git a/core/crates/primitives/src/solana_nft.rs b/core/crates/primitives/src/solana_nft.rs new file mode 100644 index 0000000000..e5c7573886 --- /dev/null +++ b/core/crates/primitives/src/solana_nft.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum SolanaNftStandard { + NonFungible, + ProgrammableNonFungible { rule_set: Option }, + Core { collection: Option }, +} diff --git a/core/crates/primitives/src/solana_token_program.rs b/core/crates/primitives/src/solana_token_program.rs new file mode 100644 index 0000000000..89de139eaf --- /dev/null +++ b/core/crates/primitives/src/solana_token_program.rs @@ -0,0 +1,23 @@ +use crate::AssetType; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum SolanaTokenProgramId { + Token, + Token2022, +} + +impl SolanaTokenProgramId { + pub fn from_asset_type(asset_type: &AssetType) -> Option { + match asset_type { + AssetType::SPL => Some(Self::Token), + AssetType::SPL2022 => Some(Self::Token2022), + _ => None, + } + } +} diff --git a/core/crates/primitives/src/solana_types.rs b/core/crates/primitives/src/solana_types.rs new file mode 100644 index 0000000000..68a626dac9 --- /dev/null +++ b/core/crates/primitives/src/solana_types.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SolanaInstruction { + pub program_id: String, + #[serde(alias = "keys")] + pub accounts: Vec, + pub data: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SolanaAccountMeta { + pub pubkey: String, + pub is_signer: bool, + pub is_writable: bool, +} diff --git a/core/crates/primitives/src/stake_provider_type.rs b/core/crates/primitives/src/stake_provider_type.rs new file mode 100644 index 0000000000..41b57c28a3 --- /dev/null +++ b/core/crates/primitives/src/stake_provider_type.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, Display, EnumString}; +use typeshare::typeshare; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, Display, AsRefStr, EnumString, PartialEq, Eq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum StakeProviderType { + Stake, + Earn, +} diff --git a/core/crates/primitives/src/stake_type.rs b/core/crates/primitives/src/stake_type.rs new file mode 100644 index 0000000000..e99aac5f83 --- /dev/null +++ b/core/crates/primitives/src/stake_type.rs @@ -0,0 +1,67 @@ +use crate::{Delegation, DelegationValidator, UInt64}; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub struct RedelegateData { + pub delegation: Delegation, + #[serde(rename = "toValidator")] + pub to_validator: DelegationValidator, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase", ascii_case_insensitive)] +pub enum Resource { + Bandwidth, + Energy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub enum StakeType { + Stake(DelegationValidator), + Unstake(Delegation), + Redelegate(RedelegateData), + Rewards(Vec), + Withdraw(Delegation), + Freeze(Resource), + Unfreeze(Resource), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub struct TronVote { + pub validator: String, + pub count: UInt64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub struct TronUnfreeze { + pub resource: Resource, + pub amount: UInt64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +pub enum TronStakeData { + Votes(Vec), + Unfreeze(Vec), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_parses_tron_resource_names() { + assert_eq!("BANDWIDTH".parse::().unwrap(), Resource::Bandwidth); + assert_eq!("ENERGY".parse::().unwrap(), Resource::Energy); + } +} diff --git a/core/crates/primitives/src/stream.rs b/core/crates/primitives/src/stream.rs new file mode 100644 index 0000000000..e865936f6c --- /dev/null +++ b/core/crates/primitives/src/stream.rs @@ -0,0 +1,82 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{AssetId, InAppNotification, TransactionId, WalletId, WebSocketPricePayload}; + +pub const DEVICE_STREAM_CHANNEL_PREFIX: &str = "stream:device:"; + +pub fn device_stream_channel(device_id: &str) -> String { + format!("{DEVICE_STREAM_CHANNEL_PREFIX}{device_id}") +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", content = "data", rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +#[allow(clippy::large_enum_variant)] +pub enum StreamEvent { + Prices(WebSocketPricePayload), + Balances(StreamBalanceUpdate), + Transactions(StreamTransactionsUpdate), + PriceAlerts(StreamPriceAlertUpdate), + Nft(StreamWalletUpdate), + Perpetual(StreamWalletUpdate), + InAppNotification(StreamNotificationUpdate), + FiatTransaction(StreamWalletUpdate), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub struct StreamMessagePrices { + pub assets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub enum StreamMessage { + GetPrices(StreamMessagePrices), + SubscribePrices(StreamMessagePrices), + UnsubscribePrices(StreamMessagePrices), + AddPrices(StreamMessagePrices), + SubscribeRealtimePrices(StreamMessagePrices), + UnsubscribeRealtimePrices(StreamMessagePrices), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub struct StreamBalanceUpdate { + pub wallet_id: WalletId, + pub asset_id: AssetId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub struct StreamTransactionsUpdate { + pub wallet_id: WalletId, + pub transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub struct StreamPriceAlertUpdate { + pub assets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub struct StreamWalletUpdate { + pub wallet_id: WalletId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub struct StreamNotificationUpdate { + pub wallet_id: WalletId, + pub notification: InAppNotification, +} diff --git a/core/crates/primitives/src/string_serde.rs b/core/crates/primitives/src/string_serde.rs new file mode 100644 index 0000000000..0711c8f20f --- /dev/null +++ b/core/crates/primitives/src/string_serde.rs @@ -0,0 +1,19 @@ +/// Implements `Serialize` and `Deserialize` for a type that round-trips through its `Display` +/// and `FromStr` impls. Use for typed string ids (e.g. `PriceId`, `WalletId`, `NFTAssetId`). +#[macro_export] +macro_rules! impl_string_serde { + ($t:ty) => { + impl serde::Serialize for $t { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } + } + + impl<'de> serde::Deserialize<'de> for $t { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } + } + }; +} diff --git a/core/crates/primitives/src/subscription.rs b/core/crates/primitives/src/subscription.rs new file mode 100644 index 0000000000..66ac5f97f2 --- /dev/null +++ b/core/crates/primitives/src/subscription.rs @@ -0,0 +1,90 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::chain::Chain; +use crate::chain_address::ChainAddress; +use crate::device::Device; +use crate::wallet::WalletSource; +use crate::wallet_id::WalletId; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct AddressChains { + pub address: String, + pub chains: Vec, +} + +impl AddressChains { + pub fn new(address: String, chains: Vec) -> Self { + Self { + address, + chains: chains.into_iter().collect::>().into_iter().collect(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WalletSubscription { + #[typeshare(serialized_as = "String")] + pub wallet_id: WalletId, + #[serde(default)] + pub source: Option, + pub subscriptions: Vec, +} + +impl WalletSubscription { + pub fn chain_addresses(&self) -> Vec { + let mut seen = BTreeSet::new(); + self.subscriptions + .iter() + .flat_map(|x| x.chains.iter().map(|&chain| ChainAddress::new(chain, x.address.clone()))) + .filter(|x| seen.insert((x.chain, x.address.clone()))) + .collect() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WalletSubscriptionLegacy { + pub wallet_id: WalletId, + #[serde(default)] + pub source: Option, + pub subscriptions: Vec, +} + +impl From for WalletSubscription { + fn from(legacy: WalletSubscriptionLegacy) -> Self { + let mut by_address: BTreeMap> = BTreeMap::new(); + for subscription in &legacy.subscriptions { + by_address.entry(subscription.address.clone()).or_default().push(subscription.chain); + } + Self { + wallet_id: legacy.wallet_id, + source: legacy.source, + subscriptions: by_address.into_iter().map(|(address, chains)| AddressChains::new(address, chains)).collect(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WalletSubscriptionChains { + #[typeshare(serialized_as = "String")] + pub wallet_id: WalletId, + pub chains: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DeviceSubscription { + pub wallet_row_id: i32, + pub device: Device, + pub wallet_id: WalletId, + pub chain: Chain, + pub address: String, +} diff --git a/core/crates/primitives/src/swap/approval.rs b/core/crates/primitives/src/swap/approval.rs new file mode 100644 index 0000000000..8e279b99e2 --- /dev/null +++ b/core/crates/primitives/src/swap/approval.rs @@ -0,0 +1,117 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{SwapProvider, TransactionState}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ApprovalData { + pub token: String, + pub spender: String, + pub value: String, + pub is_unlimited: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SwapQuoteDataType { + Contract, + Transfer, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SwapQuoteData { + pub to: String, + pub data_type: SwapQuoteDataType, + pub value: String, + pub data: String, + pub memo: Option, + pub approval: Option, + pub gas_limit: Option, +} + +impl SwapQuoteData { + pub fn gas_limit_as_u32(&self) -> Result { + self.gas_limit.as_ref().ok_or("gas_limit is required")?.parse().map_err(|_| "invalid gas_limit") + } + + pub fn new_contract(to: String, value: String, data: String, approval: Option, gas_limit: Option) -> Self { + Self { + to, + data_type: SwapQuoteDataType::Contract, + value, + data, + memo: None, + approval, + gas_limit, + } + } + + pub fn new_tranfer(to: String, value: String, memo: Option) -> Self { + Self { + to, + data_type: SwapQuoteDataType::Transfer, + value, + data: "".to_string(), + memo, + approval: None, + gas_limit: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SwapData { + pub quote: SwapQuote, + pub data: SwapQuoteData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SwapQuote { + pub from_address: String, + pub from_value: String, + #[serde(default)] + pub min_from_value: Option, + pub to_address: String, + pub to_value: String, + pub provider_data: SwapProviderData, + pub slippage_bps: u32, + pub eta_in_seconds: Option, + pub use_max_amount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SwapProviderData { + pub provider: SwapProvider, + pub name: String, + pub protocol_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum SwapStatus { + Pending, + Completed, + Failed, +} + +impl SwapStatus { + pub fn transaction_state(&self) -> Option { + match self { + SwapStatus::Completed => Some(TransactionState::Confirmed), + SwapStatus::Failed => Some(TransactionState::Failed), + SwapStatus::Pending => None, + } + } +} diff --git a/core/crates/primitives/src/swap/mod.rs b/core/crates/primitives/src/swap/mod.rs new file mode 100644 index 0000000000..c205771b16 --- /dev/null +++ b/core/crates/primitives/src/swap/mod.rs @@ -0,0 +1,38 @@ +pub mod approval; +pub mod mode; +pub mod price_impact; +pub mod quote_asset; +pub mod slippage; +pub use approval::SwapQuoteData; +pub use approval::*; +pub use mode::*; +pub use price_impact::*; +pub use quote_asset::QuoteAsset; +pub mod result; +pub use result::*; +use serde::{Deserialize, Serialize}; +pub use slippage::*; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare] +pub struct ProxyQuote { + pub quote: ProxyQuoteRequest, + pub output_value: String, + pub output_min_value: String, + pub route_data: serde_json::Value, + pub eta_in_seconds: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare] +pub struct ProxyQuoteRequest { + pub from_address: String, + pub to_address: String, + pub from_asset: QuoteAsset, + pub to_asset: QuoteAsset, + pub from_value: String, + pub referral_bps: u32, + pub slippage_bps: u32, + pub use_max_amount: bool, +} diff --git a/core/crates/primitives/src/swap/mode.rs b/core/crates/primitives/src/swap/mode.rs new file mode 100644 index 0000000000..0fc19b7358 --- /dev/null +++ b/core/crates/primitives/src/swap/mode.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::Chain; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +#[typeshare] +pub enum SwapProviderMode { + OnChain, + CrossChain, + Bridge, + OmniChain(Vec), // supports both on-chain and cross-chain. Specify the chain for on-chain swaps +} diff --git a/core/crates/primitives/src/swap/price_impact.rs b/core/crates/primitives/src/swap/price_impact.rs new file mode 100644 index 0000000000..e6e5783960 --- /dev/null +++ b/core/crates/primitives/src/swap/price_impact.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SwapPriceImpactType { + Positive, + Low, + Medium, + High, +} diff --git a/core/crates/primitives/src/swap/quote_asset.rs b/core/crates/primitives/src/swap/quote_asset.rs new file mode 100644 index 0000000000..5c7f899024 --- /dev/null +++ b/core/crates/primitives/src/swap/quote_asset.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{AssetId, Chain}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare] +pub struct QuoteAsset { + pub id: String, + pub symbol: String, + pub decimals: u32, +} + +impl QuoteAsset { + pub fn asset_id(&self) -> AssetId { + AssetId::new(&self.id).unwrap() + } + + pub fn is_native(&self) -> bool { + self.asset_id().is_native() + } + + pub fn chain(&self) -> Chain { + self.asset_id().chain + } +} + +impl From for QuoteAsset { + fn from(id: AssetId) -> Self { + Self { + id: id.to_string(), + symbol: String::new(), + decimals: 0, + } + } +} diff --git a/core/crates/primitives/src/swap/result.rs b/core/crates/primitives/src/swap/result.rs new file mode 100644 index 0000000000..0667cabf2c --- /dev/null +++ b/core/crates/primitives/src/swap/result.rs @@ -0,0 +1,12 @@ +use super::SwapStatus; +use crate::TransactionSwapMetadata; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct SwapResult { + pub status: SwapStatus, + pub metadata: Option, +} diff --git a/core/crates/primitives/src/swap/slippage.rs b/core/crates/primitives/src/swap/slippage.rs new file mode 100644 index 0000000000..b1ba639bb9 --- /dev/null +++ b/core/crates/primitives/src/swap/slippage.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Slippage { + pub bps: u32, + pub mode: SlippageMode, +} + +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum SlippageMode { + Auto, + Exact, +} + +impl From for Slippage { + fn from(value: u32) -> Self { + Slippage { + bps: value, + mode: SlippageMode::Exact, + } + } +} diff --git a/core/crates/primitives/src/swap_provider.rs b/core/crates/primitives/src/swap_provider.rs new file mode 100644 index 0000000000..30108c571d --- /dev/null +++ b/core/crates/primitives/src/swap_provider.rs @@ -0,0 +1,162 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +use crate::{ + block_explorer::BlockExplorer, + chain::Chain, + explorers::{ChainflipScan, MayanScan, NearIntents, RelayScan, RuneScan, SkipExplorer, SocketScan}, +}; + +#[derive(Debug, Copy, Clone, PartialEq, AsRefStr, EnumString, Eq, PartialOrd, Ord, Serialize, Deserialize, EnumIter)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum SwapProvider { + UniswapV3, + UniswapV4, + PancakeswapV3, + Aerodrome, + Panora, + Thorchain, + Jupiter, + Okx, + Across, + Oku, + Wagmi, + StonfiV2, + Mayan, + Chainflip, + NearIntents, + // TODO: delete CetusAggregator once mobile clients stop referencing it + CetusAggregator, + CetusClmm, + Relay, + Hyperliquid, + Orca, + Squid, +} + +impl SwapProvider { + pub fn id(&self) -> &str { + self.as_ref() + } + + pub fn all() -> Vec { + Self::iter().collect::>() + } + + pub fn is_cross_chain(&self) -> bool { + match self { + Self::Thorchain | Self::Across | Self::Mayan | Self::Chainflip | Self::NearIntents | Self::Relay | Self::Hyperliquid | Self::Squid => true, + Self::UniswapV3 + | Self::UniswapV4 + | Self::PancakeswapV3 + | Self::Panora + | Self::Jupiter + | Self::Okx + | Self::Oku + | Self::Wagmi + | Self::CetusAggregator + | Self::CetusClmm + | Self::StonfiV2 + | Self::Aerodrome + | Self::Orca => false, + } + } + + pub fn cross_chain_providers() -> Vec { + Self::all().into_iter().filter(Self::is_cross_chain).collect() + } + + pub fn swap_explorer(&self, chain: Chain) -> Option> { + match self { + Self::Mayan => Some(MayanScan::boxed()), + Self::Thorchain => Some(RuneScan::boxed()), + Self::Across => Some(SocketScan::boxed()), + Self::Chainflip => Some(ChainflipScan::boxed()), + Self::NearIntents => Some(NearIntents::boxed()), + Self::Relay => Some(RelayScan::boxed()), + Self::Squid => Some(SkipExplorer::boxed(chain)), + Self::UniswapV3 + | Self::UniswapV4 + | Self::PancakeswapV3 + | Self::Panora + | Self::Jupiter + | Self::Okx + | Self::Oku + | Self::Wagmi + | Self::CetusAggregator + | Self::CetusClmm + | Self::StonfiV2 + | Self::Aerodrome + | Self::Hyperliquid + | Self::Orca => None, + } + } + + pub fn name(&self) -> &str { + match self { + Self::UniswapV3 | Self::UniswapV4 => "Uniswap", + Self::PancakeswapV3 => "PancakeSwap", + Self::Aerodrome => "Aerodrome", + Self::Panora => "Panora", + Self::Thorchain => "THORChain", + Self::Jupiter => "Jupiter", + Self::Okx => "OKX (DEX)", + Self::Across => "Across", + Self::Oku => "Oku", + Self::Wagmi => "Wagmi", + Self::CetusAggregator | Self::CetusClmm => "Cetus", + Self::StonfiV2 => "STON.fi", + Self::Mayan => "Mayan", + Self::Chainflip => "Chainflip", + Self::NearIntents => "NEAR Intents", + Self::Relay => "Relay", + Self::Hyperliquid => "Hyperliquid", + Self::Orca => "Orca", + Self::Squid => "Squid", + } + } + + pub fn protocol_name(&self) -> &str { + match self { + Self::UniswapV3 => "Uniswap v3", + Self::UniswapV4 => "Uniswap v4", + Self::PancakeswapV3 => "PancakeSwap v3", + Self::Panora => "Panora", + Self::Across => "Across v3", + Self::Oku => "Oku", + Self::StonfiV2 => "STON.fi v2", + Self::CetusAggregator | Self::CetusClmm => "Cetus", + Self::Thorchain + | Self::Jupiter + | Self::Okx + | Self::Wagmi + | Self::Mayan + | Self::Chainflip + | Self::NearIntents + | Self::Aerodrome + | Self::Relay + | Self::Hyperliquid + | Self::Orca + | Self::Squid => self.name(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_cross_chain() { + assert!(SwapProvider::Thorchain.is_cross_chain()); + assert!(SwapProvider::Across.is_cross_chain()); + assert!(SwapProvider::Mayan.is_cross_chain()); + assert!(SwapProvider::NearIntents.is_cross_chain()); + assert!(SwapProvider::Relay.is_cross_chain()); + assert!(!SwapProvider::UniswapV3.is_cross_chain()); + assert!(!SwapProvider::Jupiter.is_cross_chain()); + } +} diff --git a/core/crates/primitives/src/tag.rs b/core/crates/primitives/src/tag.rs new file mode 100644 index 0000000000..9b714440b1 --- /dev/null +++ b/core/crates/primitives/src/tag.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +#[derive(Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum AssetTag { + Trending, + TrendingFiatPurchase, + Gainers, + Losers, + New, + Stablecoins, +} + +impl AssetTag { + pub fn all() -> Vec { + Self::iter().collect::>() + } +} diff --git a/core/crates/primitives/src/testkit/address_name_mock.rs b/core/crates/primitives/src/testkit/address_name_mock.rs new file mode 100644 index 0000000000..65048ef1d7 --- /dev/null +++ b/core/crates/primitives/src/testkit/address_name_mock.rs @@ -0,0 +1,13 @@ +use crate::{AddressName, AddressType, Chain, VerificationStatus}; + +impl AddressName { + pub fn mock(address: &str, name: &str, address_type: AddressType, status: VerificationStatus) -> Self { + Self { + chain: Chain::Ethereum, + address: address.to_string(), + name: name.to_string(), + address_type, + status, + } + } +} diff --git a/core/crates/primitives/src/testkit/asset_mock.rs b/core/crates/primitives/src/testkit/asset_mock.rs new file mode 100644 index 0000000000..9a579789a0 --- /dev/null +++ b/core/crates/primitives/src/testkit/asset_mock.rs @@ -0,0 +1,72 @@ +use crate::{ + Asset, AssetId, AssetType, Chain, + asset_constants::{ETHEREUM_USDC_ASSET_ID, SOLANA_USDC_ASSET_ID, TON_USDT_ASSET_ID}, +}; + +impl Asset { + pub fn mock() -> Self { + Asset::from_chain(Chain::Ethereum) + } + + pub fn mock_sol() -> Self { + Asset::from_chain(Chain::Solana) + } + + pub fn mock_spl_token() -> Self { + Asset::new(SOLANA_USDC_ASSET_ID.clone(), "USD Coin".to_string(), "USDC".to_string(), 6, AssetType::SPL) + } + + pub fn mock_ethereum_usdc() -> Self { + Asset::new(ETHEREUM_USDC_ASSET_ID.clone(), "USD Coin".to_string(), "USDC".to_string(), 6, AssetType::ERC20) + } + + pub fn mock_ton_usdt() -> Self { + Asset::new(TON_USDT_ASSET_ID.clone(), "Tether USD".to_string(), "USDT".to_string(), 6, AssetType::JETTON) + } + + pub fn mock_eth() -> Self { + Asset::from_chain(Chain::Ethereum) + } + + pub fn mock_btc() -> Self { + Asset::from_chain(Chain::Bitcoin) + } + + pub fn mock_erc20() -> Self { + Asset::new( + AssetId::from_token(Chain::Ethereum, "0xA0b86a33E6441066d64bb38954e41F6b4b925c59"), + "USD Coin".to_string(), + "USDC".to_string(), + 6, + AssetType::ERC20, + ) + } + + pub fn mock_with_chain(chain: Chain) -> Self { + Asset::from_chain(chain) + } + + pub fn mock_with_params(chain: Chain, token_id: Option, name: String, symbol: String, decimals: i32, asset_type: AssetType) -> Self { + Asset::new(AssetId::from(chain, token_id), name, symbol, decimals, asset_type) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_asset_mock() { + let asset = Asset::mock(); + assert_eq!(asset.symbol, "ETH"); + assert_eq!(asset.chain, Chain::Ethereum); + + let sol_asset = Asset::mock_sol(); + assert_eq!(sol_asset.symbol, "SOL"); + assert_eq!(sol_asset.chain, Chain::Solana); + + let spl_asset = Asset::mock_spl_token(); + assert_eq!(spl_asset.symbol, "USDC"); + assert_eq!(spl_asset.asset_type, AssetType::SPL); + } +} diff --git a/core/crates/primitives/src/testkit/contract_call_data_mock.rs b/core/crates/primitives/src/testkit/contract_call_data_mock.rs new file mode 100644 index 0000000000..4dc09bc00f --- /dev/null +++ b/core/crates/primitives/src/testkit/contract_call_data_mock.rs @@ -0,0 +1,20 @@ +use super::signer_mock::TEST_EVM_RECIPIENT; +use crate::contract_call_data::ContractCallData; + +impl ContractCallData { + pub fn mock() -> Self { + ContractCallData { + contract_address: TEST_EVM_RECIPIENT.to_string(), + call_data: "abcd".to_string(), + approval: None, + gas_limit: None, + } + } + + pub fn mock_with_call_data(call_data: &str) -> Self { + ContractCallData { + call_data: call_data.to_string(), + ..Self::mock() + } + } +} diff --git a/core/crates/primitives/src/testkit/delegation_mock.rs b/core/crates/primitives/src/testkit/delegation_mock.rs new file mode 100644 index 0000000000..e60eb7b517 --- /dev/null +++ b/core/crates/primitives/src/testkit/delegation_mock.rs @@ -0,0 +1,97 @@ +use crate::{AssetId, Chain, Delegation, DelegationBase, DelegationState, DelegationValidator}; +use num_bigint::BigUint; + +impl Delegation { + pub fn mock() -> Self { + Delegation { + base: DelegationBase::mock(), + validator: DelegationValidator::mock(), + price: None, + } + } + + pub fn mock_base(base: DelegationBase) -> Self { + Delegation { + base, + validator: DelegationValidator::mock(), + price: None, + } + } + + pub fn mock_tron(validator_id: &str) -> Self { + let validator_id = validator_id.to_string(); + Delegation { + base: DelegationBase { + asset_id: AssetId::from_chain(Chain::Tron), + state: DelegationState::Active, + balance: BigUint::from(0u32), + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: validator_id.clone(), + validator_id: validator_id.clone(), + }, + validator: DelegationValidator::stake(Chain::Tron, validator_id.clone(), validator_id, true, 0.0, 0.0), + price: None, + } + } + + pub fn mock_osmosis(validator_id: &str) -> Self { + Delegation { + base: DelegationBase { + asset_id: AssetId::from_chain(Chain::Osmosis), + state: DelegationState::Active, + balance: BigUint::from(10u32), + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: "25053096".to_string(), + validator_id: validator_id.to_string(), + }, + validator: DelegationValidator::mock_osmosis(validator_id), + price: None, + } + } + + pub fn mock_with_id(delegation_id: String) -> Self { + Delegation::mock_base(DelegationBase::mock_with_id(delegation_id)) + } +} + +impl DelegationBase { + pub fn mock() -> Self { + DelegationBase { + asset_id: AssetId::from_chain(Chain::Sui), + state: DelegationState::Active, + balance: BigUint::from(1000000000u64), + shares: BigUint::from(1000000000u64), + rewards: BigUint::from(0u64), + completion_date: None, + delegation_id: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + validator_id: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + } + } + + pub fn mock_with_id(delegation_id: String) -> Self { + DelegationBase { + asset_id: AssetId::from_chain(Chain::Sui), + state: DelegationState::Active, + balance: BigUint::from(1000000000u64), + shares: BigUint::from(1000000000u64), + rewards: BigUint::from(0u64), + completion_date: None, + delegation_id, + validator_id: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + } + } +} + +impl DelegationValidator { + pub fn mock() -> Self { + DelegationValidator::stake(Chain::Sui, "validator1".to_string(), "Test Validator".to_string(), true, 0.05, 0.08) + } + + pub fn mock_osmosis(id: &str) -> Self { + DelegationValidator::stake(Chain::Osmosis, id.to_string(), String::new(), true, 1.0, 9.0) + } +} diff --git a/core/crates/primitives/src/testkit/device_mock.rs b/core/crates/primitives/src/testkit/device_mock.rs new file mode 100644 index 0000000000..9b7b4e9d71 --- /dev/null +++ b/core/crates/primitives/src/testkit/device_mock.rs @@ -0,0 +1,29 @@ +use crate::{Device, Platform, PlatformStore}; + +impl Device { + pub fn mock() -> Self { + Self { + id: "test-device-id".to_string(), + platform: Platform::IOS, + platform_store: PlatformStore::AppStore, + os: "iOS 17.0".to_string(), + model: "iPhone 15".to_string(), + token: "test-token-123".to_string(), + locale: "en".to_string(), + version: "1.0.0".to_string(), + currency: "USD".to_string(), + is_push_enabled: true, + is_price_alerts_enabled: Some(true), + subscriptions_version: 1, + } + } + + pub fn mock_with(is_push_enabled: bool, token: String, is_price_alerts_enabled: Option) -> Self { + Self { + is_push_enabled, + token, + is_price_alerts_enabled, + ..Self::mock() + } + } +} diff --git a/core/crates/primitives/src/testkit/fiat_mock.rs b/core/crates/primitives/src/testkit/fiat_mock.rs new file mode 100644 index 0000000000..a84b45c692 --- /dev/null +++ b/core/crates/primitives/src/testkit/fiat_mock.rs @@ -0,0 +1,142 @@ +use crate::currency::Currency; +use crate::fiat_assets::FiatAssetLimits; +use crate::{ + Asset, AssetId, Chain, FiatProvider, FiatProviderName, FiatQuote, FiatQuoteRequest, FiatQuoteResponse, FiatQuoteType, FiatTransaction, FiatTransactionStatus, + FiatTransactionUpdate, PaymentType, +}; +use chrono::{DateTime, Utc}; + +impl FiatQuoteRequest { + pub fn mock() -> Self { + FiatQuoteRequest { + asset_id: AssetId::from_chain(Chain::Bitcoin), + quote_type: FiatQuoteType::Buy, + currency: "USD".to_string(), + amount: 100.0, + provider_id: None, + ip_address: "192.168.1.1".to_string(), + } + } + + pub fn mock_sell() -> Self { + FiatQuoteRequest { + asset_id: AssetId::from_chain(Chain::Bitcoin), + quote_type: FiatQuoteType::Sell, + currency: "USD".to_string(), + amount: 250.0, + provider_id: None, + ip_address: "192.168.1.1".to_string(), + } + } +} + +impl FiatProvider { + pub fn mock(id: FiatProviderName) -> Self { + FiatProvider { + id, + name: id.name().to_string(), + image_url: None, + priority: None, + threshold_bps: None, + enabled: true, + buy_enabled: true, + sell_enabled: true, + payment_methods: vec![], + } + } + + pub fn mock_with_priority(id: FiatProviderName, priority: i32, threshold_bps: Option) -> Self { + FiatProvider { + id, + name: id.name().to_string(), + image_url: None, + priority: Some(priority), + threshold_bps, + enabled: true, + buy_enabled: true, + sell_enabled: true, + payment_methods: vec![], + } + } +} + +impl FiatQuote { + pub fn mock(provider_id: FiatProviderName) -> Self { + FiatQuote { + id: "quote_123".to_string(), + asset: Asset::from_chain(Chain::Bitcoin), + provider: FiatProvider::mock(provider_id), + quote_type: FiatQuoteType::Buy, + fiat_amount: 100.0, + fiat_currency: "USD".to_string(), + crypto_amount: 0.001, + value: "100000".to_string(), + latency: 0, + payment_methods: vec![PaymentType::Card], + } + } +} + +impl FiatQuoteResponse { + pub fn mock(quote_id: &str, crypto_amount: f64, fiat_amount: f64) -> Self { + FiatQuoteResponse { + quote_id: quote_id.to_string(), + fiat_amount, + crypto_amount, + payment_methods: vec![], + } + } +} + +impl FiatTransaction { + pub fn mock() -> Self { + FiatTransaction { + id: "quote_123".to_string(), + asset_id: AssetId::from_chain(Chain::Bitcoin), + transaction_type: FiatQuoteType::Buy, + provider: FiatProviderName::MoonPay, + provider_transaction_id: Some("tx_123".to_string()), + status: FiatTransactionStatus::Pending, + country: Some("US".to_string()), + fiat_amount: 100.0, + fiat_currency: "USD".to_string(), + value: "100000".to_string(), + transaction_hash: None, + created_at: DateTime::::UNIX_EPOCH, + updated_at: DateTime::::UNIX_EPOCH, + } + } +} + +impl FiatTransactionUpdate { + pub fn mock() -> Self { + FiatTransactionUpdate { + transaction_id: "quote_123".to_string(), + provider_transaction_id: Some("tx_123".to_string()), + status: FiatTransactionStatus::Pending, + transaction_hash: None, + fiat_amount: Some(100.0), + fiat_currency: Some("USD".to_string()), + } + } +} + +impl FiatAssetLimits { + pub fn mock() -> Self { + FiatAssetLimits { + currency: Currency::USD, + payment_type: PaymentType::Card, + min_amount: Some(50.0), + max_amount: Some(10000.0), + } + } + + pub fn mock_usd(min_amount: f64, max_amount: f64) -> Self { + FiatAssetLimits { + currency: Currency::USD, + payment_type: PaymentType::Card, + min_amount: Some(min_amount), + max_amount: Some(max_amount), + } + } +} diff --git a/core/crates/primitives/src/testkit/gorush_mock.rs b/core/crates/primitives/src/testkit/gorush_mock.rs new file mode 100644 index 0000000000..60a54ae362 --- /dev/null +++ b/core/crates/primitives/src/testkit/gorush_mock.rs @@ -0,0 +1,27 @@ +use crate::{GorushNotification, Platform, PushNotification, PushNotificationTypes}; + +impl GorushNotification { + pub fn mock() -> Self { + Self { + tokens: vec!["test-token".to_string()], + platform: Platform::Android.as_i32(), + title: "Test".to_string(), + message: "Test".to_string(), + topic: None, + data: PushNotification { + data: None, + notification_type: PushNotificationTypes::Transaction, + }, + device_id: "test-device-id".to_string(), + dry_run: None, + } + } + + pub fn mock_with(token: &str, device_id: &str) -> Self { + Self { + tokens: vec![token.to_string()], + device_id: device_id.to_string(), + ..Self::mock() + } + } +} diff --git a/core/crates/primitives/src/testkit/json.rs b/core/crates/primitives/src/testkit/json.rs new file mode 100644 index 0000000000..f01a2c2b20 --- /dev/null +++ b/core/crates/primitives/src/testkit/json.rs @@ -0,0 +1,23 @@ +use crate::JsonRpcResult; +use serde::de::DeserializeOwned; + +pub fn load_json(json: &str) -> T +where + T: DeserializeOwned, +{ + serde_json::from_str(json).unwrap() +} + +pub fn load_testdata(name: &str) -> T +where + T: DeserializeOwned, +{ + load_json(&std::fs::read_to_string(std::env::current_dir().unwrap().join("testdata").join(name)).unwrap()) +} + +pub fn load_json_rpc_result(json: &str) -> T +where + T: DeserializeOwned, +{ + load_json::>(json).result +} diff --git a/core/crates/primitives/src/testkit/json_rpc.rs b/core/crates/primitives/src/testkit/json_rpc.rs new file mode 100644 index 0000000000..530d1a5bd8 --- /dev/null +++ b/core/crates/primitives/src/testkit/json_rpc.rs @@ -0,0 +1 @@ +pub use super::json::load_json_rpc_result; diff --git a/core/crates/primitives/src/testkit/mod.rs b/core/crates/primitives/src/testkit/mod.rs new file mode 100644 index 0000000000..af0249a530 --- /dev/null +++ b/core/crates/primitives/src/testkit/mod.rs @@ -0,0 +1,24 @@ +pub mod address_name_mock; +pub mod asset_mock; +pub mod contract_call_data_mock; +pub mod delegation_mock; +pub mod device_mock; +pub mod fiat_mock; +pub mod gorush_mock; +pub mod json; +pub mod json_rpc; +pub mod nft_mock; +pub mod perpetual_mock; +pub mod quote_asset_mock; +pub mod signer_mock; +pub mod subscription_mock; +pub mod swap_mock; +pub mod transaction_fee_mock; +pub mod transaction_load_input_mock; +pub mod transaction_load_metadata_mock; +pub mod transaction_mock; +pub mod transaction_preload_input_mock; +pub mod transaction_state_request_mock; +pub mod transfer_data_extra_mock; +pub mod wallet_connect_mock; +pub mod wallet_connection_session_mock; diff --git a/core/crates/primitives/src/testkit/nft_mock.rs b/core/crates/primitives/src/testkit/nft_mock.rs new file mode 100644 index 0000000000..4c51a62166 --- /dev/null +++ b/core/crates/primitives/src/testkit/nft_mock.rs @@ -0,0 +1,63 @@ +use crate::{ + Chain, NFTType, + asset_constants::ETHEREUM_USDT_TOKEN_ID, + nft::{NFTAsset, NFTAssetId, NFTImages, NFTResource}, +}; + +const TON_NFT_COLLECTION_ADDRESS: &str = "EQCA14o1-VWhS2efqoh_9M1b_A9DtKTuoqfmkn83AbJzwnPi"; +const TON_NFT_ITEM_ADDRESS: &str = "EQCvxJy4eG8hyHBFsZ7eePxrRsUQSEUTP46abUQGAcGY6mOw"; + +impl NFTAssetId { + pub fn mock() -> Self { + NFTAssetId::new(Chain::Ethereum, ETHEREUM_USDT_TOKEN_ID, "1") + } + + pub fn mock_ton() -> Self { + NFTAssetId::new(Chain::Ton, TON_NFT_COLLECTION_ADDRESS, TON_NFT_ITEM_ADDRESS) + } +} + +impl NFTAsset { + pub fn mock() -> Self { + Self::mock_with_type(NFTType::ERC721) + } + + pub fn mock_with_type(token_type: NFTType) -> Self { + let id = NFTAssetId::new(Chain::Ethereum, ETHEREUM_USDT_TOKEN_ID, "1"); + let collection_id = id.get_collection_id(); + NFTAsset { + id, + collection_id, + contract_address: Some(ETHEREUM_USDT_TOKEN_ID.to_string()), + token_id: "1".to_string(), + token_type, + name: "Test NFT".to_string(), + description: None, + chain: Chain::Ethereum, + resource: NFTResource::new(String::new(), String::new()), + images: NFTImages { + preview: NFTResource::new(String::new(), String::new()), + }, + attributes: vec![], + } + } + + pub fn mock_ton() -> Self { + let id = NFTAssetId::mock_ton(); + NFTAsset { + id: id.clone(), + collection_id: id.get_collection_id(), + contract_address: Some(id.token_id.clone()), + token_id: id.token_id.clone(), + token_type: NFTType::JETTON, + name: "TON NFT".to_string(), + description: None, + chain: Chain::Ton, + resource: NFTResource::new(String::new(), String::new()), + images: NFTImages { + preview: NFTResource::new(String::new(), String::new()), + }, + attributes: vec![], + } + } +} diff --git a/core/crates/primitives/src/testkit/perpetual_mock.rs b/core/crates/primitives/src/testkit/perpetual_mock.rs new file mode 100644 index 0000000000..3a9cfcc8eb --- /dev/null +++ b/core/crates/primitives/src/testkit/perpetual_mock.rs @@ -0,0 +1,52 @@ +use chrono::{TimeZone, Utc}; + +use crate::{ + Asset, Chain, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType, + chart::ChartDateValue, + portfolio::{PerpetualPortfolio, PerpetualPortfolioTimeframeData}, +}; + +impl PerpetualConfirmData { + pub fn mock(direction: PerpetualDirection, asset_index: u32, take_profit: Option, stop_loss: Option) -> Self { + Self { + direction, + margin_type: PerpetualMarginType::Cross, + base_asset: Asset::from_chain(Chain::HyperCore), + asset_index: asset_index as i32, + price: "123.45".to_string(), + fiat_value: 100.0, + size: "2.5".to_string(), + slippage: 0.01, + leverage: 5, + pnl: None, + entry_price: None, + market_price: 123.45, + margin_amount: 50.0, + take_profit, + stop_loss, + } + } +} + +impl PerpetualPortfolioTimeframeData { + pub fn mock() -> Self { + let date = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + Self { + account_value_history: vec![ChartDateValue { date, value: 1000.0 }], + pnl_history: vec![ChartDateValue { date, value: 50.0 }], + volume: 5000.0, + } + } +} + +impl PerpetualPortfolio { + pub fn mock() -> Self { + Self { + day: Some(PerpetualPortfolioTimeframeData::mock()), + week: None, + month: None, + all_time: None, + account_summary: None, + } + } +} diff --git a/core/crates/primitives/src/testkit/quote_asset_mock.rs b/core/crates/primitives/src/testkit/quote_asset_mock.rs new file mode 100644 index 0000000000..c2e82d83f4 --- /dev/null +++ b/core/crates/primitives/src/testkit/quote_asset_mock.rs @@ -0,0 +1,15 @@ +use crate::{AssetId, Chain, swap::QuoteAsset}; + +impl QuoteAsset { + pub fn mock() -> Self { + Self::mock_with_asset_id(AssetId::from_chain(Chain::Ethereum), "ETH", 18) + } + + pub fn mock_with_asset_id(id: AssetId, symbol: &str, decimals: u32) -> Self { + Self { + id: id.to_string(), + symbol: symbol.to_string(), + decimals, + } + } +} diff --git a/core/crates/primitives/src/testkit/signer_mock.rs b/core/crates/primitives/src/testkit/signer_mock.rs new file mode 100644 index 0000000000..404ed38b64 --- /dev/null +++ b/core/crates/primitives/src/testkit/signer_mock.rs @@ -0,0 +1,6 @@ +pub const TEST_PRIVATE_KEY: [u8; 32] = [1u8; 32]; +pub const TEST_EVM_SENDER: &str = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; +pub const TEST_EVM_RECIPIENT: &str = "0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF"; +pub const TEST_OSMOSIS_SENDER: &str = "osmo1kglemumu8mn658j6g4z9jzn3zef2qdyyvklwa3"; +pub const TEST_SOLANA_SENDER: &str = "8wytzyCBXco7yqgrLDiecpEt452MSuNWRe7xsLgAAX1H"; +pub const TEST_TON_SENDER: &str = "UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV"; diff --git a/core/crates/primitives/src/testkit/subscription_mock.rs b/core/crates/primitives/src/testkit/subscription_mock.rs new file mode 100644 index 0000000000..d026bd72cd --- /dev/null +++ b/core/crates/primitives/src/testkit/subscription_mock.rs @@ -0,0 +1,13 @@ +use crate::{Chain, Device, DeviceSubscription, WalletId}; + +impl DeviceSubscription { + pub fn mock() -> Self { + Self { + wallet_row_id: 1, + device: Device::mock(), + wallet_id: WalletId::Multicoin("0xABC".to_string()), + chain: Chain::Ethereum, + address: "0xABC".to_string(), + } + } +} diff --git a/core/crates/primitives/src/testkit/swap_mock.rs b/core/crates/primitives/src/testkit/swap_mock.rs new file mode 100644 index 0000000000..d93b9efc63 --- /dev/null +++ b/core/crates/primitives/src/testkit/swap_mock.rs @@ -0,0 +1,200 @@ +use super::signer_mock::TEST_EVM_RECIPIENT; +use crate::{ + SwapProvider, + asset_constants::ETHEREUM_USDT_TOKEN_ID, + swap::{ApprovalData, Slippage, SlippageMode, SwapData, SwapProviderData, SwapQuote, SwapQuoteData, SwapQuoteDataType}, +}; + +impl Slippage { + pub fn mock_exact(bps: u32) -> Self { + Self { bps, mode: SlippageMode::Exact } + } +} + +impl ApprovalData { + pub fn mock() -> Self { + Self::make(ETHEREUM_USDT_TOKEN_ID, TEST_EVM_RECIPIENT, "0", false) + } + + pub fn make(token: &str, spender: &str, value: &str, is_unlimited: bool) -> Self { + ApprovalData { + token: token.to_string(), + spender: spender.to_string(), + value: value.to_string(), + is_unlimited, + } + } +} + +impl SwapData { + pub fn mock() -> Self { + SwapData { + quote: SwapQuote::mock(), + data: SwapQuoteData::mock(), + } + } + + pub fn mock_with_provider(provider: SwapProvider) -> Self { + SwapData { + quote: SwapQuote::mock_with_provider(provider), + data: SwapQuoteData::mock(), + } + } + + pub fn mock_with_provider_data(provider: SwapProvider, data: &str, gas_limit: Option<&str>) -> Self { + let swap_data = Self::mock_with_provider(provider); + SwapData { + data: SwapQuoteData { + data: data.to_string(), + gas_limit: gas_limit.map(String::from), + ..swap_data.data + }, + ..swap_data + } + } + + pub fn mock_with_data_and_approval(data: &str, gas_limit: Option<&str>) -> Self { + let swap_data = Self::mock(); + SwapData { + data: SwapQuoteData { + data: data.to_string(), + approval: Some(ApprovalData::mock()), + gas_limit: gas_limit.map(String::from), + ..swap_data.data + }, + ..swap_data + } + } + + pub fn mock_with_values(provider: SwapProvider, from_value: &str, to_value: &str) -> Self { + SwapData { + quote: SwapQuote::mock_with_values(provider, from_value, to_value), + data: SwapQuoteData::mock(), + } + } + + pub fn mock_contract(provider: SwapProvider, from_value: &str, to_value: &str, value: &str) -> Self { + let swap_data = Self::mock_with_values(provider, from_value, to_value); + SwapData { + data: SwapQuoteData { + value: value.to_string(), + ..swap_data.data + }, + ..swap_data + } + } + + pub fn mock_transfer(provider: SwapProvider, from_value: &str, to_value: &str, to: &str) -> Self { + let swap_data = Self::mock_with_values(provider, from_value, to_value); + SwapData { + data: SwapQuoteData::new_tranfer(to.to_string(), from_value.to_string(), None), + ..swap_data + } + } +} + +impl SwapQuote { + pub fn mock() -> Self { + SwapQuote { + from_value: "1000000000".to_string(), + min_from_value: None, + to_value: "1000000".to_string(), + provider_data: SwapProviderData::mock(), + from_address: TEST_EVM_RECIPIENT.to_string(), + to_address: TEST_EVM_RECIPIENT.to_string(), + slippage_bps: 50, + eta_in_seconds: Some(30), + use_max_amount: None, + } + } + + pub fn mock_with_provider(provider: SwapProvider) -> Self { + Self::mock_with_values(provider, "1000000000", "1000000") + } + + pub fn mock_with_values(provider: SwapProvider, from_value: &str, to_value: &str) -> Self { + SwapQuote { + from_value: from_value.to_string(), + min_from_value: None, + to_value: to_value.to_string(), + provider_data: SwapProviderData::mock_with_provider(provider), + from_address: TEST_EVM_RECIPIENT.to_string(), + to_address: TEST_EVM_RECIPIENT.to_string(), + slippage_bps: 50, + eta_in_seconds: Some(30), + use_max_amount: None, + } + } +} + +impl SwapQuoteData { + pub fn mock() -> Self { + SwapQuoteData { + data_type: SwapQuoteDataType::Contract, + to: TEST_EVM_RECIPIENT.to_string(), + value: "0".to_string(), + data: "0x".to_string(), + memo: None, + approval: None, + gas_limit: Some("21000".to_string()), + } + } + + pub fn mock_with_gas_limit(gas_limit: Option) -> Self { + SwapQuoteData { gas_limit, ..Self::mock() } + } +} + +impl SwapProviderData { + pub fn mock() -> Self { + SwapProviderData { + provider: SwapProvider::UniswapV3, + name: "Uniswap V3".to_string(), + protocol_name: "uniswap_v3".to_string(), + } + } + + pub fn mock_with_provider(provider: SwapProvider) -> Self { + SwapProviderData { + provider, + name: provider.name().to_string(), + protocol_name: provider.protocol_name().to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_swap_data_mock() { + let swap_data = SwapData::mock(); + assert_eq!(swap_data.quote.from_value, "1000000000"); + assert_eq!(swap_data.quote.to_value, "1000000"); + assert_eq!(swap_data.quote.provider_data.provider, SwapProvider::UniswapV3); + } + + #[test] + fn test_swap_data_mock_with_provider() { + let swap_data = SwapData::mock_with_provider(SwapProvider::Jupiter); + assert_eq!(swap_data.quote.provider_data.provider, SwapProvider::Jupiter); + assert_eq!(swap_data.quote.provider_data.name, "Jupiter"); + } + + #[test] + fn test_swap_data_mock_with_provider_data() { + let swap_data = SwapData::mock_with_provider_data(SwapProvider::Jupiter, "tx-data", Some("420000")); + assert_eq!(swap_data.quote.provider_data.provider, SwapProvider::Jupiter); + assert_eq!(swap_data.data.data, "tx-data"); + assert_eq!(swap_data.data.gas_limit, Some("420000".to_string())); + } + + #[test] + fn test_swap_data_mock_with_data_and_approval() { + let swap_data = SwapData::mock_with_data_and_approval("tx-data", Some("420000")); + assert_eq!(swap_data.data.data, "tx-data"); + assert_eq!(swap_data.data.approval, Some(ApprovalData::mock())); + assert_eq!(swap_data.data.gas_limit, Some("420000".to_string())); + } +} diff --git a/core/crates/primitives/src/testkit/transaction_fee_mock.rs b/core/crates/primitives/src/testkit/transaction_fee_mock.rs new file mode 100644 index 0000000000..1c946001d0 --- /dev/null +++ b/core/crates/primitives/src/testkit/transaction_fee_mock.rs @@ -0,0 +1,12 @@ +use crate::{GasPriceType, TransactionFee}; + +impl TransactionFee { + pub fn mock_eip1559(gas_limit: u64) -> Self { + TransactionFee::new_gas_price_type( + GasPriceType::eip1559(20_000_000_000u64, 1_000_000_000u64), + (20_000_000_000u64 * gas_limit).into(), + gas_limit.into(), + Default::default(), + ) + } +} diff --git a/core/crates/primitives/src/testkit/transaction_load_input_mock.rs b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs new file mode 100644 index 0000000000..20cb09c066 --- /dev/null +++ b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs @@ -0,0 +1,204 @@ +use super::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER, TEST_OSMOSIS_SENDER}; +use crate::{ + Asset, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, + TransferDataOutputType, WalletConnectionSessionAppMetadata, +}; +use num_bigint::BigInt; +use std::collections::HashMap; + +impl TransactionLoadInput { + pub fn mock() -> Self { + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Sui)), + sender_address: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + destination_address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(), + value: "1000000000".to_string(), + gas_price: GasPriceType::regular(BigInt::from(1000u64)), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::None, + } + } + + pub fn mock_aptos_token_transfer(token_id: &str) -> Self { + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::mock_with_params( + Chain::Aptos, + Some(token_id.to_string()), + "USD Coin".to_string(), + "USDC".to_string(), + 6, + crate::AssetType::TOKEN, + )), + sender_address: "0x1".to_string(), + destination_address: "0x2".to_string(), + value: "1".to_string(), + gas_price: GasPriceType::regular(BigInt::from(1u64)), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Aptos { sequence: 0, data: None }, + } + } + + pub fn mock_with_input_type(input_type: TransactionInputType) -> Self { + TransactionLoadInput { + input_type, + sender_address: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + destination_address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(), + value: "1000000000".to_string(), + gas_price: GasPriceType::regular(BigInt::from(1000u64)), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::None, + } + } + + pub fn mock_evm(input_type: TransactionInputType, value: &str) -> Self { + Self::mock_evm_with_metadata(input_type, value, TransactionLoadMetadata::mock_evm(0, 1)) + } + + pub fn mock_evm_with_metadata(input_type: TransactionInputType, value: &str, metadata: TransactionLoadMetadata) -> Self { + TransactionLoadInput { + input_type, + sender_address: TEST_EVM_SENDER.to_string(), + destination_address: TEST_EVM_RECIPIENT.to_string(), + value: value.to_string(), + gas_price: GasPriceType::eip1559(20_000_000_000u64, 1_000_000_000u64), + memo: None, + is_max_value: false, + metadata, + } + } +} + +impl SignerInput { + pub fn mock_evm(input_type: TransactionInputType, value: &str, gas_limit: u64) -> Self { + SignerInput::new(TransactionLoadInput::mock_evm(input_type, value), TransactionFee::mock_eip1559(gas_limit)) + } + + pub fn mock_evm_with_metadata(input_type: TransactionInputType, value: &str, gas_limit: u64, metadata: TransactionLoadMetadata) -> Self { + SignerInput::new( + TransactionLoadInput::mock_evm_with_metadata(input_type, value, metadata), + TransactionFee::mock_eip1559(gas_limit), + ) + } + + pub fn mock_with_input_type(input_type: TransactionInputType, sender: &str, destination: &str, value: &str, metadata: TransactionLoadMetadata) -> Self { + SignerInput::new( + TransactionLoadInput { + input_type, + sender_address: sender.to_string(), + destination_address: destination.to_string(), + value: value.to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata, + }, + TransactionFee::default(), + ) + } + + pub fn mock_osmosis(input_type: TransactionInputType, destination: &str) -> Self { + let fee_amount = BigInt::from(10_000u64); + SignerInput::new( + TransactionLoadInput { + input_type, + sender_address: TEST_OSMOSIS_SENDER.to_string(), + destination_address: destination.to_string(), + value: "10".to_string(), + gas_price: GasPriceType::regular(fee_amount.clone()), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::mock_osmosis(), + }, + TransactionFee::new_gas_price_type(GasPriceType::regular(fee_amount.clone()), fee_amount, BigInt::from(200_000u64), HashMap::new()), + ) + } + + pub fn mock_ton(input_type: TransactionInputType, metadata: TransactionLoadMetadata) -> Self { + SignerInput::new( + TransactionLoadInput { + input_type, + sender_address: "UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg".to_string(), + destination_address: "UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg".to_string(), + value: "10000".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata, + }, + TransactionFee::default(), + ) + } + + pub fn mock_solana(block_hash: &str) -> Self { + SignerInput::new(TransactionLoadInput::mock_solana(block_hash), TransactionFee::default()) + } +} + +impl TransactionLoadInput { + pub fn mock_near(sender: &str, destination: &str, value: &str, sequence: u64, block_hash: &str) -> Self { + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Near)), + sender_address: sender.into(), + destination_address: destination.into(), + value: value.into(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Near { + sequence, + block_hash: block_hash.into(), + }, + } + } + + pub fn mock_solana(block_hash: &str) -> Self { + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::mock_sol()), + sender_address: String::new(), + destination_address: String::new(), + value: "0".to_string(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::mock_solana(block_hash), + } + } + + pub fn mock_transfer(asset: Asset, sender: &str, destination: &str, value: &str, fee: u64, memo: Option<&str>, metadata: TransactionLoadMetadata) -> Self { + TransactionLoadInput { + input_type: TransactionInputType::Transfer(asset), + sender_address: sender.into(), + destination_address: destination.into(), + value: value.into(), + gas_price: GasPriceType::regular(fee), + memo: memo.map(String::from), + is_max_value: false, + metadata, + } + } + + pub fn mock_sign_data(chain: Chain, data: &str, output_type: TransferDataOutputType) -> Self { + TransactionLoadInput { + input_type: TransactionInputType::Generic( + Asset::from_chain(chain), + WalletConnectionSessionAppMetadata::mock(), + TransferDataExtra { + data: Some(data.as_bytes().to_vec()), + output_type, + output_action: TransferDataOutputAction::Send, + ..Default::default() + }, + ), + sender_address: "test".into(), + destination_address: "test".into(), + value: "0".into(), + gas_price: GasPriceType::regular(0), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::None, + } + } +} diff --git a/core/crates/primitives/src/testkit/transaction_load_metadata_mock.rs b/core/crates/primitives/src/testkit/transaction_load_metadata_mock.rs new file mode 100644 index 0000000000..5c4ce09eea --- /dev/null +++ b/core/crates/primitives/src/testkit/transaction_load_metadata_mock.rs @@ -0,0 +1,93 @@ +use crate::{SolanaNftStandard, SolanaTokenProgramId, TransactionLoadMetadata, stake_type::TronStakeData}; + +impl TransactionLoadMetadata { + pub fn mock_aptos() -> Self { + TransactionLoadMetadata::Aptos { sequence: 0, data: None } + } + + pub fn mock_osmosis() -> Self { + TransactionLoadMetadata::Cosmos { + account_number: 2_913_388, + sequence: 10, + chain_id: "osmosis-1".to_string(), + } + } + + pub fn mock_evm(nonce: u64, chain_id: u64) -> Self { + TransactionLoadMetadata::Evm { + nonce, + chain_id, + contract_call: None, + } + } + + pub fn mock_tron() -> Self { + TransactionLoadMetadata::Tron { + block_number: 0, + block_version: 0, + block_timestamp: 0, + transaction_tree_root: "".to_string(), + parent_hash: "".to_string(), + witness_address: "".to_string(), + stake_data: TronStakeData::Votes(vec![]), + } + } + + pub fn mock_ton(sequence: u64) -> Self { + TransactionLoadMetadata::Ton { + sender_token_address: None, + recipient_token_address: None, + sequence, + } + } + + pub fn mock_ton_jetton(sequence: u64, sender_token_address: &str) -> Self { + TransactionLoadMetadata::Ton { + sender_token_address: Some(sender_token_address.to_string()), + recipient_token_address: None, + sequence, + } + } + + pub fn mock_solana(block_hash: &str) -> Self { + TransactionLoadMetadata::Solana { + sender_token_address: None, + recipient_token_address: None, + token_program: None, + nft: None, + block_hash: block_hash.to_string(), + } + } + + pub fn mock_solana_token(sender_token_address: Option<&str>, recipient_token_address: Option<&str>, token_program: Option) -> Self { + TransactionLoadMetadata::Solana { + sender_token_address: sender_token_address.map(String::from), + recipient_token_address: recipient_token_address.map(String::from), + token_program, + nft: None, + block_hash: "11111111111111111111111111111111".to_string(), + } + } + + pub fn mock_solana_nft(sender_token_address: &str, token_program: SolanaTokenProgramId, nft: SolanaNftStandard) -> Self { + TransactionLoadMetadata::Solana { + sender_token_address: Some(sender_token_address.to_string()), + recipient_token_address: None, + token_program: Some(token_program), + nft: Some(nft), + block_hash: "11111111111111111111111111111111".to_string(), + } + } + + pub fn mock_solana_core_nft(collection: Option<&str>) -> Self { + TransactionLoadMetadata::Solana { + sender_token_address: None, + recipient_token_address: None, + token_program: None, + nft: Some(SolanaNftStandard::Core { + collection: collection.map(String::from), + }), + block_hash: "11111111111111111111111111111111".to_string(), + } + } +} diff --git a/core/crates/primitives/src/testkit/transaction_mock.rs b/core/crates/primitives/src/testkit/transaction_mock.rs new file mode 100644 index 0000000000..c89ca79c3e --- /dev/null +++ b/core/crates/primitives/src/testkit/transaction_mock.rs @@ -0,0 +1,57 @@ +use crate::{AssetId, Chain, Transaction, TransactionState, TransactionType, TransactionUtxoInput}; +use chrono::Utc; + +impl Transaction { + pub fn mock() -> Self { + Transaction::new( + "0x1234567890abcdef".to_string(), + AssetId::from_chain(Chain::Ethereum), + "0xfrom".to_string(), + "0xto".to_string(), + None, + TransactionType::Transfer, + TransactionState::Confirmed, + "21000".to_string(), + AssetId::from_chain(Chain::Ethereum), + "1000000".to_string(), + None, + None, + Utc::now(), + ) + } + + pub fn mock_with_params(asset_id: AssetId, transaction_type: TransactionType, value: String) -> Self { + Transaction::new( + "0x1234567890abcdef".to_string(), + asset_id.clone(), + "0xfrom".to_string(), + "0xto".to_string(), + None, + transaction_type, + TransactionState::Confirmed, + "21000".to_string(), + asset_id, + value, + None, + None, + Utc::now(), + ) + } + + pub fn mock_utxo(utxo_inputs: Vec, utxo_outputs: Vec) -> Self { + Transaction::new_with_utxo( + "btc_tx_hash".to_string(), + AssetId::from_chain(Chain::Bitcoin), + TransactionType::Transfer, + TransactionState::Confirmed, + "1000".to_string(), + AssetId::from_chain(Chain::Bitcoin), + "0".to_string(), + None, + Some(utxo_inputs), + Some(utxo_outputs), + None, + Utc::now(), + ) + } +} diff --git a/core/crates/primitives/src/testkit/transaction_preload_input_mock.rs b/core/crates/primitives/src/testkit/transaction_preload_input_mock.rs new file mode 100644 index 0000000000..eb1496834b --- /dev/null +++ b/core/crates/primitives/src/testkit/transaction_preload_input_mock.rs @@ -0,0 +1,19 @@ +use crate::{Asset, Chain, TransactionInputType, TransactionPreloadInput}; + +impl TransactionPreloadInput { + pub fn mock() -> Self { + TransactionPreloadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Aptos)), + sender_address: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + destination_address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(), + } + } + + pub fn mock_with_input_type(input_type: TransactionInputType) -> Self { + TransactionPreloadInput { + input_type, + sender_address: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + destination_address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(), + } + } +} diff --git a/core/crates/primitives/src/testkit/transaction_state_request_mock.rs b/core/crates/primitives/src/testkit/transaction_state_request_mock.rs new file mode 100644 index 0000000000..f58699fe81 --- /dev/null +++ b/core/crates/primitives/src/testkit/transaction_state_request_mock.rs @@ -0,0 +1,18 @@ +use crate::{TransactionStateRequest, UInt64}; +use chrono::{DateTime, Utc}; + +impl TransactionStateRequest { + pub fn mock_with_id(id: impl Into) -> Self { + Self { + id: id.into(), + sender_address: String::new(), + created_at: DateTime::::UNIX_EPOCH, + block_number: 0, + } + } + + pub fn with_block_number(mut self, block_number: UInt64) -> Self { + self.block_number = block_number; + self + } +} diff --git a/core/crates/primitives/src/testkit/transfer_data_extra_mock.rs b/core/crates/primitives/src/testkit/transfer_data_extra_mock.rs new file mode 100644 index 0000000000..420343ef61 --- /dev/null +++ b/core/crates/primitives/src/testkit/transfer_data_extra_mock.rs @@ -0,0 +1,19 @@ +use super::signer_mock::TEST_EVM_RECIPIENT; +use crate::{TransferDataExtra, TransferDataOutputAction, TransferDataOutputType}; + +impl TransferDataExtra { + pub fn mock() -> Self { + TransferDataExtra { + to: TEST_EVM_RECIPIENT.to_string(), + gas_limit: None, + gas_price: None, + data: None, + output_type: TransferDataOutputType::EncodedTransaction, + output_action: TransferDataOutputAction::Sign, + } + } + + pub fn mock_encoded_transaction(data: Vec) -> Self { + TransferDataExtra { data: Some(data), ..Self::mock() } + } +} diff --git a/core/crates/primitives/src/testkit/wallet_connect_mock.rs b/core/crates/primitives/src/testkit/wallet_connect_mock.rs new file mode 100644 index 0000000000..561c14f2a2 --- /dev/null +++ b/core/crates/primitives/src/testkit/wallet_connect_mock.rs @@ -0,0 +1,13 @@ +use crate::WalletConnectRequest; + +impl WalletConnectRequest { + pub fn mock(method: &str, params: &str, chain_id: Option<&str>) -> Self { + Self { + topic: "test-topic".to_string(), + method: method.to_string(), + params: params.to_string(), + chain_id: chain_id.map(|v| v.to_string()), + domain: "example.com".to_string(), + } + } +} diff --git a/core/crates/primitives/src/testkit/wallet_connection_session_mock.rs b/core/crates/primitives/src/testkit/wallet_connection_session_mock.rs new file mode 100644 index 0000000000..8a5c641742 --- /dev/null +++ b/core/crates/primitives/src/testkit/wallet_connection_session_mock.rs @@ -0,0 +1,12 @@ +use crate::WalletConnectionSessionAppMetadata; + +impl WalletConnectionSessionAppMetadata { + pub fn mock() -> Self { + WalletConnectionSessionAppMetadata { + name: "Test Dapp".to_string(), + description: "Test Dapp".to_string(), + url: "https://example.com".to_string(), + icon: "https://example.com/icon.png".to_string(), + } + } +} diff --git a/core/crates/primitives/src/time.rs b/core/crates/primitives/src/time.rs new file mode 100644 index 0000000000..f53838deeb --- /dev/null +++ b/core/crates/primitives/src/time.rs @@ -0,0 +1,5 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn unix_timestamp() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() +} diff --git a/core/crates/primitives/src/total_value_type.rs b/core/crates/primitives/src/total_value_type.rs new file mode 100644 index 0000000000..34d757dd39 --- /dev/null +++ b/core/crates/primitives/src/total_value_type.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum TotalValueType { + Wallet, + Perpetual, + Earn, +} diff --git a/core/crates/primitives/src/tpsl_type.rs b/core/crates/primitives/src/tpsl_type.rs new file mode 100644 index 0000000000..a83a3e3f29 --- /dev/null +++ b/core/crates/primitives/src/tpsl_type.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare(swift = "Equatable, Sendable")] +pub enum TpslType { + TakeProfit, + StopLoss, +} diff --git a/core/crates/primitives/src/transaction.rs b/core/crates/primitives/src/transaction.rs new file mode 100644 index 0000000000..dcddcacb6a --- /dev/null +++ b/core/crates/primitives/src/transaction.rs @@ -0,0 +1,527 @@ +use crate::{ + AddressName, AssetAddress, Chain, NFTAssetId, TransactionId, TransactionNFTTransferMetadata, TransactionSwapMetadata, asset_id::AssetId, + transaction_direction::TransactionDirection, transaction_state::TransactionState, transaction_type::TransactionType, transaction_utxo::TransactionUtxoInput, +}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, vec}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionsResponse { + pub transactions: Vec, + pub address_names: Vec, +} + +impl TransactionsResponse { + pub fn new(transactions: Vec, address_names: Vec) -> Self { + Self { transactions, address_names } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Sendable, Equatable, Hashable")] +pub struct Transaction { + pub id: TransactionId, + #[typeshare(skip)] + pub hash: String, + #[serde(rename = "assetId")] + pub asset_id: AssetId, + pub from: String, + pub to: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub contract: Option, + #[serde(rename = "type")] + pub transaction_type: TransactionType, + pub state: TransactionState, + #[serde(rename = "blockNumber", skip_serializing_if = "Option::is_none")] + pub block_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sequence: Option, + pub fee: String, + #[serde(rename = "feeAssetId")] + pub fee_asset_id: AssetId, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, + pub direction: TransactionDirection, + #[serde(rename = "utxoInputs")] + #[serde(skip_serializing_if = "Option::is_none")] + pub utxo_inputs: Option>, + #[serde(rename = "utxoOutputs")] + #[serde(skip_serializing_if = "Option::is_none")] + pub utxo_outputs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[typeshare(skip)] + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(rename = "createdAt")] + pub created_at: DateTime, +} + +impl Transaction { + pub fn new( + hash: String, + asset_id: AssetId, + from_address: String, + to_address: String, + contract: Option, + transaction_type: TransactionType, + state: TransactionState, + fee: String, + fee_asset_id: AssetId, + value: String, + memo: Option, + metadata: Option, + created_at: DateTime, + ) -> Self { + Self { + id: TransactionId::new(asset_id.chain, hash.clone()), + hash, + asset_id, + from: from_address, + to: to_address, + contract, + transaction_type, + state, + block_number: Some("".to_string()), + sequence: Some("".to_string()), + fee, + fee_asset_id, + value, + memo, + direction: TransactionDirection::SelfTransfer, + utxo_inputs: vec![].into(), + utxo_outputs: vec![].into(), + metadata, + data: None, + created_at, + } + } + + pub fn new_with_utxo( + hash: String, + asset_id: AssetId, + transaction_type: TransactionType, + state: TransactionState, + fee: String, + fee_asset_id: AssetId, + value: String, + memo: Option, + utxo_inputs: Option>, + utxo_outputs: Option>, + metadata: Option, + created_at: DateTime, + ) -> Self { + Self { + id: TransactionId::new(asset_id.chain, hash.clone()), + hash, + asset_id, + from: "".to_string(), + to: "".to_string(), + contract: None, + transaction_type, + state, + block_number: Some("".to_string()), + sequence: Some("".to_string()), + fee, + fee_asset_id, + value, + memo, + direction: TransactionDirection::SelfTransfer, + utxo_inputs: utxo_inputs.unwrap_or_default().into(), + utxo_outputs: utxo_outputs.unwrap_or_default().into(), + metadata, + data: None, + created_at, + } + } + + pub fn id_from(chain: Chain, hash: String) -> String { + format!("{}_{}", chain.as_ref(), hash) + } + + pub fn is_sent(&self, address: String) -> bool { + self.input_addresses().contains(&address) || self.from == address + } + + pub fn is_utxo_tx(&self) -> bool { + self.utxo_inputs.as_ref().is_some_and(|v| !v.is_empty()) && self.utxo_outputs.as_ref().is_some_and(|v| !v.is_empty()) + } + + pub fn input_addresses(&self) -> Vec { + self.utxo_inputs.as_ref().map_or_else(Vec::new, |v| v.iter().map(|x| x.address.clone()).collect()) + } + + pub fn output_addresses(&self) -> Vec { + self.utxo_outputs.as_ref().map_or_else(Vec::new, |v| v.iter().map(|x| x.address.clone()).collect()) + } + + pub fn addresses(&self) -> Vec { + // Append addresses from utxo inputs and outputs + let mut array = vec![self.from.clone(), self.to.clone()]; + array.extend(self.input_addresses()); + array.extend(self.output_addresses()); + array.dedup(); + array.into_iter().filter(|x| !x.is_empty()).collect() + } + + pub fn finalize(&self, addresses: Vec) -> Self { + let chain = self.asset_id.chain; + if !chain.is_utxo() { + return self.clone(); + } + + let inputs_addresses = self.input_addresses(); + let outputs_addresses = self.output_addresses(); + + if addresses.is_empty() || inputs_addresses.is_empty() || outputs_addresses.is_empty() { + return self.clone(); + } + + let user_set: HashSet = HashSet::from_iter(addresses); + let input_set: HashSet = HashSet::from_iter(inputs_addresses); + let output_set: HashSet = HashSet::from_iter(outputs_addresses.clone()); + + if user_set.is_disjoint(&input_set) && user_set.is_disjoint(&output_set) { + return self.clone(); + } + + let direction = if user_set.intersection(&input_set).next().is_some() { + if user_set.is_superset(&output_set) { + TransactionDirection::SelfTransfer + } else { + TransactionDirection::Outgoing + } + } else { + TransactionDirection::Incoming + }; + + let utxo_inputs = self.utxo_inputs.as_ref().unwrap(); + let utxo_outputs = self.utxo_outputs.as_ref().unwrap(); + + let from = utxo_inputs.first().unwrap().address.clone(); + let (to, value) = match direction { + TransactionDirection::Incoming => { + let to = outputs_addresses.iter().find(|x| user_set.contains(*x)).unwrap().clone(); + let value = Self::utxo_calculate_value(utxo_outputs, &user_set).to_string(); + (to, value) + } + TransactionDirection::Outgoing => { + let to = outputs_addresses.iter().find(|x| !user_set.contains(*x)).unwrap().clone(); + let value = utxo_outputs.iter().find(|x| x.address == to).unwrap().value.clone(); + (to, value) + } + TransactionDirection::SelfTransfer => { + let to = utxo_outputs.first().unwrap().address.clone(); + let value = Self::utxo_calculate_value(utxo_outputs, &user_set).to_string(); + (to, value) + } + }; + Transaction { + id: self.id.clone(), + hash: self.hash.clone(), + asset_id: self.asset_id.clone(), + from, + to, + contract: self.contract.clone(), + transaction_type: self.transaction_type.clone(), + state: self.state, + block_number: self.block_number.clone(), + sequence: self.sequence.clone(), + fee: self.fee.clone(), + fee_asset_id: self.fee_asset_id.clone(), + value: value.to_string(), + memo: self.memo.clone(), + direction, + utxo_inputs: self.utxo_inputs.clone(), + utxo_outputs: self.utxo_outputs.clone(), + metadata: self.metadata.clone(), + data: self.data.clone(), + created_at: self.created_at, + } + } + + fn utxo_calculate_value(values: &[TransactionUtxoInput], addresses: &HashSet) -> i64 { + values.iter().filter(|x| addresses.contains(&x.address)).filter_map(|x| x.value.parse::().ok()).sum() + } + + fn swap_metadata(&self) -> Option { + self.metadata.as_ref().and_then(|value| TransactionSwapMetadata::deserialize(value).ok()) + } + + pub fn nft_asset_id(&self) -> Option { + if self.transaction_type != TransactionType::TransferNFT { + return None; + } + self.metadata + .as_ref() + .and_then(|value| TransactionNFTTransferMetadata::deserialize(value).ok()) + .map(|metadata| metadata.asset_id) + } + + pub fn asset_ids(&self) -> Vec { + match self.transaction_type { + TransactionType::Transfer + | TransactionType::TokenApproval + | TransactionType::StakeDelegate + | TransactionType::StakeUndelegate + | TransactionType::StakeRewards + | TransactionType::StakeRedelegate + | TransactionType::StakeWithdraw + | TransactionType::StakeFreeze + | TransactionType::StakeUnfreeze + | TransactionType::AssetActivation + | TransactionType::TransferNFT + | TransactionType::SmartContractCall + | TransactionType::PerpetualOpenPosition + | TransactionType::PerpetualClosePosition + | TransactionType::PerpetualModifyPosition + | TransactionType::EarnDeposit + | TransactionType::EarnWithdraw => vec![self.asset_id.clone()], + TransactionType::Swap => self.swap_metadata().map(|metadata| vec![metadata.from_asset, metadata.to_asset]).unwrap_or_default(), + } + .into_iter() + .collect::>() + .into_iter() + .collect() + } + + pub fn assets_addresses(&self) -> Vec { + match self.transaction_type { + TransactionType::Transfer | TransactionType::TransferNFT => self + .addresses() + .into_iter() + .map(|x| AssetAddress::new(self.asset_id.clone(), x, None)) + .collect::>() + .into_iter() + .collect(), + TransactionType::TokenApproval + | TransactionType::StakeDelegate + | TransactionType::StakeUndelegate + | TransactionType::StakeRewards + | TransactionType::StakeRedelegate + | TransactionType::StakeWithdraw + | TransactionType::StakeFreeze + | TransactionType::StakeUnfreeze + | TransactionType::AssetActivation + | TransactionType::SmartContractCall + | TransactionType::PerpetualOpenPosition + | TransactionType::PerpetualClosePosition + | TransactionType::PerpetualModifyPosition + | TransactionType::EarnDeposit + | TransactionType::EarnWithdraw => vec![AssetAddress::new(self.asset_id.clone(), self.to.clone(), None)], + TransactionType::Swap => self + .swap_metadata() + .map(|metadata| { + vec![ + AssetAddress::new(metadata.from_asset, self.from.clone(), None), + AssetAddress::new(metadata.to_asset, self.to.clone(), None), + ] + }) + .unwrap_or_default(), + } + } + + pub fn assets_addresses_with_fee(&self) -> Vec { + [self.assets_addresses(), vec![AssetAddress::new(self.fee_asset_id.clone(), self.from.clone(), None)]] + .concat() + .into_iter() + .collect::>() + .into_iter() + .collect() + } + + pub fn without_utxo(self) -> Self { + Self { + utxo_inputs: None, + utxo_outputs: None, + ..self + } + } + + pub fn with_data(mut self, data: Option) -> Self { + self.data = data; + self + } + + pub fn with_swap_state(self, state: TransactionState, metadata: Option) -> Self { + Self { + state, + transaction_type: TransactionType::Swap, + metadata: metadata.or(self.metadata), + ..self + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Asset, TransactionUtxoInput}; + + #[test] + fn test_asset_ids_transfer() { + assert_eq!(Transaction::mock().asset_ids().len(), 1); + + let transaction = Transaction { + asset_id: Asset::mock_ethereum_usdc().id, + ..Transaction::mock() + }; + assert_eq!(transaction.asset_ids().len(), 1); + } + + #[test] + fn test_asset_ids_swap() { + let transaction = Transaction { + transaction_type: TransactionType::Swap, + metadata: Some( + serde_json::to_value(TransactionSwapMetadata { + from_asset: Asset::mock_eth().id, + from_value: "1".to_string(), + to_asset: Asset::mock_eth().id, + to_value: "1".to_string(), + provider: None, + }) + .unwrap(), + ), + ..Transaction::mock() + }; + assert_eq!(transaction.asset_ids().len(), 1); + + let transaction = Transaction { + transaction_type: TransactionType::Swap, + metadata: Some( + serde_json::to_value(TransactionSwapMetadata { + from_asset: Asset::mock_ethereum_usdc().id, + from_value: "1".to_string(), + to_asset: Asset::mock_erc20().id, + to_value: "1".to_string(), + provider: None, + }) + .unwrap(), + ), + ..Transaction::mock() + }; + assert_eq!(transaction.asset_ids().len(), 2); + } + + #[test] + fn test_assets_addresses_transfer() { + // Without fee + assert_eq!(Transaction::mock().assets_addresses().len(), 2); + + let transaction = Transaction { + asset_id: Asset::mock_ethereum_usdc().id, + ..Transaction::mock() + }; + assert_eq!(transaction.assets_addresses().len(), 2); + assert!( + transaction + .assets_addresses() + .iter() + .any(|a| a.asset_id == Asset::mock_ethereum_usdc().id && a.address == "0xfrom") + ); + assert!( + transaction + .assets_addresses() + .iter() + .any(|a| a.asset_id == Asset::mock_ethereum_usdc().id && a.address == "0xto") + ); + + // With fee + assert_eq!(Transaction::mock().assets_addresses_with_fee().len(), 2); + assert_eq!(transaction.assets_addresses_with_fee().len(), 3); + assert!( + transaction + .assets_addresses_with_fee() + .iter() + .any(|a| a.asset_id == Asset::mock_eth().id && a.address == "0xfrom") + ); + assert!( + transaction + .assets_addresses_with_fee() + .iter() + .any(|a| a.asset_id == Asset::mock_ethereum_usdc().id && a.address == "0xfrom") + ); + assert!( + transaction + .assets_addresses_with_fee() + .iter() + .any(|a| a.asset_id == Asset::mock_ethereum_usdc().id && a.address == "0xto") + ); + } + + #[test] + fn test_assets_addresses_swap() { + let transaction = Transaction { + transaction_type: TransactionType::Swap, + from: "0xsame".to_string(), + to: "0xsame".to_string(), + metadata: Some( + serde_json::to_value(TransactionSwapMetadata { + from_asset: Asset::mock_ethereum_usdc().id, + from_value: "1".to_string(), + to_asset: Asset::mock_erc20().id, + to_value: "1".to_string(), + provider: None, + }) + .unwrap(), + ), + ..Transaction::mock() + }; + // Without fee: 2 swap assets + assert_eq!(transaction.assets_addresses().len(), 2); + // With fee: 2 swap assets + 1 fee + assert_eq!(transaction.assets_addresses_with_fee().len(), 3); + } + + fn utxo_input(address: &str, value: &str) -> TransactionUtxoInput { + TransactionUtxoInput::new(address.to_string(), value.to_string()) + } + + #[test] + fn test_finalize_incoming_utxo() { + let transaction = + Transaction::mock_utxo(vec![utxo_input("sender", "50000")], vec![utxo_input("user", "40000"), utxo_input("change", "9000")]).finalize(vec!["user".to_string()]); + + assert_eq!( + (transaction.from.as_str(), transaction.to.as_str(), transaction.value.as_str()), + ("sender", "user", "40000") + ); + assert_eq!(transaction.direction, TransactionDirection::Incoming); + } + + #[test] + fn test_finalize_outgoing_utxo() { + let transaction = + Transaction::mock_utxo(vec![utxo_input("user", "50000")], vec![utxo_input("recipient", "40000"), utxo_input("user", "9000")]).finalize(vec!["user".to_string()]); + + assert_eq!( + (transaction.from.as_str(), transaction.to.as_str(), transaction.value.as_str()), + ("user", "recipient", "40000") + ); + assert_eq!(transaction.direction, TransactionDirection::Outgoing); + } + + #[test] + fn test_finalize_self_transfer_utxo() { + let transaction = + Transaction::mock_utxo(vec![utxo_input("user", "50000")], vec![utxo_input("user", "40000"), utxo_input("user", "9000")]).finalize(vec!["user".to_string()]); + + assert_eq!((transaction.from.as_str(), transaction.to.as_str(), transaction.value.as_str()), ("user", "user", "49000")); + assert_eq!(transaction.direction, TransactionDirection::SelfTransfer); + } + + #[test] + fn test_finalize_non_utxo_unchanged() { + let original = Transaction::mock(); + let transaction = original.clone().finalize(vec!["0xfrom".to_string()]); + + assert_eq!((transaction.from, transaction.to, transaction.value), (original.from, original.to, original.value)); + } +} diff --git a/core/crates/primitives/src/transaction_data_output.rs b/core/crates/primitives/src/transaction_data_output.rs new file mode 100644 index 0000000000..ba14ec3416 --- /dev/null +++ b/core/crates/primitives/src/transaction_data_output.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum TransferDataOutputType { + EncodedTransaction, + Signature, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub enum TransferDataOutputAction { + Sign, + Send, +} diff --git a/core/crates/primitives/src/transaction_direction.rs b/core/crates/primitives/src/transaction_direction.rs new file mode 100644 index 0000000000..f8decfcf3f --- /dev/null +++ b/core/crates/primitives/src/transaction_direction.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum TransactionDirection { + #[serde(rename = "self")] + SelfTransfer, + Outgoing, + Incoming, +} diff --git a/core/crates/primitives/src/transaction_extended.rs b/core/crates/primitives/src/transaction_extended.rs new file mode 100644 index 0000000000..15eae0ef39 --- /dev/null +++ b/core/crates/primitives/src/transaction_extended.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; +use crate::{Transaction, Asset, Price, AssetPrice, AddressName}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable, Hashable")] +pub struct TransactionExtended { + pub transaction: Transaction, + pub asset: Asset, + #[serde(rename = "feeAsset")] + pub feeAsset: Asset, + pub price: Option, + #[serde(rename = "feePrice")] + pub fee_price: Option, + pub assets: Vec, + pub prices: Vec, + #[serde(rename = "fromAddress")] + pub from_address: Option, + #[serde(rename = "toAddress")] + pub to_address: Option, +} diff --git a/core/crates/primitives/src/transaction_fee.rs b/core/crates/primitives/src/transaction_fee.rs new file mode 100644 index 0000000000..bda57dd0a8 --- /dev/null +++ b/core/crates/primitives/src/transaction_fee.rs @@ -0,0 +1,179 @@ +use crate::{GasPriceType, SignerError}; +use num_bigint::BigInt; +use num_traits::ToPrimitive; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use strum::{AsRefStr, Display, EnumString}; + +#[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, Display, EnumString, PartialEq, Eq, Hash)] +pub enum FeeOption { + TokenAccountCreation, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionFee { + pub fee: BigInt, + pub gas_price_type: GasPriceType, + pub gas_limit: BigInt, + pub options: HashMap, +} + +impl Default for TransactionFee { + fn default() -> Self { + Self { + fee: BigInt::from(0), + gas_price_type: GasPriceType::regular(BigInt::from(0)), + gas_limit: BigInt::from(0), + options: HashMap::new(), + } + } +} + +impl TransactionFee { + pub fn new_from_fee(fee: BigInt) -> Self { + Self { + fee: fee.clone(), + gas_price_type: GasPriceType::regular(fee), + gas_limit: BigInt::from(0), + options: HashMap::new(), + } + } + + pub fn new_from_gas_price_and_limit(gas_price: BigInt, gas_limit: BigInt) -> Self { + Self { + fee: gas_price.clone() * &gas_limit, + gas_price_type: GasPriceType::regular(gas_price), + gas_limit, + options: HashMap::new(), + } + } + + pub fn new_from_fee_with_option(fee: BigInt, option: FeeOption, option_value: BigInt) -> Self { + Self { + fee: fee.clone() + option_value.clone(), + gas_price_type: GasPriceType::regular(fee.clone()), + gas_limit: BigInt::from(0), + options: HashMap::from([(option, option_value)]), + } + } + + pub fn new_gas_price_type(gas_price_type: GasPriceType, base_fee: BigInt, gas_limit: BigInt, options: HashMap) -> Self { + Self { + fee: base_fee + options.values().sum::(), + gas_price_type, + gas_limit, + options, + } + } + + pub fn calculate(gas_limit: u64, gas_price_type: &GasPriceType) -> Self { + let gas_limit = BigInt::from(gas_limit); + let gas_price = gas_price_type.gas_price(); + let total_fee = gas_price.clone() * &gas_limit; + + Self { + fee: total_fee, + gas_price_type: gas_price_type.clone(), + gas_limit, + options: HashMap::new(), + } + } + + pub fn gas_limit(&self) -> Result { + let gas_limit = self.gas_limit.to_u64().ok_or_else(|| SignerError::invalid_input("invalid gas limit"))?; + + if gas_limit == 0 { + return SignerError::invalid_input_err("missing gas limit"); + } + + Ok(gas_limit) + } + + pub fn gas_price_u64(&self) -> Result { + self.gas_price_type.gas_price().to_u64().ok_or_else(|| SignerError::invalid_input("invalid gas price")) + } + + pub fn priority_fee_u64(&self) -> Result { + self.gas_price_type + .priority_fee() + .to_u64() + .ok_or_else(|| SignerError::invalid_input("invalid priority fee")) + } + + pub fn unit_price_u64(&self) -> Result { + self.gas_price_type.unit_price().to_u64().ok_or_else(|| SignerError::invalid_input("invalid unit price")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transaction_fee_calculate() { + let gas_price_type = GasPriceType::regular(BigInt::from(100u64)); + let gas_limit = 1000u64; + + let fee = TransactionFee::calculate(gas_limit, &gas_price_type); + + assert_eq!(fee.fee, BigInt::from(100000u64)); // 100 * 1000 + assert_eq!(fee.gas_price_type.gas_price(), BigInt::from(100u64)); + assert_eq!(fee.gas_limit, BigInt::from(1000u64)); + } + + #[test] + fn test_new_gas_price_type() { + // Without options + let fee = TransactionFee::new_gas_price_type(GasPriceType::regular(BigInt::from(200)), BigInt::from(50000), BigInt::from(500), HashMap::new()); + assert_eq!(fee.fee, BigInt::from(50000)); + assert_eq!(fee.gas_limit, BigInt::from(500)); + + // With options + let fee = TransactionFee::new_gas_price_type( + GasPriceType::regular(BigInt::from(150)), + BigInt::from(30000), + BigInt::from(400), + HashMap::from([(FeeOption::TokenAccountCreation, BigInt::from(5000))]), + ); + assert_eq!(fee.fee, BigInt::from(35000)); // 30000 + 5000 + + // With EIP-1559 + let fee = TransactionFee::new_gas_price_type( + GasPriceType::eip1559(BigInt::from(300), BigInt::from(10)), + BigInt::from(60000), + BigInt::from(200), + HashMap::new(), + ); + assert_eq!(fee.gas_price_type.priority_fee(), BigInt::from(10)); + } + + #[test] + fn test_new_from_fee_with_option() { + let base_fee = BigInt::from(10000); + let option_value = BigInt::from(2500); + + let fee = TransactionFee::new_from_fee_with_option(base_fee.clone(), FeeOption::TokenAccountCreation, option_value.clone()); + + assert_eq!(fee.fee, BigInt::from(12500)); // 10000 + 2500 + assert_eq!(fee.gas_price_type.gas_price(), base_fee); + assert_eq!(fee.gas_limit, BigInt::from(0)); + assert_eq!(fee.options.get(&FeeOption::TokenAccountCreation), Some(&option_value)); + } + + #[test] + fn test_fee_accessors() { + let fee = TransactionFee::new_gas_price_type(GasPriceType::regular(BigInt::from(7u64)), BigInt::from(70u64), BigInt::from(10u64), HashMap::new()); + assert_eq!(fee.gas_limit().unwrap(), 10); + assert_eq!(fee.gas_price_u64().unwrap(), 7); + + let fee = TransactionFee::new_gas_price_type( + GasPriceType::solana(BigInt::from(7u64), BigInt::from(3u64), BigInt::from(2u64)), + BigInt::from(10u64), + BigInt::from(1u64), + HashMap::new(), + ); + assert_eq!(fee.unit_price_u64().unwrap(), 2); + + assert_eq!(TransactionFee::default().gas_limit().unwrap_err().to_string(), "Invalid input: missing gas limit"); + } +} diff --git a/core/crates/primitives/src/transaction_id.rs b/core/crates/primitives/src/transaction_id.rs new file mode 100644 index 0000000000..38941cc34a --- /dev/null +++ b/core/crates/primitives/src/transaction_id.rs @@ -0,0 +1,144 @@ +use crate::CHAIN_SEPARATOR; +use crate::chain::Chain; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TransactionId { + pub chain: Chain, + pub hash: String, +} + +impl TransactionId { + pub fn new(chain: Chain, hash: String) -> Self { + Self { chain, hash } + } +} + +impl std::fmt::Display for TransactionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}{CHAIN_SEPARATOR}{}", self.chain.as_ref(), self.hash) + } +} + +impl Serialize for TransactionId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for TransactionId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum TransactionIdInput { + String(String), + Object { chain: Chain, hash: String }, + } + + match TransactionIdInput::deserialize(deserializer)? { + TransactionIdInput::String(value) => TransactionId::from_str(&value).map_err(serde::de::Error::custom), + TransactionIdInput::Object { chain, hash } => Ok(TransactionId::new(chain, hash)), + } + } +} + +impl FromStr for TransactionId { + type Err = String; + + fn from_str(s: &str) -> Result { + let (chain_str, hash_str) = s + .split_once(CHAIN_SEPARATOR) + .ok_or_else(|| format!("Invalid TransactionId format: expected chain{CHAIN_SEPARATOR}hash, got {s}"))?; + let chain = Chain::from_str(chain_str).map_err(|e| format!("Invalid chain identifier '{chain_str}': {e}"))?; + Ok(TransactionId::new(chain, hash_str.to_string())) + } +} + +impl TryFrom for TransactionId { + type Error = String; + + fn try_from(s: String) -> Result { + TransactionId::from_str(&s) + } +} + +impl TryFrom<&str> for TransactionId { + type Error = String; + + fn try_from(s: &str) -> Result { + TransactionId::from_str(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Chain; + use serde_json; + use std::convert::TryFrom; + + #[test] + fn test_display_trait_to_string() { + let tx_id = TransactionId::new(Chain::Ethereum, "0x123".to_string()); + assert_eq!(tx_id.to_string(), "ethereum_0x123"); // This now uses Display::to_string() + assert_eq!(format!("{tx_id}"), "ethereum_0x123"); // Also test format!() + } + + #[test] + fn test_serde_roundtrip() { + let tx_id = TransactionId::new(Chain::Solana, "solhash456".to_string()); + let serialized = serde_json::to_string(&tx_id).unwrap(); + assert_eq!(serialized, "\"solana_solhash456\""); + let deserialized: TransactionId = serde_json::from_str(&serialized).unwrap(); + assert_eq!(tx_id, deserialized); + } + + #[test] + fn test_deserialize_object() { + let deserialized: TransactionId = serde_json::from_str(r#"{"chain":"solana","hash":"solhash456"}"#).unwrap(); + assert_eq!(deserialized, TransactionId::new(Chain::Solana, "solhash456".to_string())); + } + + #[test] + fn test_from_str_valid() { + let tx_id_str = "bitcoin_btchash789"; + let tx_id = TransactionId::from_str(tx_id_str).unwrap(); + assert_eq!(tx_id.chain, Chain::Bitcoin); + assert_eq!(tx_id.hash, "btchash789"); + } + + #[test] + fn test_from_str_invalid_format() { + let result = TransactionId::from_str("invalidformat"); + assert!(result.is_err()); + } + + #[test] + fn test_from_str_invalid_chain() { + let result = TransactionId::from_str("nonexistentchain_somehash"); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_string_valid() { + let tx_id_str = "ethereum_0xabc".to_string(); + let tx_id = TransactionId::try_from(tx_id_str).unwrap(); + assert_eq!(tx_id.chain, Chain::Ethereum); + assert_eq!(tx_id.hash, "0xabc"); + } + + #[test] + fn test_try_from_str_ref_valid() { + let tx_id_str = "polygon_0xdef"; + let tx_id = TransactionId::try_from(tx_id_str).unwrap(); + assert_eq!(tx_id.chain, Chain::Polygon); + assert_eq!(tx_id.hash, "0xdef"); + } +} diff --git a/core/crates/primitives/src/transaction_input_type.rs b/core/crates/primitives/src/transaction_input_type.rs new file mode 100644 index 0000000000..fa61634df7 --- /dev/null +++ b/core/crates/primitives/src/transaction_input_type.rs @@ -0,0 +1,298 @@ +use crate::contract_call_data::ContractCallData; +use crate::earn_type::EarnType; +use crate::stake_type::StakeType; +use crate::swap::{ApprovalData, SwapData}; +use crate::transaction_fee::TransactionFee; +use crate::transaction_load_metadata::TransactionLoadMetadata; +use crate::{ + Asset, GasPriceType, PerpetualType, SignerError, TransactionPreloadInput, TransactionType, TransferDataExtra, WalletConnectionSessionAppMetadata, nft::NFTAsset, + perpetual::AccountDataType, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::ops::Deref; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[allow(clippy::large_enum_variant)] +pub enum TransactionInputType { + Transfer(Asset), + Deposit(Asset), + Swap(Asset, Asset, SwapData), + Stake(Asset, StakeType), + TokenApprove(Asset, ApprovalData), + Generic(Asset, WalletConnectionSessionAppMetadata, TransferDataExtra), + TransferNft(Asset, NFTAsset), + Account(Asset, AccountDataType), + Perpetual(Asset, PerpetualType), + Earn(Asset, EarnType, ContractCallData), +} + +impl TransactionInputType { + pub fn get_asset(&self) -> &Asset { + match self { + TransactionInputType::Transfer(asset) => asset, + TransactionInputType::Deposit(asset) => asset, + TransactionInputType::Swap(asset, _, _) => asset, + TransactionInputType::Stake(asset, _) => asset, + TransactionInputType::TokenApprove(asset, _) => asset, + TransactionInputType::Generic(asset, _, _) => asset, + TransactionInputType::TransferNft(asset, _) => asset, + TransactionInputType::Account(asset, _) => asset, + TransactionInputType::Perpetual(asset, _) => asset, + TransactionInputType::Earn(asset, _, _) => asset, + } + } + + pub fn get_swap_data(&self) -> Result<&SwapData, &'static str> { + match self { + TransactionInputType::Swap(_, _, swap_data) => Ok(swap_data), + _ => Err("expected swap transaction"), + } + } + + pub fn get_generic_data(&self) -> Result<&TransferDataExtra, &'static str> { + match self { + TransactionInputType::Generic(_, _, extra) => Ok(extra), + _ => Err("expected generic transaction"), + } + } + + pub fn get_approval_data(&self) -> Result<&ApprovalData, &'static str> { + match self { + TransactionInputType::TokenApprove(_, approval) => Ok(approval), + _ => Err("expected token approval transaction"), + } + } + + pub fn get_nft_asset(&self) -> Result<&NFTAsset, &'static str> { + match self { + TransactionInputType::TransferNft(_, nft) => Ok(nft), + _ => Err("expected NFT transfer transaction"), + } + } + + pub fn get_earn_data(&self) -> Result<&ContractCallData, &'static str> { + match self { + TransactionInputType::Earn(_, _, data) => Ok(data), + _ => Err("expected earn transaction"), + } + } + + pub fn get_stake_type(&self) -> Result<&StakeType, &'static str> { + match self { + TransactionInputType::Stake(_, stake_type) => Ok(stake_type), + _ => Err("expected stake transaction"), + } + } + + pub fn get_perpetual_type(&self) -> Result<&PerpetualType, &'static str> { + match self { + TransactionInputType::Perpetual(_, perpetual_type) => Ok(perpetual_type), + _ => Err("expected perpetual transaction"), + } + } + + pub fn swap_to_address(&self) -> Option<&str> { + match self { + TransactionInputType::Swap(_, _, swap_data) => Some(&swap_data.data.to), + _ => None, + } + } + + pub fn get_recipient_asset(&self) -> &Asset { + match self { + TransactionInputType::Transfer(asset) => asset, + TransactionInputType::Deposit(asset) => asset, + TransactionInputType::Swap(_, asset, _) => asset, + TransactionInputType::Stake(asset, _) => asset, + TransactionInputType::TokenApprove(asset, _) => asset, + TransactionInputType::Generic(asset, _, _) => asset, + TransactionInputType::TransferNft(asset, _) => asset, + TransactionInputType::Account(asset, _) => asset, + TransactionInputType::Perpetual(asset, _) => asset, + TransactionInputType::Earn(asset, _, _) => asset, + } + } + + pub fn transaction_type(&self) -> TransactionType { + match self { + TransactionInputType::Transfer(_) | TransactionInputType::Deposit(_) => TransactionType::Transfer, + TransactionInputType::Swap(_, _, _) => TransactionType::Swap, + TransactionInputType::Stake(_, stake_type) => match stake_type { + StakeType::Stake(_) => TransactionType::StakeDelegate, + StakeType::Unstake(_) => TransactionType::StakeUndelegate, + StakeType::Redelegate(_) => TransactionType::StakeRedelegate, + StakeType::Rewards(_) => TransactionType::StakeRewards, + StakeType::Withdraw(_) => TransactionType::StakeWithdraw, + StakeType::Freeze(_) => TransactionType::StakeFreeze, + StakeType::Unfreeze(_) => TransactionType::StakeUnfreeze, + }, + TransactionInputType::TokenApprove(_, _) => TransactionType::TokenApproval, + TransactionInputType::Generic(_, _, _) => TransactionType::SmartContractCall, + TransactionInputType::TransferNft(_, _) => TransactionType::TransferNFT, + TransactionInputType::Account(_, _) => TransactionType::AssetActivation, + TransactionInputType::Perpetual(_, perpetual_type) => match perpetual_type { + PerpetualType::Open(_) | PerpetualType::Increase(_) => TransactionType::PerpetualOpenPosition, + PerpetualType::Close(_) | PerpetualType::Reduce(_) => TransactionType::PerpetualClosePosition, + PerpetualType::Modify(_) => TransactionType::PerpetualModifyPosition, + }, + TransactionInputType::Earn(_, earn_type, _) => match earn_type { + EarnType::Deposit(_) => TransactionType::EarnDeposit, + EarnType::Withdraw(_) => TransactionType::EarnWithdraw, + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionLoadInput { + pub input_type: TransactionInputType, + pub sender_address: String, + pub destination_address: String, + pub value: String, + pub gas_price: GasPriceType, + pub memo: Option, + pub is_max_value: bool, + pub metadata: TransactionLoadMetadata, +} + +impl TransactionLoadInput { + pub fn default_fee(&self) -> TransactionFee { + TransactionFee { + fee: self.gas_price.total_fee(), + gas_price_type: self.gas_price.clone(), + gas_limit: 0.into(), + options: HashMap::new(), + } + } +} + +impl TransactionLoadInput { + pub fn get_data_extra(&self) -> Result<&TransferDataExtra, &'static str> { + self.input_type.get_generic_data() + } + + pub fn get_memo(&self) -> Option<&str> { + self.memo.as_deref().filter(|m| !m.is_empty()) + } + + pub fn value_as_u64(&self) -> Result { + self.value.parse::().map_err(|_| SignerError::invalid_input("invalid transaction amount")) + } + + pub fn to_preload_input(&self) -> TransactionPreloadInput { + TransactionPreloadInput { + input_type: self.input_type.clone(), + sender_address: self.sender_address.clone(), + destination_address: self.destination_address.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignerInput { + pub input: TransactionLoadInput, + pub fee: TransactionFee, +} + +impl SignerInput { + pub fn new(input: TransactionLoadInput, fee: TransactionFee) -> Self { + Self { input, fee } + } +} + +impl Deref for SignerInput { + type Target = TransactionLoadInput; + + fn deref(&self) -> &Self::Target { + &self.input + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionLoadData { + pub fee: TransactionFee, + pub metadata: TransactionLoadMetadata, +} + +impl TransactionLoadData { + pub fn new_from(&self, fee: TransactionFee) -> Self { + Self { + fee, + metadata: self.metadata.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Asset, DelegationValidator, PerpetualConfirmData, PerpetualDirection, Resource}; + + #[test] + fn transaction_types() { + assert_eq!(TransactionInputType::Transfer(Asset::mock()).transaction_type(), TransactionType::Transfer); + assert_eq!( + TransactionInputType::Stake(Asset::mock(), StakeType::Stake(DelegationValidator::mock())).transaction_type(), + TransactionType::StakeDelegate + ); + assert_eq!( + TransactionInputType::Stake(Asset::mock(), StakeType::Freeze(Resource::Bandwidth)).transaction_type(), + TransactionType::StakeFreeze + ); + assert_eq!( + TransactionInputType::Stake(Asset::mock(), StakeType::Unfreeze(Resource::Bandwidth)).transaction_type(), + TransactionType::StakeUnfreeze + ); + assert_eq!( + TransactionInputType::Perpetual(Asset::mock(), PerpetualType::Open(PerpetualConfirmData::mock(PerpetualDirection::Long, 0, None, None))).transaction_type(), + TransactionType::PerpetualOpenPosition + ); + } + + #[test] + fn transaction_input_accessors() { + let stake_type = StakeType::Freeze(Resource::Bandwidth); + let stake_input = TransactionInputType::Stake(Asset::mock(), stake_type); + match stake_input.get_stake_type().unwrap() { + StakeType::Freeze(resource) => assert_eq!(resource, &Resource::Bandwidth), + StakeType::Stake(_) | StakeType::Unstake(_) | StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Unfreeze(_) => { + panic!("expected freeze stake type") + } + } + + let perpetual_type = PerpetualType::Open(PerpetualConfirmData::mock(PerpetualDirection::Long, 11, None, None)); + let perpetual_input = TransactionInputType::Perpetual(Asset::mock(), perpetual_type); + match perpetual_input.get_perpetual_type().unwrap() { + PerpetualType::Open(data) => assert_eq!(data.asset_index, 11), + PerpetualType::Close(_) | PerpetualType::Modify(_) | PerpetualType::Increase(_) | PerpetualType::Reduce(_) => panic!("expected open perpetual type"), + } + + assert_eq!(TransactionInputType::Transfer(Asset::mock()).get_stake_type().unwrap_err(), "expected stake transaction"); + assert_eq!( + TransactionInputType::Transfer(Asset::mock()).get_perpetual_type().unwrap_err(), + "expected perpetual transaction" + ); + } + + #[test] + fn transaction_load_input_value_as_u64() { + let mut input = TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::mock()), + sender_address: "sender".to_string(), + destination_address: "destination".to_string(), + value: "123".to_string(), + gas_price: GasPriceType::regular(1u64), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::None, + }; + + assert_eq!(input.value_as_u64().unwrap(), 123); + + input.value = "1.23".to_string(); + assert_eq!(input.value_as_u64().unwrap_err().to_string(), "Invalid input: invalid transaction amount"); + } +} diff --git a/core/crates/primitives/src/transaction_load_metadata.rs b/core/crates/primitives/src/transaction_load_metadata.rs new file mode 100644 index 0000000000..7f7146da73 --- /dev/null +++ b/core/crates/primitives/src/transaction_load_metadata.rs @@ -0,0 +1,254 @@ +use serde::{Deserialize, Serialize}; + +use crate::{UTXO, contract_call_data::ContractCallData, solana_nft::SolanaNftStandard, solana_token_program::SolanaTokenProgramId, stake_type::TronStakeData}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HyperliquidOrder { + pub approve_agent_required: bool, + pub approve_referral_required: bool, + pub approve_builder_required: bool, + pub builder_fee_bps: u32, + pub agent_address: String, + pub agent_private_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TransactionLoadMetadata { + None, + Solana { + sender_token_address: Option, + recipient_token_address: Option, + token_program: Option, + nft: Option, + block_hash: String, + }, + Ton { + sender_token_address: Option, + recipient_token_address: Option, + sequence: u64, + }, + Cosmos { + account_number: u64, + sequence: u64, + chain_id: String, + }, + Bitcoin { + utxos: Vec, + }, + Zcash { + utxos: Vec, + branch_id: String, + }, + Cardano { + utxos: Vec, + block_number: u64, + }, + Evm { + nonce: u64, + chain_id: u64, + contract_call: Option, + }, + Near { + sequence: u64, + block_hash: String, + }, + Stellar { + sequence: u64, + is_destination_address_exist: bool, + }, + Xrp { + sequence: u64, + block_number: u64, + }, + Algorand { + sequence: u64, + block_hash: String, + chain_id: String, + }, + Aptos { + sequence: u64, + data: Option, + }, + Polkadot { + sequence: u64, + genesis_hash: String, + block_hash: String, + block_number: u64, + spec_version: u64, + transaction_version: u64, + period: u64, + }, + Tron { + block_number: u64, + block_version: u64, + block_timestamp: u64, + transaction_tree_root: String, + parent_hash: String, + witness_address: String, + stake_data: TronStakeData, + }, + Sui { + message_bytes: String, + }, + Hyperliquid { + order: Option, + }, +} + +impl TransactionLoadMetadata { + pub fn get_sequence(&self) -> Result> { + match self { + TransactionLoadMetadata::Ton { sequence, .. } => Ok(*sequence), + TransactionLoadMetadata::Cosmos { sequence, .. } => Ok(*sequence), + TransactionLoadMetadata::Near { sequence, .. } => Ok(*sequence), + TransactionLoadMetadata::Stellar { sequence, .. } => Ok(*sequence), + TransactionLoadMetadata::Xrp { sequence, .. } => Ok(*sequence), + TransactionLoadMetadata::Algorand { sequence, .. } => Ok(*sequence), + TransactionLoadMetadata::Aptos { sequence, .. } => Ok(*sequence), + TransactionLoadMetadata::Polkadot { sequence, .. } => Ok(*sequence), + TransactionLoadMetadata::Evm { nonce, .. } => Ok(*nonce), + _ => Err("Sequence not available for this metadata type".into()), + } + } + + pub fn get_block_number(&self) -> Result> { + match self { + TransactionLoadMetadata::Polkadot { block_number, .. } => Ok(*block_number), + TransactionLoadMetadata::Tron { block_number, .. } => Ok(*block_number), + TransactionLoadMetadata::Xrp { block_number, .. } => Ok(*block_number), + TransactionLoadMetadata::Cardano { block_number, .. } => Ok(*block_number), + _ => Err("Block number not available for this metadata type".into()), + } + } + + pub fn get_block_hash(&self) -> Result> { + match self { + TransactionLoadMetadata::Solana { block_hash, .. } => Ok(block_hash.clone()), + TransactionLoadMetadata::Near { block_hash, .. } => Ok(block_hash.clone()), + TransactionLoadMetadata::Algorand { block_hash, .. } => Ok(block_hash.clone()), + TransactionLoadMetadata::Polkadot { block_hash, .. } => Ok(block_hash.clone()), + _ => Err("Block hash not available for this metadata type".into()), + } + } + + pub fn get_account_number(&self) -> Result> { + match self { + TransactionLoadMetadata::Cosmos { account_number, .. } => Ok(*account_number), + _ => Err("Account number not available for this metadata type".into()), + } + } + + pub fn get_chain_id(&self) -> Result> { + match self { + TransactionLoadMetadata::Cosmos { chain_id, .. } => Ok(chain_id.clone()), + TransactionLoadMetadata::Algorand { chain_id, .. } => Ok(chain_id.clone()), + TransactionLoadMetadata::Evm { chain_id, .. } => Ok(chain_id.to_string()), + _ => Err("Chain ID not available for this metadata type".into()), + } + } + + pub fn get_utxos(&self) -> Result, Box> { + match self { + TransactionLoadMetadata::Bitcoin { utxos } => Ok(utxos.clone()), + TransactionLoadMetadata::Zcash { utxos, .. } => Ok(utxos.clone()), + TransactionLoadMetadata::Cardano { utxos, .. } => Ok(utxos.clone()), + _ => Err("UTXOs not available for this metadata type".into()), + } + } + + pub fn get_is_destination_address_exist(&self) -> Result> { + match self { + TransactionLoadMetadata::Stellar { is_destination_address_exist, .. } => Ok(*is_destination_address_exist), + _ => Err("Destination existence flag not available for this metadata type".into()), + } + } + + pub fn get_recipient_token_address(&self) -> Result, Box> { + match self { + TransactionLoadMetadata::Solana { recipient_token_address, .. } => Ok(recipient_token_address.clone()), + TransactionLoadMetadata::Ton { recipient_token_address, .. } => Ok(recipient_token_address.clone()), + _ => Err("Recipient token address not available for this metadata type".into()), + } + } + + pub fn get_sender_token_address(&self) -> Result, Box> { + match self { + TransactionLoadMetadata::Solana { sender_token_address, .. } => Ok(sender_token_address.clone()), + TransactionLoadMetadata::Ton { sender_token_address, .. } => Ok(sender_token_address.clone()), + _ => Err("Sender token address not available for this metadata type".into()), + } + } + + pub fn get_solana_token_program_id(&self) -> Result, Box> { + match self { + TransactionLoadMetadata::Solana { token_program, .. } => Ok(token_program.clone()), + _ => Err("Solana token program not available for this metadata type".into()), + } + } + + pub fn get_message_bytes(&self) -> Result> { + match self { + TransactionLoadMetadata::Sui { message_bytes, .. } => Ok(message_bytes.clone()), + _ => Err("Message bytes not available for this metadata type".into()), + } + } + + pub fn get_chain_id_u64(&self) -> Result> { + self.get_chain_id()?.parse::().map_err(|e| e.to_string().into()) + } + + pub fn get_contract_call(&self) -> Result<&ContractCallData, Box> { + match self { + TransactionLoadMetadata::Evm { contract_call: Some(cc), .. } => Ok(cc), + _ => Err("Contract call not available for this metadata type".into()), + } + } + + pub fn get_hyperliquid_order(&self) -> Result<&HyperliquidOrder, Box> { + match self { + TransactionLoadMetadata::Hyperliquid { order: Some(order) } => Ok(order), + _ => Err("Hyperliquid order not available for this metadata type".into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transaction_load_metadata_accessors() { + let metadata = TransactionLoadMetadata::Hyperliquid { + order: Some(HyperliquidOrder { + approve_agent_required: true, + approve_referral_required: false, + approve_builder_required: true, + builder_fee_bps: 10, + agent_address: "0xagent".into(), + agent_private_key: "0xkey".into(), + }), + }; + + let order = metadata.get_hyperliquid_order().unwrap(); + assert_eq!(order.builder_fee_bps, 10); + assert_eq!(order.agent_address, "0xagent"); + + assert_eq!( + TransactionLoadMetadata::None.get_hyperliquid_order().unwrap_err().to_string(), + "Hyperliquid order not available for this metadata type" + ); + + let metadata = TransactionLoadMetadata::Solana { + sender_token_address: None, + recipient_token_address: None, + token_program: Some(SolanaTokenProgramId::Token2022), + nft: None, + block_hash: "block_hash".into(), + }; + assert_eq!(metadata.get_solana_token_program_id().unwrap(), Some(SolanaTokenProgramId::Token2022)); + assert_eq!( + TransactionLoadMetadata::None.get_solana_token_program_id().unwrap_err().to_string(), + "Solana token program not available for this metadata type" + ); + } +} diff --git a/core/crates/primitives/src/transaction_metadata_types.rs b/core/crates/primitives/src/transaction_metadata_types.rs new file mode 100644 index 0000000000..39b88a17fa --- /dev/null +++ b/core/crates/primitives/src/transaction_metadata_types.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{AssetId, NFTAssetId, PerpetualDirection, PerpetualProvider, stake_type::Resource}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionPerpetualMetadata { + pub pnl: f64, + pub price: f64, + pub direction: PerpetualDirection, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_liquidation: Option, + pub provider: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionSwapMetadata { + pub from_asset: AssetId, + pub from_value: String, + pub to_asset: AssetId, + pub to_value: String, + pub provider: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionNFTTransferMetadata { + pub asset_id: NFTAssetId, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl TransactionNFTTransferMetadata { + pub fn new(asset_id: NFTAssetId, name: Option) -> Self { + Self { asset_id, name } + } + + pub fn from_asset_id(asset_id: NFTAssetId) -> Self { + Self { asset_id, name: None } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionResourceTypeMetadata { + pub resource_type: Resource, +} + +impl TransactionResourceTypeMetadata { + pub fn new(resource_type: Resource) -> Self { + Self { resource_type } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionSmartContractMetadata { + pub method_name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nft_transfer_metadata_serialization() { + let asset_id = NFTAssetId::mock(); + let serialized = asset_id.to_string(); + assert_eq!( + serde_json::to_value(TransactionNFTTransferMetadata::new(asset_id.clone(), None)).unwrap(), + serde_json::json!({ "assetId": serialized }) + ); + assert_eq!( + serde_json::to_value(TransactionNFTTransferMetadata::new(asset_id, Some("NFT".to_string()))).unwrap(), + serde_json::json!({ "assetId": serialized, "name": "NFT" }) + ); + } +} diff --git a/core/crates/primitives/src/transaction_preload_input.rs b/core/crates/primitives/src/transaction_preload_input.rs new file mode 100644 index 0000000000..9471a23c1b --- /dev/null +++ b/core/crates/primitives/src/transaction_preload_input.rs @@ -0,0 +1,26 @@ +use crate::{TransactionInputType, TransactionType}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionPreloadInput { + pub input_type: TransactionInputType, + pub sender_address: String, + pub destination_address: String, +} + +impl TransactionPreloadInput { + pub fn scan_type(&self) -> Option { + match &self.input_type { + TransactionInputType::Transfer(_) => Some(TransactionType::Transfer), + _ => None, + } + } + + pub fn get_website(&self) -> Option { + match &self.input_type { + TransactionInputType::Generic(_, app_metadata, _) => Some(app_metadata.url.clone()), + _ => None, + } + } +} diff --git a/core/crates/primitives/src/transaction_state.rs b/core/crates/primitives/src/transaction_state.rs new file mode 100644 index 0000000000..fa7bbaa47f --- /dev/null +++ b/core/crates/primitives/src/transaction_state.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, EnumIter, EnumString)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum TransactionState { + Pending, + Confirmed, + InTransit, + Failed, + Reverted, +} + +impl TransactionState { + pub fn is_completed(&self) -> bool { + match self { + Self::Confirmed | Self::Failed | Self::Reverted => true, + Self::Pending | Self::InTransit => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_completed() { + assert!(TransactionState::Confirmed.is_completed()); + assert!(TransactionState::Failed.is_completed()); + assert!(TransactionState::Reverted.is_completed()); + assert!(!TransactionState::Pending.is_completed()); + assert!(!TransactionState::InTransit.is_completed()); + } +} diff --git a/core/crates/primitives/src/transaction_state_request.rs b/core/crates/primitives/src/transaction_state_request.rs new file mode 100644 index 0000000000..0659ec847d --- /dev/null +++ b/core/crates/primitives/src/transaction_state_request.rs @@ -0,0 +1,24 @@ +use crate::{Chain, SwapProvider, TransactionState, UInt64}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Sendable, Equatable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionStateRequest { + pub id: String, + pub sender_address: String, + pub created_at: DateTime, + pub block_number: UInt64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Sendable, Equatable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionSwapStateRequest { + pub transaction: TransactionStateRequest, + pub state: TransactionState, + pub swap_provider: SwapProvider, + pub destination_chain: Chain, +} diff --git a/core/crates/primitives/src/transaction_type.rs b/core/crates/primitives/src/transaction_type.rs new file mode 100644 index 0000000000..a07dc99b77 --- /dev/null +++ b/core/crates/primitives/src/transaction_type.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, EnumString, AsRefStr, PartialEq, EnumIter)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +#[derive(Default)] +pub enum TransactionType { + #[default] + Transfer, + #[serde(rename = "transferNFT")] + #[strum(serialize = "transferNFT")] + TransferNFT, + Swap, + TokenApproval, + StakeDelegate, + StakeUndelegate, + StakeRewards, + StakeRedelegate, + StakeWithdraw, + StakeFreeze, + StakeUnfreeze, + AssetActivation, + SmartContractCall, + PerpetualOpenPosition, + PerpetualClosePosition, + PerpetualModifyPosition, + EarnDeposit, + EarnWithdraw, +} + +impl TransactionType { + pub fn all() -> Vec { + Self::iter().collect::>() + } + + pub fn staking_types() -> Vec { + vec![Self::StakeDelegate, Self::StakeUndelegate, Self::StakeRedelegate, Self::StakeRewards] + } +} diff --git a/core/crates/primitives/src/transaction_update.rs b/core/crates/primitives/src/transaction_update.rs new file mode 100644 index 0000000000..588a7d439f --- /dev/null +++ b/core/crates/primitives/src/transaction_update.rs @@ -0,0 +1,34 @@ +use crate::transaction_metadata_types::{TransactionPerpetualMetadata, TransactionSwapMetadata}; +use crate::transaction_state::TransactionState; +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TransactionUpdate { + pub state: TransactionState, + pub changes: Vec, +} + +impl TransactionUpdate { + pub fn new(state: TransactionState, changes: Vec) -> Self { + Self { state, changes } + } + + pub fn new_state(state: TransactionState) -> Self { + Self { state, changes: Vec::new() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TransactionChange { + HashChange { old: String, new: String }, + Metadata(TransactionMetadata), + BlockNumber(String), + NetworkFee(BigInt), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TransactionMetadata { + Perpetual(TransactionPerpetualMetadata), + Swap(TransactionSwapMetadata), +} diff --git a/core/crates/primitives/src/transaction_utxo.rs b/core/crates/primitives/src/transaction_utxo.rs new file mode 100644 index 0000000000..811744c36b --- /dev/null +++ b/core/crates/primitives/src/transaction_utxo.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Sendable, Equatable, Hashable")] +pub struct TransactionUtxoInput { + pub address: String, // Coinbase / OP_Return will be filtered + pub value: String, +} + +impl TransactionUtxoInput { + pub fn new(address: String, value: String) -> Self { + Self { address, value } + } +} diff --git a/core/crates/primitives/src/transaction_wallet.rs b/core/crates/primitives/src/transaction_wallet.rs new file mode 100644 index 0000000000..2639927e9a --- /dev/null +++ b/core/crates/primitives/src/transaction_wallet.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{Transaction, Wallet}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +struct TransactionWallet { + pub transaction: Transaction, + pub wallet: Wallet, +} diff --git a/core/crates/primitives/src/transfer_data_extra.rs b/core/crates/primitives/src/transfer_data_extra.rs new file mode 100644 index 0000000000..93e3890fce --- /dev/null +++ b/core/crates/primitives/src/transfer_data_extra.rs @@ -0,0 +1,34 @@ +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; + +use crate::{GasPriceType, TransferDataOutputAction, TransferDataOutputType}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransferDataExtra { + pub to: String, + pub gas_limit: Option, + pub gas_price: Option, + pub data: Option>, + pub output_type: TransferDataOutputType, + pub output_action: TransferDataOutputAction, +} + +impl TransferDataExtra { + pub fn data_as_str(&self) -> Result<&str, &'static str> { + let bytes = self.data.as_ref().ok_or("missing data")?; + std::str::from_utf8(bytes).map_err(|_| "data is not valid utf8") + } +} + +impl Default for TransferDataExtra { + fn default() -> Self { + Self { + to: "".to_string(), + gas_limit: None, + gas_price: None, + data: None, + output_type: TransferDataOutputType::EncodedTransaction, + output_action: TransferDataOutputAction::Send, + } + } +} diff --git a/core/crates/primitives/src/url_action.rs b/core/crates/primitives/src/url_action.rs new file mode 100644 index 0000000000..e85baf413d --- /dev/null +++ b/core/crates/primitives/src/url_action.rs @@ -0,0 +1,50 @@ +use crate::{Deeplink, WalletConnectLink}; + +#[derive(Debug, Clone, PartialEq)] +pub enum UrlAction { + Deeplink { deeplink: Deeplink }, + WalletConnect { link: WalletConnectLink }, +} + +impl UrlAction { + pub fn from_url(url: &str) -> Option { + if let Some(link) = WalletConnectLink::from_url(url) { + return Some(Self::WalletConnect { link }); + } + Deeplink::from_url(url).map(|deeplink| Self::Deeplink { deeplink }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AssetId, Chain}; + + #[test] + fn test_from_url() { + assert_eq!( + UrlAction::from_url("https://gemwallet.com/tokens/bitcoin"), + Some(UrlAction::Deeplink { + deeplink: Deeplink::Asset { + asset_id: AssetId::from_chain(Chain::Bitcoin), + }, + }) + ); + assert_eq!( + UrlAction::from_url("gem://wc?sessionTopic=abc123"), + Some(UrlAction::WalletConnect { + link: WalletConnectLink::Session { topic: "abc123".to_string() }, + }) + ); + assert_eq!( + UrlAction::from_url("wc:topic@2?relay-protocol=irn&symKey=abc"), + Some(UrlAction::WalletConnect { + link: WalletConnectLink::Connect { + uri: "wc:topic@2?relay-protocol=irn&symKey=abc".to_string(), + }, + }) + ); + assert_eq!(UrlAction::from_url("https://example.com/tokens/bitcoin"), None); + assert_eq!(UrlAction::from_url("not a url"), None); + } +} diff --git a/core/crates/primitives/src/username_status.rs b/core/crates/primitives/src/username_status.rs new file mode 100644 index 0000000000..63f66c3347 --- /dev/null +++ b/core/crates/primitives/src/username_status.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumIter, EnumString}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, EnumIter, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum UsernameStatus { + Unverified, + Verified, +} + +impl UsernameStatus { + pub fn is_verified(&self) -> bool { + match self { + Self::Verified => true, + Self::Unverified => false, + } + } +} diff --git a/core/crates/primitives/src/utxo.rs b/core/crates/primitives/src/utxo.rs new file mode 100644 index 0000000000..690564eac7 --- /dev/null +++ b/core/crates/primitives/src/utxo.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +pub struct UTXO { + pub transaction_id: String, + pub vout: i32, + pub value: String, + pub address: String, +} diff --git a/core/crates/primitives/src/validator.rs b/core/crates/primitives/src/validator.rs new file mode 100644 index 0000000000..0d9b6cead7 --- /dev/null +++ b/core/crates/primitives/src/validator.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{AddressType, Chain, ScanAddress}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct StakeValidator { + pub id: String, + pub name: String, +} + +impl StakeValidator { + pub fn new(id: String, name: String) -> Self { + Self { id, name } + } + + pub fn as_scan_address(&self, chain: Chain) -> Option { + if self.name.is_empty() { + return None; + } + + Some(ScanAddress { + chain, + address: self.id.clone(), + name: Some(self.name.chars().take(128).collect()), + address_type: Some(AddressType::Validator), + is_malicious: Some(false), + is_memo_required: Some(false), + is_verified: Some(true), + }) + } +} diff --git a/core/crates/primitives/src/value_access.rs b/core/crates/primitives/src/value_access.rs new file mode 100644 index 0000000000..01270b546d --- /dev/null +++ b/core/crates/primitives/src/value_access.rs @@ -0,0 +1,60 @@ +use serde::de::DeserializeOwned; +use serde_json::Value; + +pub trait JsonDecode { + fn decode(&self) -> Option; +} + +impl JsonDecode for Option { + fn decode(&self) -> Option { + serde_json::from_value(self.clone()?).ok() + } +} + +pub trait ValueAccess { + fn get_value(&self, key: &str) -> Result<&Value, String>; + fn get_string(&self, key: &str) -> Result<&str, String>; + fn get_i64(&self, key: &str) -> Result; + fn at(&self, index: usize) -> Result<&Value, String>; + fn string(&self) -> Result<&str, String>; +} + +impl ValueAccess for Value { + fn get_value(&self, key: &str) -> Result<&Value, String> { + self.get(key).ok_or_else(|| format!("Missing {} parameter", key)) + } + + fn get_string(&self, key: &str) -> Result<&str, String> { + self.get_value(key)?.string() + } + + fn get_i64(&self, key: &str) -> Result { + self.get_value(key)?.as_i64().ok_or_else(|| format!("Expected integer value for {}", key)) + } + + fn at(&self, index: usize) -> Result<&Value, String> { + self.as_array() + .and_then(|array| array.get(index)) + .ok_or_else(|| format!("Missing parameter at index {}", index)) + } + + fn string(&self) -> Result<&str, String> { + self.as_str().ok_or_else(|| "Expected string value".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::ValueAccess; + + #[test] + fn test_keyed_accessors() { + let value = serde_json::json!({ + "name": "USDT", + "points": 650, + }); + + assert_eq!(value.get_string("name").unwrap(), "USDT"); + assert_eq!(value.get_i64("points").unwrap(), 650); + } +} diff --git a/core/crates/primitives/src/verification_status.rs b/core/crates/primitives/src/verification_status.rs new file mode 100644 index 0000000000..d7f78bb40b --- /dev/null +++ b/core/crates/primitives/src/verification_status.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum VerificationStatus { + Verified, + Unverified, + Suspicious, +} + +impl VerificationStatus { + pub fn from_verified(is_verified: bool) -> Self { + if is_verified { Self::Verified } else { Self::Unverified } + } + + pub fn is_verified(self) -> bool { + match self { + Self::Verified => true, + Self::Unverified | Self::Suspicious => false, + } + } +} diff --git a/core/crates/primitives/src/wallet.rs b/core/crates/primitives/src/wallet.rs new file mode 100644 index 0000000000..cb7e1ca590 --- /dev/null +++ b/core/crates/primitives/src/wallet.rs @@ -0,0 +1,31 @@ +use crate::{Account, WalletId, WalletType}; +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, EnumString, AsRefStr, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum WalletSource { + Create, + #[default] + Import, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable, Hashable")] +#[serde(rename_all = "camelCase")] +pub struct Wallet { + pub id: WalletId, + pub external_id: Option, + pub name: String, + pub index: i32, + #[serde(rename = "type")] + pub wallet_type: WalletType, + pub accounts: Vec, + pub order: i32, + pub is_pinned: bool, + pub image_url: Option, + pub source: WalletSource, +} diff --git a/core/crates/primitives/src/wallet_configuration.rs b/core/crates/primitives/src/wallet_configuration.rs new file mode 100644 index 0000000000..1358e2987f --- /dev/null +++ b/core/crates/primitives/src/wallet_configuration.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{ChainAddress, WalletId}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WalletConfiguration { + pub multi_signature_accounts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WalletConfigurationResult { + pub wallet_id: WalletId, + pub configuration: WalletConfiguration, +} diff --git a/core/crates/primitives/src/wallet_connect.rs b/core/crates/primitives/src/wallet_connect.rs new file mode 100644 index 0000000000..bb0929092f --- /dev/null +++ b/core/crates/primitives/src/wallet_connect.rs @@ -0,0 +1,118 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; +use url::Url; + +const WALLET_CONNECT_SCHEME: &str = "wc"; +const WALLET_CONNECT_HOST: &str = "wc"; +const GEM_SCHEME: &str = "gem"; + +const QUERY_URI: &str = "uri"; +const QUERY_SESSION_TOPIC: &str = "sessionTopic"; +const QUERY_REQUEST_ID: &str = "requestId"; + +#[derive(Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WCEthereumTransaction { + pub chain_id: Option, + pub from: String, + pub to: String, + pub value: Option, + pub gas: Option, + pub gas_limit: Option, + pub gas_price: Option, + pub max_fee_per_gas: Option, + pub max_priority_fee_per_gas: Option, + pub nonce: Option, + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WCTonMessage { + pub address: String, + pub amount: String, + pub payload: Option, + pub state_init: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WalletConnectRequest { + pub topic: String, + pub method: String, + pub params: String, + pub chain_id: Option, + pub domain: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum WalletConnectLink { + Connect { uri: String }, + Request, + Session { topic: String }, +} + +impl WalletConnectLink { + pub fn from_url(url: &str) -> Option { + let parsed = Url::parse(url).ok()?; + match parsed.scheme() { + WALLET_CONNECT_SCHEME => Some(Self::session_or_request(&parsed).unwrap_or_else(|| WalletConnectLink::Connect { uri: url.to_string() })), + GEM_SCHEME if parsed.host_str() == Some(WALLET_CONNECT_HOST) => match query_value(&parsed, QUERY_URI).filter(|uri| !uri.is_empty()) { + Some(uri) => Some(WalletConnectLink::Connect { uri }), + None => Self::session_or_request(&parsed), + }, + _ => None, + } + } + + fn session_or_request(url: &Url) -> Option { + if let Some(topic) = query_value(url, QUERY_SESSION_TOPIC).filter(|topic| !topic.is_empty()) { + Some(WalletConnectLink::Session { topic }) + } else if query_value(url, QUERY_REQUEST_ID).is_some() { + Some(WalletConnectLink::Request) + } else { + None + } + } +} + +fn query_value(url: &Url, key: &str) -> Option { + url.query_pairs().find(|(query_key, _)| query_key.as_ref() == key).map(|(_, value)| value.into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wallet_connect_link_from_url() { + assert_eq!( + WalletConnectLink::from_url("wc:abc@2?relay-protocol=irn&symKey=123"), + Some(WalletConnectLink::Connect { + uri: "wc:abc@2?relay-protocol=irn&symKey=123".to_string(), + }) + ); + assert_eq!(WalletConnectLink::from_url("wc:abc@2?requestId"), Some(WalletConnectLink::Request)); + assert_eq!(WalletConnectLink::from_url("wc:abc@2?requestId=123"), Some(WalletConnectLink::Request)); + assert_eq!( + WalletConnectLink::from_url("gem://wc?uri=wc:topic@2"), + Some(WalletConnectLink::Connect { uri: "wc:topic@2".to_string() }) + ); + assert_eq!( + WalletConnectLink::from_url("gem://wc?uri=wc%3Atopic%402%3Frelay-protocol%3Dirn%26symKey%3Dabc"), + Some(WalletConnectLink::Connect { + uri: "wc:topic@2?relay-protocol=irn&symKey=abc".to_string(), + }) + ); + assert_eq!(WalletConnectLink::from_url("gem://wc?requestId=1"), Some(WalletConnectLink::Request)); + assert_eq!( + WalletConnectLink::from_url("gem://wc?sessionTopic=abc123"), + Some(WalletConnectLink::Session { topic: "abc123".to_string() }) + ); + assert_eq!(WalletConnectLink::from_url("gem://wc?sessionTopic="), None); + assert_eq!(WalletConnectLink::from_url("gem://asset/solana"), None); + assert_eq!(WalletConnectLink::from_url("https://gemwallet.com/tokens/bitcoin"), None); + } +} diff --git a/core/crates/primitives/src/wallet_connect_namespace.rs b/core/crates/primitives/src/wallet_connect_namespace.rs new file mode 100644 index 0000000000..602af645c3 --- /dev/null +++ b/core/crates/primitives/src/wallet_connect_namespace.rs @@ -0,0 +1,137 @@ +use crate::{Chain, ChainType}; +use serde::Serialize; +use std::str::FromStr; +use strum::{AsRefStr, EnumString}; + +#[derive(Debug, Serialize, AsRefStr, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum WalletConnectCAIP2 { + Eip155, + Solana, + Cosmos, + Algorand, + Sui, + Ton, + Tron, +} + +impl WalletConnectCAIP2 { + pub fn get_namespace(chain: Chain) -> Option { + match chain.chain_type() { + ChainType::Ethereum => Some(WalletConnectCAIP2::Eip155.as_ref().to_string()), + ChainType::Solana => Some(WalletConnectCAIP2::Solana.as_ref().to_string()), + ChainType::Cosmos => Some(format!("{}:{}", WalletConnectCAIP2::Cosmos.as_ref(), chain.network_id())), + ChainType::Algorand => Some(WalletConnectCAIP2::Algorand.as_ref().to_string()), + ChainType::Sui => Some(WalletConnectCAIP2::Sui.as_ref().to_string()), + ChainType::Ton => Some(WalletConnectCAIP2::Ton.as_ref().to_string()), + ChainType::Tron => Some(WalletConnectCAIP2::Tron.as_ref().to_string()), + ChainType::Bitcoin | ChainType::Aptos | ChainType::Xrp | ChainType::Near | ChainType::Stellar | ChainType::Polkadot | ChainType::Cardano | ChainType::HyperCore => None, + } + } + + pub fn get_chain_type(namespace: String) -> Option { + match WalletConnectCAIP2::from_str(&namespace).ok()? { + WalletConnectCAIP2::Eip155 => Some(ChainType::Ethereum), + WalletConnectCAIP2::Solana => Some(ChainType::Solana), + WalletConnectCAIP2::Cosmos => Some(ChainType::Cosmos), + WalletConnectCAIP2::Algorand => Some(ChainType::Algorand), + WalletConnectCAIP2::Sui => Some(ChainType::Sui), + WalletConnectCAIP2::Ton => Some(ChainType::Ton), + WalletConnectCAIP2::Tron => Some(ChainType::Tron), + } + } + + pub fn get_chain(namespace: String, reference: String) -> Option { + let namespace = WalletConnectCAIP2::from_str(&namespace).ok()?; + match namespace { + WalletConnectCAIP2::Eip155 | WalletConnectCAIP2::Cosmos => { + let chain_type = Self::get_chain_type(namespace.as_ref().to_string())?; + Chain::all() + .into_iter() + .filter(|chain| chain.chain_type() == chain_type && chain.network_id() == reference) + .collect::>() + .first() + .cloned() + } + WalletConnectCAIP2::Solana => Some(Chain::Solana), + WalletConnectCAIP2::Algorand => Some(Chain::Algorand), + WalletConnectCAIP2::Sui => Some(Chain::Sui), + WalletConnectCAIP2::Ton => Some(Chain::Ton), + WalletConnectCAIP2::Tron => Some(Chain::Tron), + } + } + + pub fn get_reference(chain: Chain) -> Option { + match chain.chain_type() { + ChainType::Ethereum => Some(chain.network_id().to_string()), + ChainType::Solana => Some(chain.network_id().chars().take(32).collect()), + ChainType::Cosmos => Self::get_namespace(chain).map(|namespace| format!("{}:{}", namespace, chain.network_id())), + ChainType::Algorand => Some("wGHE2Pwdvd7S12BL5FaOP20EGYesN73k".to_string()), + ChainType::Sui => Some("mainnet".to_string()), + ChainType::Ton => Some("-239".to_string()), + ChainType::Tron => Some(chain.network_id().to_string()), + ChainType::Bitcoin | ChainType::Aptos | ChainType::Xrp | ChainType::Near | ChainType::Stellar | ChainType::Polkadot | ChainType::Cardano | ChainType::HyperCore => None, + } + } + + pub fn resolve_chain(chain_id: Option) -> Result { + let chain_id = chain_id.ok_or("Chain ID is required")?; + let parts: Vec<&str> = chain_id.split(':').collect(); + + if parts.len() != 2 { + return Err("Invalid chain ID format".to_string()); + } + + let namespace = parts[0].to_string(); + let reference = parts[1].to_string(); + + Self::get_chain(namespace, reference).ok_or("Unsupported chain".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_chain_type() { + assert_eq!(WalletConnectCAIP2::get_chain_type("eip155".to_string()), Some(ChainType::Ethereum)); + assert_eq!(WalletConnectCAIP2::get_chain_type("solana".to_string()), Some(ChainType::Solana)); + assert_eq!(WalletConnectCAIP2::get_chain_type("cosmos".to_string()), Some(ChainType::Cosmos)); + assert_eq!(WalletConnectCAIP2::get_chain_type("algorand".to_string()), Some(ChainType::Algorand)); + assert_eq!(WalletConnectCAIP2::get_chain_type("sui".to_string()), Some(ChainType::Sui)); + assert_eq!(WalletConnectCAIP2::get_chain_type("ton".to_string()), Some(ChainType::Ton)); + assert_eq!(WalletConnectCAIP2::get_chain_type("tron".to_string()), Some(ChainType::Tron)); + assert_eq!(WalletConnectCAIP2::get_chain_type("bip122".to_string()), None); + assert_eq!(WalletConnectCAIP2::get_chain_type("unknown".to_string()), None); + } + + #[test] + fn test_get_chain() { + assert_eq!(WalletConnectCAIP2::get_chain("eip155".to_string(), "1".to_string()), Some(Chain::Ethereum)); + assert_eq!(WalletConnectCAIP2::get_chain("eip155".to_string(), "56".to_string()), Some(Chain::SmartChain)); + assert_eq!(WalletConnectCAIP2::get_chain("solana".to_string(), "ignored".to_string()), Some(Chain::Solana)); + assert_eq!(WalletConnectCAIP2::get_chain("sui".to_string(), "mainnet".to_string()), Some(Chain::Sui)); + assert_eq!(WalletConnectCAIP2::get_chain("ton".to_string(), "-239".to_string()), Some(Chain::Ton)); + assert_eq!(WalletConnectCAIP2::get_chain("tron".to_string(), "0x2b6653dc".to_string()), Some(Chain::Tron)); + assert_eq!(WalletConnectCAIP2::get_chain("bip122".to_string(), "000000000019d6689c085ae165831e93".to_string()), None); + } + + #[test] + fn test_resolve_chain() { + assert_eq!(WalletConnectCAIP2::resolve_chain(Some("eip155:1".to_string())), Ok(Chain::Ethereum)); + assert_eq!( + WalletConnectCAIP2::resolve_chain(Some("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".to_string())), + Ok(Chain::Solana) + ); + assert_eq!(WalletConnectCAIP2::resolve_chain(Some("sui:mainnet".to_string())), Ok(Chain::Sui)); + assert_eq!(WalletConnectCAIP2::resolve_chain(Some("ton:-239".to_string())), Ok(Chain::Ton)); + assert_eq!(WalletConnectCAIP2::resolve_chain(Some("tron:0x2b6653dc".to_string())), Ok(Chain::Tron)); + assert!(WalletConnectCAIP2::resolve_chain(Some("bip122:000000000019d6689c085ae165831e93".to_string())).is_err()); + assert!(WalletConnectCAIP2::resolve_chain(Some("invalid".to_string())).is_err()); + assert!(WalletConnectCAIP2::resolve_chain(Some("eip155:1:extra".to_string())).is_err()); + assert!(WalletConnectCAIP2::resolve_chain(None).is_err()); + assert!(WalletConnectCAIP2::resolve_chain(Some("unknown:chain".to_string())).is_err()); + } +} diff --git a/core/crates/primitives/src/wallet_connector.rs b/core/crates/primitives/src/wallet_connector.rs new file mode 100644 index 0000000000..2c22ddb681 --- /dev/null +++ b/core/crates/primitives/src/wallet_connector.rs @@ -0,0 +1,148 @@ +use crate::{Chain, Wallet}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +pub struct WalletConnection { + pub session: WalletConnectionSession, + pub wallet: Wallet, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum WalletConnectionState { + Started, + Active, + Expired, +} + +#[derive(Debug, Serialize, Deserialize)] +#[typeshare(swift = "CaseIterable, Sendable")] +pub enum WalletConnectionMethods { + #[serde(rename = "eth_chainId")] + EthChainId, + #[serde(rename = "personal_sign")] + PersonalSign, + #[serde(rename = "eth_signTypedData")] + EthSignTypedData, + #[serde(rename = "eth_signTypedData_v4")] + EthSignTypedDataV4, + #[serde(rename = "eth_signTransaction")] + EthSignTransaction, + #[serde(rename = "eth_sendTransaction")] + EthSendTransaction, + #[serde(rename = "eth_sendRawTransaction")] + EthSendRawTransaction, + #[serde(rename = "wallet_switchEthereumChain")] + WalletSwitchEthereumChain, + #[serde(rename = "wallet_addEthereumChain")] + WalletAddEthereumChain, + #[serde(rename = "solana_signMessage")] + SolanaSignMessage, + #[serde(rename = "solana_signTransaction")] + SolanaSignTransaction, + #[serde(rename = "solana_signAndSendTransaction")] + SolanaSignAndSendTransaction, + #[serde(rename = "solana_signAllTransactions")] + SolanaSignAllTransactions, + #[serde(rename = "sui_signPersonalMessage")] + SuiSignPersonalMessage, + #[serde(rename = "sui_signTransaction")] + SuiSignTransaction, + #[serde(rename = "sui_signAndExecuteTransaction")] + SuiSignAndExecuteTransaction, + #[serde(rename = "ton_sendMessage")] + TonSendMessage, + #[serde(rename = "ton_signData")] + TonSignData, + #[serde(rename = "tron_signMessage")] + TronSignMessage, + #[serde(rename = "tron_signTransaction")] + TronSignTransaction, + #[serde(rename = "tron_sendTransaction")] + TronSendTransaction, +} + +#[derive(Debug, Serialize)] +#[typeshare(swift = "CaseIterable, Sendable")] +pub enum WalletConnectionEvents { + #[serde(rename = "connect")] + Connect, + #[serde(rename = "disconnect")] + Disconnect, + #[serde(rename = "accountsChanged")] + AccountsChanged, + #[serde(rename = "chainChanged")] + ChainChanged, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WalletConnectionSession { + pub id: String, + pub session_id: String, + pub state: WalletConnectionState, + pub chains: Vec, + pub created_at: DateTime, + pub expire_at: DateTime, + pub metadata: WalletConnectionSessionAppMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WalletConnectionSessionAppMetadata { + pub name: String, + pub description: String, + pub url: String, + pub icon: String, +} + +const SHORT_NAME_SEPARATORS: [char; 3] = ['-', ':', '|']; +const SHORT_NAME_MAX_LENGTH: usize = 80; + +impl WalletConnectionSessionAppMetadata { + pub fn short_name(&self) -> String { + let name = self.name.trim(); + for sep in SHORT_NAME_SEPARATORS { + if let Some(idx) = name.find(sep) { + return name[..idx].trim().to_string(); + } + } + if name.len() > SHORT_NAME_MAX_LENGTH { + return name[..SHORT_NAME_MAX_LENGTH].to_string(); + } + name.to_string() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WalletConnectionSessionProposal { + pub default_wallet: Wallet, + pub wallets: Vec, + pub metadata: WalletConnectionSessionAppMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Hashable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum WalletConnectionVerificationStatus { + Verified, + Unknown, + Invalid, + Malicious, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +#[serde(rename_all = "camelCase")] +pub struct WCPairingProposal { + pub pairing_id: String, + pub proposal: WalletConnectionSessionProposal, + pub verification_status: WalletConnectionVerificationStatus, +} diff --git a/core/crates/primitives/src/wallet_id.rs b/core/crates/primitives/src/wallet_id.rs new file mode 100644 index 0000000000..52072ce7d0 --- /dev/null +++ b/core/crates/primitives/src/wallet_id.rs @@ -0,0 +1,135 @@ +use std::fmt; +use std::str::FromStr; + +use crate::CHAIN_SEPARATOR; +use crate::chain::Chain; +use crate::wallet_type::WalletType; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum WalletId { + Multicoin(String), + Single(Chain, String), + PrivateKey(Chain, String), + View(Chain, String), +} + +crate::impl_string_serde!(WalletId); + +impl WalletId { + pub fn id(&self) -> String { + self.to_string() + } + + pub fn from_id(id: &str) -> Option { + id.parse().ok() + } + + pub fn wallet_type(&self) -> WalletType { + match self { + WalletId::Multicoin(_) => WalletType::Multicoin, + WalletId::Single(_, _) => WalletType::Single, + WalletId::PrivateKey(_, _) => WalletType::PrivateKey, + WalletId::View(_, _) => WalletType::View, + } + } + + pub fn address(&self) -> &str { + match self { + WalletId::Multicoin(address) | WalletId::Single(_, address) | WalletId::PrivateKey(_, address) | WalletId::View(_, address) => address, + } + } + + pub fn chain(&self) -> Option { + match self { + WalletId::Multicoin(_) => None, + WalletId::Single(chain, _) | WalletId::PrivateKey(chain, _) | WalletId::View(chain, _) => Some(*chain), + } + } +} + +impl fmt::Display for WalletId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WalletId::Multicoin(address) => write!(f, "{}{CHAIN_SEPARATOR}{}", WalletType::Multicoin.as_ref(), address), + WalletId::Single(chain, address) | WalletId::PrivateKey(chain, address) | WalletId::View(chain, address) => { + write!(f, "{}{CHAIN_SEPARATOR}{}{CHAIN_SEPARATOR}{}", self.wallet_type().as_ref(), chain.as_ref(), address) + } + } + } +} + +impl FromStr for WalletId { + type Err = String; + + fn from_str(s: &str) -> Result { + let (wallet_type_str, rest) = s + .split_once(CHAIN_SEPARATOR) + .ok_or_else(|| format!("invalid wallet identifier format: expected at least 2 parts separated by '{CHAIN_SEPARATOR}', got: {s}"))?; + let wallet_type: WalletType = wallet_type_str.parse().map_err(|_| format!("invalid wallet type: {wallet_type_str}"))?; + + match wallet_type { + WalletType::Multicoin => Ok(WalletId::Multicoin(rest.to_string())), + WalletType::Single | WalletType::PrivateKey | WalletType::View => { + let (chain_str, address) = rest + .split_once(CHAIN_SEPARATOR) + .ok_or_else(|| format!("invalid wallet identifier format for {}: expected 3 parts, got: {s}", wallet_type.as_ref()))?; + let chain: Chain = chain_str.parse().map_err(|_| format!("invalid chain: {chain_str}"))?; + let address = address.to_string(); + match wallet_type { + WalletType::Single => Ok(WalletId::Single(chain, address)), + WalletType::PrivateKey => Ok(WalletId::PrivateKey(chain, address)), + WalletType::View => Ok(WalletId::View(chain, address)), + _ => unreachable!(), + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wallet_identifier_id() { + assert_eq!(WalletId::Multicoin("0x123".to_string()).id(), "multicoin_0x123"); + assert_eq!(WalletId::Single(Chain::Ethereum, "0x456".to_string()).id(), "single_ethereum_0x456"); + assert_eq!(WalletId::PrivateKey(Chain::Bitcoin, "bc1".to_string()).id(), "privateKey_bitcoin_bc1"); + assert_eq!(WalletId::View(Chain::Ethereum, "0x789".to_string()).id(), "view_ethereum_0x789"); + } + + #[test] + fn test_wallet_identifier_from_id() { + assert!(matches!(WalletId::from_id("multicoin_0x123"), Some(WalletId::Multicoin(addr)) if addr == "0x123")); + assert!(matches!(WalletId::from_id("single_ethereum_0x456"), Some(WalletId::Single(Chain::Ethereum, addr)) if addr == "0x456")); + assert!(matches!(WalletId::from_id("privateKey_bitcoin_bc1"), Some(WalletId::PrivateKey(Chain::Bitcoin, addr)) if addr == "bc1")); + assert!(matches!(WalletId::from_id("view_ethereum_0x789"), Some(WalletId::View(Chain::Ethereum, addr)) if addr == "0x789")); + assert!(WalletId::from_id("invalid").is_none()); + } + + #[test] + fn test_wallet_identifier_wallet_type() { + assert_eq!(WalletId::Multicoin("0x123".to_string()).wallet_type(), WalletType::Multicoin); + assert_eq!(WalletId::Single(Chain::Ethereum, "0x456".to_string()).wallet_type(), WalletType::Single); + assert_eq!(WalletId::PrivateKey(Chain::Bitcoin, "bc1".to_string()).wallet_type(), WalletType::PrivateKey); + assert_eq!(WalletId::View(Chain::Ethereum, "0x789".to_string()).wallet_type(), WalletType::View); + } + + #[test] + fn test_wallet_identifier_chain() { + assert_eq!(WalletId::Multicoin("0x123".to_string()).chain(), None); + assert_eq!(WalletId::Single(Chain::Ethereum, "0x456".to_string()).chain(), Some(Chain::Ethereum)); + assert_eq!(WalletId::PrivateKey(Chain::Bitcoin, "bc1".to_string()).chain(), Some(Chain::Bitcoin)); + assert_eq!(WalletId::View(Chain::Solana, "sol123".to_string()).chain(), Some(Chain::Solana)); + } + + #[test] + fn test_wallet_identifier_serde() { + let wallet_id = WalletId::Multicoin("0x8f348F300873Fd5DA36950B2aC75a26584584feE".to_string()); + let json = serde_json::to_string(&wallet_id).unwrap(); + assert_eq!(json, "\"multicoin_0x8f348F300873Fd5DA36950B2aC75a26584584feE\""); + + let parsed: WalletId = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.id(), wallet_id.id()); + } +} diff --git a/core/crates/primitives/src/wallet_type.rs b/core/crates/primitives/src/wallet_type.rs new file mode 100644 index 0000000000..aac255f619 --- /dev/null +++ b/core/crates/primitives/src/wallet_type.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; +use typeshare::typeshare; + +#[derive(Debug, Clone, Serialize, Deserialize, EnumString, AsRefStr, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum WalletType { + Multicoin, + Single, + PrivateKey, + View, +} + +impl WalletType { + pub fn notification_priority(&self) -> u8 { + match self { + WalletType::Multicoin => 0, + WalletType::Single => 1, + WalletType::PrivateKey => 2, + WalletType::View => 3, + } + } +} diff --git a/core/crates/primitives/src/webhook_kind.rs b/core/crates/primitives/src/webhook_kind.rs new file mode 100644 index 0000000000..c8d256f0b2 --- /dev/null +++ b/core/crates/primitives/src/webhook_kind.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, EnumString}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, AsRefStr, EnumString, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum WebhookKind { + Transactions, + Support, + Fiat, +} diff --git a/core/crates/primitives/src/websocket.rs b/core/crates/primitives/src/websocket.rs new file mode 100644 index 0000000000..9eb4f1d26b --- /dev/null +++ b/core/crates/primitives/src/websocket.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{AssetId, AssetPrice, FiatRate}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +#[typeshare(swift = "Sendable")] +pub enum WebSocketPriceActionType { + Subscribe, + Add, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +pub struct WebSocketPriceAction { + pub action: WebSocketPriceActionType, + #[serde(default)] + pub assets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable")] +pub struct WebSocketPricePayload { + pub prices: Vec, + pub rates: Vec, +} diff --git a/core/crates/primitives/src/yield_provider.rs b/core/crates/primitives/src/yield_provider.rs new file mode 100644 index 0000000000..a414dd5247 --- /dev/null +++ b/core/crates/primitives/src/yield_provider.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, Display, EnumString}; +use typeshare::typeshare; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, Display, AsRefStr, EnumString, PartialEq, Eq)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum YieldProvider { + Yo, +} + +impl YieldProvider { + pub fn name(&self) -> &str { + match self { + Self::Yo => "Yo", + } + } +} diff --git a/core/crates/search_index/Cargo.toml b/core/crates/search_index/Cargo.toml new file mode 100644 index 0000000000..75cae5700c --- /dev/null +++ b/core/crates/search_index/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "search_index" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +serde = { workspace = true } + +meilisearch-sdk = { version = "0.33.0" } + +primitives = { path = "../primitives" } diff --git a/core/crates/search_index/src/lib.rs b/core/crates/search_index/src/lib.rs new file mode 100644 index 0000000000..e84827143b --- /dev/null +++ b/core/crates/search_index/src/lib.rs @@ -0,0 +1,100 @@ +pub mod models; +pub use models::*; + +use serde::{Serialize, de::DeserializeOwned}; +use std::error::Error; + +use meilisearch_sdk::{client::*, task_info::TaskInfo}; + +#[derive(Debug, Clone)] +pub struct SearchIndexClient { + client: Client, +} + +impl SearchIndexClient { + pub fn new(url: &str, api_key: &str) -> Self { + let client = Client::new(url.to_string(), Some(api_key)).unwrap(); + Self { client } + } + + pub async fn get_or_create_index(&self, name: &str, primary_key: &str) -> Result<(), Box> { + if self.client.get_index(name).await.is_err() { + self.client.create_index(name, Some(primary_key)).await?; + } + Ok(()) + } + + pub async fn add_documents(&self, index: &str, documents: Vec) -> Result> { + Ok(self.client.index(index).add_documents(&documents, None).await?) + } + + pub async fn delete_all_documents(&self, index: &str) -> Result> { + Ok(self.client.index(index).delete_all_documents().await?) + } + + pub async fn replace_documents(&self, index: &str, documents: Vec) -> Result> { + self.delete_all_documents(index).await?; + self.index_documents(index, documents).await + } + + pub async fn index_documents(&self, index: &str, documents: Vec) -> Result> { + let count = documents.len(); + if count > 0 { + self.add_documents(index, documents).await?; + } + Ok(count) + } + + pub async fn set_filterable_attributes(&self, index: &str, attributes: Vec<&str>) -> Result> { + Ok(self.client.index(index).set_filterable_attributes(attributes).await?) + } + + pub async fn set_sortable_attributes(&self, index: &str, attributes: Vec<&str>) -> Result> { + Ok(self.client.index(index).set_sortable_attributes(attributes).await?) + } + + pub async fn set_searchable_attributes(&self, index: &str, attributes: Vec<&str>) -> Result> { + Ok(self.client.index(index).set_searchable_attributes(attributes).await?) + } + + pub async fn set_ranking_rules(&self, index: &str, attributes: Vec<&str>) -> Result> { + Ok(self.client.index(index).set_ranking_rules(attributes).await?) + } + + pub async fn setup(&self, configs: &[crate::IndexConfig], primary_key: &str) -> Result<(), Box> { + for config in configs { + self.get_or_create_index(config.name, primary_key).await?; + self.set_filterable_attributes(config.name, config.filters.to_vec()).await?; + self.set_sortable_attributes(config.name, config.sorts.to_vec()).await?; + self.set_searchable_attributes(config.name, config.search_attributes.to_vec()).await?; + self.set_ranking_rules(config.name, config.ranking_rules.to_vec()).await?; + } + Ok(()) + } + + // search + + pub async fn search( + &self, + index: &str, + query: &str, + filter: &str, + sort: &[&str], + limit: usize, + offset: usize, + ) -> Result, Box> { + let results = self + .client + .index(index) + .search() + .with_query(query) + .with_filter(filter) + .with_sort(sort) + .with_limit(limit) + .with_offset(offset) + .execute::() + .await?; + + Ok(results.hits.into_iter().map(|x| x.result).collect()) + } +} diff --git a/core/crates/search_index/src/models/asset.rs b/core/crates/search_index/src/models/asset.rs new file mode 100644 index 0000000000..2e1afd8b54 --- /dev/null +++ b/core/crates/search_index/src/models/asset.rs @@ -0,0 +1,56 @@ +use primitives::{Asset, AssetMarket, AssetProperties, AssetScore}; +use serde::{Deserialize, Serialize}; + +pub const ASSETS_INDEX_NAME: &str = "assets"; +pub const ASSETS_FILTERS: &[&str] = &[ + "asset.chain", + "asset.tokenId", + "asset.name", + "asset.symbol", + "asset.type", + "score.rank", + "properties.isEnabled", + "properties.hasImage", + "properties.isBuyable", + "properties.isSellable", + "properties.isSwapable", + "properties.isStakeable", + "market.marketCap", + "market.marketCapFdv", + "market.marketCapRank", + "market.totalVolume", + "tags", +]; +pub const ASSETS_SEARCH_ATTRIBUTES: &[&str] = &["asset.tokenId", "asset.chain", "asset.name", "asset.symbol", "asset.type"]; +pub const ASSETS_RANKING_RULES: &[&str] = &[ + "words", + "typo", + "score.rank:desc", + "properties.hasImage:desc", + "properties.isBuyable:desc", + "properties.isSellable:desc", + "properties.isSwapable:desc", + "properties.isStakeable:desc", + "usageRank:desc", + "market.marketCapFdv:desc", + "proximity", + "market.marketCapRank:asc", + "market.marketCap:desc", + "market.totalVolume:desc", + "attribute", + "exactness", +]; + +pub const ASSETS_SORTS: &[&str] = &["score.rank"]; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AssetDocument { + pub id: String, + pub asset: Asset, + pub properties: AssetProperties, + pub score: AssetScore, + pub usage_rank: i32, + pub market: Option, + pub tags: Option>, +} diff --git a/core/crates/search_index/src/models/mod.rs b/core/crates/search_index/src/models/mod.rs new file mode 100644 index 0000000000..778812ac45 --- /dev/null +++ b/core/crates/search_index/src/models/mod.rs @@ -0,0 +1,45 @@ +mod asset; +mod nft; +mod perpetual; + +pub use asset::*; +pub use nft::*; +pub use perpetual::*; + +pub const INDEX_PRIMARY_KEY: &str = "id"; + +pub struct IndexConfig { + pub name: &'static str, + pub filters: &'static [&'static str], + pub sorts: &'static [&'static str], + pub search_attributes: &'static [&'static str], + pub ranking_rules: &'static [&'static str], +} + +pub const INDEX_CONFIGS: &[IndexConfig] = &[ + IndexConfig { + name: ASSETS_INDEX_NAME, + filters: ASSETS_FILTERS, + sorts: ASSETS_SORTS, + search_attributes: ASSETS_SEARCH_ATTRIBUTES, + ranking_rules: ASSETS_RANKING_RULES, + }, + IndexConfig { + name: PERPETUALS_INDEX_NAME, + filters: PERPETUALS_FILTERS, + sorts: PERPETUALS_SORTS, + search_attributes: PERPETUALS_SEARCH_ATTRIBUTES, + ranking_rules: PERPETUALS_RANKING_RULES, + }, + IndexConfig { + name: NFTS_INDEX_NAME, + filters: NFTS_FILTERS, + sorts: NFTS_SORTS, + search_attributes: NFTS_SEARCH_ATTRIBUTES, + ranking_rules: NFTS_RANKING_RULES, + }, +]; + +pub fn sanitize_index_primary_id(input: &str) -> String { + input.chars().filter(|c| c.is_ascii_alphanumeric()).collect() +} diff --git a/core/crates/search_index/src/models/nft.rs b/core/crates/search_index/src/models/nft.rs new file mode 100644 index 0000000000..f9ebc1a535 --- /dev/null +++ b/core/crates/search_index/src/models/nft.rs @@ -0,0 +1,32 @@ +use primitives::NFTCollection; +use serde::{Deserialize, Serialize}; + +use crate::sanitize_index_primary_id; + +pub const NFTS_INDEX_NAME: &str = "nfts"; +pub const NFTS_FILTERS: &[&str] = &["collection.chain", "collection.name", "collection.contractAddress", "collection.isVerified"]; +pub const NFTS_SEARCH_ATTRIBUTES: &[&str] = &["collection.name", "collection.contractAddress", "collection.chain"]; +pub const NFTS_RANKING_RULES: &[&str] = &["words", "typo", "proximity", "attribute", "exactness"]; + +pub const NFTS_SORTS: &[&str] = &[]; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NFTDocument { + pub id: String, + pub collection: NFTCollection, +} + +impl NFTDocument { + pub fn new(collection: NFTCollection) -> Self { + Self { + id: sanitize_index_primary_id(&collection.id.to_string()), + collection, + } + } +} + +impl From for NFTDocument { + fn from(collection: NFTCollection) -> Self { + Self::new(collection) + } +} diff --git a/core/crates/search_index/src/models/perpetual.rs b/core/crates/search_index/src/models/perpetual.rs new file mode 100644 index 0000000000..562e86afe5 --- /dev/null +++ b/core/crates/search_index/src/models/perpetual.rs @@ -0,0 +1,43 @@ +use primitives::{Asset, Perpetual, PerpetualSearchData}; +use serde::{Deserialize, Serialize}; + +use crate::sanitize_index_primary_id; + +pub const PERPETUALS_INDEX_NAME: &str = "perpetuals"; +pub const PERPETUALS_FILTERS: &[&str] = &["perpetual.name", "perpetual.identifier", "perpetual.provider", "perpetual.price", "perpetual.volume24h"]; +pub const PERPETUALS_SEARCH_ATTRIBUTES: &[&str] = &["perpetual.name", "perpetual.identifier", "perpetual.provider"]; +pub const PERPETUALS_RANKING_RULES: &[&str] = &["words", "typo", "perpetual.volume24h:desc", "proximity", "attribute", "exactness"]; + +pub const PERPETUALS_SORTS: &[&str] = &["perpetual.volume24h"]; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PerpetualDocument { + pub id: String, + pub perpetual: Perpetual, + pub asset: Asset, +} + +impl PerpetualDocument { + pub fn new(perpetual: Perpetual, asset: Asset) -> Self { + Self { + id: sanitize_index_primary_id(&perpetual.id.to_string()), + perpetual, + asset, + } + } +} + +impl From<(Perpetual, Asset)> for PerpetualDocument { + fn from((perpetual, asset): (Perpetual, Asset)) -> Self { + Self::new(perpetual, asset) + } +} + +impl From for PerpetualSearchData { + fn from(doc: PerpetualDocument) -> Self { + Self { + perpetual: doc.perpetual, + asset: doc.asset, + } + } +} diff --git a/core/crates/security_provider/Cargo.toml b/core/crates/security_provider/Cargo.toml new file mode 100644 index 0000000000..423aa33df2 --- /dev/null +++ b/core/crates/security_provider/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "security_provider" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true } +primitives = { path = "../primitives" } +reqwest = { workspace = true } +uuid = { workspace = true } +serde_json = { workspace = true } +hex = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +gem_client = { path = "../gem_client", features = ["reqwest"] } + +[dev-dependencies] +serde_json = { workspace = true } +tokio = { workspace = true } +settings = { path = "../settings" } + +[[test]] +name = "integration_test" +test = false diff --git a/core/crates/security_provider/src/lib.rs b/core/crates/security_provider/src/lib.rs new file mode 100644 index 0000000000..49a20efc2a --- /dev/null +++ b/core/crates/security_provider/src/lib.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; +use std::result::Result; + +pub mod mapper; +pub mod model; +pub mod providers; + +pub use model::{AddressTarget, ScanResult, TokenTarget}; + +#[async_trait] +pub trait ScanProvider: Send + Sync { + fn name(&self) -> &'static str; + async fn scan_address(&self, target: &AddressTarget) -> Result, Box>; + async fn scan_token(&self, target: &TokenTarget) -> Result, Box>; +} diff --git a/core/crates/security_provider/src/mapper.rs b/core/crates/security_provider/src/mapper.rs new file mode 100644 index 0000000000..ecc68646e3 --- /dev/null +++ b/core/crates/security_provider/src/mapper.rs @@ -0,0 +1,41 @@ +use primitives::Chain; + +pub fn chain_to_provider_id(chain: Chain) -> String { + match chain { + Chain::Ethereum => "1".to_string(), + Chain::SmartChain => "56".to_string(), + Chain::Polygon => "137".to_string(), + Chain::Arbitrum => "42161".to_string(), + Chain::Optimism => "10".to_string(), + Chain::Base => "8453".to_string(), + Chain::AvalancheC => "43114".to_string(), + Chain::OpBNB => "204".to_string(), + Chain::Fantom => "250".to_string(), + Chain::Gnosis => "100".to_string(), + Chain::Blast => "81457".to_string(), + Chain::ZkSync => "324".to_string(), + Chain::Linea => "59144".to_string(), + Chain::Mantle => "5000".to_string(), + Chain::Celo => "42220".to_string(), + Chain::Manta => "169".to_string(), + Chain::World => "480".to_string(), + // Default to Ethereum for unsupported chains + _ => "1".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chain_to_provider_id() { + assert_eq!(chain_to_provider_id(Chain::Ethereum), "1"); + assert_eq!(chain_to_provider_id(Chain::SmartChain), "56"); + assert_eq!(chain_to_provider_id(Chain::Polygon), "137"); + assert_eq!(chain_to_provider_id(Chain::Arbitrum), "42161"); + assert_eq!(chain_to_provider_id(Chain::Optimism), "10"); + assert_eq!(chain_to_provider_id(Chain::Base), "8453"); + assert_eq!(chain_to_provider_id(Chain::Bitcoin), "1"); // Unsupported, defaults to Ethereum + } +} diff --git a/core/crates/security_provider/src/model.rs b/core/crates/security_provider/src/model.rs new file mode 100644 index 0000000000..870f4b09dc --- /dev/null +++ b/core/crates/security_provider/src/model.rs @@ -0,0 +1,57 @@ +use primitives::Chain; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddressTarget { + pub address: String, + pub chain: Chain, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenTarget { + pub token_id: String, + pub chain: Chain, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ScanResult { + pub target: T, + pub is_malicious: bool, + pub reason: Option, + pub provider: String, +} + +#[test] +fn test_token_target_serialization() { + let target = TokenTarget { + token_id: "0xa0b86a33e6776a8e5b01b22e54e12b5e5d0f96f8".to_string(), + chain: Chain::Ethereum, + }; + + let serialized = serde_json::to_string(&target).unwrap(); + let deserialized: TokenTarget = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(target.token_id, deserialized.token_id); + assert_eq!(target.chain, deserialized.chain); +} + +#[test] +fn test_scan_result_token_target() { + let target = TokenTarget { + token_id: "0xa0b86a33e6776a8e5b01b22e54e12b5e5d0f96f8".to_string(), + chain: Chain::Ethereum, + }; + + let result = ScanResult { + target: target.clone(), + is_malicious: true, + reason: Some("Test reason".to_string()), + provider: "test_provider".to_string(), + }; + + assert_eq!(result.target.token_id, target.token_id); + assert_eq!(result.target.chain, target.chain); + assert!(result.is_malicious); + assert_eq!(result.reason, Some("Test reason".to_string())); + assert_eq!(result.provider, "test_provider"); +} diff --git a/core/crates/security_provider/src/providers/goplus/mod.rs b/core/crates/security_provider/src/providers/goplus/mod.rs new file mode 100644 index 0000000000..1ef8bd6111 --- /dev/null +++ b/core/crates/security_provider/src/providers/goplus/mod.rs @@ -0,0 +1,3 @@ +pub mod models; +pub mod provider; +pub use self::provider::GoPlusProvider; diff --git a/core/crates/security_provider/src/providers/goplus/models.rs b/core/crates/security_provider/src/providers/goplus/models.rs new file mode 100644 index 0000000000..f3e56322b4 --- /dev/null +++ b/core/crates/security_provider/src/providers/goplus/models.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub code: i32, + pub message: String, + pub result: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityAddress { + pub cybercrime: String, + pub money_laundering: String, + pub financial_crime: String, + pub blacklist_doubt: String, + pub stealing_attack: String, +} + +impl SecurityAddress { + pub fn is_malicious(&self) -> bool { + self.cybercrime == "1" || self.money_laundering == "1" || self.financial_crime == "1" || self.blacklist_doubt == "1" || self.stealing_attack == "1" + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityToken { + #[serde(default)] + pub is_honeypot: Option, + #[serde(default)] + pub fake_token: Option, + #[serde(default)] + pub is_airdrop_scam: Option, + #[serde(default)] + pub cannot_buy: Option, + #[serde(default)] + pub cannot_sell_all: Option, + #[serde(default)] + pub is_blacklisted: Option, +} + +impl SecurityToken { + pub fn is_malicious(&self) -> bool { + self.is_honeypot.as_deref() == Some("1") + || self.fake_token.as_deref() == Some("1") + || self.is_airdrop_scam.as_deref() == Some("1") + || self.cannot_buy.as_deref() == Some("1") + || self.cannot_sell_all.as_deref() == Some("1") + || self.is_blacklisted.as_deref() == Some("1") + } +} diff --git a/core/crates/security_provider/src/providers/goplus/provider.rs b/core/crates/security_provider/src/providers/goplus/provider.rs new file mode 100644 index 0000000000..d6778c39cd --- /dev/null +++ b/core/crates/security_provider/src/providers/goplus/provider.rs @@ -0,0 +1,67 @@ +use crate::providers::goplus::models::{Response, SecurityAddress, SecurityToken}; +use crate::{AddressTarget, ScanProvider, ScanResult, TokenTarget, mapper}; +use async_trait::async_trait; +use gem_client::{ClientExt, ReqwestClient, build_path_with_query}; +use std::collections::HashMap; +use std::result::Result; + +const PROVIDER_NAME: &str = "GoPlus"; + +pub struct GoPlusProvider { + client: ReqwestClient, +} + +impl GoPlusProvider { + pub fn new(client: ReqwestClient, _api_key: &str) -> Self { + GoPlusProvider { client } + } +} + +#[async_trait] +impl ScanProvider for GoPlusProvider { + fn name(&self) -> &'static str { + PROVIDER_NAME + } + + async fn scan_address(&self, target: &AddressTarget) -> Result, Box> { + let path = format!("/api/v1/address_security/{}", target.address); + let query = vec![("chain_id", mapper::chain_to_provider_id(target.chain))]; + let url = build_path_with_query(&path, &query)?; + let response = self.client.get::>(&url).await?; + + Ok(ScanResult { + target: target.clone(), + is_malicious: response.result.is_malicious(), + reason: None, + provider: self.name().into(), + }) + } + + async fn scan_token(&self, target: &TokenTarget) -> Result, Box> { + let path = format!("/api/v1/token_security/{}", mapper::chain_to_provider_id(target.chain)); + let query = vec![("contract_addresses", target.token_id.as_str())]; + let url = build_path_with_query(&path, &query)?; + let response = self.client.get::>>(&url).await?; + + let security_token = { + let key = target.token_id.to_lowercase(); + response.result.get(&key).cloned().or_else(|| response.result.values().next().cloned()) + }; + + let (is_malicious, reason) = match security_token { + Some(tok) => { + let mal = tok.is_malicious(); + let reason = if mal { Some("Token security risk detected".to_string()) } else { None }; + (mal, reason) + } + None => (false, Some("No token data found".to_string())), + }; + + Ok(ScanResult { + target: target.clone(), + is_malicious, + reason, + provider: self.name().into(), + }) + } +} diff --git a/core/crates/security_provider/src/providers/hashdit/mod.rs b/core/crates/security_provider/src/providers/hashdit/mod.rs new file mode 100644 index 0000000000..1000ac8964 --- /dev/null +++ b/core/crates/security_provider/src/providers/hashdit/mod.rs @@ -0,0 +1,3 @@ +mod models; +pub mod provider; +pub use self::provider::HashDitProvider; diff --git a/core/crates/security_provider/src/providers/hashdit/models.rs b/core/crates/security_provider/src/providers/hashdit/models.rs new file mode 100644 index 0000000000..267b048680 --- /dev/null +++ b/core/crates/security_provider/src/providers/hashdit/models.rs @@ -0,0 +1,143 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetectResponse { + pub status: String, + pub code: String, + pub error_data: Option, + pub data: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RiskData { + #[serde(default)] + pub has_result: Option, + #[serde(default)] + pub risk_level: Option, + pub scanned_ts: Option, + pub risk_detail: Option, // json string + + pub risk_category: Option, + pub risk_code: Option, + pub trust_score: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_response() { + let json = r#"{ + "status": "ERROR", + "type": "GENERAL", + "code": "0030002", + "errorData": "business is not found:app id can't match business", + "data": null, + "subData": null, + "params": null + }"#; + let response = serde_json::from_str::(json).unwrap(); + + assert_eq!(response.status, "ERROR"); + assert_eq!(response.code, "0030002"); + assert_eq!(response.data, None); + assert!(!response.error_data.unwrap().is_empty()); + } + + #[test] + fn test_empty_response() { + let json = r#"{ + "status": "OK", + "type": "GENERAL", + "code": "000000000", + "errorData": null, + "data": { + "risk_level": -1, + "scanned_ts": null, + "has_result": false, + "risk_detail": null, + "request_id": "8c8414f0bb0645afa4bc3670767cfc7b", + "polling_interval": 60000 + }, + "subData": null, + "params": null + }"#; + let response = serde_json::from_str::(json).unwrap(); + + assert_eq!(response.status, "OK"); + assert_eq!(response.code, "000000000"); + assert_eq!(response.error_data, None); + + let data = response.data.unwrap(); + + assert_eq!(data.has_result, Some(false)); + assert_eq!(data.risk_level, Some(-1)); + assert_eq!(data.risk_detail, None); + } + + #[test] + fn test_address_response() { + let json = r#"{ + "status": "OK", + "type": "GENERAL", + "code": "000000000", + "errorData": null, + "data": { + "risk_level": 1, + "scanned_ts": 1727869054771, + "has_result": true, + "risk_detail": "[]", + "request_id": "d8c401ff8fe84f1e8def321dd551b670", + "polling_interval": null + }, + "subData": null, + "params": null + }"#; + let response = serde_json::from_str::(json).unwrap(); + + assert_eq!(response.status, "OK"); + assert_eq!(response.code, "000000000"); + assert_eq!(response.error_data, None); + + let data = response.data.unwrap(); + + assert_eq!(data.has_result, Some(true)); + assert_eq!(data.risk_level, Some(1)); + assert_eq!(data.scanned_ts, Some(1727869054771)); + assert_eq!(data.risk_detail.unwrap(), "[]"); + } + + #[test] + fn test_token_response_minimal() { + let json = r#"{ + "status": "OK", + "type": "GENERAL", + "code": "000000000", + "errorData": null, + "data": { + "has_result": true, + "risk_level": 0, + "result": { + "token-symbol": "USDC", + "token-name": "USD Coin", + "owner-address": "0x0000000000000000000000000000000000000000", + "holders-count": "10", + "holders": [ + { "acountAddress": "0xabc", "tokenBalance": "123" }, + { "acountAddress": "0xdef", "tokenBalance": "456" } + ] + } + }, + "subData": null, + "params": null + }"#; + + let response = serde_json::from_str::(json).unwrap(); + assert_eq!(response.status, "OK"); + let data = response.data.unwrap(); + assert_eq!(data.has_result, Some(true)); + assert_eq!(data.risk_level, Some(0)); + } +} diff --git a/core/crates/security_provider/src/providers/hashdit/provider.rs b/core/crates/security_provider/src/providers/hashdit/provider.rs new file mode 100644 index 0000000000..4b956bf7d2 --- /dev/null +++ b/core/crates/security_provider/src/providers/hashdit/provider.rs @@ -0,0 +1,126 @@ +use crate::providers::hashdit::models::DetectResponse; +use crate::{AddressTarget, ScanProvider, ScanResult, TokenTarget, mapper}; +use async_trait::async_trait; +use gem_client::{ClientError, ClientExt, ReqwestClient}; +use hmac::{Hmac, KeyInit, Mac}; +use serde_json::{Value, json}; +use sha2::Sha256; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; +type HmacSha256 = Hmac; + +const PROVIDER_NAME: &str = "HashDit"; + +pub struct HashDitProvider { + client: ReqwestClient, + app_id: String, + app_secret: String, +} + +impl HashDitProvider { + pub fn new(client: ReqwestClient, app_id: &str, app_secret: &str) -> Self { + HashDitProvider { + client, + app_id: app_id.to_string(), + app_secret: app_secret.to_string(), + } + } + + fn generate_msg_for_sig(&self, timestamp: &str, nonce: &str, method: &str, url: &str, query: &str, body: &str) -> String { + if !query.is_empty() { + format!("{};{};{};{};{};{};{}", self.app_id, timestamp, nonce, method, url, query, body) + } else { + format!("{};{};{};{};{};{}", self.app_id, timestamp, nonce, method, url, body) + } + } + + fn compute_sig(&self, msg_for_sig: &str) -> String { + let mut mac = HmacSha256::new_from_slice(self.app_secret.as_bytes()).expect("HMAC can take key of any size"); + mac.update(msg_for_sig.as_bytes()); + let result = mac.finalize(); + let code_bytes = result.into_bytes(); + hex::encode(code_bytes) + } + + async fn send_request(&self, business: &str, body: &T) -> Result { + let query = HashMap::from([("business".to_string(), business.to_string())]); + let query_str = query.iter().map(|(k, v)| format!("{}={}", k, v)).collect::>().join("&"); + let method = "POST"; + let path = "/security-api/public/app/v1/detect"; + + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_millis().to_string(); + let nonce: String = uuid::Uuid::new_v4().to_string().replace('-', ""); + + let body_str = serde_json::to_string(body).unwrap_or_default(); + let msg_for_sig = self.generate_msg_for_sig(×tamp, &nonce, method, path, &query_str, &body_str); + let sig = self.compute_sig(&msg_for_sig); + + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json;charset=UTF-8".to_string()); + headers.insert("X-Signature-appid".to_string(), self.app_id.clone()); + headers.insert("X-Signature-signature".to_string(), sig); + headers.insert("X-Signature-timestamp".to_string(), timestamp); + headers.insert("X-Signature-nonce".to_string(), nonce); + + let url = format!("{}?{}", path, query_str); + self.client.post_with_headers(&url, body, headers).await + } + + fn parse_response(response: DetectResponse) -> Result<(bool, Option), Box> { + if let Some(error_data) = response.error_data { + return Err(Box::from(error_data)); + } + + let mut is_malicious = false; + let mut reason: Option = None; + + if let Some(data) = response.data { + let has_result = data.has_result.unwrap_or_else(|| data.risk_level.is_some()); + if has_result { + let level = data.risk_level.unwrap_or(0); + // 3 - Medium Risk + is_malicious = level >= 3; + reason = Some(format!("Risk level: {}", level)); + } else { + is_malicious = false; + reason = Some("No data found".to_string()); + } + } + + Ok((is_malicious, reason)) + } + + async fn _scan(&self, target: &T, business: &str, body: &Value) -> Result, Box> { + let response = self.send_request(business, body).await?; + let (is_malicious, reason) = Self::parse_response(response)?; + Ok(ScanResult { + target: target.clone(), + is_malicious, + reason, + provider: PROVIDER_NAME.into(), + }) + } +} + +#[async_trait] +impl ScanProvider for HashDitProvider { + fn name(&self) -> &'static str { + PROVIDER_NAME + } + + async fn scan_address(&self, target: &AddressTarget) -> Result, Box> { + let body = json!({ + "chain_id": mapper::chain_to_provider_id(target.chain), + "address": target.address, + }); + self._scan(target, "gem_wallet_address_detection", &body).await + } + + async fn scan_token(&self, target: &TokenTarget) -> Result, Box> { + let body = json!({ + "chain_id": mapper::chain_to_provider_id(target.chain), + "address": target.token_id, + }); + self._scan(target, "gem_wallet_token_detection", &body).await + } +} diff --git a/core/crates/security_provider/src/providers/mod.rs b/core/crates/security_provider/src/providers/mod.rs new file mode 100644 index 0000000000..b5ba2b945e --- /dev/null +++ b/core/crates/security_provider/src/providers/mod.rs @@ -0,0 +1,2 @@ +pub mod goplus; +pub mod hashdit; diff --git a/core/crates/security_provider/tests/integration_test.rs b/core/crates/security_provider/tests/integration_test.rs new file mode 100644 index 0000000000..bfffc9f9b5 --- /dev/null +++ b/core/crates/security_provider/tests/integration_test.rs @@ -0,0 +1,94 @@ +#[cfg(test)] +mod tests { + use std::env; + use std::time::Duration; + + use primitives::Chain; + use security_provider::{AddressTarget, ScanProvider, TokenTarget, providers::goplus::GoPlusProvider, providers::hashdit::HashDitProvider}; + use settings::Settings; + + use gem_client::ReqwestClient; + + fn load_settings() -> Settings { + let current_dir = env::current_dir().unwrap(); + let path = current_dir.join("../../Settings.yaml"); + Settings::new_setting_path(path).unwrap() + } + + fn build_client(base_url: String, timeout: Duration) -> ReqwestClient { + let http = reqwest::Client::builder().timeout(timeout).build().expect("failed to build reqwest client"); + ReqwestClient::new(base_url, http) + } + + #[tokio::test] + async fn test_goplus_scan_address_eth() { + let settings = load_settings(); + let client = build_client(settings.scan.goplus.url.clone(), settings.scan.timeout); + let provider = GoPlusProvider::new(client, &settings.scan.goplus.key.secret); + + let target = AddressTarget { + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string(), // Vitalik.eth + chain: Chain::Ethereum, + }; + + let result = provider.scan_address(&target).await.expect("goplus address scan failed"); + + assert_eq!(result.provider, "GoPlus"); + assert_eq!(result.is_malicious, false); + } + + #[tokio::test] + async fn test_goplus_scan_token_eth_usdc() { + let settings = load_settings(); + let client = build_client(settings.scan.goplus.url.clone(), settings.scan.timeout); + let provider = GoPlusProvider::new(client, &settings.scan.goplus.key.secret); + + let target = TokenTarget { + token_id: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), // USDT + chain: Chain::Ethereum, + }; + + let result = provider.scan_token(&target).await.expect("goplus token scan failed"); + + assert_eq!(result.provider, "GoPlus"); + assert_eq!(result.is_malicious, false); + } + + #[tokio::test] + async fn test_hashdit_scan_address_eth() { + let settings = load_settings(); + let client = build_client(settings.scan.hashdit.url.clone(), settings.scan.timeout); + let app_id = &settings.scan.hashdit.key.public; + let app_secret = &settings.scan.hashdit.key.secret; + let provider = HashDitProvider::new(client, app_id, app_secret); + + let target = AddressTarget { + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string(), // Vitalik.eth + chain: Chain::Ethereum, + }; + + let result = provider.scan_address(&target).await.expect("hashdit address scan failed"); + + assert_eq!(result.provider, "HashDit"); + assert_eq!(result.is_malicious, false); + } + + #[tokio::test] + async fn test_hashdit_scan_token_eth_usdc() { + let settings = load_settings(); + let client = build_client(settings.scan.hashdit.url.clone(), settings.scan.timeout); + let app_id = &settings.scan.hashdit.key.public; + let app_secret = &settings.scan.hashdit.key.secret; + let provider = HashDitProvider::new(client, app_id, app_secret); + + let target = TokenTarget { + token_id: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), // USDC + chain: Chain::Ethereum, + }; + + let result = provider.scan_token(&target).await.expect("hashdit token scan failed"); + + assert_eq!(result.provider, "HashDit"); + assert_eq!(result.is_malicious, false); + } +} diff --git a/core/crates/serde_serializers/Cargo.toml b/core/crates/serde_serializers/Cargo.toml new file mode 100644 index 0000000000..6c2652fb6b --- /dev/null +++ b/core/crates/serde_serializers/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "serde_serializers" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +bigint = ["dep:num-bigint"] + +[dependencies] +hex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +num-bigint = { workspace = true, optional = true } diff --git a/core/crates/serde_serializers/src/bigint.rs b/core/crates/serde_serializers/src/bigint.rs new file mode 100644 index 0000000000..e2570fbacd --- /dev/null +++ b/core/crates/serde_serializers/src/bigint.rs @@ -0,0 +1,108 @@ +use num_bigint::BigInt; +use serde::{Deserialize, de}; + +fn parse_bigint_hex(value: &str) -> Result { + let hex_value = value.strip_prefix("0x").unwrap_or(value); + if hex_value.is_empty() { + return Ok(BigInt::from(0)); + } + BigInt::parse_bytes(hex_value.as_bytes(), 16).ok_or_else(|| format!("Invalid hex string: {value}")) +} + +fn parse_bigint_str(value: &str) -> Result { + match value.strip_prefix("0x") { + Some(_) => parse_bigint_hex(value), + None => value.parse::().map_err(|err| err.to_string()), + } +} + +pub fn serialize_bigint(value: &BigInt, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +pub fn deserialize_bigint_from_str<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let s: String = de::Deserialize::deserialize(deserializer)?; + parse_bigint_str(&s).map_err(de::Error::custom) +} + +pub fn deserialize_option_bigint_from_str<'de, D>(deserializer: D) -> Result, D::Error> +where + D: de::Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + Some(str_val) => parse_bigint_str(&str_val).map(Some).map_err(de::Error::custom), + None => Ok(None), + } +} + +pub fn bigint_from_hex_str(hex_str: &str) -> Result> { + parse_bigint_hex(hex_str).map_err(|err| err.into()) +} + +pub fn deserialize_bigint_vec_from_hex_str<'de, D>(deserializer: D) -> Result, D::Error> +where + D: de::Deserializer<'de>, +{ + let hex_strings: Vec = de::Deserialize::deserialize(deserializer)?; + hex_strings.into_iter().map(|hex_str| bigint_from_hex_str(&hex_str).map_err(de::Error::custom)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use num_bigint::BigInt; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize)] + struct TestStruct { + #[serde(serialize_with = "serialize_bigint", deserialize_with = "deserialize_bigint_from_str")] + value: BigInt, + } + + #[derive(Deserialize)] + struct TestVecStruct { + #[serde(deserialize_with = "deserialize_bigint_vec_from_hex_str")] + values: Vec, + } + + #[test] + fn test_bigint_serialization() { + let value = BigInt::parse_bytes(b"12345678901234567890", 10).unwrap(); + let test_struct = TestStruct { value }; + let serialized = serde_json::to_string(&test_struct).unwrap(); + assert_eq!(serialized, r#"{"value":"12345678901234567890"}"#); + + let deserialized: TestStruct = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized.value, BigInt::parse_bytes(b"12345678901234567890", 10).unwrap()); + + let hex_cases = [ + (r#"{"value":"0xff"}"#, BigInt::from(255)), + (r#"{"value":"0x0"}"#, BigInt::from(0)), + (r#"{"value":"0x"}"#, BigInt::from(0)), + ]; + for (json, expected) in hex_cases { + let deserialized: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(deserialized.value, expected); + } + } + + #[test] + fn test_bigint_from_hex_str() { + assert_eq!(bigint_from_hex_str("0x1a").unwrap(), BigInt::from(26)); + assert_eq!(bigint_from_hex_str("1a").unwrap(), BigInt::from(26)); + assert_eq!(bigint_from_hex_str("0x0").unwrap(), BigInt::from(0)); + assert_eq!(bigint_from_hex_str("0x").unwrap(), BigInt::from(0)); + assert_eq!(bigint_from_hex_str("ff").unwrap(), BigInt::from(255)); + assert!(bigint_from_hex_str("xyz").is_err()); + + let deserialized: TestVecStruct = serde_json::from_str(r#"{"values":["0x1a","0xff","0x0"]}"#).unwrap(); + assert_eq!(deserialized.values, vec![BigInt::from(26), BigInt::from(255), BigInt::from(0)]); + } +} diff --git a/core/crates/serde_serializers/src/biguint.rs b/core/crates/serde_serializers/src/biguint.rs new file mode 100644 index 0000000000..3725a65ce7 --- /dev/null +++ b/core/crates/serde_serializers/src/biguint.rs @@ -0,0 +1,106 @@ +use num_bigint::BigUint; +use serde::{Deserialize, de}; + +fn parse_biguint_hex(value: &str) -> Result { + let hex_value = value.strip_prefix("0x").unwrap_or(value); + if hex_value.is_empty() { + return Ok(BigUint::from(0u32)); + } + BigUint::parse_bytes(hex_value.as_bytes(), 16).ok_or_else(|| format!("Invalid hex string: {value}")) +} + +pub fn serialize_biguint(value: &BigUint, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +pub fn serialize_biguint_to_hex_str(value: &BigUint, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&format!("0x{}", value.to_str_radix(16))) +} + +pub fn deserialize_biguint_from_str<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let s: String = de::Deserialize::deserialize(deserializer)?; + s.parse::().map_err(de::Error::custom) +} + +pub fn deserialize_option_biguint_from_str<'de, D>(deserializer: D) -> Result, D::Error> +where + D: de::Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + Some(value) => value.parse::().map(Some).map_err(de::Error::custom), + None => Ok(None), + } +} + +pub fn deserialize_biguint_from_hex_str<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let s: String = de::Deserialize::deserialize(deserializer)?; + parse_biguint_hex(&s).map_err(de::Error::custom) +} + +pub fn deserialize_biguint_from_option_hex_str<'de, D>(deserializer: D) -> Result, D::Error> +where + D: de::Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => parse_biguint_hex(&s).map(Some).map_err(de::Error::custom), + None => Ok(None), + } +} + +pub fn biguint_from_hex_str(hex_value: &str) -> Result> { + parse_biguint_hex(hex_value).map_err(|err| err.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Deserialize)] + struct HexStruct { + #[serde(deserialize_with = "deserialize_biguint_from_hex_str")] + value: BigUint, + } + + #[derive(Deserialize)] + struct OptionHexStruct { + #[serde(default, deserialize_with = "deserialize_biguint_from_option_hex_str")] + value: Option, + } + + #[test] + fn test_biguint_hex_deserialization() { + assert_eq!(biguint_from_hex_str("0x1a").unwrap(), BigUint::from(26u32)); + assert_eq!(biguint_from_hex_str("1a").unwrap(), BigUint::from(26u32)); + assert_eq!(biguint_from_hex_str("0x0").unwrap(), BigUint::from(0u32)); + assert_eq!(biguint_from_hex_str("0x").unwrap(), BigUint::from(0u32)); + assert_eq!(biguint_from_hex_str("ff").unwrap(), BigUint::from(255u32)); + assert!(biguint_from_hex_str("xyz").is_err()); + + let hex_cases = [(r#"{"value":"0x1a"}"#, BigUint::from(26u32)), (r#"{"value":"1a"}"#, BigUint::from(26u32))]; + for (json, expected) in hex_cases { + let deserialized: HexStruct = serde_json::from_str(json).unwrap(); + assert_eq!(deserialized.value, expected); + } + + let deserialized: OptionHexStruct = serde_json::from_str(r#"{"value":"0x0"}"#).unwrap(); + assert_eq!(deserialized.value, Some(BigUint::from(0u32))); + + let deserialized: OptionHexStruct = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(deserialized.value, None); + } +} diff --git a/core/crates/serde_serializers/src/duration.rs b/core/crates/serde_serializers/src/duration.rs new file mode 100644 index 0000000000..88b1caec4a --- /dev/null +++ b/core/crates/serde_serializers/src/duration.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Deserializer}; +use std::time::Duration; + +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + parse_duration(&s).map_err(serde::de::Error::custom) +} + +pub fn deserialize_option<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + value.map(|raw| parse_duration(&raw)).transpose().map_err(serde::de::Error::custom) +} + +pub fn parse_duration(s: &str) -> Result { + let s = s.trim(); + + if s.is_empty() { + return Err("empty duration string".to_string()); + } + + let mut num_str = String::new(); + let mut unit = String::new(); + + for c in s.chars() { + if c.is_ascii_digit() || c == '.' { + num_str.push(c); + } else if c.is_ascii_alphabetic() { + unit.push(c); + } + } + + if num_str.is_empty() { + return Err("no number found in duration".to_string()); + } + + let value: f64 = num_str.parse().map_err(|_| format!("invalid number: {}", num_str))?; + + let duration = match unit.as_str() { + "ns" => Duration::from_nanos(value as u64), + "us" => Duration::from_micros(value as u64), + "ms" => Duration::from_millis(value as u64), + "s" => Duration::from_secs_f64(value), + "m" => Duration::from_secs_f64(value * 60.0), + "h" => Duration::from_secs_f64(value * 3600.0), + "d" => Duration::from_secs_f64(value * 86400.0), + "" => Duration::from_millis(value as u64), + _ => return Err(format!("unknown duration unit '{}', supported: ns, us, ms, s, m, h, d", unit)), + }; + + Ok(duration) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("3s").unwrap(), Duration::from_secs(3)); + assert_eq!(parse_duration("3000ms").unwrap(), Duration::from_millis(3000)); + assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60)); + assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); + assert_eq!(parse_duration("1.5s").unwrap(), Duration::from_millis(1500)); + assert_eq!(parse_duration("3000").unwrap(), Duration::from_millis(3000)); + } + + #[test] + fn test_deserialize_option() { + #[derive(Deserialize)] + struct Value { + #[serde(default, deserialize_with = "deserialize_option")] + ttl: Option, + } + + let value: Value = serde_json::from_str(r#"{"ttl":"1m"}"#).unwrap(); + assert_eq!(value.ttl, Some(Duration::from_secs(60))); + + let value: Value = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(value.ttl, None); + } +} diff --git a/core/crates/serde_serializers/src/f64.rs b/core/crates/serde_serializers/src/f64.rs new file mode 100644 index 0000000000..f9a2989b31 --- /dev/null +++ b/core/crates/serde_serializers/src/f64.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Deserializer, de}; + +pub fn serialize_f64(value: &f64, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +pub fn deserialize_f64_from_str<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + s.parse::().map_err(de::Error::custom) +} + +pub fn deserialize_option_f64_from_str<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => s.parse::().map(Some).map_err(de::Error::custom), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + use serde::de::IntoDeserializer; + + #[derive(Serialize, Deserialize)] + struct TestOptionStruct { + #[serde(default, deserialize_with = "deserialize_option_f64_from_str")] + value: Option, + } + + #[test] + fn test_f64_deserialization() { + let deserializer: serde::de::value::StrDeserializer = "42.42".into_deserializer(); + assert_eq!(deserialize_f64_from_str(deserializer).unwrap(), 42.42); + + let deserializer: serde::de::value::StrDeserializer = "invalid".into_deserializer(); + assert!(deserialize_f64_from_str(deserializer).is_err()); + + let deserialized: TestOptionStruct = serde_json::from_str(r#"{"value":"42.42"}"#).unwrap(); + assert_eq!(deserialized.value, Some(42.42)); + + let deserialized: TestOptionStruct = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(deserialized.value, None); + } +} diff --git a/core/crates/serde_serializers/src/hex_bytes.rs b/core/crates/serde_serializers/src/hex_bytes.rs new file mode 100644 index 0000000000..7d1c1ba89c --- /dev/null +++ b/core/crates/serde_serializers/src/hex_bytes.rs @@ -0,0 +1,88 @@ +use std::borrow::Cow; + +use serde::{Deserialize, Deserializer, Serializer, de::Error as _}; + +pub fn serialize(value: &T, serializer: S) -> Result +where + T: AsRef<[u8]> + ?Sized, + S: Serializer, +{ + serializer.serialize_str(&hex::encode(value.as_ref())) +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + decode(&value).map_err(D::Error::custom) +} + +fn decode(value: &str) -> Result, hex::FromHexError> { + let value = value.trim(); + let value = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")).unwrap_or(value); + if value.is_empty() { + return Ok(vec![]); + } + + let normalized = if value.len() % 2 == 1 { Cow::Owned(format!("0{value}")) } else { Cow::Borrowed(value) }; + hex::decode(normalized.as_ref()) +} + +pub mod option { + use super::*; + + pub fn serialize(value: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(value) => serializer.serialize_some(&hex::encode(value)), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer)? + .map(|value| decode(&value).map_err(D::Error::custom)) + .transpose() + } +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Deserialize, PartialEq, Serialize)] + struct BytesValue { + #[serde(with = "crate::hex_bytes")] + value: Vec, + } + + #[derive(Debug, Deserialize, PartialEq, Serialize)] + struct OptionBytesValue { + #[serde(default, skip_serializing_if = "Option::is_none", with = "crate::hex_bytes::option")] + value: Option>, + } + + #[test] + fn test_hex_bytes() { + let value: BytesValue = serde_json::from_str(r#"{"value":" 0xabc "}"#).unwrap(); + assert_eq!(value.value, vec![0x0a, 0xbc]); + assert_eq!(serde_json::to_string(&value).unwrap(), r#"{"value":"0abc"}"#); + } + + #[test] + fn test_option_hex_bytes() { + let value: OptionBytesValue = serde_json::from_str(r#"{"value":"0x"}"#).unwrap(); + assert_eq!(value.value, Some(vec![])); + assert_eq!(serde_json::to_string(&value).unwrap(), r#"{"value":""}"#); + + let value: OptionBytesValue = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(value.value, None); + assert_eq!(serde_json::to_string(&value).unwrap(), r#"{}"#); + } +} diff --git a/core/crates/serde_serializers/src/lib.rs b/core/crates/serde_serializers/src/lib.rs new file mode 100644 index 0000000000..d165eb4764 --- /dev/null +++ b/core/crates/serde_serializers/src/lib.rs @@ -0,0 +1,23 @@ +#[cfg(feature = "bigint")] +pub mod bigint; +mod visitors; +#[cfg(feature = "bigint")] +pub use bigint::{bigint_from_hex_str, deserialize_bigint_from_str, deserialize_bigint_vec_from_hex_str, deserialize_option_bigint_from_str, serialize_bigint}; +#[cfg(feature = "bigint")] +pub mod biguint; +#[cfg(feature = "bigint")] +pub use biguint::{ + biguint_from_hex_str, deserialize_biguint_from_hex_str, deserialize_biguint_from_option_hex_str, deserialize_biguint_from_str, deserialize_option_biguint_from_str, + serialize_biguint, serialize_biguint_to_hex_str, +}; +pub mod duration; +pub use duration::{deserialize as deserialize_duration, deserialize_option as deserialize_option_duration, parse_duration}; +pub mod f64; +pub use f64::{deserialize_f64_from_str, deserialize_option_f64_from_str, serialize_f64}; +pub mod hex_bytes; +pub mod string; +pub use string::{deserialize_string_from_str_or_number, deserialize_string_from_value}; +pub mod u64; +pub use u64::{deserialize_option_u64_from_str, deserialize_option_u64_from_str_or_int, deserialize_u64_from_str, deserialize_u64_from_str_or_int, serialize_u64}; +pub mod u128; +pub use u128::{deserialize_option_u128_from_str, deserialize_u128_from_str, serialize_u128}; diff --git a/core/crates/serde_serializers/src/string.rs b/core/crates/serde_serializers/src/string.rs new file mode 100644 index 0000000000..01e00a1c2c --- /dev/null +++ b/core/crates/serde_serializers/src/string.rs @@ -0,0 +1,61 @@ +use serde::Deserializer; + +use crate::visitors::StringFromValueVisitor; + +pub fn deserialize_string_from_value<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(StringFromValueVisitor::new(true)) +} + +pub fn deserialize_string_from_str_or_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(StringFromValueVisitor::new(false)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq)] + struct TestStruct { + #[serde(deserialize_with = "deserialize_string_from_str_or_number")] + pub value: String, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct TestStructWithNull { + #[serde(default, deserialize_with = "deserialize_string_from_value")] + pub value: String, + } + + #[test] + fn test_string_deserialization() { + let str_or_number_cases = [ + (r#"{"value": "hello"}"#, "hello"), + (r#"{"value": 123}"#, "123"), + (r#"{"value": 0}"#, "0"), + (r#"{"value": 123.456}"#, "123.456"), + ]; + for (json, expected) in str_or_number_cases { + let result: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + + let value_cases = [ + (r#"{"value": "hello"}"#, "hello"), + (r#"{"value": 123}"#, "123"), + (r#"{"value": 0}"#, "0"), + (r#"{"value": null}"#, ""), + (r#"{"value": 123.456}"#, "123.456"), + ]; + for (json, expected) in value_cases { + let result: TestStructWithNull = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + } +} diff --git a/core/crates/serde_serializers/src/u128.rs b/core/crates/serde_serializers/src/u128.rs new file mode 100644 index 0000000000..7775b26e58 --- /dev/null +++ b/core/crates/serde_serializers/src/u128.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Deserializer}; + +pub fn serialize_u128(value: &u128, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +pub fn deserialize_u128_from_str<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + s.parse::().map_err(serde::de::Error::custom) +} + +pub fn deserialize_option_u128_from_str<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + Some(str_val) => str_val.parse::().map(Some).map_err(serde::de::Error::custom), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestStruct { + #[serde(default, deserialize_with = "deserialize_option_u128_from_str")] + pub amount: Option, + } + + #[test] + fn test_u128_deserialization() { + let cases = [ + (r#"{"amount": "123456789012345678901"}"#, Some(123456789012345678901u128)), + (r#"{"amount": null}"#, None), + (r#"{}"#, None), + ]; + for (json, expected) in cases { + let result: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.amount, expected); + } + } +} diff --git a/core/crates/serde_serializers/src/u64.rs b/core/crates/serde_serializers/src/u64.rs new file mode 100644 index 0000000000..60b6d1168d --- /dev/null +++ b/core/crates/serde_serializers/src/u64.rs @@ -0,0 +1,164 @@ +use std::fmt; + +use serde::{Deserialize, Deserializer, de}; + +use crate::visitors::{StringOrNumberFromValue, StringOrNumberVisitor}; + +fn parse_u64_string(value: &str) -> Result { + if let Some(hex_val) = value.strip_prefix("0x") { + u64::from_str_radix(hex_val, 16).map_err(|_| format!("Invalid hex string for u64: {value}")) + } else { + value.parse::().map_err(|_| format!("Invalid decimal string for u64: {value}")) + } +} + +fn invalid_number(value: impl fmt::Display) -> String { + format!("Invalid number for u64: {value}") +} + +impl StringOrNumberFromValue for u64 { + const EXPECTING: &'static str = "a string or integer representing u64"; + + fn from_str(value: &str) -> Result { + parse_u64_string(value) + } + + fn from_u64(value: u64) -> Result { + Ok(value) + } + + fn from_i64(value: i64) -> Result { + if value < 0 { + return Err(invalid_number(value)); + } + Ok(value as u64) + } +} + +pub fn serialize_u64(value: &u64, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +pub fn deserialize_u64_from_str<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + parse_u64_string(&s).map_err(de::Error::custom) +} + +pub fn deserialize_u64_from_str_or_int<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(StringOrNumberVisitor::::new()) +} + +pub fn deserialize_option_u64_from_str_or_int<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + match value { + Some(serde_json::Value::String(value)) => parse_u64_string(&value).map(Some).map_err(de::Error::custom), + Some(serde_json::Value::Number(value)) => value + .as_u64() + .or_else(|| value.as_i64().filter(|value| *value >= 0).map(|value| value as u64)) + .map(Some) + .ok_or_else(|| de::Error::custom(invalid_number(value))), + Some(serde_json::Value::Null) | None => Ok(None), + Some(value) => Err(de::Error::custom(format!("Expected string or integer representing u64, got: {value:?}"))), + } +} + +pub fn deserialize_option_u64_from_str<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + Some(str_val) => parse_u64_string(&str_val).map(Some).map_err(de::Error::custom), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestStruct { + #[serde(default, deserialize_with = "deserialize_option_u64_from_str")] + pub gas_used: Option, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestMixedStruct { + #[serde(deserialize_with = "deserialize_u64_from_str")] + pub value: u64, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestStrOrIntStruct { + #[serde(deserialize_with = "deserialize_u64_from_str_or_int")] + pub value: u64, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestOptionStrOrIntStruct { + #[serde(default, deserialize_with = "deserialize_option_u64_from_str_or_int")] + pub value: Option, + } + + #[test] + fn test_u64_deserialization() { + let option_cases = [ + (r#"{"gas_used": "123"}"#, Some(123u64)), + (r#"{"gas_used": "0x2a"}"#, Some(42)), + (r#"{"gas_used": null}"#, None), + (r#"{}"#, None), + ]; + for (json, expected) in option_cases { + let result: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.gas_used, expected); + } + + let str_cases = [ + (r#"{"value": "0x1a2b"}"#, 6699u64), + (r#"{"value": "0x0"}"#, 0), + (r#"{"value": "12345"}"#, 12345), + (r#"{"value": "0"}"#, 0), + ]; + for (json, expected) in str_cases { + let result: TestMixedStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + + let mixed_cases = [(r#"{"value": 42}"#, 42u64), (r#"{"value": "0x2a"}"#, 42), (r#"{"value": "42"}"#, 42)]; + for (json, expected) in mixed_cases { + let result: TestStrOrIntStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + + let option_mixed_cases = [ + (r#"{"value": 42}"#, Some(42u64)), + (r#"{"value": "0x2a"}"#, Some(42)), + (r#"{"value": "42"}"#, Some(42)), + (r#"{"value": null}"#, None), + (r#"{}"#, None), + ]; + for (json, expected) in option_mixed_cases { + let result: TestOptionStrOrIntStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + + assert!(serde_json::from_str::(r#"{"value": 1.5}"#).is_err()); + assert!(serde_json::from_str::(r#"{"value": -1}"#).is_err()); + assert!(serde_json::from_str::(r#"{"value": 1.5}"#).is_err()); + assert!(serde_json::from_str::(r#"{"value": -1}"#).is_err()); + } +} diff --git a/core/crates/serde_serializers/src/visitors/mod.rs b/core/crates/serde_serializers/src/visitors/mod.rs new file mode 100644 index 0000000000..d1a09a97dc --- /dev/null +++ b/core/crates/serde_serializers/src/visitors/mod.rs @@ -0,0 +1,14 @@ +use serde::de; + +fn map_err(value: Result) -> Result +where + E: de::Error, +{ + value.map_err(de::Error::custom) +} + +mod string_or_number; +mod string_value; + +pub(crate) use string_or_number::{StringOrNumberFromValue, StringOrNumberVisitor}; +pub(crate) use string_value::StringFromValueVisitor; diff --git a/core/crates/serde_serializers/src/visitors/string_or_number.rs b/core/crates/serde_serializers/src/visitors/string_or_number.rs new file mode 100644 index 0000000000..20d9889125 --- /dev/null +++ b/core/crates/serde_serializers/src/visitors/string_or_number.rs @@ -0,0 +1,61 @@ +use std::fmt; +use std::marker::PhantomData; + +use serde::de::{self, Visitor}; + +use super::map_err; + +pub(crate) trait StringOrNumberFromValue: Sized { + const EXPECTING: &'static str; + + fn from_str(value: &str) -> Result; + fn from_u64(value: u64) -> Result; + fn from_i64(value: i64) -> Result; +} + +pub(crate) struct StringOrNumberVisitor(PhantomData); + +impl StringOrNumberVisitor { + pub(crate) fn new() -> Self { + Self(PhantomData) + } +} + +impl<'de, T> Visitor<'de> for StringOrNumberVisitor +where + T: StringOrNumberFromValue, +{ + type Value = T; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str(T::EXPECTING) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + map_err(T::from_str(value)) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + map_err(T::from_str(&value)) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + map_err(T::from_u64(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + map_err(T::from_i64(value)) + } +} diff --git a/core/crates/serde_serializers/src/visitors/string_value.rs b/core/crates/serde_serializers/src/visitors/string_value.rs new file mode 100644 index 0000000000..95387c240d --- /dev/null +++ b/core/crates/serde_serializers/src/visitors/string_value.rs @@ -0,0 +1,78 @@ +use std::fmt; + +use serde::de::{self, Visitor}; + +pub(crate) struct StringFromValueVisitor { + allow_null: bool, +} + +impl StringFromValueVisitor { + pub(crate) fn new(allow_null: bool) -> Self { + Self { allow_null } + } + + fn expected_message(&self) -> &'static str { + if self.allow_null { "a string, number, or null" } else { "a string or number" } + } + + fn null_value(self) -> Result + where + E: de::Error, + { + if self.allow_null { + Ok(String::new()) + } else { + Err(de::Error::custom(format!("expected {}", self.expected_message()))) + } + } +} + +impl<'de> Visitor<'de> for StringFromValueVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str(self.expected_message()) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(value.to_owned()) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + Ok(value) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + self.null_value() + } +} diff --git a/core/crates/settings/Cargo.toml b/core/crates/settings/Cargo.toml new file mode 100644 index 0000000000..ef7d56dc10 --- /dev/null +++ b/core/crates/settings/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "settings" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +serde = { workspace = true } +config = { workspace = true } +serde_serializers = { path = "../serde_serializers" } + +[features] +testkit = [] diff --git a/core/crates/settings/src/lib.rs b/core/crates/settings/src/lib.rs new file mode 100644 index 0000000000..33199a1bb3 --- /dev/null +++ b/core/crates/settings/src/lib.rs @@ -0,0 +1,405 @@ +#![allow(unused)] + +use serde::Deserialize; +use serde_serializers::duration; +use std::{env, path::PathBuf, time::Duration}; + +use config::{Config, ConfigError, Environment, File}; +use std::collections::HashMap; + +#[derive(Debug, Deserialize, Clone)] +pub struct Settings { + pub redis: Redis, + pub postgres: Postgres, + pub meilisearch: MeiliSearch, + pub rabbitmq: RabbitMQ, + + pub api: API, + pub parser: Parser, + pub daemon: Daemon, + pub consumer: Consumer, + + pub fiat: Fiat, + pub moonpay: MoonPay, + pub transak: Transak, + pub mercuryo: Mercuryo, + pub banxa: UrlKeySettings, + pub paybis: Paybis, + pub flashnet: UrlKeySettings, + + pub swap: Swap, + + pub prices: Prices, + pub coingecko: CoinGecko, + pub charter: Charter, + pub name: Name, + pub chains: Chains, + pub pusher: Pusher, + pub scan: Scan, + pub nft: NFT, + pub ankr: Ankr, + pub trongrid: Trongrid, + pub assets: Assets, + pub rewards: Rewards, + pub ip: Ip, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Fiat { + #[serde(deserialize_with = "duration::deserialize")] + pub timeout: Duration, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Redis { + pub url: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Postgres { + pub url: String, + pub pool: u32, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Retry { + #[serde(deserialize_with = "duration::deserialize")] + pub delay: Duration, + #[serde(deserialize_with = "duration::deserialize")] + pub timeout: Duration, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct RabbitMQ { + pub url: String, + pub prefetch: u16, + pub retry: Retry, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct MeiliSearch { + pub url: String, + pub key: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct KeySecret { + pub secret: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Key { + pub secret: String, + pub public: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct KeySettings { + pub key: Key, +} +pub type MoonPay = KeySettings; +pub type Paybis = KeySettings; + +#[derive(Debug, Deserialize, Clone)] +pub struct Transak { + pub key: Key, + pub referrer_domain: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Mercuryo { + pub key: MercuryoKey, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct MercuryoKey { + pub secret: String, + pub public: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct SecretKeySettings { + pub key: KeySecret, +} +pub type CoinGecko = SecretKeySettings; + +#[derive(Debug, Deserialize, Clone)] +pub struct UrlSecretKeySettings { + pub url: String, + pub key: KeySecret, +} +pub type UD = UrlSecretKeySettings; + +#[derive(Debug, Deserialize, Clone)] +pub struct UrlKeySettings { + pub url: String, + pub key: Key, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Prices { + pub pyth: PriceProviderEndpoint, + pub jupiter: PriceProviderEndpoint, + pub defillama: PriceProviderEndpoint, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct PriceProviderEndpoint { + pub url: String, + pub timer: u64, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Charter { + pub timer: u64, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Name { + pub max_name_length: usize, + pub ens: URL, + pub ud: UD, + pub sns: URL, + pub ton: URL, + pub eths: URL, + pub spaceid: URL, + pub did: URL, + pub suins: URL, + pub aptos: URL, + pub injective: URL, + pub icns: URL, + pub lens: URL, + pub base: URL, + pub hyperliquid: URL, + pub alldomains: URL, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct URL { + pub url: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Chains { + pub solana: Chain, + pub ethereum: Chain, + pub smartchain: Chain, + pub polygon: Chain, + pub optimism: Chain, + pub arbitrum: Chain, + pub base: Chain, + pub opbnb: Chain, + pub avalanchec: Chain, + pub ton: Chain, + pub cosmos: Chain, + pub osmosis: Chain, + pub thorchain: Chain, + pub celestia: Chain, + pub tron: Chain, + pub xrp: Chain, + pub aptos: Chain, + pub sui: Chain, + pub bitcoin: Chain, + pub bitcoincash: Chain, + pub litecoin: Chain, + pub doge: Chain, + pub zcash: Chain, + pub fantom: Chain, + pub gnosis: Chain, + pub injective: Chain, + pub sei: Chain, + pub seievm: Chain, + pub manta: Chain, + pub blast: Chain, + pub noble: Chain, + pub zksync: Chain, + pub linea: Chain, + pub mantle: Chain, + pub celo: Chain, + pub near: Chain, + pub world: Chain, + pub plasma: Chain, + pub stellar: Chain, + pub sonic: Chain, + pub algorand: Chain, + pub polkadot: Chain, + pub cardano: Chain, + #[serde(rename = "abstract")] + pub abstract_chain: Chain, + pub berachain: Chain, + pub ink: Chain, + pub unichain: Chain, + pub hyperliquid: Chain, + pub hypercore: Chain, + pub monad: Chain, + pub xlayer: Chain, + pub stable: Chain, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Chain { + pub url: String, + #[serde(default)] + pub node: ChainURLType, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub enum ChainURLType { + #[default] + Default, + Archival, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Shutdown { + #[serde(deserialize_with = "duration::deserialize")] + pub timeout: Duration, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Parser { + #[serde(deserialize_with = "duration::deserialize")] + pub timeout: Duration, + pub shutdown: Shutdown, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Daemon { + pub service: String, + pub shutdown: Shutdown, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Consumer { + pub error: ConsumerError, + #[serde(default, deserialize_with = "duration::deserialize")] + pub delay: Duration, + pub shutdown: Shutdown, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ConsumerError { + #[serde(deserialize_with = "duration::deserialize")] + pub timeout: Duration, + pub skip: bool, + pub retries: u32, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct API { + pub service: String, + pub auth: Auth, + pub admin: Admin, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Auth { + pub enabled: bool, + #[serde(deserialize_with = "duration::deserialize")] + pub tolerance: Duration, + pub jwt: Jwt, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Jwt { + pub secret: String, + #[serde(deserialize_with = "duration::deserialize")] + pub expiry: Duration, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Admin { + pub enabled: bool, + pub token: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Pusher { + pub url: String, + pub ios: PusherIOS, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct PusherIOS { + pub topic: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Scan { + #[serde(deserialize_with = "duration::deserialize")] + pub timeout: Duration, + pub hashdit: UrlKeySettings, + pub goplus: UrlKeySettings, +} + +impl Settings { + pub fn new() -> Result { + let current_dir = env::current_dir().unwrap(); + Self::new_setting_path(current_dir.join("Settings.yaml")) + } + + pub fn new_setting_path(path: PathBuf) -> Result { + let s = Config::builder() + .add_source(File::from(path)) + .add_source(Environment::with_prefix("").prefix_separator("").separator("_")) + .build()?; + s.try_deserialize() + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct NFT { + pub url: String, + pub nftscan: NFTScan, + pub opensea: OpenSea, + pub magiceden: MagicEden, +} +pub type Ankr = SecretKeySettings; +pub type Trongrid = SecretKeySettings; +pub type NFTScan = SecretKeySettings; +pub type OpenSea = SecretKeySettings; +pub type MagicEden = SecretKeySettings; + +pub type Assets = URL; + +#[derive(Debug, Deserialize, Clone)] +pub struct Rewards { + #[serde(default)] + pub wallets: HashMap, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct RewardsWallet { + pub key: String, + pub address: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Ip { + pub abuseipdb: AbuseIPDB, + pub ipapi: IpApi, +} +pub type AbuseIPDB = UrlSecretKeySettings; +pub type IpApi = UrlSecretKeySettings; + +#[derive(Debug, Deserialize, Clone)] +pub struct Swap { + pub okx: Okx, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Okx { + pub key: Key, + pub passphrase: String, + pub project: String, +} + +#[cfg(feature = "testkit")] +pub mod testkit; + +pub fn service_user_agent(service: &str, sub_service: Option<&str>) -> String { + match sub_service { + Some(sub) => format!("{}_{}", service, sub), + None => service.to_string(), + } +} diff --git a/core/crates/settings/src/testkit.rs b/core/crates/settings/src/testkit.rs new file mode 100644 index 0000000000..9689cd8df7 --- /dev/null +++ b/core/crates/settings/src/testkit.rs @@ -0,0 +1,25 @@ +use crate::Settings; +use std::path::PathBuf; + +pub fn get_test_settings() -> Settings { + find_settings_file() + .and_then(|path| Settings::new_setting_path(path).ok()) + .expect("Failed to load Settings.yaml for tests. Make sure Settings.yaml exists in the project root.") +} + +fn find_settings_file() -> Option { + let mut current_dir = std::env::current_dir().ok()?; + + loop { + let settings_path = current_dir.join("Settings.yaml"); + if settings_path.exists() { + return Some(settings_path); + } + + if !current_dir.pop() { + break; + } + } + + None +} diff --git a/core/crates/settings_chain/Cargo.toml b/core/crates/settings_chain/Cargo.toml new file mode 100644 index 0000000000..e0be7379f9 --- /dev/null +++ b/core/crates/settings_chain/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "settings_chain" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +url = { workspace = true } +reqwest = { workspace = true } +chrono = { workspace = true } + +primitives = { path = "../primitives" } +chain_traits = { path = "../chain_traits" } +settings = { path = "../settings" } +gem_client = { path = "../gem_client", features = ["reqwest"] } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["client"] } + +gem_algorand = { path = "../gem_algorand", features = ["rpc", "reqwest"] } +gem_aptos = { path = "../gem_aptos", features = ["rpc", "reqwest"] } +gem_bitcoin = { path = "../gem_bitcoin", features = ["rpc", "reqwest"] } +gem_cardano = { path = "../gem_cardano", features = ["rpc", "reqwest"] } +gem_cosmos = { path = "../gem_cosmos", features = ["rpc", "reqwest"] } +gem_evm = { path = "../gem_evm", features = ["rpc", "reqwest"] } +gem_hypercore = { path = "../gem_hypercore", features = ["reqwest"] } +gem_near = { path = "../gem_near", features = ["rpc", "reqwest"] } +gem_polkadot = { path = "../gem_polkadot", features = ["rpc", "reqwest"] } +gem_solana = { path = "../gem_solana", features = ["rpc", "reqwest"] } +gem_stellar = { path = "../gem_stellar", features = ["rpc", "reqwest"] } +gem_sui = { path = "../gem_sui", features = ["rpc", "reqwest"] } +gem_ton = { path = "../gem_ton", features = ["rpc", "reqwest"] } +gem_tron = { path = "../gem_tron", features = ["rpc", "reqwest"] } +gem_xrp = { path = "../gem_xrp", features = ["rpc", "reqwest"] } diff --git a/core/crates/settings_chain/src/broadcast_providers.rs b/core/crates/settings_chain/src/broadcast_providers.rs new file mode 100644 index 0000000000..d027e85eec --- /dev/null +++ b/core/crates/settings_chain/src/broadcast_providers.rs @@ -0,0 +1,52 @@ +use std::collections::HashMap; + +use chain_traits::{ChainRequestClassifier, ChainTransactionDecode}; +use primitives::{Chain, ChainRequest, ChainRequestType, ChainType}; + +trait BroadcastProvider: ChainRequestClassifier + ChainTransactionDecode {} + +impl BroadcastProvider for T where T: ChainRequestClassifier + ChainTransactionDecode {} + +pub struct BroadcastProviders { + providers: HashMap>, +} + +impl BroadcastProviders { + pub fn from_chains(chains: impl IntoIterator) -> Self { + Self { + providers: chains.into_iter().map(|chain| (chain, new_provider(chain))).collect(), + } + } + + fn get_provider(&self, chain: Chain) -> Option<&dyn BroadcastProvider> { + self.providers.get(&chain).map(|provider| provider.as_ref()) + } + + pub fn classify_request(&self, chain: Chain, request: ChainRequest<'_>) -> ChainRequestType { + self.get_provider(chain).map_or(ChainRequestType::Unknown, |provider| provider.classify_request(request)) + } + + pub fn decode_transaction_broadcast(&self, chain: Chain, response: &[u8]) -> Option { + self.get_provider(chain).and_then(|provider| provider.decode_transaction_broadcast_bytes(response)) + } +} + +fn new_provider(chain: Chain) -> Box { + match chain.chain_type() { + ChainType::Bitcoin => Box::new(gem_bitcoin::provider::BroadcastProvider), + ChainType::Ethereum => Box::new(gem_evm::provider::BroadcastProvider), + ChainType::Solana => Box::new(gem_solana::provider::BroadcastProvider), + ChainType::Cosmos => Box::new(gem_cosmos::provider::BroadcastProvider), + ChainType::Ton => Box::new(gem_ton::provider::BroadcastProvider), + ChainType::Tron => Box::new(gem_tron::provider::BroadcastProvider), + ChainType::Aptos => Box::new(gem_aptos::provider::BroadcastProvider), + ChainType::Sui => Box::new(gem_sui::provider::BroadcastProvider), + ChainType::Xrp => Box::new(gem_xrp::provider::BroadcastProvider), + ChainType::Near => Box::new(gem_near::provider::BroadcastProvider), + ChainType::Stellar => Box::new(gem_stellar::provider::BroadcastProvider), + ChainType::Algorand => Box::new(gem_algorand::provider::BroadcastProvider), + ChainType::Polkadot => Box::new(gem_polkadot::provider::BroadcastProvider), + ChainType::Cardano => Box::new(gem_cardano::provider::BroadcastProvider), + ChainType::HyperCore => Box::new(gem_hypercore::provider::BroadcastProvider), + } +} diff --git a/core/crates/settings_chain/src/chain_providers.rs b/core/crates/settings_chain/src/chain_providers.rs new file mode 100644 index 0000000000..24d9849a4f --- /dev/null +++ b/core/crates/settings_chain/src/chain_providers.rs @@ -0,0 +1,118 @@ +use std::error::Error; + +use chain_traits::{ChainTraits, TransactionsRequest}; +use chrono::{DateTime, Utc}; +use primitives::{AddressStatus, Asset, AssetBalance, Chain, DelegationBase, PerpetualPositionsSummary, StakeValidator, Transaction, TransactionStateRequest, TransactionUpdate}; +use settings::Settings; + +use crate::ProviderFactory; + +pub struct ChainProviders { + providers: Vec>, +} + +impl ChainProviders { + pub fn new(providers: Vec>) -> Self { + Self { providers } + } + + pub fn from_settings(settings: &Settings, service_name: &str) -> Self { + Self::new(ProviderFactory::new_providers_with_user_agent(settings, service_name)) + } + + pub fn for_chain(chain: Chain, settings: &Settings, service_name: &str) -> Self { + Self::new(vec![ProviderFactory::new_from_settings_with_user_agent(chain, settings, service_name)]) + } + + fn get_provider(&self, chain: Chain) -> Result<&dyn ChainTraits, Box> { + self.providers + .iter() + .find(|x| x.get_chain() == chain) + .map(|provider| provider.as_ref()) + .ok_or_else(|| format!("Provider for chain {} not found", chain.as_ref()).into()) + } + + pub async fn get_token_data(&self, chain: Chain, token_id: String) -> Result> { + self.get_provider(chain)?.get_token_data(token_id).await + } + + pub fn get_is_token_address(&self, chain: Chain, token_id: &str) -> Result> { + Ok(self.get_provider(chain)?.get_is_token_address(token_id)) + } + + pub async fn get_balance_coin(&self, chain: Chain, address: String) -> Result> { + self.get_provider(chain)?.get_balance_coin(address).await + } + + pub async fn get_balance_tokens(&self, chain: Chain, address: String, token_ids: Vec) -> Result, Box> { + self.get_provider(chain)?.get_balance_tokens(address, token_ids).await + } + + pub async fn get_balance_assets(&self, chain: Chain, address: String) -> Result, Box> { + self.get_provider(chain)?.get_balance_assets(address).await + } + + pub async fn get_balance_staking(&self, chain: Chain, address: String) -> Result, Box> { + self.get_provider(chain)?.get_balance_staking(address).await + } + + pub async fn get_transactions_in_blocks(&self, chain: Chain, blocks: Vec) -> Result, Box> { + self.get_provider(chain)?.get_transactions_in_blocks(blocks).await + } + + pub async fn get_transactions_by_address(&self, chain: Chain, request: TransactionsRequest) -> Result, Box> { + self.get_provider(chain)?.get_transactions_by_address(request).await.map(sort_transactions_by_date) + } + + pub async fn get_validators(&self, chain: Chain) -> Result, Box> { + Ok(self.get_provider(chain)?.get_staking_validators(None).await?.into_iter().map(|v| v.into()).collect()) + } + + pub async fn get_staking_apy(&self, chain: Chain) -> Result> { + Ok(self.get_provider(chain)?.get_staking_apy().await?.unwrap_or(0.0)) + } + + pub async fn get_latest_block(&self, chain: Chain) -> Result> { + self.get_provider(chain)?.get_block_latest_number().await + } + + pub async fn get_transaction_by_hash(&self, chain: Chain, hash: String) -> Result, Box> { + self.get_provider(chain)?.get_transaction_by_hash(hash).await + } + + pub async fn get_transaction_status(&self, chain: Chain, hash: String) -> Result> { + self.get_provider(chain)? + .get_transaction_status(TransactionStateRequest { + id: hash, + sender_address: String::new(), + created_at: DateTime::::UNIX_EPOCH, + block_number: 0, + }) + .await + } + + pub async fn get_block_transactions(&self, chain: Chain, block_number: u64) -> Result, Box> { + self.get_provider(chain)?.get_transactions_by_block(block_number).await + } + + pub async fn get_staking_delegations(&self, chain: Chain, address: String) -> Result, Box> { + self.get_provider(chain)?.get_staking_delegations(address).await + } + + pub async fn get_perpetual_positions(&self, chain: Chain, address: String) -> Result> { + self.get_provider(chain)?.get_positions(address).await + } + + pub async fn get_perpetual_referred_addresses(&self, chain: Chain) -> Result, Box> { + self.get_provider(chain)?.get_perpetual_referred_addresses().await + } + + pub async fn get_address_status(&self, chain: Chain, address: String) -> Result, Box> { + self.get_provider(chain)?.get_address_status(address).await + } +} + +fn sort_transactions_by_date(mut transactions: Vec) -> Vec { + transactions.sort_by_key(|b| std::cmp::Reverse(b.created_at)); + transactions +} diff --git a/core/crates/settings_chain/src/lib.rs b/core/crates/settings_chain/src/lib.rs new file mode 100644 index 0000000000..379d0f8fdd --- /dev/null +++ b/core/crates/settings_chain/src/lib.rs @@ -0,0 +1,217 @@ +mod broadcast_providers; +mod chain_providers; +mod provider_config; +pub use broadcast_providers::BroadcastProviders; +pub use chain_providers::ChainProviders; +pub use chain_traits::TransactionsRequest; +use gem_algorand::{ + AlgorandClient, + rpc::{AlgorandClientIndexer, client_indexer::ALGORAND_INDEXER_URL}, +}; +use gem_client::{ReqwestClient, retry_policy}; +use gem_hypercore::rpc::client::HyperCoreClient; +pub use provider_config::ProviderConfig; +pub use settings::ChainURLType; + +use chain_traits::ChainTraits; + +use gem_aptos::rpc::AptosClient; +use gem_bitcoin::rpc::client::BitcoinClient; +use gem_cardano::rpc::CardanoClient; +use gem_cosmos::rpc::client::CosmosClient; +use gem_evm::rpc::{EthereumClient, ankr::AnkrClient}; +use gem_jsonrpc::client::JsonRpcClient; +use gem_near::rpc::client::NearClient; +use gem_polkadot::rpc::PolkadotClient; +use gem_solana::rpc::client::SolanaClient; +use gem_stellar::rpc::client::StellarClient; +use gem_sui::rpc::SuiClient; +use gem_ton::rpc::TonClient; +use gem_tron::rpc::{client::TronClient, trongrid::client::TronGridClient}; +use gem_xrp::rpc::XRPClient; + +use std::collections::HashMap; + +use primitives::{Chain, EVMChain, NodeType, chain_cosmos::CosmosChain}; +use settings::Settings; + +pub struct ProviderFactory {} + +impl ProviderFactory { + pub fn new_from_settings(chain: Chain, settings: &Settings) -> Box { + Self::new_from_settings_with_user_agent(chain, settings, "") + } + + pub fn new_from_settings_with_user_agent(chain: Chain, settings: &Settings, user_agent: &str) -> Box { + let chain_config = Self::get_chain_config(chain, settings); + let node_type = Self::get_node_type(chain_config.node.clone()); + + Self::new_provider( + ProviderConfig::new( + chain, + &chain_config.url, + node_type, + settings.ankr.key.secret.as_str(), + settings.trongrid.key.secret.as_str(), + ), + user_agent, + ) + } + + pub fn new_providers(settings: &Settings) -> Vec> { + Chain::all().iter().map(|x| Self::new_from_settings(*x, &settings.clone())).collect() + } + + pub fn new_providers_with_user_agent(settings: &Settings, user_agent: &str) -> Vec> { + Chain::all() + .iter() + .map(|x| Self::new_from_settings_with_user_agent(*x, &settings.clone(), user_agent)) + .collect() + } + + pub fn new_provider(config: ProviderConfig, user_agent: &str) -> Box { + let host = config.url.parse::().ok().and_then(|u| u.host_str().map(String::from)).unwrap_or_default(); + + let retry_policy_config = retry_policy(host, 3); + let reqwest_client = gem_client::builder().retry(retry_policy_config).build().expect("Failed to build reqwest client"); + + let chain = config.chain; + let url = config.url.clone(); + let node_type = config.clone().node_type; + let gem_client = ReqwestClient::new_with_user_agent(url.clone(), reqwest_client.clone(), user_agent.to_string()); + + match chain { + Chain::Bitcoin | Chain::BitcoinCash | Chain::Litecoin | Chain::Doge | Chain::Zcash => { + Box::new(BitcoinClient::new(gem_client, primitives::BitcoinChain::from_chain(chain).unwrap())) + } + Chain::Ethereum + | Chain::SmartChain + | Chain::Polygon + | Chain::Fantom + | Chain::Gnosis + | Chain::Arbitrum + | Chain::Optimism + | Chain::Base + | Chain::AvalancheC + | Chain::OpBNB + | Chain::Manta + | Chain::Blast + | Chain::ZkSync + | Chain::Linea + | Chain::Mantle + | Chain::Celo + | Chain::World + | Chain::Plasma + | Chain::Sonic + | Chain::SeiEvm + | Chain::Abstract + | Chain::Berachain + | Chain::Ink + | Chain::Unichain + | Chain::Hyperliquid + | Chain::Monad + | Chain::XLayer + | Chain::Stable => { + let chain = EVMChain::from_chain(chain).unwrap(); + let client = gem_client.clone(); + let rpc_client = JsonRpcClient::new(client.clone()); + let ethereum_client = EthereumClient::new(rpc_client.clone(), chain).with_node_type(node_type).with_ankr_client(AnkrClient::new( + JsonRpcClient::new(ReqwestClient::new(config.clone().ankr_url(), reqwest_client.clone())), + chain, + )); + Box::new(ethereum_client) + } + Chain::Cardano => Box::new(CardanoClient::new(gem_client)), + Chain::Cosmos | Chain::Osmosis | Chain::Celestia | Chain::Thorchain | Chain::Injective | Chain::Noble | Chain::Sei => { + let chain = CosmosChain::from_chain(chain).unwrap(); + Box::new(CosmosClient::new(chain, gem_client.clone())) + } + Chain::Aptos => Box::new(AptosClient::new(gem_client.clone())), + Chain::Sui => Box::new(SuiClient::new(url)), + Chain::Xrp => Box::new(XRPClient::new(JsonRpcClient::new(gem_client.clone()))), + Chain::Algorand => { + let indexer_client = ReqwestClient::new(ALGORAND_INDEXER_URL.to_string(), reqwest_client.clone()); + Box::new(AlgorandClient::new(gem_client.clone(), AlgorandClientIndexer::new(indexer_client))) + } + Chain::Stellar => Box::new(StellarClient::new(gem_client.clone())), + Chain::Near => Box::new(NearClient::new(JsonRpcClient::new(gem_client.clone()))), + Chain::Polkadot => Box::new(PolkadotClient::new(gem_client.clone())), + Chain::Solana => Box::new(SolanaClient::new(JsonRpcClient::new(gem_client.clone()))), + Chain::Ton => Box::new(TonClient::new(gem_client.clone())), + Chain::Tron => Box::new(TronClient::new(gem_client.clone(), TronGridClient::new(gem_client.clone(), config.trongrid_key.clone()))), + Chain::HyperCore => Box::new(HyperCoreClient::new(gem_client.clone())), + } + } + + pub fn get_chain_config(chain: Chain, settings: &Settings) -> &settings::Chain { + match chain { + Chain::Bitcoin => &settings.chains.bitcoin, + Chain::BitcoinCash => &settings.chains.bitcoincash, + Chain::Litecoin => &settings.chains.litecoin, + Chain::Ethereum => &settings.chains.ethereum, + Chain::SmartChain => &settings.chains.smartchain, + Chain::Solana => &settings.chains.solana, + Chain::Polygon => &settings.chains.polygon, + Chain::Thorchain => &settings.chains.thorchain, + Chain::Cosmos => &settings.chains.cosmos, + Chain::Osmosis => &settings.chains.osmosis, + Chain::Arbitrum => &settings.chains.arbitrum, + Chain::Ton => &settings.chains.ton, + Chain::Tron => &settings.chains.tron, + Chain::Doge => &settings.chains.doge, + Chain::Zcash => &settings.chains.zcash, + Chain::Optimism => &settings.chains.optimism, + Chain::Aptos => &settings.chains.aptos, + Chain::Base => &settings.chains.base, + Chain::AvalancheC => &settings.chains.avalanchec, + Chain::Sui => &settings.chains.sui, + Chain::Xrp => &settings.chains.xrp, + Chain::OpBNB => &settings.chains.opbnb, + Chain::Fantom => &settings.chains.fantom, + Chain::Gnosis => &settings.chains.gnosis, + Chain::Celestia => &settings.chains.celestia, + Chain::Injective => &settings.chains.injective, + Chain::Sei => &settings.chains.sei, + Chain::SeiEvm => &settings.chains.seievm, + Chain::Manta => &settings.chains.manta, + Chain::Blast => &settings.chains.blast, + Chain::Noble => &settings.chains.noble, + Chain::ZkSync => &settings.chains.zksync, + Chain::Linea => &settings.chains.linea, + Chain::Mantle => &settings.chains.mantle, + Chain::Celo => &settings.chains.celo, + Chain::Near => &settings.chains.near, + Chain::World => &settings.chains.world, + Chain::Plasma => &settings.chains.plasma, + Chain::Stellar => &settings.chains.stellar, + Chain::Sonic => &settings.chains.sonic, + Chain::Algorand => &settings.chains.algorand, + Chain::Polkadot => &settings.chains.polkadot, + Chain::Cardano => &settings.chains.cardano, + Chain::Abstract => &settings.chains.abstract_chain, + Chain::Berachain => &settings.chains.berachain, + Chain::Ink => &settings.chains.ink, + Chain::Unichain => &settings.chains.unichain, + Chain::Hyperliquid => &settings.chains.hyperliquid, + Chain::HyperCore => &settings.chains.hypercore, + Chain::Monad => &settings.chains.monad, + Chain::XLayer => &settings.chains.xlayer, + Chain::Stable => &settings.chains.stable, + } + } + + pub fn get_chain_endpoints(settings: &Settings) -> HashMap { + Chain::all() + .into_iter() + .map(|chain| (chain, Self::get_chain_config(chain, settings).url.clone())) + .filter(|(_, url)| !url.is_empty()) + .collect() + } + + pub fn get_node_type(url_type: ChainURLType) -> NodeType { + match url_type { + ChainURLType::Default => NodeType::Default, + ChainURLType::Archival => NodeType::Archival, + } + } +} diff --git a/core/crates/settings_chain/src/provider_config.rs b/core/crates/settings_chain/src/provider_config.rs new file mode 100644 index 0000000000..14974926c5 --- /dev/null +++ b/core/crates/settings_chain/src/provider_config.rs @@ -0,0 +1,32 @@ +use primitives::{Chain, NodeType}; +#[derive(Clone)] +pub struct ProviderConfig { + pub chain: Chain, + pub url: String, + pub node_type: NodeType, + pub ankr_key: String, + pub trongrid_key: String, +} + +impl ProviderConfig { + pub fn new(chain: Chain, url: &str, node_type: NodeType, ankr_key: &str, trongrid_key: &str) -> Self { + Self { + chain, + url: url.to_string(), + node_type, + ankr_key: ankr_key.to_string(), + trongrid_key: trongrid_key.to_string(), + } + } + + pub fn ankr_url(&self) -> String { + format!("https://rpc.ankr.com/multichain/{}", self.ankr_key) + } + + pub fn with_url(&self, url: &str) -> Self { + Self { + url: url.to_string(), + ..self.clone() + } + } +} diff --git a/core/crates/signer/Cargo.toml b/core/crates/signer/Cargo.toml new file mode 100644 index 0000000000..70bcd76ea8 --- /dev/null +++ b/core/crates/signer/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "signer" +version = "1.0.0" +edition = { workspace = true } + +[dependencies] +ed25519-dalek = { version = "2.2.0", default-features = false, features = ["std", "zeroize"] } +k256 = { workspace = true } +hex = { workspace = true } +primitives = { path = "../primitives" } +bs58 = { workspace = true } +alloy-primitives = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +gem_hash = { path = "../gem_hash" } +gem_encoding = { path = "../gem_encoding", features = ["base32"] } +zeroize = { workspace = true } + +[dev-dependencies] diff --git a/core/crates/signer/src/address.rs b/core/crates/signer/src/address.rs new file mode 100644 index 0000000000..85d170c0e0 --- /dev/null +++ b/core/crates/signer/src/address.rs @@ -0,0 +1,15 @@ +use primitives::SignerError; + +#[derive(Clone, Copy)] +pub struct Base32Address([u8; 32]); + +impl Base32Address { + pub fn from_slice(bytes: &[u8]) -> Result { + let payload: [u8; 32] = bytes.try_into().map_err(|_| SignerError::invalid_input("invalid base32 address payload"))?; + Ok(Self(payload)) + } + + pub fn payload(&self) -> &[u8; 32] { + &self.0 + } +} diff --git a/core/crates/signer/src/decode.rs b/core/crates/signer/src/decode.rs new file mode 100644 index 0000000000..6d3ca33a51 --- /dev/null +++ b/core/crates/signer/src/decode.rs @@ -0,0 +1,240 @@ +use crate::{SignatureScheme, SignerError}; +use primitives::hex::encode_with_0x; +use primitives::{Chain, ChainType, decode_hex}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone, Copy)] +enum KeyEncoding { + Hex, + Base58, + Base32, +} + +fn import_encodings_for_chain(chain: &Chain) -> &'static [KeyEncoding] { + match chain.chain_type() { + ChainType::Bitcoin => &[], + ChainType::Solana => &[KeyEncoding::Base58, KeyEncoding::Hex], + ChainType::Stellar => &[KeyEncoding::Base32, KeyEncoding::Hex], + _ => &[KeyEncoding::Hex], + } +} + +fn export_encoding_for_chain(chain: &Chain) -> KeyEncoding { + match chain.chain_type() { + ChainType::Bitcoin | ChainType::Solana => KeyEncoding::Base58, + _ => KeyEncoding::Hex, + } +} + +pub fn supports_private_key_import(chain: &Chain) -> bool { + !import_encodings_for_chain(chain).is_empty() +} + +fn scheme_for_chain(chain: &Chain) -> SignatureScheme { + match chain.chain_type() { + ChainType::Solana + | ChainType::Ton + | ChainType::Aptos + | ChainType::Sui + | ChainType::Near + | ChainType::Stellar + | ChainType::Algorand + | ChainType::Polkadot + | ChainType::Cardano => SignatureScheme::Ed25519, + _ => SignatureScheme::Secp256k1, + } +} + +fn decode_base58(value: &str) -> Option> { + let decoded = bs58::decode(value).into_vec().ok()?; + match decoded.len() { + 32 => Some(decoded), + 64 => Some(decoded[..32].to_vec()), + _ => None, + } +} + +fn base32_decode_char(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'2'..=b'7' => Some(c - b'2' + 26), + _ => None, + } +} + +fn base32_decode(input: &[u8]) -> Option> { + let mut output = Vec::with_capacity(input.len() * 5 / 8); + let mut buffer: u64 = 0; + let mut bits = 0u8; + + for &c in input { + if c == b'=' { + break; + } + let val = base32_decode_char(c)?; + buffer = (buffer << 5) | val as u64; + bits += 5; + if bits >= 8 { + bits -= 8; + output.push((buffer >> bits) as u8); + buffer &= (1u64 << bits) - 1; + } + } + Some(output) +} + +fn crc16_xmodem(data: &[u8]) -> u16 { + let mut crc: u16 = 0; + for &byte in data { + crc ^= (byte as u16) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x1021 } else { crc << 1 }; + } + } + crc +} + +fn decode_base32_stellar(value: &str) -> Option> { + if value.len() != 56 || !value.starts_with('S') { + return None; + } + let decoded = base32_decode(value.as_bytes())?; + if decoded.len() != 35 || decoded[0] != 0x90 { + return None; + } + let expected = u16::from_le_bytes([decoded[33], decoded[34]]); + if crc16_xmodem(&decoded[..33]) != expected { + return None; + } + Some(decoded[1..33].to_vec()) +} + +fn validate_key(bytes: &[u8], scheme: SignatureScheme) -> Result<(), SignerError> { + match scheme { + SignatureScheme::Ed25519 => { + let arr: &[u8; 32] = bytes.try_into().map_err(|_| SignerError::invalid_input("Invalid ed25519 private key"))?; + ed25519_dalek::SigningKey::from_bytes(arr); + Ok(()) + } + SignatureScheme::Secp256k1 => { + k256::ecdsa::SigningKey::from_slice(bytes).map_err(|_| SignerError::invalid_input("Invalid secp256k1 private key"))?; + Ok(()) + } + } +} + +fn decode_key(value: &str, encodings: &[KeyEncoding], scheme: SignatureScheme) -> Result>, SignerError> { + for encoding in encodings { + let decoded = match encoding { + KeyEncoding::Hex => decode_hex(value).ok(), + KeyEncoding::Base58 => decode_base58(value), + KeyEncoding::Base32 => decode_base32_stellar(value), + }; + if let Some(bytes) = decoded + && validate_key(&bytes, scheme).is_ok() + { + return Ok(Zeroizing::new(bytes)); + } + } + Err(SignerError::invalid_input("Invalid private key format")) +} + +fn encode_key(bytes: &[u8], encoding: KeyEncoding) -> Result { + match encoding { + KeyEncoding::Hex => Ok(encode_with_0x(bytes)), + KeyEncoding::Base58 => Ok(bs58::encode(bytes).into_string()), + KeyEncoding::Base32 => Err(SignerError::invalid_input("Unsupported private key export encoding")), + } +} + +pub fn decode_private_key(chain: &Chain, value: &str) -> Result>, SignerError> { + let import_encodings = import_encodings_for_chain(chain); + let scheme = scheme_for_chain(chain); + decode_key(value, import_encodings, scheme) +} + +pub fn encode_private_key(chain: &Chain, private_key: &[u8]) -> Result { + encode_key(private_key, export_encoding_for_chain(chain)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_solana_base58() { + let bytes = decode_private_key(&Chain::Solana, "4ha2npeRkDXipjgGJ3L5LhZ9TK9dRjP2yktydkFBhAzXj3N8ytpYyTS24kxcYGEefy4WKWRcog2zSPvpPZoGmxCC").unwrap(); + assert_eq!(bytes.len(), 32); + } + + #[test] + fn test_decode_ethereum_hex() { + let bytes = decode_private_key(&Chain::Ethereum, "0x30df0ffc2b43717f4653c2a1e827e9dfb3d9364e019cc60092496cd4997d5d6e").unwrap(); + assert_eq!(bytes.len(), 32); + } + + #[test] + fn test_decode_stellar_strkey() { + let bytes = decode_private_key(&Chain::Stellar, "SA6XNHUKMW4QAKSHB2NOZ4SYP34ERYVAWSBTEDREYSJ2LEJ5LFHLTIRJ").unwrap(); + assert_eq!(hex::encode(bytes.as_slice()), "3d769e8a65b9002a470e9aecf2587ef848e2a0b483320e24c493a5913d594eb9"); + } + + #[test] + fn test_decode_invalid() { + assert!(decode_private_key(&Chain::Ethereum, "not_valid").is_err()); + assert!(decode_private_key(&Chain::Stellar, "GA6XNHUKMW4QAKSHB2NOZ4SYP34ERYVAWSBTEDREYSJ2LEJ5LFHLTIRJ").is_err()); + } + + #[test] + fn test_encode_solana_base58() { + let bytes = decode_private_key(&Chain::Solana, "4ha2npeRkDXipjgGJ3L5LhZ9TK9dRjP2yktydkFBhAzXj3N8ytpYyTS24kxcYGEefy4WKWRcog2zSPvpPZoGmxCC").unwrap(); + let encoded = encode_private_key(&Chain::Solana, &bytes).unwrap(); + + assert_eq!(encoded, "DTJi5pMtSKZHdkLX4wxwvjGjf2xwXx1LSuuUZhugYWDV"); + } + + #[test] + fn test_encode_ethereum_hex() { + let bytes = decode_private_key(&Chain::Ethereum, "0x30df0ffc2b43717f4653c2a1e827e9dfb3d9364e019cc60092496cd4997d5d6e").unwrap(); + let encoded = encode_private_key(&Chain::Ethereum, &bytes).unwrap(); + + assert_eq!(encoded, "0x30df0ffc2b43717f4653c2a1e827e9dfb3d9364e019cc60092496cd4997d5d6e"); + } + + #[test] + fn test_encode_bitcoin_base58() { + let bytes = decode_private_key(&Chain::Ethereum, "0x30df0ffc2b43717f4653c2a1e827e9dfb3d9364e019cc60092496cd4997d5d6e").unwrap(); + let encoded = encode_private_key(&Chain::Bitcoin, &bytes).unwrap(); + + assert_eq!(encoded, "4Hmr8TxnwVB7m6fzfPRcASMn2hLRgzUz3gDGwF4ZnpVK"); + } + + #[test] + fn test_encode_does_not_revalidate_bytes() { + assert_eq!(encode_private_key(&Chain::Ethereum, &[1u8; 16]).unwrap(), "0x01010101010101010101010101010101"); + assert_eq!(encode_private_key(&Chain::Solana, &[1u8; 64]).unwrap(), bs58::encode([1u8; 64]).into_string()); + } + + #[test] + fn test_stellar_bad_checksum() { + // Valid stellar key with last char changed to corrupt checksum + assert!(decode_private_key(&Chain::Stellar, "SA6XNHUKMW4QAKSHB2NOZ4SYP34ERYVAWSBTEDREYSJ2LEJ5LFHLTIRA").is_err()); + } + + #[test] + fn test_supports_private_key_import() { + assert!(supports_private_key_import(&Chain::Ethereum)); + assert!(supports_private_key_import(&Chain::Solana)); + assert!(supports_private_key_import(&Chain::Stellar)); + assert!(!supports_private_key_import(&Chain::Bitcoin)); + assert!(!supports_private_key_import(&Chain::Litecoin)); + assert!(!supports_private_key_import(&Chain::Doge)); + } + + #[test] + fn test_base58_rejects_unexpected_length() { + // 96-byte base58 payload should not be accepted + let long_key = bs58::encode(vec![1u8; 96]).into_string(); + assert!(decode_base58(&long_key).is_none()); + } +} diff --git a/core/crates/signer/src/ed25519.rs b/core/crates/signer/src/ed25519.rs new file mode 100644 index 0000000000..7e37917519 --- /dev/null +++ b/core/crates/signer/src/ed25519.rs @@ -0,0 +1,27 @@ +use ed25519_dalek::{Signer as DalekSigner, SigningKey}; + +use primitives::SignerError; + +/// Byte value representing the Ed25519 scheme in on-chain serialization formats. +pub const ED25519_KEY_TYPE: u8 = 0; + +#[derive(Debug)] +pub struct Ed25519KeyPair { + signing_key: SigningKey, + pub public_key_bytes: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH], +} + +impl Ed25519KeyPair { + pub fn from_private_key(private_key: &[u8]) -> Result { + let key_bytes: [u8; ed25519_dalek::SECRET_KEY_LENGTH] = private_key.try_into().map_err(|_| SignerError::invalid_input("Invalid Ed25519 private key length"))?; + let signing_key = SigningKey::from_bytes(&key_bytes); + Ok(Self { + public_key_bytes: signing_key.verifying_key().to_bytes(), + signing_key, + }) + } + + pub fn sign(&self, digest: &[u8]) -> [u8; ed25519_dalek::SIGNATURE_LENGTH] { + self.signing_key.sign(digest).to_bytes() + } +} diff --git a/core/crates/signer/src/eip712/data.rs b/core/crates/signer/src/eip712/data.rs new file mode 100644 index 0000000000..9a1810de7a --- /dev/null +++ b/core/crates/signer/src/eip712/data.rs @@ -0,0 +1,27 @@ +use crate::SignerError; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +pub struct TypeField { + pub name: String, + #[serde(rename = "type")] + pub r#type: String, +} + +#[derive(Debug, Deserialize)] +pub struct TypedData { + pub types: HashMap>, + #[serde(rename = "primaryType")] + pub primary_type: String, + #[serde(default)] + pub domain: Value, + pub message: Value, +} + +impl TypedData { + pub fn from_value(value: Value) -> Result { + serde_json::from_value(value).map_err(|err| SignerError::invalid_input(format!("Invalid EIP-712 JSON: {err}"))) + } +} diff --git a/core/crates/signer/src/eip712/hash_impl.rs b/core/crates/signer/src/eip712/hash_impl.rs new file mode 100644 index 0000000000..78dd758691 --- /dev/null +++ b/core/crates/signer/src/eip712/hash_impl.rs @@ -0,0 +1,286 @@ +use alloy_primitives::hex; +use gem_hash::keccak::keccak256; +use primitives::SignerError; +use serde_json::{Map, Value}; +use std::borrow::Cow; +use std::collections::BTreeSet; + +use super::data::{TypeField, TypedData}; +use super::parse::{ + ADDR_LENGTH, MAX_WORD_BYTES, adjust_signed_value, base_type_name, left_pad, parse_array_type, parse_fixed_bytes_size, parse_int_value, parse_numeric_bits, parse_uint_value, + right_pad, +}; + +const PREFIX_PERSONAL_MESSAGE: &[u8] = b"\x19\x01"; + +pub fn hash_typed_data(json: &str) -> Result<[u8; 32], SignerError> { + let value: Value = serde_json::from_str(json).map_err(|err| SignerError::invalid_input(format!("Invalid EIP-712 JSON: {err}")))?; + validate_eip712_domain_chain_id_binding(&value)?; + let parsed = TypedData::from_value(value)?; + + if parsed.message.is_null() { + return SignerError::invalid_input_err("Invalid EIP-712 JSON: missing message"); + } + + let domain_hash = if parsed.types.contains_key("EIP712Domain") { + hash_struct("EIP712Domain", Some(&parsed.domain), &parsed.types)? + } else { + [0u8; 32] + }; + + let message_hash = hash_struct(&parsed.primary_type, Some(&parsed.message), &parsed.types)?; + + let mut preimage = Vec::with_capacity(2 + 32 + 32); + preimage.extend_from_slice(PREFIX_PERSONAL_MESSAGE); + preimage.extend_from_slice(&domain_hash); + preimage.extend_from_slice(&message_hash); + + Ok(keccak256(&preimage)) +} + +pub fn validate_eip712_domain_chain_id_binding(value: &Value) -> Result<(), SignerError> { + let domain_chain_id = value.get("domain").and_then(|domain| domain.get("chainId")); + let schema_has_chain_id = value + .get("types") + .and_then(|types| types.get("EIP712Domain")) + .and_then(Value::as_array) + .is_some_and(|fields| fields.iter().any(|field| field.get("name").and_then(Value::as_str) == Some("chainId"))); + + match (domain_chain_id, schema_has_chain_id) { + (Some(_), false) => SignerError::invalid_input_err("EIP712Domain type schema must declare chainId when domain.chainId is present"), + (Some(Value::Null) | None, true) => SignerError::invalid_input_err("EIP712 domain missing chainId"), + _ => Ok(()), + } +} + +fn hash_struct(primary_type: &str, data: Option<&Value>, types: &std::collections::HashMap>) -> Result<[u8; 32], SignerError> { + let fields = types + .get(primary_type) + .ok_or_else(|| SignerError::invalid_input(format!("Unknown EIP-712 type '{primary_type}'")))?; + + let data_map: Cow> = match data { + Some(Value::Object(map)) => Cow::Borrowed(map), + Some(Value::Null) | None => Cow::Owned(Map::new()), + Some(other) => return Err(SignerError::invalid_input(format!("Expected object for type '{primary_type}', got {}", other))), + }; + + let mut encoded = Vec::with_capacity(32 * (fields.len() + 1)); + let type_hash = hash_type(primary_type, types)?; + encoded.extend_from_slice(&type_hash); + + for field in fields { + let encoded_value = encode_value(&field.r#type, data_map.get(&field.name), types)?; + encoded.extend_from_slice(&encoded_value); + } + + Ok(keccak256(&encoded)) +} + +fn encode_value(type_name: &str, value: Option<&Value>, types: &std::collections::HashMap>) -> Result<[u8; 32], SignerError> { + if let Some((element_type, expected_len)) = parse_array_type(type_name) { + let mut concatenated = Vec::new(); + + match value { + Some(Value::Array(items)) => { + if let Some(len) = expected_len + && items.len() != len + { + return Err(SignerError::invalid_input(format!( + "Expected array of length {len} for type '{ty}', got {}", + items.len(), + ty = type_name + ))); + } + + for item in items { + let element_bytes = encode_value(&element_type, Some(item), types)?; + concatenated.extend_from_slice(&element_bytes); + } + } + Some(Value::Null) | None => { + if let Some(len) = expected_len + && len != 0 + { + return Err(SignerError::invalid_input(format!( + "Expected array of length {len} for type '{ty}', but value was null", + ty = type_name + ))); + } + } + Some(other) => { + return Err(SignerError::invalid_input(format!("Expected array for type '{ty}', got {}", other, ty = type_name))); + } + } + + return Ok(keccak256(&concatenated)); + } + + let base_type = base_type_name(type_name); + if types.contains_key(base_type) { + return hash_struct(base_type, value, types); + } + + match base_type { + "string" => encode_string(value), + "bytes" => encode_bytes(value), + "bool" => encode_bool(value), + "address" => encode_address(value), + _ => { + if base_type.starts_with("uint") || base_type == "uint" { + encode_uint(base_type, value) + } else if base_type.starts_with("int") { + encode_int(base_type, value) + } else if base_type.starts_with("bytes") { + encode_fixed_bytes(base_type, value) + } else { + SignerError::invalid_input_err(format!("Unsupported EIP-712 type '{ty}'", ty = type_name)) + } + } + } +} + +fn encode_string(value: Option<&Value>) -> Result<[u8; 32], SignerError> { + let string_value = match value { + Some(Value::String(s)) => s.as_str(), + Some(Value::Null) | None => "", + Some(other) => return Err(SignerError::invalid_input(format!("Expected string value, got {}", other))), + }; + + Ok(keccak256(string_value.as_bytes())) +} + +fn encode_bytes(value: Option<&Value>) -> Result<[u8; 32], SignerError> { + let bytes = match value { + Some(Value::String(s)) => hex::decode(s).map_err(SignerError::from_display)?, + Some(Value::Null) | None => Vec::new(), + Some(other) => return Err(SignerError::invalid_input(format!("Expected hex string for bytes value, got {}", other))), + }; + + Ok(keccak256(&bytes)) +} + +fn encode_bool(value: Option<&Value>) -> Result<[u8; 32], SignerError> { + let bool_value = match value { + Some(Value::Bool(b)) => *b, + Some(Value::Null) | None => false, + Some(Value::Number(num)) => match (num.as_u64(), num.as_i64()) { + (Some(v), _) => v != 0, + (_, Some(v)) => v != 0, + _ => return Err(SignerError::invalid_input("Invalid numeric value for bool")), + }, + Some(other) => return Err(SignerError::invalid_input(format!("Expected boolean value, got {}", other))), + }; + + if bool_value { Ok(left_pad(&[1])) } else { Ok([0u8; 32]) } +} + +fn encode_address(value: Option<&Value>) -> Result<[u8; 32], SignerError> { + let bytes = match value { + Some(Value::String(s)) => { + let raw = hex::decode(s).map_err(SignerError::from_display)?; + if raw.len() != 20 { + return Err(SignerError::invalid_input(format!("Invalid address length for '{s}'"))); + } + raw + } + Some(Value::Null) | None => vec![0u8; ADDR_LENGTH], + Some(other) => return Err(SignerError::invalid_input(format!("Expected address string, got {}", other))), + }; + + Ok(left_pad(&bytes)) +} + +fn encode_uint(type_name: &str, value: Option<&Value>) -> Result<[u8; 32], SignerError> { + let bits = parse_numeric_bits(type_name, "uint")?; + let number = parse_uint_value(value)?; + + if number.bit_len() > bits { + return Err(SignerError::invalid_input(format!( + "Value out of range for type '{type_name}' ({bits}-bit unsigned integer)" + ))); + } + + Ok(number.to_be_bytes::()) +} + +fn encode_int(type_name: &str, value: Option<&Value>) -> Result<[u8; 32], SignerError> { + let bits = parse_numeric_bits(type_name, "int")?; + let number = parse_int_value(value)?; + let unsigned = adjust_signed_value(number, bits)?; + Ok(unsigned.to_be_bytes::()) +} + +fn encode_fixed_bytes(type_name: &str, value: Option<&Value>) -> Result<[u8; 32], SignerError> { + let size = parse_fixed_bytes_size(type_name)?; + let mut bytes = match value { + Some(Value::String(s)) if s.is_empty() => Vec::new(), + Some(Value::String(s)) => hex::decode(s).map_err(SignerError::from_display)?, + Some(Value::Null) | None => Vec::new(), + Some(other) => return Err(SignerError::invalid_input(format!("Expected hex string for {type_name}, got {}", other))), + }; + + if bytes.len() > size { + return Err(SignerError::invalid_input(format!("Value too large for type '{type_name}'"))); + } + + if bytes.len() < size { + bytes.resize(size, 0u8); + } + + Ok(right_pad(&bytes)) +} + +fn hash_type(primary_type: &str, types: &std::collections::HashMap>) -> Result<[u8; 32], SignerError> { + let encoded = encode_type(primary_type, types)?; + Ok(keccak256(encoded.as_bytes())) +} + +fn encode_type(primary_type: &str, types: &std::collections::HashMap>) -> Result { + if !types.contains_key(primary_type) { + return Err(SignerError::invalid_input(format!("Unknown EIP-712 type '{primary_type}'"))); + } + + let mut deps = BTreeSet::new(); + collect_type_dependencies(primary_type, types, &mut deps); + deps.remove(primary_type); + + let mut parts = Vec::with_capacity(deps.len() + 1); + parts.push(primary_type.to_string()); + parts.extend(deps); + + let mut encoded = String::new(); + for type_name in parts { + let fields = types + .get(&type_name) + .ok_or_else(|| SignerError::invalid_input(format!("Unknown EIP-712 type '{type_name}'")))?; + + encoded.push_str(&type_name); + encoded.push('('); + for (idx, field) in fields.iter().enumerate() { + if idx > 0 { + encoded.push(','); + } + encoded.push_str(&field.r#type); + encoded.push(' '); + encoded.push_str(&field.name); + } + encoded.push(')'); + } + + Ok(encoded) +} + +fn collect_type_dependencies(primary_type: &str, types: &std::collections::HashMap>, results: &mut BTreeSet) { + let base = base_type_name(primary_type); + if results.contains(base) || !types.contains_key(base) { + return; + } + + results.insert(base.to_string()); + + if let Some(fields) = types.get(base) { + for field in fields { + collect_type_dependencies(&field.r#type, types, results); + } + } +} diff --git a/core/crates/signer/src/eip712/mod.rs b/core/crates/signer/src/eip712/mod.rs new file mode 100644 index 0000000000..d83777cc20 --- /dev/null +++ b/core/crates/signer/src/eip712/mod.rs @@ -0,0 +1,73 @@ +mod data; +mod hash_impl; +mod parse; + +pub use hash_impl::{hash_typed_data, validate_eip712_domain_chain_id_binding}; + +#[cfg(test)] +mod tests { + use super::*; + use hex::FromHex; + + #[test] + fn hash_matches_reference_vector() { + let json = include_str!("../../testdata/eip712_reference_vector.json"); + + let our_hash = hash_typed_data(json).expect("hash succeeds"); + let expected = <[u8; 32]>::from_hex("be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2").unwrap(); + assert_eq!(our_hash, expected); + } + + #[test] + fn hash_hyperliquid_with_colon_type() { + let json = include_str!("../../../gem_hypercore/testdata/hl_eip712_approve_agent.json"); + let digest = hash_typed_data(json).expect("hash succeeds"); + let expected = <[u8; 32]>::from_hex("480af9fd3cdc70c2f8a521388be13620d16a0f643d9cffdfbb65cd019cc27537").unwrap(); + assert_eq!(digest, expected); + } + + #[test] + fn hash_handles_arrays_and_nested_types() { + let json = include_str!("../../testdata/eip712_arrays_nested.json"); + + let digest = hash_typed_data(json).expect("hash succeeds"); + let expected = <[u8; 32]>::from_hex("6acbc18af9d2decca3d38571c2f595b1ebb1b93e9e7b046632df71f6ceb217f9").unwrap(); + assert_eq!(digest, expected); + } + + #[test] + fn hash_rejects_missing_message() { + let json = include_str!("../../testdata/eip712_missing_message.json"); + + let err = hash_typed_data(json).expect_err("missing message returns error"); + assert!(err.to_string().contains("missing message")); + } + + #[test] + fn hash_supports_signed_integers() { + let json = include_str!("../../testdata/eip712_signed_integers.json"); + + let digest = hash_typed_data(json).expect("hash succeeds"); + let expected = <[u8; 32]>::from_hex("c6bed7e6a1ec9d2737b1d7bbca1e966eff59e74e21d8e20a66351b2db82cfc6a").unwrap(); + assert_eq!(digest, expected); + } + + #[test] + fn hash_differs_across_chain_ids() { + let ethereum_json = include_str!("../../testdata/eip712_canonical_chain_id_1.json"); + let polygon_json = include_str!("../../testdata/eip712_canonical_chain_id_137.json"); + assert_ne!(hash_typed_data(ethereum_json).unwrap(), hash_typed_data(polygon_json).unwrap()); + } + + #[test] + fn hash_rejects_unbound_chain_id() { + let missing_schema_field = include_str!("../../../gem_evm/testdata/eip712_domain_chain_id_without_schema_field.json"); + assert!(hash_typed_data(missing_schema_field).unwrap_err().to_string().contains("chainId")); + + let schema_without_domain_value = include_str!("../../../gem_evm/testdata/eip712_schema_chain_id_without_domain_value.json"); + assert!(hash_typed_data(schema_without_domain_value).unwrap_err().to_string().contains("missing chainId")); + + let null_domain_chain_id = include_str!("../../../gem_evm/testdata/eip712_domain_chain_id_null_value.json"); + assert!(hash_typed_data(null_domain_chain_id).unwrap_err().to_string().contains("chainId")); + } +} diff --git a/core/crates/signer/src/eip712/parse.rs b/core/crates/signer/src/eip712/parse.rs new file mode 100644 index 0000000000..eb19c9a173 --- /dev/null +++ b/core/crates/signer/src/eip712/parse.rs @@ -0,0 +1,144 @@ +use alloy_primitives::{I256, U256}; +use primitives::SignerError; +use serde_json::Value; +use std::str::FromStr; + +pub const MAX_WORD_BYTES: usize = 32; +pub const ADDR_LENGTH: usize = 20; + +pub fn parse_array_type(type_name: &str) -> Option<(String, Option)> { + if !type_name.ends_with(']') { + return None; + } + + let start = type_name.rfind('[')?; + let length_str = &type_name[start + 1..type_name.len() - 1]; + let element_type = type_name[..start].to_string(); + + let length = if length_str.is_empty() { None } else { Some(length_str.parse().ok()?) }; + + Some((element_type, length)) +} + +pub fn base_type_name(type_name: &str) -> &str { + type_name.split('[').next().unwrap_or(type_name) +} + +pub fn parse_numeric_bits(type_name: &str, prefix: &str) -> Result { + let bits_part = &type_name[prefix.len()..]; + if bits_part.is_empty() { + return Ok(256); + } + + let bits = bits_part + .parse::() + .map_err(|_| SignerError::invalid_input(format!("Invalid bit size for type '{type_name}'")))?; + if bits == 0 || bits > MAX_WORD_BYTES * 8 || bits % 8 != 0 { + return SignerError::invalid_input_err(format!("Unsupported bit size for type '{type_name}'")); + } + Ok(bits) +} + +pub fn parse_fixed_bytes_size(type_name: &str) -> Result { + let size_part = &type_name["bytes".len()..]; + if size_part.is_empty() { + return SignerError::invalid_input_err(format!("Invalid fixed bytes type '{type_name}'")); + } + + let size = size_part + .parse::() + .map_err(|_| SignerError::invalid_input(format!("Invalid length for {type_name}")))?; + if size == 0 || size > MAX_WORD_BYTES { + return SignerError::invalid_input_err(format!("Unsupported length for {type_name}")); + } + Ok(size) +} + +pub fn parse_uint_value(value: Option<&Value>) -> Result { + match value { + Some(Value::String(s)) => U256::from_str(s).map_err(SignerError::from_display), + Some(Value::Number(num)) => { + if let Some(u) = num.as_u64() { + return Ok(U256::from(u)); + } + + if let Some(i) = num.as_i64() { + if i >= 0 { + return Ok(U256::from(i as u64)); + } + return SignerError::invalid_input_err("Negative numeric value provided for unsigned integer"); + } + + Err(SignerError::invalid_input("Unsupported numeric value for unsigned integer")) + } + Some(Value::Null) | None => Ok(U256::ZERO), + Some(other) => Err(SignerError::invalid_input(format!("Expected integer value, got {}", other))), + } +} + +pub fn parse_int_value(value: Option<&Value>) -> Result { + match value { + Some(Value::String(s)) => I256::from_str(s).map_err(SignerError::from_display), + Some(Value::Number(num)) => { + if let Some(i) = num.as_i64() { + return I256::try_from(i as i128).map_err(SignerError::from_display); + } + + if let Some(u) = num.as_u64() { + return Ok(I256::from_raw(U256::from(u))); + } + + Err(SignerError::invalid_input("Unsupported numeric value for signed integer")) + } + Some(Value::Null) | None => Ok(I256::ZERO), + Some(other) => Err(SignerError::invalid_input(format!("Expected integer value, got {}", other))), + } +} + +pub fn left_pad(bytes: &[u8]) -> [u8; MAX_WORD_BYTES] { + let mut out = [0u8; MAX_WORD_BYTES]; + let len = bytes.len().min(MAX_WORD_BYTES); + out[MAX_WORD_BYTES - len..].copy_from_slice(&bytes[bytes.len() - len..]); + out +} + +pub fn right_pad(bytes: &[u8]) -> [u8; MAX_WORD_BYTES] { + let mut out = [0u8; MAX_WORD_BYTES]; + let len = bytes.len().min(MAX_WORD_BYTES); + out[..len].copy_from_slice(&bytes[..len]); + out +} + +pub fn adjust_signed_value(number: I256, bits: usize) -> Result { + if bits == 0 || bits > MAX_WORD_BYTES * 8 { + return SignerError::invalid_input_err(format!("Unsupported bit size {bits} for signed integer")); + } + + if number.bits() > bits as u32 { + return SignerError::invalid_input_err(format!("Value out of range for signed integer with {bits} bits")); + } + + Ok(number.into_raw()) +} + +#[cfg(test)] +mod tests { + use alloy_primitives::I256; + + use super::{MAX_WORD_BYTES, adjust_signed_value}; + + #[test] + fn test_adjust_signed_value() { + let negative = adjust_signed_value(I256::try_from(-42).unwrap(), 32).unwrap().to_be_bytes::(); + assert_eq!(negative[..31], [0xff; 31]); + assert_eq!(negative[31], 0xd6); + + let positive = adjust_signed_value(I256::try_from(42).unwrap(), 32).unwrap().to_be_bytes::(); + assert_eq!(positive[..31], [0; 31]); + assert_eq!(positive[31], 42); + + assert!(adjust_signed_value(I256::try_from(-(1i128 << 31)).unwrap(), 32).is_ok()); + assert!(adjust_signed_value(I256::try_from(1i128 << 31).unwrap(), 32).is_err()); + assert!(adjust_signed_value(I256::try_from(-(1i128 << 31) - 1).unwrap(), 32).is_err()); + } +} diff --git a/core/crates/signer/src/error.rs b/core/crates/signer/src/error.rs new file mode 100644 index 0000000000..c5c44ffa87 --- /dev/null +++ b/core/crates/signer/src/error.rs @@ -0,0 +1,25 @@ +use primitives::SignerError; + +/// Extension trait to convert `Result` / `Option` into `Result<_, SignerError>` +/// with a single `invalid_input(msg)` call. +pub trait InvalidInput { + type Ok; + + fn invalid_input(self, msg: &'static str) -> Result; +} + +impl InvalidInput for Result { + type Ok = T; + + fn invalid_input(self, msg: &'static str) -> Result { + self.map_err(|_| SignerError::invalid_input(msg)) + } +} + +impl InvalidInput for Option { + type Ok = T; + + fn invalid_input(self, msg: &'static str) -> Result { + self.ok_or_else(|| SignerError::invalid_input(msg)) + } +} diff --git a/core/crates/signer/src/lib.rs b/core/crates/signer/src/lib.rs new file mode 100644 index 0000000000..bb807ebc27 --- /dev/null +++ b/core/crates/signer/src/lib.rs @@ -0,0 +1,80 @@ +mod address; +mod decode; +mod ed25519; +mod eip712; +mod error; +mod secp256k1; + +#[cfg(test)] +pub(crate) mod testkit { + pub const TEST_PRIVATE_KEY: &str = "1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34"; +} + +pub use crate::address::Base32Address; +pub use crate::ed25519::{ED25519_KEY_TYPE, Ed25519KeyPair}; +pub use crate::error::InvalidInput; +pub use crate::secp256k1::{ + RECOVERY_ID_INDEX, SIGNATURE_LENGTH, ensure_ethereum_signature_recovery_id_offset, public_key_from_private as secp256k1_public_key, + uncompressed_public_key_from_private as secp256k1_uncompressed_public_key, +}; + +pub use decode::{decode_private_key, encode_private_key, supports_private_key_import}; +pub use eip712::{hash_typed_data as hash_eip712, validate_eip712_domain_chain_id_binding}; +pub use primitives::SignerError; + +#[derive(Debug, Default)] +pub struct Signer; + +#[derive(Clone, Copy, Debug)] +pub enum SignatureScheme { + Ed25519, + Secp256k1, +} + +impl Signer { + pub fn sign_digest(scheme: SignatureScheme, digest: &[u8], private_key: &[u8]) -> Result, SignerError> { + match scheme { + SignatureScheme::Ed25519 => Ok(Ed25519KeyPair::from_private_key(private_key)?.sign(digest).to_vec()), + SignatureScheme::Secp256k1 => secp256k1::sign_digest_append_recovery(digest, private_key), + } + } + + /// Sign a secp256k1 digest returning [r(32), s(32), v(1)] where v ∈ {27, 28}. + pub fn sign_ethereum_digest(digest: &[u8], private_key: &[u8]) -> Result, SignerError> { + secp256k1::sign_ethereum_digest(digest, private_key) + } + + pub fn sign_eip712(typed_data_json: &str, private_key: &[u8]) -> Result { + let digest = eip712::hash_typed_data(typed_data_json)?; + let signature = Self::sign_ethereum_digest(&digest, private_key)?; + Ok(hex::encode(signature)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::TEST_PRIVATE_KEY; + + #[test] + fn ed25519_key_pair_rejects_invalid_length() { + let result = Ed25519KeyPair::from_private_key(&[0u8; 16]); + assert!(result.is_err()); + } + + #[test] + fn ed25519_key_pair_signs_and_derives_public_key() { + let private_key = hex::decode(TEST_PRIVATE_KEY).unwrap(); + let digest = b"test message"; + let key_pair = Ed25519KeyPair::from_private_key(&private_key).unwrap(); + + let signature = key_pair.sign(digest); + + assert_eq!(signature.len(), 64, "Ed25519 signature should be 64 bytes"); + assert_eq!(key_pair.public_key_bytes.len(), 32, "Ed25519 public key should be 32 bytes"); + + let other = Ed25519KeyPair::from_private_key(&private_key).unwrap(); + assert_eq!(other.sign(digest), signature); + assert_eq!(other.public_key_bytes, key_pair.public_key_bytes); + } +} diff --git a/core/crates/signer/src/secp256k1.rs b/core/crates/signer/src/secp256k1.rs new file mode 100644 index 0000000000..1e70460bdb --- /dev/null +++ b/core/crates/signer/src/secp256k1.rs @@ -0,0 +1,114 @@ +use k256::ecdsa::SigningKey as SecpSigningKey; +use primitives::SignerError; + +pub const SIGNATURE_LENGTH: usize = 65; +pub const RECOVERY_ID_INDEX: usize = SIGNATURE_LENGTH - 1; +const ETHEREUM_RECOVERY_ID_OFFSET: u8 = 27; + +/// Returns (signature_bytes, recovery_id) where recovery_id ∈ {0, 1}. +pub(crate) fn sign_digest(digest: &[u8], private_key: &[u8]) -> Result<(Vec, u8), SignerError> { + let signing_key = SecpSigningKey::from_slice(private_key).map_err(|_| SignerError::signing_error("Invalid Secp256k1 private key"))?; + let (signature, recovery_id) = signing_key + .sign_prehash_recoverable(digest) + .map_err(|_| SignerError::signing_error("Failed to sign Secp256k1 digest"))?; + Ok((signature.to_bytes().to_vec(), u8::from(recovery_id))) +} + +/// Returns [r(32), s(32), v(1)] where v ∈ {0, 1}. +pub(crate) fn sign_digest_append_recovery(digest: &[u8], private_key: &[u8]) -> Result, SignerError> { + let (rs, v) = sign_digest(digest, private_key)?; + Ok([rs, vec![v]].concat()) +} + +/// Returns [r(32), s(32), v(1)] where v ∈ {27, 28} (Ethereum/Tron). +pub(crate) fn sign_ethereum_digest(digest: &[u8], private_key: &[u8]) -> Result, SignerError> { + let (rs, v) = sign_digest(digest, private_key)?; + Ok([rs, vec![v + ETHEREUM_RECOVERY_ID_OFFSET]].concat()) +} + +pub fn public_key_from_private(private_key: &[u8]) -> Result, SignerError> { + let signing_key = SecpSigningKey::from_slice(private_key).map_err(|_| SignerError::invalid_input("Invalid Secp256k1 private key"))?; + Ok(signing_key.verifying_key().to_sec1_bytes().to_vec()) +} + +pub fn uncompressed_public_key_from_private(private_key: &[u8]) -> Result, SignerError> { + let signing_key = SecpSigningKey::from_slice(private_key).map_err(|_| SignerError::invalid_input("Invalid Secp256k1 private key"))?; + Ok(signing_key.verifying_key().to_encoded_point(false).as_bytes().to_vec()) +} + +/// Ensure a 65-byte signature uses Ethereum's 27/28 recovery id convention. +pub fn ensure_ethereum_signature_recovery_id_offset(signature: &mut [u8]) { + if signature.len() != 65 { + return; + } + let v = &mut signature[64]; + if *v < ETHEREUM_RECOVERY_ID_OFFSET { + *v += ETHEREUM_RECOVERY_ID_OFFSET; + } +} + +#[cfg(test)] +mod tests { + use super::{ + ETHEREUM_RECOVERY_ID_OFFSET, SecpSigningKey, ensure_ethereum_signature_recovery_id_offset, sign_digest, sign_ethereum_digest, uncompressed_public_key_from_private, + }; + use crate::testkit::TEST_PRIVATE_KEY; + use k256::ecdsa::{RecoveryId, Signature, VerifyingKey}; + const DIGEST: [u8; 32] = [7u8; 32]; + + #[test] + fn sign_digest_returns_raw_recovery_id() { + let private_key = hex::decode(TEST_PRIVATE_KEY).unwrap(); + let (rs, v) = sign_digest(&DIGEST, &private_key).unwrap(); + let signing_key = SecpSigningKey::from_slice(&private_key).unwrap(); + + assert_eq!(rs.len(), 64); + assert!(matches!(v, 0 | 1), "raw recovery id must be 0 or 1, got {v}"); + + let recovery_id = RecoveryId::from_byte(v).unwrap(); + let signature = Signature::try_from(rs.as_slice()).unwrap(); + let recovered = VerifyingKey::recover_from_prehash(&DIGEST, &signature, recovery_id).unwrap(); + assert_eq!(recovered.to_sec1_bytes().to_vec(), signing_key.verifying_key().to_sec1_bytes().to_vec()); + } + + #[test] + fn sign_ethereum_digest_applies_offset() { + let private_key = hex::decode(TEST_PRIVATE_KEY).unwrap(); + let (rs, v) = sign_digest(&DIGEST, &private_key).unwrap(); + let signature = sign_ethereum_digest(&DIGEST, &private_key).unwrap(); + + assert_eq!(rs, &signature[..64]); + assert_eq!(v + ETHEREUM_RECOVERY_ID_OFFSET, signature[64]); + } + + #[test] + fn uncompressed_public_key_from_private_derives_sec1_key() { + let private_key = hex::decode(TEST_PRIVATE_KEY).unwrap(); + let public_key = uncompressed_public_key_from_private(&private_key).unwrap(); + + assert_eq!(public_key.len(), 65); + assert_eq!(public_key[0], 0x04); + assert_eq!( + hex::encode(public_key), + "04a73ac47eb0f40940f30eb5444a6471de077a1a1c60ab7a533b82ffdf2d86a4f9a0aad8509e3a1fdda6514b1125cc4ab532a7a6ab58c529fed6a3854e1827f426", + ); + assert!(uncompressed_public_key_from_private(&[0u8; 16]).is_err()); + } + + #[test] + fn ensure_ethereum_signature_recovery_id_offset_is_idempotent() { + let mut sig = vec![0u8; 65]; + + sig[64] = 0; + ensure_ethereum_signature_recovery_id_offset(&mut sig); + assert_eq!(sig[64], ETHEREUM_RECOVERY_ID_OFFSET); + ensure_ethereum_signature_recovery_id_offset(&mut sig); + assert_eq!(sig[64], ETHEREUM_RECOVERY_ID_OFFSET); + + sig[64] = 1; + ensure_ethereum_signature_recovery_id_offset(&mut sig); + assert_eq!(sig[64], 1 + ETHEREUM_RECOVERY_ID_OFFSET); + ensure_ethereum_signature_recovery_id_offset(&mut sig); + assert_eq!(sig[64], 1 + ETHEREUM_RECOVERY_ID_OFFSET); + } +} diff --git a/core/crates/signer/testdata/eip712_arrays_nested.json b/core/crates/signer/testdata/eip712_arrays_nested.json new file mode 100644 index 0000000000..15725a44e6 --- /dev/null +++ b/core/crates/signer/testdata/eip712_arrays_nested.json @@ -0,0 +1,40 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "Inner": [ + { "name": "flag", "type": "bool" }, + { "name": "payload", "type": "bytes32" } + ], + "Group": [ + { "name": "members", "type": "address[]" }, + { "name": "name", "type": "string" }, + { "name": "nested", "type": "Inner" }, + { "name": "weights", "type": "uint64[3]" } + ] + }, + "primaryType": "Group", + "domain": { + "name": "Test", + "version": "1", + "chainId": 31337, + "verifyingContract": "0x0000000000000000000000000000000000000001" + }, + "message": { + "members": [ + "0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1", + "0xffcf8fdee72ac11b5c542428b35eef5769c409f0", + "0x627306090abab3a6e1400e9345bc60c78a8bef57" + ], + "name": "Team Rocket", + "nested": { + "flag": true, + "payload": "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + }, + "weights": ["1", "2", "3"] + } +} diff --git a/core/crates/signer/testdata/eip712_canonical_chain_id_1.json b/core/crates/signer/testdata/eip712_canonical_chain_id_1.json new file mode 100644 index 0000000000..50738996a2 --- /dev/null +++ b/core/crates/signer/testdata/eip712_canonical_chain_id_1.json @@ -0,0 +1,17 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "chainId", "type": "uint256" } + ], + "Message": [ + { "name": "content", "type": "string" } + ] + }, + "primaryType": "Message", + "domain": { + "chainId": 1 + }, + "message": { + "content": "Hello" + } +} diff --git a/core/crates/signer/testdata/eip712_canonical_chain_id_137.json b/core/crates/signer/testdata/eip712_canonical_chain_id_137.json new file mode 100644 index 0000000000..5720e61923 --- /dev/null +++ b/core/crates/signer/testdata/eip712_canonical_chain_id_137.json @@ -0,0 +1,17 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "chainId", "type": "uint256" } + ], + "Message": [ + { "name": "content", "type": "string" } + ] + }, + "primaryType": "Message", + "domain": { + "chainId": 137 + }, + "message": { + "content": "Hello" + } +} diff --git a/core/crates/signer/testdata/eip712_missing_message.json b/core/crates/signer/testdata/eip712_missing_message.json new file mode 100644 index 0000000000..4bd1179e5a --- /dev/null +++ b/core/crates/signer/testdata/eip712_missing_message.json @@ -0,0 +1,11 @@ +{ + "types": { + "EIP712Domain": [], + "Simple": [ + { "name": "value", "type": "uint256" } + ] + }, + "primaryType": "Simple", + "domain": {}, + "message": null +} diff --git a/core/crates/signer/testdata/eip712_reference_vector.json b/core/crates/signer/testdata/eip712_reference_vector.json new file mode 100644 index 0000000000..c04f4ffe56 --- /dev/null +++ b/core/crates/signer/testdata/eip712_reference_vector.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "Person": [ + { "name": "name", "type": "string" }, + { "name": "wallet", "type": "address" } + ], + "Mail": [ + { "name": "from", "type": "Person" }, + { "name": "to", "type": "Person" }, + { "name": "contents", "type": "string" } + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } +} diff --git a/core/crates/signer/testdata/eip712_signed_integers.json b/core/crates/signer/testdata/eip712_signed_integers.json new file mode 100644 index 0000000000..89e728c737 --- /dev/null +++ b/core/crates/signer/testdata/eip712_signed_integers.json @@ -0,0 +1,17 @@ +{ + "types": { + "EIP712Domain": [], + "Payload": [ + { "name": "balance", "type": "int256" }, + { "name": "delta", "type": "int32" }, + { "name": "active", "type": "bool" } + ] + }, + "primaryType": "Payload", + "domain": {}, + "message": { + "balance": "-0x0100", + "delta": -42, + "active": false + } +} diff --git a/core/crates/simulation/Cargo.toml b/core/crates/simulation/Cargo.toml new file mode 100644 index 0000000000..29a73baad3 --- /dev/null +++ b/core/crates/simulation/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "simulation" +version = { workspace = true } +edition = { workspace = true } + +[features] +default = [] +rpc = ["gem_evm/rpc", "dep:gem_client"] + +[dependencies] +primitives = { path = "../primitives" } +gem_evm = { path = "../gem_evm" } +gem_client = { path = "../gem_client", optional = true } + +alloy-sol-types = { workspace = true } +num-bigint = { workspace = true } +num-traits = { workspace = true } +strum = { workspace = true } + +[dev-dependencies] +alloy-primitives = { workspace = true } +primitives = { path = "../primitives", features = ["testkit"] } +gem_client = { path = "../gem_client", features = ["testkit"] } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["testkit"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/core/crates/simulation/src/evm/approval_method.rs b/core/crates/simulation/src/evm/approval_method.rs new file mode 100644 index 0000000000..d14574a868 --- /dev/null +++ b/core/crates/simulation/src/evm/approval_method.rs @@ -0,0 +1,63 @@ +use gem_evm::eip712::EIP712Message; +use strum::EnumString; + +const PERMIT2_DOMAIN_NAME: &str = "Permit2"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString)] +pub(crate) enum ApprovalMethod { + #[strum(serialize = "approve")] + Approve, + #[strum(serialize = "setApprovalForAll")] + SetApprovalForAll, + #[strum(serialize = "Permit")] + Permit, + #[strum(serialize = "PermitSingle")] + PermitSingle, + #[strum(serialize = "PermitBatch")] + PermitBatch, +} + +impl ApprovalMethod { + pub(crate) fn supports_value_display(&self) -> bool { + match self { + Self::Approve | Self::Permit | Self::PermitSingle => true, + Self::SetApprovalForAll | Self::PermitBatch => false, + } + } + + pub(crate) fn from_eip712(message: &EIP712Message) -> Option { + if let Ok(method) = message.primary_type.parse::() + && method.is_eip712_approval_method() + { + return Some(method); + } + + message + .domain + .name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(PERMIT2_DOMAIN_NAME)) + .then_some(Self::PermitSingle) + } + + fn is_eip712_approval_method(&self) -> bool { + match self { + Self::Permit | Self::PermitSingle | Self::PermitBatch => true, + Self::Approve | Self::SetApprovalForAll => false, + } + } +} + +impl std::fmt::Display for ApprovalMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + Self::Approve => "Approve", + Self::SetApprovalForAll => "Set Approval For All", + Self::Permit => "Permit", + Self::PermitSingle => "Permit Single", + Self::PermitBatch => "Permit Batch", + }; + + f.write_str(value) + } +} diff --git a/core/crates/simulation/src/evm/approval_request.rs b/core/crates/simulation/src/evm/approval_request.rs new file mode 100644 index 0000000000..0180743ca8 --- /dev/null +++ b/core/crates/simulation/src/evm/approval_request.rs @@ -0,0 +1,271 @@ +use num_bigint::BigInt; +use primitives::{ + AssetId, Chain, SimulationPayloadField, SimulationPayloadFieldDisplay, SimulationPayloadFieldKind, SimulationPayloadFieldType, SimulationResult, SimulationSeverity, + SimulationWarning, SimulationWarningApproval, SimulationWarningType, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use super::{approval_method::ApprovalMethod, approval_value::ApprovalValue}; +use gem_evm::ethereum_address_checksum; + +const EXCESSIVE_EXPIRATION_WINDOW: Duration = Duration::from_secs(60 * 60 * 24 * 30); + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ApprovalRequest { + asset_id: AssetId, + contract_address: String, + token_address: Option, + pub(crate) spender_address: String, + method: ApprovalMethod, + approval_value: Option, + display_expiration: Option, + warning_expiration: Option, +} + +impl ApprovalRequest { + pub(crate) fn erc20(chain: Chain, contract_address: &str, spender_address: String, approval_value: String) -> Option { + Self::new( + chain, + ApprovalContext { + contract_address: contract_address.to_string(), + spender_address, + token_address: None, + approval_value: ApprovalValue::from_raw(&approval_value), + display_expiration: None, + warning_expiration: None, + method: ApprovalMethod::Approve, + token_field: TokenField::AlwaysHide, + }, + ) + } + + pub(crate) fn nft_collection(chain: Chain, contract_address: &str, spender_address: String) -> Option { + Self::new( + chain, + ApprovalContext { + contract_address: contract_address.to_string(), + spender_address, + token_address: None, + approval_value: None, + display_expiration: None, + warning_expiration: None, + method: ApprovalMethod::SetApprovalForAll, + token_field: TokenField::AlwaysHide, + }, + ) + } + + pub(crate) fn permit( + chain: Chain, + contract_address: String, + spender_address: String, + approval_value: String, + expiration: Option, + token_address: Option, + method: ApprovalMethod, + ) -> Option { + Self::new( + chain, + ApprovalContext { + contract_address, + spender_address, + token_address, + approval_value: ApprovalValue::from_raw(&approval_value), + display_expiration: expiration.as_deref().map(str::parse).transpose().ok()?, + warning_expiration: expiration.as_deref().map(str::parse).transpose().ok()?, + method, + token_field: TokenField::HideWhenMatchingContract, + }, + ) + } + + pub(crate) fn permit_batch( + chain: Chain, + contract_address: String, + spender_address: String, + approval_value: ApprovalValue, + token_address: Option, + warning_expiration: Option, + ) -> Option { + Self::new( + chain, + ApprovalContext { + contract_address, + spender_address, + token_address, + approval_value: Some(approval_value), + display_expiration: None, + warning_expiration, + method: ApprovalMethod::PermitBatch, + token_field: TokenField::ShowWhenPresent, + }, + ) + } + + fn new(chain: Chain, context: ApprovalContext) -> Option { + let contract_address = ethereum_address_checksum(&context.contract_address).ok()?; + let spender_address = ethereum_address_checksum(&context.spender_address).ok()?; + let token_address = context.token_address.map(|value| ethereum_address_checksum(&value)).transpose().ok()?; + let asset_address = token_address.clone().unwrap_or_else(|| contract_address.clone()); + let token_address = match context.token_field { + TokenField::AlwaysHide => None, + TokenField::HideWhenMatchingContract if asset_address == contract_address => None, + TokenField::HideWhenMatchingContract | TokenField::ShowWhenPresent => token_address, + }; + + Some(Self { + asset_id: AssetId::from_token(chain, &asset_address), + contract_address, + token_address, + spender_address, + method: context.method, + approval_value: context.approval_value, + display_expiration: context.display_expiration, + warning_expiration: context.warning_expiration, + }) + } + + pub(crate) fn simulate(self) -> SimulationResult { + let warnings = self.warnings(); + self.build_simulation_result(warnings) + } + + pub(crate) fn build_simulation_result(self, warnings: Vec) -> SimulationResult { + let mut result = SimulationResult::new(warnings, self.payload()); + + if self.method.supports_value_display() + && let Some(approval_value) = self.approval_value + { + result.header = Some(approval_value.to_simulation_header(self.asset_id)); + } + + result + } + + pub(crate) fn primary_warning(&self) -> SimulationWarning { + let warning = match self.method { + ApprovalMethod::Approve => SimulationWarningType::TokenApproval(SimulationWarningApproval { + asset_id: self.asset_id.clone(), + value: self.warning_approval_value(), + }), + ApprovalMethod::SetApprovalForAll => SimulationWarningType::NftCollectionApproval(self.asset_id.clone()), + ApprovalMethod::Permit | ApprovalMethod::PermitSingle => SimulationWarningType::PermitApproval(SimulationWarningApproval { + asset_id: self.asset_id.clone(), + value: self.warning_approval_value(), + }), + ApprovalMethod::PermitBatch => SimulationWarningType::PermitBatchApproval(self.warning_approval_value()), + }; + + SimulationWarning::new( + match self.method { + ApprovalMethod::Approve => SimulationSeverity::Low, + ApprovalMethod::SetApprovalForAll | ApprovalMethod::Permit | ApprovalMethod::PermitSingle | ApprovalMethod::PermitBatch => SimulationSeverity::Warning, + }, + warning, + None, + ) + } + + pub(crate) fn warnings(&self) -> Vec { + let mut warnings = vec![self.primary_warning()]; + if let Some(warning) = self.expiration_warning() { + warnings.push(warning); + } + warnings + } + + fn warning_approval_value(&self) -> Option { + match self.approval_value.as_ref() { + Some(ApprovalValue::Exact(value)) => Some(BigInt::from(value.clone())), + Some(ApprovalValue::Unlimited) | None => None, + } + } + + pub(crate) fn expiration_warning(&self) -> Option { + let expiration = self.warning_expiration?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + if expiration <= now.saturating_add(EXCESSIVE_EXPIRATION_WINDOW.as_secs()) { + return None; + } + + Some(SimulationWarning::new( + SimulationSeverity::Warning, + SimulationWarningType::ValidationError, + Some("Excessive expiration".to_string()), + )) + } + + fn payload(&self) -> Vec { + let mut payload = vec![ + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Contract, + &self.contract_address, + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + ), + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Method, + self.method.to_string(), + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Primary, + ), + ]; + + if let Some(token_address) = self.token_address.as_deref() { + payload.push(SimulationPayloadField::standard( + SimulationPayloadFieldKind::Token, + token_address, + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + )); + } + + payload.push(SimulationPayloadField::standard( + SimulationPayloadFieldKind::Spender, + &self.spender_address, + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + )); + + if self.method.supports_value_display() + && let Some(approval_value) = self.approval_value.as_ref() + { + payload.push(SimulationPayloadField::standard( + SimulationPayloadFieldKind::Value, + approval_value.display_value(), + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Secondary, + )); + } + + if let Some(expiration) = self.display_expiration { + payload.push(SimulationPayloadField::custom( + "expiration", + expiration.to_string(), + SimulationPayloadFieldType::Timestamp, + SimulationPayloadFieldDisplay::Secondary, + )); + } + + payload + } +} + +#[derive(Debug, Clone)] +struct ApprovalContext { + contract_address: String, + spender_address: String, + token_address: Option, + approval_value: Option, + display_expiration: Option, + warning_expiration: Option, + method: ApprovalMethod, + token_field: TokenField, +} + +#[derive(Debug, Clone, Copy)] +enum TokenField { + AlwaysHide, + HideWhenMatchingContract, + ShowWhenPresent, +} diff --git a/core/crates/simulation/src/evm/approval_value.rs b/core/crates/simulation/src/evm/approval_value.rs new file mode 100644 index 0000000000..921cb23d1d --- /dev/null +++ b/core/crates/simulation/src/evm/approval_value.rs @@ -0,0 +1,52 @@ +use num_bigint::BigUint; +use num_traits::One; +use primitives::SimulationHeader; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum ApprovalValue { + Exact(BigUint), + Unlimited, +} + +impl ApprovalValue { + pub(crate) fn from_raw(raw_value: &str) -> Option { + let Ok(value) = raw_value.parse::() else { + return None; + }; + + if Self::is_unlimited(&value) { + return Some(Self::Unlimited); + } + Some(Self::Exact(value)) + } + + fn is_unlimited(value: &BigUint) -> bool { + Self::is_max_unsigned(value, 160) || Self::is_max_unsigned(value, 256) + } + + fn is_max_unsigned(value: &BigUint, bit_width: u32) -> bool { + value == &((BigUint::one() << bit_width) - BigUint::one()) + } + + pub(crate) fn display_value(&self) -> String { + match self { + Self::Exact(value) => value.to_string(), + Self::Unlimited => "Unlimited".to_string(), + } + } + + pub(crate) fn to_simulation_header(&self, asset_id: primitives::AssetId) -> SimulationHeader { + match self { + Self::Exact(value) => SimulationHeader { + asset_id, + value: value.to_string(), + is_unlimited: false, + }, + Self::Unlimited => SimulationHeader { + asset_id, + value: String::new(), + is_unlimited: true, + }, + } + } +} diff --git a/core/crates/simulation/src/evm/client.rs b/core/crates/simulation/src/evm/client.rs new file mode 100644 index 0000000000..395c02df29 --- /dev/null +++ b/core/crates/simulation/src/evm/client.rs @@ -0,0 +1,182 @@ +use std::error::Error; + +use gem_client::Client; +use gem_evm::{eip712::EIP712Message, rpc::EthereumClient}; +use primitives::hex; +use primitives::{Chain, SimulationResult, SimulationSeverity, SimulationWarning, SimulationWarningType}; + +use super::{ + approval_request::ApprovalRequest, + decode::{decode_eip712_approval, decode_evm_approval}, +}; + +pub struct SimulationClient<'a, C: Client + Clone> { + ethereum_client: &'a EthereumClient, +} + +impl<'a, C: Client + Clone> SimulationClient<'a, C> { + pub fn new(ethereum_client: &'a EthereumClient) -> Self { + Self { ethereum_client } + } + + pub async fn simulate_eip712_message(&self, chain: Chain, message: &EIP712Message) -> Result> { + match decode_eip712_approval(chain, message) { + Some(approval) => self.simulate_approval(approval).await, + None => Ok(SimulationResult::default()), + } + } + + pub async fn simulate_evm_calldata(&self, chain: Chain, calldata: &[u8], contract_address: &str) -> Result> { + match decode_evm_approval(chain, calldata, contract_address) { + Some(approval) => self.simulate_approval(approval).await, + None => Ok(super::decode::simulate_evm_calldata(chain, calldata, contract_address)), + } + } + + async fn simulate_approval(&self, approval: ApprovalRequest) -> Result> { + let warnings = self.approval_warnings(&approval).await?.into_iter().chain(approval.expiration_warning()).collect(); + Ok(approval.build_simulation_result(warnings)) + } + + async fn approval_warnings(&self, approval: &ApprovalRequest) -> Result, Box> { + if self.spender_is_externally_owned(&approval.spender_address).await? { + return Ok(vec![SimulationWarning::new( + SimulationSeverity::Critical, + SimulationWarningType::ExternallyOwnedSpender, + None, + )]); + } + + Ok(vec![approval.primary_warning()]) + } + + async fn spender_is_externally_owned(&self, spender_address: &str) -> Result> { + let code = self.ethereum_client.get_code(spender_address).await?; + let bytecode = hex::decode_hex(&code)?; + Ok(bytecode.is_empty() || bytecode.iter().all(|byte| *byte == 0)) + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use alloy_primitives::{U256, address}; + use alloy_sol_types::SolCall; + use gem_evm::eip712::parse_eip712_json; + use gem_evm::rpc::EthereumClient; + use gem_jsonrpc::testkit::mock_jsonrpc_client; + use primitives::{Chain, EVMChain, SimulationSeverity, SimulationWarning, SimulationWarningType, asset_constants::ETHEREUM_USDC_TOKEN_ID}; + use serde_json::Value; + + use super::SimulationClient; + + #[tokio::test] + async fn eip712_permit_with_externally_owned_spender_adds_critical_warning() -> Result<(), Box> { + let json: Value = serde_json::from_str(include_str!("../../../gem_evm/testdata/1inch_permit.json"))?; + let message = parse_eip712_json(&json)?; + let client = ethereum_client("0x"); + + let result = SimulationClient::new(&client).simulate_eip712_message(Chain::Ethereum, &message).await?; + + assert_eq!(result.warnings.len(), 1); + assert_eq!( + result.warnings.first(), + Some(&SimulationWarning { + severity: SimulationSeverity::Critical, + warning: SimulationWarningType::ExternallyOwnedSpender, + message: None, + }) + ); + + Ok(()) + } + + #[tokio::test] + async fn erc20_approve_with_contract_spender_does_not_add_externally_owned_warning() -> Result<(), Box> { + let calldata = gem_evm::contracts::IERC20::approveCall { + spender: address!("3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"), + value: U256::from(1000u64), + } + .abi_encode(); + + let client = ethereum_client("0x1234"); + let result = SimulationClient::new(&client) + .simulate_evm_calldata(Chain::Ethereum, &calldata, ETHEREUM_USDC_TOKEN_ID) + .await?; + + assert_eq!(result.warnings.len(), 1); + assert_ne!(result.warnings[0].warning, SimulationWarningType::ExternallyOwnedSpender); + + Ok(()) + } + + #[tokio::test] + async fn invalid_spender_code_response_returns_error() { + let json: Value = serde_json::from_str(include_str!("../../../gem_evm/testdata/1inch_permit.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let client = ethereum_client("0xzz"); + + let result = SimulationClient::new(&client).simulate_eip712_message(Chain::Ethereum, &message).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn zero_filled_spender_code_is_treated_as_externally_owned() -> Result<(), Box> { + let json: Value = serde_json::from_str(include_str!("../../../gem_evm/testdata/1inch_permit.json"))?; + let message = parse_eip712_json(&json)?; + let client = ethereum_client("0x00"); + + let result = SimulationClient::new(&client).simulate_eip712_message(Chain::Ethereum, &message).await?; + + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].warning, SimulationWarningType::ExternallyOwnedSpender); + + Ok(()) + } + + #[tokio::test] + async fn eip712_permit_with_excessive_expiration_keeps_warning_with_client() -> Result<(), Box> { + let json: Value = serde_json::from_str(include_str!("../../testdata/permit_excessive_expiration.json"))?; + let message = parse_eip712_json(&json)?; + let client = ethereum_client("0x1234"); + + let result = SimulationClient::new(&client).simulate_eip712_message(Chain::Ethereum, &message).await?; + + assert_eq!(result.warnings.len(), 2); + assert_eq!(result.warnings[1].warning, SimulationWarningType::ValidationError); + assert_eq!(result.warnings[1].message.as_deref(), Some("Excessive expiration")); + + Ok(()) + } + + #[tokio::test] + async fn eip712_permit_batch_with_externally_owned_spender_adds_critical_warning() -> Result<(), Box> { + let json: Value = serde_json::from_str(include_str!("../../testdata/permit_batch_multiple_tokens.json"))?; + let message = parse_eip712_json(&json)?; + let client = ethereum_client("0x"); + + let result = SimulationClient::new(&client).simulate_eip712_message(Chain::Ethereum, &message).await?; + + assert_eq!(result.warnings.len(), 1); + assert_eq!( + result.warnings.first(), + Some(&SimulationWarning { + severity: SimulationSeverity::Critical, + warning: SimulationWarningType::ExternallyOwnedSpender, + message: None, + }) + ); + + Ok(()) + } + fn ethereum_client(code: &str) -> EthereumClient { + let code = code.to_string(); + let client = mock_jsonrpc_client(move |method, _| match method { + "eth_getCode" => Ok(Value::from(code.clone())), + _ => Ok(Value::Null), + }); + EthereumClient::new(client, EVMChain::Ethereum) + } +} diff --git a/core/crates/simulation/src/evm/decode.rs b/core/crates/simulation/src/evm/decode.rs new file mode 100644 index 0000000000..ea3e4e6555 --- /dev/null +++ b/core/crates/simulation/src/evm/decode.rs @@ -0,0 +1,467 @@ +use alloy_sol_types::SolCall; +use num_bigint::BigUint; +use primitives::{Chain, SimulationPayloadField, SimulationPayloadFieldDisplay, SimulationPayloadFieldKind, SimulationPayloadFieldType, SimulationResult}; + +use super::{approval_method::ApprovalMethod, approval_request::ApprovalRequest, approval_value::ApprovalValue}; +use gem_evm::{ + contracts::{IERC20, IERC721, IERC1155}, + eip712::{EIP712Field, EIP712Message, EIP712TypedValue}, + ethereum_address_checksum, +}; + +pub fn simulate_eip712_message(chain: Chain, message: &EIP712Message) -> SimulationResult { + match decode_eip712_approval(chain, message) { + Some(approval) => approval.simulate(), + None => SimulationResult::default(), + } +} + +pub fn simulate_evm_calldata(chain: Chain, calldata: &[u8], contract_address: &str) -> SimulationResult { + match decode_evm_approval(chain, calldata, contract_address) { + Some(approval) => approval.simulate(), + None => { + let address = ethereum_address_checksum(contract_address).unwrap_or_else(|_| contract_address.to_string()); + SimulationResult::new( + vec![], + vec![SimulationPayloadField::standard( + SimulationPayloadFieldKind::Contract, + address, + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + )], + ) + } + } +} + +pub(crate) fn decode_eip712_approval(chain: Chain, message: &EIP712Message) -> Option { + let contract_address = message.domain.verifying_contract.clone()?; + let method = ApprovalMethod::from_eip712(message)?; + + match method { + ApprovalMethod::Permit => decode_permit_approval(chain, message, contract_address, method), + ApprovalMethod::PermitSingle | ApprovalMethod::PermitBatch => decode_permit2_approval(chain, message, contract_address, method), + ApprovalMethod::Approve | ApprovalMethod::SetApprovalForAll => None, + } +} + +pub(crate) fn decode_evm_approval(chain: Chain, calldata: &[u8], contract_address: &str) -> Option { + if calldata.len() < 4 { + return None; + } + + if calldata.starts_with(&::SELECTOR) + && let Ok(call) = ::abi_decode(calldata) + { + return ApprovalRequest::erc20(chain, contract_address, format!("{:#x}", call.spender), call.value.to_string()); + } + + if calldata.starts_with(&::SELECTOR) + && let Ok(call) = ::abi_decode(calldata) + && call.approved + { + return ApprovalRequest::nft_collection(chain, contract_address, format!("{:#x}", call.operator)); + } + + if calldata.starts_with(&::SELECTOR) + && let Ok(call) = ::abi_decode(calldata) + && call.approved + { + return ApprovalRequest::nft_collection(chain, contract_address, format!("{:#x}", call.operator)); + } + + None +} + +fn find_field_string(fields: &[EIP712Field], name: &str) -> Option { + fields.iter().find(|field| field.name == name).and_then(|field| match &field.value { + EIP712TypedValue::Address { value } | EIP712TypedValue::Uint256 { value } | EIP712TypedValue::String { value } => Some(value.clone()), + EIP712TypedValue::Struct { .. } | EIP712TypedValue::Int256 { .. } | EIP712TypedValue::Bool { .. } | EIP712TypedValue::Bytes { .. } | EIP712TypedValue::Array { .. } => None, + }) +} + +fn find_field_u64(fields: &[EIP712Field], name: &str) -> Option { + find_field_string(fields, name)?.parse().ok() +} + +fn find_field_struct<'a>(fields: &'a [EIP712Field], name: &str) -> Option<&'a [EIP712Field]> { + fields.iter().find(|field| field.name == name).and_then(|field| match &field.value { + EIP712TypedValue::Struct { fields } => Some(fields.as_slice()), + EIP712TypedValue::Address { .. } + | EIP712TypedValue::Uint256 { .. } + | EIP712TypedValue::Int256 { .. } + | EIP712TypedValue::String { .. } + | EIP712TypedValue::Bool { .. } + | EIP712TypedValue::Bytes { .. } + | EIP712TypedValue::Array { .. } => None, + }) +} + +fn find_field_struct_array<'a>(fields: &'a [EIP712Field], name: &str) -> Option> { + fields.iter().find(|field| field.name == name).and_then(|field| match &field.value { + EIP712TypedValue::Array { items } => Some( + items + .iter() + .filter_map(|item| match item { + EIP712TypedValue::Struct { fields } => Some(fields.as_slice()), + EIP712TypedValue::Address { .. } + | EIP712TypedValue::Uint256 { .. } + | EIP712TypedValue::Int256 { .. } + | EIP712TypedValue::String { .. } + | EIP712TypedValue::Bool { .. } + | EIP712TypedValue::Bytes { .. } + | EIP712TypedValue::Array { .. } => None, + }) + .collect(), + ), + EIP712TypedValue::Address { .. } + | EIP712TypedValue::Uint256 { .. } + | EIP712TypedValue::Struct { .. } + | EIP712TypedValue::Int256 { .. } + | EIP712TypedValue::String { .. } + | EIP712TypedValue::Bool { .. } + | EIP712TypedValue::Bytes { .. } => None, + }) +} + +fn decode_permit_approval(chain: Chain, message: &EIP712Message, contract_address: String, method: ApprovalMethod) -> Option { + ApprovalRequest::permit( + chain, + contract_address, + find_field_string(&message.message, "spender")?, + find_field_string(&message.message, "value")?, + find_field_string(&message.message, "deadline"), + None, + method, + ) +} + +fn decode_permit2_approval(chain: Chain, message: &EIP712Message, contract_address: String, method: ApprovalMethod) -> Option { + if method == ApprovalMethod::PermitBatch { + return decode_permit2_batch_approval(chain, message, contract_address); + } + + let details = find_permit_details(&message.message)?; + let approval_value = find_field_string(details, "amount").or_else(|| find_field_string(details, "value"))?; + let expiration = find_field_string(details, "expiration") + .or_else(|| find_field_string(&message.message, "sigDeadline")) + .or_else(|| find_field_string(&message.message, "deadline")); + + ApprovalRequest::permit( + chain, + contract_address, + find_field_string(&message.message, "spender")?, + approval_value, + expiration, + find_field_string(details, "token"), + method, + ) +} + +fn decode_permit2_batch_approval(chain: Chain, message: &EIP712Message, contract_address: String) -> Option { + let details = find_field_struct_array(&message.message, "details")?; + if details.is_empty() { + return None; + } + + let spender_address = find_field_string(&message.message, "spender")?; + let token_address = single_token_address(&details); + let warning_expiration = batch_warning_expiration(&details, &message.message); + let mut total_value = BigUint::ZERO; + + for detail in details { + let raw_value = find_field_string(detail, "amount").or_else(|| find_field_string(detail, "value"))?; + match ApprovalValue::from_raw(&raw_value)? { + ApprovalValue::Unlimited => { + return ApprovalRequest::permit_batch(chain, contract_address, spender_address, ApprovalValue::Unlimited, token_address, warning_expiration); + } + ApprovalValue::Exact(value) => { + total_value += value; + } + } + } + + ApprovalRequest::permit_batch( + chain, + contract_address, + spender_address, + ApprovalValue::Exact(total_value), + token_address, + warning_expiration, + ) +} + +fn find_permit_details(fields: &[EIP712Field]) -> Option<&[EIP712Field]> { + if let Some(details) = find_field_struct(fields, "details") { + return Some(details); + } + + if let Some(details) = find_field_struct(fields, "permitted") { + return Some(details); + } + + fields.iter().find_map(|field| match &field.value { + EIP712TypedValue::Struct { fields } => { + let has_value = find_field_string(fields, "amount").is_some() || find_field_string(fields, "value").is_some(); + if has_value { + return Some(fields.as_slice()); + } + None + } + EIP712TypedValue::Address { .. } + | EIP712TypedValue::Uint256 { .. } + | EIP712TypedValue::Int256 { .. } + | EIP712TypedValue::String { .. } + | EIP712TypedValue::Bool { .. } + | EIP712TypedValue::Bytes { .. } + | EIP712TypedValue::Array { .. } => None, + }) +} + +fn single_token_address(details: &[&[EIP712Field]]) -> Option { + let mut token_address: Option = None; + + for detail in details { + let next_token_address = find_field_string(detail, "token")?; + let next_token_address = ethereum_address_checksum(&next_token_address).ok()?; + + match token_address.as_ref() { + Some(current_token_address) if current_token_address != &next_token_address => return None, + Some(_) => {} + None => token_address = Some(next_token_address), + } + } + + token_address +} + +fn batch_warning_expiration(details: &[&[EIP712Field]], message_fields: &[EIP712Field]) -> Option { + details + .iter() + .filter_map(|detail| find_field_u64(detail, "expiration")) + .chain(find_field_u64(message_fields, "sigDeadline").into_iter().chain(find_field_u64(message_fields, "deadline"))) + .max() +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{U256, address}; + use alloy_sol_types::SolCall; + use gem_evm::eip712::parse_eip712_json; + use primitives::{ + Chain, SimulationPayloadFieldKind, SimulationResult, SimulationWarningType, + asset_constants::{ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDC_TOKEN_ID}, + contract_constants::UNISWAP_PERMIT2_CONTRACT, + }; + use serde_json::Value; + + use super::{decode_eip712_approval, simulate_eip712_message, simulate_evm_calldata}; + + fn warning(result: &SimulationResult) -> &SimulationWarningType { + assert_eq!(result.warnings.len(), 1); + &result.warnings[0].warning + } + + fn warning_messages(result: &SimulationResult) -> Vec> { + result.warnings.iter().map(|warning| warning.message.as_deref()).collect() + } + + fn is_permit_warning(warning: &SimulationWarningType) -> bool { + match warning { + SimulationWarningType::PermitApproval(_) | SimulationWarningType::PermitBatchApproval(_) => true, + _ => false, + } + } + + fn is_unlimited_permit_warning(warning: &SimulationWarningType) -> bool { + match warning { + SimulationWarningType::PermitApproval(a) => a.value.is_none(), + SimulationWarningType::PermitBatchApproval(v) => v.is_none(), + _ => false, + } + } + + fn is_token_warning(warning: &SimulationWarningType) -> bool { + match warning { + SimulationWarningType::TokenApproval(_) => true, + _ => false, + } + } + + fn is_nft_warning(warning: &SimulationWarningType) -> bool { + match warning { + SimulationWarningType::NftCollectionApproval(_) => true, + _ => false, + } + } + + #[test] + fn eip712_permit_simulation_result_contains_payload_and_warnings() { + let json: Value = serde_json::from_str(include_str!("../../../gem_evm/testdata/1inch_permit.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let result = simulate_eip712_message(Chain::Ethereum, &message); + + assert!(is_permit_warning(warning(&result))); + assert_eq!(result.payload[0].kind, SimulationPayloadFieldKind::Contract); + assert_eq!(result.payload[1].value, "Permit"); + assert_eq!(result.payload[2].kind, SimulationPayloadFieldKind::Spender); + assert_eq!(result.payload[3].value, "Unlimited"); + assert_eq!(result.payload[4].kind, SimulationPayloadFieldKind::Custom); + assert_eq!(result.payload[4].label.as_deref(), Some("expiration")); + assert_eq!(result.header.as_ref().map(|header| header.asset_id.clone()), Some(ETHEREUM_USDC_ASSET_ID.clone())); + assert_eq!(result.header.as_ref().map(|header| header.is_unlimited), Some(true)); + } + + #[test] + fn eip712_permit_with_excessive_expiration_adds_warning_message() { + let json: Value = serde_json::from_str(include_str!("../../testdata/permit_excessive_expiration.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let result = simulate_eip712_message(Chain::Ethereum, &message); + + assert_eq!(result.warnings.len(), 2); + assert!(is_permit_warning(&result.warnings[0].warning)); + assert_eq!(warning_messages(&result), vec![None, Some("Excessive expiration")]); + } + + #[test] + fn eip712_permit2_simulation_result_contains_payload_and_warnings() { + let json: Value = serde_json::from_str(include_str!("../../../gem_evm/testdata/uniswap_permit2.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let result = simulate_eip712_message(Chain::Ethereum, &message); + + assert!(is_unlimited_permit_warning(warning(&result))); + assert_eq!(result.payload[0].kind, SimulationPayloadFieldKind::Contract); + assert_eq!(result.payload[1].value, "Permit Single"); + assert_eq!(result.payload[2].kind, SimulationPayloadFieldKind::Token); + assert_eq!(result.payload[3].kind, SimulationPayloadFieldKind::Spender); + assert_eq!(result.payload[4].kind, SimulationPayloadFieldKind::Value); + assert_eq!(result.payload[4].value, "Unlimited"); + assert_eq!(result.payload[5].label.as_deref(), Some("expiration")); + } + + #[test] + fn eip712_permit2_batch_sums_finite_values() { + let json: Value = serde_json::from_str(include_str!("../../testdata/permit_batch_multiple_tokens.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let result = simulate_eip712_message(Chain::Ethereum, &message); + + assert!(is_permit_warning(warning(&result))); + assert_eq!(result.payload[1].value, "Permit Batch"); + assert!(result.payload.iter().all(|field| match field.kind { + SimulationPayloadFieldKind::Token => false, + SimulationPayloadFieldKind::Contract + | SimulationPayloadFieldKind::Method + | SimulationPayloadFieldKind::Spender + | SimulationPayloadFieldKind::Value + | SimulationPayloadFieldKind::Custom => true, + })); + assert_eq!(result.header, None); + } + + #[test] + fn eip712_permit2_batch_without_single_token_does_not_claim_warning_asset() { + let json: Value = serde_json::from_str(include_str!("../../testdata/permit_batch_multiple_tokens.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let result = simulate_eip712_message(Chain::Ethereum, &message); + + assert_eq!(warning(&result), &SimulationWarningType::PermitBatchApproval(Some(3000000000000000000_u128.into()))); + } + + #[test] + fn eip712_permit2_batch_preserves_message_spender_address() { + let json: Value = serde_json::from_str(include_str!("../../testdata/permit_batch_single_token.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let approval = decode_eip712_approval(Chain::Ethereum, &message).unwrap(); + + assert_eq!(approval.spender_address, "0x3333333333333333333333333333333333333333"); + assert_ne!(approval.spender_address, UNISWAP_PERMIT2_CONTRACT); + } + + #[test] + fn eip712_permit2_batch_shows_token_when_all_items_share_token() { + let json: Value = serde_json::from_str(include_str!("../../testdata/permit_batch_shared_token.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let result = simulate_eip712_message(Chain::Ethereum, &message); + + assert_eq!(result.payload[2].kind, SimulationPayloadFieldKind::Token); + assert_eq!(result.payload[2].value, "0x1111111111111111111111111111111111111111"); + assert_eq!(result.payload[3].kind, SimulationPayloadFieldKind::Spender); + } + + #[test] + fn eip712_permit2_batch_with_excessive_expiration_adds_warning_message() { + let json: Value = serde_json::from_str(include_str!("../../testdata/permit_batch_excessive_expiration.json")).unwrap(); + let message = parse_eip712_json(&json).unwrap(); + let result = simulate_eip712_message(Chain::Ethereum, &message); + + assert_eq!(warning_messages(&result), vec![None, Some("Excessive expiration")]); + } + + #[test] + fn erc20_approve_simulation_result_contains_payload_and_warnings() { + let spender = address!("1111111111111111111111111111111111111111"); + let value = U256::MAX; + let calldata = gem_evm::contracts::IERC20::approveCall { spender, value }.abi_encode(); + + let result = simulate_evm_calldata(Chain::Ethereum, &calldata, ETHEREUM_USDC_TOKEN_ID); + + assert!(is_token_warning(warning(&result))); + assert_eq!(result.payload[0].kind, SimulationPayloadFieldKind::Contract); + assert_eq!(result.payload[1].value, "Approve"); + assert_eq!(result.payload[2].kind, SimulationPayloadFieldKind::Spender); + assert_eq!(result.payload[3].kind, SimulationPayloadFieldKind::Value); + assert_eq!(result.payload[3].value, "Unlimited"); + } + + #[test] + fn erc20_approve_requires_matching_selector() { + let spender = address!("1111111111111111111111111111111111111111"); + let value = U256::MAX; + let mut calldata = gem_evm::contracts::IERC20::approveCall { spender, value }.abi_encode(); + calldata[..4].copy_from_slice(&[0_u8; 4]); + + let result = simulate_evm_calldata(Chain::Ethereum, &calldata, ETHEREUM_USDC_TOKEN_ID); + + assert_eq!(result.warnings.len(), 0); + assert_eq!(result.payload.len(), 1); + assert_eq!(result.payload[0].kind, SimulationPayloadFieldKind::Contract); + } + + #[test] + fn erc721_set_approval_for_all_simulation_result_contains_payload_and_warnings() { + let operator = address!("1111111111111111111111111111111111111111"); + let calldata = gem_evm::contracts::IERC721::setApprovalForAllCall { operator, approved: true }.abi_encode(); + + let result = simulate_evm_calldata(Chain::Ethereum, &calldata, "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85"); + + assert!(is_nft_warning(warning(&result))); + assert_eq!(result.payload[0].kind, SimulationPayloadFieldKind::Contract); + assert_eq!(result.payload[1].value, "Set Approval For All"); + assert_eq!(result.payload[2].kind, SimulationPayloadFieldKind::Spender); + } + + #[test] + fn erc721_set_approval_for_all_false_returns_empty_result() { + let operator = address!("1111111111111111111111111111111111111111"); + let calldata = gem_evm::contracts::IERC721::setApprovalForAllCall { operator, approved: false }.abi_encode(); + + let result = simulate_evm_calldata(Chain::Ethereum, &calldata, "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85"); + + assert_eq!(result.warnings.len(), 0); + assert_eq!(result.payload.len(), 1); + assert_eq!(result.payload[0].kind, SimulationPayloadFieldKind::Contract); + } + + #[test] + fn erc1155_set_approval_for_all_simulation_result_contains_payload_and_warnings() { + let operator = address!("1111111111111111111111111111111111111111"); + let calldata = gem_evm::contracts::IERC1155::setApprovalForAllCall { operator, approved: true }.abi_encode(); + + let result = simulate_evm_calldata(Chain::Ethereum, &calldata, "0x495f947276749Ce646f68AC8c248420045cb7b5e"); + + assert!(is_nft_warning(warning(&result))); + assert_eq!(result.payload[0].kind, SimulationPayloadFieldKind::Contract); + assert_eq!(result.payload[1].value, "Set Approval For All"); + assert_eq!(result.payload[2].kind, SimulationPayloadFieldKind::Spender); + } +} diff --git a/core/crates/simulation/src/evm/mod.rs b/core/crates/simulation/src/evm/mod.rs new file mode 100644 index 0000000000..f70912dee7 --- /dev/null +++ b/core/crates/simulation/src/evm/mod.rs @@ -0,0 +1,12 @@ +mod approval_method; +mod approval_request; +mod approval_value; +mod decode; + +#[cfg(feature = "rpc")] +mod client; + +pub use decode::{simulate_eip712_message, simulate_evm_calldata}; + +#[cfg(feature = "rpc")] +pub use client::SimulationClient; diff --git a/core/crates/simulation/src/lib.rs b/core/crates/simulation/src/lib.rs new file mode 100644 index 0000000000..c469d0c8ed --- /dev/null +++ b/core/crates/simulation/src/lib.rs @@ -0,0 +1 @@ +pub mod evm; diff --git a/core/crates/simulation/testdata/permit_batch_excessive_expiration.json b/core/crates/simulation/testdata/permit_batch_excessive_expiration.json new file mode 100644 index 0000000000..e20a7eac7e --- /dev/null +++ b/core/crates/simulation/testdata/permit_batch_excessive_expiration.json @@ -0,0 +1,44 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "PermitBatch": [ + { "name": "details", "type": "PermitDetails[]" }, + { "name": "spender", "type": "address" }, + { "name": "sigDeadline", "type": "uint256" } + ], + "PermitDetails": [ + { "name": "token", "type": "address" }, + { "name": "amount", "type": "uint160" }, + { "name": "expiration", "type": "uint48" }, + { "name": "nonce", "type": "uint48" } + ] + }, + "primaryType": "PermitBatch", + "domain": { + "name": "Permit2", + "chainId": "1", + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": [ + { + "token": "0x1111111111111111111111111111111111111111", + "amount": "1000000000000000000", + "expiration": "9999999999", + "nonce": "0" + }, + { + "token": "0x2222222222222222222222222222222222222222", + "amount": "2000000000000000000", + "expiration": "9999999998", + "nonce": "1" + } + ], + "spender": "0x3333333333333333333333333333333333333333", + "sigDeadline": "9999999997" + } +} diff --git a/core/crates/simulation/testdata/permit_batch_multiple_tokens.json b/core/crates/simulation/testdata/permit_batch_multiple_tokens.json new file mode 100644 index 0000000000..beca7ba7cd --- /dev/null +++ b/core/crates/simulation/testdata/permit_batch_multiple_tokens.json @@ -0,0 +1,44 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "PermitBatch": [ + { "name": "details", "type": "PermitDetails[]" }, + { "name": "spender", "type": "address" }, + { "name": "sigDeadline", "type": "uint256" } + ], + "PermitDetails": [ + { "name": "token", "type": "address" }, + { "name": "amount", "type": "uint160" }, + { "name": "expiration", "type": "uint48" }, + { "name": "nonce", "type": "uint48" } + ] + }, + "primaryType": "PermitBatch", + "domain": { + "name": "Permit2", + "chainId": "1", + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": [ + { + "token": "0x1111111111111111111111111111111111111111", + "amount": "1000000000000000000", + "expiration": "1712600000", + "nonce": "0" + }, + { + "token": "0x2222222222222222222222222222222222222222", + "amount": "2000000000000000000", + "expiration": "1712600001", + "nonce": "1" + } + ], + "spender": "0x3333333333333333333333333333333333333333", + "sigDeadline": "1712600500" + } +} diff --git a/core/crates/simulation/testdata/permit_batch_shared_token.json b/core/crates/simulation/testdata/permit_batch_shared_token.json new file mode 100644 index 0000000000..7ac3a4ee00 --- /dev/null +++ b/core/crates/simulation/testdata/permit_batch_shared_token.json @@ -0,0 +1,44 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "PermitBatch": [ + { "name": "details", "type": "PermitDetails[]" }, + { "name": "spender", "type": "address" }, + { "name": "sigDeadline", "type": "uint256" } + ], + "PermitDetails": [ + { "name": "token", "type": "address" }, + { "name": "amount", "type": "uint160" }, + { "name": "expiration", "type": "uint48" }, + { "name": "nonce", "type": "uint48" } + ] + }, + "primaryType": "PermitBatch", + "domain": { + "name": "Permit2", + "chainId": "1", + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": [ + { + "token": "0x1111111111111111111111111111111111111111", + "amount": "1000000000000000000", + "expiration": "1712600000", + "nonce": "0" + }, + { + "token": "0x1111111111111111111111111111111111111111", + "amount": "2000000000000000000", + "expiration": "1712600001", + "nonce": "1" + } + ], + "spender": "0x3333333333333333333333333333333333333333", + "sigDeadline": "1712600500" + } +} diff --git a/core/crates/simulation/testdata/permit_batch_single_token.json b/core/crates/simulation/testdata/permit_batch_single_token.json new file mode 100644 index 0000000000..0ffa118a89 --- /dev/null +++ b/core/crates/simulation/testdata/permit_batch_single_token.json @@ -0,0 +1,38 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "PermitBatch": [ + { "name": "details", "type": "PermitDetails[]" }, + { "name": "spender", "type": "address" }, + { "name": "sigDeadline", "type": "uint256" } + ], + "PermitDetails": [ + { "name": "token", "type": "address" }, + { "name": "amount", "type": "uint160" }, + { "name": "expiration", "type": "uint48" }, + { "name": "nonce", "type": "uint48" } + ] + }, + "primaryType": "PermitBatch", + "domain": { + "name": "Permit2", + "chainId": "1", + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": [ + { + "token": "0x1111111111111111111111111111111111111111", + "amount": "1000000000000000000", + "expiration": "1712600000", + "nonce": "0" + } + ], + "spender": "0x3333333333333333333333333333333333333333", + "sigDeadline": "1712600500" + } +} diff --git a/core/crates/simulation/testdata/permit_excessive_expiration.json b/core/crates/simulation/testdata/permit_excessive_expiration.json new file mode 100644 index 0000000000..61cce0940d --- /dev/null +++ b/core/crates/simulation/testdata/permit_excessive_expiration.json @@ -0,0 +1,31 @@ +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "Permit": [ + { "name": "owner", "type": "address" }, + { "name": "spender", "type": "address" }, + { "name": "value", "type": "uint256" }, + { "name": "nonce", "type": "uint256" }, + { "name": "deadline", "type": "uint256" } + ] + }, + "primaryType": "Permit", + "domain": { + "name": "USD Coin", + "version": "2", + "chainId": "1", + "verifyingContract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "message": { + "owner": "0x1111111111111111111111111111111111111111", + "spender": "0x2222222222222222222222222222222222222222", + "value": "1000", + "nonce": "0", + "deadline": "9999999999" + } +} diff --git a/core/crates/storage/Cargo.toml b/core/crates/storage/Cargo.toml new file mode 100644 index 0000000000..fc7814361b --- /dev/null +++ b/core/crates/storage/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "storage" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +r2d2 = { workspace = true } + +diesel = { version = "2.3.5", features = ["postgres", "chrono", "serde_json", "r2d2"] } +diesel_migrations = { version = "2.3.1" } + +primitives = { path = "../primitives" } diff --git a/core/crates/storage/src/config_cacher.rs b/core/crates/storage/src/config_cacher.rs new file mode 100644 index 0000000000..1489298963 --- /dev/null +++ b/core/crates/storage/src/config_cacher.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, NaiveDateTime}; +use primitives::{ConfigKey, ConfigParamKey}; +use serde::de::DeserializeOwned; + +use crate::database::config::ConfigStore; +use crate::repositories::config_repository::ConfigRepository; +use crate::{Database, DatabaseError}; + +const DEFAULT_TTL_SECONDS: u64 = 60; + +fn parse_duration(value: &str) -> Result { + primitives::parse_duration(value).ok_or_else(|| DatabaseError::Error(format!("Failed to parse duration: {value}"))) +} + +struct CachedValue { + value: String, + expires_at: Instant, +} + +pub struct ConfigCacher { + database: Database, + cache: RwLock>, + ttl: Duration, +} + +impl ConfigCacher { + pub fn new(database: Database) -> Self { + Self { + database, + cache: RwLock::new(HashMap::new()), + ttl: Duration::from_secs(DEFAULT_TTL_SECONDS), + } + } + + fn get_cached(&self, key: &ConfigKey) -> Option { + let cache = self.cache.read().ok()?; + let cached = cache.get(key)?; + if cached.expires_at > Instant::now() { Some(cached.value.clone()) } else { None } + } + + fn set_cached(&self, key: ConfigKey, value: String) { + if let Ok(mut cache) = self.cache.write() { + cache.insert( + key, + CachedValue { + value, + expires_at: Instant::now() + self.ttl, + }, + ); + } + } + + pub fn get(&self, key: ConfigKey) -> Result { + if let Some(value) = self.get_cached(&key) { + return Ok(value); + } + let value = self.database.client().map_err(|e| DatabaseError::Error(e.to_string()))?.get_config(key.clone())?; + self.set_cached(key, value.clone()); + Ok(value) + } + + pub fn get_i32(&self, key: ConfigKey) -> Result { + Ok(self.get(key)?.parse()?) + } + + pub fn get_i64(&self, key: ConfigKey) -> Result { + Ok(self.get(key)?.parse()?) + } + + pub fn get_usize(&self, key: ConfigKey) -> Result { + Ok(self.get(key)?.parse()?) + } + + pub fn get_f64(&self, key: ConfigKey) -> Result { + Ok(self.get(key)?.parse()?) + } + + pub fn get_bool(&self, key: ConfigKey) -> Result { + Ok(self.get(key)?.parse()?) + } + + pub fn get_duration(&self, key: ConfigKey) -> Result { + parse_duration(&self.get(key)?) + } + + pub fn get_param_duration(&self, param: &ConfigParamKey) -> Result { + parse_duration(&self.get_param_value(param)) + } + + pub fn get_param_f64(&self, param: &ConfigParamKey) -> Result { + Ok(self.get_param_value(param).parse()?) + } + + pub fn get_datetime(&self, key: ConfigKey) -> Result { + let ts = self.get_i64(key)?; + DateTime::from_timestamp(ts, 0) + .map(|dt| dt.naive_utc()) + .ok_or_else(|| DatabaseError::Error(format!("Invalid timestamp: {}", ts))) + } + + pub fn set_datetime(&self, key: ConfigKey, time: NaiveDateTime) -> Result { + let ts = time.and_utc().timestamp(); + self.set(key, &ts.to_string()) + } + + pub fn get_vec_string(&self, key: ConfigKey) -> Result, DatabaseError> { + self.get_vec(key) + } + + pub fn get_vec(&self, key: ConfigKey) -> Result, DatabaseError> { + Ok(serde_json::from_str(&self.get(key)?)?) + } + + pub fn set(&self, key: ConfigKey, value: &str) -> Result { + self.invalidate(&key); + ConfigRepository::set_config(&mut self.database.client().map_err(|e| DatabaseError::Error(e.to_string()))?, key, value) + } + + pub fn invalidate(&self, key: &ConfigKey) { + if let Ok(mut cache) = self.cache.write() { + cache.remove(key); + } + } + + fn get_param_value(&self, param: &ConfigParamKey) -> String { + self.database + .client() + .ok() + .and_then(|mut c| ConfigStore::get_config_key(&mut c, ¶m.key()).ok()) + .map_or_else(|| param.default_value().to_string(), |row| row.value) + } +} diff --git a/core/crates/storage/src/database/assets.rs b/core/crates/storage/src/database/assets.rs new file mode 100644 index 0000000000..640c066663 --- /dev/null +++ b/core/crates/storage/src/database/assets.rs @@ -0,0 +1,164 @@ +use crate::schema::assets::dsl::*; + +use crate::DatabaseClient; +use crate::models::{AssetRow, NewAssetRow}; +use chrono::NaiveDateTime; +use diesel::{prelude::*, upsert::excluded}; + +#[derive(Debug, Clone)] +pub enum AssetUpdate { + IsEnabled(bool), + IsSwappable(bool), + IsBuyable(bool), + IsSellable(bool), + Rank(i32), + StakingApr(Option), + HasImage(bool), + HasPrice(bool), + Supply { + circulating_supply: Option, + total_supply: Option, + max_supply: Option, + }, +} + +impl AssetUpdate { + pub fn supply(circulating: Option, total: Option, max: Option) -> Option { + (circulating.is_some() || total.is_some() || max.is_some()).then_some(Self::Supply { + circulating_supply: circulating, + total_supply: total, + max_supply: max, + }) + } +} + +#[derive(Debug, Clone)] +pub enum AssetFilter { + IsSwappable(bool), + IsBuyable(bool), + IsSellable(bool), + HasImage(bool), + HasPrice(bool), + Chain(String), +} + +pub(crate) trait AssetsStore { + fn get_assets_all(&mut self) -> Result, diesel::result::Error>; + fn add_assets(&mut self, values: Vec) -> Result; + fn update_assets(&mut self, asset_ids: Vec, updates: Vec) -> Result; + fn upsert_assets(&mut self, values: Vec) -> Result; + fn get_assets_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error>; + fn get_asset(&mut self, asset_id: &str) -> Result; + fn get_assets(&mut self, asset_ids: Vec) -> Result, diesel::result::Error>; + fn get_all_asset_ids(&mut self) -> Result, diesel::result::Error>; + fn get_asset_ids_updated_since(&mut self, since: NaiveDateTime) -> Result, diesel::result::Error>; + fn get_swap_assets(&mut self) -> Result, diesel::result::Error>; + fn get_swap_assets_version(&mut self) -> Result; +} + +impl AssetsStore for DatabaseClient { + fn get_assets_all(&mut self) -> Result, diesel::result::Error> { + assets.filter(is_enabled.eq(true)).select(AssetRow::as_select()).load(&mut self.connection) + } + fn add_assets(&mut self, values: Vec) -> Result { + if values.is_empty() { + return Ok(0); + } + diesel::insert_into(assets).values(values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn update_assets(&mut self, asset_ids: Vec, updates: Vec) -> Result { + if asset_ids.is_empty() || updates.is_empty() { + return Ok(0); + } + + updates.into_iter().try_fold(0, |total, update| { + let target = assets.filter(id.eq_any(&asset_ids)); + let updated = match update { + AssetUpdate::IsEnabled(value) => diesel::update(target).set(is_enabled.eq(value)).execute(&mut self.connection)?, + AssetUpdate::IsSwappable(value) => diesel::update(target).set(is_swappable.eq(value)).execute(&mut self.connection)?, + AssetUpdate::IsBuyable(value) => diesel::update(target).set(is_buyable.eq(value)).execute(&mut self.connection)?, + AssetUpdate::IsSellable(value) => diesel::update(target).set(is_sellable.eq(value)).execute(&mut self.connection)?, + AssetUpdate::Rank(value) => diesel::update(target).set(rank.eq(value)).execute(&mut self.connection)?, + AssetUpdate::StakingApr(value) => diesel::update(target).set(staking_apr.eq(value)).execute(&mut self.connection)?, + AssetUpdate::HasImage(value) => diesel::update(target).set(has_image.eq(value)).execute(&mut self.connection)?, + AssetUpdate::HasPrice(value) => diesel::update(target).set(has_price.eq(value)).execute(&mut self.connection)?, + AssetUpdate::Supply { + circulating_supply: c, + total_supply: t, + max_supply: m, + } => diesel::update(target) + .set((circulating_supply.eq(c), total_supply.eq(t), max_supply.eq(m))) + .execute(&mut self.connection)?, + }; + Ok(total + updated) + }) + } + + fn upsert_assets(&mut self, values: Vec) -> Result { + diesel::insert_into(assets) + .values(values) + .on_conflict(id) + .do_update() + .set((rank.eq(excluded(rank)),)) + .execute(&mut self.connection) + } + + fn get_assets_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + let mut query = assets.filter(is_enabled.eq(true)).into_boxed(); + + for filter in filters { + match filter { + AssetFilter::IsBuyable(value) => { + query = query.filter(is_buyable.eq(value)); + } + AssetFilter::IsSellable(value) => { + query = query.filter(is_sellable.eq(value)); + } + AssetFilter::IsSwappable(value) => { + query = query.filter(is_swappable.eq(value)); + } + AssetFilter::HasImage(value) => { + query = query.filter(has_image.eq(value)); + } + AssetFilter::HasPrice(value) => { + query = query.filter(has_price.eq(value)); + } + AssetFilter::Chain(value) => { + query = query.filter(chain.eq(value)); + } + } + } + + query.select(AssetRow::as_select()).load(&mut self.connection) + } + + fn get_asset(&mut self, asset_id: &str) -> Result { + assets.find(asset_id).select(AssetRow::as_select()).first(&mut self.connection) + } + + fn get_assets(&mut self, asset_ids: Vec) -> Result, diesel::result::Error> { + assets.filter(id.eq_any(asset_ids)).select(AssetRow::as_select()).load(&mut self.connection) + } + + fn get_all_asset_ids(&mut self) -> Result, diesel::result::Error> { + assets.select(id).load(&mut self.connection) + } + + fn get_asset_ids_updated_since(&mut self, since: NaiveDateTime) -> Result, diesel::result::Error> { + assets.filter(updated_at.gt(since)).select(id).load(&mut self.connection) + } + + fn get_swap_assets(&mut self) -> Result, diesel::result::Error> { + assets + .filter(rank.gt(21)) + .filter(is_swappable.eq(true)) + .select(id) + .order(rank.desc()) + .load(&mut self.connection) + } + + fn get_swap_assets_version(&mut self) -> Result { + Ok((std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() / 3600) as i32) + } +} diff --git a/core/crates/storage/src/database/assets_addresses.rs b/core/crates/storage/src/database/assets_addresses.rs new file mode 100644 index 0000000000..2d879c56ef --- /dev/null +++ b/core/crates/storage/src/database/assets_addresses.rs @@ -0,0 +1,92 @@ +use crate::schema::assets_addresses::dsl::*; + +use crate::sql_types::AssetId as AssetIdRow; +use crate::{DatabaseClient, models::asset_address::AssetAddressRow}; +use chrono::NaiveDateTime; +use diesel::Connection; +use diesel::dsl::sql; +use diesel::prelude::*; +use diesel::sql_types::{Nullable, Text}; +use primitives::{AssetId as PrimitiveAssetId, ChainAddress}; + +pub(crate) trait AssetsAddressesStore { + fn add_assets_addresses(&mut self, values: Vec) -> Result; + fn get_assets_by_addresses(&mut self, values: Vec, from_datetime: Option) -> Result, diesel::result::Error>; + fn get_asset_addresses(&mut self, chain_address: ChainAddress) -> Result, diesel::result::Error>; + fn get_asset_address(&mut self, chain_address: ChainAddress, target_asset_id: PrimitiveAssetId) -> Result, diesel::result::Error>; + fn delete_assets_addresses(&mut self, values: Vec) -> Result; +} + +impl AssetsAddressesStore for DatabaseClient { + fn add_assets_addresses(&mut self, values: Vec) -> Result { + if values.is_empty() { + return Ok(0); + } + diesel::insert_into(assets_addresses) + .values(&values) + .on_conflict((asset_id, address)) + .do_update() + .set(value.eq(sql::>("COALESCE(excluded.value, assets_addresses.value)"))) + .execute(&mut self.connection) + } + + fn get_assets_by_addresses(&mut self, values: Vec, from_datetime: Option) -> Result, diesel::result::Error> { + let chains = values.iter().map(|x| x.chain.as_ref()).collect::>(); + let addresses = values.iter().map(|x| x.address.clone()).collect::>(); + use crate::schema::{assets, assets_addresses::dsl as a}; + + let mut query = a::assets_addresses + .filter(a::chain.eq_any(chains)) + .filter(a::address.eq_any(addresses)) + .filter(a::value.is_null().or(a::value.ne("0"))) + .filter(diesel::dsl::exists(assets::table.filter(assets::id.eq(a::asset_id)).filter(assets::has_price.eq(true)))) + .select(AssetAddressRow::as_select()) + .into_boxed(); + + if let Some(datetime) = from_datetime { + query = query.filter(a::created_at.gt(datetime)); + } + + query.load(&mut self.connection) + } + + fn get_asset_addresses(&mut self, chain_address: ChainAddress) -> Result, diesel::result::Error> { + assets_addresses + .filter(chain.eq(chain_address.chain.as_ref())) + .filter(address.eq(chain_address.address)) + .select(AssetAddressRow::as_select()) + .load(&mut self.connection) + } + + fn get_asset_address(&mut self, chain_address: ChainAddress, target_asset_id: PrimitiveAssetId) -> Result, diesel::result::Error> { + assets_addresses + .filter(chain.eq(chain_address.chain.as_ref())) + .filter(address.eq(chain_address.address)) + .filter(asset_id.eq(AssetIdRow::from(target_asset_id))) + .select(AssetAddressRow::as_select()) + .first(&mut self.connection) + .optional() + } + + fn delete_assets_addresses(&mut self, values: Vec) -> Result { + if values.is_empty() { + return Ok(0); + } + + self.connection.transaction(|connection| { + let mut deleted = 0; + + for row in values { + deleted += diesel::delete( + assets_addresses + .filter(chain.eq(&row.chain)) + .filter(asset_id.eq(&row.asset_id)) + .filter(address.eq(&row.address)), + ) + .execute(connection)?; + } + + Ok(deleted) + }) + } +} diff --git a/core/crates/storage/src/database/assets_links.rs b/core/crates/storage/src/database/assets_links.rs new file mode 100644 index 0000000000..2bd3798d68 --- /dev/null +++ b/core/crates/storage/src/database/assets_links.rs @@ -0,0 +1,26 @@ +use crate::models::asset::AssetLinkRow; +use crate::schema::assets_links::dsl::*; + +use crate::DatabaseClient; +use diesel::{prelude::*, upsert::excluded}; + +pub(crate) trait AssetsLinksStore { + fn add_assets_links(&mut self, values: Vec) -> Result; + fn get_asset_links(&mut self, asset_id: &str) -> Result, diesel::result::Error>; +} + +impl AssetsLinksStore for DatabaseClient { + fn add_assets_links(&mut self, values: Vec) -> Result { + diesel::insert_into(assets_links) + .values(values) + .on_conflict((asset_id, link_type)) + .do_update() + .set((url.eq(excluded(url)),)) + .execute(&mut self.connection) + } + + fn get_asset_links(&mut self, _asset_id: &str) -> Result, diesel::result::Error> { + use crate::schema::assets_links::dsl::*; + assets_links.filter(asset_id.eq(_asset_id)).select(AssetLinkRow::as_select()).load(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/assets_usage_ranks.rs b/core/crates/storage/src/database/assets_usage_ranks.rs new file mode 100644 index 0000000000..979304f893 --- /dev/null +++ b/core/crates/storage/src/database/assets_usage_ranks.rs @@ -0,0 +1,33 @@ +use crate::schema::assets_usage_ranks::dsl::*; +use crate::{DatabaseClient, models::AssetUsageRankRow}; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use diesel::upsert::excluded; + +pub(crate) trait AssetsUsageRanksStore { + fn upsert_usage_ranks(&mut self, values: Vec) -> Result; + fn delete_usage_ranks_before(&mut self, before: NaiveDateTime) -> Result; + fn get_all_usage_ranks(&mut self) -> Result, diesel::result::Error>; +} + +impl AssetsUsageRanksStore for DatabaseClient { + fn upsert_usage_ranks(&mut self, values: Vec) -> Result { + if values.is_empty() { + return Ok(0); + } + diesel::insert_into(assets_usage_ranks) + .values(&values) + .on_conflict(asset_id) + .do_update() + .set(usage_rank.eq(excluded(usage_rank))) + .execute(&mut self.connection) + } + + fn delete_usage_ranks_before(&mut self, before: NaiveDateTime) -> Result { + diesel::delete(assets_usage_ranks.filter(updated_at.lt(before))).execute(&mut self.connection) + } + + fn get_all_usage_ranks(&mut self) -> Result, diesel::result::Error> { + assets_usage_ranks.select(AssetUsageRankRow::as_select()).load(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/chains.rs b/core/crates/storage/src/database/chains.rs new file mode 100644 index 0000000000..5c29c788d1 --- /dev/null +++ b/core/crates/storage/src/database/chains.rs @@ -0,0 +1,15 @@ +use crate::{DatabaseClient, models::ChainIdRow}; +use diesel::prelude::*; +use primitives::Chain; + +pub trait ChainStore { + fn add_chains(&mut self, values: Vec) -> Result; +} + +impl ChainStore for DatabaseClient { + fn add_chains(&mut self, values: Vec) -> Result { + let chain_values = values.into_iter().map(|chain_id| ChainIdRow { id: chain_id.into() }).collect::>(); + use crate::schema::chains::dsl::*; + diesel::insert_into(chains).values(chain_values).on_conflict_do_nothing().execute(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/charts.rs b/core/crates/storage/src/database/charts.rs new file mode 100644 index 0000000000..0f9ef21652 --- /dev/null +++ b/core/crates/storage/src/database/charts.rs @@ -0,0 +1,210 @@ +use crate::DatabaseClient; +use crate::models::chart::{ChartRow, DailyChartRow, HourlyChartRow}; +use crate::models::min_max::{DataPoint, MinMax}; +use crate::schema::charts::dsl::{charts, coin_id as raw_coin_id, created_at as raw_created_at, price as raw_price}; +use crate::schema::charts_daily::dsl::{charts_daily, coin_id as daily_coin_id, created_at as daily_created_at, price as daily_price}; +use crate::schema::charts_hourly::dsl::{charts_hourly, coin_id as hourly_coin_id, created_at as hourly_created_at, price as hourly_price}; +use chrono::NaiveDateTime; +use diesel::dsl::sql; +use diesel::prelude::*; +use diesel::result::Error; +use primitives::{ChartPeriod, ChartTimeframe}; + +pub enum ChartGranularity { + Minute, + Minute15, + Hourly, + Hour6, + Daily, +} + +pub type ChartResult = (chrono::NaiveDateTime, f64); + +#[derive(Debug, Clone)] +pub enum ChartFilter { + CreatedBefore(NaiveDateTime), + CreatedAfter(NaiveDateTime), + PriceIds(Vec), +} + +pub(crate) trait ChartsStore { + fn add_charts(&mut self, timeframe: ChartTimeframe, values: Vec) -> Result; + fn get_charts(&mut self, price_id: &str, period: &ChartPeriod) -> Result, Error>; + fn aggregate_charts(&mut self, timeframe: ChartTimeframe) -> Result; + fn delete_charts(&mut self, timeframe: ChartTimeframe, before: NaiveDateTime) -> Result; + fn get_charts_by_filter(&mut self, filters: Vec) -> Result, Error>; + fn get_chart_extremes(&mut self, price_id: &str, timeframe: ChartTimeframe) -> Result, Error>; +} + +impl ChartsStore for DatabaseClient { + fn add_charts(&mut self, timeframe: ChartTimeframe, values: Vec) -> Result { + if values.is_empty() { + return Ok(0); + } + match timeframe { + ChartTimeframe::Raw => diesel::insert_into(charts).values(values).on_conflict_do_nothing().execute(&mut self.connection), + ChartTimeframe::Hourly => { + let rows: Vec = values.into_iter().map(Into::into).collect(); + diesel::insert_into(charts_hourly).values(rows).on_conflict_do_nothing().execute(&mut self.connection) + } + ChartTimeframe::Daily => { + let rows: Vec = values.into_iter().map(Into::into).collect(); + diesel::insert_into(charts_daily).values(rows).on_conflict_do_nothing().execute(&mut self.connection) + } + } + } + + fn get_charts(&mut self, price_id: &str, period: &ChartPeriod) -> Result, Error> { + let date_selection = format!("date_bin('{}', created_at, timestamp '2000-01-01')", self.period_sql(period.clone())); + let granularity = Self::get_chart_granularity_for_period(period); + let created_at_filter = format!("created_at >= now() - INTERVAL '{} minutes'", self.period_minutes(period.clone())); + match granularity { + ChartGranularity::Minute | ChartGranularity::Minute15 => charts + .select((sql::(date_selection.as_str()), sql::("AVG(price)"))) + .filter(raw_coin_id.eq(price_id)) + .filter(sql::(&created_at_filter)) + .group_by(sql::("1")) + .order(sql::("1").asc()) + .load(&mut self.connection), + ChartGranularity::Hourly | ChartGranularity::Hour6 => charts_hourly + .select((sql::(date_selection.as_str()), sql::("AVG(price)"))) + .filter(hourly_coin_id.eq(price_id)) + .filter(sql::(&created_at_filter)) + .group_by(sql::("1")) + .order(sql::("1").asc()) + .load(&mut self.connection), + ChartGranularity::Daily => charts_daily + .select((sql::(date_selection.as_str()), sql::("AVG(price)"))) + .filter(daily_coin_id.eq(price_id)) + .filter(sql::(&created_at_filter)) + .group_by(sql::("1")) + .order(sql::("1").asc()) + .load(&mut self.connection), + } + } + + fn aggregate_charts(&mut self, timeframe: ChartTimeframe) -> Result { + let query = match timeframe { + ChartTimeframe::Raw => return Ok(0), + ChartTimeframe::Hourly => "SELECT aggregate_hourly_charts();", + ChartTimeframe::Daily => "SELECT aggregate_daily_charts();", + }; + diesel::sql_query(query).execute(&mut self.connection) + } + + fn delete_charts(&mut self, timeframe: ChartTimeframe, before: NaiveDateTime) -> Result { + match timeframe { + ChartTimeframe::Raw => diesel::delete(charts.filter(raw_created_at.lt(before))).execute(&mut self.connection), + ChartTimeframe::Hourly => diesel::delete(charts_hourly.filter(hourly_created_at.lt(before))).execute(&mut self.connection), + ChartTimeframe::Daily => diesel::delete(charts_daily.filter(daily_created_at.lt(before))).execute(&mut self.connection), + } + } + + fn get_charts_by_filter(&mut self, filters: Vec) -> Result, Error> { + let query = filters.into_iter().fold( + charts.distinct_on(raw_coin_id).order_by((raw_coin_id, raw_created_at.desc())).into_boxed(), + |q, filter| match filter { + ChartFilter::CreatedBefore(at) => q.filter(raw_created_at.le(at)), + ChartFilter::CreatedAfter(at) => q.filter(raw_created_at.ge(at)), + ChartFilter::PriceIds(ids) => q.filter(raw_coin_id.eq_any(ids)), + }, + ); + query.select((raw_coin_id, raw_price)).load(&mut self.connection) + } + + fn get_chart_extremes(&mut self, price_id: &str, timeframe: ChartTimeframe) -> Result, Error> { + match timeframe { + ChartTimeframe::Raw => { + let max = charts + .filter(raw_coin_id.eq(price_id)) + .filter(raw_price.gt(0.0)) + .order_by(raw_price.desc()) + .select((raw_price, raw_created_at)) + .first::<(f64, NaiveDateTime)>(&mut self.connection) + .optional()? + .map(DataPoint::from); + let min = charts + .filter(raw_coin_id.eq(price_id)) + .filter(raw_price.gt(0.0)) + .order_by(raw_price.asc()) + .select((raw_price, raw_created_at)) + .first::<(f64, NaiveDateTime)>(&mut self.connection) + .optional()? + .map(DataPoint::from); + Ok(MinMax { max, min }) + } + ChartTimeframe::Hourly => { + let max = charts_hourly + .filter(hourly_coin_id.eq(price_id)) + .filter(hourly_price.gt(0.0)) + .order_by(hourly_price.desc()) + .select((hourly_price, hourly_created_at)) + .first::<(f64, NaiveDateTime)>(&mut self.connection) + .optional()? + .map(DataPoint::from); + let min = charts_hourly + .filter(hourly_coin_id.eq(price_id)) + .filter(hourly_price.gt(0.0)) + .order_by(hourly_price.asc()) + .select((hourly_price, hourly_created_at)) + .first::<(f64, NaiveDateTime)>(&mut self.connection) + .optional()? + .map(DataPoint::from); + Ok(MinMax { max, min }) + } + ChartTimeframe::Daily => { + let max = charts_daily + .filter(daily_coin_id.eq(price_id)) + .filter(daily_price.gt(0.0)) + .order_by(daily_price.desc()) + .select((daily_price, daily_created_at)) + .first::<(f64, NaiveDateTime)>(&mut self.connection) + .optional()? + .map(DataPoint::from); + let min = charts_daily + .filter(daily_coin_id.eq(price_id)) + .filter(daily_price.gt(0.0)) + .order_by(daily_price.asc()) + .select((daily_price, daily_created_at)) + .first::<(f64, NaiveDateTime)>(&mut self.connection) + .optional()? + .map(DataPoint::from); + Ok(MinMax { max, min }) + } + } + } +} + +impl DatabaseClient { + fn get_chart_granularity_for_period(period: &ChartPeriod) -> ChartGranularity { + match period { + ChartPeriod::Hour => ChartGranularity::Minute, + ChartPeriod::Day => ChartGranularity::Minute15, + ChartPeriod::Week => ChartGranularity::Hourly, + ChartPeriod::Month => ChartGranularity::Hour6, + ChartPeriod::Year | ChartPeriod::All => ChartGranularity::Daily, + } + } + + fn period_sql(&self, period: ChartPeriod) -> &str { + match period { + ChartPeriod::Hour => "1 minutes", + ChartPeriod::Day => "15 minutes", + ChartPeriod::Week => "1 hour", + ChartPeriod::Month => "6 hour", + ChartPeriod::Year => "3 day", + ChartPeriod::All => "3 day", + } + } + + fn period_minutes(&self, period: ChartPeriod) -> i32 { + match period { + ChartPeriod::Hour => 60, + ChartPeriod::Day => 1440, + ChartPeriod::Week => 10_080, + ChartPeriod::Month => 43_200, + ChartPeriod::Year => 525_600, + ChartPeriod::All => 10_525_600, + } + } +} diff --git a/core/crates/storage/src/database/config.rs b/core/crates/storage/src/database/config.rs new file mode 100644 index 0000000000..4f0aac12de --- /dev/null +++ b/core/crates/storage/src/database/config.rs @@ -0,0 +1,39 @@ +use diesel::prelude::*; + +use crate::DatabaseClient; +use crate::models::ConfigRow; + +pub trait ConfigStore { + fn get_config_key(&mut self, key: &str) -> Result; + fn get_config_keys(&mut self) -> Result, diesel::result::Error>; + fn add_config(&mut self, configs: Vec) -> Result; + fn set_config(&mut self, config_key: &str, config_value: &str) -> Result; + fn delete_keys(&mut self, keys: Vec) -> Result; +} + +impl ConfigStore for DatabaseClient { + fn get_config_key(&mut self, config_key: &str) -> Result { + use crate::schema::config::dsl::*; + config.filter(key.eq(config_key)).select(ConfigRow::as_select()).first(&mut self.connection) + } + + fn get_config_keys(&mut self) -> Result, diesel::result::Error> { + use crate::schema::config::dsl::*; + config.select(key).load(&mut self.connection) + } + + fn add_config(&mut self, configs: Vec) -> Result { + use crate::schema::config::dsl::*; + diesel::insert_into(config).values(&configs).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn set_config(&mut self, config_key: &str, config_value: &str) -> Result { + use crate::schema::config::dsl::*; + diesel::update(config.filter(key.eq(config_key))).set(value.eq(config_value)).execute(&mut self.connection) + } + + fn delete_keys(&mut self, keys: Vec) -> Result { + use crate::schema::config::dsl::*; + diesel::delete(config.filter(key.eq_any(keys))).execute(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/devices.rs b/core/crates/storage/src/database/devices.rs new file mode 100644 index 0000000000..f0641f68aa --- /dev/null +++ b/core/crates/storage/src/database/devices.rs @@ -0,0 +1,104 @@ +use crate::{DatabaseClient, models::*}; +use chrono::{Duration, NaiveDateTime, Utc}; +use diesel::{prelude::*, upsert::excluded}; + +#[derive(Debug, Clone)] +pub enum DeviceFieldUpdate { + IsPushEnabled(bool), + IsPriceAlertsEnabled(bool), +} + +#[derive(Debug, Clone)] +pub enum DeviceFilter { + IsPushEnabled(bool), + CreatedBetween { start: NaiveDateTime, end: NaiveDateTime }, +} + +pub trait DevicesStore { + fn add_device(&mut self, device: UpdateDeviceRow) -> Result; + fn get_device_by_id(&mut self, id: i32) -> Result; + fn get_device(&mut self, device_id: &str) -> Result; + fn update_device(&mut self, device: UpdateDeviceRow) -> Result; + fn update_device_fields(&mut self, device_ids: Vec, updates: Vec) -> Result; + fn get_stale_device_ids(&mut self, days: i64) -> Result, diesel::result::Error>; + fn get_devices_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error>; +} + +impl DevicesStore for DatabaseClient { + fn add_device(&mut self, device: UpdateDeviceRow) -> Result { + use crate::schema::devices::dsl::*; + diesel::insert_into(devices) + .values(&device) + .on_conflict(device_id) + .do_update() + .set((device_id.eq(excluded(device_id)),)) + .returning(DeviceRow::as_returning()) + .get_result(&mut self.connection) + } + + fn get_device_by_id(&mut self, _id: i32) -> Result { + use crate::schema::devices::dsl::*; + devices.find(_id).select(DeviceRow::as_select()).first(&mut self.connection) + } + + fn get_device(&mut self, _device_id: &str) -> Result { + use crate::schema::devices::dsl::*; + devices.filter(device_id.eq(_device_id)).select(DeviceRow::as_select()).first(&mut self.connection) + } + + fn update_device(&mut self, device: UpdateDeviceRow) -> Result { + use crate::schema::devices::dsl::*; + diesel::update(devices) + .filter(device_id.eq(device.clone().device_id)) + .set(device) + .returning(DeviceRow::as_returning()) + .get_result(&mut self.connection) + } + + fn update_device_fields(&mut self, device_ids: Vec, updates: Vec) -> Result { + use crate::schema::devices::dsl::*; + + if updates.is_empty() || device_ids.is_empty() { + return Ok(0); + } + + let mut total_updated = 0; + for update in updates { + let target = devices.filter(device_id.eq_any(&device_ids)); + let updated = match update { + DeviceFieldUpdate::IsPushEnabled(value) => diesel::update(target).set(is_push_enabled.eq(value)).execute(&mut self.connection)?, + DeviceFieldUpdate::IsPriceAlertsEnabled(value) => diesel::update(target).set(is_price_alerts_enabled.eq(value)).execute(&mut self.connection)?, + }; + total_updated += updated; + } + + Ok(total_updated) + } + + fn get_stale_device_ids(&mut self, days: i64) -> Result, diesel::result::Error> { + use crate::schema::devices::dsl::*; + let cutoff_date = Utc::now() - Duration::days(days); + devices.filter(updated_at.lt(cutoff_date.naive_utc())).select(id).load(&mut self.connection) + } + + fn get_devices_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + use crate::schema::devices::dsl::*; + + let mut query = devices.into_boxed(); + + for filter in filters { + match filter { + DeviceFilter::IsPushEnabled(enabled) => { + query = query.filter(is_push_enabled.eq(enabled)); + } + DeviceFilter::CreatedBetween { start, end } => { + query = query.filter(created_at.between(start, end).and(diesel::dsl::sql::( + "DATE_TRUNC('hour', updated_at) = DATE_TRUNC('hour', created_at)", + ))); + } + } + } + + query.select(DeviceRow::as_select()).load(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/fiat.rs b/core/crates/storage/src/database/fiat.rs new file mode 100644 index 0000000000..08ad7b4e18 --- /dev/null +++ b/core/crates/storage/src/database/fiat.rs @@ -0,0 +1,358 @@ +use crate::schema::fiat_providers; +use crate::sql_types::{AssetId, FiatProviderNameRow}; +use crate::{DatabaseClient, models::*}; +use chrono::NaiveDateTime; +use diesel::associations::HasTable; +use diesel::dsl::count_star; +use diesel::prelude::*; +use diesel::upsert::excluded; +use primitives::{FiatProviderName, FiatTransactionUpdate}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FiatAssetFilter { + HasAssetId, + IsEnabled(bool), + IsEnabledByProvider(bool), + IsBuyEnabled(bool), + IsSellEnabled(bool), + ProviderEnabled(bool), + ProviderBuyEnabled(bool), + ProviderSellEnabled(bool), +} + +pub(crate) trait FiatStore { + fn add_fiat_assets(&mut self, values: Vec) -> Result; + fn add_fiat_providers(&mut self, values: Vec) -> Result; + fn add_fiat_providers_countries(&mut self, values: Vec) -> Result; + fn get_fiat_providers_countries(&mut self) -> Result, diesel::result::Error>; + fn update_fiat_transaction(&mut self, provider: FiatProviderName, update: FiatTransactionUpdate) -> Result; + fn get_fiat_transaction(&mut self, provider: FiatProviderName, transaction_id: &str) -> Result, diesel::result::Error>; + fn get_fiat_transactions_by_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error>; + fn get_fiat_assets_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error>; + fn get_fiat_assets_popular(&mut self, from: NaiveDateTime, limit: i64) -> Result, diesel::result::Error>; + fn get_fiat_assets_for_asset_id(&mut self, asset_id: &str) -> Result, diesel::result::Error>; + fn set_fiat_rates(&mut self, rates: Vec) -> Result; + fn get_fiat_rates(&mut self) -> Result, diesel::result::Error>; + fn get_fiat_rate(&mut self, currency: &str) -> Result; + fn get_fiat_providers(&mut self) -> Result, diesel::result::Error>; + fn add_fiat_transaction(&mut self, transaction: NewFiatTransactionRow) -> Result; + fn update_fiat_provider_payment_methods(&mut self, provider_id: FiatProviderName, values: serde_json::Value) -> Result; +} + +impl FiatStore for DatabaseClient { + fn add_fiat_assets(&mut self, values: Vec) -> Result { + use crate::schema::fiat_assets::dsl::*; + diesel::insert_into(fiat_assets) + .values(values) + .on_conflict(id) + .do_update() + .set(( + asset_id.eq(excluded(asset_id)), + symbol.eq(excluded(symbol)), + network.eq(excluded(network)), + token_id.eq(excluded(token_id)), + unsupported_countries.eq(excluded(unsupported_countries)), + buy_limits.eq(excluded(buy_limits)), + sell_limits.eq(excluded(sell_limits)), + is_buy_enabled.eq(excluded(is_buy_enabled)), + is_sell_enabled.eq(excluded(is_sell_enabled)), + is_enabled_by_provider.eq(excluded(is_enabled_by_provider)), + )) + .execute(&mut self.connection) + } + + fn add_fiat_providers(&mut self, values: Vec) -> Result { + use crate::schema::fiat_providers::dsl::*; + diesel::insert_into(fiat_providers).values(values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn add_fiat_providers_countries(&mut self, values: Vec) -> Result { + use crate::schema::fiat_providers_countries::dsl::*; + diesel::insert_into(fiat_providers_countries) + .values(values) + .on_conflict(id) + .do_update() + .set((alpha2.eq(excluded(alpha2)), is_allowed.eq(excluded(is_allowed)))) + .execute(&mut self.connection) + } + + fn get_fiat_providers_countries(&mut self) -> Result, diesel::result::Error> { + use crate::schema::fiat_providers_countries::dsl::*; + fiat_providers_countries.select(FiatProviderCountryRow::as_select()).load(&mut self.connection) + } + + fn update_fiat_transaction(&mut self, provider: FiatProviderName, update: FiatTransactionUpdate) -> Result { + use crate::schema::fiat_transactions::dsl::*; + + let provider = FiatProviderNameRow::from(provider); + let changeset = UpdateFiatTransactionRow::from_primitive(&update); + + if let Some(row) = self.update_by_provider_transaction_id(&provider, &update.transaction_id, &changeset)? { + return Ok(row); + } + + match update.provider_transaction_id.as_deref() { + Some(provider_transaction_id_value) => { + if let Some(row) = self.update_by_provider_transaction_id(&provider, provider_transaction_id_value, &changeset)? { + return Ok(row); + } + if let Some(row) = self.update_by_quote_id(&provider, &update.transaction_id, provider_transaction_id_value, &changeset)? { + return Ok(row); + } + let existing = self + .get_fiat_transaction_for_quote(&provider, &update.transaction_id)? + .ok_or(diesel::result::Error::NotFound)?; + let new_row = NewFiatTransactionRow::from_existing(&existing, &update, provider_transaction_id_value.to_string()); + diesel::insert_into(fiat_transactions) + .values(&new_row) + .returning(FiatTransactionRow::as_returning()) + .get_result(&mut self.connection) + } + None => { + let existing = self + .get_fiat_transaction_for_quote(&provider, &update.transaction_id)? + .ok_or(diesel::result::Error::NotFound)?; + self.update_fiat_transaction_by_id(existing.id, changeset) + } + } + } + + fn get_fiat_transaction(&mut self, provider: FiatProviderName, transaction_id: &str) -> Result, diesel::result::Error> { + use crate::schema::fiat_transactions::dsl::*; + + let provider = FiatProviderNameRow::from(provider); + fiat_transactions + .filter(provider_id.eq(&provider)) + .filter(provider_transaction_id.eq(transaction_id).or(quote_id.eq(transaction_id))) + .order((updated_at.desc(), id.desc())) + .select(FiatTransactionRow::as_select()) + .first(&mut self.connection) + .optional() + } + + fn get_fiat_transactions_by_addresses(&mut self, addresses_list: Vec) -> Result, diesel::result::Error> { + use crate::schema::{fiat_transactions, wallets_addresses}; + + if addresses_list.is_empty() { + return Ok(vec![]); + } + + fiat_transactions::table + .inner_join(wallets_addresses::table) + .filter(wallets_addresses::address.eq_any(addresses_list)) + .order(fiat_transactions::created_at.desc()) + .select(FiatTransactionRow::as_select()) + .load(&mut self.connection) + } + + fn get_fiat_assets_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + use crate::schema::{fiat_assets, fiat_providers}; + + let mut query = fiat_assets::table.inner_join(fiat_providers::table).into_boxed(); + + for filter in filters { + query = match filter { + FiatAssetFilter::HasAssetId => query.filter(fiat_assets::asset_id.is_not_null()), + FiatAssetFilter::IsEnabled(value) => query.filter(fiat_assets::is_enabled.eq(value)), + FiatAssetFilter::IsEnabledByProvider(value) => query.filter(fiat_assets::is_enabled_by_provider.eq(value)), + FiatAssetFilter::IsBuyEnabled(value) => query.filter(fiat_assets::is_buy_enabled.eq(value)), + FiatAssetFilter::IsSellEnabled(value) => query.filter(fiat_assets::is_sell_enabled.eq(value)), + FiatAssetFilter::ProviderEnabled(value) => query.filter(fiat_providers::enabled.eq(value)), + FiatAssetFilter::ProviderBuyEnabled(value) => query.filter(fiat_providers::buy_enabled.eq(value)), + FiatAssetFilter::ProviderSellEnabled(value) => query.filter(fiat_providers::sell_enabled.eq(value)), + }; + } + + query + .select(FiatAssetRow::as_select()) + .distinct() + .order(fiat_assets::asset_id.asc()) + .load(&mut self.connection) + } + + fn get_fiat_assets_popular(&mut self, from: NaiveDateTime, limit: i64) -> Result, diesel::result::Error> { + use crate::schema::fiat_transactions::dsl::*; + + fiat_transactions + .filter(created_at.gt(from)) + .select(asset_id) + .group_by(asset_id) + .order_by(count_star().desc()) + .limit(limit) + .load::(&mut self.connection) + } + + fn get_fiat_assets_for_asset_id(&mut self, requested_asset_id: &str) -> Result, diesel::result::Error> { + use crate::schema::fiat_assets::dsl::*; + fiat_assets::table() + .inner_join(fiat_providers::table) + .filter(fiat_providers::enabled.eq(true)) + .filter(asset_id.eq(requested_asset_id)) + .select(FiatAssetRow::as_select()) + .load(&mut self.connection) + } + + fn set_fiat_rates(&mut self, rates: Vec) -> Result { + use crate::schema::fiat_rates::dsl::*; + diesel::insert_into(fiat_rates) + .values(&rates) + .on_conflict(id) + .do_update() + .set(rate.eq(excluded(rate))) + .execute(&mut self.connection) + } + + fn get_fiat_rates(&mut self) -> Result, diesel::result::Error> { + use crate::schema::fiat_rates::dsl::*; + fiat_rates.select(FiatRateRow::as_select()).load(&mut self.connection) + } + + fn get_fiat_rate(&mut self, currency: &str) -> Result { + use crate::schema::fiat_rates::dsl::*; + fiat_rates.find(currency).select(FiatRateRow::as_select()).first(&mut self.connection) + } + + fn get_fiat_providers(&mut self) -> Result, diesel::result::Error> { + use crate::schema::fiat_providers::dsl::*; + fiat_providers.select(FiatProviderRow::as_select()).load(&mut self.connection) + } + + fn add_fiat_transaction(&mut self, transaction: NewFiatTransactionRow) -> Result { + use crate::schema::fiat_transactions::dsl::*; + + diesel::insert_into(fiat_transactions) + .values(&transaction) + .on_conflict_do_nothing() + .execute(&mut self.connection) + } + + fn update_fiat_provider_payment_methods(&mut self, provider_id_value: FiatProviderName, values: serde_json::Value) -> Result { + use crate::schema::fiat_providers::dsl::*; + diesel::update(fiat_providers.filter(id.eq(FiatProviderNameRow::from(provider_id_value)))) + .set(payment_methods.eq(values)) + .execute(&mut self.connection) + } +} + +impl DatabaseClient { + fn update_by_provider_transaction_id( + &mut self, + provider: &FiatProviderNameRow, + provider_transaction_id_value: &str, + changeset: &UpdateFiatTransactionRow, + ) -> Result, diesel::result::Error> { + use crate::schema::fiat_transactions::dsl::*; + + diesel::update( + fiat_transactions + .filter(provider_id.eq(provider)) + .filter(provider_transaction_id.eq(provider_transaction_id_value)), + ) + .set(changeset) + .returning(FiatTransactionRow::as_returning()) + .get_result(&mut self.connection) + .optional() + } + + fn update_by_quote_id( + &mut self, + provider: &FiatProviderNameRow, + target_quote_id: &str, + provider_transaction_id_value: &str, + changeset: &UpdateFiatTransactionRow, + ) -> Result, diesel::result::Error> { + use crate::schema::fiat_transactions::dsl::*; + + diesel::update( + fiat_transactions + .filter(provider_id.eq(provider)) + .filter(quote_id.eq(target_quote_id)) + .filter(provider_transaction_id.is_null()), + ) + .set((provider_transaction_id.eq(provider_transaction_id_value), changeset)) + .returning(FiatTransactionRow::as_returning()) + .get_result(&mut self.connection) + .optional() + } + + fn get_fiat_transaction_for_quote(&mut self, provider: &FiatProviderNameRow, target_quote_id: &str) -> Result, diesel::result::Error> { + use crate::schema::fiat_transactions::dsl::*; + + fiat_transactions + .filter(provider_id.eq(provider)) + .filter(quote_id.eq(target_quote_id)) + .order((created_at.desc(), id.desc())) + .select(FiatTransactionRow::as_select()) + .first(&mut self.connection) + .optional() + } + + fn update_fiat_transaction_by_id(&mut self, transaction_id: i32, changeset: UpdateFiatTransactionRow) -> Result { + use crate::schema::fiat_transactions::dsl::*; + + diesel::update(fiat_transactions.find(transaction_id)) + .set(changeset) + .returning(FiatTransactionRow::as_returning()) + .get_result(&mut self.connection) + } + + pub fn add_fiat_assets(&mut self, values: Vec) -> Result { + FiatStore::add_fiat_assets(self, values) + } + + pub fn add_fiat_providers(&mut self, values: Vec) -> Result { + FiatStore::add_fiat_providers(self, values) + } + + pub fn add_fiat_providers_countries(&mut self, values: Vec) -> Result { + FiatStore::add_fiat_providers_countries(self, values) + } + + pub fn get_fiat_providers_countries(&mut self) -> Result, diesel::result::Error> { + FiatStore::get_fiat_providers_countries(self) + } + + pub fn update_fiat_transaction(&mut self, provider: FiatProviderName, update: FiatTransactionUpdate) -> Result { + FiatStore::update_fiat_transaction(self, provider, update) + } + + pub fn get_fiat_transaction(&mut self, provider: FiatProviderName, transaction_id: &str) -> Result, diesel::result::Error> { + FiatStore::get_fiat_transaction(self, provider, transaction_id) + } + + pub fn get_fiat_transactions_by_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error> { + FiatStore::get_fiat_transactions_by_addresses(self, addresses) + } + + pub fn get_fiat_assets_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + FiatStore::get_fiat_assets_by_filter(self, filters) + } + + pub fn get_fiat_assets_popular(&mut self, from: NaiveDateTime, limit: i64) -> Result, diesel::result::Error> { + FiatStore::get_fiat_assets_popular(self, from, limit) + } + + pub fn set_fiat_rates(&mut self, rates: Vec) -> Result { + FiatStore::set_fiat_rates(self, rates) + } + + pub fn get_fiat_rates(&mut self) -> Result, diesel::result::Error> { + FiatStore::get_fiat_rates(self) + } + + pub fn get_fiat_rate(&mut self, currency: &str) -> Result { + FiatStore::get_fiat_rate(self, currency) + } + + pub fn get_fiat_providers(&mut self) -> Result, diesel::result::Error> { + FiatStore::get_fiat_providers(self) + } + + pub fn add_fiat_transaction(&mut self, transaction: NewFiatTransactionRow) -> Result { + FiatStore::add_fiat_transaction(self, transaction) + } + + pub fn update_fiat_provider_payment_methods(&mut self, provider_id: FiatProviderName, values: serde_json::Value) -> Result { + FiatStore::update_fiat_provider_payment_methods(self, provider_id, values) + } +} diff --git a/core/crates/storage/src/database/migrations.rs b/core/crates/storage/src/database/migrations.rs new file mode 100644 index 0000000000..60d9253eae --- /dev/null +++ b/core/crates/storage/src/database/migrations.rs @@ -0,0 +1,13 @@ +use super::MIGRATIONS; +use crate::DatabaseClient; +use diesel_migrations::MigrationHarness; + +pub(crate) trait MigrationsStore { + fn run_migrations(&mut self); +} + +impl MigrationsStore for DatabaseClient { + fn run_migrations(&mut self) { + self.connection.run_pending_migrations(MIGRATIONS).unwrap(); + } +} diff --git a/core/crates/storage/src/database/mod.rs b/core/crates/storage/src/database/mod.rs new file mode 100644 index 0000000000..2a3f281d1b --- /dev/null +++ b/core/crates/storage/src/database/mod.rs @@ -0,0 +1,166 @@ +pub mod assets; +pub mod assets_addresses; +pub mod assets_links; +pub mod assets_usage_ranks; + +pub mod chains; +pub mod charts; +pub mod config; +pub mod devices; +pub mod fiat; +pub mod migrations; +pub mod nft; +pub mod notifications; +pub mod parser_state; +pub mod perpetuals; +pub mod price_alerts; +pub mod prices; +pub mod prices_providers; +pub mod referrals; +pub mod releases; +pub mod rewards; +pub mod rewards_redemptions; +pub mod scan_addresses; +pub mod tag; +pub mod transactions; +pub mod usernames; +pub mod wallets; +pub mod webhooks; + +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, Pool, PooledConnection}; +use diesel_migrations::{EmbeddedMigrations, embed_migrations}; +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/migrations"); + +pub type PgPool = Pool>; +pub type PgPooledConnection = PooledConnection>; + +use crate::repositories::{ + assets_addresses_repository::AssetsAddressesRepository, assets_links_repository::AssetsLinksRepository, assets_repository::AssetsRepository, + assets_usage_ranks_repository::AssetsUsageRanksRepository, chains_repository::ChainsRepository, charts_repository::ChartsRepository, config_repository::ConfigRepository, + devices_repository::DevicesRepository, fiat_repository::FiatRepository, migrations_repository::MigrationsRepository, nft_repository::NftRepository, + notifications_repository::NotificationsRepository, parser_state_repository::ParserStateRepository, perpetuals_repository::PerpetualsRepository, + price_alerts_repository::PriceAlertsRepository, prices_providers_repository::PricesProvidersRepository, prices_repository::PricesRepository, + releases_repository::ReleasesRepository, rewards_redemptions_repository::RewardsRedemptionsRepository, rewards_repository::RewardsRepository, + scan_addresses_repository::ScanAddressesRepository, tag_repository::TagRepository, transactions_repository::TransactionsRepository, wallets_repository::WalletsRepository, + webhooks_repository::WebhooksRepository, +}; + +pub fn create_pool(database_url: &str, pool_size: u32) -> PgPool { + let manager = ConnectionManager::::new(database_url); + Pool::builder() + .max_size(pool_size) + .build(manager) + .unwrap_or_else(|_| panic!("Error creating connection pool for {database_url}")) +} + +pub struct DatabaseClient { + connection: PgPooledConnection, +} + +impl DatabaseClient { + pub fn from_pool(pool: &PgPool) -> Result { + let connection = pool.get()?; + Ok(Self { connection }) + } + + pub fn assets(&mut self) -> &mut dyn AssetsRepository { + self + } + + pub fn assets_addresses(&mut self) -> &mut dyn AssetsAddressesRepository { + self + } + + pub fn assets_links(&mut self) -> &mut dyn AssetsLinksRepository { + self + } + + pub fn assets_usage_ranks(&mut self) -> &mut dyn AssetsUsageRanksRepository { + self + } + + pub fn chains(&mut self) -> &mut dyn ChainsRepository { + self + } + + pub fn charts(&mut self) -> &mut dyn ChartsRepository { + self + } + + pub fn config(&mut self) -> &mut dyn ConfigRepository { + self + } + + pub fn devices(&mut self) -> &mut dyn DevicesRepository { + self + } + + pub fn fiat(&mut self) -> &mut dyn FiatRepository { + self + } + + pub fn migrations(&mut self) -> &mut dyn MigrationsRepository { + self + } + + pub fn perpetuals(&mut self) -> &mut dyn PerpetualsRepository { + self + } + + pub fn nft(&mut self) -> &mut dyn NftRepository { + self + } + + pub fn notifications(&mut self) -> &mut dyn NotificationsRepository { + self + } + + pub fn parser_state(&mut self) -> &mut dyn ParserStateRepository { + self + } + + pub fn price_alerts(&mut self) -> &mut dyn PriceAlertsRepository { + self + } + + pub fn prices(&mut self) -> &mut dyn PricesRepository { + self + } + + pub fn prices_providers(&mut self) -> &mut dyn PricesProvidersRepository { + self + } + + pub fn rewards(&mut self) -> &mut dyn RewardsRepository { + self + } + + pub fn rewards_redemptions(&mut self) -> &mut dyn RewardsRedemptionsRepository { + self + } + + pub fn releases(&mut self) -> &mut dyn ReleasesRepository { + self + } + + pub fn scan_addresses(&mut self) -> &mut dyn ScanAddressesRepository { + self + } + + pub fn tag(&mut self) -> &mut dyn TagRepository { + self + } + + pub fn transactions(&mut self) -> &mut dyn TransactionsRepository { + self + } + + pub fn wallets(&mut self) -> &mut dyn WalletsRepository { + self + } + + pub fn webhooks(&mut self) -> &mut dyn WebhooksRepository { + self + } +} diff --git a/core/crates/storage/src/database/nft.rs b/core/crates/storage/src/database/nft.rs new file mode 100644 index 0000000000..3c61d07993 --- /dev/null +++ b/core/crates/storage/src/database/nft.rs @@ -0,0 +1,187 @@ +use crate::sql_types::ChainRow; +use crate::{DatabaseClient, models::*}; + +use chrono::NaiveDateTime; +use diesel::prelude::*; +use nft_asset::NewNftAssetRow; +use nft_asset_association::NewNftAssetAssociationRow; +use nft_link::NftLinkRow; +use nft_report::NewNftReportRow; +use primitives::Chain; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NftCollectionFilter { + UpdatedSince(NaiveDateTime), + Ids(Vec), + Identifiers(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NftAssetFilter { + Identifiers(Vec), + AddressId(i32), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NftAssetAssociationFilter { + AddressId(i32), + Chains(Vec), +} + +pub(crate) trait NftStore { + fn get_nft_assets_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error>; + fn get_nft_asset(&mut self, identifier: &str) -> Result; + fn add_nft_assets(&mut self, values: Vec) -> Result; + fn upsert_nft_asset(&mut self, value: NewNftAssetRow) -> Result; + fn get_nft_collections_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error>; + fn get_nft_collection(&mut self, identifier: &str) -> Result; + fn get_nft_collection_links(&mut self, collection_id: i32) -> Result, diesel::result::Error>; + fn add_nft_collections(&mut self, values: Vec) -> Result; + fn upsert_nft_collection(&mut self, value: NewNftCollectionRow) -> Result; + fn add_nft_collections_links(&mut self, values: Vec) -> Result; + fn set_nft_collection_links(&mut self, collection_id: i32, values: Vec) -> Result; + fn add_nft_report(&mut self, report: NewNftReportRow) -> Result; + fn get_nft_asset_association_ids_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error>; + fn add_nft_asset_associations(&mut self, values: Vec) -> Result; + fn delete_nft_asset_associations(&mut self, address_id: i32, asset_ids: Vec) -> Result; +} + +impl NftStore for DatabaseClient { + fn get_nft_assets_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + use crate::schema::nft_assets::dsl::*; + use crate::schema::nft_assets_associations; + + let mut query = nft_assets.into_boxed(); + for filter in filters { + match filter { + NftAssetFilter::Identifiers(values) => query = query.filter(identifier.eq_any(values)), + NftAssetFilter::AddressId(value) => { + query = query.filter( + id.eq_any( + nft_assets_associations::table + .filter(nft_assets_associations::address_id.eq(value)) + .select(nft_assets_associations::asset_id), + ), + ); + } + } + } + query.select(NftAssetRow::as_select()).load(&mut self.connection) + } + + fn get_nft_asset(&mut self, _identifier: &str) -> Result { + use crate::schema::nft_assets::dsl::*; + nft_assets.filter(identifier.eq(_identifier)).select(NftAssetRow::as_select()).first(&mut self.connection) + } + + fn add_nft_assets(&mut self, values: Vec) -> Result { + use crate::schema::nft_assets::dsl::*; + diesel::insert_into(nft_assets).values(values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn upsert_nft_asset(&mut self, value: NewNftAssetRow) -> Result { + use crate::schema::nft_assets::dsl::*; + diesel::insert_into(nft_assets) + .values(&value) + .on_conflict(identifier) + .do_update() + .set(&value) + .returning(NftAssetRow::as_returning()) + .get_result(&mut self.connection) + } + + fn get_nft_collections_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + use crate::schema::nft_collections::dsl::*; + let mut query = nft_collections.into_boxed(); + for filter in filters { + match filter { + NftCollectionFilter::UpdatedSince(value) => query = query.filter(updated_at.gt(value)), + NftCollectionFilter::Ids(values) => query = query.filter(id.eq_any(values)), + NftCollectionFilter::Identifiers(values) => query = query.filter(identifier.eq_any(values)), + } + } + query.select(NftCollectionRow::as_select()).load(&mut self.connection) + } + + fn get_nft_collection(&mut self, _identifier: &str) -> Result { + use crate::schema::nft_collections::dsl::*; + nft_collections + .filter(identifier.eq(_identifier)) + .select(NftCollectionRow::as_select()) + .first(&mut self.connection) + } + + fn get_nft_collection_links(&mut self, _collection_id: i32) -> Result, diesel::result::Error> { + use crate::schema::nft_collections_links::dsl::*; + nft_collections_links + .filter(collection_id.eq(_collection_id)) + .select(NftLinkRow::as_select()) + .load(&mut self.connection) + } + + fn add_nft_collections(&mut self, values: Vec) -> Result { + use crate::schema::nft_collections::dsl::*; + diesel::insert_into(nft_collections).values(values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn upsert_nft_collection(&mut self, value: NewNftCollectionRow) -> Result { + use crate::schema::nft_collections::dsl::*; + diesel::insert_into(nft_collections) + .values(&value) + .on_conflict(identifier) + .do_update() + .set(&value) + .returning(NftCollectionRow::as_returning()) + .get_result(&mut self.connection) + } + + fn add_nft_collections_links(&mut self, values: Vec) -> Result { + use crate::schema::nft_collections_links::dsl::*; + diesel::insert_into(nft_collections_links) + .values(values) + .on_conflict((collection_id, link_type)) + .do_nothing() + .execute(&mut self.connection) + } + + fn set_nft_collection_links(&mut self, collection_row_id: i32, values: Vec) -> Result { + use crate::schema::nft_collections_links::dsl::*; + diesel::delete(nft_collections_links.filter(collection_id.eq(collection_row_id))).execute(&mut self.connection)?; + if values.is_empty() { + return Ok(0); + } + self.add_nft_collections_links(values) + } + + fn add_nft_report(&mut self, report: NewNftReportRow) -> Result { + use crate::schema::nft_reports::dsl::*; + diesel::insert_into(nft_reports).values(report).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn get_nft_asset_association_ids_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + use crate::schema::nft_assets::dsl::{chain as asset_chain, id as asset_pk, nft_assets}; + use crate::schema::nft_assets_associations::dsl::*; + let mut query = nft_assets_associations.inner_join(nft_assets.on(asset_pk.eq(asset_id))).into_boxed(); + for filter in filters { + match filter { + NftAssetAssociationFilter::AddressId(value) => query = query.filter(address_id.eq(value)), + NftAssetAssociationFilter::Chains(values) => query = query.filter(asset_chain.eq_any(values.into_iter().map(ChainRow::from).collect::>())), + } + } + query.select(asset_id).load(&mut self.connection) + } + + fn add_nft_asset_associations(&mut self, values: Vec) -> Result { + use crate::schema::nft_assets_associations::dsl::*; + diesel::insert_into(nft_assets_associations) + .values(values) + .on_conflict((address_id, asset_id)) + .do_nothing() + .execute(&mut self.connection) + } + + fn delete_nft_asset_associations(&mut self, _address_id: i32, asset_ids: Vec) -> Result { + use crate::schema::nft_assets_associations::dsl::*; + diesel::delete(nft_assets_associations.filter(address_id.eq(_address_id)).filter(asset_id.eq_any(asset_ids))).execute(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/notifications.rs b/core/crates/storage/src/database/notifications.rs new file mode 100644 index 0000000000..9fe920a1c6 --- /dev/null +++ b/core/crates/storage/src/database/notifications.rs @@ -0,0 +1,55 @@ +use crate::models::{AssetRow, NewNotificationRow, NotificationRow}; +use crate::schema::{assets, devices, notifications, wallets, wallets_subscriptions}; +use crate::{DatabaseClient, DatabaseError}; +use chrono::NaiveDateTime; +use diesel::prelude::*; + +type WalletIdsSubquery<'a> = diesel::dsl::Select< + diesel::dsl::Filter, diesel::dsl::Eq>, + wallets_subscriptions::wallet_id, +>; + +fn wallet_ids_by_device_id(device_id: &str) -> WalletIdsSubquery<'_> { + wallets_subscriptions::table + .inner_join(devices::table) + .filter(devices::device_id.eq(device_id)) + .select(wallets_subscriptions::wallet_id) +} + +pub trait NotificationsStore { + fn get_notifications_by_device_id(&mut self, device_id: &str, from_datetime: Option) -> Result)>, DatabaseError>; + fn create_notifications(&mut self, notifications: Vec) -> Result; + fn mark_all_as_read(&mut self, device_id: &str) -> Result; +} + +impl NotificationsStore for DatabaseClient { + fn get_notifications_by_device_id(&mut self, device_id: &str, from_datetime: Option) -> Result)>, DatabaseError> { + let mut query = notifications::table + .inner_join(wallets::table) + .left_join(assets::table) + .filter(notifications::wallet_id.eq_any(wallet_ids_by_device_id(device_id))) + .order(notifications::created_at.desc()) + .select((NotificationRow::as_select(), wallets::identifier, Option::::as_select())) + .into_boxed(); + + if let Some(datetime) = from_datetime { + query = query.filter(notifications::created_at.gt(datetime)); + } + + Ok(query.load(&mut self.connection)?) + } + + fn create_notifications(&mut self, values: Vec) -> Result { + Ok(diesel::insert_into(notifications::table).values(&values).execute(&mut self.connection)?) + } + + fn mark_all_as_read(&mut self, device_id: &str) -> Result { + let count = diesel::update(notifications::table) + .filter(notifications::wallet_id.eq_any(wallet_ids_by_device_id(device_id))) + .filter(notifications::is_read.eq(false)) + .set((notifications::is_read.eq(true), notifications::read_at.eq(diesel::dsl::now))) + .execute(&mut self.connection)?; + + Ok(count) + } +} diff --git a/core/crates/storage/src/database/parser_state.rs b/core/crates/storage/src/database/parser_state.rs new file mode 100644 index 0000000000..a8554cac17 --- /dev/null +++ b/core/crates/storage/src/database/parser_state.rs @@ -0,0 +1,71 @@ +use crate::{DatabaseClient, models::*, sql_types::ChainRow}; + +use diesel::prelude::*; +use primitives::Chain; + +pub(crate) trait ParserStateStore { + fn get_parser_state(&mut self, chain: Chain) -> Result; + fn add_parser_state(&mut self, chain: Chain, block_time_ms: i32) -> Result; + fn get_parser_states(&mut self) -> Result, diesel::result::Error>; + fn set_parser_state_latest_block(&mut self, chain: Chain, block: i64) -> Result; + fn set_parser_state_current_block(&mut self, chain: Chain, block: i64) -> Result; +} + +impl ParserStateStore for DatabaseClient { + fn get_parser_state(&mut self, chain_value: Chain) -> Result { + use crate::schema::parser_state::dsl::*; + parser_state + .filter(chain.eq(ChainRow::from(chain_value))) + .select(ParserStateRow::as_select()) + .first(&mut self.connection) + } + + fn add_parser_state(&mut self, chain_value: Chain, block_time_ms: i32) -> Result { + use crate::schema::parser_state::dsl::*; + diesel::insert_into(parser_state) + .values((chain.eq(ChainRow::from(chain_value)), block_time.eq(block_time_ms))) + .on_conflict_do_nothing() + .execute(&mut self.connection) + } + + fn get_parser_states(&mut self) -> Result, diesel::result::Error> { + use crate::schema::parser_state::dsl::*; + parser_state.select(ParserStateRow::as_select()).load(&mut self.connection) + } + + fn set_parser_state_latest_block(&mut self, chain_value: Chain, block: i64) -> Result { + use crate::schema::parser_state::dsl::*; + diesel::update(parser_state.find(ChainRow::from(chain_value))) + .set(latest_block.eq(block)) + .execute(&mut self.connection) + } + + fn set_parser_state_current_block(&mut self, chain_value: Chain, block: i64) -> Result { + use crate::schema::parser_state::dsl::*; + diesel::update(parser_state.find(ChainRow::from(chain_value))) + .set(current_block.eq(block)) + .execute(&mut self.connection) + } +} + +impl DatabaseClient { + pub fn get_parser_state(&mut self, chain: Chain) -> Result { + ParserStateStore::get_parser_state(self, chain) + } + + pub fn add_parser_state(&mut self, chain: Chain, block_time_ms: i32) -> Result { + ParserStateStore::add_parser_state(self, chain, block_time_ms) + } + + pub fn get_parser_states(&mut self) -> Result, diesel::result::Error> { + ParserStateStore::get_parser_states(self) + } + + pub fn set_parser_state_latest_block(&mut self, chain: Chain, block: i64) -> Result { + ParserStateStore::set_parser_state_latest_block(self, chain, block) + } + + pub fn set_parser_state_current_block(&mut self, chain: Chain, block: i64) -> Result { + ParserStateStore::set_parser_state_current_block(self, chain, block) + } +} diff --git a/core/crates/storage/src/database/perpetuals.rs b/core/crates/storage/src/database/perpetuals.rs new file mode 100644 index 0000000000..a17d9091d5 --- /dev/null +++ b/core/crates/storage/src/database/perpetuals.rs @@ -0,0 +1,64 @@ +use crate::DatabaseClient; +use crate::models::{NewPerpetualRow, PerpetualRow}; +use crate::schema::{perpetuals, perpetuals_assets}; +use chrono::NaiveDateTime; +use diesel::{prelude::*, upsert::excluded}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PerpetualFilter { + UpdatedSince(NaiveDateTime), +} + +pub(crate) trait PerpetualsStore { + fn perpetuals_update(&mut self, values: Vec) -> Result; + + fn get_perpetuals_for_asset(&mut self, asset_id_value: &str) -> Result, diesel::result::Error>; + + fn get_perpetuals_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error>; +} + +impl PerpetualsStore for DatabaseClient { + fn perpetuals_update(&mut self, values: Vec) -> Result { + if values.is_empty() { + return Ok(0); + } + diesel::insert_into(perpetuals::table) + .values(&values) + .on_conflict(perpetuals::id) + .do_update() + .set(( + perpetuals::name.eq(excluded(perpetuals::name)), + perpetuals::provider.eq(excluded(perpetuals::provider)), + perpetuals::asset_id.eq(excluded(perpetuals::asset_id)), + perpetuals::price.eq(excluded(perpetuals::price)), + perpetuals::price_percent_change_24h.eq(excluded(perpetuals::price_percent_change_24h)), + perpetuals::open_interest.eq(excluded(perpetuals::open_interest)), + perpetuals::volume_24h.eq(excluded(perpetuals::volume_24h)), + perpetuals::funding.eq(excluded(perpetuals::funding)), + perpetuals::leverage.eq(excluded(perpetuals::leverage)), + )) + .execute(&mut self.connection) + } + + fn get_perpetuals_for_asset(&mut self, asset_id_value: &str) -> Result, diesel::result::Error> { + perpetuals::table + .inner_join(perpetuals_assets::table.on(perpetuals::id.eq(perpetuals_assets::perpetual_id))) + .filter(perpetuals_assets::asset_id.eq(asset_id_value)) + .select(PerpetualRow::as_select()) + .load(&mut self.connection) + } + + fn get_perpetuals_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + let mut query = perpetuals::table.into_boxed(); + + for filter in filters { + match filter { + PerpetualFilter::UpdatedSince(value) => { + query = query.filter(perpetuals::updated_at.gt(value)); + } + } + } + + query.select(PerpetualRow::as_select()).load(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/price_alerts.rs b/core/crates/storage/src/database/price_alerts.rs new file mode 100644 index 0000000000..197e1f1f92 --- /dev/null +++ b/core/crates/storage/src/database/price_alerts.rs @@ -0,0 +1,67 @@ +use chrono::NaiveDateTime; + +use crate::{DatabaseClient, models::*}; +use diesel::prelude::*; + +pub(crate) trait PriceAlertsStore { + fn get_price_alerts(&mut self, after_notified_at: NaiveDateTime) -> Result, diesel::result::Error>; + fn get_price_alerts_for_device_id(&mut self, device_id: &str, asset_id: Option<&str>) -> Result, diesel::result::Error>; + fn add_price_alerts(&mut self, values: Vec) -> Result; + fn delete_price_alerts(&mut self, device_id: i32, ids: Vec) -> Result; + fn update_price_alerts_set_notified_at(&mut self, ids: Vec, last_notified_at: NaiveDateTime) -> Result; +} + +impl PriceAlertsStore for DatabaseClient { + fn get_price_alerts(&mut self, after_notified_at: NaiveDateTime) -> Result, diesel::result::Error> { + use crate::schema::devices; + use crate::schema::price_alerts::dsl::*; + + price_alerts + .filter( + (price_direction.is_not_null().and(last_notified_at.is_null())) + .or(price_direction.is_null().and(last_notified_at.lt(after_notified_at).or(last_notified_at.is_null()))), + ) + .inner_join(devices::table.on(device_id.eq(devices::id))) + .select((PriceAlertRow::as_select(), crate::models::DeviceRow::as_select())) + .load(&mut self.connection) + } + + fn get_price_alerts_for_device_id(&mut self, _device_id: &str, _asset_id: Option<&str>) -> Result, diesel::result::Error> { + use crate::schema::devices; + use crate::schema::price_alerts::dsl::*; + + let mut query = price_alerts + .inner_join(devices::table.on(device_id.eq(devices::id))) + .filter(devices::device_id.eq(_device_id)) + .into_boxed(); + + if let Some(_asset_id) = _asset_id { + query = query.filter(asset_id.eq(_asset_id)); + } + + query.select((PriceAlertRow::as_select(), crate::models::DeviceRow::as_select())).load(&mut self.connection) + } + + fn add_price_alerts(&mut self, values: Vec) -> Result { + use crate::schema::price_alerts::dsl::*; + diesel::insert_into(price_alerts) + .values(values) + .on_conflict((device_id, identifier)) + .do_update() + .set(last_notified_at.eq(Option::::None)) + .execute(&mut self.connection) + } + + fn delete_price_alerts(&mut self, _device_id: i32, ids: Vec) -> Result { + use crate::schema::price_alerts::dsl::*; + diesel::delete(price_alerts.filter(device_id.eq(_device_id).and(identifier.eq_any(ids)))).execute(&mut self.connection) + } + + fn update_price_alerts_set_notified_at(&mut self, ids: Vec, _last_notified_at: NaiveDateTime) -> Result { + use crate::schema::price_alerts::dsl::*; + diesel::update(price_alerts) + .filter(identifier.eq_any(&ids)) + .set(last_notified_at.eq(_last_notified_at)) + .execute(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/prices.rs b/core/crates/storage/src/database/prices.rs new file mode 100644 index 0000000000..00ca65240d --- /dev/null +++ b/core/crates/storage/src/database/prices.rs @@ -0,0 +1,161 @@ +use crate::models::price::{NewPriceRow, PricesChangeset}; +use crate::sql_types::PriceProviderRow; +use crate::{DatabaseClient, models::*}; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use diesel::sql_types::{Nullable, SingleValue, SqlType}; +use diesel::upsert::excluded; +use primitives::PriceProvider; + +diesel::define_sql_function! { + fn coalesce(a: Nullable, b: Nullable) -> Nullable; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssetsWithPricesFilter { + UpdatedSince(NaiveDateTime), +} + +#[derive(Debug, Clone)] +pub enum PriceFilter { + Provider(PriceProvider), + UpdatedBefore(NaiveDateTime), + UpdatedAfter(NaiveDateTime), + Ids(Vec), +} + +#[derive(Debug, Clone)] +pub enum PriceUpdate { + AllTimeHigh { value: f64, date: Option }, + AllTimeLow { value: f64, date: Option }, + PriceChangePercentage24h(f64), +} + +pub(crate) trait PricesStore { + fn add_prices(&mut self, values: Vec) -> Result; + fn set_prices(&mut self, values: Vec) -> Result; + fn set_prices_assets(&mut self, values: Vec) -> Result; + fn get_prices_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error>; + fn get_prices_assets(&mut self) -> Result, diesel::result::Error>; + fn get_prices_assets_by_provider(&mut self, provider: PriceProvider) -> Result, diesel::result::Error>; + fn get_prices_for_asset_ids(&mut self, asset_ids: &[String]) -> Result, diesel::result::Error>; + fn get_price_by_id(&mut self, price_id: &str) -> Result; + fn get_prices_assets_for_price_ids(&mut self, ids: Vec) -> Result, diesel::result::Error>; + fn delete_prices(&mut self, ids: Vec) -> Result; + fn get_asset_ids_updated_since(&mut self, since: NaiveDateTime) -> Result, diesel::result::Error>; + fn update_prices(&mut self, price_ids: &[String], changeset: &PricesChangeset) -> Result; +} + +impl PricesStore for DatabaseClient { + fn add_prices(&mut self, values: Vec) -> Result { + use crate::schema::prices::dsl::*; + if values.is_empty() { + return Ok(0); + } + diesel::insert_into(prices).values(&values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn set_prices(&mut self, values: Vec) -> Result { + use crate::schema::prices::dsl::*; + if values.is_empty() { + return Ok(0); + } + diesel::insert_into(prices) + .values(&values) + .on_conflict(id) + .do_update() + .set(( + price.eq(excluded(price)), + price_change_percentage_24h.eq(coalesce(excluded(price_change_percentage_24h), price_change_percentage_24h)), + market_cap_rank.eq(excluded(market_cap_rank)), + total_volume.eq(excluded(total_volume)), + last_updated_at.eq(excluded(last_updated_at)), + )) + .execute(&mut self.connection) + } + + fn set_prices_assets(&mut self, values: Vec) -> Result { + use crate::schema::prices_assets::dsl::*; + if values.is_empty() { + return Ok(0); + } + diesel::insert_into(prices_assets) + .values(&values) + .on_conflict((asset_id, provider)) + .do_update() + .set(price_id.eq(excluded(price_id))) + .execute(&mut self.connection) + } + + fn get_prices_by_filter(&mut self, filters: Vec) -> Result, diesel::result::Error> { + use crate::schema::prices::dsl::*; + let query = filters.into_iter().fold(prices.into_boxed(), |q, filter| match filter { + PriceFilter::Provider(p) => q.filter(provider.eq(PriceProviderRow::from(p))), + PriceFilter::UpdatedBefore(time) => q.filter(last_updated_at.lt(time).or(last_updated_at.is_null())), + PriceFilter::UpdatedAfter(time) => q.filter(last_updated_at.ge(time)), + PriceFilter::Ids(ids) => q.filter(id.eq_any(ids)), + }); + query.order(market_cap_rank.asc().nulls_last()).select(PriceRow::as_select()).load(&mut self.connection) + } + + fn get_prices_assets(&mut self) -> Result, diesel::result::Error> { + use crate::schema::prices_assets::dsl::*; + prices_assets.select(PriceAssetRow::as_select()).load(&mut self.connection) + } + + fn get_prices_assets_by_provider(&mut self, price_provider: PriceProvider) -> Result, diesel::result::Error> { + use crate::schema::prices_assets::dsl::*; + prices_assets + .filter(provider.eq(PriceProviderRow::from(price_provider))) + .select(PriceAssetRow::as_select()) + .load(&mut self.connection) + } + + fn get_prices_for_asset_ids(&mut self, asset_ids: &[String]) -> Result, diesel::result::Error> { + use crate::schema::{prices, prices_assets}; + + prices_assets::table + .inner_join(prices::table.on(prices_assets::price_id.eq(prices::id))) + .filter(prices_assets::asset_id.eq_any(asset_ids)) + .select((prices_assets::asset_id, PriceRow::as_select())) + .load::<(crate::sql_types::AssetId, PriceRow)>(&mut self.connection) + .map(|rows| rows.into_iter().map(|(id, row)| (id.0.to_string(), row)).collect()) + } + + fn get_price_by_id(&mut self, price_id: &str) -> Result { + use crate::schema::prices::dsl::*; + prices.filter(id.eq(price_id)).select(PriceRow::as_select()).first(&mut self.connection) + } + + fn get_prices_assets_for_price_ids(&mut self, ids: Vec) -> Result, diesel::result::Error> { + use crate::schema::prices_assets::dsl::*; + prices_assets.filter(price_id.eq_any(ids)).select(PriceAssetRow::as_select()).load(&mut self.connection) + } + + fn delete_prices(&mut self, ids: Vec) -> Result { + use crate::schema::prices::dsl::*; + if ids.is_empty() { + return Ok(0); + } + diesel::delete(prices.filter(id.eq_any(ids))).execute(&mut self.connection) + } + + fn get_asset_ids_updated_since(&mut self, since: NaiveDateTime) -> Result, diesel::result::Error> { + use crate::schema::{prices, prices_assets}; + + prices_assets::table + .inner_join(prices::table.on(prices_assets::price_id.eq(prices::id))) + .filter(prices::last_updated_at.gt(since)) + .select(prices_assets::asset_id) + .load::(&mut self.connection) + .map(|ids| ids.into_iter().map(|id| id.0.to_string()).collect()) + } + + fn update_prices(&mut self, price_ids: &[String], changeset: &PricesChangeset) -> Result { + use crate::schema::prices::dsl::*; + if price_ids.is_empty() { + return Ok(0); + } + diesel::update(prices.filter(id.eq_any(price_ids))).set(changeset).execute(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/prices_providers.rs b/core/crates/storage/src/database/prices_providers.rs new file mode 100644 index 0000000000..c7148b43b4 --- /dev/null +++ b/core/crates/storage/src/database/prices_providers.rs @@ -0,0 +1,29 @@ +use crate::DatabaseClient; +use crate::models::PriceProviderConfigRow; +use diesel::prelude::*; +use diesel::upsert::excluded; + +pub(crate) trait PricesProvidersStore { + fn add_prices_providers(&mut self, values: Vec) -> Result; + fn get_prices_providers(&mut self) -> Result, diesel::result::Error>; +} + +impl PricesProvidersStore for DatabaseClient { + fn add_prices_providers(&mut self, values: Vec) -> Result { + use crate::schema::prices_providers::dsl::*; + diesel::insert_into(prices_providers) + .values(&values) + .on_conflict(id) + .do_update() + .set((priority.eq(excluded(priority)),)) + .execute(&mut self.connection) + } + + fn get_prices_providers(&mut self) -> Result, diesel::result::Error> { + use crate::schema::prices_providers::dsl::*; + prices_providers + .order(priority.asc()) + .select(PriceProviderConfigRow::as_select()) + .load(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/referrals.rs b/core/crates/storage/src/database/referrals.rs new file mode 100644 index 0000000000..c35c262bdd --- /dev/null +++ b/core/crates/storage/src/database/referrals.rs @@ -0,0 +1,443 @@ +use crate::DatabaseClient; +use crate::models::{NewRewardReferralRow, NewRiskSignalRow, ReferralAttemptRow, RewardReferralRow, RiskSignalRow}; +use crate::sql_types::{Platform, RewardStatus}; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use diesel::result::Error as DieselError; +use primitives::Platform as PrimitivePlatform; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub enum ReferralUpdate { + VerifiedAt(NaiveDateTime), +} + +#[derive(Debug, Clone, Default)] +pub struct AbusePatterns { + pub max_countries_per_device: i64, + pub max_referrers_per_device: i64, + pub max_referrers_per_fingerprint: i64, + pub max_devices_per_ip: i64, + pub signals_in_velocity_window: i64, +} + +pub(crate) trait ReferralsStore { + fn add_referral(&mut self, referral: NewRewardReferralRow) -> Result<(), DieselError>; + fn get_referral_by_referred_device_id(&mut self, referred_device_id: i32) -> Result, DieselError>; + fn get_referral_by_username(&mut self, username: &str) -> Result, DieselError>; + fn update_referral(&mut self, referral_id: i32, update: ReferralUpdate) -> Result<(), DieselError>; + fn add_referral_attempt(&mut self, attempt: ReferralAttemptRow) -> Result<(), DieselError>; + fn count_referrals_since(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result; +} + +impl ReferralsStore for DatabaseClient { + fn add_referral(&mut self, referral: NewRewardReferralRow) -> Result<(), DieselError> { + use crate::schema::{rewards, rewards_referrals}; + use diesel::Connection; + + self.connection.transaction(|conn| { + diesel::insert_into(rewards_referrals::table).values(&referral).execute(conn)?; + + diesel::update(rewards::table.filter(rewards::username.eq(&referral.referred_username))) + .set(rewards::referrer_username.eq(&referral.referrer_username)) + .execute(conn)?; + + diesel::update(rewards::table.filter(rewards::username.eq(&referral.referrer_username))) + .set(rewards::referral_count.eq(rewards::referral_count + 1)) + .execute(conn)?; + + Ok(()) + }) + } + + fn get_referral_by_referred_device_id(&mut self, referred_device_id: i32) -> Result, DieselError> { + use crate::schema::rewards_referrals::dsl; + dsl::rewards_referrals + .filter(dsl::referred_device_id.eq(referred_device_id)) + .select(RewardReferralRow::as_select()) + .first(&mut self.connection) + .optional() + } + + fn get_referral_by_username(&mut self, username: &str) -> Result, DieselError> { + use crate::schema::rewards_referrals::dsl; + dsl::rewards_referrals.filter(dsl::referred_username.eq(username)).first(&mut self.connection).optional() + } + + fn update_referral(&mut self, referral_id: i32, update: ReferralUpdate) -> Result<(), DieselError> { + use crate::schema::rewards_referrals::dsl; + match update { + ReferralUpdate::VerifiedAt(timestamp) => { + diesel::update(dsl::rewards_referrals.find(referral_id)) + .set(dsl::verified_at.eq(timestamp)) + .execute(&mut self.connection)?; + } + } + Ok(()) + } + + fn add_referral_attempt(&mut self, attempt: ReferralAttemptRow) -> Result<(), DieselError> { + use crate::schema::rewards_referral_attempts::dsl; + diesel::insert_into(dsl::rewards_referral_attempts).values(&attempt).execute(&mut self.connection)?; + Ok(()) + } + + fn count_referrals_since(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_referrals::dsl; + dsl::rewards_referrals + .filter(dsl::referrer_username.eq(referrer_username)) + .filter(dsl::created_at.ge(since)) + .count() + .get_result(&mut self.connection) + } +} + +pub(crate) trait RiskSignalsStore { + fn add_risk_signal(&mut self, signal: NewRiskSignalRow) -> Result; + fn has_fingerprint_for_referrer(&mut self, fingerprint: &str, referrer_username: &str, since: NaiveDateTime) -> Result; + fn get_matching_risk_signals( + &mut self, + fingerprint: &str, + ip_address: &str, + ip_isp: &str, + device_model: &str, + device_id: i32, + since: NaiveDateTime, + ) -> Result, DieselError>; + fn count_signals_since(&mut self, ip_address: Option<&str>, since: NaiveDateTime) -> Result; + fn count_signals_for_device_id(&mut self, device_id: i32, since: NaiveDateTime) -> Result; + fn count_signals_for_country(&mut self, country_code: &str, since: NaiveDateTime) -> Result; + fn sum_risk_scores_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result; + fn count_attempts_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result; + fn get_referrer_usernames_with_referrals(&mut self, since: NaiveDateTime, min_referrals: i64) -> Result, DieselError>; + fn count_unique_countries_for_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result; + fn count_unique_referrers_for_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result; + fn count_unique_referrers_for_fingerprint(&mut self, fingerprint: &str, since: NaiveDateTime) -> Result; + fn count_unique_devices_for_ip(&mut self, ip_address: &str, since: NaiveDateTime) -> Result; + fn count_unique_referrers_for_device_model_pattern( + &mut self, + device_model: &str, + device_platform: PrimitivePlatform, + device_locale: &str, + since: NaiveDateTime, + ) -> Result; + fn get_abuse_patterns_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime, velocity_window_secs: i64) -> Result; + fn count_disabled_users_by_ip(&mut self, ip_address: &str, since: NaiveDateTime) -> Result; + fn count_disabled_users_by_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result; + fn count_unique_countries_for_referrer(&mut self, username: &str, since: NaiveDateTime) -> Result; + fn count_unique_devices_for_referrer(&mut self, username: &str, since: NaiveDateTime) -> Result; +} + +impl RiskSignalsStore for DatabaseClient { + fn add_risk_signal(&mut self, signal: NewRiskSignalRow) -> Result { + use crate::schema::rewards_risk_signals::dsl; + diesel::insert_into(dsl::rewards_risk_signals) + .values(&signal) + .returning(dsl::id) + .get_result(&mut self.connection) + } + + fn has_fingerprint_for_referrer(&mut self, fingerprint: &str, referrer_username: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::exists; + + diesel::select(exists( + dsl::rewards_risk_signals + .filter(dsl::fingerprint.eq(fingerprint)) + .filter(dsl::referrer_username.eq(referrer_username)) + .filter(dsl::created_at.ge(since)), + )) + .get_result(&mut self.connection) + } + + fn get_matching_risk_signals( + &mut self, + fingerprint: &str, + ip_address: &str, + ip_isp: &str, + device_model: &str, + device_id: i32, + since: NaiveDateTime, + ) -> Result, DieselError> { + use crate::schema::rewards_risk_signals::dsl; + + dsl::rewards_risk_signals + .filter(dsl::created_at.ge(since)) + .filter( + dsl::fingerprint + .eq(fingerprint) + .or(dsl::ip_address.eq(ip_address)) + .or(dsl::ip_isp.eq(ip_isp).and(dsl::device_model.eq(device_model))) + .or(dsl::device_id.eq(device_id)), + ) + .order(dsl::created_at.desc()) + .limit(100) + .select(RiskSignalRow::as_select()) + .load(&mut self.connection) + } + + fn count_signals_since(&mut self, ip_address: Option<&str>, since: NaiveDateTime) -> Result { + use crate::schema::rewards_referrals; + use crate::schema::rewards_risk_signals::dsl; + + let mut query = dsl::rewards_risk_signals + .inner_join(rewards_referrals::table.on(rewards_referrals::risk_signal_id.eq(dsl::id))) + .filter(dsl::created_at.ge(since)) + .into_boxed(); + + if let Some(ip) = ip_address { + query = query.filter(dsl::ip_address.eq(ip)); + } + + query.count().get_result(&mut self.connection) + } + + fn count_signals_for_device_id(&mut self, device_id: i32, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + + dsl::rewards_risk_signals + .filter(dsl::device_id.eq(device_id)) + .filter(dsl::created_at.ge(since)) + .count() + .get_result(&mut self.connection) + } + + fn count_signals_for_country(&mut self, country_code: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + + dsl::rewards_risk_signals + .filter(dsl::ip_country_code.eq(country_code)) + .filter(dsl::created_at.ge(since)) + .count() + .get_result(&mut self.connection) + } + + fn sum_risk_scores_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::sum; + + dsl::rewards_risk_signals + .filter(dsl::referrer_username.eq(referrer_username)) + .filter(dsl::created_at.ge(since)) + .select(sum(dsl::risk_score)) + .first::>(&mut self.connection) + .map(|s| s.unwrap_or(0)) + } + + fn count_attempts_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_referral_attempts::dsl; + + dsl::rewards_referral_attempts + .filter(dsl::referrer_username.eq(referrer_username)) + .filter(dsl::created_at.ge(since)) + .filter(dsl::risk_signal_id.is_not_null()) + .count() + .get_result(&mut self.connection) + } + + fn get_referrer_usernames_with_referrals(&mut self, since: NaiveDateTime, min_referrals: i64) -> Result, DieselError> { + use crate::schema::{rewards, rewards_referrals}; + use diesel::dsl::count_star; + + rewards_referrals::table + .inner_join(rewards::table.on(rewards_referrals::referrer_username.eq(rewards::username))) + .filter(rewards::status.ne(RewardStatus::Disabled)) + .filter(rewards_referrals::created_at.ge(since)) + .group_by(rewards_referrals::referrer_username) + .having(count_star().ge(min_referrals)) + .select(rewards_referrals::referrer_username) + .load(&mut self.connection) + } + + fn count_unique_countries_for_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + dsl::rewards_risk_signals + .filter(dsl::device_id.eq(device_id)) + .filter(dsl::created_at.ge(since)) + .select(count(dsl::ip_country_code).aggregate_distinct()) + .first(&mut self.connection) + } + + fn count_unique_referrers_for_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + dsl::rewards_risk_signals + .filter(dsl::device_id.eq(device_id)) + .filter(dsl::created_at.ge(since)) + .select(count(dsl::referrer_username).aggregate_distinct()) + .first(&mut self.connection) + } + + fn count_unique_referrers_for_fingerprint(&mut self, fingerprint: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + dsl::rewards_risk_signals + .filter(dsl::fingerprint.eq(fingerprint)) + .filter(dsl::created_at.ge(since)) + .select(count(dsl::referrer_username).aggregate_distinct()) + .first(&mut self.connection) + } + + fn count_unique_devices_for_ip(&mut self, ip_address: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + dsl::rewards_risk_signals + .filter(dsl::ip_address.eq(ip_address)) + .filter(dsl::created_at.ge(since)) + .select(count(dsl::device_id).aggregate_distinct()) + .first(&mut self.connection) + } + + fn count_unique_referrers_for_device_model_pattern( + &mut self, + device_model: &str, + device_platform: PrimitivePlatform, + device_locale: &str, + since: NaiveDateTime, + ) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + dsl::rewards_risk_signals + .filter(dsl::device_model.eq(device_model)) + .filter(dsl::device_platform.eq(Platform::from(device_platform))) + .filter(dsl::device_locale.eq(device_locale)) + .filter(dsl::created_at.ge(since)) + .select(count(dsl::referrer_username).aggregate_distinct()) + .first(&mut self.connection) + } + + fn get_abuse_patterns_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime, velocity_window_secs: i64) -> Result { + use crate::schema::rewards_risk_signals::dsl; + + let signals: Vec = dsl::rewards_risk_signals + .filter(dsl::referrer_username.eq(referrer_username)) + .filter(dsl::created_at.ge(since)) + .select(RiskSignalRow::as_select()) + .load(&mut self.connection)?; + + if signals.is_empty() { + return Ok(AbusePatterns::default()); + } + + let unique_devices: HashSet = signals.iter().map(|s| s.device_id).collect(); + let unique_fingerprints: HashSet<&str> = signals.iter().map(|s| s.fingerprint.as_str()).collect(); + let unique_ips: HashSet<&str> = signals.iter().map(|s| s.ip_address.as_str()).collect(); + + let mut max_countries_per_device: i64 = 0; + let mut max_referrers_per_device: i64 = 0; + let mut max_referrers_per_fingerprint: i64 = 0; + let mut max_devices_per_ip: i64 = 0; + + for device_id in unique_devices { + let countries = self.count_unique_countries_for_device(device_id, since)?; + max_countries_per_device = max_countries_per_device.max(countries); + + let referrers = self.count_unique_referrers_for_device(device_id, since)?; + max_referrers_per_device = max_referrers_per_device.max(referrers); + } + + for fingerprint in unique_fingerprints { + let referrers = self.count_unique_referrers_for_fingerprint(fingerprint, since)?; + max_referrers_per_fingerprint = max_referrers_per_fingerprint.max(referrers); + } + + for ip_address in unique_ips { + let devices = self.count_unique_devices_for_ip(ip_address, since)?; + max_devices_per_ip = max_devices_per_ip.max(devices); + } + + let max_signals_in_velocity_window = calculate_max_signals_in_window(&signals, velocity_window_secs); + + Ok(AbusePatterns { + max_countries_per_device, + max_referrers_per_device, + max_referrers_per_fingerprint, + max_devices_per_ip, + signals_in_velocity_window: max_signals_in_velocity_window, + }) + } + + fn count_disabled_users_by_ip(&mut self, ip_address: &str, since: NaiveDateTime) -> Result { + use crate::schema::{rewards, rewards_risk_signals}; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + rewards_risk_signals::table + .inner_join(rewards::table.on(rewards_risk_signals::referrer_username.eq(rewards::username))) + .filter(rewards_risk_signals::ip_address.eq(ip_address)) + .filter(rewards_risk_signals::created_at.ge(since)) + .filter(rewards::status.eq(RewardStatus::Disabled)) + .select(count(rewards_risk_signals::referrer_username).aggregate_distinct()) + .first(&mut self.connection) + } + + fn count_disabled_users_by_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result { + use crate::schema::{rewards, rewards_risk_signals}; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + rewards_risk_signals::table + .inner_join(rewards::table.on(rewards_risk_signals::referrer_username.eq(rewards::username))) + .filter(rewards_risk_signals::device_id.eq(device_id)) + .filter(rewards_risk_signals::created_at.ge(since)) + .filter(rewards::status.eq(RewardStatus::Disabled)) + .select(count(rewards_risk_signals::referrer_username).aggregate_distinct()) + .first(&mut self.connection) + } + + fn count_unique_countries_for_referrer(&mut self, username: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + dsl::rewards_risk_signals + .filter(dsl::referrer_username.eq(username)) + .filter(dsl::created_at.ge(since)) + .select(count(dsl::ip_country_code).aggregate_distinct()) + .first(&mut self.connection) + } + + fn count_unique_devices_for_referrer(&mut self, username: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_risk_signals::dsl; + use diesel::dsl::count; + use diesel::expression_methods::AggregateExpressionMethods; + + dsl::rewards_risk_signals + .filter(dsl::referrer_username.eq(username)) + .filter(dsl::created_at.ge(since)) + .select(count(dsl::device_id).aggregate_distinct()) + .first(&mut self.connection) + } +} + +fn calculate_max_signals_in_window(signals: &[RiskSignalRow], window_secs: i64) -> i64 { + if signals.is_empty() { + return 0; + } + + let mut timestamps: Vec<_> = signals.iter().map(|s| s.created_at).collect(); + timestamps.sort(); + + let mut max_count: i64 = 1; + let mut left = 0; + + for right in 0..timestamps.len() { + while timestamps[right].signed_duration_since(timestamps[left]).num_seconds() > window_secs { + left += 1; + } + max_count = max_count.max((right - left + 1) as i64); + } + + max_count +} diff --git a/core/crates/storage/src/database/releases.rs b/core/crates/storage/src/database/releases.rs new file mode 100644 index 0000000000..be71f1b19d --- /dev/null +++ b/core/crates/storage/src/database/releases.rs @@ -0,0 +1,41 @@ +use crate::{DatabaseClient, models::*, sql_types::PlatformStore}; + +use diesel::{prelude::*, upsert::excluded}; + +pub(crate) trait ReleasesStore { + fn get_releases(&mut self) -> Result, diesel::result::Error>; + fn get_release(&mut self, store: &PlatformStore) -> Result, diesel::result::Error>; + fn add_releases(&mut self, values: Vec) -> Result; + fn update_release(&mut self, release: ReleaseRow) -> Result; +} + +impl ReleasesStore for DatabaseClient { + fn get_releases(&mut self) -> Result, diesel::result::Error> { + use crate::schema::releases::dsl::*; + releases.order(updated_at.desc()).select(ReleaseRow::as_select()).load(&mut self.connection) + } + + fn get_release(&mut self, store: &PlatformStore) -> Result, diesel::result::Error> { + use crate::schema::releases::dsl::*; + releases + .filter(platform_store.eq(store)) + .select(ReleaseRow::as_select()) + .first(&mut self.connection) + .optional() + } + + fn add_releases(&mut self, values: Vec) -> Result { + use crate::schema::releases::dsl::*; + diesel::insert_into(releases).values(&values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn update_release(&mut self, release: ReleaseRow) -> Result { + use crate::schema::releases::dsl::*; + diesel::insert_into(releases) + .values(&release) + .on_conflict(platform_store) + .do_update() + .set(version.eq(excluded(version))) + .execute(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/rewards.rs b/core/crates/storage/src/database/rewards.rs new file mode 100644 index 0000000000..72c2f7056a --- /dev/null +++ b/core/crates/storage/src/database/rewards.rs @@ -0,0 +1,150 @@ +use crate::DatabaseClient; +use crate::models::{NewRewardEventRow, NewRewardsRow, RewardEventRow, RewardsRow}; +use crate::sql_types::{RewardEventType, RewardStatus}; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use diesel::result::Error as DieselError; +use primitives::RewardStatus as PrimitiveRewardStatus; + +#[derive(Debug, Clone)] +pub enum RewardsUpdate { + Status(RewardStatus), + VerifyAfter(NaiveDateTime), + ClearVerifyAfter, +} + +#[derive(Debug, Clone)] +pub enum RewardsFilter { + Username(String), + Statuses(Vec), + Limit(i64), +} + +pub(crate) trait RewardsStore { + fn get_rewards_by_filter(&mut self, filters: Vec) -> Result, DieselError>; + fn create_rewards(&mut self, rewards: NewRewardsRow) -> Result; + fn update_rewards(&mut self, username: &str, update: RewardsUpdate) -> Result; + fn add_event(&mut self, event: NewRewardEventRow, points: i32) -> Result; + fn get_event(&mut self, event_id: i32) -> Result; + fn get_events(&mut self, username: &str) -> Result, DieselError>; + fn get_top_referrers_since(&mut self, event_types: &[RewardEventType], since: NaiveDateTime, limit: i64) -> Result, DieselError>; + fn disable_rewards(&mut self, username: &str, reason: &str, comment: &str) -> Result; +} + +impl RewardsStore for DatabaseClient { + fn get_rewards_by_filter(&mut self, filters: Vec) -> Result, DieselError> { + use crate::schema::rewards::dsl; + let mut query = dsl::rewards.into_boxed(); + + for filter in filters { + match filter { + RewardsFilter::Username(username) => { + query = query.filter(dsl::username.eq(username)); + } + RewardsFilter::Statuses(statuses) => { + query = query.filter(dsl::status.eq_any(statuses.into_iter().map(RewardStatus::from).collect::>())); + } + RewardsFilter::Limit(limit) => { + query = query.limit(limit); + } + } + } + + query.select(RewardsRow::as_select()).load(&mut self.connection) + } + + fn create_rewards(&mut self, rewards: NewRewardsRow) -> Result { + use crate::schema::rewards::dsl; + diesel::insert_into(dsl::rewards) + .values(&rewards) + .returning(RewardsRow::as_returning()) + .get_result(&mut self.connection) + } + + fn update_rewards(&mut self, username: &str, update: RewardsUpdate) -> Result { + use crate::schema::rewards::dsl; + let target = dsl::rewards.filter(dsl::username.eq(username)); + match update { + RewardsUpdate::Status(status) => diesel::update(target).set(dsl::status.eq(status)).execute(&mut self.connection), + RewardsUpdate::VerifyAfter(dt) => diesel::update(target).set(dsl::verify_after.eq(dt)).execute(&mut self.connection), + RewardsUpdate::ClearVerifyAfter => diesel::update(target).set(dsl::verify_after.eq(None::)).execute(&mut self.connection), + } + } + + fn add_event(&mut self, new_event: NewRewardEventRow, points: i32) -> Result { + use crate::schema::{rewards, rewards_events}; + use diesel::Connection; + + if points < 0 { + return Err(DieselError::RollbackTransaction); + } + + self.connection.transaction(|conn| { + let event = diesel::insert_into(rewards_events::table) + .values(&new_event) + .returning(RewardEventRow::as_returning()) + .get_result(conn)?; + + diesel::update(rewards::table.filter(rewards::username.eq(&new_event.username))) + .set(rewards::points.eq(rewards::points + points)) + .returning(rewards::username) + .get_result::(conn)?; + + Ok(event) + }) + } + + fn get_event(&mut self, event_id: i32) -> Result { + use crate::schema::rewards_events::dsl; + dsl::rewards_events + .filter(dsl::id.eq(event_id)) + .select(RewardEventRow::as_select()) + .first(&mut self.connection) + } + + fn get_events(&mut self, username: &str) -> Result, DieselError> { + use crate::schema::rewards_events::dsl; + dsl::rewards_events + .filter(dsl::username.eq(username)) + .order(dsl::created_at.desc()) + .select(RewardEventRow::as_select()) + .load(&mut self.connection) + } + + fn get_top_referrers_since(&mut self, event_types: &[RewardEventType], since: NaiveDateTime, limit: i64) -> Result, DieselError> { + use crate::schema::{rewards, rewards_events}; + use diesel::dsl::count_star; + + rewards_events::table + .inner_join(rewards::table.on(rewards_events::username.eq(rewards::username))) + .filter(rewards::status.ne(RewardStatus::Disabled)) + .filter(rewards_events::event_type.eq_any(event_types)) + .filter(rewards_events::created_at.ge(since)) + .group_by(rewards_events::username) + .select((rewards_events::username, count_star())) + .order_by(count_star().desc()) + .limit(limit) + .load(&mut self.connection) + } + + fn disable_rewards(&mut self, username: &str, reason: &str, comment: &str) -> Result { + use crate::schema::{rewards, rewards_events}; + use diesel::Connection; + + self.connection.transaction(|conn| { + diesel::update(rewards::table.filter(rewards::username.eq(username))) + .set((rewards::status.eq(RewardStatus::Disabled), rewards::disable_reason.eq(reason), rewards::comment.eq(comment))) + .execute(conn)?; + + let event_id = diesel::insert_into(rewards_events::table) + .values(NewRewardEventRow { + username: username.to_string(), + event_type: RewardEventType::Disabled, + }) + .returning(rewards_events::id) + .get_result(conn)?; + + Ok(event_id) + }) + } +} diff --git a/core/crates/storage/src/database/rewards_redemptions.rs b/core/crates/storage/src/database/rewards_redemptions.rs new file mode 100644 index 0000000000..a46516cb71 --- /dev/null +++ b/core/crates/storage/src/database/rewards_redemptions.rs @@ -0,0 +1,117 @@ +use crate::DatabaseClient; +use crate::models::{AssetRow, NewRewardRedemptionRow, RedemptionOptionFull, RewardRedemptionOptionRow, RewardRedemptionRow}; +use crate::sql_types::{RedemptionStatus, RewardRedemptionType}; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use diesel::result::Error as DieselError; + +#[derive(Debug, Clone)] +pub enum RedemptionUpdate { + Status(RedemptionStatus), + TransactionId(String), + Error(String), +} + +pub(crate) trait RewardsRedemptionsStore { + fn add_redemption(&mut self, username: &str, points: i32, redemption: NewRewardRedemptionRow) -> Result; + fn update_redemption(&mut self, redemption_id: i32, updates: Vec) -> Result<(), DieselError>; + fn get_redemption(&mut self, redemption_id: i32) -> Result; + fn get_redemption_options(&mut self, types: &[RewardRedemptionType]) -> Result, DieselError>; + fn get_redemption_option(&mut self, id: &str) -> Result; + fn count_redemptions_since(&mut self, username: &str, since: NaiveDateTime) -> Result; +} + +impl RewardsRedemptionsStore for DatabaseClient { + fn add_redemption(&mut self, username: &str, points: i32, redemption: NewRewardRedemptionRow) -> Result { + use crate::schema::{rewards, rewards_redemption_options, rewards_redemptions}; + use diesel::Connection; + + if points < 0 { + return Err(DieselError::RollbackTransaction); + } + + self.connection.transaction(|conn| { + let rows_updated = diesel::update( + rewards_redemption_options::table.filter( + rewards_redemption_options::id + .eq(&redemption.option_id) + .and(rewards_redemption_options::remaining.is_null().or(rewards_redemption_options::remaining.gt(0))), + ), + ) + .set(rewards_redemption_options::remaining.eq(rewards_redemption_options::remaining - 1)) + .execute(conn)?; + + if rows_updated == 0 { + return Err(DieselError::NotFound); + } + + if points > 0 { + diesel::update(rewards::table.filter(rewards::username.eq(username).and(rewards::points.ge(points)))) + .set(rewards::points.eq(rewards::points - points)) + .returning(rewards::username) + .get_result::(conn)?; + } + + diesel::insert_into(rewards_redemptions::table) + .values(&redemption) + .returning(rewards_redemptions::id) + .get_result(conn) + }) + } + + fn update_redemption(&mut self, redemption_id: i32, updates: Vec) -> Result<(), DieselError> { + use crate::schema::rewards_redemptions::dsl; + + if updates.is_empty() { + return Ok(()); + } + + for update in updates { + let target = dsl::rewards_redemptions.find(redemption_id); + match update { + RedemptionUpdate::Status(value) => diesel::update(target).set(dsl::status.eq(value)).execute(&mut self.connection)?, + RedemptionUpdate::TransactionId(value) => diesel::update(target).set(dsl::transaction_id.eq(value)).execute(&mut self.connection)?, + RedemptionUpdate::Error(value) => diesel::update(target).set(dsl::error.eq(value)).execute(&mut self.connection)?, + }; + } + + Ok(()) + } + + fn get_redemption(&mut self, redemption_id: i32) -> Result { + use crate::schema::rewards_redemptions::dsl; + dsl::rewards_redemptions + .filter(dsl::id.eq(redemption_id)) + .select(RewardRedemptionRow::as_select()) + .first(&mut self.connection) + } + + fn get_redemption_options(&mut self, types: &[RewardRedemptionType]) -> Result, DieselError> { + use crate::schema::{assets, rewards_redemption_options}; + rewards_redemption_options::table + .filter(rewards_redemption_options::redemption_type.eq_any(types)) + .left_join(assets::table.on(rewards_redemption_options::asset_id.eq(assets::id.nullable()))) + .select((RewardRedemptionOptionRow::as_select(), Option::::as_select())) + .load::<(RewardRedemptionOptionRow, Option)>(&mut self.connection) + .map(|results| results.into_iter().map(|(option, asset)| RedemptionOptionFull::new(option, asset)).collect()) + } + + fn get_redemption_option(&mut self, id: &str) -> Result { + use crate::schema::{assets, rewards_redemption_options}; + rewards_redemption_options::table + .filter(rewards_redemption_options::id.eq(id)) + .left_join(assets::table.on(rewards_redemption_options::asset_id.eq(assets::id.nullable()))) + .select((RewardRedemptionOptionRow::as_select(), Option::::as_select())) + .first::<(RewardRedemptionOptionRow, Option)>(&mut self.connection) + .map(|(option, asset)| RedemptionOptionFull::new(option, asset)) + } + + fn count_redemptions_since(&mut self, username: &str, since: NaiveDateTime) -> Result { + use crate::schema::rewards_redemptions::dsl; + dsl::rewards_redemptions + .filter(dsl::username.eq(username)) + .filter(dsl::created_at.ge(since)) + .count() + .get_result(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/scan_addresses.rs b/core/crates/storage/src/database/scan_addresses.rs new file mode 100644 index 0000000000..dd2fcc08a2 --- /dev/null +++ b/core/crates/storage/src/database/scan_addresses.rs @@ -0,0 +1,24 @@ +use crate::{DatabaseClient, models::*}; + +use diesel::prelude::*; + +pub(crate) trait ScanAddressesStore { + fn get_scan_addresses_by_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error>; + fn add_scan_addresses(&mut self, values: Vec) -> Result; +} + +impl ScanAddressesStore for DatabaseClient { + fn get_scan_addresses_by_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error> { + use crate::schema::scan_addresses::dsl::*; + scan_addresses + .filter(address.eq_any(addresses)) + .order((address.asc(), id.asc())) + .select(ScanAddressRow::as_select()) + .load(&mut self.connection) + } + + fn add_scan_addresses(&mut self, values: Vec) -> Result { + use crate::schema::scan_addresses::dsl::*; + diesel::insert_into(scan_addresses).values(values).on_conflict_do_nothing().execute(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/tag.rs b/core/crates/storage/src/database/tag.rs new file mode 100644 index 0000000000..e2415403bb --- /dev/null +++ b/core/crates/storage/src/database/tag.rs @@ -0,0 +1,67 @@ +use crate::{DatabaseClient, models::*}; +use diesel::prelude::*; +use primitives::AssetId; + +pub(crate) trait TagStore { + fn add_tags(&mut self, values: Vec) -> Result; + fn add_assets_tags(&mut self, values: Vec) -> Result; + fn get_assets_tags(&mut self) -> Result, diesel::result::Error>; + fn get_assets_tags_for_tag(&mut self, _tag_id: &str) -> Result, diesel::result::Error>; + fn delete_assets_tags(&mut self, _tag_id: &str) -> Result; + fn set_assets_tags_for_tag(&mut self, _tag_id: &str, asset_ids: Vec) -> Result; + fn get_assets_tags_for_asset(&mut self, _asset_id: &str) -> Result, diesel::result::Error>; +} + +impl TagStore for DatabaseClient { + fn add_tags(&mut self, values: Vec) -> Result { + use crate::schema::tags::dsl::*; + diesel::insert_into(tags).values(values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn add_assets_tags(&mut self, values: Vec) -> Result { + use crate::schema::assets_tags::dsl::*; + diesel::insert_into(assets_tags).values(values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn get_assets_tags(&mut self) -> Result, diesel::result::Error> { + use crate::schema::assets_tags::dsl::*; + assets_tags.select(AssetTagRow::as_select()).load(&mut self.connection) + } + + fn get_assets_tags_for_tag(&mut self, _tag_id: &str) -> Result, diesel::result::Error> { + use crate::schema::assets_tags::dsl::*; + assets_tags + .filter(tag_id.eq(_tag_id)) + .order(order.asc()) + .select(AssetTagRow::as_select()) + .load(&mut self.connection) + } + + fn delete_assets_tags(&mut self, _tag_id: &str) -> Result { + use crate::schema::assets_tags::dsl::*; + diesel::delete(assets_tags.filter(tag_id.eq(_tag_id))).execute(&mut self.connection) + } + + fn set_assets_tags_for_tag(&mut self, _tag_id: &str, asset_ids: Vec) -> Result { + use crate::schema::assets_tags::dsl::*; + let values = asset_ids + .into_iter() + .enumerate() + .map(|(index, current_asset_id)| AssetTagRow { + asset_id: current_asset_id.into(), + tag_id: _tag_id.to_string(), + order: Some(index as i32), + }) + .collect::>(); + + self.connection.transaction::<_, diesel::result::Error, _>(|conn| { + diesel::delete(assets_tags.filter(tag_id.eq(_tag_id))).execute(conn)?; + diesel::insert_into(assets_tags).values(values).execute(conn) + }) + } + + fn get_assets_tags_for_asset(&mut self, _asset_id: &str) -> Result, diesel::result::Error> { + use crate::schema::assets_tags::dsl::*; + assets_tags.filter(asset_id.eq(_asset_id)).select(AssetTagRow::as_select()).load(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/transactions.rs b/core/crates/storage/src/database/transactions.rs new file mode 100644 index 0000000000..883fd63452 --- /dev/null +++ b/core/crates/storage/src/database/transactions.rs @@ -0,0 +1,281 @@ +use crate::{ + DatabaseClient, + models::*, + schema::transactions_addresses, + sql_types::{AssetId, TransactionState, TransactionType}, +}; +use chrono::NaiveDateTime; +use diesel::dsl::{count, sql}; +use diesel::prelude::*; +use diesel::sql_types::{Jsonb, Nullable}; +use diesel::upsert::excluded; +use primitives::Transaction; + +pub enum TransactionFilter { + States(Vec), + Kinds(Vec), +} + +#[derive(Debug, Clone)] +pub enum TransactionUpdate { + State(TransactionState), + Kind(TransactionType), + Metadata(serde_json::Value), +} + +pub(crate) trait TransactionsStore { + fn get_transaction_by_id(&mut self, chain: &str, hash: &str) -> Result; + fn get_transaction_exists(&mut self, chain: &str, hash: &str) -> Result; + fn add_transactions(&mut self, transactions: Vec) -> Result; + fn get_transactions_by_device_id( + &mut self, + _device_id: &str, + addresses: Vec, + chains: Vec, + asset_id: Option, + from_datetime: Option, + ) -> Result, diesel::result::Error>; + fn get_transactions_addresses(&mut self, min_count: i64, limit: i64, since: NaiveDateTime) -> Result, diesel::result::Error>; + fn delete_transactions_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error>; + fn delete_orphaned_transactions(&mut self, candidate_ids: Vec) -> Result; + fn get_asset_usage_counts(&mut self, since: NaiveDateTime) -> Result, diesel::result::Error>; + fn get_transactions_by_wallet_since(&mut self, wallet_id: i32, since: NaiveDateTime, filters: Vec) -> Result, diesel::result::Error>; + fn get_transactions_by_filter(&mut self, filters: Vec, limit: i64) -> Result, diesel::result::Error>; + fn update_transaction(&mut self, chain: &str, hash: &str, updates: Vec) -> Result; + fn get_addresses_by_chain_and_kind(&mut self, chain: &str, kinds: Vec, since: NaiveDateTime) -> Result, diesel::result::Error>; +} + +impl TransactionsStore for DatabaseClient { + fn get_transaction_by_id(&mut self, chain: &str, hash: &str) -> Result { + use crate::schema::transactions::dsl; + dsl::transactions + .filter(dsl::chain.eq(chain)) + .filter(dsl::hash.eq(hash)) + .select(TransactionRow::as_select()) + .first(&mut self.connection) + } + + fn get_transaction_exists(&mut self, chain: &str, hash: &str) -> Result { + use crate::schema::transactions::dsl; + + diesel::select(diesel::dsl::exists(dsl::transactions.filter(dsl::chain.eq(chain)).filter(dsl::hash.eq(hash)))).get_result(&mut self.connection) + } + + fn add_transactions(&mut self, transactions: Vec) -> Result { + use crate::schema::transactions::dsl; + + self.connection + .build_transaction() + .read_write() + .run::<_, diesel::result::Error, _>(|conn: &mut diesel::pg::PgConnection| { + let mut total_addresses = 0usize; + + for transaction in transactions { + let new_transaction = NewTransactionRow::from_primitive(transaction.clone()); + + let inserted: TransactionRow = diesel::insert_into(dsl::transactions) + .values(&new_transaction) + .on_conflict((dsl::chain, dsl::hash)) + .do_update() + .set(( + dsl::from_address.eq(excluded(dsl::from_address)), + dsl::to_address.eq(excluded(dsl::to_address)), + dsl::value.eq(excluded(dsl::value)), + dsl::kind.eq(excluded(dsl::kind)), + dsl::state.eq(excluded(dsl::state)), + dsl::fee.eq(excluded(dsl::fee)), + dsl::fee_asset_id.eq(excluded(dsl::fee_asset_id)), + dsl::memo.eq(excluded(dsl::memo)), + dsl::metadata.eq(sql::>("COALESCE(EXCLUDED.metadata, transactions.metadata)")), + dsl::utxo_inputs.eq(excluded(dsl::utxo_inputs)), + dsl::utxo_outputs.eq(excluded(dsl::utxo_outputs)), + )) + .returning(TransactionRow::as_select()) + .get_result(conn)?; + + let addresses = NewTransactionAddressesRow::from_transaction(inserted.id, &transaction); + + if !addresses.is_empty() { + use crate::schema::transactions_addresses::dsl as addr_dsl; + total_addresses += diesel::insert_into(addr_dsl::transactions_addresses) + .values(&addresses) + .on_conflict((addr_dsl::transaction_id, addr_dsl::address, addr_dsl::asset_id)) + .do_nothing() + .execute(conn)?; + } + } + + Ok(total_addresses) + }) + } + + fn get_transactions_by_device_id( + &mut self, + _device_id: &str, + addresses: Vec, + chains: Vec, + filter_asset_id: Option, + from_datetime: Option, + ) -> Result, diesel::result::Error> { + use crate::schema::transactions::dsl::*; + + let mut query = transactions + .into_boxed() + .inner_join(transactions_addresses::table) + .filter(chain.eq_any(chains.clone())) + .filter(transactions_addresses::address.eq_any(addresses)) + .filter(state.ne(TransactionState::InTransit)); + + if let Some(filter_asset) = filter_asset_id { + query = query.filter(asset_id.eq(filter_asset)); + } + + if let Some(datetime) = from_datetime { + query = query.filter(created_at.gt(datetime).or(updated_at.gt(datetime))); + } + + query.order(created_at.desc()).select(TransactionRow::as_select()).distinct().load(&mut self.connection) + } + + fn get_transactions_addresses(&mut self, min_count: i64, limit: i64, since: NaiveDateTime) -> Result, diesel::result::Error> { + use crate::schema::transactions::dsl as tx_dsl; + use crate::schema::transactions_addresses::dsl::*; + + transactions_addresses + .inner_join(tx_dsl::transactions) + .filter(tx_dsl::created_at.ge(since)) + .select((address, tx_dsl::chain)) + .group_by((address, tx_dsl::chain)) + .having(count(address).gt(min_count)) + .order_by(count(address).desc()) + .limit(limit) + .load::(&mut self.connection) + } + + fn delete_transactions_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error> { + use crate::schema::transactions_addresses::dsl::*; + diesel::delete(transactions_addresses) + .filter(address.eq_any(addresses)) + .returning(transaction_id) + .load(&mut self.connection) + } + + fn delete_orphaned_transactions(&mut self, candidate_ids: Vec) -> Result { + use crate::schema::transactions::dsl::*; + use crate::schema::transactions_addresses::dsl as addr; + + if candidate_ids.is_empty() { + return Ok(0); + } + + let ids: Vec = transactions + .filter(id.eq_any(&candidate_ids)) + .left_outer_join(addr::transactions_addresses.on(id.eq(addr::transaction_id))) + .filter(addr::transaction_id.is_null()) + .select(id) + .load(&mut self.connection)?; + + if ids.is_empty() { + return Ok(0); + } + + diesel::delete(transactions.filter(id.eq_any(ids))).execute(&mut self.connection) + } + + fn get_asset_usage_counts(&mut self, since: NaiveDateTime) -> Result, diesel::result::Error> { + use crate::schema::assets_addresses::dsl::*; + + assets_addresses + .filter(updated_at.ge(since)) + .group_by(asset_id) + .select((asset_id, count(asset_id))) + .load::<(AssetId, i64)>(&mut self.connection) + } + + fn get_transactions_by_wallet_since(&mut self, wallet_id: i32, since: NaiveDateTime, filters: Vec) -> Result, diesel::result::Error> { + use crate::schema::transactions::dsl as tx_dsl; + use crate::schema::transactions_addresses::dsl as addr_dsl; + use crate::schema::wallets_addresses::dsl as wallet_addr_dsl; + use crate::schema::wallets_subscriptions::dsl as wallet_sub_dsl; + + let mut query = tx_dsl::transactions + .inner_join(addr_dsl::transactions_addresses.on(tx_dsl::id.eq(addr_dsl::transaction_id))) + .inner_join(wallet_addr_dsl::wallets_addresses.on(addr_dsl::address.eq(wallet_addr_dsl::address))) + .inner_join(wallet_sub_dsl::wallets_subscriptions.on(wallet_addr_dsl::id.eq(wallet_sub_dsl::address_id))) + .into_boxed() + .filter(wallet_sub_dsl::wallet_id.eq(wallet_id)) + .filter(tx_dsl::created_at.ge(since)); + + for filter in filters { + match filter { + TransactionFilter::States(states) => { + query = query.filter(tx_dsl::state.eq_any(states)); + } + TransactionFilter::Kinds(kinds) => { + query = query.filter(tx_dsl::kind.eq_any(kinds)); + } + } + } + + query.distinct().select(TransactionRow::as_select()).load(&mut self.connection) + } + + fn get_transactions_by_filter(&mut self, filters: Vec, limit: i64) -> Result, diesel::result::Error> { + use crate::schema::transactions::dsl; + let mut query = dsl::transactions.into_boxed(); + + for filter in filters { + match filter { + TransactionFilter::States(states) => { + query = query.filter(dsl::state.eq_any(states)); + } + TransactionFilter::Kinds(kinds) => { + query = query.filter(dsl::kind.eq_any(kinds)); + } + } + } + + query + .order(dsl::created_at.asc()) + .limit(limit) + .select(TransactionRow::as_select()) + .load(&mut self.connection) + } + + fn update_transaction(&mut self, chain: &str, hash: &str, updates: Vec) -> Result { + use crate::schema::transactions::dsl; + + if updates.is_empty() { + return Ok(0); + } + + let target = dsl::transactions.filter(dsl::chain.eq(chain).and(dsl::hash.eq(hash))); + let mut total = 0; + + for update in &updates { + let updated = match update { + TransactionUpdate::State(state) => diesel::update(target).set(dsl::state.eq(state)).execute(&mut self.connection)?, + TransactionUpdate::Kind(kind) => diesel::update(target).set(dsl::kind.eq(kind)).execute(&mut self.connection)?, + TransactionUpdate::Metadata(metadata) => diesel::update(target).set(dsl::metadata.eq(metadata)).execute(&mut self.connection)?, + }; + total += updated; + } + + Ok(total) + } + + fn get_addresses_by_chain_and_kind(&mut self, chain: &str, kinds: Vec, since: NaiveDateTime) -> Result, diesel::result::Error> { + use crate::schema::transactions::dsl as tx_dsl; + use crate::schema::transactions_addresses::dsl::*; + + transactions_addresses + .inner_join(tx_dsl::transactions) + .filter(tx_dsl::chain.eq(chain)) + .filter(tx_dsl::kind.eq_any(kinds)) + .filter(tx_dsl::state.eq(TransactionState::Confirmed)) + .filter(tx_dsl::created_at.ge(since)) + .select(address) + .distinct() + .load::(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/usernames.rs b/core/crates/storage/src/database/usernames.rs new file mode 100644 index 0000000000..15421a3923 --- /dev/null +++ b/core/crates/storage/src/database/usernames.rs @@ -0,0 +1,49 @@ +use crate::DatabaseClient; +use crate::models::{NewUsernameRow, UsernameRow}; +use diesel::prelude::*; +use diesel::sql_types::Text; + +diesel::define_sql_function!(fn lower(x: Text) -> Text); + +pub enum UsernameLookup<'a> { + Username(&'a str), + WalletId(i32), +} + +pub(crate) trait UsernamesStore { + fn get_username(&mut self, lookup: UsernameLookup) -> Result; + fn create_username(&mut self, username: NewUsernameRow) -> Result; + fn update_username(&mut self, wallet_id: i32, new_username: &str) -> Result; +} + +impl UsernamesStore for DatabaseClient { + fn get_username(&mut self, lookup: UsernameLookup) -> Result { + use crate::schema::usernames::dsl; + match lookup { + UsernameLookup::Username(username) => dsl::usernames + .filter(lower(dsl::username).eq(username.to_lowercase())) + .select(UsernameRow::as_select()) + .first(&mut self.connection), + UsernameLookup::WalletId(wallet_id) => dsl::usernames + .filter(dsl::wallet_id.eq(wallet_id)) + .select(UsernameRow::as_select()) + .first(&mut self.connection), + } + } + + fn create_username(&mut self, username: NewUsernameRow) -> Result { + use crate::schema::usernames::dsl; + diesel::insert_into(dsl::usernames) + .values(&username) + .returning(UsernameRow::as_returning()) + .get_result(&mut self.connection) + } + + fn update_username(&mut self, wallet_id: i32, new_username: &str) -> Result { + use crate::schema::usernames::dsl; + diesel::update(dsl::usernames.filter(dsl::wallet_id.eq(wallet_id))) + .set(dsl::username.eq(new_username)) + .returning(UsernameRow::as_returning()) + .get_result(&mut self.connection) + } +} diff --git a/core/crates/storage/src/database/wallets.rs b/core/crates/storage/src/database/wallets.rs new file mode 100644 index 0000000000..a6b2da4336 --- /dev/null +++ b/core/crates/storage/src/database/wallets.rs @@ -0,0 +1,250 @@ +use crate::DatabaseClient; +use crate::models::{DeviceRow, NewWalletAddressRow, NewWalletRow, NewWalletSubscriptionRow, SubscriptionAddressExcludeRow, WalletAddressRow, WalletRow, WalletSubscriptionRow}; +use crate::schema::{devices, subscriptions_addresses_exclude, wallets, wallets_addresses, wallets_subscriptions}; +use crate::sql_types::ChainRow; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::Chain; + +pub trait WalletsStore { + fn get_wallet(&mut self, identifier: &str) -> Result; + fn get_wallet_by_device_and_identifier(&mut self, device_id: i32, identifier: &str) -> Result; + fn get_wallet_by_id(&mut self, id: i32) -> Result; + fn get_wallets(&mut self, identifiers: Vec) -> Result, diesel::result::Error>; + fn create_wallet(&mut self, wallet: NewWalletRow) -> Result; + fn create_wallets(&mut self, wallets: Vec) -> Result; + + fn get_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error>; + fn add_addresses(&mut self, addresses: Vec) -> Result; + + fn get_subscriptions_by_device_id(&mut self, device_id: i32) -> Result, diesel::result::Error>; + fn get_subscriptions_by_device_and_wallet(&mut self, device_id: i32, wallet_id: i32) -> Result, diesel::result::Error>; + fn subscriptions_wallet_address_for_chain(&mut self, device_id: i32, wallet_id: i32, chain: ChainRow) -> Result; + fn get_devices_by_wallet_id(&mut self, wallet_id: i32) -> Result, diesel::result::Error>; + fn add_subscriptions(&mut self, subscriptions: Vec) -> Result; + fn delete_subscriptions(&mut self, device_id: i32, wallet_id: i32, chain: ChainRow, address_ids: Vec) -> Result; + fn delete_wallet_subscriptions(&mut self, device_id: i32, wallet_ids: Vec) -> Result; + fn delete_wallet_chains(&mut self, device_id: i32, wallet_id: i32, chains: Vec) -> Result; + fn delete_subscriptions_for_device_ids(&mut self, device_ids: Vec) -> Result; + + fn get_subscriptions_by_chain_addresses( + &mut self, + chain: Chain, + addresses: Vec, + ) -> Result, diesel::result::Error>; + fn get_subscription_address_exists(&mut self, chain: Chain, address: &str) -> Result; + fn add_subscriptions_exclude_addresses(&mut self, values: Vec) -> Result; + fn get_subscriptions_exclude_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error>; + fn get_device_addresses(&mut self, device_id: i32, chain: ChainRow) -> Result, diesel::result::Error>; + fn get_first_subscription_date_by_wallet_id(&mut self, wallet_id: i32) -> Result, diesel::result::Error>; +} + +impl WalletsStore for DatabaseClient { + fn get_wallet(&mut self, identifier: &str) -> Result { + wallets::table + .filter(wallets::identifier.eq(identifier)) + .select(WalletRow::as_select()) + .first(&mut self.connection) + } + + fn get_wallet_by_device_and_identifier(&mut self, device_id: i32, identifier: &str) -> Result { + wallets::table + .inner_join(wallets_subscriptions::table.on(wallets_subscriptions::wallet_id.eq(wallets::id))) + .filter(wallets::identifier.eq(identifier)) + .filter(wallets_subscriptions::device_id.eq(device_id)) + .select(WalletRow::as_select()) + .first(&mut self.connection) + } + + fn get_wallet_by_id(&mut self, id: i32) -> Result { + wallets::table.filter(wallets::id.eq(id)).select(WalletRow::as_select()).first(&mut self.connection) + } + + fn get_wallets(&mut self, identifiers: Vec) -> Result, diesel::result::Error> { + wallets::table + .filter(wallets::identifier.eq_any(identifiers)) + .select(WalletRow::as_select()) + .load(&mut self.connection) + } + + fn create_wallet(&mut self, wallet: NewWalletRow) -> Result { + diesel::insert_into(wallets::table) + .values(&wallet) + .returning(WalletRow::as_returning()) + .get_result(&mut self.connection) + } + + fn create_wallets(&mut self, new_wallets: Vec) -> Result { + diesel::insert_into(wallets::table) + .values(&new_wallets) + .on_conflict(wallets::identifier) + .do_nothing() + .execute(&mut self.connection) + } + + fn get_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error> { + wallets_addresses::table + .filter(wallets_addresses::address.eq_any(addresses)) + .select(WalletAddressRow::as_select()) + .load(&mut self.connection) + } + + fn add_addresses(&mut self, addresses: Vec) -> Result { + diesel::insert_into(wallets_addresses::table) + .values(&addresses) + .on_conflict(wallets_addresses::address) + .do_nothing() + .execute(&mut self.connection) + } + + fn get_subscriptions_by_device_id(&mut self, device_id: i32) -> Result, diesel::result::Error> { + wallets_subscriptions::table + .inner_join(wallets::table) + .inner_join(wallets_addresses::table) + .filter(wallets_subscriptions::device_id.eq(device_id)) + .select((WalletRow::as_select(), WalletSubscriptionRow::as_select(), WalletAddressRow::as_select())) + .load(&mut self.connection) + } + + fn get_subscriptions_by_device_and_wallet(&mut self, device_id: i32, wallet_id: i32) -> Result, diesel::result::Error> { + wallets_subscriptions::table + .inner_join(wallets_addresses::table) + .filter(wallets_subscriptions::device_id.eq(device_id)) + .filter(wallets_subscriptions::wallet_id.eq(wallet_id)) + .select((WalletSubscriptionRow::as_select(), WalletAddressRow::as_select())) + .load(&mut self.connection) + } + + fn subscriptions_wallet_address_for_chain(&mut self, device_id: i32, wallet_id: i32, chain: ChainRow) -> Result { + wallets_subscriptions::table + .inner_join(wallets_addresses::table) + .filter(wallets_subscriptions::device_id.eq(device_id)) + .filter(wallets_subscriptions::wallet_id.eq(wallet_id)) + .filter(wallets_subscriptions::chain.eq(chain)) + .select(WalletAddressRow::as_select()) + .first(&mut self.connection) + } + + fn get_devices_by_wallet_id(&mut self, wallet_id: i32) -> Result, diesel::result::Error> { + wallets_subscriptions::table + .inner_join(devices::table) + .filter(wallets_subscriptions::wallet_id.eq(wallet_id)) + .select(DeviceRow::as_select()) + .distinct() + .load(&mut self.connection) + } + + fn add_subscriptions(&mut self, subscriptions: Vec) -> Result { + diesel::insert_into(wallets_subscriptions::table) + .values(&subscriptions) + .on_conflict(( + wallets_subscriptions::wallet_id, + wallets_subscriptions::device_id, + wallets_subscriptions::chain, + wallets_subscriptions::address_id, + )) + .do_nothing() + .execute(&mut self.connection) + } + + fn delete_subscriptions(&mut self, device_id: i32, wallet_id: i32, chain: ChainRow, address_ids: Vec) -> Result { + diesel::delete(wallets_subscriptions::table) + .filter(wallets_subscriptions::device_id.eq(device_id)) + .filter(wallets_subscriptions::wallet_id.eq(wallet_id)) + .filter(wallets_subscriptions::chain.eq(chain)) + .filter(wallets_subscriptions::address_id.eq_any(address_ids)) + .execute(&mut self.connection) + } + + fn delete_wallet_subscriptions(&mut self, device_id: i32, wallet_ids: Vec) -> Result { + diesel::delete(wallets_subscriptions::table) + .filter(wallets_subscriptions::device_id.eq(device_id)) + .filter(wallets_subscriptions::wallet_id.eq_any(wallet_ids)) + .execute(&mut self.connection) + } + + fn delete_wallet_chains(&mut self, device_id: i32, wallet_id: i32, chains: Vec) -> Result { + let chain_rows: Vec = chains.into_iter().map(ChainRow::from).collect(); + + diesel::delete(wallets_subscriptions::table) + .filter(wallets_subscriptions::device_id.eq(device_id)) + .filter(wallets_subscriptions::wallet_id.eq(wallet_id)) + .filter(wallets_subscriptions::chain.eq_any(chain_rows)) + .execute(&mut self.connection) + } + + fn delete_subscriptions_for_device_ids(&mut self, device_ids: Vec) -> Result { + diesel::delete(wallets_subscriptions::table) + .filter(wallets_subscriptions::device_id.eq_any(device_ids)) + .execute(&mut self.connection) + } + + fn get_subscriptions_by_chain_addresses( + &mut self, + chain: Chain, + addresses: Vec, + ) -> Result, diesel::result::Error> { + let chain_row = ChainRow::from(chain); + + wallets_subscriptions::table + .inner_join(wallets::table) + .inner_join(wallets_addresses::table) + .inner_join(devices::table) + .filter(wallets_subscriptions::chain.eq(chain_row)) + .filter(wallets_addresses::address.eq_any(&addresses)) + .filter(diesel::dsl::not(diesel::dsl::exists( + subscriptions_addresses_exclude::table.filter(subscriptions_addresses_exclude::address.eq(wallets_addresses::address)), + ))) + .select(( + WalletRow::as_select(), + WalletSubscriptionRow::as_select(), + WalletAddressRow::as_select(), + DeviceRow::as_select(), + )) + .load(&mut self.connection) + } + + fn get_subscription_address_exists(&mut self, chain: Chain, address: &str) -> Result { + let chain_row = ChainRow::from(chain); + + diesel::select(diesel::dsl::exists( + wallets_subscriptions::table + .inner_join(wallets_addresses::table) + .filter(wallets_subscriptions::chain.eq(chain_row)) + .filter(wallets_addresses::address.eq(address)), + )) + .get_result(&mut self.connection) + } + + fn add_subscriptions_exclude_addresses(&mut self, values: Vec) -> Result { + diesel::insert_into(subscriptions_addresses_exclude::table) + .values(values) + .on_conflict_do_nothing() + .execute(&mut self.connection) + } + + fn get_subscriptions_exclude_addresses(&mut self, addresses: Vec) -> Result, diesel::result::Error> { + subscriptions_addresses_exclude::table + .filter(subscriptions_addresses_exclude::address.eq_any(addresses)) + .select(subscriptions_addresses_exclude::address) + .load(&mut self.connection) + } + + fn get_device_addresses(&mut self, device_id: i32, chain: ChainRow) -> Result, diesel::result::Error> { + wallets_subscriptions::table + .inner_join(wallets_addresses::table) + .filter(wallets_subscriptions::device_id.eq(device_id)) + .filter(wallets_subscriptions::chain.eq(chain)) + .select(wallets_addresses::address) + .load(&mut self.connection) + } + + fn get_first_subscription_date_by_wallet_id(&mut self, wallet_id: i32) -> Result, diesel::result::Error> { + wallets_subscriptions::table + .filter(wallets_subscriptions::wallet_id.eq(wallet_id)) + .select(wallets_subscriptions::created_at) + .order(wallets_subscriptions::created_at.asc()) + .first(&mut self.connection) + .optional() + } +} diff --git a/core/crates/storage/src/database/webhooks.rs b/core/crates/storage/src/database/webhooks.rs new file mode 100644 index 0000000000..49fb85a61a --- /dev/null +++ b/core/crates/storage/src/database/webhooks.rs @@ -0,0 +1,32 @@ +use diesel::OptionalExtension; +use diesel::prelude::*; +use primitives::WebhookKind; + +use crate::DatabaseClient; +use crate::models::NewWebhookEndpointRow; +use crate::sql_types::WebhookKind as WebhookKindRow; + +pub trait WebhooksStore { + fn add_webhook_endpoints(&mut self, values: Vec) -> Result; + fn get_webhook_endpoint(&mut self, kind: WebhookKind, sender: &str, secret: &str) -> Result, diesel::result::Error>; +} + +impl WebhooksStore for DatabaseClient { + fn add_webhook_endpoints(&mut self, values: Vec) -> Result { + use crate::schema::webhook_endpoints::dsl::*; + + diesel::insert_into(webhook_endpoints).values(values).on_conflict_do_nothing().execute(&mut self.connection) + } + + fn get_webhook_endpoint(&mut self, kind_value: WebhookKind, sender_value: &str, secret_value: &str) -> Result, diesel::result::Error> { + use crate::schema::webhook_endpoints::dsl::*; + + webhook_endpoints + .filter(kind.eq(WebhookKindRow::from(kind_value))) + .filter(sender.eq(sender_value)) + .filter(secret.eq(secret_value)) + .select(enabled) + .first(&mut self.connection) + .optional() + } +} diff --git a/core/crates/storage/src/error.rs b/core/crates/storage/src/error.rs new file mode 100644 index 0000000000..c31d543b22 --- /dev/null +++ b/core/crates/storage/src/error.rs @@ -0,0 +1,341 @@ +use std::error::Error; +use std::fmt; + +#[derive(Debug, Clone)] +pub enum DatabaseError { + NotFound { resource: &'static str, lookup: NotFoundLookup }, + Error(String), +} + +#[derive(Debug, Clone)] +pub enum NotFoundLookup { + Public(String), + Internal(String), +} + +impl fmt::Display for DatabaseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DatabaseError::NotFound { + resource, + lookup: NotFoundLookup::Public(lookup), + } => write!(f, "{resource} {lookup} not found"), + DatabaseError::NotFound { + resource, + lookup: NotFoundLookup::Internal(_), + } => write!(f, "{resource} not found"), + DatabaseError::Error(msg) => write!(f, "{}", msg), + } + } +} + +impl Error for DatabaseError {} + +impl DatabaseError { + pub fn not_found(resource: &'static str, lookup: impl Into) -> Self { + Self::NotFound { + resource, + lookup: NotFoundLookup::Public(lookup.into()), + } + } + + pub fn not_found_internal(resource: &'static str, lookup: impl Into) -> Self { + Self::NotFound { + resource, + lookup: NotFoundLookup::Internal(lookup.into()), + } + } + + pub fn is_not_found(&self) -> bool { + matches!(self, Self::NotFound { .. }) + } +} + +pub trait ResourceName { + const RESOURCE_NAME: &'static str; +} + +impl ResourceName for crate::models::AssetRow { + const RESOURCE_NAME: &'static str = "Asset"; +} + +impl ResourceName for crate::models::ConfigRow { + const RESOURCE_NAME: &'static str = "Config"; +} + +impl ResourceName for crate::models::DeviceRow { + const RESOURCE_NAME: &'static str = "Device"; +} + +impl ResourceName for crate::models::FiatRateRow { + const RESOURCE_NAME: &'static str = "FiatRate"; +} + +impl ResourceName for crate::models::FiatTransactionRow { + const RESOURCE_NAME: &'static str = "FiatTransaction"; +} + +impl ResourceName for crate::models::NftAssetRow { + const RESOURCE_NAME: &'static str = "NFTAsset"; +} + +impl ResourceName for crate::models::NftCollectionRow { + const RESOURCE_NAME: &'static str = "NFTCollection"; +} + +impl ResourceName for crate::models::ParserStateRow { + const RESOURCE_NAME: &'static str = "ParserState"; +} + +impl ResourceName for crate::models::PriceRow { + const RESOURCE_NAME: &'static str = "Price"; +} + +impl ResourceName for crate::models::RedemptionOptionFull { + const RESOURCE_NAME: &'static str = "RewardRedemptionOption"; +} + +impl ResourceName for crate::models::RewardEventRow { + const RESOURCE_NAME: &'static str = "RewardEvent"; +} + +impl ResourceName for crate::models::RewardRedemptionRow { + const RESOURCE_NAME: &'static str = "RewardRedemption"; +} + +impl ResourceName for crate::models::RewardsRow { + const RESOURCE_NAME: &'static str = "Rewards"; +} + +impl ResourceName for crate::models::ScanAddressRow { + const RESOURCE_NAME: &'static str = "ScanAddress"; +} + +impl ResourceName for crate::models::TransactionRow { + const RESOURCE_NAME: &'static str = "Transaction"; +} + +impl ResourceName for crate::models::UsernameRow { + const RESOURCE_NAME: &'static str = "Username"; +} + +impl ResourceName for crate::models::WalletRow { + const RESOURCE_NAME: &'static str = "Wallet"; +} + +impl ResourceName for crate::models::WalletAddressRow { + const RESOURCE_NAME: &'static str = "WalletAddress"; +} + +impl ResourceName for crate::models::WalletSubscriptionRow { + const RESOURCE_NAME: &'static str = "WalletSubscription"; +} + +impl From for DatabaseError { + fn from(error: diesel::result::Error) -> Self { + match error { + diesel::result::Error::NotFound => DatabaseError::Error("Unexpected database record not found without lookup context".to_string()), + e => DatabaseError::Error(e.to_string()), + } + } +} + +pub trait DieselResultExt { + fn or_not_found(self, lookup: String) -> Result + where + T: ResourceName; + fn or_not_found_internal(self, lookup: String) -> Result + where + T: ResourceName; + fn or_not_found_for(self, lookup: String) -> Result; + fn or_not_found_internal_for(self, lookup: String) -> Result; +} + +impl DieselResultExt for Result { + fn or_not_found(self, lookup: String) -> Result + where + T: ResourceName, + { + self.or_not_found_for::(lookup) + } + + fn or_not_found_internal(self, lookup: String) -> Result + where + T: ResourceName, + { + self.or_not_found_internal_for::(lookup) + } + + fn or_not_found_for(self, lookup: String) -> Result { + match self { + Ok(value) => Ok(value), + Err(diesel::result::Error::NotFound) => Err(DatabaseError::not_found(R::RESOURCE_NAME, lookup)), + Err(error) => Err(error.into()), + } + } + + fn or_not_found_internal_for(self, lookup: String) -> Result { + match self { + Ok(value) => Ok(value), + Err(diesel::result::Error::NotFound) => Err(DatabaseError::not_found_internal(R::RESOURCE_NAME, lookup)), + Err(error) => Err(error.into()), + } + } +} + +impl From for DatabaseError { + fn from(error: std::num::ParseIntError) -> Self { + DatabaseError::Error(error.to_string()) + } +} + +impl From for DatabaseError { + fn from(error: std::num::ParseFloatError) -> Self { + DatabaseError::Error(error.to_string()) + } +} + +impl From for DatabaseError { + fn from(error: std::str::ParseBoolError) -> Self { + DatabaseError::Error(error.to_string()) + } +} + +impl From for DatabaseError { + fn from(error: serde_json::Error) -> Self { + DatabaseError::Error(error.to_string()) + } +} + +impl From for DatabaseError { + fn from(error: r2d2::Error) -> Self { + DatabaseError::Error(error.to_string()) + } +} + +#[derive(Debug, Clone)] +pub enum ReferralValidationError { + CodeDoesNotExist, + DeviceAlreadyUsed, + CannotReferSelf, + EligibilityExpired(i64), + RewardsNotEnabled(String), + Database(DatabaseError), +} + +impl fmt::Display for ReferralValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReferralValidationError::CodeDoesNotExist => write!(f, "Referral code does not exist"), + ReferralValidationError::DeviceAlreadyUsed => write!(f, "This device has already been used to apply a referral code"), + ReferralValidationError::CannotReferSelf => write!(f, "Cannot use your own referral code"), + ReferralValidationError::EligibilityExpired(days) => write!(f, "eligibility_expired: {} days", days), + ReferralValidationError::RewardsNotEnabled(user) => write!(f, "Rewards are not enabled for {}", user), + ReferralValidationError::Database(e) => write!(f, "{}", e), + } + } +} + +impl Error for ReferralValidationError {} + +impl From for ReferralValidationError { + fn from(error: DatabaseError) -> Self { + ReferralValidationError::Database(error) + } +} + +impl From for ReferralValidationError { + fn from(error: diesel::result::Error) -> Self { + ReferralValidationError::Database(error.into()) + } +} + +#[derive(Debug, Clone)] +pub enum UsernameValidationError { + Invalid(String), + AlreadyTaken, + Database(DatabaseError), +} + +impl fmt::Display for UsernameValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UsernameValidationError::Invalid(msg) => write!(f, "{}", msg), + UsernameValidationError::AlreadyTaken => write!(f, "Username already taken"), + UsernameValidationError::Database(e) => write!(f, "{}", e), + } + } +} + +impl Error for UsernameValidationError {} + +impl From for UsernameValidationError { + fn from(error: DatabaseError) -> Self { + UsernameValidationError::Database(error) + } +} + +impl From for UsernameValidationError { + fn from(error: diesel::result::Error) -> Self { + UsernameValidationError::Database(error.into()) + } +} + +#[cfg(test)] +mod tests { + use super::{DatabaseError, DieselResultExt, NotFoundLookup}; + + #[test] + fn test_database_error_display_not_found() { + assert_eq!(DatabaseError::not_found("Asset", "0x1233").to_string(), "Asset 0x1233 not found"); + } + + #[test] + fn test_database_error_display_wallet_address_not_found() { + assert_eq!(DatabaseError::not_found("WalletAddress", "solana").to_string(), "WalletAddress solana not found"); + } + + #[test] + fn test_database_error_display_hides_internal_lookup() { + let error = DatabaseError::not_found_internal("Wallet", "42"); + assert_eq!(error.to_string(), "Wallet not found"); + match error { + DatabaseError::NotFound { + resource, + lookup: NotFoundLookup::Internal(lookup), + } => { + assert_eq!(resource, "Wallet"); + assert_eq!(lookup, "42"); + } + _ => panic!("expected internal not found"), + } + } + + #[test] + fn test_diesel_result_ext_not_found() { + let result: Result = Err(diesel::result::Error::NotFound); + let error = result.or_not_found("abc".to_string()).unwrap_err(); + + assert!(error.is_not_found()); + assert_eq!(error.to_string(), "Wallet abc not found"); + } + + #[test] + fn test_diesel_result_ext_not_found_internal() { + let result: Result = Err(diesel::result::Error::NotFound); + let error = result.or_not_found_internal("42".to_string()).unwrap_err(); + + assert!(error.is_not_found()); + assert_eq!(error.to_string(), "Wallet not found"); + match error { + DatabaseError::NotFound { + resource, + lookup: NotFoundLookup::Internal(lookup), + } => { + assert_eq!(resource, "Wallet"); + assert_eq!(lookup, "42"); + } + _ => panic!("expected internal not found"), + } + } +} diff --git a/core/crates/storage/src/lib.rs b/core/crates/storage/src/lib.rs new file mode 100644 index 0000000000..2f9b9885d0 --- /dev/null +++ b/core/crates/storage/src/lib.rs @@ -0,0 +1,172 @@ +use std::error::Error; + +mod config_cacher; +pub mod database; +pub mod error; +pub mod models; +pub mod repositories; +pub mod schema; +pub mod sql_types; +#[cfg(test)] +pub mod testkit; + +pub use config_cacher::ConfigCacher; + +diesel::allow_columns_to_appear_in_same_group_by_clause!(schema::transactions_addresses::address, schema::transactions::chain,); + +pub use self::database::{ + DatabaseClient, + assets::{AssetFilter, AssetUpdate}, + charts::ChartFilter, + fiat::FiatAssetFilter, + nft::{NftAssetFilter, NftCollectionFilter}, + perpetuals::PerpetualFilter, + prices::{AssetsWithPricesFilter, PriceUpdate}, + referrals::{AbusePatterns, ReferralUpdate}, + rewards::{RewardsFilter, RewardsUpdate}, + rewards_redemptions::RedemptionUpdate, + transactions::{TransactionFilter, TransactionUpdate}, +}; +pub use self::error::{DatabaseError, DieselResultExt, ReferralValidationError, UsernameValidationError}; +pub use self::models::{AssetUsageRankRow, FiatAssetRowsExt, NewNotificationRow, NewWalletRow, RewardRedemptionOptionRow}; +pub use self::repositories::{ + assets_addresses_repository::AssetsAddressesRepository, + assets_links_repository::AssetsLinksRepository, + assets_repository::AssetsRepository, + assets_usage_ranks_repository::AssetsUsageRanksRepository, + chains_repository::ChainsRepository, + charts_repository::ChartsRepository, + config_repository::ConfigRepository, + devices_repository::DevicesRepository, + fiat_repository::FiatRepository, + migrations_repository::MigrationsRepository, + nft_repository::NftRepository, + notifications_repository::NotificationsRepository, + parser_state_repository::ParserStateRepository, + perpetuals_repository::PerpetualsRepository, + price_alerts_repository::PriceAlertsRepository, + prices_providers_repository::PricesProvidersRepository, + prices_repository::PricesRepository, + releases_repository::ReleasesRepository, + rewards_redemptions_repository::RewardsRedemptionsRepository, + rewards_repository::{ReferrerInfo, RewardsEligibilityConfig, RewardsRepository}, + risk_signals_repository::RiskSignalsRepository, + scan_addresses_repository::ScanAddressesRepository, + tag_repository::TagRepository, + transactions_repository::TransactionsRepository, + wallets_repository::WalletsRepository, + webhooks_repository::WebhooksRepository, +}; +pub use self::sql_types::{NotificationType, TransactionState, TransactionType, WalletSource, WalletType}; +pub use diesel::OptionalExtension; + +#[derive(Clone)] +pub struct Database(database::PgPool); + +impl Database { + pub fn new(database_url: &str, pool_size: u32) -> Self { + Self(database::create_pool(database_url, pool_size)) + } + + pub fn client(&self) -> Result> { + Ok(DatabaseClient::from_pool(&self.0)?) + } +} + +impl Database { + pub fn assets(&self) -> Result> { + self.client() + } + + pub fn assets_addresses(&self) -> Result> { + self.client() + } + + pub fn assets_links(&self) -> Result> { + self.client() + } + + pub fn assets_usage_ranks(&self) -> Result> { + self.client() + } + + pub fn chains(&self) -> Result> { + self.client() + } + + pub fn charts(&self) -> Result> { + self.client() + } + + pub fn devices(&self) -> Result> { + self.client() + } + + pub fn fiat(&self) -> Result> { + self.client() + } + + pub fn migrations(&self) -> Result> { + self.client() + } + + pub fn perpetuals(&self) -> Result> { + self.client() + } + + pub fn nft(&self) -> Result> { + self.client() + } + + pub fn notifications(&self) -> Result> { + self.client() + } + + pub fn parser_state(&self) -> Result> { + self.client() + } + + pub fn price_alerts(&self) -> Result> { + self.client() + } + + pub fn prices(&self) -> Result> { + self.client() + } + + pub fn prices_providers(&self) -> Result> { + self.client() + } + + pub fn rewards(&self) -> Result> { + self.client() + } + + pub fn rewards_redemptions(&self) -> Result> { + self.client() + } + + pub fn releases(&self) -> Result> { + self.client() + } + + pub fn scan_addresses(&self) -> Result> { + self.client() + } + + pub fn tag(&self) -> Result> { + self.client() + } + + pub fn transactions(&self) -> Result> { + self.client() + } + + pub fn wallets(&self) -> Result> { + self.client() + } + + pub fn webhooks(&self) -> Result> { + self.client() + } +} diff --git a/core/crates/storage/src/migrations/00000000000000_diesel_initial_setup/down.sql b/core/crates/storage/src/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000000..d8f9c82f90 --- /dev/null +++ b/core/crates/storage/src/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass) CASCADE; +DROP FUNCTION IF EXISTS diesel_set_updated_at() CASCADE; diff --git a/core/crates/storage/src/migrations/00000000000000_diesel_initial_setup/up.sql b/core/crates/storage/src/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000000..d68895b1a7 --- /dev/null +++ b/core/crates/storage/src/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/core/crates/storage/src/migrations/2023-07-18-212125_chains/down.sql b/core/crates/storage/src/migrations/2023-07-18-212125_chains/down.sql new file mode 100644 index 0000000000..049b76abb7 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-18-212125_chains/down.sql @@ -0,0 +1 @@ +drop table chains; \ No newline at end of file diff --git a/core/crates/storage/src/migrations/2023-07-18-212125_chains/up.sql b/core/crates/storage/src/migrations/2023-07-18-212125_chains/up.sql new file mode 100644 index 0000000000..5a91702035 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-18-212125_chains/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE chains ( + id VARCHAR(32) PRIMARY KEY NOT NULL, + + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('chains'); diff --git a/core/crates/storage/src/migrations/2023-07-19-000000_fiat_rates/down.sql b/core/crates/storage/src/migrations/2023-07-19-000000_fiat_rates/down.sql new file mode 100644 index 0000000000..25ccb05a46 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-19-000000_fiat_rates/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS fiat_rates; diff --git a/core/crates/storage/src/migrations/2023-07-19-000000_fiat_rates/up.sql b/core/crates/storage/src/migrations/2023-07-19-000000_fiat_rates/up.sql new file mode 100644 index 0000000000..a82576fca6 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-19-000000_fiat_rates/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE fiat_rates ( + id VARCHAR(8) NOT NULL PRIMARY KEY, + name VARCHAR NOT NULL, + rate float NOT NULL DEFAULT 0, + created_at timestamp NOT NULL default current_timestamp, + updated_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('fiat_rates'); diff --git a/core/crates/storage/src/migrations/2023-07-20-000000_devices/down.sql b/core/crates/storage/src/migrations/2023-07-20-000000_devices/down.sql new file mode 100644 index 0000000000..c1e2ffc471 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-20-000000_devices/down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS devices CASCADE; +DROP TYPE IF EXISTS platform_store CASCADE; +DROP TYPE IF EXISTS platform CASCADE; diff --git a/core/crates/storage/src/migrations/2023-07-20-000000_devices/up.sql b/core/crates/storage/src/migrations/2023-07-20-000000_devices/up.sql new file mode 100644 index 0000000000..5a96cb56fa --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-20-000000_devices/up.sql @@ -0,0 +1,25 @@ +CREATE TYPE platform AS ENUM ('ios', 'android'); +CREATE TYPE platform_store AS ENUM ('appStore', 'googlePlay', 'fdroid', 'huawei', 'solanaStore', 'samsungStore', 'apkUniversal', 'emerald', 'local'); + +CREATE TABLE devices ( + id SERIAL PRIMARY KEY, + device_id VARCHAR(64) NOT NULL, + is_push_enabled boolean NOT NULL, + platform platform NOT NULL, + platform_store platform_store NOT NULL, + token VARCHAR(256) NOT NULL, + locale VARCHAR(8) NOT NULL, + version VARCHAR(8) NOT NULL, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + currency VARCHAR(8) NOT NULL REFERENCES fiat_rates (id) ON DELETE CASCADE, + subscriptions_version INTEGER NOT NULL DEFAULT 0, + is_price_alerts_enabled boolean NOT NULL DEFAULT false, + os VARCHAR(64) NOT NULL, + model VARCHAR(128) NOT NULL, + UNIQUE(device_id) +); + +CREATE INDEX devices_token_idx ON devices (token); + +SELECT diesel_manage_updated_at('devices'); diff --git a/core/crates/storage/src/migrations/2023-07-22-205905_assets/down.sql b/core/crates/storage/src/migrations/2023-07-22-205905_assets/down.sql new file mode 100644 index 0000000000..1027062a5e --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-22-205905_assets/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS assets_usage_ranks CASCADE; +DROP TABLE IF EXISTS assets_addresses CASCADE; +DROP TABLE IF EXISTS assets_links CASCADE; +DROP TABLE IF EXISTS assets_tags CASCADE; +DROP TABLE IF EXISTS tags CASCADE; +DROP TABLE IF EXISTS assets CASCADE; +DROP TYPE IF EXISTS link_type CASCADE; +DROP TYPE IF EXISTS asset_type CASCADE; diff --git a/core/crates/storage/src/migrations/2023-07-22-205905_assets/up.sql b/core/crates/storage/src/migrations/2023-07-22-205905_assets/up.sql new file mode 100644 index 0000000000..b25b0943d2 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-22-205905_assets/up.sql @@ -0,0 +1,84 @@ +CREATE TYPE asset_type AS ENUM ('NATIVE', 'ERC20', 'BEP20', 'BEP2', 'SPL', 'SPL2022', 'TRC20', 'TOKEN', 'IBC', 'JETTON', 'SYNTH', 'ASA', 'PERPETUAL', 'SPOT'); +CREATE TYPE link_type AS ENUM ('x', 'discord', 'reddit', 'telegram', 'github', 'youtube', 'facebook', 'website', 'coingecko', 'opensea', 'instagram', 'magiceden', 'coinmarketcap', 'tiktok'); + +CREATE TABLE assets ( + id VARCHAR(128) PRIMARY KEY, + chain VARCHAR(32) NOT NULL REFERENCES chains (id) ON DELETE CASCADE, + token_id VARCHAR(128), + asset_type asset_type NOT NULL, + name VARCHAR(128) NOT NULL, + symbol VARCHAR(32) NOT NULL, + decimals INTEGER NOT NULL, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + rank INTEGER NOT NULL DEFAULT 0, + + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_buyable boolean NOT NULL default false, + is_sellable boolean NOT NULL default false, + is_swappable boolean NOT NULL default false, + is_stakeable boolean NOT NULL default false, + staking_apr float, + is_earnable boolean NOT NULL default false, + earn_apr float, + has_image boolean NOT NULL default false, + has_price boolean NOT NULL default false, + circulating_supply float, + total_supply float, + max_supply float, + + UNIQUE(id) +); + +SELECT diesel_manage_updated_at('assets'); +CREATE INDEX assets_updated_at_idx ON assets (updated_at); + +CREATE TABLE tags ( + id VARCHAR(64) PRIMARY KEY, + created_at timestamp NOT NULL default current_timestamp +); + +CREATE TABLE assets_tags ( + asset_id VARCHAR(128) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + tag_id VARCHAR(64) NOT NULL REFERENCES tags (id) ON DELETE CASCADE, + "order" INTEGER, + created_at timestamp NOT NULL default current_timestamp, + PRIMARY KEY (asset_id, tag_id) +); + +CREATE TABLE assets_links ( + id SERIAL PRIMARY KEY, + asset_id VARCHAR(128) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + link_type link_type NOT NULL, + url VARCHAR(256) NOT NULL, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + UNIQUE(asset_id, link_type) +); + +SELECT diesel_manage_updated_at('assets_links'); + +CREATE TABLE assets_addresses ( + id SERIAL PRIMARY KEY, + chain VARCHAR(32) NOT NULL REFERENCES chains (id) ON DELETE CASCADE, + asset_id VARCHAR(256) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + address VARCHAR(256) NOT NULL, + value VARCHAR(256), + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + UNIQUE (asset_id, address) +); + +CREATE INDEX assets_addresses_chain_idx ON assets_addresses (chain); +CREATE INDEX assets_addresses_asset_id_idx ON assets_addresses (asset_id); +CREATE INDEX assets_addresses_address_idx ON assets_addresses (address); + +SELECT diesel_manage_updated_at('assets_addresses'); + +CREATE TABLE assets_usage_ranks ( + asset_id VARCHAR(128) PRIMARY KEY REFERENCES assets (id) ON DELETE CASCADE, + usage_rank INTEGER NOT NULL, + updated_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('assets_usage_ranks'); diff --git a/core/crates/storage/src/migrations/2023-07-23-000000_add_wallets/down.sql b/core/crates/storage/src/migrations/2023-07-23-000000_add_wallets/down.sql new file mode 100644 index 0000000000..6d5b0188c6 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-23-000000_add_wallets/down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS wallets_subscriptions_device_chain_idx; +DROP INDEX IF EXISTS wallets_subscriptions_chain_wallet_address_id_idx; +DROP INDEX IF EXISTS wallets_subscriptions_wallet_address_id_idx; +DROP INDEX IF EXISTS wallets_subscriptions_device_id_idx; +DROP INDEX IF EXISTS wallets_subscriptions_wallet_id_idx; +DROP TABLE IF EXISTS wallets_subscriptions CASCADE; + +DROP TABLE IF EXISTS wallets_addresses CASCADE; + +DROP TABLE IF EXISTS wallets CASCADE; +DROP TYPE IF EXISTS wallet_source CASCADE; +DROP TYPE IF EXISTS wallet_type CASCADE; diff --git a/core/crates/storage/src/migrations/2023-07-23-000000_add_wallets/up.sql b/core/crates/storage/src/migrations/2023-07-23-000000_add_wallets/up.sql new file mode 100644 index 0000000000..f1899a640d --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-23-000000_add_wallets/up.sql @@ -0,0 +1,31 @@ +CREATE TYPE wallet_type AS ENUM ('multicoin', 'single', 'privateKey', 'view'); +CREATE TYPE wallet_source AS ENUM ('create', 'import'); + +CREATE TABLE wallets ( + id SERIAL PRIMARY KEY, + identifier VARCHAR(128) UNIQUE NOT NULL, + wallet_type wallet_type NOT NULL, + source wallet_source NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); + +CREATE TABLE wallets_addresses ( + id SERIAL PRIMARY KEY, + address VARCHAR(256) UNIQUE NOT NULL +); + +CREATE TABLE wallets_subscriptions ( + id SERIAL PRIMARY KEY, + wallet_id INTEGER NOT NULL REFERENCES wallets(id) ON DELETE CASCADE, + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + chain VARCHAR(32) NOT NULL REFERENCES chains(id) ON DELETE CASCADE, + address_id INTEGER NOT NULL REFERENCES wallets_addresses(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + UNIQUE(wallet_id, device_id, chain, address_id) +); + +CREATE INDEX wallets_subscriptions_wallet_id_idx ON wallets_subscriptions (wallet_id); +CREATE INDEX wallets_subscriptions_device_id_idx ON wallets_subscriptions (device_id); +CREATE INDEX wallets_subscriptions_address_id_idx ON wallets_subscriptions (address_id); +CREATE INDEX wallets_subscriptions_chain_address_id_idx ON wallets_subscriptions (chain, address_id); +CREATE INDEX wallets_subscriptions_device_chain_idx ON wallets_subscriptions (device_id, chain); diff --git a/core/crates/storage/src/migrations/2023-07-23-215138_fiat/down.sql b/core/crates/storage/src/migrations/2023-07-23-215138_fiat/down.sql new file mode 100644 index 0000000000..a063f1f1d8 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-23-215138_fiat/down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS fiat_transactions; +DROP TABLE IF EXISTS fiat_assets; +DROP TABLE IF EXISTS fiat_providers_countries; +DROP TABLE IF EXISTS fiat_providers; +DROP TYPE IF EXISTS fiat_transaction_status; +DROP TYPE IF EXISTS fiat_transaction_type; diff --git a/core/crates/storage/src/migrations/2023-07-23-215138_fiat/up.sql b/core/crates/storage/src/migrations/2023-07-23-215138_fiat/up.sql new file mode 100644 index 0000000000..c3617cd63c --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-23-215138_fiat/up.sql @@ -0,0 +1,75 @@ +CREATE TYPE fiat_transaction_type AS ENUM ('buy', 'sell'); +CREATE TYPE fiat_transaction_status AS ENUM ('complete', 'pending', 'failed', 'unknown'); + +CREATE TABLE fiat_providers ( + id VARCHAR(32) PRIMARY KEY NOT NULL, + name VARCHAR(32) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + buy_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sell_enabled BOOLEAN NOT NULL DEFAULT TRUE, + priority INTEGER NULL, + priority_threshold_bps INTEGER NULL, + payment_methods jsonb NOT NULL DEFAULT '[]'::jsonb, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('fiat_providers'); + +CREATE TABLE fiat_providers_countries ( + id VARCHAR(32) PRIMARY KEY NOT NULL, + provider VARCHAR(128) NOT NULL REFERENCES fiat_providers (id) ON DELETE CASCADE, + alpha2 VARCHAR(32) NOT NULL, + is_allowed BOOLEAN NOT NULL, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('fiat_providers_countries'); + +CREATE TABLE fiat_assets ( + id VARCHAR(128) PRIMARY KEY, + asset_id VARCHAR(128) REFERENCES assets (id) ON DELETE CASCADE, + provider VARCHAR(128) NOT NULL REFERENCES fiat_providers (id) ON DELETE CASCADE, + code VARCHAR(128) NOT NULL, + symbol VARCHAR(128) NOT NULL, + network VARCHAR(128) NULL, + token_id VARCHAR(128) NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_enabled_by_provider BOOLEAN NOT NULL DEFAULT TRUE, + is_buy_enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_sell_enabled BOOLEAN NOT NULL DEFAULT TRUE, + unsupported_countries jsonb, + buy_limits jsonb, + sell_limits jsonb, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('fiat_assets'); + +CREATE TABLE fiat_transactions ( + id SERIAL PRIMARY KEY, + provider_id VARCHAR(128) NOT NULL REFERENCES fiat_providers (id) ON DELETE CASCADE, + asset_id VARCHAR(128) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + quote_id VARCHAR(128) NOT NULL, + device_id INTEGER NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + wallet_id INTEGER NOT NULL REFERENCES wallets (id) ON DELETE CASCADE, + fiat_amount float NOT NULL DEFAULT 0, + fiat_currency VARCHAR(32) NOT NULL, + value VARCHAR(256), + status fiat_transaction_status NOT NULL, + country VARCHAR(256), + provider_transaction_id VARCHAR(256), + transaction_hash VARCHAR(256), + address_id INTEGER NOT NULL REFERENCES wallets_addresses (id) ON DELETE CASCADE, + transaction_type fiat_transaction_type NOT NULL, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('fiat_transactions'); + +CREATE INDEX idx_fiat_transactions_provider_quote_id ON fiat_transactions(provider_id, quote_id); +CREATE UNIQUE INDEX idx_fiat_transactions_provider_quote_placeholder ON fiat_transactions(provider_id, quote_id) WHERE provider_transaction_id IS NULL; +CREATE UNIQUE INDEX idx_fiat_transactions_provider_transaction_id ON fiat_transactions(provider_id, provider_transaction_id) WHERE provider_transaction_id IS NOT NULL; diff --git a/core/crates/storage/src/migrations/2023-07-28-193518_prices/down.sql b/core/crates/storage/src/migrations/2023-07-28-193518_prices/down.sql new file mode 100644 index 0000000000..439946f9fc --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-28-193518_prices/down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS prices_assets; +DROP TABLE IF EXISTS prices; +DROP TABLE IF EXISTS prices_providers; diff --git a/core/crates/storage/src/migrations/2023-07-28-193518_prices/up.sql b/core/crates/storage/src/migrations/2023-07-28-193518_prices/up.sql new file mode 100644 index 0000000000..1d59e64283 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-28-193518_prices/up.sql @@ -0,0 +1,44 @@ +CREATE TABLE prices_providers ( + id VARCHAR(32) PRIMARY KEY NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + priority INTEGER NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('prices_providers'); + +CREATE TABLE prices ( + id VARCHAR(256) PRIMARY KEY NOT NULL, + provider VARCHAR(32) NOT NULL REFERENCES prices_providers (id) ON DELETE CASCADE, + price float NOT NULL DEFAULT 0, + price_change_percentage_24h float, + market_cap_rank INTEGER, + last_updated_at timestamp NOT NULL default current_timestamp, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + all_time_high_date timestamp, + all_time_low_date timestamp, + all_time_high float NOT NULL DEFAULT 0, + all_time_low float NOT NULL DEFAULT 0, + total_volume float +); + +SELECT diesel_manage_updated_at('prices'); + +CREATE INDEX prices_provider_idx ON prices (provider); +CREATE INDEX prices_last_updated_at_idx ON prices (last_updated_at); + +CREATE TABLE prices_assets ( + asset_id VARCHAR(256) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + price_id VARCHAR(256) NOT NULL REFERENCES prices (id) ON DELETE CASCADE, + provider VARCHAR(32) NOT NULL REFERENCES prices_providers (id) ON DELETE CASCADE, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + PRIMARY KEY (asset_id, provider) +); + +SELECT diesel_manage_updated_at('prices_assets'); + +CREATE INDEX prices_assets_price_id_idx ON prices_assets (price_id); +CREATE INDEX prices_assets_provider_idx ON prices_assets (provider); diff --git a/core/crates/storage/src/migrations/2023-07-29-000000_charts/down.sql b/core/crates/storage/src/migrations/2023-07-29-000000_charts/down.sql new file mode 100644 index 0000000000..76607614c7 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-29-000000_charts/down.sql @@ -0,0 +1,8 @@ +-- Drop functions +DROP FUNCTION IF EXISTS aggregate_hourly_charts(); +DROP FUNCTION IF EXISTS aggregate_daily_charts(); + +-- Drop tables +DROP TABLE IF EXISTS charts_daily; +DROP TABLE IF EXISTS charts_hourly; +DROP TABLE IF EXISTS charts; diff --git a/core/crates/storage/src/migrations/2023-07-29-000000_charts/up.sql b/core/crates/storage/src/migrations/2023-07-29-000000_charts/up.sql new file mode 100644 index 0000000000..479cb19f14 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-07-29-000000_charts/up.sql @@ -0,0 +1,62 @@ +CREATE TABLE IF NOT EXISTS charts ( + coin_id VARCHAR(255) NOT NULL REFERENCES prices (id) ON DELETE CASCADE, + price float NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (coin_id, created_at) +); + +CREATE TABLE IF NOT EXISTS charts_hourly ( + coin_id VARCHAR(255) NOT NULL REFERENCES prices (id) ON DELETE CASCADE, + price float NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (coin_id, created_at) +); + +CREATE TABLE IF NOT EXISTS charts_daily ( + coin_id VARCHAR(255) NOT NULL REFERENCES prices (id) ON DELETE CASCADE, + price float NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (coin_id, created_at) +); + +-- indexes +CREATE INDEX IF NOT EXISTS idx_charts_created_at ON charts (created_at); +CREATE INDEX IF NOT EXISTS idx_charts_hourly_created_at ON charts_hourly (created_at); +CREATE INDEX IF NOT EXISTS idx_charts_daily_created_at ON charts_daily (created_at); + +-- functions +CREATE OR REPLACE FUNCTION aggregate_hourly_charts() RETURNS VOID AS $$ +BEGIN + INSERT INTO charts_hourly (coin_id, created_at, price) + SELECT + charts.coin_id, + DATE_TRUNC('hour', charts.created_at) AS bucket, + AVG(charts.price) + FROM charts + WHERE charts.created_at >= DATE_TRUNC('hour', NOW()) - INTERVAL '1 hour' + GROUP BY charts.coin_id, DATE_TRUNC('hour', charts.created_at) + ORDER BY charts.coin_id, bucket + ON CONFLICT (coin_id, created_at) DO UPDATE SET price = EXCLUDED.price + WHERE charts_hourly.price <> EXCLUDED.price; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION aggregate_daily_charts() RETURNS VOID AS $$ +BEGIN + INSERT INTO charts_daily (coin_id, created_at, price) + SELECT + charts_hourly.coin_id, + DATE_TRUNC('day', charts_hourly.created_at) AS bucket, + AVG(charts_hourly.price) + FROM charts_hourly + WHERE charts_hourly.created_at >= DATE_TRUNC('day', NOW()) - INTERVAL '1 day' + GROUP BY charts_hourly.coin_id, DATE_TRUNC('day', charts_hourly.created_at) + ORDER BY charts_hourly.coin_id, bucket + ON CONFLICT (coin_id, created_at) DO UPDATE SET price = EXCLUDED.price + WHERE charts_daily.price <> EXCLUDED.price; +END; +$$ LANGUAGE plpgsql; + +ALTER TABLE charts SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 1000); +ALTER TABLE charts_hourly SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 500); +ALTER TABLE charts_daily SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 500); \ No newline at end of file diff --git a/core/crates/storage/src/migrations/2023-09-03-220931_parser/down.sql b/core/crates/storage/src/migrations/2023-09-03-220931_parser/down.sql new file mode 100644 index 0000000000..295660d4a6 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-09-03-220931_parser/down.sql @@ -0,0 +1 @@ +drop table parser_state; \ No newline at end of file diff --git a/core/crates/storage/src/migrations/2023-09-03-220931_parser/up.sql b/core/crates/storage/src/migrations/2023-09-03-220931_parser/up.sql new file mode 100644 index 0000000000..d7a5c9bfd7 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-09-03-220931_parser/up.sql @@ -0,0 +1,17 @@ +CREATE TABLE parser_state ( + chain VARCHAR NOT NULL PRIMARY KEY REFERENCES chains (id) ON DELETE CASCADE, + current_block BIGINT NOT NULL default 0, + latest_block BIGINT NOT NULL default 0, + await_blocks INTEGER NOT NULL default 0, + timeout_between_blocks INTEGER NOT NULL default 0, + timeout_latest_block INTEGER NOT NULL default 0, + parallel_blocks INTEGER NOT NULL default 1, + is_enabled boolean NOT NULL default true, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + queue_behind_blocks INTEGER, + block_time INTEGER NOT NULL, + UNIQUE(chain) +); + +SELECT diesel_manage_updated_at('parser_state'); \ No newline at end of file diff --git a/core/crates/storage/src/migrations/2023-09-04-220616_subscriptions/down.sql b/core/crates/storage/src/migrations/2023-09-04-220616_subscriptions/down.sql new file mode 100644 index 0000000000..85675db84d --- /dev/null +++ b/core/crates/storage/src/migrations/2023-09-04-220616_subscriptions/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS subscriptions_addresses_exclude; diff --git a/core/crates/storage/src/migrations/2023-09-04-220616_subscriptions/up.sql b/core/crates/storage/src/migrations/2023-09-04-220616_subscriptions/up.sql new file mode 100644 index 0000000000..036e5117dc --- /dev/null +++ b/core/crates/storage/src/migrations/2023-09-04-220616_subscriptions/up.sql @@ -0,0 +1,11 @@ +CREATE TABLE subscriptions_addresses_exclude ( + address VARCHAR(128) PRIMARY KEY NOT NULL, + chain VARCHAR(32) NOT NULL REFERENCES chains (id) ON DELETE CASCADE, + name VARCHAR(64), + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('subscriptions_addresses_exclude'); + +CREATE INDEX subscriptions_addresses_exclude_address_idx ON subscriptions_addresses_exclude (address); diff --git a/core/crates/storage/src/migrations/2023-09-05-011115_transactions/down.sql b/core/crates/storage/src/migrations/2023-09-05-011115_transactions/down.sql new file mode 100644 index 0000000000..09f8a01324 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-09-05-011115_transactions/down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS transactions_addresses; +DROP TABLE IF EXISTS transactions; +DROP TYPE IF EXISTS transaction_state; +DROP TYPE IF EXISTS transaction_type; diff --git a/core/crates/storage/src/migrations/2023-09-05-011115_transactions/up.sql b/core/crates/storage/src/migrations/2023-09-05-011115_transactions/up.sql new file mode 100644 index 0000000000..b1b0c437ea --- /dev/null +++ b/core/crates/storage/src/migrations/2023-09-05-011115_transactions/up.sql @@ -0,0 +1,41 @@ +CREATE TYPE transaction_type AS ENUM ('transfer', 'transferNFT', 'swap', 'tokenApproval', 'stakeDelegate', 'stakeUndelegate', 'stakeRewards', 'stakeRedelegate', 'stakeWithdraw', 'stakeFreeze', 'stakeUnfreeze', 'assetActivation', 'smartContractCall', 'perpetualOpenPosition', 'perpetualClosePosition', 'perpetualModifyPosition'); +CREATE TYPE transaction_state AS ENUM ('pending', 'confirmed', 'inTransit', 'failed', 'reverted'); + +CREATE TABLE transactions +( + id BIGSERIAL PRIMARY KEY, + chain VARCHAR(16) NOT NULL REFERENCES chains (id) ON DELETE CASCADE, + hash VARCHAR(128) NOT NULL, + from_address VARCHAR(256), + to_address VARCHAR(256), + memo VARCHAR(256), + state transaction_state NOT NULL, + kind transaction_type NOT NULL, + value VARCHAR(256), + asset_id VARCHAR NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + fee VARCHAR(32), + utxo_inputs jsonb, + utxo_outputs jsonb, + fee_asset_id VARCHAR NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + metadata jsonb, + created_at timestamp NOT NULL default current_timestamp, + updated_at timestamp NOT NULL default current_timestamp, + CONSTRAINT transactions_chain_hash_unique UNIQUE (chain, hash) +); + +SELECT diesel_manage_updated_at('transactions'); + +CREATE INDEX transactions_created_at_idx ON transactions (created_at DESC); +CREATE INDEX transactions_asset_id_idx ON transactions (asset_id); +CREATE INDEX transactions_state_idx ON transactions (state); + +CREATE TABLE transactions_addresses +( + id SERIAL PRIMARY KEY, + transaction_id BIGINT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, + asset_id VARCHAR(256) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + address VARCHAR(256) NOT NULL, + UNIQUE (transaction_id, address, asset_id) +); + +CREATE INDEX transactions_addresses_address_idx ON transactions_addresses (address); diff --git a/core/crates/storage/src/migrations/2023-10-18-184745_scan_address/down.sql b/core/crates/storage/src/migrations/2023-10-18-184745_scan_address/down.sql new file mode 100644 index 0000000000..241a0cbf44 --- /dev/null +++ b/core/crates/storage/src/migrations/2023-10-18-184745_scan_address/down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS scan_addresses; +DROP TYPE IF EXISTS address_type; diff --git a/core/crates/storage/src/migrations/2023-10-18-184745_scan_address/up.sql b/core/crates/storage/src/migrations/2023-10-18-184745_scan_address/up.sql new file mode 100644 index 0000000000..799a175f1f --- /dev/null +++ b/core/crates/storage/src/migrations/2023-10-18-184745_scan_address/up.sql @@ -0,0 +1,21 @@ +CREATE TYPE address_type AS ENUM ('address', 'contract', 'validator'); + +CREATE TABLE scan_addresses ( + id SERIAL PRIMARY KEY, + chain VARCHAR NOT NULL REFERENCES chains (id) ON DELETE CASCADE, + address VARCHAR(128) NOT NULL, + name VARCHAR(128), + type address_type NOT NULL DEFAULT 'address', + is_verified boolean NOT NULL DEFAULT false, + is_fraudulent boolean NOT NULL DEFAULT false, + is_memo_required boolean NOT NULL DEFAULT false, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + UNIQUE(chain, address) +); + +SELECT diesel_manage_updated_at('scan_addresses'); + +CREATE INDEX scan_addresses_address_idx ON scan_addresses (address); +CREATE INDEX scan_addresses_chain_idx ON scan_addresses (chain); +CREATE INDEX scan_addresses_type_idx ON scan_addresses (type); diff --git a/core/crates/storage/src/migrations/2024-09-12-202145_price_alerts/down.sql b/core/crates/storage/src/migrations/2024-09-12-202145_price_alerts/down.sql new file mode 100644 index 0000000000..65465c14b1 --- /dev/null +++ b/core/crates/storage/src/migrations/2024-09-12-202145_price_alerts/down.sql @@ -0,0 +1 @@ +drop table price_alerts; diff --git a/core/crates/storage/src/migrations/2024-09-12-202145_price_alerts/up.sql b/core/crates/storage/src/migrations/2024-09-12-202145_price_alerts/up.sql new file mode 100644 index 0000000000..dc0e379125 --- /dev/null +++ b/core/crates/storage/src/migrations/2024-09-12-202145_price_alerts/up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS price_alerts ( + id SERIAL PRIMARY KEY, + identifier varchar(512) NOT NULL, + + device_id INTEGER NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + asset_id VARCHAR(128) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + currency VARCHAR(128) NOT NULL REFERENCES fiat_rates (id) ON DELETE CASCADE, + price_direction VARCHAR(16), + price float, + price_percent_change float, + last_notified_at timestamp, + + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + + UNIQUE (device_id, identifier) +); + +SELECT diesel_manage_updated_at('price_alerts'); + +CREATE INDEX price_alerts_asset_id_idx ON price_alerts (asset_id); +CREATE INDEX price_alerts_last_notified_at_idx ON price_alerts (last_notified_at); \ No newline at end of file diff --git a/core/crates/storage/src/migrations/2024-09-24-204906_releases/down.sql b/core/crates/storage/src/migrations/2024-09-24-204906_releases/down.sql new file mode 100644 index 0000000000..b5960b4266 --- /dev/null +++ b/core/crates/storage/src/migrations/2024-09-24-204906_releases/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS releases; diff --git a/core/crates/storage/src/migrations/2024-09-24-204906_releases/up.sql b/core/crates/storage/src/migrations/2024-09-24-204906_releases/up.sql new file mode 100644 index 0000000000..47475672a7 --- /dev/null +++ b/core/crates/storage/src/migrations/2024-09-24-204906_releases/up.sql @@ -0,0 +1,12 @@ +CREATE TABLE releases ( + platform_store platform_store PRIMARY KEY NOT NULL, + + version VARCHAR(32) NOT NULL, + upgrade_required bool NOT NULL default false, + update_enabled bool NOT NULL default true, + + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('releases'); diff --git a/core/crates/storage/src/migrations/2025-01-14-162733_nft/down.sql b/core/crates/storage/src/migrations/2025-01-14-162733_nft/down.sql new file mode 100644 index 0000000000..b47beb1772 --- /dev/null +++ b/core/crates/storage/src/migrations/2025-01-14-162733_nft/down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS nft_reports CASCADE; +DROP TABLE IF EXISTS nft_assets_associations CASCADE; +DROP TABLE IF EXISTS nft_assets CASCADE; +DROP TABLE IF EXISTS nft_collections_links CASCADE; +DROP TABLE IF EXISTS nft_collections CASCADE; +DROP TYPE IF EXISTS nft_type CASCADE; diff --git a/core/crates/storage/src/migrations/2025-01-14-162733_nft/up.sql b/core/crates/storage/src/migrations/2025-01-14-162733_nft/up.sql new file mode 100644 index 0000000000..ddada2e375 --- /dev/null +++ b/core/crates/storage/src/migrations/2025-01-14-162733_nft/up.sql @@ -0,0 +1,105 @@ +CREATE TYPE nft_type AS ENUM ('erc721', 'erc1155', 'spl', 'jetton'); + +CREATE TABLE nft_collections ( + id SERIAL PRIMARY KEY, + identifier VARCHAR(512) UNIQUE NOT NULL, + + chain VARCHAR(64) NOT NULL REFERENCES chains (id) ON DELETE CASCADE, + + name VARCHAR(1024) NOT NULL, + description VARCHAR(4096) NOT NULL, + symbol VARCHAR(128), + + owner VARCHAR(128), + contract_address VARCHAR(128) NOT NULL, + + image_preview_url VARCHAR(512), + image_preview_mime_type VARCHAR(64), + + is_verified BOOLEAN NOT NULL default false, + is_enabled BOOLEAN NOT NULL default true, + + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('nft_collections'); +CREATE INDEX nft_collections_updated_at_idx ON nft_collections (updated_at); + +CREATE TABLE nft_collections_links ( + id SERIAL PRIMARY KEY, + + collection_id INTEGER NOT NULL REFERENCES nft_collections (id) ON DELETE CASCADE, + + link_type link_type NOT NULL, + + url VARCHAR(256) NOT NULL, + + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + + UNIQUE(collection_id, link_type) +); + +SELECT diesel_manage_updated_at('nft_collections_links'); + +CREATE TABLE nft_assets ( + id SERIAL PRIMARY KEY, + identifier VARCHAR(512) UNIQUE NOT NULL, + + collection_id INTEGER NOT NULL REFERENCES nft_collections (id) ON DELETE CASCADE, + chain VARCHAR(64) NOT NULL REFERENCES chains (id) ON DELETE CASCADE, + + name VARCHAR(1024) NOT NULL, + description VARCHAR(4096) NOT NULL, + + image_preview_url VARCHAR(512), + image_preview_mime_type VARCHAR(64), + + resource_url VARCHAR(512), + resource_mime_type VARCHAR(64), + + token_type nft_type NOT NULL, + token_id VARCHAR(512) NOT NULL, + contract_address VARCHAR(512) NOT NULL, + + attributes JSONB NOT NULL, + + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('nft_assets'); +CREATE INDEX nft_assets_collection_id_idx ON nft_assets (collection_id); + +CREATE TABLE nft_assets_associations ( + id SERIAL PRIMARY KEY, + + address_id INTEGER NOT NULL REFERENCES wallets_addresses (id) ON DELETE CASCADE, + asset_id INTEGER NOT NULL REFERENCES nft_assets (id) ON DELETE CASCADE, + + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + + UNIQUE(address_id, asset_id) +); + +SELECT diesel_manage_updated_at('nft_assets_associations'); +CREATE INDEX nft_assets_associations_address_id_idx ON nft_assets_associations (address_id); +CREATE INDEX nft_assets_associations_asset_id_idx ON nft_assets_associations (asset_id); + +CREATE TABLE nft_reports ( + id SERIAL PRIMARY KEY, + + asset_id INTEGER REFERENCES nft_assets (id) ON DELETE CASCADE, + collection_id INTEGER NOT NULL REFERENCES nft_collections (id) ON DELETE CASCADE, + + device_id INTEGER NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + reason VARCHAR(1024), + reviewed BOOLEAN NOT NULL DEFAULT false, + + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('nft_reports'); diff --git a/core/crates/storage/src/migrations/2025-10-02-120000_perpetuals/down.sql b/core/crates/storage/src/migrations/2025-10-02-120000_perpetuals/down.sql new file mode 100644 index 0000000000..67147c4737 --- /dev/null +++ b/core/crates/storage/src/migrations/2025-10-02-120000_perpetuals/down.sql @@ -0,0 +1,2 @@ +DROP TABLE perpetuals_assets; +DROP TABLE perpetuals; diff --git a/core/crates/storage/src/migrations/2025-10-02-120000_perpetuals/up.sql b/core/crates/storage/src/migrations/2025-10-02-120000_perpetuals/up.sql new file mode 100644 index 0000000000..50464b1596 --- /dev/null +++ b/core/crates/storage/src/migrations/2025-10-02-120000_perpetuals/up.sql @@ -0,0 +1,29 @@ +CREATE TABLE perpetuals ( + id VARCHAR(128) PRIMARY KEY, + name VARCHAR(128) NOT NULL, + provider VARCHAR(32) NOT NULL, + asset_id VARCHAR(256) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + identifier VARCHAR(128) NOT NULL, + price DOUBLE PRECISION NOT NULL, + price_percent_change_24h DOUBLE PRECISION NOT NULL, + open_interest DOUBLE PRECISION NOT NULL, + volume_24h DOUBLE PRECISION NOT NULL, + funding DOUBLE PRECISION NOT NULL, + leverage INTEGER[] NOT NULL, + is_isolated_only BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE perpetuals_assets +( + id SERIAL PRIMARY KEY, + perpetual_id VARCHAR(128) NOT NULL REFERENCES perpetuals (id) ON DELETE CASCADE, + asset_id VARCHAR(256) NOT NULL REFERENCES assets (id) ON DELETE CASCADE, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +SELECT diesel_manage_updated_at('perpetuals'); +CREATE INDEX perpetuals_updated_at_idx ON perpetuals (updated_at); +SELECT diesel_manage_updated_at('perpetuals_assets'); diff --git a/core/crates/storage/src/migrations/2025-12-10-120000_rewards/down.sql b/core/crates/storage/src/migrations/2025-12-10-120000_rewards/down.sql new file mode 100644 index 0000000000..4f5947f97f --- /dev/null +++ b/core/crates/storage/src/migrations/2025-12-10-120000_rewards/down.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS rewards_referral_attempts; +DROP TABLE IF EXISTS rewards_events; +DROP TABLE IF EXISTS rewards_referrals; +DROP TABLE IF EXISTS rewards_risk_signals; +DROP TABLE IF EXISTS rewards; +DROP TABLE IF EXISTS usernames; +DROP TYPE IF EXISTS username_status; +DROP TYPE IF EXISTS reward_event_type; +DROP TYPE IF EXISTS reward_status; +DROP TYPE IF EXISTS ip_usage_type; diff --git a/core/crates/storage/src/migrations/2025-12-10-120000_rewards/up.sql b/core/crates/storage/src/migrations/2025-12-10-120000_rewards/up.sql new file mode 100644 index 0000000000..e314595d3d --- /dev/null +++ b/core/crates/storage/src/migrations/2025-12-10-120000_rewards/up.sql @@ -0,0 +1,101 @@ +CREATE TYPE reward_status AS ENUM ('unverified', 'pending', 'verified', 'trusted', 'disabled'); +CREATE TYPE reward_event_type AS ENUM ('createUsername', 'invitePending', 'inviteNew', 'inviteExisting', 'joined', 'enabled', 'disabled', 'redeemed'); +CREATE TYPE username_status AS ENUM ('unverified', 'verified'); +CREATE TYPE ip_usage_type AS ENUM ('dataCenter', 'hosting', 'isp', 'mobile', 'business', 'education', 'government', 'unknown'); + +CREATE TABLE usernames ( + username VARCHAR(64) PRIMARY KEY, + wallet_id INTEGER NOT NULL UNIQUE REFERENCES wallets(id) ON DELETE CASCADE, + status username_status NOT NULL DEFAULT 'unverified', + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +CREATE INDEX usernames_username_lower_idx ON usernames (lower(username)); + +SELECT diesel_manage_updated_at('usernames'); + +CREATE TABLE rewards ( + username VARCHAR(64) PRIMARY KEY REFERENCES usernames(username) ON DELETE CASCADE ON UPDATE CASCADE, + status reward_status NOT NULL, + level VARCHAR(32), + points INT NOT NULL DEFAULT 0 CHECK (points >= 0), + referrer_username VARCHAR(64) REFERENCES rewards(username) ON DELETE SET NULL ON UPDATE CASCADE, + referral_count INT NOT NULL DEFAULT 0 CHECK (referral_count >= 0), + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + comment VARCHAR(512), + disable_reason VARCHAR(256), + verify_after TIMESTAMP, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('rewards'); + +CREATE TABLE rewards_risk_signals ( + id SERIAL PRIMARY KEY, + fingerprint VARCHAR(64) NOT NULL, + referrer_username VARCHAR(64) NOT NULL REFERENCES rewards(username) ON DELETE CASCADE ON UPDATE CASCADE, + device_id INT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + device_platform platform NOT NULL, + device_platform_store platform_store NOT NULL, + device_os VARCHAR(32) NOT NULL, + device_model VARCHAR(64) NOT NULL, + device_locale VARCHAR(16) NOT NULL, + device_currency VARCHAR(8) NOT NULL, + ip_address VARCHAR(45) NOT NULL, + ip_country_code VARCHAR(2) NOT NULL, + ip_usage_type ip_usage_type NOT NULL, + ip_isp VARCHAR(128) NOT NULL, + ip_abuse_score INT NOT NULL, + risk_score INT NOT NULL, + user_agent VARCHAR(256) NOT NULL, + metadata JSONB, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); + +CREATE INDEX rewards_risk_signals_fingerprint_idx ON rewards_risk_signals(fingerprint); +CREATE INDEX rewards_risk_signals_referrer_username_idx ON rewards_risk_signals(referrer_username); +CREATE INDEX rewards_risk_signals_ip_address_idx ON rewards_risk_signals(ip_address); +CREATE INDEX rewards_risk_signals_device_id_idx ON rewards_risk_signals(device_id); + +CREATE TABLE rewards_referrals ( + id SERIAL PRIMARY KEY, + referrer_username VARCHAR(64) NOT NULL REFERENCES rewards(username) ON DELETE CASCADE ON UPDATE CASCADE, + referred_username VARCHAR(64) NOT NULL REFERENCES rewards(username) ON DELETE CASCADE ON UPDATE CASCADE UNIQUE, + referred_device_id INT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + risk_signal_id INT NOT NULL REFERENCES rewards_risk_signals(id), + verified_at TIMESTAMP, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +CREATE INDEX rewards_referrals_referrer_idx ON rewards_referrals(referrer_username); +CREATE INDEX rewards_referrals_referred_device_id_idx ON rewards_referrals(referred_device_id); + +SELECT diesel_manage_updated_at('rewards_referrals'); + +CREATE TABLE rewards_events ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL REFERENCES usernames(username) ON DELETE CASCADE ON UPDATE CASCADE, + event_type reward_event_type NOT NULL, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +CREATE INDEX rewards_events_username_idx ON rewards_events(username); + +SELECT diesel_manage_updated_at('rewards_events'); + +CREATE TABLE rewards_referral_attempts ( + id SERIAL PRIMARY KEY, + referrer_username VARCHAR(64) NOT NULL REFERENCES rewards(username) ON UPDATE CASCADE ON DELETE CASCADE, + wallet_id INTEGER NOT NULL REFERENCES wallets(id) ON DELETE CASCADE, + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + risk_signal_id INT NULL REFERENCES rewards_risk_signals(id) ON DELETE SET NULL, + reason VARCHAR(256) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_rewards_referral_attempts_referrer_username ON rewards_referral_attempts(referrer_username); +CREATE INDEX idx_rewards_referral_attempts_created_at ON rewards_referral_attempts(created_at); diff --git a/core/crates/storage/src/migrations/2025-12-16-011051_rewards_redemptions/down.sql b/core/crates/storage/src/migrations/2025-12-16-011051_rewards_redemptions/down.sql new file mode 100644 index 0000000000..61ba9fb04c --- /dev/null +++ b/core/crates/storage/src/migrations/2025-12-16-011051_rewards_redemptions/down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS rewards_redemptions; +DROP TABLE IF EXISTS rewards_redemption_options; +DROP TYPE IF EXISTS redemption_status; +DROP TYPE IF EXISTS reward_redemption_type; diff --git a/core/crates/storage/src/migrations/2025-12-16-011051_rewards_redemptions/up.sql b/core/crates/storage/src/migrations/2025-12-16-011051_rewards_redemptions/up.sql new file mode 100644 index 0000000000..72eea272d0 --- /dev/null +++ b/core/crates/storage/src/migrations/2025-12-16-011051_rewards_redemptions/up.sql @@ -0,0 +1,32 @@ +CREATE TYPE reward_redemption_type AS ENUM ('asset', 'giftAsset'); +CREATE TYPE redemption_status AS ENUM ('pending', 'processing', 'completed', 'failed'); + +CREATE TABLE rewards_redemption_options ( + id VARCHAR(64) PRIMARY KEY, + redemption_type reward_redemption_type NOT NULL, + points INT NOT NULL, + asset_id VARCHAR(128) REFERENCES assets(id) ON DELETE CASCADE, + value VARCHAR(64) NOT NULL, + remaining INT, + updated_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); + +SELECT diesel_manage_updated_at('rewards_redemption_options'); + +CREATE TABLE rewards_redemptions ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL REFERENCES rewards(username) ON UPDATE CASCADE ON DELETE CASCADE, + option_id VARCHAR(64) NOT NULL REFERENCES rewards_redemption_options(id) ON DELETE CASCADE, + device_id INT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + wallet_id INT NOT NULL REFERENCES wallets(id) ON DELETE CASCADE, + transaction_id VARCHAR(512), + status redemption_status NOT NULL, + error VARCHAR(1024), + updated_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); + +CREATE INDEX idx_rewards_redemptions_username_created_at ON rewards_redemptions(username, created_at); + +SELECT diesel_manage_updated_at('rewards_redemptions'); diff --git a/core/crates/storage/src/migrations/2025-12-18-120000_config/down.sql b/core/crates/storage/src/migrations/2025-12-18-120000_config/down.sql new file mode 100644 index 0000000000..2e6f861559 --- /dev/null +++ b/core/crates/storage/src/migrations/2025-12-18-120000_config/down.sql @@ -0,0 +1 @@ +DROP TABLE config; diff --git a/core/crates/storage/src/migrations/2025-12-18-120000_config/up.sql b/core/crates/storage/src/migrations/2025-12-18-120000_config/up.sql new file mode 100644 index 0000000000..b86eed94b7 --- /dev/null +++ b/core/crates/storage/src/migrations/2025-12-18-120000_config/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE config ( + key VARCHAR(64) PRIMARY KEY, + value VARCHAR(256) NOT NULL, + default_value VARCHAR(256) NOT NULL, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp +); + +SELECT diesel_manage_updated_at('config'); diff --git a/core/crates/storage/src/migrations/2026-01-13-120000_notifications/down.sql b/core/crates/storage/src/migrations/2026-01-13-120000_notifications/down.sql new file mode 100644 index 0000000000..22509d3033 --- /dev/null +++ b/core/crates/storage/src/migrations/2026-01-13-120000_notifications/down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS notifications_asset_id_idx; +DROP INDEX IF EXISTS notifications_created_at_idx; +DROP INDEX IF EXISTS notifications_is_read_idx; +DROP INDEX IF EXISTS notifications_wallet_id_idx; +DROP TABLE IF EXISTS notifications; +DROP TYPE IF EXISTS notification_type; diff --git a/core/crates/storage/src/migrations/2026-01-13-120000_notifications/up.sql b/core/crates/storage/src/migrations/2026-01-13-120000_notifications/up.sql new file mode 100644 index 0000000000..e30ec75f47 --- /dev/null +++ b/core/crates/storage/src/migrations/2026-01-13-120000_notifications/up.sql @@ -0,0 +1,17 @@ +CREATE TYPE notification_type AS ENUM ('referralJoined', 'rewardsEnabled', 'rewardsCodeDisabled', 'rewardsRedeemed', 'rewardsCreateUsername', 'rewardsInvite'); + +CREATE TABLE notifications ( + id SERIAL PRIMARY KEY, + wallet_id INTEGER NOT NULL REFERENCES wallets(id) ON DELETE CASCADE, + asset_id VARCHAR REFERENCES assets(id) ON DELETE CASCADE, + notification_type notification_type NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT false, + metadata JSONB, + read_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); + +CREATE INDEX notifications_wallet_id_idx ON notifications (wallet_id); +CREATE INDEX notifications_asset_id_idx ON notifications (asset_id); +CREATE INDEX notifications_is_read_idx ON notifications (is_read); +CREATE INDEX notifications_created_at_idx ON notifications (created_at DESC); diff --git a/core/crates/storage/src/migrations/2026-04-09-120000_webhook_endpoints/down.sql b/core/crates/storage/src/migrations/2026-04-09-120000_webhook_endpoints/down.sql new file mode 100644 index 0000000000..66b823a344 --- /dev/null +++ b/core/crates/storage/src/migrations/2026-04-09-120000_webhook_endpoints/down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS webhook_endpoints; +DROP TYPE IF EXISTS webhook_kind; diff --git a/core/crates/storage/src/migrations/2026-04-09-120000_webhook_endpoints/up.sql b/core/crates/storage/src/migrations/2026-04-09-120000_webhook_endpoints/up.sql new file mode 100644 index 0000000000..400f221feb --- /dev/null +++ b/core/crates/storage/src/migrations/2026-04-09-120000_webhook_endpoints/up.sql @@ -0,0 +1,16 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TYPE webhook_kind AS ENUM ('transactions', 'support', 'fiat'); + +CREATE TABLE webhook_endpoints ( + kind webhook_kind NOT NULL, + sender VARCHAR(128) NOT NULL, + secret VARCHAR(64) NOT NULL DEFAULT gen_random_uuid()::text, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + updated_at timestamp NOT NULL default current_timestamp, + created_at timestamp NOT NULL default current_timestamp, + PRIMARY KEY (kind, sender), + UNIQUE (secret) +); + +SELECT diesel_manage_updated_at('webhook_endpoints'); diff --git a/core/crates/storage/src/mod.rs b/core/crates/storage/src/mod.rs new file mode 100644 index 0000000000..b19f230bb3 --- /dev/null +++ b/core/crates/storage/src/mod.rs @@ -0,0 +1,3 @@ +//mod storage; + +mod schema; diff --git a/core/crates/storage/src/models/asset.rs b/core/crates/storage/src/models/asset.rs new file mode 100644 index 0000000000..cffda3bd54 --- /dev/null +++ b/core/crates/storage/src/models/asset.rs @@ -0,0 +1,160 @@ +use std::str::FromStr; + +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::{Asset, AssetBasic, AssetId as PrimitiveAssetId, AssetLink, AssetProperties, AssetScore}; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{AssetId, AssetType, ChainRow, LinkType}; + +#[derive(Debug, Queryable, Selectable, Identifiable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::assets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AssetRow { + pub id: String, + pub chain: ChainRow, + pub token_id: Option, + pub name: String, + pub symbol: String, + pub asset_type: AssetType, + pub decimals: i32, + pub rank: i32, + + pub is_enabled: bool, + pub is_buyable: bool, + pub is_sellable: bool, + pub is_swappable: bool, + pub is_stakeable: bool, + pub staking_apr: Option, + pub is_earnable: bool, + pub earn_apr: Option, + pub has_image: bool, + pub has_price: bool, + pub circulating_supply: Option, + pub total_supply: Option, + pub max_supply: Option, + + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::assets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewAssetRow { + pub id: String, + pub chain: ChainRow, + pub token_id: Option, + pub name: String, + pub symbol: String, + pub asset_type: AssetType, + pub decimals: i32, + pub rank: i32, + + pub is_enabled: bool, + pub is_buyable: bool, + pub is_sellable: bool, + pub is_swappable: bool, + pub is_stakeable: bool, + pub staking_apr: Option, + pub is_earnable: bool, + pub earn_apr: Option, + pub has_image: bool, + pub has_price: bool, + pub circulating_supply: Option, + pub total_supply: Option, + pub max_supply: Option, +} + +impl NewAssetRow { + pub fn from_primitive_default(asset: Asset) -> Self { + Self::from_primitive(asset.clone(), AssetScore::default(), AssetProperties::default(asset.id)) + } + + pub fn from_primitive(asset: Asset, score: AssetScore, properties: AssetProperties) -> Self { + Self { + id: asset.id.to_string(), + chain: ChainRow::from(asset.id.chain), + token_id: asset.id.token_id, + name: asset.name, + symbol: asset.symbol, + asset_type: asset.asset_type.into(), + decimals: asset.decimals, + rank: score.rank, + is_enabled: properties.is_enabled, + is_buyable: properties.is_buyable, + is_sellable: properties.is_sellable, + is_swappable: properties.is_swapable, + is_stakeable: properties.is_stakeable, + staking_apr: properties.staking_apr, + is_earnable: properties.is_earnable, + earn_apr: properties.earn_apr, + has_image: properties.has_image, + has_price: properties.has_price, + circulating_supply: None, + total_supply: None, + max_supply: None, + } + } +} + +impl AssetRow { + pub fn as_asset_id(&self) -> PrimitiveAssetId { + PrimitiveAssetId { + chain: self.chain.0, + token_id: self.token_id.clone(), + } + } + + pub fn as_primitive(&self) -> Asset { + Asset::new(self.as_asset_id(), self.name.clone(), self.symbol.clone(), self.decimals, self.asset_type.0.clone()) + } + + pub fn as_basic_primitive(&self) -> AssetBasic { + AssetBasic::new(self.as_primitive(), self.as_property_primitive(), self.as_score_primitive()) + } + + pub fn as_score_primitive(&self) -> AssetScore { + AssetScore::new(self.rank) + } + + pub fn as_property_primitive(&self) -> AssetProperties { + AssetProperties { + is_enabled: self.is_enabled, + is_buyable: self.is_buyable, + is_sellable: self.is_sellable, + is_swapable: self.is_swappable, + is_stakeable: self.is_stakeable, + staking_apr: self.staking_apr, + is_earnable: self.is_earnable, + earn_apr: self.earn_apr, + has_image: self.has_image, + has_price: self.has_price, + } + } +} + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::assets_links)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AssetLinkRow { + pub asset_id: AssetId, + pub link_type: LinkType, + pub url: String, +} + +impl AssetLinkRow { + pub fn as_primitive(&self) -> AssetLink { + AssetLink { + name: self.link_type.as_ref().to_string(), + url: self.url.clone(), + } + } + + pub fn from_primitive(asset_id: &PrimitiveAssetId, link: AssetLink) -> Self { + Self { + asset_id: asset_id.into(), + link_type: primitives::LinkType::from_str(&link.name).unwrap().into(), + url: link.url.clone(), + } + } +} diff --git a/core/crates/storage/src/models/asset_address.rs b/core/crates/storage/src/models/asset_address.rs new file mode 100644 index 0000000000..681731b30f --- /dev/null +++ b/core/crates/storage/src/models/asset_address.rs @@ -0,0 +1,75 @@ +use diesel::prelude::*; +use primitives::{AssetAddress, AssetId as PrimitiveAssetId, Chain}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +use crate::sql_types::{AssetId, ChainRow}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, Clone)] +#[diesel(table_name = crate::schema::assets_addresses)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AssetAddressRow { + pub chain: ChainRow, + pub asset_id: AssetId, + pub address: String, + pub value: Option, +} + +impl AssetAddressRow { + pub fn new(chain: Chain, asset_id: PrimitiveAssetId, address: String, value: Option) -> Self { + Self { + chain: ChainRow::from(chain), + asset_id: asset_id.into(), + address, + value, + } + } + + pub fn from_primitive(asset_address: AssetAddress) -> Self { + Self { + chain: ChainRow::from(asset_address.asset_id.chain), + asset_id: asset_address.asset_id.into(), + address: asset_address.address.clone(), + value: asset_address.value.clone(), + } + } + + pub fn as_primitive(&self) -> AssetAddress { + AssetAddress { + asset_id: self.asset_id.0.clone(), + address: self.address.clone(), + value: self.value.clone(), + } + } +} + +pub trait AssetAddressRowsExt { + fn asset_ids(self) -> Vec; +} + +impl AssetAddressRowsExt for Vec { + fn asset_ids(self) -> Vec { + let mut seen = HashSet::new(); + + self.into_iter().filter_map(|row| seen.insert(row.asset_id.0.clone()).then_some(row.asset_id.0)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Asset, asset_constants::ETHEREUM_USDC_ASSET_ID}; + + #[test] + fn test_asset_ids() { + let eth = Asset::from_chain(Chain::Ethereum).id; + let usdc = ETHEREUM_USDC_ASSET_ID.clone(); + let rows = vec![ + AssetAddressRow::new(Chain::Ethereum, eth.clone(), "0xwallet".to_string(), Some("100".to_string())), + AssetAddressRow::new(Chain::Ethereum, usdc.clone(), "0xwallet".to_string(), Some("1".to_string())), + AssetAddressRow::new(Chain::Ethereum, usdc.clone(), "0xother".to_string(), Some("2".to_string())), + ]; + + assert_eq!(rows.asset_ids(), vec![eth, usdc]); + } +} diff --git a/core/crates/storage/src/models/asset_usage_rank.rs b/core/crates/storage/src/models/asset_usage_rank.rs new file mode 100644 index 0000000000..f2861f71b0 --- /dev/null +++ b/core/crates/storage/src/models/asset_usage_rank.rs @@ -0,0 +1,11 @@ +use diesel::prelude::*; + +use crate::sql_types::AssetId; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::assets_usage_ranks)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AssetUsageRankRow { + pub asset_id: AssetId, + pub usage_rank: i32, +} diff --git a/core/crates/storage/src/models/chain.rs b/core/crates/storage/src/models/chain.rs new file mode 100644 index 0000000000..f889139243 --- /dev/null +++ b/core/crates/storage/src/models/chain.rs @@ -0,0 +1,10 @@ +use crate::sql_types::ChainRow; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, Clone)] +#[diesel(table_name = crate::schema::chains)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ChainIdRow { + pub id: ChainRow, +} diff --git a/core/crates/storage/src/models/chart.rs b/core/crates/storage/src/models/chart.rs new file mode 100644 index 0000000000..b39d084f65 --- /dev/null +++ b/core/crates/storage/src/models/chart.rs @@ -0,0 +1,86 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::models::PriceRow; + +#[derive(Debug, Clone, Queryable, Selectable, Insertable, Serialize, Deserialize)] +#[diesel(table_name = crate::schema::charts)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ChartRow { + pub coin_id: String, + pub price: f64, + pub created_at: NaiveDateTime, +} + +impl ChartRow { + pub fn new(coin_id: String, price: f64, created_at: NaiveDateTime) -> Self { + ChartRow { coin_id, price, created_at } + } + + pub fn from_price(price: PriceRow) -> Self { + ChartRow { + coin_id: price.id.to_string(), + price: price.price, + created_at: price.last_updated_at, + } + } +} + +#[derive(Debug, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = crate::schema::charts_hourly)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct HourlyChartRow { + pub coin_id: String, + pub price: f64, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = crate::schema::charts_daily)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct DailyChartRow { + pub coin_id: String, + pub price: f64, + pub created_at: NaiveDateTime, +} + +impl From for ChartRow { + fn from(chart: HourlyChartRow) -> Self { + ChartRow { + coin_id: chart.coin_id, + price: chart.price, + created_at: chart.created_at, + } + } +} + +impl From for ChartRow { + fn from(chart: DailyChartRow) -> Self { + ChartRow { + coin_id: chart.coin_id, + price: chart.price, + created_at: chart.created_at, + } + } +} + +impl From for HourlyChartRow { + fn from(chart: ChartRow) -> Self { + HourlyChartRow { + coin_id: chart.coin_id, + price: chart.price, + created_at: chart.created_at, + } + } +} + +impl From for DailyChartRow { + fn from(chart: ChartRow) -> Self { + DailyChartRow { + coin_id: chart.coin_id, + price: chart.price, + created_at: chart.created_at, + } + } +} diff --git a/core/crates/storage/src/models/config.rs b/core/crates/storage/src/models/config.rs new file mode 100644 index 0000000000..7ec6566f5f --- /dev/null +++ b/core/crates/storage/src/models/config.rs @@ -0,0 +1,34 @@ +use diesel::prelude::*; +use primitives::{ConfigKey, ConfigParamKey}; +use std::str::FromStr; + +#[derive(Debug, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = crate::schema::config)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ConfigRow { + pub key: String, + pub value: String, + pub default_value: String, +} + +impl ConfigRow { + pub fn from_primitive(key: ConfigKey) -> Self { + Self { + key: key.as_ref().to_string(), + value: key.default_value().to_string(), + default_value: key.default_value().to_string(), + } + } + + pub fn from_param(key: ConfigParamKey) -> Self { + Self { + key: key.key(), + value: key.default_value().to_string(), + default_value: key.default_value().to_string(), + } + } + + pub fn key(&self) -> Option { + ConfigKey::from_str(&self.key).ok() + } +} diff --git a/core/crates/storage/src/models/device.rs b/core/crates/storage/src/models/device.rs new file mode 100644 index 0000000000..001492bd17 --- /dev/null +++ b/core/crates/storage/src/models/device.rs @@ -0,0 +1,83 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::Device; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{Platform, PlatformStore}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::devices)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct DeviceRow { + pub id: i32, + pub device_id: String, + pub platform: Platform, + pub platform_store: PlatformStore, + pub token: String, + pub locale: String, + pub currency: String, + pub is_push_enabled: bool, + pub is_price_alerts_enabled: bool, + pub version: String, + pub subscriptions_version: i32, + pub os: String, + pub model: String, + pub updated_at: NaiveDateTime, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::devices)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct UpdateDeviceRow { + pub device_id: String, + pub platform: Platform, + pub platform_store: PlatformStore, + pub token: String, + pub locale: String, + pub currency: String, + pub is_push_enabled: bool, + pub is_price_alerts_enabled: bool, + pub version: String, + pub subscriptions_version: i32, + pub os: String, + pub model: String, +} + +impl DeviceRow { + pub fn as_primitive(&self) -> Device { + Device { + id: self.device_id.clone(), + platform: self.platform.0, + platform_store: self.platform_store.0, + os: self.os.clone(), + model: self.model.clone(), + token: self.token.clone(), + locale: self.locale.clone(), + currency: self.currency.clone(), + is_push_enabled: self.is_push_enabled, + is_price_alerts_enabled: Some(self.is_price_alerts_enabled), + version: self.version.clone(), + subscriptions_version: self.subscriptions_version, + } + } +} + +impl UpdateDeviceRow { + pub fn from_primitive(device: Device) -> Self { + Self { + device_id: device.id, + platform: device.platform.into(), + platform_store: device.platform_store.into(), + os: device.os, + model: device.model, + token: device.token, + locale: device.locale, + currency: device.currency, + is_push_enabled: device.is_push_enabled, + is_price_alerts_enabled: device.is_price_alerts_enabled.unwrap_or(false), + version: device.version, + subscriptions_version: device.subscriptions_version, + } + } +} diff --git a/core/crates/storage/src/models/fiat.rs b/core/crates/storage/src/models/fiat.rs new file mode 100644 index 0000000000..14073395c5 --- /dev/null +++ b/core/crates/storage/src/models/fiat.rs @@ -0,0 +1,378 @@ +use std::collections::{HashMap, HashSet}; + +use crate::DatabaseError; +use crate::sql_types::{AssetId, FiatProviderNameRow, FiatTransactionStatusRow, FiatTransactionType}; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::{ + AssetId as PrimitiveAssetId, FiatAsset, FiatProvider, FiatProviderCountry, FiatProviderName, FiatRate, FiatTransaction, FiatTransactionUpdate, PaymentType, + fiat_assets::FiatAssetLimits, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::fiat_rates)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct FiatRateRow { + pub id: String, + pub name: String, + pub rate: f64, +} + +impl FiatRateRow { + pub fn as_primitive(&self) -> FiatRate { + FiatRate { + symbol: self.id.clone(), + rate: self.rate, + } + } + + pub fn from_primitive(rate: FiatRate) -> Self { + FiatRateRow { + id: rate.symbol, + name: "".to_string(), + rate: rate.rate, + } + } +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::fiat_assets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct FiatAssetRow { + pub id: String, + pub asset_id: Option, + pub provider: FiatProviderNameRow, + pub code: String, + pub symbol: String, + pub network: Option, + pub token_id: Option, + pub is_enabled: bool, // managed by db + pub is_enabled_by_provider: bool, + pub is_buy_enabled: bool, + pub is_sell_enabled: bool, + pub buy_limits: Option, + pub sell_limits: Option, + pub unsupported_countries: Option, +} + +impl FiatAssetRow { + pub fn from_primitive(asset: FiatAsset) -> Result { + let provider = FiatProviderNameRow::from(asset.provider); + let id = format!("{}_{}", provider.0.id(), asset.id).to_lowercase(); + let buy_limits = Some(serde_json::to_value(asset.buy_limits)?); + let sell_limits = Some(serde_json::to_value(asset.sell_limits)?); + let unsupported_countries = Some(serde_json::to_value(asset.unsupported_countries)?); + + Ok(Self { + id, + asset_id: asset.asset_id.map(AssetId::from), + provider, + code: asset.id, + symbol: asset.symbol, + network: asset.network, + token_id: asset.token_id, + is_enabled: asset.enabled, + is_enabled_by_provider: asset.enabled, + is_buy_enabled: asset.is_buy_enabled, + is_sell_enabled: asset.is_sell_enabled, + buy_limits, + sell_limits, + unsupported_countries, + }) + } + + pub fn is_enabled(&self) -> bool { + self.is_enabled && self.is_enabled_by_provider + } + + pub fn is_buy_enabled(&self) -> bool { + self.is_enabled() && self.is_buy_enabled + } + + pub fn is_sell_enabled(&self) -> bool { + self.is_enabled() && self.is_sell_enabled + } + + pub fn unsupported_countries(&self) -> HashMap> { + self.unsupported_countries.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() + } + + pub fn buy_limits(&self) -> Vec { + self.buy_limits.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() + } + + pub fn sell_limits(&self) -> Vec { + self.sell_limits.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() + } +} + +pub trait FiatAssetRowsExt { + fn asset_ids(self) -> Vec; +} + +impl FiatAssetRowsExt for Vec { + fn asset_ids(self) -> Vec { + self.into_iter() + .filter_map(|x| x.asset_id.map(|asset_id| asset_id.0)) + .collect::>() + .into_iter() + .collect() + } +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::fiat_providers)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct FiatProviderRow { + pub id: FiatProviderNameRow, + pub name: String, + pub enabled: bool, + pub buy_enabled: bool, + pub sell_enabled: bool, + pub priority: Option, + pub priority_threshold_bps: Option, + pub payment_methods: serde_json::Value, +} + +impl FiatProviderRow { + pub fn from_primitive(provider: FiatProviderName) -> Self { + Self { + id: provider.into(), + name: provider.name().to_string(), + enabled: true, + buy_enabled: true, + sell_enabled: true, + priority: None, + priority_threshold_bps: None, + payment_methods: serde_json::to_value(Vec::::new()).unwrap(), + } + } + + pub fn as_primitive(&self) -> FiatProvider { + let provider = self.id.0; + let payment_methods: Vec = serde_json::from_value(self.payment_methods.clone()).unwrap_or_default(); + + FiatProvider { + id: provider, + name: provider.name().to_string(), + image_url: None, + priority: self.priority, + threshold_bps: self.priority_threshold_bps, + enabled: self.enabled, + buy_enabled: self.buy_enabled, + sell_enabled: self.sell_enabled, + payment_methods, + } + } + + pub fn is_buy_enabled(&self) -> bool { + self.enabled && self.buy_enabled + } + + pub fn is_sell_enabled(&self) -> bool { + self.enabled && self.sell_enabled + } +} + +#[derive(Debug, Queryable, Selectable, Clone)] +#[diesel(table_name = crate::schema::fiat_transactions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct FiatTransactionRow { + pub id: i32, + pub asset_id: AssetId, + pub transaction_type: FiatTransactionType, + pub provider_id: FiatProviderNameRow, + pub provider_transaction_id: Option, + pub status: FiatTransactionStatusRow, + pub country: Option, + pub fiat_amount: f64, + pub fiat_currency: String, + pub value: Option, + pub address_id: i32, + pub transaction_hash: Option, + pub device_id: i32, + pub wallet_id: i32, + pub quote_id: String, + pub updated_at: NaiveDateTime, + pub created_at: NaiveDateTime, +} + +impl FiatTransactionRow { + pub fn as_primitive(&self) -> Result { + let value = self + .value + .clone() + .ok_or_else(|| DatabaseError::Error(format!("Fiat transaction {} is missing value", self.quote_id)))?; + + Ok(FiatTransaction { + id: self.quote_id.clone(), + asset_id: self.asset_id.0.clone(), + transaction_type: self.transaction_type.0.clone(), + provider: self.provider_id.0, + provider_transaction_id: self.provider_transaction_id.clone(), + status: self.status.0.clone(), + country: self.country.clone(), + fiat_amount: self.fiat_amount, + fiat_currency: self.fiat_currency.clone(), + value, + transaction_hash: self.transaction_hash.clone(), + created_at: self.created_at.and_utc(), + updated_at: self.updated_at.and_utc(), + }) + } +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::fiat_transactions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewFiatTransactionRow { + pub asset_id: AssetId, + pub transaction_type: FiatTransactionType, + pub provider_id: FiatProviderNameRow, + pub provider_transaction_id: Option, + pub status: FiatTransactionStatusRow, + pub country: Option, + pub fiat_amount: f64, + pub fiat_currency: String, + pub value: Option, + pub address_id: i32, + pub transaction_hash: Option, + pub device_id: i32, + pub wallet_id: i32, + pub quote_id: String, +} + +impl NewFiatTransactionRow { + pub fn new(transaction: FiatTransaction, device_id: i32, wallet_id: i32, address_id: i32) -> Self { + Self { + asset_id: transaction.asset_id.into(), + transaction_type: transaction.transaction_type.into(), + provider_id: transaction.provider.into(), + provider_transaction_id: transaction.provider_transaction_id, + status: transaction.status.into(), + country: transaction.country, + fiat_amount: transaction.fiat_amount, + fiat_currency: transaction.fiat_currency, + value: Some(transaction.value), + address_id, + transaction_hash: transaction.transaction_hash, + device_id, + wallet_id, + quote_id: transaction.id, + } + } + + pub fn from_existing(existing: &FiatTransactionRow, update: &FiatTransactionUpdate, provider_transaction_id: String) -> Self { + Self { + asset_id: existing.asset_id.clone(), + transaction_type: existing.transaction_type.clone(), + provider_id: existing.provider_id.clone(), + provider_transaction_id: Some(provider_transaction_id), + status: update.status.clone().into(), + country: existing.country.clone(), + fiat_amount: update.fiat_amount.unwrap_or(existing.fiat_amount), + fiat_currency: update.fiat_currency.clone().unwrap_or_else(|| existing.fiat_currency.clone()), + value: existing.value.clone(), + address_id: existing.address_id, + transaction_hash: update.transaction_hash.clone(), + device_id: existing.device_id, + wallet_id: existing.wallet_id, + quote_id: existing.quote_id.clone(), + } + } +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::fiat_providers_countries)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct FiatProviderCountryRow { + pub id: String, + pub provider: FiatProviderNameRow, + pub alpha2: String, + pub is_allowed: bool, +} + +impl FiatProviderCountryRow { + pub fn from_primitive(primitive: FiatProviderCountry) -> Self { + let provider = FiatProviderNameRow::from(primitive.provider); + + Self { + id: format!("{}_{}", provider.0.id(), primitive.alpha2).to_lowercase(), + provider, + alpha2: primitive.alpha2.to_string(), + is_allowed: primitive.is_allowed, + } + } + + pub fn as_primitive(&self) -> FiatProviderCountry { + FiatProviderCountry { + provider: self.provider.0, + alpha2: self.alpha2.clone(), + is_allowed: self.is_allowed, + } + } +} + +#[derive(AsChangeset)] +#[diesel(table_name = crate::schema::fiat_transactions)] +pub struct UpdateFiatTransactionRow { + pub status: FiatTransactionStatusRow, + pub fiat_amount: Option, + pub fiat_currency: Option, + pub transaction_hash: Option, +} + +impl UpdateFiatTransactionRow { + pub fn from_primitive(transaction: &FiatTransactionUpdate) -> Self { + Self { + status: transaction.status.clone().into(), + fiat_amount: transaction.fiat_amount, + fiat_currency: transaction.fiat_currency.clone(), + transaction_hash: transaction.transaction_hash.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{FiatTransactionRow, UpdateFiatTransactionRow}; + use chrono::{DateTime, Utc}; + use primitives::{FiatTransactionStatus, FiatTransactionUpdate}; + + #[test] + fn as_primitive_maps_value_and_timestamps() { + let row = FiatTransactionRow::mock_with_timestamps(DateTime::::from_timestamp(1, 0).unwrap(), DateTime::::from_timestamp(2, 0).unwrap()); + + let transaction = row.as_primitive().unwrap(); + + assert_eq!(transaction.id, "quote_123"); + assert_eq!(transaction.value, "123000000000000000"); + assert_eq!(transaction.created_at, DateTime::::from_timestamp(1, 0).unwrap()); + assert_eq!(transaction.updated_at, DateTime::::from_timestamp(2, 0).unwrap()); + } + + #[test] + fn as_primitive_returns_error_without_value() { + let row = FiatTransactionRow::mock_without_value(); + + assert!(row.as_primitive().is_err()); + } + + #[test] + fn update_row_maps_fiat_currency() { + let update = FiatTransactionUpdate { + transaction_id: "quote_123".to_string(), + provider_transaction_id: Some("tx_123".to_string()), + status: FiatTransactionStatus::Pending, + transaction_hash: Some("0xabc".to_string()), + fiat_amount: Some(100.0), + fiat_currency: Some("EUR".to_string()), + }; + + let row = UpdateFiatTransactionRow::from_primitive(&update); + + assert_eq!(row.fiat_currency, Some("EUR".to_string())); + assert_eq!(row.fiat_amount, Some(100.0)); + } +} diff --git a/core/crates/storage/src/models/min_max.rs b/core/crates/storage/src/models/min_max.rs new file mode 100644 index 0000000000..bd56bea0fb --- /dev/null +++ b/core/crates/storage/src/models/min_max.rs @@ -0,0 +1,19 @@ +use chrono::NaiveDateTime; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct DataPoint { + pub value: T, + pub date: NaiveDateTime, +} + +impl From<(T, NaiveDateTime)> for DataPoint { + fn from((value, date): (T, NaiveDateTime)) -> Self { + Self { value, date } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct MinMax { + pub max: Option>, + pub min: Option>, +} diff --git a/core/crates/storage/src/models/mod.rs b/core/crates/storage/src/models/mod.rs new file mode 100644 index 0000000000..cb6d7347c8 --- /dev/null +++ b/core/crates/storage/src/models/mod.rs @@ -0,0 +1,63 @@ +pub mod asset; +pub mod asset_address; +pub mod asset_usage_rank; +pub mod chain; +pub mod chart; +pub mod config; +pub mod device; +pub mod fiat; +pub(crate) mod min_max; +pub mod nft_asset; +pub mod nft_asset_association; +pub mod nft_collection; +pub mod nft_link; +pub mod nft_report; +pub mod notification; +pub mod parser_state; +pub mod perpetual; +pub mod price; +pub mod price_alert; +pub mod price_provider; +pub mod release; +pub mod reward; +pub mod scan_addresses; +pub mod subscription_address_exclude; +pub mod tag; +pub mod transaction; +pub mod transaction_addresses; +pub mod username; +pub mod wallet; +pub mod webhook; + +pub use self::asset::{AssetLinkRow, AssetRow, NewAssetRow}; +pub use self::asset_address::{AssetAddressRow, AssetAddressRowsExt}; +pub use self::asset_usage_rank::AssetUsageRankRow; +pub use self::chain::ChainIdRow; +pub use self::chart::{ChartRow, DailyChartRow, HourlyChartRow}; +pub use self::config::ConfigRow; +pub use self::device::{DeviceRow, UpdateDeviceRow}; +pub use self::fiat::{FiatAssetRow, FiatAssetRowsExt, FiatProviderCountryRow, FiatProviderRow, FiatRateRow, FiatTransactionRow, NewFiatTransactionRow, UpdateFiatTransactionRow}; +pub use self::nft_asset::{NewNftAssetRow, NftAssetRow}; +pub use self::nft_asset_association::{NewNftAssetAssociationRow, NftAssetAssociationRow}; +pub use self::nft_collection::{NewNftCollectionRow, NftCollectionRow}; +pub use self::nft_link::NftLinkRow; +pub use self::nft_report::NewNftReportRow; +pub use self::notification::{NewNotificationRow, NotificationRow}; +pub use self::parser_state::ParserStateRow; +pub use self::perpetual::{NewPerpetualAssetRow, NewPerpetualRow, PerpetualRow}; +pub use self::price::{NewPriceRow, PriceAssetDataRow, PriceAssetRow, PriceRow}; +pub use self::price_alert::{NewPriceAlertRow, PriceAlertRow}; +pub use self::price_provider::PriceProviderConfigRow; +pub use self::release::ReleaseRow; +pub use self::reward::{ + NewRewardEventRow, NewRewardRedemptionRow, NewRewardReferralRow, NewRewardsRow, NewRiskSignalRow, RedemptionOptionFull, ReferralAttemptRow, RewardEventRow, + RewardRedemptionOptionRow, RewardRedemptionRow, RewardReferralRow, RewardsRow, RiskSignalRow, +}; +pub use self::scan_addresses::{NewScanAddressRow, ScanAddressRow}; +pub use self::subscription_address_exclude::SubscriptionAddressExcludeRow; +pub use self::tag::{AssetTagRow, TagRow}; +pub use self::transaction::{NewTransactionRow, TransactionRow}; +pub use self::transaction_addresses::{AddressChainIdResultRow, NewTransactionAddressesRow, TransactionAddressesRow}; +pub use self::username::{NewUsernameRow, UsernameRow}; +pub use self::wallet::{NewWalletAddressRow, NewWalletRow, NewWalletSubscriptionRow, WalletAddressRow, WalletRow, WalletSubscriptionRow}; +pub use self::webhook::NewWebhookEndpointRow; diff --git a/core/crates/storage/src/models/nft_asset.rs b/core/crates/storage/src/models/nft_asset.rs new file mode 100644 index 0000000000..449a81ad6f --- /dev/null +++ b/core/crates/storage/src/models/nft_asset.rs @@ -0,0 +1,90 @@ +use diesel::prelude::*; +use primitives::{NFTAsset, NFTImages, NFTResource}; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{ChainRow, NftAssetIdRow, NftCollectionIdRow, NftType}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::nft_assets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NftAssetRow { + pub id: i32, + pub identifier: NftAssetIdRow, + pub collection_id: i32, + pub chain: ChainRow, + pub name: String, + pub description: String, + pub token_id: String, + pub token_type: NftType, + pub image_preview_url: Option, + pub image_preview_mime_type: Option, + pub resource_url: Option, + pub resource_mime_type: Option, + pub contract_address: String, + pub attributes: serde_json::Value, +} + +#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::nft_assets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewNftAssetRow { + pub identifier: NftAssetIdRow, + pub collection_id: i32, + pub chain: ChainRow, + pub name: String, + pub description: String, + pub token_id: String, + pub token_type: NftType, + pub image_preview_url: Option, + pub image_preview_mime_type: Option, + pub resource_url: Option, + pub resource_mime_type: Option, + pub contract_address: String, + pub attributes: serde_json::Value, +} + +impl NftAssetRow { + pub fn as_primitive(&self, collection_identifier: NftCollectionIdRow) -> NFTAsset { + NFTAsset { + id: self.identifier.0.clone(), + collection_id: collection_identifier.0, + name: self.name.clone(), + description: Some(self.description.clone()), + chain: self.chain.0, + contract_address: Some(self.contract_address.clone()), + token_id: self.token_id.clone(), + resource: NFTResource { + url: self.resource_url.clone().unwrap_or_default(), + mime_type: self.resource_mime_type.clone().unwrap_or_default(), + }, + images: NFTImages { + preview: NFTResource { + url: self.image_preview_url.clone().unwrap_or_default(), + mime_type: self.image_preview_mime_type.clone().unwrap_or_default(), + }, + }, + token_type: self.token_type.0.clone(), + attributes: serde_json::from_value(self.attributes.clone()).unwrap_or_default(), + } + } +} + +impl NewNftAssetRow { + pub fn from_primitive(primitive: NFTAsset, collection_id: i32) -> Self { + Self { + identifier: primitive.id.clone().into(), + collection_id, + chain: ChainRow::from(primitive.chain), + name: primitive.name.clone(), + description: primitive.description.unwrap_or_default(), + contract_address: primitive.contract_address.unwrap_or_default(), + token_id: primitive.token_id.clone(), + token_type: primitive.token_type.into(), + image_preview_url: Some(primitive.images.preview.url.clone()), + image_preview_mime_type: Some(primitive.images.preview.mime_type.clone()), + resource_url: Some(primitive.resource.url.clone()), + resource_mime_type: Some(primitive.resource.mime_type.clone()), + attributes: serde_json::to_value(primitive.attributes).unwrap_or_default(), + } + } +} diff --git a/core/crates/storage/src/models/nft_asset_association.rs b/core/crates/storage/src/models/nft_asset_association.rs new file mode 100644 index 0000000000..959b49625d --- /dev/null +++ b/core/crates/storage/src/models/nft_asset_association.rs @@ -0,0 +1,17 @@ +use diesel::prelude::*; + +#[derive(Debug, Queryable, Selectable, Clone)] +#[diesel(table_name = crate::schema::nft_assets_associations)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NftAssetAssociationRow { + pub id: i32, + pub address_id: i32, + pub asset_id: i32, +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::nft_assets_associations)] +pub struct NewNftAssetAssociationRow { + pub address_id: i32, + pub asset_id: i32, +} diff --git a/core/crates/storage/src/models/nft_collection.rs b/core/crates/storage/src/models/nft_collection.rs new file mode 100644 index 0000000000..fba2f86fc7 --- /dev/null +++ b/core/crates/storage/src/models/nft_collection.rs @@ -0,0 +1,82 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::{AssetLink, NFTCollection, NFTImages, NFTResource, VerificationStatus}; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{ChainRow, NftCollectionIdRow}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::nft_collections)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NftCollectionRow { + pub id: i32, + pub identifier: NftCollectionIdRow, + pub chain: ChainRow, + pub name: String, + pub description: String, + pub symbol: Option, + pub owner: Option, + pub contract_address: String, + pub image_preview_url: Option, + pub image_preview_mime_type: Option, + pub is_verified: bool, + pub is_enabled: bool, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::nft_collections)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewNftCollectionRow { + pub identifier: NftCollectionIdRow, + pub chain: ChainRow, + pub name: String, + pub description: String, + pub symbol: Option, + pub owner: Option, + pub contract_address: String, + pub image_preview_url: Option, + pub image_preview_mime_type: Option, + pub is_verified: bool, + pub is_enabled: bool, +} + +impl NewNftCollectionRow { + pub fn from_primitive(collection: NFTCollection) -> Self { + NewNftCollectionRow { + identifier: collection.id.clone().into(), + name: collection.name.clone(), + description: collection.description.unwrap_or_default(), + chain: ChainRow::from(collection.chain), + image_preview_url: Some(collection.images.preview.url.clone()), + image_preview_mime_type: Some(collection.images.preview.mime_type.clone()), + is_verified: collection.status.is_verified(), + symbol: collection.symbol, + owner: None, + contract_address: collection.contract_address.clone(), + is_enabled: true, + } + } +} + +impl NftCollectionRow { + pub fn as_primitive(&self, links: Vec) -> NFTCollection { + NFTCollection { + id: self.identifier.0.clone(), + name: self.name.clone(), + symbol: self.symbol.clone(), + description: Some(self.description.clone()), + chain: self.chain.0, + contract_address: self.contract_address.clone(), + images: NFTImages { + preview: NFTResource { + url: self.image_preview_url.clone().unwrap_or_default(), + mime_type: self.image_preview_mime_type.clone().unwrap_or_default(), + }, + }, + is_verified: self.is_verified, + status: VerificationStatus::from_verified(self.is_verified), + links, + } + } +} diff --git a/core/crates/storage/src/models/nft_link.rs b/core/crates/storage/src/models/nft_link.rs new file mode 100644 index 0000000000..24e89066b3 --- /dev/null +++ b/core/crates/storage/src/models/nft_link.rs @@ -0,0 +1,33 @@ +use std::str::FromStr; + +use diesel::prelude::*; +use primitives::AssetLink; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::LinkType; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::nft_collections_links)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NftLinkRow { + pub collection_id: i32, + pub link_type: LinkType, + pub url: String, +} + +impl NftLinkRow { + pub fn as_primitive(&self) -> AssetLink { + AssetLink { + name: self.link_type.as_ref().to_string(), + url: self.url.clone(), + } + } + + pub fn from_primitive(collection_id: i32, link: AssetLink) -> Self { + Self { + collection_id, + link_type: primitives::LinkType::from_str(&link.name).unwrap().into(), + url: link.url.clone(), + } + } +} diff --git a/core/crates/storage/src/models/nft_report.rs b/core/crates/storage/src/models/nft_report.rs new file mode 100644 index 0000000000..e4d62ebe82 --- /dev/null +++ b/core/crates/storage/src/models/nft_report.rs @@ -0,0 +1,10 @@ +use diesel::prelude::*; + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::nft_reports)] +pub struct NewNftReportRow { + pub device_id: i32, + pub collection_id: i32, + pub asset_id: Option, + pub reason: Option, +} diff --git a/core/crates/storage/src/models/notification.rs b/core/crates/storage/src/models/notification.rs new file mode 100644 index 0000000000..f778bd73bf --- /dev/null +++ b/core/crates/storage/src/models/notification.rs @@ -0,0 +1,43 @@ +use crate::sql_types::{AssetId, NotificationType}; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::{Asset, NotificationData}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::notifications)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NotificationRow { + pub id: i32, + pub wallet_id: i32, + pub asset_id: Option, + pub notification_type: NotificationType, + pub is_read: bool, + pub metadata: Option, + pub read_at: Option, + pub created_at: NaiveDateTime, +} + +impl NotificationRow { + pub fn as_primitive(&self, wallet_identifier: String, asset: Option) -> NotificationData { + NotificationData { + id: self.id, + wallet_id: wallet_identifier, + asset, + notification_type: self.notification_type.0, + is_read: self.is_read, + metadata: self.metadata.clone(), + read_at: self.read_at.map(|dt| dt.and_utc()), + created_at: self.created_at.and_utc(), + } + } +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::notifications)] +pub struct NewNotificationRow { + pub wallet_id: i32, + pub asset_id: Option, + pub notification_type: NotificationType, + pub metadata: Option, +} diff --git a/core/crates/storage/src/models/parser_state.rs b/core/crates/storage/src/models/parser_state.rs new file mode 100644 index 0000000000..e858e0d955 --- /dev/null +++ b/core/crates/storage/src/models/parser_state.rs @@ -0,0 +1,22 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::ChainRow; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::parser_state)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ParserStateRow { + pub chain: ChainRow, + pub current_block: i64, + pub latest_block: i64, + pub await_blocks: i32, + pub timeout_between_blocks: i32, + pub timeout_latest_block: i32, + pub parallel_blocks: i32, + pub is_enabled: bool, + pub updated_at: NaiveDateTime, + pub queue_behind_blocks: Option, + pub block_time: i32, +} diff --git a/core/crates/storage/src/models/perpetual.rs b/core/crates/storage/src/models/perpetual.rs new file mode 100644 index 0000000000..b4688ee784 --- /dev/null +++ b/core/crates/storage/src/models/perpetual.rs @@ -0,0 +1,109 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::{ + AssetId as PrimitiveAssetId, + perpetual::{Perpetual as PrimitivePerpetual, PerpetualBasic}, +}; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{AssetId, PerpetualIdRow, PerpetualProviderRow}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::perpetuals)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PerpetualRow { + pub id: PerpetualIdRow, + pub name: String, + pub provider: PerpetualProviderRow, + pub asset_id: AssetId, + pub identifier: String, + pub price: f64, + pub price_percent_change_24h: f64, + pub open_interest: f64, + pub volume_24h: f64, + pub funding: f64, + pub leverage: Vec>, + pub is_isolated_only: bool, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::perpetuals)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewPerpetualRow { + pub id: PerpetualIdRow, + pub name: String, + pub provider: PerpetualProviderRow, + pub asset_id: AssetId, + pub identifier: String, + pub price: f64, + pub price_percent_change_24h: f64, + pub open_interest: f64, + pub volume_24h: f64, + pub funding: f64, + pub leverage: Vec>, + pub is_isolated_only: bool, +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::perpetuals_assets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewPerpetualAssetRow { + pub perpetual_id: String, + pub asset_id: AssetId, +} + +impl NewPerpetualAssetRow { + pub fn new(perpetual_id: String, asset_id: PrimitiveAssetId) -> Self { + Self { + perpetual_id, + asset_id: asset_id.into(), + } + } +} + +impl NewPerpetualRow { + pub fn from_primitive(perpetual: PrimitivePerpetual) -> Self { + Self { + id: perpetual.id.into(), + name: perpetual.name, + provider: perpetual.provider.into(), + asset_id: perpetual.asset_id.into(), + identifier: perpetual.identifier, + price: perpetual.price, + price_percent_change_24h: perpetual.price_percent_change_24h, + open_interest: perpetual.open_interest, + volume_24h: perpetual.volume_24h, + funding: perpetual.funding, + leverage: vec![Some(i32::from(perpetual.max_leverage))], + is_isolated_only: perpetual.is_isolated_only, + } + } +} + +impl PerpetualRow { + pub fn as_primitive(&self) -> PrimitivePerpetual { + PrimitivePerpetual { + id: self.id.0.clone(), + name: self.name.clone(), + provider: self.provider.0.clone(), + asset_id: self.asset_id.0.clone(), + identifier: self.identifier.clone(), + price: self.price, + price_percent_change_24h: self.price_percent_change_24h, + open_interest: self.open_interest, + volume_24h: self.volume_24h, + funding: self.funding, + max_leverage: self.leverage.first().and_then(|v| v.and_then(|i| u8::try_from(i).ok())).unwrap_or(1), + is_isolated_only: self.is_isolated_only, + } + } + + pub fn as_basic(&self) -> PerpetualBasic { + PerpetualBasic { + asset_id: self.asset_id.0.clone(), + perpetual_id: self.id.0.clone(), + provider: self.provider.0.clone(), + } + } +} diff --git a/core/crates/storage/src/models/price.rs b/core/crates/storage/src/models/price.rs new file mode 100644 index 0000000000..df1465c146 --- /dev/null +++ b/core/crates/storage/src/models/price.rs @@ -0,0 +1,408 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::{AssetId as PrimitiveAssetId, AssetMarket, AssetPriceInfo, ChartValuePercentage, Price, PriceData, PriceId as PrimitivePriceId, PriceProvider}; +use serde::{Deserialize, Serialize}; +use std::hash::{Hash, Hasher}; + +use crate::database::prices::PriceUpdate; +use crate::models::min_max::MinMax; + +use crate::sql_types::{AssetId, PriceId, PriceProviderRow}; + +use super::AssetRow; + +#[derive(Debug, Queryable, Selectable, Identifiable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::prices)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PriceRow { + pub id: PriceId, + pub provider: PriceProviderRow, + pub price: f64, + pub price_change_percentage_24h: Option, + pub all_time_high: f64, + pub all_time_high_date: Option, + pub all_time_low: f64, + pub all_time_low_date: Option, + pub market_cap_rank: Option, + pub total_volume: Option, + pub last_updated_at: NaiveDateTime, +} + +#[derive(Debug, Selectable, Identifiable, Serialize, Deserialize, Insertable, Clone)] +#[diesel(table_name = crate::schema::prices)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewPriceRow { + pub id: PriceId, + pub provider: PriceProviderRow, + pub price: f64, + pub price_change_percentage_24h: Option, + pub all_time_high: f64, + pub all_time_high_date: Option, + pub all_time_low: f64, + pub all_time_low_date: Option, + pub total_volume: Option, +} + +#[derive(Default, AsChangeset)] +#[diesel(table_name = crate::schema::prices)] +#[diesel(treat_none_as_null = false)] +pub struct PricesChangeset { + all_time_high: Option, + all_time_high_date: Option>, + all_time_low: Option, + all_time_low_date: Option>, + price_change_percentage_24h: Option, +} + +impl PricesChangeset { + pub fn from_updates(updates: Vec) -> Self { + updates.into_iter().fold(Self::default(), |mut acc, update| { + match update { + PriceUpdate::AllTimeHigh { value, date } => { + acc.all_time_high = Some(value); + acc.all_time_high_date = Some(date); + } + PriceUpdate::AllTimeLow { value, date } => { + acc.all_time_low = Some(value); + acc.all_time_low_date = Some(date); + } + PriceUpdate::PriceChangePercentage24h(value) => { + acc.price_change_percentage_24h = Some(value); + } + } + acc + }) + } +} + +impl NewPriceRow { + pub fn new(provider: PriceProvider, provider_price_id: String) -> Self { + Self::with_market_data(provider, provider_price_id, None, None, None) + } + + pub fn with_market_data( + provider: PriceProvider, + provider_price_id: String, + market: Option<&AssetMarket>, + price: Option, + price_change_percentage_24h: Option, + ) -> Self { + let id = PrimitivePriceId::new(provider, provider_price_id); + Self { + id: id.into(), + provider: provider.into(), + price: price.unwrap_or(0.0), + price_change_percentage_24h: price_change_percentage_24h.filter(|_| provider.supports_price_change_24h()), + all_time_high: market.and_then(|m| m.all_time_high).unwrap_or(0.0), + all_time_high_date: market.and_then(|m| m.all_time_high_date).map(|d| d.naive_utc()), + all_time_low: market.and_then(|m| m.all_time_low).unwrap_or(0.0), + all_time_low_date: market.and_then(|m| m.all_time_low_date).map(|d| d.naive_utc()), + total_volume: market.and_then(|m| m.total_volume), + } + } +} + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::prices_assets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PriceAssetRow { + pub asset_id: AssetId, + pub price_id: PriceId, + pub provider: PriceProviderRow, +} + +impl PriceAssetRow { + pub fn new(asset_id: PrimitiveAssetId, provider: PriceProvider, provider_price_id: &str) -> Self { + PriceAssetRow { + asset_id: asset_id.into(), + price_id: PrimitivePriceId::new(provider, provider_price_id.to_string()).into(), + provider: provider.into(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Queryable)] +pub struct PriceAssetDataRow { + pub asset: AssetRow, + pub price: Option, +} + +impl PartialEq for PriceAssetRow { + fn eq(&self, other: &Self) -> bool { + self.asset_id == other.asset_id && self.price_id == other.price_id + } +} + +impl PartialEq for PriceRow { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} +impl Eq for PriceRow {} + +impl Hash for PriceRow { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + +impl PriceRow { + pub fn with_price(provider: PriceProvider, provider_price_id: String, price: f64) -> Self { + Self::new(provider, provider_price_id, price, None, 0.0, None, 0.0, None, None, None, chrono::Utc::now().naive_utc()) + } + + #[allow(clippy::too_many_arguments)] + pub fn new( + provider: PriceProvider, + provider_price_id: String, + price: f64, + price_change_percentage_24h: Option, + all_time_high: f64, + all_time_high_date: Option, + all_time_low: f64, + all_time_low_date: Option, + market_cap_rank: Option, + total_volume: Option, + last_updated_at: NaiveDateTime, + ) -> Self { + let id = PrimitivePriceId::new(provider, provider_price_id); + PriceRow { + id: id.into(), + provider: provider.into(), + price, + price_change_percentage_24h, + last_updated_at, + all_time_high, + all_time_high_date, + all_time_low, + all_time_low_date, + market_cap_rank, + total_volume, + } + } + + pub(crate) fn merge_extremes_from_charts(&self, extremes: MinMax) -> Vec { + let mut updates = Vec::new(); + if let Some(point) = extremes.max + && point.value >= self.all_time_high + && (point.value, Some(point.date)) != (self.all_time_high, self.all_time_high_date) + { + updates.push(PriceUpdate::AllTimeHigh { + value: point.value, + date: Some(point.date), + }); + } + if let Some(point) = extremes.min + && point.value > 0.0 + && (self.all_time_low == 0.0 || point.value <= self.all_time_low) + && (point.value, Some(point.date)) != (self.all_time_low, self.all_time_low_date) + { + updates.push(PriceUpdate::AllTimeLow { + value: point.value, + date: Some(point.date), + }); + } + updates + } + + pub fn merge_extremes(&self, wire: Option<&PriceRow>) -> Vec { + let mut updates = Vec::new(); + + let mut high = (self.all_time_high, self.all_time_high_date); + if let Some(w) = wire + && (w.all_time_high > high.0 || (w.all_time_high == high.0 && w.all_time_high_date.is_some() && w.all_time_high_date != high.1)) + { + high = (w.all_time_high, w.all_time_high_date); + } + if self.price > high.0 { + high = (self.price, Some(self.last_updated_at)); + } + if high != (self.all_time_high, self.all_time_high_date) { + updates.push(PriceUpdate::AllTimeHigh { value: high.0, date: high.1 }); + } + + let mut low = (self.all_time_low, self.all_time_low_date); + if let Some(w) = wire + && w.all_time_low > 0.0 + && (low.0 == 0.0 || w.all_time_low < low.0 || (w.all_time_low == low.0 && w.all_time_low_date.is_some() && w.all_time_low_date != low.1)) + { + low = (w.all_time_low, w.all_time_low_date); + } + if self.price > 0.0 && (low.0 == 0.0 || self.price < low.0) { + low = (self.price, Some(self.last_updated_at)); + } + if low != (self.all_time_low, self.all_time_low_date) { + updates.push(PriceUpdate::AllTimeLow { value: low.0, date: low.1 }); + } + + updates + } + + pub fn provider_value(&self) -> PriceProvider { + self.provider.0 + } + + pub fn provider_price_id(&self) -> &str { + &self.id.provider_price_id + } + + pub fn as_primitive(&self) -> Price { + Price::new( + self.price, + self.price_change_percentage_24h.unwrap_or(0.0), + self.last_updated_at.and_utc(), + self.provider_value(), + ) + } + + pub fn as_market_primitive(&self, asset: &AssetRow) -> AssetMarket { + let ath_percentage = if self.all_time_high > 0.0 { + Some((self.price - self.all_time_high) / self.all_time_high * 100.0) + } else { + None + }; + let atl_percentage = if self.all_time_low > 0.0 { + Some((self.price - self.all_time_low) / self.all_time_low * 100.0) + } else { + None + }; + let market_cap = asset.circulating_supply.map(|supply| self.price * supply); + let market_cap_fdv = asset.total_supply.or(asset.max_supply).map(|supply| self.price * supply); + AssetMarket { + market_cap, + market_cap_fdv, + market_cap_rank: self.market_cap_rank, + total_volume: self.total_volume, + circulating_supply: asset.circulating_supply, + total_supply: asset.total_supply, + max_supply: asset.max_supply, + all_time_high: Some(self.all_time_high), + all_time_high_date: self.all_time_high_date.map(|d| d.and_utc()), + all_time_high_change_percentage: ath_percentage, + all_time_low: Some(self.all_time_low), + all_time_low_date: self.all_time_low_date.map(|d| d.and_utc()), + all_time_low_change_percentage: atl_percentage, + all_time_high_value: self.all_time_high_date.map(|d| ChartValuePercentage { + date: d.and_utc(), + value: self.all_time_high as f32, + percentage: ath_percentage.unwrap_or_default() as f32, + }), + all_time_low_value: self.all_time_low_date.map(|d| ChartValuePercentage { + date: d.and_utc(), + value: self.all_time_low as f32, + percentage: atl_percentage.unwrap_or_default() as f32, + }), + } + } + + pub fn as_price_asset_info(&self, asset: &AssetRow) -> AssetPriceInfo { + AssetPriceInfo { + asset_id: asset.as_asset_id(), + price: self.as_primitive(), + market: self.as_market_primitive(asset), + } + } + + pub fn as_price_data(&self) -> PriceData { + PriceData { + id: self.id.0.clone(), + provider: self.provider_value(), + provider_price_id: self.provider_price_id().to_string(), + price: self.price, + price_change_percentage_24h: self.price_change_percentage_24h.unwrap_or(0.0), + all_time_high: self.all_time_high, + all_time_high_date: self.all_time_high_date.map(|d| d.and_utc()), + all_time_low: self.all_time_low, + all_time_low_date: self.all_time_low_date.map(|d| d.and_utc()), + market_cap_rank: self.market_cap_rank, + total_volume: self.total_volume, + last_updated_at: self.last_updated_at.and_utc(), + } + } + + pub fn from_price_data(data: PriceData) -> Self { + PriceRow { + id: data.id.into(), + provider: data.provider.into(), + price: data.price, + price_change_percentage_24h: data.provider.supports_price_change_24h().then_some(data.price_change_percentage_24h), + all_time_high: data.all_time_high, + all_time_high_date: data.all_time_high_date.map(|d| d.naive_utc()), + all_time_low: data.all_time_low, + all_time_low_date: data.all_time_low_date.map(|d| d.naive_utc()), + market_cap_rank: data.market_cap_rank, + total_volume: data.total_volume, + last_updated_at: data.last_updated_at.naive_utc(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn ts(secs: i64) -> NaiveDateTime { + chrono::Utc.timestamp_opt(secs, 0).unwrap().naive_utc() + } + + fn row(price: f64, ath: f64, ath_d: Option, atl: f64, atl_d: Option) -> PriceRow { + PriceRow::new(PriceProvider::Pyth, "x".into(), price, None, ath, ath_d, atl, atl_d, None, None, ts(1000)) + } + + #[test] + fn test_total_volume_market_roundtrip() { + let mut price = row(50.0, 100.0, Some(ts(100)), 10.0, Some(ts(200))); + price.total_volume = Some(123.0); + + let price_data = price.as_price_data(); + let price_row = PriceRow::from_price_data(price_data); + + assert_eq!(price_row.total_volume, Some(123.0)); + assert_eq!(price_row.as_market_primitive(&AssetRow::mock()).total_volume, Some(123.0)); + } + + #[test] + fn test_merge_extremes() { + let stored = row(50.0, 100.0, Some(ts(100)), 10.0, Some(ts(200))); + assert!(stored.merge_extremes(None).is_empty()); + + let stored = row(5.0, 100.0, Some(ts(100)), 10.0, Some(ts(200))); + let updates = stored.merge_extremes(None); + assert_eq!(updates.len(), 1); + assert!(matches!(updates[0], PriceUpdate::AllTimeLow { value, .. } if value == 5.0)); + + let stored = row(50.0, 80.0, Some(ts(100)), 10.0, Some(ts(200))); + let wire = row(50.0, 150.0, Some(ts(900)), 0.0, None); + let updates = stored.merge_extremes(Some(&wire)); + assert_eq!(updates.len(), 1); + match &updates[0] { + PriceUpdate::AllTimeHigh { value, date } => { + assert_eq!(*value, 150.0); + assert_eq!(*date, Some(ts(900))); + } + _ => panic!("expected AllTimeHigh"), + } + + let stored = row(50.0, 100.0, Some(ts(100)), 10.0, Some(ts(200))); + let wire = row(50.0, 0.0, None, 0.0, None); + assert!(stored.merge_extremes(Some(&wire)).is_empty()); + + let stored = row(7.5, 100.0, Some(ts(100)), 0.0, None); + let updates = stored.merge_extremes(None); + assert_eq!(updates.len(), 1); + assert!(matches!(updates[0], PriceUpdate::AllTimeLow { value, .. } if value == 7.5)); + + let stored = row(200.0, 100.0, Some(ts(100)), 10.0, Some(ts(200))); + let wire = row(200.0, 150.0, Some(ts(900)), 0.0, None); + let updates = stored.merge_extremes(Some(&wire)); + assert_eq!(updates.len(), 1); + match &updates[0] { + PriceUpdate::AllTimeHigh { value, date } => { + assert_eq!(*value, 200.0); + assert_eq!(*date, Some(ts(1000))); + } + _ => panic!("expected AllTimeHigh"), + } + } +} diff --git a/core/crates/storage/src/models/price_alert.rs b/core/crates/storage/src/models/price_alert.rs new file mode 100644 index 0000000000..e21c9de697 --- /dev/null +++ b/core/crates/storage/src/models/price_alert.rs @@ -0,0 +1,59 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::PriceAlert; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{AssetId, PriceAlertDirectionRow}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::price_alerts)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PriceAlertRow { + pub identifier: String, + pub device_id: i32, + pub asset_id: AssetId, + pub currency: String, + pub price_direction: Option, + pub price: Option, + pub price_percent_change: Option, + pub last_notified_at: Option, +} + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::price_alerts)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewPriceAlertRow { + pub identifier: String, + pub device_id: i32, + pub asset_id: AssetId, + pub currency: String, + pub price_direction: Option, + pub price: Option, + pub price_percent_change: Option, +} + +impl PriceAlertRow { + pub fn as_primitive(&self) -> PriceAlert { + PriceAlert { + asset_id: self.asset_id.0.clone(), + currency: self.currency.clone(), + price_direction: self.price_direction.as_ref().map(|value| value.0.clone()), + price: self.price, + price_percent_change: self.price_percent_change, + last_notified_at: self.last_notified_at.map(|x| x.and_utc()), + identifier: self.identifier.clone(), + } + } + + pub fn new_price_alert(primitive: PriceAlert, device_id: i32) -> NewPriceAlertRow { + NewPriceAlertRow { + identifier: primitive.id(), + device_id, + asset_id: primitive.asset_id.into(), + currency: primitive.currency.clone(), + price_direction: primitive.price_direction.map(Into::into), + price: primitive.price, + price_percent_change: primitive.price_percent_change, + } + } +} diff --git a/core/crates/storage/src/models/price_provider.rs b/core/crates/storage/src/models/price_provider.rs new file mode 100644 index 0000000000..afd2907c65 --- /dev/null +++ b/core/crates/storage/src/models/price_provider.rs @@ -0,0 +1,24 @@ +use diesel::prelude::*; +use primitives::PriceProvider; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::PriceProviderRow; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::prices_providers)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PriceProviderConfigRow { + pub id: PriceProviderRow, + pub enabled: bool, + pub priority: i32, +} + +impl PriceProviderConfigRow { + pub fn new(provider: PriceProvider, enabled: bool) -> Self { + Self { + id: provider.into(), + enabled, + priority: provider.priority(), + } + } +} diff --git a/core/crates/storage/src/models/release.rs b/core/crates/storage/src/models/release.rs new file mode 100644 index 0000000000..16e95b86a0 --- /dev/null +++ b/core/crates/storage/src/models/release.rs @@ -0,0 +1,33 @@ +use crate::sql_types::PlatformStore; +use diesel::prelude::*; +use primitives::Release; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::releases)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ReleaseRow { + pub platform_store: PlatformStore, + pub version: String, + pub upgrade_required: bool, + pub update_enabled: bool, +} + +impl ReleaseRow { + pub fn as_primitive(&self) -> Release { + Release { + store: self.platform_store.0, + version: self.version.clone(), + upgrade_required: self.upgrade_required, + } + } + + pub fn from_primitive(release: Release) -> Self { + Self { + platform_store: release.store.into(), + version: release.version, + upgrade_required: release.upgrade_required, + update_enabled: true, + } + } +} diff --git a/core/crates/storage/src/models/reward.rs b/core/crates/storage/src/models/reward.rs new file mode 100644 index 0000000000..441b4fbc11 --- /dev/null +++ b/core/crates/storage/src/models/reward.rs @@ -0,0 +1,248 @@ +use chrono::{NaiveDateTime, TimeZone, Utc}; +use diesel::prelude::*; +use primitives::rewards::{RewardRedemption, RewardRedemptionOption}; +use primitives::{Asset, RewardEvent}; + +use crate::sql_types::{AssetId, IpUsageType, Platform, PlatformStore, RedemptionStatus, RewardEventType, RewardRedemptionType, RewardStatus}; + +#[derive(Debug, Queryable, Selectable, Clone)] +#[diesel(table_name = crate::schema::rewards)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct RewardsRow { + pub username: String, + pub status: RewardStatus, + pub level: Option, + pub points: i32, + pub referrer_username: Option, + pub referral_count: i32, + pub device_id: i32, + pub comment: Option, + pub disable_reason: Option, + pub verify_after: Option, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::rewards)] +pub struct NewRewardsRow { + pub username: String, + pub status: RewardStatus, + pub level: Option, + pub points: i32, + pub referrer_username: Option, + pub referral_count: i32, + pub device_id: i32, + pub comment: Option, + pub disable_reason: Option, + pub verify_after: Option, +} + +impl NewRewardsRow { + pub fn new(username: String, device_id: i32) -> Self { + Self { + username, + status: RewardStatus::Unverified, + level: None, + points: 0, + referrer_username: None, + referral_count: 0, + device_id, + comment: None, + disable_reason: None, + verify_after: None, + } + } +} + +#[derive(Debug, Queryable, Selectable, Clone)] +#[diesel(table_name = crate::schema::rewards_referrals)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct RewardReferralRow { + pub id: i32, + pub referrer_username: String, + pub referred_username: String, + pub referred_device_id: i32, + pub risk_signal_id: i32, + pub verified_at: Option, + pub updated_at: NaiveDateTime, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::rewards_referrals)] +pub struct NewRewardReferralRow { + pub referrer_username: String, + pub referred_username: String, + pub referred_device_id: i32, + pub risk_signal_id: i32, + pub verified_at: Option, +} + +#[derive(Debug, Queryable, Selectable, Clone)] +#[diesel(table_name = crate::schema::rewards_events)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct RewardEventRow { + pub id: i32, + pub username: String, + pub event_type: RewardEventType, + pub created_at: NaiveDateTime, +} + +impl RewardEventRow { + pub fn as_primitive(&self) -> RewardEvent { + let event = self.event_type.0; + RewardEvent { + username: self.username.clone(), + points: event.points(), + event, + created_at: Utc.from_utc_datetime(&self.created_at), + } + } +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::rewards_events)] +pub struct NewRewardEventRow { + pub username: String, + pub event_type: RewardEventType, +} + +#[derive(Debug, Queryable, Selectable, Clone)] +#[diesel(table_name = crate::schema::rewards_redemptions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct RewardRedemptionRow { + pub id: i32, + pub username: String, + pub option_id: String, + pub device_id: i32, + pub wallet_id: i32, + pub transaction_id: Option, + pub status: RedemptionStatus, + pub error: Option, + pub updated_at: NaiveDateTime, + pub created_at: NaiveDateTime, +} + +impl RewardRedemptionRow { + pub fn as_primitive(&self, option: RewardRedemptionOption) -> RewardRedemption { + RewardRedemption { + id: self.id, + option, + status: *self.status, + transaction_id: self.transaction_id.clone(), + created_at: Utc.from_utc_datetime(&self.created_at), + } + } +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::rewards_redemptions)] +pub struct NewRewardRedemptionRow { + pub username: String, + pub option_id: String, + pub device_id: i32, + pub wallet_id: i32, + pub status: RedemptionStatus, +} + +#[derive(Debug, Queryable, Selectable, Insertable, Clone)] +#[diesel(table_name = crate::schema::rewards_redemption_options)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct RewardRedemptionOptionRow { + pub id: String, + pub redemption_type: RewardRedemptionType, + pub points: i32, + pub asset_id: Option, + pub value: String, + pub remaining: Option, + pub updated_at: chrono::NaiveDateTime, + pub created_at: chrono::NaiveDateTime, +} + +impl RewardRedemptionOptionRow { + pub fn as_primitive(&self, asset: Option) -> RewardRedemptionOption { + RewardRedemptionOption { + id: self.id.clone(), + redemption_type: *self.redemption_type, + points: self.points, + asset, + value: self.value.clone(), + remaining: self.remaining, + } + } +} + +use crate::models::AssetRow; + +#[derive(Debug, Clone)] +pub struct RedemptionOptionFull { + pub option: RewardRedemptionOptionRow, + pub asset: Option, +} + +impl RedemptionOptionFull { + pub fn new(option: RewardRedemptionOptionRow, asset: Option) -> Self { + Self { option, asset } + } + + pub fn as_primitive(&self) -> RewardRedemptionOption { + self.option.as_primitive(self.asset.as_ref().map(|a| a.as_primitive())) + } +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::rewards_referral_attempts)] +pub struct ReferralAttemptRow { + pub referrer_username: String, + pub wallet_id: i32, + pub device_id: i32, + pub risk_signal_id: Option, + pub reason: String, +} + +#[derive(Debug, Queryable, Selectable, Clone)] +#[diesel(table_name = crate::schema::rewards_risk_signals)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct RiskSignalRow { + pub id: i32, + pub fingerprint: String, + pub referrer_username: String, + pub device_id: i32, + pub device_platform: Platform, + pub device_platform_store: PlatformStore, + pub device_os: String, + pub device_model: String, + pub device_locale: String, + pub device_currency: String, + pub ip_address: String, + pub ip_country_code: String, + pub ip_usage_type: IpUsageType, + pub ip_isp: String, + pub ip_abuse_score: i32, + pub risk_score: i32, + pub user_agent: String, + pub metadata: Option, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::rewards_risk_signals)] +pub struct NewRiskSignalRow { + pub fingerprint: String, + pub referrer_username: String, + pub device_id: i32, + pub device_platform: Platform, + pub device_platform_store: PlatformStore, + pub device_os: String, + pub device_model: String, + pub device_locale: String, + pub device_currency: String, + pub ip_address: String, + pub ip_country_code: String, + pub ip_usage_type: IpUsageType, + pub ip_isp: String, + pub ip_abuse_score: i32, + pub risk_score: i32, + pub user_agent: String, + pub metadata: Option, +} diff --git a/core/crates/storage/src/models/scan_addresses.rs b/core/crates/storage/src/models/scan_addresses.rs new file mode 100644 index 0000000000..0cd4c287be --- /dev/null +++ b/core/crates/storage/src/models/scan_addresses.rs @@ -0,0 +1,93 @@ +use diesel::prelude::*; +use primitives::{AddressName, ScanAddress, VerificationStatus}; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{AddressType, ChainRow}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::scan_addresses)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ScanAddressRow { + pub id: i32, + pub chain: ChainRow, + pub address: String, + pub name: Option, + #[diesel(column_name = type_)] + pub type_: AddressType, + pub is_verified: bool, + pub is_fraudulent: bool, + pub is_memo_required: bool, + pub updated_at: chrono::NaiveDateTime, + pub created_at: chrono::NaiveDateTime, +} + +impl ScanAddressRow { + pub fn as_primitive(self) -> Option { + Some(AddressName { + chain: self.chain.0, + address: self.address, + name: self.name?, + address_type: self.type_.0.clone(), + status: if self.is_fraudulent { + VerificationStatus::Suspicious + } else if self.is_verified { + VerificationStatus::Verified + } else { + VerificationStatus::Unverified + }, + }) + } + + pub fn as_scan_address_primitive(self) -> ScanAddress { + ScanAddress { + chain: self.chain.0, + address: self.address, + name: self.name, + address_type: Some(self.type_.0.clone()), + is_malicious: Some(self.is_fraudulent), + is_memo_required: Some(self.is_memo_required), + is_verified: Some(self.is_verified), + } + } +} + +#[derive(Debug, Insertable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::scan_addresses)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewScanAddressRow { + pub chain: ChainRow, + pub address: String, + pub name: Option, + #[diesel(column_name = type_)] + pub type_: AddressType, + pub is_verified: bool, + pub is_fraudulent: bool, + pub is_memo_required: bool, +} + +impl NewScanAddressRow { + pub fn from_primitive(scan_address: ScanAddress) -> Self { + Self { + chain: ChainRow::from(scan_address.chain), + address: scan_address.address, + name: scan_address.name, + type_: scan_address.address_type.unwrap_or(primitives::AddressType::Address).into(), + is_verified: scan_address.is_verified.unwrap_or(false), + is_fraudulent: scan_address.is_malicious.unwrap_or(false), + is_memo_required: scan_address.is_memo_required.unwrap_or(false), + } + } +} + +#[cfg(test)] +mod tests { + use super::ScanAddressRow; + use primitives::Chain; + + #[test] + fn as_primitive_returns_none_without_name() { + let row = ScanAddressRow::mock(1, Chain::Ethereum, "0x0000000000000000000000000000000000000001", None); + + assert_eq!(row.as_primitive(), None); + } +} diff --git a/core/crates/storage/src/models/subscription_address_exclude.rs b/core/crates/storage/src/models/subscription_address_exclude.rs new file mode 100644 index 0000000000..2e41abec8b --- /dev/null +++ b/core/crates/storage/src/models/subscription_address_exclude.rs @@ -0,0 +1,12 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::ChainRow; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::subscriptions_addresses_exclude)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct SubscriptionAddressExcludeRow { + pub address: String, + pub chain: ChainRow, +} diff --git a/core/crates/storage/src/models/tag.rs b/core/crates/storage/src/models/tag.rs new file mode 100644 index 0000000000..b0b7aae709 --- /dev/null +++ b/core/crates/storage/src/models/tag.rs @@ -0,0 +1,29 @@ +use diesel::prelude::*; +use primitives::AssetTag; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::AssetId; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, Clone)] +#[diesel(table_name = crate::schema::tags)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct TagRow { + pub id: String, +} + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Insertable, Clone)] +#[diesel(table_name = crate::schema::assets_tags)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AssetTagRow { + pub asset_id: AssetId, + pub tag_id: String, + pub order: Option, +} + +impl TagRow { + pub fn from_primitive(primitive: AssetTag) -> Self { + Self { + id: primitive.as_ref().to_lowercase(), + } + } +} diff --git a/core/crates/storage/src/models/transaction.rs b/core/crates/storage/src/models/transaction.rs new file mode 100644 index 0000000000..91335ccec5 --- /dev/null +++ b/core/crates/storage/src/models/transaction.rs @@ -0,0 +1,146 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use primitives::{Chain, Transaction, TransactionDirection, TransactionId, TransactionUtxoInput}; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{AssetId, ChainRow, TransactionState, TransactionType}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::transactions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct TransactionRow { + pub id: i64, + pub chain: ChainRow, + pub hash: String, + pub from_address: Option, + pub to_address: Option, + pub memo: Option, + pub state: TransactionState, + pub kind: TransactionType, + pub value: Option, + pub asset_id: AssetId, + pub fee: Option, + pub utxo_inputs: Option, + pub utxo_outputs: Option, + pub fee_asset_id: AssetId, + pub metadata: Option, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Serialize, Deserialize, Insertable, AsChangeset, Clone)] +#[diesel(table_name = crate::schema::transactions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewTransactionRow { + pub chain: ChainRow, + pub hash: String, + pub from_address: Option, + pub to_address: Option, + pub memo: Option, + pub state: TransactionState, + pub kind: TransactionType, + pub value: Option, + pub asset_id: AssetId, + pub fee: Option, + pub utxo_inputs: Option, + pub utxo_outputs: Option, + pub fee_asset_id: AssetId, + pub metadata: Option, + pub created_at: NaiveDateTime, +} + +impl TransactionRow { + pub fn chain(&self) -> Chain { + self.chain.0 + } + + pub fn get_addresses(&self) -> Vec { + vec![self.from_address.clone(), self.to_address.clone()].into_iter().flatten().collect() + } + + pub fn as_primitive(&self, addresses: Vec) -> Transaction { + let chain = self.chain(); + let transaction_id = TransactionId::new(chain, self.hash.clone()); + let asset_id = self.asset_id.0.clone(); + let from = self.from_address.clone().unwrap_or_default(); + let to_address = self.to_address.clone().unwrap_or_default(); + let inputs: Option> = serde_json::from_value(self.utxo_inputs.clone().into()).ok(); + let outputs: Option> = serde_json::from_value(self.utxo_outputs.clone().into()).ok(); + + let direction = if addresses.contains(&from) { + TransactionDirection::Outgoing + } else if addresses.contains(&to_address) { + TransactionDirection::Incoming + } else { + TransactionDirection::SelfTransfer + }; + let transaction_type = self.kind.0.clone(); + + Transaction { + id: transaction_id.clone(), + hash: self.hash.clone(), + asset_id, + from: from.clone(), + to: to_address.clone(), + contract: None, + transaction_type, + state: self.state.0, + block_number: None, + sequence: None, + fee: self.fee.clone().unwrap(), + fee_asset_id: self.fee_asset_id.0.clone(), + value: self.value.clone().unwrap_or("0".to_string()), + memo: self.memo.clone(), + direction, + utxo_inputs: inputs.unwrap_or_default().into(), + utxo_outputs: outputs.unwrap_or_default().into(), + metadata: self.metadata.clone(), + data: None, + created_at: self.created_at.and_utc(), + } + } +} + +impl NewTransactionRow { + pub fn from_primitive(transaction: Transaction) -> Self { + let utxo_inputs = if transaction.utxo_inputs.clone().unwrap_or_default().is_empty() { + None + } else { + serde_json::to_value(transaction.utxo_inputs.clone()).ok() + }; + let utxo_outputs = if transaction.utxo_outputs.clone().unwrap_or_default().is_empty() { + None + } else { + serde_json::to_value(transaction.clone().utxo_outputs.clone()).ok() + }; + let metadata = if transaction.metadata.is_none() { + None + } else { + serde_json::to_value(transaction.metadata.clone()).ok() + }; + let from_address = if transaction.from.is_empty() { None } else { Some(transaction.from) }; + let to_address = if transaction.to.is_empty() { None } else { Some(transaction.to) }; + let value = if transaction.value.is_empty() || transaction.value == "0" { + None + } else { + Some(transaction.value) + }; + + Self { + chain: transaction.asset_id.chain.into(), + hash: transaction.hash, + memo: transaction.memo, + asset_id: transaction.asset_id.into(), + value, + fee: transaction.fee.into(), + fee_asset_id: transaction.fee_asset_id.into(), + from_address, + to_address, + kind: transaction.transaction_type.into(), + state: transaction.state.into(), + utxo_inputs, + utxo_outputs, + metadata, + created_at: transaction.created_at.naive_utc(), + } + } +} diff --git a/core/crates/storage/src/models/transaction_addresses.rs b/core/crates/storage/src/models/transaction_addresses.rs new file mode 100644 index 0000000000..bbd34a9ecc --- /dev/null +++ b/core/crates/storage/src/models/transaction_addresses.rs @@ -0,0 +1,44 @@ +use diesel::prelude::*; +use primitives::Transaction; +use serde::{Deserialize, Serialize}; + +use crate::sql_types::{AssetId, ChainRow}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::transactions_addresses)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct TransactionAddressesRow { + pub id: i32, + pub transaction_id: i64, + pub asset_id: AssetId, + pub address: String, +} + +#[derive(Debug, Serialize, Deserialize, Insertable, AsChangeset, Clone, PartialEq, Eq, Hash)] +#[diesel(table_name = crate::schema::transactions_addresses)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewTransactionAddressesRow { + pub transaction_id: i64, + pub asset_id: AssetId, + pub address: String, +} + +impl NewTransactionAddressesRow { + pub fn from_transaction(transaction_id: i64, transaction: &Transaction) -> Vec { + transaction + .assets_addresses() + .into_iter() + .map(|x| Self { + transaction_id, + asset_id: x.asset_id.into(), + address: x.address, + }) + .collect() + } +} + +#[derive(Queryable, Debug, Clone)] +pub struct AddressChainIdResultRow { + pub address: String, + pub chain_id: ChainRow, +} diff --git a/core/crates/storage/src/models/username.rs b/core/crates/storage/src/models/username.rs new file mode 100644 index 0000000000..8ba517b0f2 --- /dev/null +++ b/core/crates/storage/src/models/username.rs @@ -0,0 +1,32 @@ +use crate::sql_types::UsernameStatus; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::usernames)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct UsernameRow { + pub username: String, + pub wallet_id: i32, + pub status: UsernameStatus, +} + +impl UsernameRow { + pub fn has_custom_username(&self) -> bool { + let len = self.username.len(); + (4..=16).contains(&len) && self.username.chars().all(|c| c.is_ascii_alphanumeric()) + } + + pub fn is_verified(&self) -> bool { + self.status.is_verified() + } +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::usernames)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewUsernameRow { + pub username: String, + pub wallet_id: i32, + pub status: UsernameStatus, +} diff --git a/core/crates/storage/src/models/wallet.rs b/core/crates/storage/src/models/wallet.rs new file mode 100644 index 0000000000..41760ad6fd --- /dev/null +++ b/core/crates/storage/src/models/wallet.rs @@ -0,0 +1,56 @@ +use crate::sql_types::{ChainRow, WalletIdRow, WalletSource, WalletType}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::wallets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WalletRow { + pub id: i32, + #[diesel(column_name = identifier)] + pub wallet_id: WalletIdRow, + pub wallet_type: WalletType, + pub source: WalletSource, +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::wallets)] +pub struct NewWalletRow { + pub identifier: String, + pub wallet_type: WalletType, + pub source: WalletSource, +} + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::wallets_addresses)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WalletAddressRow { + pub id: i32, + pub address: String, +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::wallets_addresses)] +pub struct NewWalletAddressRow { + pub address: String, +} + +#[derive(Debug, Queryable, Selectable, Serialize, Deserialize, Clone)] +#[diesel(table_name = crate::schema::wallets_subscriptions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WalletSubscriptionRow { + pub id: i32, + pub wallet_id: i32, + pub device_id: i32, + pub chain: ChainRow, + pub address_id: i32, +} + +#[derive(Debug, Insertable, Clone)] +#[diesel(table_name = crate::schema::wallets_subscriptions)] +pub struct NewWalletSubscriptionRow { + pub wallet_id: i32, + pub device_id: i32, + pub chain: ChainRow, + pub address_id: i32, +} diff --git a/core/crates/storage/src/models/webhook.rs b/core/crates/storage/src/models/webhook.rs new file mode 100644 index 0000000000..3803cf885e --- /dev/null +++ b/core/crates/storage/src/models/webhook.rs @@ -0,0 +1,21 @@ +use diesel::prelude::*; +use primitives::WebhookKind; + +use crate::sql_types::WebhookKind as WebhookKindRow; + +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = crate::schema::webhook_endpoints)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NewWebhookEndpointRow { + pub kind: WebhookKindRow, + pub sender: String, +} + +impl NewWebhookEndpointRow { + pub fn new(kind: WebhookKind, sender: impl Into) -> Self { + Self { + kind: kind.into(), + sender: sender.into(), + } + } +} diff --git a/core/crates/storage/src/repositories/assets_addresses_repository.rs b/core/crates/storage/src/repositories/assets_addresses_repository.rs new file mode 100644 index 0000000000..c4f06f635a --- /dev/null +++ b/core/crates/storage/src/repositories/assets_addresses_repository.rs @@ -0,0 +1,41 @@ +use crate::database::assets_addresses::AssetsAddressesStore; +use crate::models::{AssetAddressRow, AssetAddressRowsExt}; +use crate::{DatabaseClient, DatabaseError}; +use chrono::NaiveDateTime; +use primitives::{AssetAddress as PrimitiveAssetAddress, AssetId, ChainAddress}; + +pub trait AssetsAddressesRepository { + fn add_assets_addresses(&mut self, values: Vec) -> Result; + fn get_assets_by_addresses(&mut self, values: Vec, from_datetime: Option) -> Result, DatabaseError>; + fn get_asset_addresses(&mut self, value: ChainAddress) -> Result, DatabaseError>; + fn get_asset_address(&mut self, value: ChainAddress, asset_id: AssetId) -> Result, DatabaseError>; + fn delete_assets_addresses(&mut self, values: Vec) -> Result; +} + +impl AssetsAddressesRepository for DatabaseClient { + fn add_assets_addresses(&mut self, values: Vec) -> Result { + Ok(AssetsAddressesStore::add_assets_addresses( + self, + values.into_iter().map(AssetAddressRow::from_primitive).collect(), + )?) + } + + fn get_assets_by_addresses(&mut self, values: Vec, from_datetime: Option) -> Result, DatabaseError> { + Ok(AssetsAddressesStore::get_assets_by_addresses(self, values, from_datetime)?.asset_ids()) + } + + fn get_asset_addresses(&mut self, value: ChainAddress) -> Result, DatabaseError> { + Ok(AssetsAddressesStore::get_asset_addresses(self, value)?.into_iter().map(|row| row.as_primitive()).collect()) + } + + fn get_asset_address(&mut self, value: ChainAddress, asset_id: AssetId) -> Result, DatabaseError> { + Ok(AssetsAddressesStore::get_asset_address(self, value, asset_id)?.map(|row| row.as_primitive())) + } + + fn delete_assets_addresses(&mut self, values: Vec) -> Result { + Ok(AssetsAddressesStore::delete_assets_addresses( + self, + values.into_iter().map(AssetAddressRow::from_primitive).collect(), + )?) + } +} diff --git a/core/crates/storage/src/repositories/assets_links_repository.rs b/core/crates/storage/src/repositories/assets_links_repository.rs new file mode 100644 index 0000000000..51da6d544f --- /dev/null +++ b/core/crates/storage/src/repositories/assets_links_repository.rs @@ -0,0 +1,26 @@ +use crate::DatabaseError; + +use crate::database::assets_links::AssetsLinksStore; +use crate::{DatabaseClient, models::AssetLinkRow}; +use primitives::{AssetId, AssetLink as PrimitiveAssetLink}; + +pub trait AssetsLinksRepository { + fn add_assets_links(&mut self, asset_id: &AssetId, values: Vec) -> Result; + fn get_asset_links(&mut self, asset_id: &AssetId) -> Result, DatabaseError>; +} + +impl AssetsLinksRepository for DatabaseClient { + fn add_assets_links(&mut self, asset_id: &AssetId, values: Vec) -> Result { + Ok(AssetsLinksStore::add_assets_links( + self, + values.into_iter().map(|x| AssetLinkRow::from_primitive(asset_id, x)).collect(), + )?) + } + + fn get_asset_links(&mut self, asset_id: &AssetId) -> Result, DatabaseError> { + Ok(AssetsLinksStore::get_asset_links(self, &asset_id.to_string())? + .into_iter() + .map(|x| x.as_primitive()) + .collect()) + } +} diff --git a/core/crates/storage/src/repositories/assets_repository.rs b/core/crates/storage/src/repositories/assets_repository.rs new file mode 100644 index 0000000000..ebb3f5b3eb --- /dev/null +++ b/core/crates/storage/src/repositories/assets_repository.rs @@ -0,0 +1,112 @@ +use crate::database::assets::AssetsStore; +use crate::database::assets::{AssetFilter, AssetUpdate}; +use crate::models::{AssetRow, NewAssetRow, PriceRow}; +use crate::repositories::prices_repository::PricesRepository; +use crate::{DatabaseClient, DatabaseError, DieselResultExt}; +use primitives::{Asset, AssetBasic, AssetFull, AssetId, AssetIdVecExt, AssetPriceMetadata}; +use std::time::Duration; + +pub trait AssetsRepository { + fn get_assets_all(&mut self) -> Result, DatabaseError>; + fn add_assets(&mut self, values: Vec) -> Result; + fn update_assets(&mut self, asset_ids: Vec, updates: Vec) -> Result; + fn upsert_assets(&mut self, values: Vec) -> Result; + fn get_assets_by_filter(&mut self, filters: Vec) -> Result, DatabaseError>; + fn get_asset(&mut self, asset_id: &AssetId) -> Result; + fn get_asset_full(&mut self, asset_id: &AssetId, max_age: Duration) -> Result; + fn get_assets(&mut self, asset_ids: Vec) -> Result, DatabaseError>; + fn get_assets_rows(&mut self, asset_ids: Vec) -> Result, DatabaseError>; + fn get_assets_basic(&mut self, asset_ids: Vec) -> Result, DatabaseError>; + fn get_assets_with_prices(&mut self, asset_ids: Vec, max_age: Duration) -> Result, DatabaseError>; + fn get_swap_assets(&mut self) -> Result, DatabaseError>; + fn get_swap_assets_version(&mut self) -> Result; +} + +impl AssetsRepository for DatabaseClient { + fn get_assets_all(&mut self) -> Result, DatabaseError> { + Ok(AssetsStore::get_assets_all(self)?.into_iter().map(|x| x.as_basic_primitive()).collect()) + } + + fn add_assets(&mut self, values: Vec) -> Result { + Ok(AssetsStore::add_assets( + self, + values.into_iter().map(|x| NewAssetRow::from_primitive(x.asset, x.score, x.properties)).collect(), + )?) + } + + fn update_assets(&mut self, asset_ids: Vec, updates: Vec) -> Result { + Ok(AssetsStore::update_assets(self, asset_ids.ids(), updates)?) + } + + fn upsert_assets(&mut self, values: Vec) -> Result { + Ok(AssetsStore::upsert_assets(self, values.into_iter().map(NewAssetRow::from_primitive_default).collect())?) + } + + fn get_assets_by_filter(&mut self, filters: Vec) -> Result, DatabaseError> { + Ok(AssetsStore::get_assets_by_filter(self, filters)?.into_iter().map(|x| x.as_basic_primitive()).collect()) + } + + fn get_asset(&mut self, asset_id: &AssetId) -> Result { + let id = asset_id.to_string(); + Ok(AssetsStore::get_asset(self, &id).or_not_found(id.clone())?.as_primitive()) + } + + fn get_asset_full(&mut self, asset_id: &AssetId, max_age: Duration) -> Result { + use crate::database::assets_links::AssetsLinksStore; + use crate::database::tag::TagStore; + + let id = asset_id.to_string(); + let asset = AssetsStore::get_asset(self, &id).or_not_found(id.clone())?; + let price_row: Option = PricesRepository::get_assets_with_prices(self, vec![asset_id.clone()], max_age)? + .into_iter() + .next() + .and_then(|d| d.price); + let market = price_row.as_ref().map(|x| x.as_market_primitive(&asset)); + let price = price_row.as_ref().map(|x| x.as_primitive()); + let links = AssetsLinksStore::get_asset_links(self, &id)?.into_iter().map(|x| x.as_primitive()).collect(); + let tags = TagStore::get_assets_tags_for_asset(self, &id)?.into_iter().map(|x| x.tag_id).collect(); + let perpetuals = self.perpetuals().get_perpetuals_for_asset(asset_id)?; + let perpetuals = perpetuals.into_iter().map(|x| x.as_basic()).collect(); + + Ok(AssetFull { + price, + market, + asset: asset.as_primitive(), + properties: asset.as_property_primitive(), + score: asset.as_score_primitive(), + links, + tags, + perpetuals, + }) + } + + fn get_assets(&mut self, asset_ids: Vec) -> Result, DatabaseError> { + Ok(AssetsStore::get_assets(self, asset_ids.ids())?.into_iter().map(|x| x.as_primitive()).collect()) + } + + fn get_assets_rows(&mut self, asset_ids: Vec) -> Result, DatabaseError> { + Ok(AssetsStore::get_assets(self, asset_ids.ids())?) + } + + fn get_assets_basic(&mut self, asset_ids: Vec) -> Result, DatabaseError> { + Ok(AssetsStore::get_assets(self, asset_ids.ids())?.into_iter().map(|x| x.as_basic_primitive()).collect()) + } + + fn get_assets_with_prices(&mut self, asset_ids: Vec, max_age: Duration) -> Result, DatabaseError> { + Ok(PricesRepository::get_assets_with_prices(self, asset_ids, max_age)? + .into_iter() + .map(|row| AssetPriceMetadata { + asset: row.asset.as_basic_primitive(), + price: row.price.map(|p| p.as_primitive()), + }) + .collect()) + } + + fn get_swap_assets(&mut self) -> Result, DatabaseError> { + Ok(AssetsStore::get_swap_assets(self)?) + } + + fn get_swap_assets_version(&mut self) -> Result { + Ok(AssetsStore::get_swap_assets_version(self)?) + } +} diff --git a/core/crates/storage/src/repositories/assets_usage_ranks_repository.rs b/core/crates/storage/src/repositories/assets_usage_ranks_repository.rs new file mode 100644 index 0000000000..adafacd840 --- /dev/null +++ b/core/crates/storage/src/repositories/assets_usage_ranks_repository.rs @@ -0,0 +1,25 @@ +use crate::DatabaseClient; +use crate::DatabaseError; +use crate::database::assets_usage_ranks::AssetsUsageRanksStore; +use crate::models::AssetUsageRankRow; +use chrono::NaiveDateTime; + +pub trait AssetsUsageRanksRepository { + fn upsert_usage_ranks(&mut self, values: Vec) -> Result; + fn delete_usage_ranks_before(&mut self, before: NaiveDateTime) -> Result; + fn get_all_usage_ranks(&mut self) -> Result, DatabaseError>; +} + +impl AssetsUsageRanksRepository for DatabaseClient { + fn upsert_usage_ranks(&mut self, values: Vec) -> Result { + Ok(AssetsUsageRanksStore::upsert_usage_ranks(self, values)?) + } + + fn delete_usage_ranks_before(&mut self, before: NaiveDateTime) -> Result { + Ok(AssetsUsageRanksStore::delete_usage_ranks_before(self, before)?) + } + + fn get_all_usage_ranks(&mut self) -> Result, DatabaseError> { + Ok(AssetsUsageRanksStore::get_all_usage_ranks(self)?) + } +} diff --git a/core/crates/storage/src/repositories/chains_repository.rs b/core/crates/storage/src/repositories/chains_repository.rs new file mode 100644 index 0000000000..13af9082a7 --- /dev/null +++ b/core/crates/storage/src/repositories/chains_repository.rs @@ -0,0 +1,13 @@ +use crate::database::chains::ChainStore; +use crate::{DatabaseClient, DatabaseError}; +use primitives::Chain; + +pub trait ChainsRepository { + fn add_chains(&mut self, chains: Vec) -> Result; +} + +impl ChainsRepository for DatabaseClient { + fn add_chains(&mut self, chains: Vec) -> Result { + Ok(ChainStore::add_chains(self, chains)?) + } +} diff --git a/core/crates/storage/src/repositories/charts_repository.rs b/core/crates/storage/src/repositories/charts_repository.rs new file mode 100644 index 0000000000..fcd5ecf6a0 --- /dev/null +++ b/core/crates/storage/src/repositories/charts_repository.rs @@ -0,0 +1,36 @@ +use crate::DatabaseError; + +use crate::DatabaseClient; +use crate::database::charts::{ChartFilter, ChartResult, ChartsStore}; +use chrono::NaiveDateTime; +use primitives::{ChartPeriod, ChartTimeframe}; + +pub trait ChartsRepository { + fn add_charts(&mut self, timeframe: ChartTimeframe, values: Vec) -> Result; + fn get_charts(&mut self, price_id: &str, period: &ChartPeriod) -> Result, DatabaseError>; + fn aggregate_charts(&mut self, timeframe: ChartTimeframe) -> Result; + fn delete_charts(&mut self, timeframe: ChartTimeframe, before: NaiveDateTime) -> Result; + fn get_charts_by_filter(&mut self, filters: Vec) -> Result, DatabaseError>; +} + +impl ChartsRepository for DatabaseClient { + fn add_charts(&mut self, timeframe: ChartTimeframe, values: Vec) -> Result { + Ok(ChartsStore::add_charts(self, timeframe, values)?) + } + + fn get_charts(&mut self, price_id: &str, period: &ChartPeriod) -> Result, DatabaseError> { + Ok(ChartsStore::get_charts(self, price_id, period)?) + } + + fn aggregate_charts(&mut self, timeframe: ChartTimeframe) -> Result { + Ok(ChartsStore::aggregate_charts(self, timeframe)?) + } + + fn delete_charts(&mut self, timeframe: ChartTimeframe, before: NaiveDateTime) -> Result { + Ok(ChartsStore::delete_charts(self, timeframe, before)?) + } + + fn get_charts_by_filter(&mut self, filters: Vec) -> Result, DatabaseError> { + Ok(ChartsStore::get_charts_by_filter(self, filters)?) + } +} diff --git a/core/crates/storage/src/repositories/config_repository.rs b/core/crates/storage/src/repositories/config_repository.rs new file mode 100644 index 0000000000..c0b2d992ed --- /dev/null +++ b/core/crates/storage/src/repositories/config_repository.rs @@ -0,0 +1,65 @@ +use std::time::Duration; + +use primitives::ConfigKey; + +use crate::database::config::ConfigStore; +use crate::models::ConfigRow; +use crate::{DatabaseClient, DatabaseError, DieselResultExt}; + +pub trait ConfigRepository { + fn get_config(&mut self, key: ConfigKey) -> Result; + fn get_config_i64(&mut self, key: ConfigKey) -> Result; + fn get_config_f64(&mut self, key: ConfigKey) -> Result; + fn get_config_bool(&mut self, key: ConfigKey) -> Result; + fn get_config_duration(&mut self, key: ConfigKey) -> Result; + fn get_config_vec_string(&mut self, key: ConfigKey) -> Result, DatabaseError>; + fn get_config_keys(&mut self) -> Result, DatabaseError>; + fn add_config(&mut self, configs: Vec) -> Result; + fn set_config(&mut self, key: ConfigKey, value: &str) -> Result; + fn delete_keys(&mut self, keys: Vec) -> Result; +} + +impl ConfigRepository for DatabaseClient { + fn get_config(&mut self, key: ConfigKey) -> Result { + let key = key.as_ref().to_string(); + let result = ConfigStore::get_config_key(self, &key).or_not_found(key)?; + Ok(result.value) + } + + fn get_config_i64(&mut self, key: ConfigKey) -> Result { + Ok(self.get_config(key)?.parse()?) + } + + fn get_config_f64(&mut self, key: ConfigKey) -> Result { + Ok(self.get_config(key)?.parse()?) + } + + fn get_config_bool(&mut self, key: ConfigKey) -> Result { + Ok(self.get_config(key)?.parse()?) + } + + fn get_config_duration(&mut self, key: ConfigKey) -> Result { + let value = self.get_config(key)?; + primitives::parse_duration(&value).ok_or_else(|| DatabaseError::Error(format!("Failed to parse duration: {}", value))) + } + + fn get_config_vec_string(&mut self, key: ConfigKey) -> Result, DatabaseError> { + Ok(serde_json::from_str(&self.get_config(key)?)?) + } + + fn add_config(&mut self, configs: Vec) -> Result { + Ok(ConfigStore::add_config(self, configs)?) + } + + fn set_config(&mut self, key: ConfigKey, value: &str) -> Result { + Ok(ConfigStore::set_config(self, key.as_ref(), value)?) + } + + fn get_config_keys(&mut self) -> Result, DatabaseError> { + Ok(ConfigStore::get_config_keys(self)?) + } + + fn delete_keys(&mut self, keys: Vec) -> Result { + Ok(ConfigStore::delete_keys(self, keys)?) + } +} diff --git a/core/crates/storage/src/repositories/devices_repository.rs b/core/crates/storage/src/repositories/devices_repository.rs new file mode 100644 index 0000000000..2bc97e5fde --- /dev/null +++ b/core/crates/storage/src/repositories/devices_repository.rs @@ -0,0 +1,75 @@ +use crate::database::devices::{DeviceFieldUpdate, DeviceFilter, DevicesStore}; +use crate::database::wallets::WalletsStore; +use crate::{DatabaseClient, DatabaseError, DieselResultExt}; +use primitives::Device; + +pub trait DevicesRepository { + fn add_device(&mut self, device: crate::models::UpdateDeviceRow) -> Result; + fn get_device_by_id(&mut self, id: i32) -> Result; + fn get_device(&mut self, device_id: &str) -> Result; + fn get_device_exist(&mut self, device_id: &str) -> Result; + fn get_device_row_id(&mut self, device_id: &str) -> Result; + fn update_device(&mut self, device: crate::models::UpdateDeviceRow) -> Result; + fn update_device_fields(&mut self, device_ids: Vec, updates: Vec) -> Result; + fn delete_devices_subscriptions_after_days(&mut self, days: i64) -> Result; + fn devices_inactive_days(&mut self, min_days: i64, max_days: i64, push_enabled: Option) -> Result, DatabaseError>; +} + +impl DevicesRepository for DatabaseClient { + fn add_device(&mut self, device: crate::models::UpdateDeviceRow) -> Result { + Ok(DevicesStore::add_device(self, device)?.as_primitive()) + } + + fn get_device_by_id(&mut self, id: i32) -> Result { + Ok(DevicesStore::get_device_by_id(self, id).or_not_found_internal(id.to_string())?.as_primitive()) + } + + fn get_device(&mut self, device_id: &str) -> Result { + Ok(DevicesStore::get_device(self, device_id).or_not_found(device_id.to_string())?.as_primitive()) + } + + fn get_device_exist(&mut self, device_id: &str) -> Result { + match DevicesStore::get_device(self, device_id) { + Ok(_) => Ok(true), + Err(diesel::result::Error::NotFound) => Ok(false), + Err(error) => Err(error.into()), + } + } + + fn get_device_row_id(&mut self, device_id: &str) -> Result { + Ok(DevicesStore::get_device(self, device_id).or_not_found(device_id.to_string())?.id) + } + + fn update_device(&mut self, device: crate::models::UpdateDeviceRow) -> Result { + let device_id = device.device_id.clone(); + Ok(DevicesStore::update_device(self, device).or_not_found(device_id)?.as_primitive()) + } + + fn update_device_fields(&mut self, device_ids: Vec, updates: Vec) -> Result { + Ok(DevicesStore::update_device_fields(self, device_ids, updates)?) + } + + fn delete_devices_subscriptions_after_days(&mut self, days: i64) -> Result { + let device_ids = DevicesStore::get_stale_device_ids(self, days)?; + Ok(WalletsStore::delete_subscriptions_for_device_ids(self, device_ids)?) + } + + fn devices_inactive_days(&mut self, min_days: i64, max_days: i64, push_enabled: Option) -> Result, DatabaseError> { + use chrono::{Duration, Utc}; + + let min_days_cutoff = Utc::now() - Duration::days(min_days); + let max_days_cutoff = Utc::now() - Duration::days(max_days); + + let mut filters = vec![DeviceFilter::CreatedBetween { + start: max_days_cutoff.naive_utc(), + end: min_days_cutoff.naive_utc(), + }]; + + if let Some(enabled) = push_enabled { + filters.push(DeviceFilter::IsPushEnabled(enabled)); + } + + let result = DevicesStore::get_devices_by_filter(self, filters)?; + Ok(result.into_iter().map(|x| x.as_primitive()).collect()) + } +} diff --git a/core/crates/storage/src/repositories/fiat_repository.rs b/core/crates/storage/src/repositories/fiat_repository.rs new file mode 100644 index 0000000000..eeeacdc925 --- /dev/null +++ b/core/crates/storage/src/repositories/fiat_repository.rs @@ -0,0 +1,80 @@ +use crate::{DatabaseError, DieselResultExt}; +use chrono::NaiveDateTime; +use primitives::{AssetId, FiatProviderCountry, FiatProviderName, FiatRate, FiatTransaction}; + +use crate::DatabaseClient; +use crate::database::fiat::{FiatAssetFilter, FiatStore}; + +pub trait FiatRepository { + fn add_fiat_assets(&mut self, values: Vec) -> Result; + fn add_fiat_providers(&mut self, values: Vec) -> Result; + fn add_fiat_providers_countries(&mut self, values: Vec) -> Result; + fn get_fiat_providers_countries(&mut self) -> Result, DatabaseError>; + fn get_fiat_transactions_by_addresses(&mut self, addresses: Vec) -> Result, DatabaseError>; + fn get_fiat_assets_by_filter(&mut self, filters: Vec) -> Result, DatabaseError>; + fn get_fiat_assets_popular(&mut self, from: NaiveDateTime, limit: i64) -> Result, DatabaseError>; + fn get_fiat_assets_for_asset_id(&mut self, asset_id: &AssetId) -> Result, DatabaseError>; + fn set_fiat_rates(&mut self, rates: Vec) -> Result; + fn get_fiat_rates(&mut self) -> Result, DatabaseError>; + fn get_fiat_rate(&mut self, currency: &str) -> Result; + fn get_fiat_providers(&mut self) -> Result, DatabaseError>; + fn update_fiat_provider_payment_methods(&mut self, provider_id: FiatProviderName, values: serde_json::Value) -> Result; +} + +impl FiatRepository for DatabaseClient { + fn add_fiat_assets(&mut self, values: Vec) -> Result { + Ok(FiatStore::add_fiat_assets(self, values)?) + } + + fn add_fiat_providers(&mut self, values: Vec) -> Result { + Ok(FiatStore::add_fiat_providers(self, values)?) + } + + fn add_fiat_providers_countries(&mut self, values: Vec) -> Result { + Ok(FiatStore::add_fiat_providers_countries(self, values)?) + } + + fn get_fiat_providers_countries(&mut self) -> Result, DatabaseError> { + let result = FiatStore::get_fiat_providers_countries(self)?; + Ok(result.into_iter().map(|x| x.as_primitive()).collect()) + } + + fn get_fiat_transactions_by_addresses(&mut self, addresses: Vec) -> Result, DatabaseError> { + let result = FiatStore::get_fiat_transactions_by_addresses(self, addresses)?; + result.into_iter().map(|row| row.as_primitive()).collect() + } + + fn get_fiat_assets_by_filter(&mut self, filters: Vec) -> Result, DatabaseError> { + Ok(FiatStore::get_fiat_assets_by_filter(self, filters)?) + } + + fn get_fiat_assets_popular(&mut self, from: NaiveDateTime, limit: i64) -> Result, DatabaseError> { + Ok(FiatStore::get_fiat_assets_popular(self, from, limit)?.into_iter().map(Into::into).collect()) + } + + fn get_fiat_assets_for_asset_id(&mut self, asset_id: &AssetId) -> Result, DatabaseError> { + Ok(FiatStore::get_fiat_assets_for_asset_id(self, &asset_id.to_string())?) + } + + fn set_fiat_rates(&mut self, rates: Vec) -> Result { + Ok(FiatStore::set_fiat_rates(self, rates)?) + } + + fn get_fiat_rates(&mut self) -> Result, DatabaseError> { + let result = FiatStore::get_fiat_rates(self)?; + Ok(result.into_iter().map(|x| x.as_primitive()).collect()) + } + + fn get_fiat_rate(&mut self, currency: &str) -> Result { + let result = FiatStore::get_fiat_rate(self, currency).or_not_found(currency.to_string())?; + Ok(result.as_primitive()) + } + + fn get_fiat_providers(&mut self) -> Result, DatabaseError> { + Ok(FiatStore::get_fiat_providers(self)?) + } + + fn update_fiat_provider_payment_methods(&mut self, provider_id: FiatProviderName, values: serde_json::Value) -> Result { + Ok(FiatStore::update_fiat_provider_payment_methods(self, provider_id, values)?) + } +} diff --git a/core/crates/storage/src/repositories/migrations_repository.rs b/core/crates/storage/src/repositories/migrations_repository.rs new file mode 100644 index 0000000000..f22fe5b77c --- /dev/null +++ b/core/crates/storage/src/repositories/migrations_repository.rs @@ -0,0 +1,14 @@ +use crate::DatabaseClient; +use crate::DatabaseError; +use crate::database::migrations::MigrationsStore; + +pub trait MigrationsRepository { + fn run_migrations(&mut self) -> Result<(), DatabaseError>; +} + +impl MigrationsRepository for DatabaseClient { + fn run_migrations(&mut self) -> Result<(), DatabaseError> { + MigrationsStore::run_migrations(self); + Ok(()) + } +} diff --git a/core/crates/storage/src/repositories/mod.rs b/core/crates/storage/src/repositories/mod.rs new file mode 100644 index 0000000000..a5f183ac65 --- /dev/null +++ b/core/crates/storage/src/repositories/mod.rs @@ -0,0 +1,26 @@ +pub mod assets_addresses_repository; +pub mod assets_links_repository; +pub mod assets_repository; +pub mod assets_usage_ranks_repository; +pub mod chains_repository; +pub mod charts_repository; +pub mod config_repository; +pub mod devices_repository; +pub mod fiat_repository; +pub mod migrations_repository; +pub mod nft_repository; +pub mod notifications_repository; +pub mod parser_state_repository; +pub mod perpetuals_repository; +pub mod price_alerts_repository; +pub mod prices_providers_repository; +pub mod prices_repository; +pub mod releases_repository; +pub mod rewards_redemptions_repository; +pub mod rewards_repository; +pub mod risk_signals_repository; +pub mod scan_addresses_repository; +pub mod tag_repository; +pub mod transactions_repository; +pub mod wallets_repository; +pub mod webhooks_repository; diff --git a/core/crates/storage/src/repositories/nft_repository.rs b/core/crates/storage/src/repositories/nft_repository.rs new file mode 100644 index 0000000000..75dbd1cbab --- /dev/null +++ b/core/crates/storage/src/repositories/nft_repository.rs @@ -0,0 +1,85 @@ +use crate::database::nft::{NftAssetAssociationFilter, NftAssetFilter, NftCollectionFilter, NftStore}; +use crate::models::{NewNftAssetAssociationRow, NewNftAssetRow, NewNftCollectionRow, NftAssetRow, NftCollectionRow, nft_link::NftLinkRow, nft_report::NewNftReportRow}; +use crate::{DatabaseClient, DatabaseError, DieselResultExt}; +use primitives::{Chain, Diff}; + +pub trait NftRepository { + fn get_nft_assets_by_filter(&mut self, filters: Vec) -> Result, DatabaseError>; + fn get_nft_asset(&mut self, identifier: &str) -> Result; + fn add_nft_assets(&mut self, values: Vec) -> Result; + fn upsert_nft_asset(&mut self, value: NewNftAssetRow) -> Result; + fn get_nft_collections_by_filter(&mut self, filters: Vec) -> Result, DatabaseError>; + fn get_nft_collection(&mut self, identifier: &str) -> Result; + fn get_nft_collection_links(&mut self, collection_id: i32) -> Result, DatabaseError>; + fn add_nft_collections(&mut self, values: Vec) -> Result; + fn upsert_nft_collection(&mut self, value: NewNftCollectionRow) -> Result; + fn add_nft_collections_links(&mut self, values: Vec) -> Result; + fn set_nft_collection_links(&mut self, collection_id: i32, values: Vec) -> Result; + fn add_nft_report(&mut self, report: NewNftReportRow) -> Result; + fn set_nft_asset_associations(&mut self, address_id: i32, chains: Vec, asset_ids: Vec) -> Result<(), DatabaseError>; +} + +impl NftRepository for DatabaseClient { + fn get_nft_assets_by_filter(&mut self, filters: Vec) -> Result, DatabaseError> { + Ok(NftStore::get_nft_assets_by_filter(self, filters)?) + } + + fn get_nft_asset(&mut self, identifier: &str) -> Result { + NftStore::get_nft_asset(self, identifier).or_not_found(identifier.to_string()) + } + + fn add_nft_assets(&mut self, values: Vec) -> Result { + Ok(NftStore::add_nft_assets(self, values)?) + } + + fn upsert_nft_asset(&mut self, value: NewNftAssetRow) -> Result { + Ok(NftStore::upsert_nft_asset(self, value)?) + } + + fn get_nft_collections_by_filter(&mut self, filters: Vec) -> Result, DatabaseError> { + Ok(NftStore::get_nft_collections_by_filter(self, filters)?) + } + + fn get_nft_collection(&mut self, identifier: &str) -> Result { + NftStore::get_nft_collection(self, identifier).or_not_found(identifier.to_string()) + } + + fn get_nft_collection_links(&mut self, collection_id: i32) -> Result, DatabaseError> { + Ok(NftStore::get_nft_collection_links(self, collection_id)?) + } + + fn add_nft_collections(&mut self, values: Vec) -> Result { + Ok(NftStore::add_nft_collections(self, values)?) + } + + fn upsert_nft_collection(&mut self, value: NewNftCollectionRow) -> Result { + Ok(NftStore::upsert_nft_collection(self, value)?) + } + + fn add_nft_collections_links(&mut self, values: Vec) -> Result { + Ok(NftStore::add_nft_collections_links(self, values)?) + } + + fn set_nft_collection_links(&mut self, collection_id: i32, values: Vec) -> Result { + Ok(NftStore::set_nft_collection_links(self, collection_id, values)?) + } + + fn add_nft_report(&mut self, report: NewNftReportRow) -> Result { + Ok(NftStore::add_nft_report(self, report)?) + } + + fn set_nft_asset_associations(&mut self, address_id: i32, chains: Vec, asset_ids: Vec) -> Result<(), DatabaseError> { + let existing = NftStore::get_nft_asset_association_ids_by_filter(self, vec![NftAssetAssociationFilter::AddressId(address_id), NftAssetAssociationFilter::Chains(chains)])?; + let diff = Diff::compare(asset_ids, existing); + + let to_insert: Vec = diff.different.into_iter().map(|asset_id| NewNftAssetAssociationRow { address_id, asset_id }).collect(); + + if !to_insert.is_empty() { + NftStore::add_nft_asset_associations(self, to_insert)?; + } + if !diff.missing.is_empty() { + NftStore::delete_nft_asset_associations(self, address_id, diff.missing)?; + } + Ok(()) + } +} diff --git a/core/crates/storage/src/repositories/notifications_repository.rs b/core/crates/storage/src/repositories/notifications_repository.rs new file mode 100644 index 0000000000..72f31d2b48 --- /dev/null +++ b/core/crates/storage/src/repositories/notifications_repository.rs @@ -0,0 +1,28 @@ +use crate::database::notifications::NotificationsStore; +use crate::models::NewNotificationRow; +use crate::{DatabaseClient, DatabaseError}; +use chrono::NaiveDateTime; +use primitives::NotificationData; + +pub trait NotificationsRepository { + fn get_notifications_by_device_id(&mut self, device_id: &str, from_datetime: Option) -> Result, DatabaseError>; + fn create_notifications(&mut self, notifications: Vec) -> Result; + fn mark_all_as_read(&mut self, device_id: &str) -> Result; +} + +impl NotificationsRepository for DatabaseClient { + fn get_notifications_by_device_id(&mut self, device_id: &str, from_datetime: Option) -> Result, DatabaseError> { + Ok(NotificationsStore::get_notifications_by_device_id(self, device_id, from_datetime)? + .into_iter() + .map(|(row, wallet_identifier, asset_row)| row.as_primitive(wallet_identifier, asset_row.map(|a| a.as_primitive()))) + .collect()) + } + + fn create_notifications(&mut self, notifications: Vec) -> Result { + NotificationsStore::create_notifications(self, notifications) + } + + fn mark_all_as_read(&mut self, device_id: &str) -> Result { + NotificationsStore::mark_all_as_read(self, device_id) + } +} diff --git a/core/crates/storage/src/repositories/parser_state_repository.rs b/core/crates/storage/src/repositories/parser_state_repository.rs new file mode 100644 index 0000000000..1c0853f56d --- /dev/null +++ b/core/crates/storage/src/repositories/parser_state_repository.rs @@ -0,0 +1,33 @@ +use crate::database::parser_state::ParserStateStore; +use crate::{DatabaseClient, DatabaseError, DieselResultExt}; +use primitives::Chain; + +pub trait ParserStateRepository { + fn get_parser_state(&mut self, chain: Chain) -> Result; + fn add_parser_state(&mut self, chain: Chain, block_time_ms: i32) -> Result; + fn get_parser_states(&mut self) -> Result, DatabaseError>; + fn set_parser_state_latest_block(&mut self, chain: Chain, block: i64) -> Result; + fn set_parser_state_current_block(&mut self, chain: Chain, block: i64) -> Result; +} + +impl ParserStateRepository for DatabaseClient { + fn get_parser_state(&mut self, chain: Chain) -> Result { + ParserStateStore::get_parser_state(self, chain).or_not_found(chain.as_ref().to_string()) + } + + fn add_parser_state(&mut self, chain: Chain, block_time_ms: i32) -> Result { + Ok(ParserStateStore::add_parser_state(self, chain, block_time_ms)?) + } + + fn get_parser_states(&mut self) -> Result, DatabaseError> { + Ok(ParserStateStore::get_parser_states(self)?) + } + + fn set_parser_state_latest_block(&mut self, chain: Chain, block: i64) -> Result { + Ok(ParserStateStore::set_parser_state_latest_block(self, chain, block)?) + } + + fn set_parser_state_current_block(&mut self, chain: Chain, block: i64) -> Result { + Ok(ParserStateStore::set_parser_state_current_block(self, chain, block)?) + } +} diff --git a/core/crates/storage/src/repositories/perpetuals_repository.rs b/core/crates/storage/src/repositories/perpetuals_repository.rs new file mode 100644 index 0000000000..e6f28b29bb --- /dev/null +++ b/core/crates/storage/src/repositories/perpetuals_repository.rs @@ -0,0 +1,35 @@ +use crate::database::perpetuals::{PerpetualFilter, PerpetualsStore}; +use crate::models::{NewPerpetualRow, PerpetualRow}; +use crate::{DatabaseClient, DatabaseError}; +use primitives::{AssetId, perpetual::Perpetual}; + +pub trait PerpetualsRepository { + fn get_perpetuals_for_asset(&mut self, asset_id: &AssetId) -> Result, DatabaseError>; + + fn perpetuals_update(&mut self, values: Vec) -> Result; + + fn perpetuals_all(&mut self) -> Result, DatabaseError>; + + fn get_perpetuals_by_filter(&mut self, filters: Vec) -> Result, DatabaseError>; +} + +impl PerpetualsRepository for DatabaseClient { + fn get_perpetuals_for_asset(&mut self, asset_id: &AssetId) -> Result, DatabaseError> { + Ok(PerpetualsStore::get_perpetuals_for_asset(self, &asset_id.to_string())? + .into_iter() + .map(|x| x.as_primitive()) + .collect()) + } + + fn perpetuals_update(&mut self, values: Vec) -> Result { + Ok(PerpetualsStore::perpetuals_update(self, values)?) + } + + fn perpetuals_all(&mut self) -> Result, DatabaseError> { + Ok(PerpetualsStore::get_perpetuals_by_filter(self, vec![])?.into_iter().map(|x| x.as_primitive()).collect()) + } + + fn get_perpetuals_by_filter(&mut self, filters: Vec) -> Result, DatabaseError> { + Ok(PerpetualsStore::get_perpetuals_by_filter(self, filters)?) + } +} diff --git a/core/crates/storage/src/repositories/price_alerts_repository.rs b/core/crates/storage/src/repositories/price_alerts_repository.rs new file mode 100644 index 0000000000..ae1d0eebdf --- /dev/null +++ b/core/crates/storage/src/repositories/price_alerts_repository.rs @@ -0,0 +1,73 @@ +use crate::{DatabaseError, DieselResultExt}; +use chrono::NaiveDateTime; +use primitives::{AssetId, Device, DevicePriceAlert, PriceAlert, PriceAlerts, PriceData}; +use std::collections::HashMap; +use std::time::Duration; + +use crate::DatabaseClient; +use crate::database::devices::DevicesStore; +use crate::database::price_alerts::PriceAlertsStore; +use crate::repositories::prices_repository::PricesRepository; + +pub trait PriceAlertsRepository { + fn get_price_alerts(&mut self, after_notified_at: NaiveDateTime, max_age: Duration) -> Result, DatabaseError>; + fn get_price_alerts_for_device_id(&mut self, device_id: &str, asset_id: Option<&AssetId>) -> Result, DatabaseError>; + fn add_price_alerts(&mut self, device_id: &str, price_alerts: PriceAlerts) -> Result; + fn delete_price_alerts(&mut self, device_id: &str, ids: Vec) -> Result; + fn update_price_alerts_set_notified_at(&mut self, ids: Vec, last_notified_at: NaiveDateTime) -> Result; +} + +impl PriceAlertsRepository for DatabaseClient { + fn get_price_alerts(&mut self, after_notified_at: NaiveDateTime, max_age: Duration) -> Result, DatabaseError> { + let alerts = PriceAlertsStore::get_price_alerts(self, after_notified_at)?; + if alerts.is_empty() { + return Ok(vec![]); + } + let asset_ids: Vec = alerts + .iter() + .map(|(a, _)| a.asset_id.0.clone()) + .collect::>() + .into_iter() + .collect(); + let primary: HashMap = self + .get_primary_prices(&asset_ids, max_age)? + .into_iter() + .map(|(id, row)| (id.to_string(), row.as_price_data())) + .collect(); + Ok(alerts + .into_iter() + .filter_map(|(alert, device)| { + primary + .get(&alert.asset_id.to_string()) + .map(|price| (alert.as_primitive(), price.clone(), device.as_primitive())) + }) + .collect()) + } + + fn get_price_alerts_for_device_id(&mut self, device_id: &str, asset_id: Option<&AssetId>) -> Result, DatabaseError> { + let asset_id = asset_id.map(|id| id.to_string()); + let results = PriceAlertsStore::get_price_alerts_for_device_id(self, device_id, asset_id.as_deref())?; + Ok(results + .into_iter() + .map(|(alert, device)| DevicePriceAlert { + device: device.as_primitive(), + price_alert: alert.as_primitive(), + }) + .collect()) + } + + fn add_price_alerts(&mut self, device_id: &str, price_alerts: PriceAlerts) -> Result { + let device = DevicesStore::get_device(self, device_id).or_not_found(device_id.to_string())?; + let values = price_alerts.into_iter().map(|x| crate::models::PriceAlertRow::new_price_alert(x, device.id)).collect(); + Ok(PriceAlertsStore::add_price_alerts(self, values)?) + } + + fn delete_price_alerts(&mut self, device_id: &str, ids: Vec) -> Result { + let device = DevicesStore::get_device(self, device_id).or_not_found(device_id.to_string())?; + Ok(PriceAlertsStore::delete_price_alerts(self, device.id, ids)?) + } + + fn update_price_alerts_set_notified_at(&mut self, ids: Vec, last_notified_at: NaiveDateTime) -> Result { + Ok(PriceAlertsStore::update_price_alerts_set_notified_at(self, ids, last_notified_at)?) + } +} diff --git a/core/crates/storage/src/repositories/prices_providers_repository.rs b/core/crates/storage/src/repositories/prices_providers_repository.rs new file mode 100644 index 0000000000..b992cef6e4 --- /dev/null +++ b/core/crates/storage/src/repositories/prices_providers_repository.rs @@ -0,0 +1,19 @@ +use crate::DatabaseClient; +use crate::DatabaseError; +use crate::database::prices_providers::PricesProvidersStore; +use crate::models::PriceProviderConfigRow; + +pub trait PricesProvidersRepository { + fn add_prices_providers(&mut self, values: Vec) -> Result; + fn get_prices_providers(&mut self) -> Result, DatabaseError>; +} + +impl PricesProvidersRepository for DatabaseClient { + fn add_prices_providers(&mut self, values: Vec) -> Result { + Ok(PricesProvidersStore::add_prices_providers(self, values)?) + } + + fn get_prices_providers(&mut self) -> Result, DatabaseError> { + Ok(PricesProvidersStore::get_prices_providers(self)?) + } +} diff --git a/core/crates/storage/src/repositories/prices_repository.rs b/core/crates/storage/src/repositories/prices_repository.rs new file mode 100644 index 0000000000..c5d4351169 --- /dev/null +++ b/core/crates/storage/src/repositories/prices_repository.rs @@ -0,0 +1,251 @@ +use crate::{DatabaseError, DieselResultExt}; +use chrono::Utc; +use primitives::{AssetId, AssetIdVecExt, Price, PriceId, PriceProvider}; +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use crate::DatabaseClient; +use crate::database::assets::AssetsStore; +use crate::database::charts::ChartsStore; +use crate::database::prices::{AssetsWithPricesFilter, PriceFilter, PriceUpdate, PricesStore}; +use crate::database::prices_providers::PricesProvidersStore; +use crate::error::ResourceName; +use crate::models::min_max::MinMax; +use crate::models::{ChartRow, PriceAssetRow, PriceProviderConfigRow, PriceRow, price::NewPriceRow, price::PriceAssetDataRow, price::PricesChangeset}; + +pub trait PricesRepository { + fn add_prices(&mut self, values: Vec) -> Result; + fn set_prices(&mut self, prices: Vec) -> Result, DatabaseError>; + fn set_prices_assets(&mut self, values: Vec) -> Result; + fn get_prices_by_filter(&mut self, filters: Vec) -> Result, DatabaseError>; + fn get_prices_assets(&mut self) -> Result, DatabaseError>; + fn get_prices_assets_by_provider(&mut self, provider: PriceProvider) -> Result, DatabaseError>; + fn get_primary_price_key(&mut self, asset_id: &AssetId, max_age: Duration) -> Result; + fn get_primary_prices(&mut self, asset_ids: &[AssetId], max_age: Duration) -> Result, DatabaseError>; + fn get_price_by_id(&mut self, price_id: &str) -> Result; + fn get_prices_for_asset(&mut self, asset_id: &AssetId) -> Result, DatabaseError>; + fn get_prices_assets_for_price_ids(&mut self, ids: Vec) -> Result, DatabaseError>; + fn delete_prices(&mut self, ids: Vec) -> Result; + fn get_assets_with_prices_by_filter(&mut self, filters: Vec, max_age: Duration) -> Result, DatabaseError>; + fn get_assets_with_prices(&mut self, asset_ids: Vec, max_age: Duration) -> Result, DatabaseError>; + fn update_prices(&mut self, price_ids: Vec, updates: Vec) -> Result; + fn update_extremes_for_price(&mut self, price_id: &str) -> Result; +} + +impl PricesRepository for DatabaseClient { + fn add_prices(&mut self, values: Vec) -> Result { + Ok(PricesStore::add_prices(self, values)?) + } + + fn set_prices_assets(&mut self, values: Vec) -> Result { + Ok(PricesStore::set_prices_assets(self, values)?) + } + + fn get_prices_by_filter(&mut self, filters: Vec) -> Result, DatabaseError> { + Ok(PricesStore::get_prices_by_filter(self, filters)?) + } + + fn get_prices_assets(&mut self) -> Result, DatabaseError> { + Ok(PricesStore::get_prices_assets(self)?) + } + + fn get_prices_assets_by_provider(&mut self, provider: PriceProvider) -> Result, DatabaseError> { + Ok(PricesStore::get_prices_assets_by_provider(self, provider)?) + } + + fn get_primary_price_key(&mut self, asset_id: &AssetId, max_age: Duration) -> Result { + let providers = PricesProvidersStore::get_prices_providers(self)?; + let rows = PricesStore::get_prices_for_asset_ids(self, &[asset_id.to_string()])? + .into_iter() + .map(|(_, row)| row) + .collect::>(); + Ok(resolve_primary(&providers, &rows, max_age) + .ok_or_else(|| DatabaseError::not_found(PriceRow::RESOURCE_NAME, asset_id.to_string()))? + .id + .0 + .clone()) + } + + fn get_primary_prices(&mut self, asset_ids: &[AssetId], max_age: Duration) -> Result, DatabaseError> { + if asset_ids.is_empty() { + return Ok(vec![]); + } + let providers = PricesProvidersStore::get_prices_providers(self)?; + let string_ids: Vec = asset_ids.iter().map(|id| id.to_string()).collect(); + let mut rows_by_asset: HashMap> = PricesStore::get_prices_for_asset_ids(self, &string_ids)? + .into_iter() + .fold(HashMap::new(), |mut acc, (id, row)| { + acc.entry(id).or_default().push(row); + acc + }); + Ok(asset_ids + .iter() + .filter_map(|asset_id| { + let rows = rows_by_asset.remove(&asset_id.to_string())?; + let row = resolve_primary(&providers, &rows, max_age)?.clone(); + Some((asset_id.clone(), row)) + }) + .collect()) + } + + fn get_price_by_id(&mut self, price_id: &str) -> Result { + Ok(PricesStore::get_price_by_id(self, price_id).or_not_found(price_id.to_string())?.as_primitive()) + } + + fn get_prices_for_asset(&mut self, asset_id: &AssetId) -> Result, DatabaseError> { + Ok(PricesStore::get_prices_for_asset_ids(self, &[asset_id.to_string()])? + .into_iter() + .map(|(_, row)| row) + .collect()) + } + + fn get_prices_assets_for_price_ids(&mut self, ids: Vec) -> Result, DatabaseError> { + Ok(PricesStore::get_prices_assets_for_price_ids(self, ids)?) + } + + fn delete_prices(&mut self, ids: Vec) -> Result { + Ok(PricesStore::delete_prices(self, ids)?) + } + + fn get_assets_with_prices_by_filter(&mut self, filters: Vec, max_age: Duration) -> Result, DatabaseError> { + let since = filters.iter().map(|AssetsWithPricesFilter::UpdatedSince(value)| *value).next(); + let asset_ids: Vec = match since { + Some(value) => { + let mut asset_ids = AssetsStore::get_asset_ids_updated_since(self, value)?; + asset_ids.extend(PricesStore::get_asset_ids_updated_since(self, value)?); + asset_ids.sort(); + asset_ids.dedup(); + asset_ids + } + None => AssetsStore::get_all_asset_ids(self)?, + }; + let asset_ids = asset_ids.into_iter().filter_map(|id| AssetId::new(&id)).collect(); + self.get_assets_with_prices(asset_ids, max_age) + } + + fn update_prices(&mut self, price_ids: Vec, updates: Vec) -> Result { + if updates.is_empty() { + return Ok(0); + } + let changeset = PricesChangeset::from_updates(updates); + Ok(PricesStore::update_prices(self, &price_ids, &changeset)?) + } + + fn update_extremes_for_price(&mut self, price_id: &str) -> Result { + use primitives::ChartTimeframe; + let row = PricesStore::get_price_by_id(self, price_id).or_not_found(price_id.to_string())?; + let timeframes = [ChartTimeframe::Raw, ChartTimeframe::Hourly, ChartTimeframe::Daily]; + let extremes: Vec> = timeframes + .into_iter() + .map(|tf| ChartsStore::get_chart_extremes(self, price_id, tf)) + .collect::>()?; + let combined = MinMax { + max: extremes.iter().filter_map(|e| e.max).max_by(|a, b| a.value.total_cmp(&b.value)), + min: extremes.iter().filter_map(|e| e.min).min_by(|a, b| a.value.total_cmp(&b.value)), + }; + let updates = row.merge_extremes_from_charts(combined); + PricesRepository::update_prices(self, vec![price_id.to_string()], updates) + } + + fn set_prices(&mut self, prices: Vec) -> Result, DatabaseError> { + if prices.is_empty() { + return Ok(vec![]); + } + let price_ids: Vec = prices.iter().map(|p| p.id.to_string()).collect(); + let mappings = PricesStore::get_prices_assets_for_price_ids(self, price_ids)?; + let mapped_ids: HashSet = mappings.iter().map(|m| m.price_id.to_string()).collect(); + let to_store: Vec = prices.into_iter().filter(|p| mapped_ids.contains(&p.id.to_string())).collect(); + if to_store.is_empty() { + return Ok(vec![]); + } + let ids: Vec = to_store.iter().map(|p| p.id.to_string()).collect(); + let incoming_by_id: HashMap = to_store.iter().cloned().map(|p| (p.id.to_string(), p)).collect(); + PricesStore::set_prices(self, to_store)?; + + let current_prices = PricesStore::get_prices_by_filter(self, vec![PriceFilter::Ids(ids)])?; + let extreme_updates: Vec<(String, Vec)> = current_prices + .iter() + .filter_map(|price| { + let id = price.id.to_string(); + let updates = price.merge_extremes(incoming_by_id.get(&id)); + (!updates.is_empty()).then_some((id, updates)) + }) + .collect(); + for (id, updates) in extreme_updates { + PricesRepository::update_prices(self, vec![id], updates)?; + } + + let charts: Vec = current_prices.iter().cloned().map(ChartRow::from_price).collect(); + ChartsStore::add_charts(self, primitives::ChartTimeframe::Raw, charts)?; + + Ok(mappings.into_iter().map(|m| m.asset_id.0).collect::>().into_iter().collect()) + } + + fn get_assets_with_prices(&mut self, asset_ids: Vec, max_age: Duration) -> Result, DatabaseError> { + if asset_ids.is_empty() { + return Ok(vec![]); + } + + let providers = PricesProvidersStore::get_prices_providers(self)?; + let assets = AssetsStore::get_assets(self, asset_ids.ids())?; + let mut prices_by_asset: HashMap> = PricesStore::get_prices_for_asset_ids(self, &assets.iter().map(|a| a.id.clone()).collect::>())? + .into_iter() + .fold(HashMap::new(), |mut acc, (asset_id, row)| { + acc.entry(asset_id).or_default().push(row); + acc + }); + + Ok(assets + .into_iter() + .map(|asset| { + let rows = prices_by_asset.remove(&asset.id).unwrap_or_default(); + let price = resolve_primary(&providers, &rows, max_age).cloned(); + PriceAssetDataRow { asset, price } + }) + .collect()) + } +} + +fn resolve_primary<'a>(providers: &[PriceProviderConfigRow], rows: &'a [PriceRow], max_age: Duration) -> Option<&'a PriceRow> { + let cutoff = (Utc::now() - chrono::Duration::from_std(max_age).ok()?).naive_utc(); + let mut candidates: Vec<(&PriceProviderConfigRow, &PriceRow)> = providers + .iter() + .filter(|p| p.enabled) + .filter_map(|p| rows.iter().find(|row| row.provider.0 == p.id.0).map(|row| (p, row))) + .filter(|(_, row)| row.last_updated_at >= cutoff) + .collect(); + candidates.sort_by_key(|(p, _)| p.priority); + candidates.first().map(|(_, row)| *row) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn aged(provider: PriceProvider, seconds_ago: i64) -> PriceRow { + let mut row = PriceRow::mock(provider, "x"); + row.last_updated_at = (Utc::now() - chrono::Duration::seconds(seconds_ago)).naive_utc(); + row + } + + #[test] + fn test_resolve_primary() { + let providers = vec![ + PriceProviderConfigRow::new(PriceProvider::Coingecko, true), + PriceProviderConfigRow::new(PriceProvider::Pyth, true), + PriceProviderConfigRow::new(PriceProvider::Jupiter, false), + ]; + let max_age = Duration::from_secs(3600); + + let fresh = vec![aged(PriceProvider::Coingecko, 60), aged(PriceProvider::Pyth, 60)]; + assert_eq!(resolve_primary(&providers, &fresh, max_age).unwrap().provider.0, PriceProvider::Coingecko); + + let stale_primary = vec![aged(PriceProvider::Coingecko, 7200), aged(PriceProvider::Pyth, 60)]; + assert_eq!(resolve_primary(&providers, &stale_primary, max_age).unwrap().provider.0, PriceProvider::Pyth); + + let only_disabled = vec![aged(PriceProvider::Jupiter, 60)]; + assert!(resolve_primary(&providers, &only_disabled, max_age).is_none()); + + assert!(resolve_primary(&providers, &[], max_age).is_none()); + } +} diff --git a/core/crates/storage/src/repositories/releases_repository.rs b/core/crates/storage/src/repositories/releases_repository.rs new file mode 100644 index 0000000000..18ac4f910a --- /dev/null +++ b/core/crates/storage/src/repositories/releases_repository.rs @@ -0,0 +1,32 @@ +use crate::DatabaseError; + +use crate::DatabaseClient; +use crate::database::releases::ReleasesStore; +use crate::models::ReleaseRow; +use primitives::PlatformStore; + +pub trait ReleasesRepository { + fn get_releases(&mut self) -> Result, DatabaseError>; + fn add_releases(&mut self, values: Vec) -> Result; + fn update_release(&mut self, release: ReleaseRow) -> Result; + fn is_update_enabled(&mut self, store: PlatformStore) -> Result; +} + +impl ReleasesRepository for DatabaseClient { + fn get_releases(&mut self) -> Result, DatabaseError> { + Ok(ReleasesStore::get_releases(self)?) + } + + fn add_releases(&mut self, values: Vec) -> Result { + Ok(ReleasesStore::add_releases(self, values)?) + } + + fn update_release(&mut self, release: ReleaseRow) -> Result { + Ok(ReleasesStore::update_release(self, release)?) + } + + fn is_update_enabled(&mut self, store: PlatformStore) -> Result { + let release = ReleasesStore::get_release(self, &store.into())?; + Ok(release.map(|r| r.update_enabled).unwrap_or(true)) + } +} diff --git a/core/crates/storage/src/repositories/rewards_redemptions_repository.rs b/core/crates/storage/src/repositories/rewards_redemptions_repository.rs new file mode 100644 index 0000000000..760f82b9e7 --- /dev/null +++ b/core/crates/storage/src/repositories/rewards_redemptions_repository.rs @@ -0,0 +1,73 @@ +use crate::database::rewards::{RewardsFilter, RewardsStore}; +use crate::database::rewards_redemptions::{RedemptionUpdate, RewardsRedemptionsStore}; +use crate::models::{NewRewardRedemptionRow, RewardRedemptionRow}; +use crate::sql_types::{RedemptionStatus, RewardRedemptionType}; +use crate::{DatabaseClient, DatabaseError, DieselResultExt}; +use chrono::Utc; +use primitives::rewards::{RewardRedemption, RewardRedemptionOption}; + +pub trait RewardsRedemptionsRepository { + fn add_redemption(&mut self, username: &str, option_id: &str, device_id: i32, wallet_id: i32) -> Result; + fn get_redemption(&mut self, redemption_id: i32) -> Result; + fn update_redemption(&mut self, redemption_id: i32, updates: Vec) -> Result<(), DatabaseError>; + fn get_redemption_options(&mut self, types: &[RewardRedemptionType]) -> Result, DatabaseError>; + fn get_redemption_option(&mut self, id: &str) -> Result; + fn count_redemptions_since_days(&mut self, username: &str, days: i64) -> Result; +} + +impl RewardsRedemptionsRepository for DatabaseClient { + fn add_redemption(&mut self, username: &str, option_id: &str, device_id: i32, wallet_id: i32) -> Result { + let redemption_option = RewardsRedemptionsStore::get_redemption_option(self, option_id).or_not_found(option_id.to_string())?; + let rewards = RewardsStore::get_rewards_by_filter(self, vec![RewardsFilter::Username(username.to_string())])? + .into_iter() + .next() + .ok_or_else(|| DatabaseError::not_found("Rewards", username.to_string()))?; + + if rewards.points < redemption_option.option.points { + return Err(DatabaseError::Error("Not enough points".into())); + } + + if redemption_option.option.remaining == Some(0) { + return Err(DatabaseError::Error("Redemption option is no longer available".into())); + } + + let redemption_id = RewardsRedemptionsStore::add_redemption( + self, + username, + redemption_option.option.points, + NewRewardRedemptionRow { + username: username.to_string(), + option_id: option_id.to_string(), + device_id, + wallet_id, + status: RedemptionStatus::Pending, + }, + )?; + + let option = redemption_option.as_primitive(); + let redemption_row = RewardsRedemptionsStore::get_redemption(self, redemption_id).or_not_found_internal(redemption_id.to_string())?; + Ok(redemption_row.as_primitive(option)) + } + + fn get_redemption(&mut self, redemption_id: i32) -> Result { + RewardsRedemptionsStore::get_redemption(self, redemption_id).or_not_found_internal(redemption_id.to_string()) + } + + fn update_redemption(&mut self, redemption_id: i32, updates: Vec) -> Result<(), DatabaseError> { + Ok(RewardsRedemptionsStore::update_redemption(self, redemption_id, updates)?) + } + + fn get_redemption_options(&mut self, types: &[RewardRedemptionType]) -> Result, DatabaseError> { + let results = RewardsRedemptionsStore::get_redemption_options(self, types)?; + Ok(results.into_iter().map(|r| r.as_primitive()).collect()) + } + + fn get_redemption_option(&mut self, id: &str) -> Result { + Ok(RewardsRedemptionsStore::get_redemption_option(self, id).or_not_found(id.to_string())?.as_primitive()) + } + + fn count_redemptions_since_days(&mut self, username: &str, days: i64) -> Result { + let since = Utc::now().naive_utc() - chrono::Duration::days(days); + Ok(RewardsRedemptionsStore::count_redemptions_since(self, username, since)?) + } +} diff --git a/core/crates/storage/src/repositories/rewards_repository.rs b/core/crates/storage/src/repositories/rewards_repository.rs new file mode 100644 index 0000000000..4ce17a9fe0 --- /dev/null +++ b/core/crates/storage/src/repositories/rewards_repository.rs @@ -0,0 +1,754 @@ +use crate::database::referrals::{ReferralUpdate, ReferralsStore}; +use crate::database::rewards::{RewardsFilter, RewardsStore, RewardsUpdate}; +use crate::database::transactions::{TransactionFilter, TransactionsStore}; +use crate::database::usernames::{UsernameLookup, UsernamesStore}; +use crate::database::wallets::WalletsStore; +use crate::models::{ + NewRewardEventRow, NewRewardReferralRow, NewRewardsRow, NewUsernameRow, ReferralAttemptRow, RewardEventRow, RewardReferralRow, RewardsRow, UsernameRow, WalletRow, +}; +use crate::repositories::config_repository::ConfigRepository; +use crate::repositories::rewards_redemptions_repository::RewardsRedemptionsRepository; +use crate::sql_types::ChainRow; +use crate::sql_types::{RewardEventType, RewardRedemptionType, RewardStatus, TransactionState, UsernameStatus}; +use crate::{DatabaseClient, DatabaseError, DieselResultExt, ReferralValidationError, UsernameValidationError}; +use chrono::NaiveDateTime; +use primitives::rewards::RewardStatus as PrimitiveRewardStatus; +use primitives::{Chain, ConfigKey, Device, NaiveDateTimeExt, ReferralLeader, ReferralLeaderboard, RewardEvent, Rewards, WalletId, now}; + +fn create_username_and_rewards(client: &mut DatabaseClient, wallet_id: i32, address: &str, device_id: i32) -> Result { + UsernamesStore::create_username( + client, + NewUsernameRow { + username: address.to_string(), + wallet_id, + status: UsernameStatus::Unverified, + }, + )?; + Ok(RewardsStore::create_rewards(client, NewRewardsRow::new(address.to_string(), device_id))?) +} + +fn validate_username(username: &str) -> Result<(), UsernameValidationError> { + let len = username.len(); + if len < 4 { + return Err(UsernameValidationError::Invalid("Username must be at least 4 characters".into())); + } + if len > 16 { + return Err(UsernameValidationError::Invalid("Username must be at most 16 characters".into())); + } + if !username.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(UsernameValidationError::Invalid("Username must contain only letters and digits".into())); + } + Ok(()) +} + +fn find_username(client: &mut DatabaseClient, lookup: UsernameLookup<'_>) -> Result, DatabaseError> { + match UsernamesStore::get_username(client, lookup) { + Ok(username) => Ok(Some(username)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn require_username(client: &mut DatabaseClient, lookup: UsernameLookup<'_>) -> Result { + match lookup { + UsernameLookup::Username(username) => UsernamesStore::get_username(client, lookup).or_not_found(username.to_string()), + UsernameLookup::WalletId(wallet_id) => UsernamesStore::get_username(client, lookup).or_not_found_internal(wallet_id.to_string()), + } +} + +fn require_rewards(client: &mut DatabaseClient, username: &str) -> Result { + RewardsStore::get_rewards_by_filter(client, vec![RewardsFilter::Username(username.to_string())])? + .into_iter() + .next() + .ok_or_else(|| DatabaseError::not_found("Rewards", username.to_string())) +} + +fn require_reward_event(client: &mut DatabaseClient, event_id: i32) -> Result { + RewardsStore::get_event(client, event_id).or_not_found_internal(event_id.to_string()) +} + +fn find_wallet(client: &mut DatabaseClient, identifier: &str) -> Result, DatabaseError> { + match WalletsStore::get_wallet(client, identifier) { + Ok(wallet) => Ok(Some(wallet)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn require_wallet_by_id(client: &mut DatabaseClient, wallet_id: i32) -> Result { + WalletsStore::get_wallet_by_id(client, wallet_id).or_not_found_internal(wallet_id.to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RewardsEligibilityConfig { + pub activity_cutoff: NaiveDateTime, + pub transactions_required: i64, +} + +#[derive(Debug, Clone)] +pub struct ReferrerInfo { + pub status: PrimitiveRewardStatus, + pub referral_count: i32, + pub wallet_id: i32, +} + +fn compute_verification_delay(base_delay: std::time::Duration, multiplier: i64, referrer_status: &PrimitiveRewardStatus) -> Option { + if referrer_status == &PrimitiveRewardStatus::Trusted || multiplier <= 0 { + return None; + } + Some(std::time::Duration::from_secs(base_delay.as_secs() / multiplier as u64)) +} + +fn referral_verification_delay(config: &mut dyn ConfigRepository, referrer_status: &PrimitiveRewardStatus) -> Result, DatabaseError> { + let base_delay = config.get_config_duration(ConfigKey::ReferralVerificationDelay)?; + let multiplier = if referrer_status.is_verified() { + config.get_config_i64(ConfigKey::ReferralVerifiedMultiplier)? + } else { + 1 + }; + Ok(compute_verification_delay(base_delay, multiplier, referrer_status)) +} + +fn can_verify_referral(status: &PrimitiveRewardStatus, verify_after: Option) -> bool { + if status.is_verified() { + return true; + } + verify_after.is_some_and(|dt| dt <= now()) +} + +fn is_matching_pending_referral_confirmation(referral: &RewardReferralRow, referrer_username: &str, referred_username: &str) -> bool { + referral.verified_at.is_none() && referral.referrer_username == referrer_username && referral.referred_username == referred_username +} + +fn latest_wallet_device_id(client: &mut DatabaseClient, wallet_id: i32) -> Result { + WalletsStore::get_devices_by_wallet_id(client, wallet_id)? + .into_iter() + .max_by_key(|device| device.updated_at) + .map(|device| device.id) + .ok_or_else(|| DatabaseError::Error(format!("Wallet {wallet_id} has no subscribed devices"))) +} + +fn referred_username(client: &mut DatabaseClient, wallet_id: i32) -> Result { + match find_username(client, UsernameLookup::WalletId(wallet_id))? { + Some(username) => Ok(username.username), + None => { + let wallet = require_wallet_by_id(client, wallet_id)?; + Ok(wallet.wallet_id.address().to_string()) + } + } +} + +fn ensure_wallet_reward_identity(client: &mut DatabaseClient, wallet_id: i32) -> Result { + let device_id = latest_wallet_device_id(client, wallet_id)?; + + match find_username(client, UsernameLookup::WalletId(wallet_id))? { + Some(username) => { + if require_rewards(client, &username.username).is_err() { + RewardsStore::create_rewards(client, NewRewardsRow::new(username.username.clone(), device_id))?; + } + Ok(username) + } + None => { + let wallet = require_wallet_by_id(client, wallet_id)?; + let address = wallet.wallet_id.address().to_string(); + create_username_and_rewards(client, wallet_id, &address, device_id)?; + require_username(client, UsernameLookup::WalletId(wallet_id)) + } + } +} + +fn add_referral_verified_event_rows(client: &mut DatabaseClient, referrer_username: &str, referred_username: &str) -> Result, DatabaseError> { + let referrer_event = RewardsStore::add_event( + client, + NewRewardEventRow { + username: referrer_username.to_string(), + event_type: RewardEventType::InviteNew, + }, + RewardEventType::InviteNew.points(), + )?; + + let referred_event = RewardsStore::add_event( + client, + NewRewardEventRow { + username: referred_username.to_string(), + event_type: RewardEventType::Joined, + }, + RewardEventType::Joined.points(), + )?; + + Ok(vec![referrer_event, referred_event]) +} + +fn add_referral_verified_events(client: &mut DatabaseClient, referrer_username: &str, referred_username: &str) -> Result, DatabaseError> { + Ok(add_referral_verified_event_rows(client, referrer_username, referred_username)? + .into_iter() + .map(|event| event.as_primitive()) + .collect()) +} + +fn add_referral_pending_events(client: &mut DatabaseClient, referrer_username: &str) -> Result, DatabaseError> { + let event = RewardsStore::add_event( + client, + NewRewardEventRow { + username: referrer_username.to_string(), + event_type: RewardEventType::InvitePending, + }, + RewardEventType::InvitePending.points(), + )?; + Ok(vec![event.as_primitive()]) +} + +fn add_referral_with_events( + client: &mut DatabaseClient, + referrer_username: &str, + referred_username: &str, + device_id: i32, + risk_signal_id: i32, + verified_at: Option, +) -> Result, DatabaseError> { + ReferralsStore::add_referral( + client, + NewRewardReferralRow { + referrer_username: referrer_username.to_string(), + referred_username: referred_username.to_string(), + referred_device_id: device_id, + risk_signal_id, + verified_at, + }, + )?; + + if verified_at.is_some() { + add_referral_verified_events(client, referrer_username, referred_username) + } else { + add_referral_pending_events(client, referrer_username) + } +} + +fn complete_referral(client: &mut DatabaseClient, referred_username: &str) -> Result, DatabaseError> { + let Some(referral) = ReferralsStore::get_referral_by_username(client, referred_username)? else { + return Ok(vec![]); + }; + + if referral.verified_at.is_some() { + return Ok(vec![]); + } + + ReferralsStore::update_referral(client, referral.id, ReferralUpdate::VerifiedAt(now()))?; + Ok(add_referral_verified_event_rows(client, &referral.referrer_username, referred_username)? + .into_iter() + .map(|event| event.id) + .collect()) +} + +pub trait RewardsRepository { + fn get_reward_by_wallet_id(&mut self, wallet_id: i32) -> Result; + fn get_reward_events_by_wallet_id(&mut self, wallet_id: i32) -> Result, DatabaseError>; + fn get_reward_event(&mut self, event_id: i32) -> Result; + fn get_reward_event_devices(&mut self, event_id: i32) -> Result, DatabaseError>; + fn create_reward(&mut self, wallet_id: i32, username: &str) -> Result<(Rewards, i32), UsernameValidationError>; + fn get_referral_code(&mut self, code: &str) -> Result, DatabaseError>; + fn get_referrer_info(&mut self, username: &str) -> Result; + fn is_pending_referral(&mut self, referrer_username: &str, wallet_id: i32, device_id: i32) -> Result; + fn validate_referral_use( + &mut self, + referrer_username: &str, + referrer_wallet_id: i32, + wallet_id: i32, + device_id: i32, + device_created_at: NaiveDateTime, + eligibility_days: i64, + ) -> Result<(), ReferralValidationError>; + fn add_referral_attempt(&mut self, referrer_username: &str, referred_wallet_id: i32, device_id: i32, risk_signal_id: Option, reason: &str) -> Result<(), DatabaseError>; + fn get_first_subscription_date_by_wallet_id(&mut self, wallet_id: i32) -> Result, DatabaseError>; + fn get_wallet_id_by_username(&mut self, username: &str) -> Result; + fn get_referrer_username(&mut self, referred_username: &str) -> Result, DatabaseError>; + fn get_address_by_username(&mut self, username: &str) -> Result; + fn get_status_by_username(&mut self, username: &str) -> Result; + fn get_referral_count_by_username(&mut self, username: &str) -> Result; + fn count_referrals_since(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result; + fn get_rewards_leaderboard(&mut self) -> Result; + fn disable_rewards(&mut self, username: &str, reason: &str, comment: &str) -> Result; + fn get_rewards_by_filter(&mut self, filters: Vec) -> Result, DatabaseError>; + fn check_eligibility(&mut self, username: &str, eligibility: RewardsEligibilityConfig) -> Result, DatabaseError>; + fn promote_to_verified(&mut self, username: &str) -> Result, DatabaseError>; + + fn use_or_verify_referral( + &mut self, + referrer_username: &str, + referrer_status: &PrimitiveRewardStatus, + referred_wallet_id: i32, + device_id: i32, + risk_signal_id: i32, + ) -> Result, DatabaseError>; +} + +impl RewardsRepository for DatabaseClient { + fn get_reward_by_wallet_id(&mut self, wallet_id: i32) -> Result { + let username = ensure_wallet_reward_identity(self, wallet_id)?; + let rewards = require_rewards(self, &username.username)?; + let has_custom_code = username.has_custom_username(); + let code = if has_custom_code { Some(username.username.clone()) } else { None }; + + let status = *rewards.status; + let types = [RewardRedemptionType::Asset]; + let options = RewardsRedemptionsRepository::get_redemption_options(self, &types)? + .into_iter() + .filter(|x| x.remaining.unwrap_or_default() > 0) + .collect(); + + Ok(Rewards { + code, + invite_reward_points: RewardEventType::InviteNew.points(), + referral_count: rewards.referral_count, + points: rewards.points, + used_referral_code: rewards.referrer_username, + status, + created_at: rewards.created_at, + verify_after: rewards.verify_after.map(|dt| dt.and_utc()), + redemption_options: options, + disable_reason: rewards.disable_reason.clone(), + referral_allowance: Default::default(), + }) + } + + fn get_reward_events_by_wallet_id(&mut self, wallet_id: i32) -> Result, DatabaseError> { + let username = ensure_wallet_reward_identity(self, wallet_id)?; + let events = RewardsStore::get_events(self, &username.username)?; + Ok(events.iter().map(|e| e.as_primitive()).collect()) + } + + fn get_reward_event(&mut self, event_id: i32) -> Result { + let event = require_reward_event(self, event_id)?; + Ok(event.as_primitive()) + } + + fn get_reward_event_devices(&mut self, event_id: i32) -> Result, DatabaseError> { + let event = require_reward_event(self, event_id)?; + let username = require_username(self, UsernameLookup::Username(&event.username))?; + let devices = WalletsStore::get_devices_by_wallet_id(self, username.wallet_id)?; + Ok(devices.into_iter().map(|d| d.as_primitive()).collect()) + } + + fn create_reward(&mut self, wallet_id: i32, username: &str) -> Result<(Rewards, i32), UsernameValidationError> { + validate_username(username)?; + + if find_username(self, UsernameLookup::Username(username))?.is_some() { + return Err(UsernameValidationError::AlreadyTaken); + } + + let existing = ensure_wallet_reward_identity(self, wallet_id)?; + if existing.has_custom_username() { + return Err(UsernameValidationError::Invalid("Wallet already has a username".into())); + } + + UsernamesStore::update_username(self, wallet_id, username).or_not_found_internal(wallet_id.to_string())?; + + let event = RewardsStore::add_event( + self, + NewRewardEventRow { + username: username.to_string(), + event_type: RewardEventType::CreateUsername, + }, + RewardEventType::CreateUsername.points(), + )?; + + let rewards = self.get_reward_by_wallet_id(wallet_id)?; + Ok((rewards, event.id)) + } + + fn get_referral_code(&mut self, code: &str) -> Result, DatabaseError> { + Ok(find_username(self, UsernameLookup::Username(code))?.map(|username| username.username)) + } + + fn get_referrer_info(&mut self, username: &str) -> Result { + let username_row = require_username(self, UsernameLookup::Username(username))?; + let rewards = require_rewards(self, username)?; + Ok(ReferrerInfo { + status: *rewards.status, + referral_count: rewards.referral_count, + wallet_id: username_row.wallet_id, + }) + } + + fn is_pending_referral(&mut self, referrer_username: &str, wallet_id: i32, device_id: i32) -> Result { + let referred_name = referred_username(self, wallet_id)?; + let rewards = match require_rewards(self, &referred_name) { + Ok(r) => r, + Err(_) => return Ok(false), + }; + if *rewards.status != PrimitiveRewardStatus::Pending { + return Ok(false); + } + match ReferralsStore::get_referral_by_referred_device_id(self, device_id)? { + Some(referral) => Ok(is_matching_pending_referral_confirmation(&referral, referrer_username, &referred_name)), + None => Ok(false), + } + } + + fn validate_referral_use( + &mut self, + referrer_username: &str, + referrer_wallet_id: i32, + wallet_id: i32, + device_id: i32, + device_created_at: NaiveDateTime, + eligibility_days: i64, + ) -> Result<(), ReferralValidationError> { + let eligibility_cutoff = now() - chrono::Duration::days(eligibility_days); + + if device_created_at <= eligibility_cutoff { + return Err(ReferralValidationError::EligibilityExpired(eligibility_days)); + } + + if let Some(first_subscription_at) = WalletsStore::get_first_subscription_date_by_wallet_id(self, wallet_id)? + && first_subscription_at.is_older_than_days(eligibility_days) + { + return Err(ReferralValidationError::EligibilityExpired(eligibility_days)); + } + + let device_subscriptions = WalletsStore::get_device_addresses(self, device_id, ChainRow::from(Chain::Ethereum))?; + + for address in &device_subscriptions { + let wallet_identifier = WalletId::Multicoin(address.clone()).id(); + if let Some(wallet) = find_wallet(self, &wallet_identifier)? { + if let Some(first_subscription_at) = WalletsStore::get_first_subscription_date_by_wallet_id(self, wallet.id)? + && first_subscription_at.is_older_than_days(eligibility_days) + { + return Err(ReferralValidationError::EligibilityExpired(eligibility_days)); + } + if referrer_wallet_id == wallet.id { + return Err(ReferralValidationError::CannotReferSelf); + } + } + } + + if let Some(referral) = ReferralsStore::get_referral_by_referred_device_id(self, device_id)? { + let referred_name = referred_username(self, wallet_id)?; + if !is_matching_pending_referral_confirmation(&referral, referrer_username, &referred_name) { + return Err(ReferralValidationError::DeviceAlreadyUsed); + } + } + + Ok(()) + } + + fn add_referral_attempt(&mut self, referrer_username: &str, wallet_id: i32, device_id: i32, risk_signal_id: Option, reason: &str) -> Result<(), DatabaseError> { + ReferralsStore::add_referral_attempt( + self, + ReferralAttemptRow { + referrer_username: referrer_username.to_string(), + wallet_id, + device_id, + risk_signal_id, + reason: reason.to_string(), + }, + )?; + Ok(()) + } + + fn get_first_subscription_date_by_wallet_id(&mut self, wallet_id: i32) -> Result, DatabaseError> { + Ok(WalletsStore::get_first_subscription_date_by_wallet_id(self, wallet_id)?) + } + + fn get_wallet_id_by_username(&mut self, username: &str) -> Result { + let username = require_username(self, UsernameLookup::Username(username))?; + Ok(username.wallet_id) + } + + fn get_referrer_username(&mut self, referred_username: &str) -> Result, DatabaseError> { + let referral = ReferralsStore::get_referral_by_username(self, referred_username)?; + Ok(referral.map(|r| r.referrer_username)) + } + + fn get_address_by_username(&mut self, username: &str) -> Result { + let username_row = require_username(self, UsernameLookup::Username(username))?; + let wallet = require_wallet_by_id(self, username_row.wallet_id)?; + Ok(wallet.wallet_id.address().to_string()) + } + + fn get_status_by_username(&mut self, username: &str) -> Result { + let rewards = require_rewards(self, username)?; + Ok(*rewards.status) + } + + fn get_referral_count_by_username(&mut self, username: &str) -> Result { + let rewards = require_rewards(self, username)?; + Ok(rewards.referral_count) + } + + fn count_referrals_since(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result { + Ok(ReferralsStore::count_referrals_since(self, referrer_username, since)?) + } + + fn get_rewards_leaderboard(&mut self) -> Result { + let current = now(); + let limit = 10; + let invite_types = [RewardEventType::InviteNew]; + let points_per_referral = RewardEventType::InviteNew.points() as i64; + + let map_entry = |(username, referrals): (String, i64)| ReferralLeader { + username, + referrals: referrals as i32, + points: (referrals * points_per_referral) as i32, + }; + + let daily = RewardsStore::get_top_referrers_since(self, &invite_types, current.days_ago(1), limit)? + .into_iter() + .map(map_entry) + .collect(); + + let weekly = RewardsStore::get_top_referrers_since(self, &invite_types, current.days_ago(7), limit)? + .into_iter() + .map(map_entry) + .collect(); + + let monthly = RewardsStore::get_top_referrers_since(self, &invite_types, current.days_ago(30), limit)? + .into_iter() + .map(map_entry) + .collect(); + + Ok(ReferralLeaderboard { daily, weekly, monthly }) + } + + fn disable_rewards(&mut self, username: &str, reason: &str, comment: &str) -> Result { + Ok(RewardsStore::disable_rewards(self, username, reason, comment)?) + } + + fn get_rewards_by_filter(&mut self, filters: Vec) -> Result, DatabaseError> { + Ok(RewardsStore::get_rewards_by_filter(self, filters)?) + } + + fn check_eligibility(&mut self, username: &str, eligibility: RewardsEligibilityConfig) -> Result, DatabaseError> { + let username_row = require_username(self, UsernameLookup::Username(username))?; + let rewards = require_rewards(self, &username_row.username)?; + + if *rewards.status != PrimitiveRewardStatus::Unverified { + return Ok(None); + } + + if rewards.verify_after.is_some_and(|dt| dt > now()) { + return Ok(None); + } + + let Some(first_subscription_at) = WalletsStore::get_first_subscription_date_by_wallet_id(self, username_row.wallet_id)? else { + return Ok(None); + }; + + if first_subscription_at > eligibility.activity_cutoff { + return Ok(None); + } + + let Some(latest_activity_at) = WalletsStore::get_devices_by_wallet_id(self, username_row.wallet_id)? + .into_iter() + .map(|device| device.updated_at) + .max() + else { + return Ok(None); + }; + + if latest_activity_at < eligibility.activity_cutoff { + return Ok(None); + } + + let transactions_current = TransactionsStore::get_transactions_by_wallet_since( + self, + username_row.wallet_id, + first_subscription_at, + vec![TransactionFilter::States(vec![TransactionState::Confirmed])], + )? + .len() as i64; + + if transactions_current < eligibility.transactions_required { + return Ok(None); + } + + Ok(Some(username_row.wallet_id)) + } + + fn promote_to_verified(&mut self, username: &str) -> Result, DatabaseError> { + RewardsStore::update_rewards(self, username, RewardsUpdate::Status(RewardStatus::Verified))?; + + let enabled_event = RewardsStore::add_event( + self, + NewRewardEventRow { + username: username.to_string(), + event_type: RewardEventType::Enabled, + }, + RewardEventType::Enabled.points(), + )?; + + let mut event_ids = vec![enabled_event.id]; + event_ids.extend(complete_referral(self, username)?); + Ok(event_ids) + } + + fn use_or_verify_referral( + &mut self, + referrer_username: &str, + referrer_status: &PrimitiveRewardStatus, + referred_wallet_id: i32, + device_id: i32, + risk_signal_id: i32, + ) -> Result, DatabaseError> { + let referred_username = ensure_wallet_reward_identity(self, referred_wallet_id)?.username; + let referred_rewards = require_rewards(self, &referred_username)?; + let can_verify = can_verify_referral(&referred_rewards.status, referred_rewards.verify_after); + + if can_verify && !referred_rewards.status.is_verified() { + RewardsStore::update_rewards(self, &referred_username, RewardsUpdate::Status(RewardStatus::Unverified))?; + RewardsStore::update_rewards(self, &referred_username, RewardsUpdate::ClearVerifyAfter)?; + } + + match ReferralsStore::get_referral_by_username(self, &referred_username)? { + Some(referral) if referral.verified_at.is_none() => self.confirm_pending_referral(referral, referrer_username, &referred_username, device_id, can_verify), + Some(_) => Err(DatabaseError::Error("Referral already verified".to_string())), + None => self.create_new_referral(referrer_username, &referred_username, device_id, risk_signal_id, can_verify, referrer_status), + } + } +} + +impl DatabaseClient { + fn confirm_pending_referral( + &mut self, + referral: RewardReferralRow, + referrer_username: &str, + referred_username: &str, + device_id: i32, + can_verify: bool, + ) -> Result, DatabaseError> { + if referral.referrer_username != referrer_username { + return Err(DatabaseError::Error("Referral code does not match pending referral".to_string())); + } + if referral.referred_device_id != device_id { + return Err(DatabaseError::Error("Must verify from same device".to_string())); + } + if can_verify { + ReferralsStore::update_referral(self, referral.id, ReferralUpdate::VerifiedAt(now()))?; + add_referral_verified_events(self, &referral.referrer_username, referred_username) + } else { + Ok(vec![]) + } + } + + fn create_new_referral( + &mut self, + referrer_username: &str, + referred_username: &str, + device_id: i32, + risk_signal_id: i32, + can_verify: bool, + referrer_status: &PrimitiveRewardStatus, + ) -> Result, DatabaseError> { + let delay = referral_verification_delay(self.config(), referrer_status)?; + + if !can_verify && let Some(delay) = delay { + let verify_after = now() + chrono::Duration::seconds(delay.as_secs() as i64); + RewardsStore::update_rewards(self, referred_username, RewardsUpdate::VerifyAfter(verify_after))?; + RewardsStore::update_rewards(self, referred_username, RewardsUpdate::Status(RewardStatus::Pending))?; + } + + let skip_delay = can_verify || delay.is_none(); + let verified_at = skip_delay.then_some(now()); + add_referral_with_events(self, referrer_username, referred_username, device_id, risk_signal_id, verified_at) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::UsernameRow; + + fn username_row(username: &str, wallet_id: i32) -> UsernameRow { + UsernameRow { + username: username.to_string(), + wallet_id, + status: UsernameStatus::Unverified, + } + } + + #[test] + fn test_has_custom_username() { + assert!(username_row("alice", 1).has_custom_username()); + assert!(username_row("user1234", 1).has_custom_username()); + assert!(!username_row("0x1234567890abcdef1234567890abcdef12345678", 1).has_custom_username()); + assert!(!username_row("wallet_1", 1).has_custom_username()); + } + + #[test] + fn test_validate_username() { + assert!(validate_username("abcd").is_ok()); + assert!(validate_username("user123").is_ok()); + assert!(validate_username("1234567890123456").is_ok()); + + assert!(validate_username("abc").is_err()); + assert!(validate_username("12345678901234567").is_err()); + assert!(validate_username("user_name").is_err()); + assert!(validate_username("user-name").is_err()); + assert!(validate_username("user.name").is_err()); + assert!(validate_username("user name").is_err()); + } + + #[test] + fn test_can_verify_referral() { + assert!(can_verify_referral(&PrimitiveRewardStatus::Verified, None)); + assert!(can_verify_referral(&PrimitiveRewardStatus::Trusted, None)); + + assert!(!can_verify_referral(&PrimitiveRewardStatus::Unverified, None)); + assert!(!can_verify_referral(&PrimitiveRewardStatus::Pending, None)); + + let past = (chrono::Utc::now() - chrono::Duration::hours(1)).naive_utc(); + assert!(can_verify_referral(&PrimitiveRewardStatus::Unverified, Some(past))); + assert!(can_verify_referral(&PrimitiveRewardStatus::Pending, Some(past))); + + let future = (chrono::Utc::now() + chrono::Duration::hours(1)).naive_utc(); + assert!(!can_verify_referral(&PrimitiveRewardStatus::Unverified, Some(future))); + assert!(!can_verify_referral(&PrimitiveRewardStatus::Pending, Some(future))); + + assert!(can_verify_referral(&RewardStatus::Verified, Some(future))); + } + + #[test] + fn test_is_matching_pending_referral_confirmation() { + let now = chrono::Utc::now().naive_utc(); + let referral = RewardReferralRow { + id: 1, + referrer_username: "alice".to_string(), + referred_username: "bob".to_string(), + referred_device_id: 10, + risk_signal_id: 20, + verified_at: None, + updated_at: now, + created_at: now, + }; + + assert!(is_matching_pending_referral_confirmation(&referral, "alice", "bob")); + assert!(!is_matching_pending_referral_confirmation(&referral, "charlie", "bob")); + assert!(!is_matching_pending_referral_confirmation(&referral, "alice", "dave")); + + let verified_referral = RewardReferralRow { + verified_at: Some(now), + ..referral + }; + assert!(!is_matching_pending_referral_confirmation(&verified_referral, "alice", "bob")); + } + + #[test] + fn test_compute_verification_delay() { + let base = std::time::Duration::from_secs(86400); // 24h + + // Trusted: no delay regardless of multiplier + assert_eq!(compute_verification_delay(base, 2, &PrimitiveRewardStatus::Trusted), None); + + // Verified with multiplier 2: 24h / 2 = 12h + assert_eq!( + compute_verification_delay(base, 2, &PrimitiveRewardStatus::Verified), + Some(std::time::Duration::from_secs(43200)) + ); + + // Unverified: full delay (multiplier applied as 1 by caller) + assert_eq!(compute_verification_delay(base, 1, &PrimitiveRewardStatus::Unverified), Some(base)); + + // Multiplier 0: no delay + assert_eq!(compute_verification_delay(base, 0, &PrimitiveRewardStatus::Verified), None); + } +} diff --git a/core/crates/storage/src/repositories/risk_signals_repository.rs b/core/crates/storage/src/repositories/risk_signals_repository.rs new file mode 100644 index 0000000000..c56d81c5ac --- /dev/null +++ b/core/crates/storage/src/repositories/risk_signals_repository.rs @@ -0,0 +1,147 @@ +use crate::database::referrals::{AbusePatterns, RiskSignalsStore}; +use crate::models::{NewRiskSignalRow, RiskSignalRow}; +use crate::{DatabaseClient, DatabaseError}; +use chrono::NaiveDateTime; +use primitives::Platform; + +pub trait RiskSignalsRepository { + fn add_risk_signal(&mut self, signal: NewRiskSignalRow) -> Result; + fn has_fingerprint_for_referrer(&mut self, fingerprint: &str, referrer_username: &str, since: NaiveDateTime) -> Result; + fn get_matching_risk_signals( + &mut self, + fingerprint: &str, + ip_address: &str, + ip_isp: &str, + device_model: &str, + device_id: i32, + since: NaiveDateTime, + ) -> Result, DatabaseError>; + fn count_signals_since(&mut self, ip_address: Option<&str>, since: NaiveDateTime) -> Result; + fn count_signals_for_device_id(&mut self, device_id: i32, since: NaiveDateTime) -> Result; + fn count_signals_for_country(&mut self, country_code: &str, since: NaiveDateTime) -> Result; + fn sum_risk_scores_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result; + fn count_attempts_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result; + fn get_referrer_usernames_with_referrals(&mut self, since: NaiveDateTime, min_referrals: i64) -> Result, DatabaseError>; + fn count_unique_countries_for_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result; + fn count_unique_referrers_for_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result; + fn count_unique_referrers_for_fingerprint(&mut self, fingerprint: &str, since: NaiveDateTime) -> Result; + fn count_unique_devices_for_ip(&mut self, ip_address: &str, since: NaiveDateTime) -> Result; + fn count_unique_referrers_for_device_model_pattern( + &mut self, + device_model: &str, + device_platform: Platform, + device_locale: &str, + since: NaiveDateTime, + ) -> Result; + fn get_abuse_patterns_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime, velocity_window_secs: i64) -> Result; + fn count_disabled_users_by_ip(&mut self, ip_address: &str, since: NaiveDateTime) -> Result; + fn count_disabled_users_by_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result; + fn count_unique_countries_for_referrer(&mut self, username: &str, since: NaiveDateTime) -> Result; + fn count_unique_devices_for_referrer(&mut self, username: &str, since: NaiveDateTime) -> Result; +} + +impl RiskSignalsRepository for DatabaseClient { + fn add_risk_signal(&mut self, signal: NewRiskSignalRow) -> Result { + Ok(RiskSignalsStore::add_risk_signal(self, signal)?) + } + + fn has_fingerprint_for_referrer(&mut self, fingerprint: &str, referrer_username: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::has_fingerprint_for_referrer(self, fingerprint, referrer_username, since)?) + } + + fn get_matching_risk_signals( + &mut self, + fingerprint: &str, + ip_address: &str, + ip_isp: &str, + device_model: &str, + device_id: i32, + since: NaiveDateTime, + ) -> Result, DatabaseError> { + Ok(RiskSignalsStore::get_matching_risk_signals( + self, + fingerprint, + ip_address, + ip_isp, + device_model, + device_id, + since, + )?) + } + + fn count_signals_since(&mut self, ip_address: Option<&str>, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_signals_since(self, ip_address, since)?) + } + + fn count_signals_for_device_id(&mut self, device_id: i32, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_signals_for_device_id(self, device_id, since)?) + } + + fn count_signals_for_country(&mut self, country_code: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_signals_for_country(self, country_code, since)?) + } + + fn sum_risk_scores_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::sum_risk_scores_for_referrer(self, referrer_username, since)?) + } + + fn count_attempts_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_attempts_for_referrer(self, referrer_username, since)?) + } + + fn get_referrer_usernames_with_referrals(&mut self, since: NaiveDateTime, min_referrals: i64) -> Result, DatabaseError> { + Ok(RiskSignalsStore::get_referrer_usernames_with_referrals(self, since, min_referrals)?) + } + + fn count_unique_countries_for_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_unique_countries_for_device(self, device_id, since)?) + } + + fn count_unique_referrers_for_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_unique_referrers_for_device(self, device_id, since)?) + } + + fn count_unique_referrers_for_fingerprint(&mut self, fingerprint: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_unique_referrers_for_fingerprint(self, fingerprint, since)?) + } + + fn count_unique_devices_for_ip(&mut self, ip_address: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_unique_devices_for_ip(self, ip_address, since)?) + } + + fn count_unique_referrers_for_device_model_pattern( + &mut self, + device_model: &str, + device_platform: Platform, + device_locale: &str, + since: NaiveDateTime, + ) -> Result { + Ok(RiskSignalsStore::count_unique_referrers_for_device_model_pattern( + self, + device_model, + device_platform, + device_locale, + since, + )?) + } + + fn get_abuse_patterns_for_referrer(&mut self, referrer_username: &str, since: NaiveDateTime, velocity_window_secs: i64) -> Result { + Ok(RiskSignalsStore::get_abuse_patterns_for_referrer(self, referrer_username, since, velocity_window_secs)?) + } + + fn count_disabled_users_by_ip(&mut self, ip_address: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_disabled_users_by_ip(self, ip_address, since)?) + } + + fn count_disabled_users_by_device(&mut self, device_id: i32, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_disabled_users_by_device(self, device_id, since)?) + } + + fn count_unique_countries_for_referrer(&mut self, username: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_unique_countries_for_referrer(self, username, since)?) + } + + fn count_unique_devices_for_referrer(&mut self, username: &str, since: NaiveDateTime) -> Result { + Ok(RiskSignalsStore::count_unique_devices_for_referrer(self, username, since)?) + } +} diff --git a/core/crates/storage/src/repositories/scan_addresses_repository.rs b/core/crates/storage/src/repositories/scan_addresses_repository.rs new file mode 100644 index 0000000000..2493b5ed7f --- /dev/null +++ b/core/crates/storage/src/repositories/scan_addresses_repository.rs @@ -0,0 +1,110 @@ +use crate::database::scan_addresses::ScanAddressesStore; +use crate::models::{NewScanAddressRow, ScanAddressRow}; +use crate::sql_types::ChainRow; +use crate::{DatabaseClient, DatabaseError}; +use primitives::{Chain, ScanAddress}; +use std::collections::{HashMap, HashSet}; + +pub trait ScanAddressesRepository { + fn get_scan_address(&mut self, _chain: Chain, value: &str) -> Result; + fn get_scan_addresses(&mut self, queries: &[(Chain, &str)]) -> Result, DatabaseError>; + fn get_scan_addresses_by_addresses(&mut self, addresses: Vec) -> Result, DatabaseError>; + fn add_scan_addresses(&mut self, values: Vec) -> Result; +} + +impl ScanAddressesRepository for DatabaseClient { + fn get_scan_address(&mut self, chain: Chain, value: &str) -> Result { + let rows = ScanAddressesStore::get_scan_addresses_by_addresses(self, vec![value.to_string()])?; + select_scan_address(chain, value, rows).ok_or_else(|| DatabaseError::not_found("ScanAddress", format!("{}/{}", chain.as_ref(), value))) + } + + fn get_scan_addresses(&mut self, queries: &[(Chain, &str)]) -> Result, DatabaseError> { + let addresses = queries.iter().map(|(_, address)| (*address).to_string()).collect::>().into_iter().collect(); + let rows = ScanAddressesStore::get_scan_addresses_by_addresses(self, addresses)?; + + Ok(select_scan_addresses(queries, rows)) + } + + fn get_scan_addresses_by_addresses(&mut self, addresses: Vec) -> Result, DatabaseError> { + Ok(ScanAddressesStore::get_scan_addresses_by_addresses(self, addresses)?) + } + + fn add_scan_addresses(&mut self, values: Vec) -> Result { + let new_addresses = values.into_iter().map(NewScanAddressRow::from_primitive).collect(); + Ok(ScanAddressesStore::add_scan_addresses(self, new_addresses)?) + } +} + +fn select_scan_address(chain: Chain, address: &str, rows: Vec) -> Option { + let rows = rows.into_iter().filter(|row| row.address == address).collect::>(); + select_scan_address_row(chain, &rows) +} + +fn select_scan_addresses(queries: &[(Chain, &str)], rows: Vec) -> Vec { + let mut rows_by_address = HashMap::>::new(); + for row in rows { + rows_by_address.entry(row.address.clone()).or_default().push(row); + } + + queries + .iter() + .filter_map(|(chain, address)| rows_by_address.get(*address).and_then(|rows| select_scan_address_row(*chain, rows))) + .collect() +} + +fn select_scan_address_row(chain: Chain, rows: &[ScanAddressRow]) -> Option { + rows.iter().find(|row| row.chain.0 == chain).cloned().or_else(|| { + rows.first().cloned().map(|mut row| { + row.chain = ChainRow::from(chain); + row + }) + }) +} + +#[cfg(test)] +mod tests { + use super::{select_scan_address, select_scan_addresses}; + use crate::models::ScanAddressRow; + use primitives::Chain; + + #[test] + fn test_select_scan_address_prefers_exact_chain_match() { + let rows = vec![ + ScanAddressRow::mock(1, Chain::Ethereum, "0x123", Some("Ethereum")), + ScanAddressRow::mock(2, Chain::Arbitrum, "0x123", Some("Arbitrum")), + ]; + + let result = select_scan_address(Chain::Arbitrum, "0x123", rows).unwrap(); + + assert_eq!(result.chain.0, Chain::Arbitrum); + assert_eq!(result.name, Some("Arbitrum".to_string())); + } + + #[test] + fn test_select_scan_address_falls_back_to_other_chain() { + let rows = vec![ScanAddressRow::mock(1, Chain::Ethereum, "0x123", Some("1inch"))]; + + let result = select_scan_address(Chain::Arbitrum, "0x123", rows).unwrap(); + + assert_eq!(result.chain.0, Chain::Arbitrum); + assert_eq!(result.name, Some("1inch".to_string())); + } + + #[test] + fn test_select_scan_addresses_resolves_each_query_independently() { + let rows = vec![ + ScanAddressRow::mock(1, Chain::Ethereum, "0x123", Some("1inch")), + ScanAddressRow::mock(2, Chain::Polygon, "0x456", Some("Polygon")), + ScanAddressRow::mock(3, Chain::Arbitrum, "0x456", Some("Arbitrum")), + ]; + let queries = vec![(Chain::Arbitrum, "0x123"), (Chain::Arbitrum, "0x456")]; + + let result = select_scan_addresses(&queries, rows); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].chain.0, Chain::Arbitrum); + assert_eq!(result[0].name, Some("1inch".to_string())); + assert_eq!(result[1].chain.0, Chain::Arbitrum); + assert_eq!(result[1].name, Some("Arbitrum".to_string())); + } +} diff --git a/core/crates/storage/src/repositories/tag_repository.rs b/core/crates/storage/src/repositories/tag_repository.rs new file mode 100644 index 0000000000..f980041d5b --- /dev/null +++ b/core/crates/storage/src/repositories/tag_repository.rs @@ -0,0 +1,46 @@ +use crate::DatabaseError; + +use crate::DatabaseClient; +use crate::database::tag::TagStore; +use crate::models::{AssetTagRow, TagRow}; +use primitives::AssetId; + +pub trait TagRepository { + fn add_tags(&mut self, values: Vec) -> Result; + fn add_assets_tags(&mut self, values: Vec) -> Result; + fn get_assets_tags(&mut self) -> Result, DatabaseError>; + fn get_assets_tags_for_tag(&mut self, _tag_id: &str) -> Result, DatabaseError>; + fn delete_assets_tags(&mut self, _tag_id: &str) -> Result; + fn set_assets_tags_for_tag(&mut self, _tag_id: &str, asset_ids: Vec) -> Result; + fn get_assets_tags_for_asset(&mut self, _asset_id: &AssetId) -> Result, DatabaseError>; +} + +impl TagRepository for DatabaseClient { + fn add_tags(&mut self, values: Vec) -> Result { + Ok(TagStore::add_tags(self, values)?) + } + + fn add_assets_tags(&mut self, values: Vec) -> Result { + Ok(TagStore::add_assets_tags(self, values)?) + } + + fn get_assets_tags(&mut self) -> Result, DatabaseError> { + Ok(TagStore::get_assets_tags(self)?) + } + + fn get_assets_tags_for_tag(&mut self, _tag_id: &str) -> Result, DatabaseError> { + Ok(TagStore::get_assets_tags_for_tag(self, _tag_id)?) + } + + fn delete_assets_tags(&mut self, _tag_id: &str) -> Result { + Ok(TagStore::delete_assets_tags(self, _tag_id)?) + } + + fn set_assets_tags_for_tag(&mut self, _tag_id: &str, asset_ids: Vec) -> Result { + Ok(TagStore::set_assets_tags_for_tag(self, _tag_id, asset_ids)?) + } + + fn get_assets_tags_for_asset(&mut self, _asset_id: &AssetId) -> Result, DatabaseError> { + Ok(TagStore::get_assets_tags_for_asset(self, &_asset_id.to_string())?) + } +} diff --git a/core/crates/storage/src/repositories/transactions_repository.rs b/core/crates/storage/src/repositories/transactions_repository.rs new file mode 100644 index 0000000000..58f717ba17 --- /dev/null +++ b/core/crates/storage/src/repositories/transactions_repository.rs @@ -0,0 +1,90 @@ +use crate::database::transactions::{TransactionFilter, TransactionUpdate, TransactionsStore}; +use crate::models::{AddressChainIdResultRow, TransactionRow}; +use crate::sql_types::TransactionType; +use crate::{DatabaseClient, DatabaseError, DieselResultExt}; +use chrono::NaiveDateTime; +use primitives::{AssetId, Transaction, TransactionId}; + +pub trait TransactionsRepository { + fn get_transaction_by_id(&mut self, id: &TransactionId) -> Result; + fn get_transaction_exists(&mut self, id: &TransactionId) -> Result; + fn add_transactions(&mut self, transactions: Vec) -> Result; + fn get_transactions_by_device_id( + &mut self, + _device_id: &str, + addresses: Vec, + chains: Vec, + asset_id: Option, + from_datetime: Option, + ) -> Result, DatabaseError>; + fn get_transactions_addresses(&mut self, min_count: i64, limit: i64, since: NaiveDateTime) -> Result, DatabaseError>; + fn delete_transactions_addresses(&mut self, addresses: Vec) -> Result, DatabaseError>; + fn delete_orphaned_transactions(&mut self, candidate_ids: Vec) -> Result; + fn get_asset_usage_counts(&mut self, since: NaiveDateTime) -> Result, DatabaseError>; + fn get_transactions_by_filter(&mut self, filters: Vec, limit: i64) -> Result, DatabaseError>; + fn update_transaction(&mut self, chain: &str, hash: &str, updates: Vec) -> Result; + fn get_addresses_by_chain_and_kind(&mut self, chain: &str, kinds: Vec, since: NaiveDateTime) -> Result, DatabaseError>; +} + +impl TransactionsRepository for DatabaseClient { + fn get_transaction_by_id(&mut self, id: &TransactionId) -> Result { + TransactionsStore::get_transaction_by_id(self, id.chain.as_ref(), &id.hash).or_not_found(id.to_string()) + } + + fn get_transaction_exists(&mut self, id: &TransactionId) -> Result { + Ok(TransactionsStore::get_transaction_exists(self, id.chain.as_ref(), &id.hash)?) + } + + fn add_transactions(&mut self, transactions: Vec) -> Result { + Ok(TransactionsStore::add_transactions(self, transactions)?) + } + + fn get_transactions_by_device_id( + &mut self, + _device_id: &str, + addresses: Vec, + chains: Vec, + asset_id: Option, + from_datetime: Option, + ) -> Result, DatabaseError> { + Ok(TransactionsStore::get_transactions_by_device_id( + self, + _device_id, + addresses, + chains, + asset_id.map(|id| id.to_string()), + from_datetime, + )?) + } + + fn get_transactions_addresses(&mut self, min_count: i64, limit: i64, since: NaiveDateTime) -> Result, DatabaseError> { + Ok(TransactionsStore::get_transactions_addresses(self, min_count, limit, since)?) + } + + fn delete_transactions_addresses(&mut self, addresses: Vec) -> Result, DatabaseError> { + Ok(TransactionsStore::delete_transactions_addresses(self, addresses)?) + } + + fn delete_orphaned_transactions(&mut self, candidate_ids: Vec) -> Result { + Ok(TransactionsStore::delete_orphaned_transactions(self, candidate_ids)?) + } + + fn get_asset_usage_counts(&mut self, since: NaiveDateTime) -> Result, DatabaseError> { + Ok(TransactionsStore::get_asset_usage_counts(self, since)? + .into_iter() + .map(|(asset_id, count)| (asset_id.into(), count)) + .collect()) + } + + fn get_transactions_by_filter(&mut self, filters: Vec, limit: i64) -> Result, DatabaseError> { + Ok(TransactionsStore::get_transactions_by_filter(self, filters, limit)?) + } + + fn update_transaction(&mut self, chain: &str, hash: &str, updates: Vec) -> Result { + Ok(TransactionsStore::update_transaction(self, chain, hash, updates)?) + } + + fn get_addresses_by_chain_and_kind(&mut self, chain: &str, kinds: Vec, since: NaiveDateTime) -> Result, DatabaseError> { + Ok(TransactionsStore::get_addresses_by_chain_and_kind(self, chain, kinds, since)?) + } +} diff --git a/core/crates/storage/src/repositories/wallets_repository.rs b/core/crates/storage/src/repositories/wallets_repository.rs new file mode 100644 index 0000000000..c5eae2bd5c --- /dev/null +++ b/core/crates/storage/src/repositories/wallets_repository.rs @@ -0,0 +1,186 @@ +use crate::database::wallets::WalletsStore; +use crate::models::{DeviceRow, NewWalletAddressRow, NewWalletRow, NewWalletSubscriptionRow, SubscriptionAddressExcludeRow, WalletAddressRow, WalletRow, WalletSubscriptionRow}; +use crate::sql_types::ChainRow; +use crate::{DatabaseClient, DatabaseError, DieselResultExt}; +use primitives::{Chain, DeviceSubscription}; +use std::collections::{HashMap, HashSet}; + +pub trait WalletsRepository { + fn get_wallet(&mut self, identifier: &str) -> Result; + fn get_wallet_by_device_and_identifier(&mut self, device_id: i32, identifier: &str) -> Result; + fn get_wallet_by_id(&mut self, id: i32) -> Result; + fn get_wallets(&mut self, identifiers: Vec) -> Result, DatabaseError>; + fn create_wallets(&mut self, wallets: Vec) -> Result; + fn get_or_create_wallet(&mut self, wallet: NewWalletRow) -> Result; + fn get_subscriptions(&mut self, device_id: i32) -> Result, DatabaseError>; + fn get_subscriptions_by_wallet_id(&mut self, device_id: i32, wallet_id: i32) -> Result, DatabaseError>; + fn subscriptions_wallet_address_for_chain(&mut self, device_id: i32, wallet_id: i32, chain: Chain) -> Result; + fn get_devices_by_wallet_id(&mut self, wallet_id: i32) -> Result, DatabaseError>; + fn add_subscriptions(&mut self, device_id: i32, subscriptions: Vec<(i32, Chain, String)>) -> Result; + fn delete_subscriptions(&mut self, device_id: i32, subscriptions: Vec<(i32, Chain, String)>) -> Result; + fn delete_wallet_subscriptions(&mut self, device_id: i32, wallet_ids: Vec) -> Result; + fn delete_wallet_chains(&mut self, device_id: i32, wallet_id: i32, chains: Vec) -> Result; + + fn get_subscriptions_by_chain_addresses(&mut self, chain: Chain, addresses: Vec) -> Result, DatabaseError>; + fn get_subscription_address_exists(&mut self, chain: Chain, address: &str) -> Result; + fn add_subscriptions_exclude_addresses(&mut self, values: Vec) -> Result; + fn get_subscriptions_exclude_addresses(&mut self, addresses: Vec) -> Result, DatabaseError>; + fn get_addresses(&mut self, addresses: Vec) -> Result, DatabaseError>; +} + +impl WalletsRepository for DatabaseClient { + fn get_wallet(&mut self, identifier: &str) -> Result { + WalletsStore::get_wallet(self, identifier).or_not_found(identifier.to_string()) + } + + fn get_wallet_by_device_and_identifier(&mut self, device_id: i32, identifier: &str) -> Result { + WalletsStore::get_wallet_by_device_and_identifier(self, device_id, identifier).or_not_found(identifier.to_string()) + } + + fn get_wallet_by_id(&mut self, id: i32) -> Result { + WalletsStore::get_wallet_by_id(self, id).or_not_found_internal(id.to_string()) + } + + fn get_wallets(&mut self, identifiers: Vec) -> Result, DatabaseError> { + Ok(WalletsStore::get_wallets(self, identifiers)?) + } + + fn create_wallets(&mut self, wallets: Vec) -> Result { + Ok(WalletsStore::create_wallets(self, wallets)?) + } + + fn get_or_create_wallet(&mut self, wallet: NewWalletRow) -> Result { + match WalletsStore::get_wallet(self, &wallet.identifier) { + Ok(existing) => Ok(existing), + Err(diesel::result::Error::NotFound) => Ok(WalletsStore::create_wallet(self, wallet)?), + Err(error) => Err(error.into()), + } + } + + fn get_subscriptions(&mut self, device_id: i32) -> Result, DatabaseError> { + Ok(WalletsStore::get_subscriptions_by_device_id(self, device_id)?) + } + + fn get_subscriptions_by_wallet_id(&mut self, device_id: i32, wallet_id: i32) -> Result, DatabaseError> { + Ok(WalletsStore::get_subscriptions_by_device_and_wallet(self, device_id, wallet_id)?) + } + + fn subscriptions_wallet_address_for_chain(&mut self, device_id: i32, wallet_id: i32, chain: Chain) -> Result { + WalletsStore::subscriptions_wallet_address_for_chain(self, device_id, wallet_id, ChainRow::from(chain)).or_not_found_for::(chain.as_ref().to_string()) + } + + fn get_devices_by_wallet_id(&mut self, wallet_id: i32) -> Result, DatabaseError> { + Ok(WalletsStore::get_devices_by_wallet_id(self, wallet_id)?) + } + + fn add_subscriptions(&mut self, device_id: i32, subscriptions: Vec<(i32, Chain, String)>) -> Result { + if subscriptions.is_empty() { + return Ok(0); + } + + let all_addresses: Vec = subscriptions.iter().map(|(_, _, addr)| addr.clone()).collect::>().into_iter().collect(); + + let existing_rows = WalletsStore::get_addresses(self, all_addresses.clone())?; + let existing_set: HashSet = existing_rows.iter().map(|row| row.address.clone()).collect(); + + let missing_addresses: Vec = all_addresses + .iter() + .filter(|addr| !existing_set.contains(*addr)) + .map(|address| NewWalletAddressRow { address: address.clone() }) + .collect(); + + let new_rows = if missing_addresses.is_empty() { + vec![] + } else { + let missing_strs: Vec = missing_addresses.iter().map(|a| a.address.clone()).collect(); + WalletsStore::add_addresses(self, missing_addresses)?; + WalletsStore::get_addresses(self, missing_strs)? + }; + + let address_map: HashMap = existing_rows.into_iter().chain(new_rows).map(|row| (row.address, row.id)).collect(); + + let rows: Vec = subscriptions + .into_iter() + .filter_map(|(wallet_id, chain, address)| { + address_map.get(&address).map(|&address_id| NewWalletSubscriptionRow { + wallet_id, + device_id, + chain: ChainRow::from(chain), + address_id, + }) + }) + .collect(); + + if rows.is_empty() { + return Ok(0); + } + + Ok(WalletsStore::add_subscriptions(self, rows)?) + } + + fn delete_subscriptions(&mut self, device_id: i32, subscriptions: Vec<(i32, Chain, String)>) -> Result { + if subscriptions.is_empty() { + return Ok(0); + } + + let all_addresses: Vec = subscriptions.iter().map(|(_, _, addr)| addr.clone()).collect::>().into_iter().collect(); + + let address_rows = WalletsStore::get_addresses(self, all_addresses)?; + let address_map: HashMap = address_rows.into_iter().map(|row| (row.address, row.id)).collect(); + + let mut grouped: HashMap<(i32, Chain), Vec> = HashMap::new(); + for (wallet_id, chain, address) in subscriptions { + if let Some(&address_id) = address_map.get(&address) { + grouped.entry((wallet_id, chain)).or_default().push(address_id); + } + } + + if grouped.is_empty() { + return Ok(0); + } + + let mut count = 0; + for ((wallet_id, chain), address_ids) in grouped { + count += WalletsStore::delete_subscriptions(self, device_id, wallet_id, ChainRow::from(chain), address_ids)?; + } + + Ok(count) + } + + fn delete_wallet_subscriptions(&mut self, device_id: i32, wallet_ids: Vec) -> Result { + Ok(WalletsStore::delete_wallet_subscriptions(self, device_id, wallet_ids)?) + } + + fn delete_wallet_chains(&mut self, device_id: i32, wallet_id: i32, chains: Vec) -> Result { + Ok(WalletsStore::delete_wallet_chains(self, device_id, wallet_id, chains)?) + } + + fn get_subscriptions_by_chain_addresses(&mut self, chain: Chain, addresses: Vec) -> Result, DatabaseError> { + Ok(WalletsStore::get_subscriptions_by_chain_addresses(self, chain, addresses)? + .into_iter() + .map(|(wallet, sub, addr, device)| DeviceSubscription { + wallet_row_id: wallet.id, + device: device.as_primitive(), + wallet_id: wallet.wallet_id.0.clone(), + chain: sub.chain.0, + address: addr.address, + }) + .collect()) + } + + fn get_subscription_address_exists(&mut self, chain: Chain, address: &str) -> Result { + Ok(WalletsStore::get_subscription_address_exists(self, chain, address)?) + } + + fn add_subscriptions_exclude_addresses(&mut self, values: Vec) -> Result { + Ok(WalletsStore::add_subscriptions_exclude_addresses(self, values)?) + } + + fn get_subscriptions_exclude_addresses(&mut self, addresses: Vec) -> Result, DatabaseError> { + Ok(WalletsStore::get_subscriptions_exclude_addresses(self, addresses)?) + } + + fn get_addresses(&mut self, addresses: Vec) -> Result, DatabaseError> { + Ok(WalletsStore::get_addresses(self, addresses)?) + } +} diff --git a/core/crates/storage/src/repositories/webhooks_repository.rs b/core/crates/storage/src/repositories/webhooks_repository.rs new file mode 100644 index 0000000000..ffd59a0b57 --- /dev/null +++ b/core/crates/storage/src/repositories/webhooks_repository.rs @@ -0,0 +1,19 @@ +use crate::database::webhooks::WebhooksStore; +use crate::models::NewWebhookEndpointRow; +use crate::{DatabaseClient, DatabaseError}; +use primitives::WebhookKind; + +pub trait WebhooksRepository { + fn add_webhook_endpoints(&mut self, values: Vec) -> Result; + fn get_webhook_endpoint(&mut self, kind: WebhookKind, sender: &str, secret: &str) -> Result, DatabaseError>; +} + +impl WebhooksRepository for DatabaseClient { + fn add_webhook_endpoints(&mut self, values: Vec) -> Result { + Ok(WebhooksStore::add_webhook_endpoints(self, values)?) + } + + fn get_webhook_endpoint(&mut self, kind: WebhookKind, sender: &str, secret: &str) -> Result, DatabaseError> { + Ok(WebhooksStore::get_webhook_endpoint(self, kind, sender, secret)?) + } +} diff --git a/core/crates/storage/src/schema.rs b/core/crates/storage/src/schema.rs new file mode 100644 index 0000000000..711ee18da3 --- /dev/null +++ b/core/crates/storage/src/schema.rs @@ -0,0 +1,1021 @@ +// @generated automatically by Diesel CLI. + +pub mod sql_types { + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "address_type"))] + pub struct AddressType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "asset_type"))] + pub struct AssetType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "fiat_transaction_status"))] + pub struct FiatTransactionStatus; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "fiat_transaction_type"))] + pub struct FiatTransactionType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "ip_usage_type"))] + pub struct IpUsageType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "link_type"))] + pub struct LinkType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "nft_type"))] + pub struct NftType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "notification_type"))] + pub struct NotificationType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "platform"))] + pub struct Platform; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "platform_store"))] + pub struct PlatformStore; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "redemption_status"))] + pub struct RedemptionStatus; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "reward_event_type"))] + pub struct RewardEventType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "reward_redemption_type"))] + pub struct RewardRedemptionType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "reward_status"))] + pub struct RewardStatus; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "transaction_state"))] + pub struct TransactionState; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "transaction_type"))] + pub struct TransactionType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "username_status"))] + pub struct UsernameStatus; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "wallet_source"))] + pub struct WalletSource; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "wallet_type"))] + pub struct WalletType; + + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "webhook_kind"))] + pub struct WebhookKind; +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::AssetType; + + assets (id) { + #[max_length = 128] + id -> Varchar, + #[max_length = 32] + chain -> Varchar, + #[max_length = 128] + token_id -> Nullable, + asset_type -> AssetType, + #[max_length = 128] + name -> Varchar, + #[max_length = 32] + symbol -> Varchar, + decimals -> Int4, + updated_at -> Timestamp, + created_at -> Timestamp, + rank -> Int4, + is_enabled -> Bool, + is_buyable -> Bool, + is_sellable -> Bool, + is_swappable -> Bool, + is_stakeable -> Bool, + staking_apr -> Nullable, + is_earnable -> Bool, + earn_apr -> Nullable, + has_image -> Bool, + has_price -> Bool, + circulating_supply -> Nullable, + total_supply -> Nullable, + max_supply -> Nullable, + } +} + +diesel::table! { + assets_addresses (id) { + id -> Int4, + #[max_length = 32] + chain -> Varchar, + #[max_length = 256] + asset_id -> Varchar, + #[max_length = 256] + address -> Varchar, + #[max_length = 256] + value -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::LinkType; + + assets_links (id) { + id -> Int4, + #[max_length = 128] + asset_id -> Varchar, + link_type -> LinkType, + #[max_length = 256] + url -> Varchar, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + assets_tags (asset_id, tag_id) { + #[max_length = 128] + asset_id -> Varchar, + #[max_length = 64] + tag_id -> Varchar, + order -> Nullable, + created_at -> Timestamp, + } +} + +diesel::table! { + assets_usage_ranks (asset_id) { + #[max_length = 128] + asset_id -> Varchar, + usage_rank -> Int4, + updated_at -> Timestamp, + } +} + +diesel::table! { + chains (id) { + #[max_length = 32] + id -> Varchar, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + charts (coin_id, created_at) { + #[max_length = 255] + coin_id -> Varchar, + price -> Float8, + created_at -> Timestamp, + } +} + +diesel::table! { + charts_daily (coin_id, created_at) { + #[max_length = 255] + coin_id -> Varchar, + price -> Float8, + created_at -> Timestamp, + } +} + +diesel::table! { + charts_hourly (coin_id, created_at) { + #[max_length = 255] + coin_id -> Varchar, + price -> Float8, + created_at -> Timestamp, + } +} + +diesel::table! { + config (key) { + #[max_length = 64] + key -> Varchar, + #[max_length = 256] + value -> Varchar, + #[max_length = 256] + default_value -> Varchar, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::Platform; + use super::sql_types::PlatformStore; + + devices (id) { + id -> Int4, + #[max_length = 64] + device_id -> Varchar, + is_push_enabled -> Bool, + platform -> Platform, + platform_store -> PlatformStore, + #[max_length = 256] + token -> Varchar, + #[max_length = 8] + locale -> Varchar, + #[max_length = 8] + version -> Varchar, + updated_at -> Timestamp, + created_at -> Timestamp, + #[max_length = 8] + currency -> Varchar, + subscriptions_version -> Int4, + is_price_alerts_enabled -> Bool, + #[max_length = 64] + os -> Varchar, + #[max_length = 128] + model -> Varchar, + } +} + +diesel::table! { + fiat_assets (id) { + #[max_length = 128] + id -> Varchar, + #[max_length = 128] + asset_id -> Nullable, + #[max_length = 128] + provider -> Varchar, + #[max_length = 128] + code -> Varchar, + #[max_length = 128] + symbol -> Varchar, + #[max_length = 128] + network -> Nullable, + #[max_length = 128] + token_id -> Nullable, + is_enabled -> Bool, + is_enabled_by_provider -> Bool, + is_buy_enabled -> Bool, + is_sell_enabled -> Bool, + unsupported_countries -> Nullable, + buy_limits -> Nullable, + sell_limits -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + fiat_providers (id) { + #[max_length = 32] + id -> Varchar, + #[max_length = 32] + name -> Varchar, + enabled -> Bool, + buy_enabled -> Bool, + sell_enabled -> Bool, + priority -> Nullable, + priority_threshold_bps -> Nullable, + payment_methods -> Jsonb, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + fiat_providers_countries (id) { + #[max_length = 32] + id -> Varchar, + #[max_length = 128] + provider -> Varchar, + #[max_length = 32] + alpha2 -> Varchar, + is_allowed -> Bool, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + fiat_rates (id) { + #[max_length = 8] + id -> Varchar, + name -> Varchar, + rate -> Float8, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::FiatTransactionStatus; + use super::sql_types::FiatTransactionType; + + fiat_transactions (id) { + id -> Int4, + #[max_length = 128] + provider_id -> Varchar, + #[max_length = 128] + asset_id -> Varchar, + #[max_length = 128] + quote_id -> Varchar, + device_id -> Int4, + wallet_id -> Int4, + fiat_amount -> Float8, + #[max_length = 32] + fiat_currency -> Varchar, + #[max_length = 256] + value -> Nullable, + status -> FiatTransactionStatus, + #[max_length = 256] + country -> Nullable, + #[max_length = 256] + provider_transaction_id -> Nullable, + #[max_length = 256] + transaction_hash -> Nullable, + address_id -> Int4, + transaction_type -> FiatTransactionType, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::NftType; + + nft_assets (id) { + id -> Int4, + #[max_length = 512] + identifier -> Varchar, + collection_id -> Int4, + #[max_length = 64] + chain -> Varchar, + #[max_length = 1024] + name -> Varchar, + #[max_length = 4096] + description -> Varchar, + #[max_length = 512] + image_preview_url -> Nullable, + #[max_length = 64] + image_preview_mime_type -> Nullable, + #[max_length = 512] + resource_url -> Nullable, + #[max_length = 64] + resource_mime_type -> Nullable, + token_type -> NftType, + #[max_length = 512] + token_id -> Varchar, + #[max_length = 512] + contract_address -> Varchar, + attributes -> Jsonb, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + nft_assets_associations (id) { + id -> Int4, + address_id -> Int4, + asset_id -> Int4, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + nft_collections (id) { + id -> Int4, + #[max_length = 512] + identifier -> Varchar, + #[max_length = 64] + chain -> Varchar, + #[max_length = 1024] + name -> Varchar, + #[max_length = 4096] + description -> Varchar, + #[max_length = 128] + symbol -> Nullable, + #[max_length = 128] + owner -> Nullable, + #[max_length = 128] + contract_address -> Varchar, + #[max_length = 512] + image_preview_url -> Nullable, + #[max_length = 64] + image_preview_mime_type -> Nullable, + is_verified -> Bool, + is_enabled -> Bool, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::LinkType; + + nft_collections_links (id) { + id -> Int4, + collection_id -> Int4, + link_type -> LinkType, + #[max_length = 256] + url -> Varchar, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + nft_reports (id) { + id -> Int4, + asset_id -> Nullable, + collection_id -> Int4, + device_id -> Int4, + #[max_length = 1024] + reason -> Nullable, + reviewed -> Bool, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::NotificationType; + + notifications (id) { + id -> Int4, + wallet_id -> Int4, + asset_id -> Nullable, + notification_type -> NotificationType, + is_read -> Bool, + metadata -> Nullable, + read_at -> Nullable, + created_at -> Timestamp, + } +} + +diesel::table! { + parser_state (chain) { + chain -> Varchar, + current_block -> Int8, + latest_block -> Int8, + await_blocks -> Int4, + timeout_between_blocks -> Int4, + timeout_latest_block -> Int4, + parallel_blocks -> Int4, + is_enabled -> Bool, + updated_at -> Timestamp, + created_at -> Timestamp, + queue_behind_blocks -> Nullable, + block_time -> Int4, + } +} + +diesel::table! { + perpetuals (id) { + #[max_length = 128] + id -> Varchar, + #[max_length = 128] + name -> Varchar, + #[max_length = 32] + provider -> Varchar, + #[max_length = 256] + asset_id -> Varchar, + #[max_length = 128] + identifier -> Varchar, + price -> Float8, + price_percent_change_24h -> Float8, + open_interest -> Float8, + volume_24h -> Float8, + funding -> Float8, + leverage -> Array>, + is_isolated_only -> Bool, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + perpetuals_assets (id) { + id -> Int4, + #[max_length = 128] + perpetual_id -> Varchar, + #[max_length = 256] + asset_id -> Varchar, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + price_alerts (id) { + id -> Int4, + #[max_length = 512] + identifier -> Varchar, + device_id -> Int4, + #[max_length = 128] + asset_id -> Varchar, + #[max_length = 128] + currency -> Varchar, + #[max_length = 16] + price_direction -> Nullable, + price -> Nullable, + price_percent_change -> Nullable, + last_notified_at -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + prices (id) { + #[max_length = 256] + id -> Varchar, + #[max_length = 32] + provider -> Varchar, + price -> Float8, + price_change_percentage_24h -> Nullable, + market_cap_rank -> Nullable, + last_updated_at -> Timestamp, + updated_at -> Timestamp, + created_at -> Timestamp, + all_time_high_date -> Nullable, + all_time_low_date -> Nullable, + all_time_high -> Float8, + all_time_low -> Float8, + total_volume -> Nullable, + } +} + +diesel::table! { + prices_assets (asset_id, provider) { + #[max_length = 256] + asset_id -> Varchar, + #[max_length = 256] + price_id -> Varchar, + #[max_length = 32] + provider -> Varchar, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + prices_providers (id) { + #[max_length = 32] + id -> Varchar, + enabled -> Bool, + priority -> Int4, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::PlatformStore; + + releases (platform_store) { + platform_store -> PlatformStore, + #[max_length = 32] + version -> Varchar, + upgrade_required -> Bool, + update_enabled -> Bool, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::RewardStatus; + + rewards (username) { + #[max_length = 64] + username -> Varchar, + status -> RewardStatus, + #[max_length = 32] + level -> Nullable, + points -> Int4, + #[max_length = 64] + referrer_username -> Nullable, + referral_count -> Int4, + device_id -> Int4, + #[max_length = 512] + comment -> Nullable, + #[max_length = 256] + disable_reason -> Nullable, + verify_after -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::RewardEventType; + + rewards_events (id) { + id -> Int4, + #[max_length = 64] + username -> Varchar, + event_type -> RewardEventType, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::RewardRedemptionType; + + rewards_redemption_options (id) { + #[max_length = 64] + id -> Varchar, + redemption_type -> RewardRedemptionType, + points -> Int4, + #[max_length = 128] + asset_id -> Nullable, + #[max_length = 64] + value -> Varchar, + remaining -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::RedemptionStatus; + + rewards_redemptions (id) { + id -> Int4, + #[max_length = 64] + username -> Varchar, + #[max_length = 64] + option_id -> Varchar, + device_id -> Int4, + wallet_id -> Int4, + #[max_length = 512] + transaction_id -> Nullable, + status -> RedemptionStatus, + #[max_length = 1024] + error -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + rewards_referral_attempts (id) { + id -> Int4, + #[max_length = 64] + referrer_username -> Varchar, + wallet_id -> Int4, + device_id -> Int4, + risk_signal_id -> Nullable, + #[max_length = 256] + reason -> Varchar, + created_at -> Timestamp, + } +} + +diesel::table! { + rewards_referrals (id) { + id -> Int4, + #[max_length = 64] + referrer_username -> Varchar, + #[max_length = 64] + referred_username -> Varchar, + referred_device_id -> Int4, + risk_signal_id -> Int4, + verified_at -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::Platform; + use super::sql_types::PlatformStore; + use super::sql_types::IpUsageType; + + rewards_risk_signals (id) { + id -> Int4, + #[max_length = 64] + fingerprint -> Varchar, + #[max_length = 64] + referrer_username -> Varchar, + device_id -> Int4, + device_platform -> Platform, + device_platform_store -> PlatformStore, + #[max_length = 32] + device_os -> Varchar, + #[max_length = 64] + device_model -> Varchar, + #[max_length = 16] + device_locale -> Varchar, + #[max_length = 8] + device_currency -> Varchar, + #[max_length = 45] + ip_address -> Varchar, + #[max_length = 2] + ip_country_code -> Varchar, + ip_usage_type -> IpUsageType, + #[max_length = 128] + ip_isp -> Varchar, + ip_abuse_score -> Int4, + risk_score -> Int4, + #[max_length = 256] + user_agent -> Varchar, + metadata -> Nullable, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::AddressType; + + scan_addresses (id) { + id -> Int4, + chain -> Varchar, + #[max_length = 128] + address -> Varchar, + #[max_length = 128] + name -> Nullable, + #[sql_name = "type"] + type_ -> AddressType, + is_verified -> Bool, + is_fraudulent -> Bool, + is_memo_required -> Bool, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + subscriptions_addresses_exclude (address) { + #[max_length = 128] + address -> Varchar, + #[max_length = 32] + chain -> Varchar, + #[max_length = 64] + name -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + tags (id) { + #[max_length = 64] + id -> Varchar, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::TransactionState; + use super::sql_types::TransactionType; + + transactions (id) { + id -> Int8, + #[max_length = 16] + chain -> Varchar, + #[max_length = 128] + hash -> Varchar, + #[max_length = 256] + from_address -> Nullable, + #[max_length = 256] + to_address -> Nullable, + #[max_length = 256] + memo -> Nullable, + state -> TransactionState, + kind -> TransactionType, + #[max_length = 256] + value -> Nullable, + asset_id -> Varchar, + #[max_length = 32] + fee -> Nullable, + utxo_inputs -> Nullable, + utxo_outputs -> Nullable, + fee_asset_id -> Varchar, + metadata -> Nullable, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::table! { + transactions_addresses (id) { + id -> Int4, + transaction_id -> Int8, + #[max_length = 256] + asset_id -> Varchar, + #[max_length = 256] + address -> Varchar, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::UsernameStatus; + + usernames (username) { + #[max_length = 64] + username -> Varchar, + wallet_id -> Int4, + status -> UsernameStatus, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::WalletType; + use super::sql_types::WalletSource; + + wallets (id) { + id -> Int4, + #[max_length = 128] + identifier -> Varchar, + wallet_type -> WalletType, + source -> WalletSource, + created_at -> Timestamp, + } +} + +diesel::table! { + wallets_addresses (id) { + id -> Int4, + #[max_length = 256] + address -> Varchar, + } +} + +diesel::table! { + wallets_subscriptions (id) { + id -> Int4, + wallet_id -> Int4, + device_id -> Int4, + #[max_length = 32] + chain -> Varchar, + address_id -> Int4, + created_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::WebhookKind; + + webhook_endpoints (kind, sender) { + kind -> WebhookKind, + #[max_length = 128] + sender -> Varchar, + #[max_length = 64] + secret -> Varchar, + enabled -> Bool, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::joinable!(assets -> chains (chain)); +diesel::joinable!(assets_addresses -> assets (asset_id)); +diesel::joinable!(assets_addresses -> chains (chain)); +diesel::joinable!(assets_links -> assets (asset_id)); +diesel::joinable!(assets_tags -> assets (asset_id)); +diesel::joinable!(assets_tags -> tags (tag_id)); +diesel::joinable!(assets_usage_ranks -> assets (asset_id)); +diesel::joinable!(charts -> prices (coin_id)); +diesel::joinable!(charts_daily -> prices (coin_id)); +diesel::joinable!(charts_hourly -> prices (coin_id)); +diesel::joinable!(devices -> fiat_rates (currency)); +diesel::joinable!(fiat_assets -> assets (asset_id)); +diesel::joinable!(fiat_assets -> fiat_providers (provider)); +diesel::joinable!(fiat_providers_countries -> fiat_providers (provider)); +diesel::joinable!(fiat_transactions -> assets (asset_id)); +diesel::joinable!(fiat_transactions -> devices (device_id)); +diesel::joinable!(fiat_transactions -> fiat_providers (provider_id)); +diesel::joinable!(fiat_transactions -> wallets (wallet_id)); +diesel::joinable!(fiat_transactions -> wallets_addresses (address_id)); +diesel::joinable!(nft_assets -> chains (chain)); +diesel::joinable!(nft_assets -> nft_collections (collection_id)); +diesel::joinable!(nft_assets_associations -> nft_assets (asset_id)); +diesel::joinable!(nft_assets_associations -> wallets_addresses (address_id)); +diesel::joinable!(nft_collections -> chains (chain)); +diesel::joinable!(nft_collections_links -> nft_collections (collection_id)); +diesel::joinable!(nft_reports -> devices (device_id)); +diesel::joinable!(nft_reports -> nft_assets (asset_id)); +diesel::joinable!(nft_reports -> nft_collections (collection_id)); +diesel::joinable!(notifications -> assets (asset_id)); +diesel::joinable!(notifications -> wallets (wallet_id)); +diesel::joinable!(parser_state -> chains (chain)); +diesel::joinable!(perpetuals -> assets (asset_id)); +diesel::joinable!(perpetuals_assets -> assets (asset_id)); +diesel::joinable!(perpetuals_assets -> perpetuals (perpetual_id)); +diesel::joinable!(price_alerts -> assets (asset_id)); +diesel::joinable!(price_alerts -> devices (device_id)); +diesel::joinable!(price_alerts -> fiat_rates (currency)); +diesel::joinable!(prices -> prices_providers (provider)); +diesel::joinable!(prices_assets -> assets (asset_id)); +diesel::joinable!(prices_assets -> prices (price_id)); +diesel::joinable!(prices_assets -> prices_providers (provider)); +diesel::joinable!(rewards -> devices (device_id)); +diesel::joinable!(rewards -> usernames (username)); +diesel::joinable!(rewards_events -> usernames (username)); +diesel::joinable!(rewards_redemption_options -> assets (asset_id)); +diesel::joinable!(rewards_redemptions -> devices (device_id)); +diesel::joinable!(rewards_redemptions -> rewards (username)); +diesel::joinable!(rewards_redemptions -> rewards_redemption_options (option_id)); +diesel::joinable!(rewards_redemptions -> wallets (wallet_id)); +diesel::joinable!(rewards_referral_attempts -> devices (device_id)); +diesel::joinable!(rewards_referral_attempts -> rewards (referrer_username)); +diesel::joinable!(rewards_referral_attempts -> rewards_risk_signals (risk_signal_id)); +diesel::joinable!(rewards_referral_attempts -> wallets (wallet_id)); +diesel::joinable!(rewards_referrals -> devices (referred_device_id)); +diesel::joinable!(rewards_referrals -> rewards_risk_signals (risk_signal_id)); +diesel::joinable!(rewards_risk_signals -> devices (device_id)); +diesel::joinable!(rewards_risk_signals -> rewards (referrer_username)); +diesel::joinable!(scan_addresses -> chains (chain)); +diesel::joinable!(subscriptions_addresses_exclude -> chains (chain)); +diesel::joinable!(transactions -> chains (chain)); +diesel::joinable!(transactions_addresses -> assets (asset_id)); +diesel::joinable!(transactions_addresses -> transactions (transaction_id)); +diesel::joinable!(usernames -> wallets (wallet_id)); +diesel::joinable!(wallets_subscriptions -> chains (chain)); +diesel::joinable!(wallets_subscriptions -> devices (device_id)); +diesel::joinable!(wallets_subscriptions -> wallets (wallet_id)); +diesel::joinable!(wallets_subscriptions -> wallets_addresses (address_id)); + +diesel::allow_tables_to_appear_in_same_query!( + assets, + assets_addresses, + assets_links, + assets_tags, + assets_usage_ranks, + chains, + charts, + charts_daily, + charts_hourly, + config, + devices, + fiat_assets, + fiat_providers, + fiat_providers_countries, + fiat_rates, + fiat_transactions, + nft_assets, + nft_assets_associations, + nft_collections, + nft_collections_links, + nft_reports, + notifications, + parser_state, + perpetuals, + perpetuals_assets, + price_alerts, + prices, + prices_assets, + prices_providers, + releases, + rewards, + rewards_events, + rewards_redemption_options, + rewards_redemptions, + rewards_referral_attempts, + rewards_referrals, + rewards_risk_signals, + scan_addresses, + subscriptions_addresses_exclude, + tags, + transactions, + transactions_addresses, + usernames, + wallets, + wallets_addresses, + wallets_subscriptions, + webhook_endpoints, +); diff --git a/core/crates/storage/src/sql_types.rs b/core/crates/storage/src/sql_types.rs new file mode 100644 index 0000000000..8b2a42ee75 --- /dev/null +++ b/core/crates/storage/src/sql_types.rs @@ -0,0 +1,341 @@ +use diesel::deserialize::{self, FromSql, FromSqlRow}; +use diesel::expression::AsExpression; +use diesel::pg::{Pg, PgValue}; +use diesel::serialize::{self, Output, ToSql}; +use primitives::AssetId as PrimitiveAssetId; +use primitives::nft::NFTType as PrimitiveNFTType; +use primitives::rewards::{ + RedemptionStatus as PrimitiveRedemptionStatus, RewardEventType as PrimitiveRewardEventType, RewardRedemptionType as PrimitiveRewardRedemptionType, + RewardStatus as PrimitiveRewardStatus, +}; +use primitives::scan::AddressType as PrimitiveAddressType; +use primitives::{ + AssetType as PrimitiveAssetType, Chain, FiatProviderName as PrimitiveFiatProviderName, FiatQuoteType as PrimitiveFiatQuoteType, + FiatTransactionStatus as PrimitiveFiatTransactionStatus, IpUsageType as PrimitiveIpUsageType, LinkType as PrimitiveLinkType, NotificationType as PrimitiveNotificationType, + PerpetualProvider as PrimitivePerpetualProvider, Platform as PrimitivePlatform, PlatformStore as PrimitivePlatformStore, PriceAlertDirection as PrimitivePriceAlertDirection, + PriceId as PrimitivePriceId, PriceProvider as PrimitivePriceProvider, TransactionState as PrimitiveTransactionState, TransactionType as PrimitiveTransactionType, + UsernameStatus as PrimitiveUsernameStatus, WalletSource as PrimitiveWalletSource, WalletType as PrimitiveWalletType, WebhookKind as PrimitiveWebhookKind, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::io::Write; +use std::ops::Deref; +use std::str::FromStr; + +use crate::schema::sql_types::{ + AddressType as AddressTypeSql, AssetType as AssetTypeSql, FiatTransactionStatus as FiatTransactionStatusSql, FiatTransactionType as FiatTransactionTypeSql, + IpUsageType as IpUsageTypeSql, LinkType as LinkTypeSql, NftType as NftTypeSql, NotificationType as NotificationTypeSql, Platform as PlatformSql, + PlatformStore as PlatformStoreSql, RedemptionStatus as RedemptionStatusSql, RewardEventType as RewardEventTypeSql, RewardRedemptionType as RewardRedemptionTypeSql, + RewardStatus as RewardStatusSql, TransactionState as TransactionStateSql, TransactionType as TransactionTypeSql, UsernameStatus as UsernameStatusSql, + WalletSource as WalletSourceSql, WalletType as WalletTypeSql, WebhookKind as WebhookKindSql, +}; + +macro_rules! diesel_enum { + ($wrapper:ident, $inner:ty, $sql_type:ty, [$($variant:ident),+ $(,)?]) => { + #[derive(Debug, Clone, Serialize, Deserialize, AsExpression, FromSqlRow)] + #[serde(transparent)] + #[diesel(sql_type = $sql_type)] + pub struct $wrapper(pub $inner); + + #[allow(non_upper_case_globals)] + impl $wrapper { + $(pub const $variant: Self = Self(<$inner>::$variant);)+ + } + + impl Deref for $wrapper { + type Target = $inner; + fn deref(&self) -> &Self::Target { &self.0 } + } + + impl From<$inner> for $wrapper { + fn from(v: $inner) -> Self { Self(v) } + } + + impl From<$wrapper> for $inner { + fn from(w: $wrapper) -> Self { w.0 } + } + + impl FromSql<$sql_type, Pg> for $wrapper { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let s = std::str::from_utf8(bytes.as_bytes())?; + Ok(Self(<$inner>::from_str(s).map_err(|e| format!("Invalid {}: {}", stringify!($wrapper), e))?)) + } + } + + impl ToSql<$sql_type, Pg> for $wrapper { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.0.as_ref().as_bytes())?; + Ok(serialize::IsNull::No) + } + } + }; +} + +diesel_enum!(RewardStatus, PrimitiveRewardStatus, RewardStatusSql, [Unverified, Pending, Verified, Trusted, Disabled]); + +diesel_enum!(RewardRedemptionType, PrimitiveRewardRedemptionType, RewardRedemptionTypeSql, [Asset, GiftAsset]); + +diesel_enum!(RedemptionStatus, PrimitiveRedemptionStatus, RedemptionStatusSql, [Pending, Processing, Completed, Failed]); + +diesel_enum!( + TransactionType, + PrimitiveTransactionType, + TransactionTypeSql, + [ + Transfer, + TransferNFT, + Swap, + TokenApproval, + StakeDelegate, + StakeUndelegate, + StakeRewards, + StakeRedelegate, + StakeWithdraw, + StakeFreeze, + StakeUnfreeze, + AssetActivation, + SmartContractCall, + PerpetualOpenPosition, + PerpetualClosePosition, + PerpetualModifyPosition + ] +); + +diesel_enum!( + LinkType, + PrimitiveLinkType, + LinkTypeSql, + [ + X, + Discord, + Reddit, + Telegram, + GitHub, + YouTube, + Facebook, + Website, + Coingecko, + OpenSea, + Instagram, + MagicEden, + CoinMarketCap, + TikTok + ] +); + +diesel_enum!(NftType, PrimitiveNFTType, NftTypeSql, [ERC721, ERC1155, SPL, JETTON]); + +diesel_enum!(FiatTransactionType, PrimitiveFiatQuoteType, FiatTransactionTypeSql, [Buy, Sell]); + +diesel_enum!( + FiatTransactionStatusRow, + PrimitiveFiatTransactionStatus, + FiatTransactionStatusSql, + [Complete, Pending, Failed, Unknown] +); + +diesel_enum!( + AssetType, + PrimitiveAssetType, + AssetTypeSql, + [NATIVE, ERC20, BEP20, SPL, SPL2022, TRC20, TOKEN, IBC, JETTON, SYNTH, ASA, PERPETUAL, SPOT] +); + +diesel_enum!(AddressType, PrimitiveAddressType, AddressTypeSql, [Address, Contract, Validator, InternalWallet]); + +diesel_enum!( + RewardEventType, + PrimitiveRewardEventType, + RewardEventTypeSql, + [CreateUsername, InvitePending, InviteNew, Joined, Enabled, Disabled] +); + +diesel_enum!( + TransactionState, + PrimitiveTransactionState, + TransactionStateSql, + [Pending, Confirmed, InTransit, Failed, Reverted] +); + +diesel_enum!(UsernameStatus, PrimitiveUsernameStatus, UsernameStatusSql, [Unverified, Verified]); + +diesel_enum!(Platform, PrimitivePlatform, PlatformSql, [IOS, Android]); + +diesel_enum!( + PlatformStore, + PrimitivePlatformStore, + PlatformStoreSql, + [AppStore, GooglePlay, Fdroid, Huawei, SolanaStore, SamsungStore, ApkUniversal, Local] +); + +diesel_enum!(WalletType, PrimitiveWalletType, WalletTypeSql, [Multicoin, Single, PrivateKey, View]); + +diesel_enum!(WalletSource, PrimitiveWalletSource, WalletSourceSql, [Create, Import]); + +diesel_enum!( + NotificationType, + PrimitiveNotificationType, + NotificationTypeSql, + [ReferralJoined, RewardsEnabled, RewardsCodeDisabled, RewardsRedeemed, RewardsCreateUsername, RewardsInvite] +); + +diesel_enum!( + IpUsageType, + PrimitiveIpUsageType, + IpUsageTypeSql, + [DataCenter, Hosting, Isp, Mobile, Business, Education, Government, Unknown] +); +diesel_enum!(WebhookKind, PrimitiveWebhookKind, WebhookKindSql, [Transactions, Support, Fiat]); + +macro_rules! diesel_varchar { + ($wrapper:ident, $inner:ty) => { + #[derive(Debug, Clone, Serialize, Deserialize, AsExpression, FromSqlRow)] + #[serde(transparent)] + #[diesel(sql_type = diesel::sql_types::Varchar)] + pub struct $wrapper(pub $inner); + + impl Deref for $wrapper { + type Target = $inner; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl From<$inner> for $wrapper { + fn from(v: $inner) -> Self { + Self(v) + } + } + + impl From<$wrapper> for $inner { + fn from(w: $wrapper) -> Self { + w.0 + } + } + + impl FromSql for $wrapper { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let s = std::str::from_utf8(bytes.as_bytes())?; + Ok(Self(<$inner>::from_str(s).map_err(|e| format!("Invalid {}: {}", stringify!($wrapper), e))?)) + } + } + + impl ToSql for $wrapper { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.0.as_ref().as_bytes())?; + Ok(serialize::IsNull::No) + } + } + }; +} + +diesel_varchar!(ChainRow, Chain); +diesel_varchar!(PriceAlertDirectionRow, PrimitivePriceAlertDirection); +diesel_varchar!(PerpetualProviderRow, PrimitivePerpetualProvider); +diesel_varchar!(PriceProviderRow, PrimitivePriceProvider); +diesel_varchar!(FiatProviderNameRow, PrimitiveFiatProviderName); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[serde(transparent)] +#[diesel(sql_type = diesel::sql_types::Varchar)] +pub struct AssetId(pub PrimitiveAssetId); + +impl Deref for AssetId { + type Target = PrimitiveAssetId; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for AssetId { + fn from(value: PrimitiveAssetId) -> Self { + Self(value) + } +} + +impl From<&PrimitiveAssetId> for AssetId { + fn from(value: &PrimitiveAssetId) -> Self { + Self(value.clone()) + } +} + +impl From for PrimitiveAssetId { + fn from(value: AssetId) -> Self { + value.0 + } +} + +impl fmt::Display for AssetId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl FromSql for AssetId { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let s = std::str::from_utf8(bytes.as_bytes())?; + Ok(Self(PrimitiveAssetId::new(s).ok_or_else(|| format!("Invalid AssetId: {s}"))?)) + } +} + +impl ToSql for AssetId { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.0.to_string().as_bytes())?; + Ok(serialize::IsNull::No) + } +} + +macro_rules! diesel_varchar_display { + ($wrapper:ident, $inner:ty) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] + #[serde(transparent)] + #[diesel(sql_type = diesel::sql_types::Varchar)] + pub struct $wrapper(pub $inner); + + impl Deref for $wrapper { + type Target = $inner; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl From<$inner> for $wrapper { + fn from(v: $inner) -> Self { + Self(v) + } + } + + impl From<$wrapper> for $inner { + fn from(w: $wrapper) -> Self { + w.0 + } + } + + impl fmt::Display for $wrapper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } + } + + impl FromSql for $wrapper { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let s = std::str::from_utf8(bytes.as_bytes())?; + Ok(Self(<$inner>::from_str(s).map_err(|e| format!("Invalid {}: {}", stringify!($wrapper), e))?)) + } + } + + impl ToSql for $wrapper { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.0.to_string().as_bytes())?; + Ok(serialize::IsNull::No) + } + } + }; +} + +diesel_varchar_display!(PriceId, PrimitivePriceId); +diesel_varchar_display!(WalletIdRow, primitives::WalletId); +diesel_varchar_display!(NftAssetIdRow, primitives::NFTAssetId); +diesel_varchar_display!(NftCollectionIdRow, primitives::NFTCollectionId); +diesel_varchar_display!(PerpetualIdRow, primitives::PerpetualId); diff --git a/core/crates/storage/src/testkit/asset_mock.rs b/core/crates/storage/src/testkit/asset_mock.rs new file mode 100644 index 0000000000..c7be0091e0 --- /dev/null +++ b/core/crates/storage/src/testkit/asset_mock.rs @@ -0,0 +1,33 @@ +use crate::models::{AssetRow, NewAssetRow}; +use chrono::Utc; +use primitives::{Asset, Chain}; + +impl AssetRow { + pub fn mock() -> Self { + let asset = NewAssetRow::from_primitive_default(Asset::from_chain(Chain::Bitcoin)); + Self { + id: asset.id, + chain: asset.chain, + token_id: asset.token_id, + name: asset.name, + symbol: asset.symbol, + asset_type: asset.asset_type, + decimals: asset.decimals, + rank: asset.rank, + is_enabled: asset.is_enabled, + is_buyable: asset.is_buyable, + is_sellable: asset.is_sellable, + is_swappable: asset.is_swappable, + is_stakeable: asset.is_stakeable, + staking_apr: asset.staking_apr, + is_earnable: asset.is_earnable, + earn_apr: asset.earn_apr, + has_image: asset.has_image, + has_price: asset.has_price, + circulating_supply: asset.circulating_supply, + total_supply: asset.total_supply, + max_supply: asset.max_supply, + updated_at: Utc::now().naive_utc(), + } + } +} diff --git a/core/crates/storage/src/testkit/fiat_transaction_mock.rs b/core/crates/storage/src/testkit/fiat_transaction_mock.rs new file mode 100644 index 0000000000..5b8f63e0a5 --- /dev/null +++ b/core/crates/storage/src/testkit/fiat_transaction_mock.rs @@ -0,0 +1,39 @@ +use crate::models::FiatTransactionRow; +use chrono::DateTime; +use primitives::{AssetId, Chain, FiatProviderName, FiatQuoteType, FiatTransactionStatus}; + +impl FiatTransactionRow { + pub fn mock() -> Self { + Self { + id: 1, + asset_id: AssetId::from_chain(Chain::Ethereum).into(), + transaction_type: FiatQuoteType::Buy.into(), + provider_id: FiatProviderName::MoonPay.into(), + provider_transaction_id: Some("tx_123".to_string()), + status: FiatTransactionStatus::Pending.into(), + country: Some("US".to_string()), + fiat_amount: 100.0, + fiat_currency: "USD".to_string(), + value: Some("123000000000000000".to_string()), + address_id: 1, + transaction_hash: Some("0xabc".to_string()), + device_id: 1, + wallet_id: 1, + quote_id: "quote_123".to_string(), + updated_at: DateTime::UNIX_EPOCH.naive_utc(), + created_at: DateTime::UNIX_EPOCH.naive_utc(), + } + } + + pub fn mock_with_timestamps(created_at: DateTime, updated_at: DateTime) -> Self { + Self { + created_at: created_at.naive_utc(), + updated_at: updated_at.naive_utc(), + ..Self::mock() + } + } + + pub fn mock_without_value() -> Self { + Self { value: None, ..Self::mock() } + } +} diff --git a/core/crates/storage/src/testkit/mod.rs b/core/crates/storage/src/testkit/mod.rs new file mode 100644 index 0000000000..5fc1c9c347 --- /dev/null +++ b/core/crates/storage/src/testkit/mod.rs @@ -0,0 +1,4 @@ +pub mod asset_mock; +pub mod fiat_transaction_mock; +pub mod price_mock; +pub mod scan_address_mock; diff --git a/core/crates/storage/src/testkit/price_mock.rs b/core/crates/storage/src/testkit/price_mock.rs new file mode 100644 index 0000000000..1bda7e6a6f --- /dev/null +++ b/core/crates/storage/src/testkit/price_mock.rs @@ -0,0 +1,22 @@ +use crate::models::PriceRow; +use crate::sql_types::{PriceId, PriceProviderRow}; +use chrono::Utc; +use primitives::{PriceId as PrimitivePriceId, PriceProvider}; + +impl PriceRow { + pub fn mock(provider: PriceProvider, provider_price_id: &str) -> Self { + Self { + id: PriceId::from(PrimitivePriceId::new(provider, provider_price_id.to_string())), + provider: PriceProviderRow(provider), + price: 1.0, + price_change_percentage_24h: None, + all_time_high: 0.0, + all_time_high_date: None, + all_time_low: 0.0, + all_time_low_date: None, + market_cap_rank: None, + total_volume: None, + last_updated_at: Utc::now().naive_utc(), + } + } +} diff --git a/core/crates/storage/src/testkit/scan_address_mock.rs b/core/crates/storage/src/testkit/scan_address_mock.rs new file mode 100644 index 0000000000..235c4c2d3c --- /dev/null +++ b/core/crates/storage/src/testkit/scan_address_mock.rs @@ -0,0 +1,21 @@ +use crate::models::ScanAddressRow; +use crate::sql_types::{AddressType, ChainRow}; +use chrono::DateTime; +use primitives::{AddressType as PrimitiveAddressType, Chain}; + +impl ScanAddressRow { + pub fn mock(id: i32, chain: Chain, address: &str, name: Option<&str>) -> Self { + Self { + id, + chain: ChainRow::from(chain), + address: address.to_string(), + name: name.map(str::to_string), + type_: AddressType::from(PrimitiveAddressType::Contract), + is_verified: false, + is_fraudulent: false, + is_memo_required: false, + updated_at: DateTime::UNIX_EPOCH.naive_utc(), + created_at: DateTime::UNIX_EPOCH.naive_utc(), + } + } +} diff --git a/core/crates/streamer/Cargo.toml b/core/crates/streamer/Cargo.toml new file mode 100644 index 0000000000..2b6907bde6 --- /dev/null +++ b/core/crates/streamer/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "streamer" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +tokio = { workspace = true, features = ["sync", "rt", "time"] } +serde = { workspace = true } +serde_json = { workspace = true } +futures = { workspace = true } +async-trait = { workspace = true } +strum = { workspace = true } + +lapin = { version = "4.10.0" } + +gem_tracing = { path = "../tracing" } +primitives = { path = "../primitives" } diff --git a/core/crates/streamer/src/connection.rs b/core/crates/streamer/src/connection.rs new file mode 100644 index 0000000000..b0f05bb6d9 --- /dev/null +++ b/core/crates/streamer/src/connection.rs @@ -0,0 +1,35 @@ +use std::error::Error; +use std::sync::Arc; + +use lapin::{Channel, Connection, ConnectionProperties}; + +#[derive(Clone)] +pub struct StreamConnection { + url: String, + name: String, + connection: Arc, +} + +impl StreamConnection { + pub async fn new(url: &str, name: impl Into) -> Result> { + let name: String = name.into(); + let connection = Connection::connect(url, ConnectionProperties::default().with_connection_name(name.clone().into())).await?; + Ok(Self { + url: url.to_string(), + name, + connection: Arc::new(connection), + }) + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn name(&self) -> &str { + &self.name + } + + pub async fn create_channel(&self) -> Result> { + Ok(self.connection.create_channel().await?) + } +} diff --git a/core/crates/streamer/src/consumer.rs b/core/crates/streamer/src/consumer.rs new file mode 100644 index 0000000000..2a1e96e299 --- /dev/null +++ b/core/crates/streamer/src/consumer.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; +use std::{ + error::Error, + fmt::Display, + time::{Duration, Instant}, +}; + +use crate::{QueueName, ShutdownReceiver, StreamReader}; +use async_trait::async_trait; +use gem_tracing::{DurationMs, error_with_fields, info_with_fields}; +use serde::Deserialize; +use tokio; + +#[derive(Clone)] +pub struct ConsumerConfig { + pub timeout_on_error: Duration, + pub skip_on_error: bool, + pub delay: Duration, + pub retries: u32, +} + +enum ProcessResult { + Processed(R), + Skipped, + Error(Box), +} + +#[async_trait] +pub trait ConsumerStatusReporter: Send + Sync { + async fn report_success(&self, name: &str, duration: u64, result: &str); +} + +#[async_trait] +pub trait MessageConsumer { + async fn process(&self, payload: P) -> Result>; + async fn should_process(&self, payload: P) -> Result>; +} + +pub async fn run_consumer( + name: &str, + mut stream_reader: StreamReader, + queue_name: QueueName, + routing_key: Option<&str>, + consumer: C, + config: ConsumerConfig, + shutdown_rx: ShutdownReceiver, + reporter: Arc, +) -> Result<(), Box> +where + P: Clone + Send + Display + 'static, + C: MessageConsumer + Send + 'static, + R: std::fmt::Debug, + for<'a> P: Deserialize<'a> + std::fmt::Debug, +{ + if routing_key.is_none() { + info_with_fields!("running consumer", consumer = queue_name.to_string()); + } + stream_reader + .read::( + queue_name, + routing_key, + |payload| process_message(name, &consumer, &config, &reporter, payload), + shutdown_rx, + ) + .await +} + +fn process_message(name: &str, consumer: &C, config: &ConsumerConfig, reporter: &Arc, payload: P) -> Result<(), Box> +where + P: Clone + Send + Display + 'static, + C: MessageConsumer + Send + 'static, + R: std::fmt::Debug, + for<'a> P: Deserialize<'a> + std::fmt::Debug, +{ + info_with_fields!("processing", consumer = name, payload = payload.to_string()); + let start = Instant::now(); + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + match consumer.should_process(payload.clone()).await { + Ok(true) => match consumer.process(payload.clone()).await { + Ok(r) => ProcessResult::Processed(r), + Err(e) => ProcessResult::Error(e), + }, + Ok(false) => ProcessResult::Skipped, + Err(e) => ProcessResult::Error(e), + } + }) + }); + match result { + ProcessResult::Processed(value) => { + let duration = start.elapsed().as_millis() as u64; + let result_str = format!("{:?}", value); + info_with_fields!( + "processed", + consumer = name, + payload = payload.to_string(), + result = result_str, + elapsed = DurationMs(start.elapsed()) + ); + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(reporter.report_success(name, duration, &result_str))); + if !config.delay.is_zero() { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(tokio::time::sleep(config.delay))); + } + Ok(()) + } + ProcessResult::Skipped => { + info_with_fields!("skipped", consumer = name, payload = payload.to_string(), elapsed = DurationMs(start.elapsed())); + Ok(()) + } + ProcessResult::Error(e) => { + error_with_fields!("failed", &*e, consumer = name, payload = payload.to_string(), elapsed = DurationMs(start.elapsed())); + if !config.timeout_on_error.is_zero() { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(tokio::time::sleep(config.timeout_on_error))); + } + if config.skip_on_error { Ok(()) } else { Err(e) } + } + } +} diff --git a/core/crates/streamer/src/exchange.rs b/core/crates/streamer/src/exchange.rs new file mode 100644 index 0000000000..221c475e7c --- /dev/null +++ b/core/crates/streamer/src/exchange.rs @@ -0,0 +1,42 @@ +use std::fmt; + +use lapin::ExchangeKind; +use strum::{EnumIter, IntoEnumIterator}; + +use crate::QueueName; + +#[derive(Debug, Clone, EnumIter)] +pub enum ExchangeName { + NewAddresses, +} + +impl ExchangeName { + pub fn all() -> Vec { + ExchangeName::iter().collect() + } + + pub fn kind(&self) -> ExchangeKind { + match self { + ExchangeName::NewAddresses => ExchangeKind::Topic, + } + } + + pub fn queues(&self) -> Vec { + match self { + ExchangeName::NewAddresses => vec![ + QueueName::FetchTokenAssociations, + QueueName::FetchCoinAssociations, + QueueName::FetchAddressTransactions, + QueueName::FetchNftAssociations, + ], + } + } +} + +impl fmt::Display for ExchangeName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ExchangeName::NewAddresses => write!(f, "new_addresses"), + } + } +} diff --git a/core/crates/streamer/src/lib.rs b/core/crates/streamer/src/lib.rs new file mode 100644 index 0000000000..80b20b3a29 --- /dev/null +++ b/core/crates/streamer/src/lib.rs @@ -0,0 +1,86 @@ +pub mod connection; +pub mod consumer; +pub mod exchange; +pub mod payload; +pub mod queue; +pub mod steam_producer_queue; +pub mod stream_producer; +pub mod stream_reader; + +use std::error::Error; +use std::future::Future; +use std::time::Duration; + +use gem_tracing::info_with_fields; +use tokio::sync::watch; + +pub type ShutdownReceiver = watch::Receiver; + +pub fn no_shutdown() -> ShutdownReceiver { + let (tx, rx) = watch::channel(false); + std::mem::forget(tx); + rx +} + +#[derive(Clone)] +pub struct Retry { + pub delay: Duration, + pub timeout: Duration, +} + +impl Retry { + pub fn new(delay: Duration, timeout: Duration) -> Self { + Self { delay, timeout } + } +} + +pub async fn with_retry(retry: &Retry, name: &str, shutdown_rx: &ShutdownReceiver, mut f: F) -> Result, Box> +where + F: FnMut() -> Fut, + Fut: Future>>, +{ + let mut delay = retry.delay; + let mut attempt: u32 = 0; + loop { + if *shutdown_rx.borrow() { + return Ok(None); + } + attempt += 1; + match f().await { + Ok(result) => { + if attempt > 1 { + info_with_fields!("rabbitmq reconnected", connection = name, attempt = attempt); + } + return Ok(Some(result)); + } + Err(err) => { + info_with_fields!( + "rabbitmq reconnect retry", + connection = name, + attempt = attempt, + delay_secs = delay.as_secs(), + error = err.to_string() + ); + let mut rx = shutdown_rx.clone(); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = rx.changed() => return Ok(None), + } + delay = (delay * 2).min(retry.timeout); + } + } + } +} + +pub use connection::StreamConnection; +pub use consumer::ConsumerConfig; +pub use consumer::ConsumerStatusReporter; +pub use consumer::run_consumer; +pub use exchange::ExchangeName; +pub use lapin::ExchangeKind; +pub use payload::*; +pub use primitives::{AssetId, PushErrorLog}; +pub use queue::QueueName; +pub use steam_producer_queue::StreamProducerQueue; +pub use stream_producer::{StreamProducer, StreamProducerConfig}; +pub use stream_reader::{StreamReader, StreamReaderConfig}; diff --git a/core/crates/streamer/src/payload.rs b/core/crates/streamer/src/payload.rs new file mode 100644 index 0000000000..e1f99ad814 --- /dev/null +++ b/core/crates/streamer/src/payload.rs @@ -0,0 +1,391 @@ +use primitives::{ + AssetAddress, AssetId, Chain, ChainAddress, FailedNotification, FiatProviderName, FiatTransactionUpdate, GorushNotification, NFTAssetId, NotificationType, PriceData, PriceId, + Transaction, TransactionId, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionsPayload { + pub chain: Chain, + pub blocks: Vec, + pub transactions: Vec, + #[serde(default)] + pub notify_devices: bool, +} + +impl fmt::Display for TransactionsPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "chain={}, blocks={:?}, transactions: {}", self.chain.as_ref(), self.blocks, self.transactions.len()) + } +} + +impl TransactionsPayload { + pub fn new(chain: Chain, transactions: Vec) -> Self { + Self { + chain, + blocks: vec![], + transactions, + notify_devices: false, + } + } + + pub fn new_with_notify(chain: Chain, blocks: Vec, transactions: Vec) -> Self { + Self { + chain, + blocks, + transactions, + notify_devices: true, + } + } + + pub fn should_notify_devices(&self) -> bool { + self.notify_devices + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationsPayload { + pub notifications: Vec, +} + +impl fmt::Display for NotificationsPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "notifications: {}", self.notifications.len()) + } +} + +impl NotificationsPayload { + pub fn new(notifications: Vec) -> Self { + Self { notifications } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationsFailedPayload { + pub failures: Vec, +} + +impl fmt::Display for NotificationsFailedPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "failures: {}", self.failures.len()) + } +} + +impl NotificationsFailedPayload { + pub fn new(failures: Vec) -> Self { + Self { failures } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FetchAssetsPayload { + pub asset_id: AssetId, +} + +impl fmt::Display for FetchAssetsPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "chain={}, token_id={:?}", self.asset_id.chain.as_ref(), self.asset_id.token_id) + } +} + +impl FetchAssetsPayload { + pub fn new(asset_id: AssetId) -> Self { + Self { asset_id } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FetchPricesPayload { + AssetId(AssetId), + PriceId(PriceId), +} + +impl fmt::Display for FetchPricesPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AssetId(v) => v.fmt(f), + Self::PriceId(v) => v.fmt(f), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FetchBlocksPayload { + pub chain: Chain, + pub block: u64, +} + +impl fmt::Display for FetchBlocksPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "chain={}, block={}", self.chain.as_ref(), self.block) + } +} + +impl FetchBlocksPayload { + pub fn new(chain: Chain, block: u64) -> Self { + Self { chain, block } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FetchNFTCollectionPayload { + pub chain: Chain, + pub collection_id: String, +} + +impl FetchNFTCollectionPayload { + pub fn new(chain: Chain, collection_id: String) -> Self { + Self { chain, collection_id } + } +} + +impl fmt::Display for FetchNFTCollectionPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "chain={}, collection_id={}", self.chain.as_ref(), self.collection_id) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FetchNFTAssetPayload { + pub asset_id: NFTAssetId, +} + +impl FetchNFTAssetPayload { + pub fn new(asset_id: NFTAssetId) -> Self { + Self { asset_id } + } +} + +impl fmt::Display for FetchNFTAssetPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "asset_id={}", self.asset_id) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetsAddressPayload { + pub values: Vec, +} + +impl AssetsAddressPayload { + pub fn new(values: Vec) -> Self { + Self { values } + } +} + +impl fmt::Display for AssetsAddressPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for value in self.values.iter() { + write!(f, "address: {}, asset_id: {}", value.address, value.asset_id)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainAddressPayload { + pub value: ChainAddress, +} + +impl ChainAddressPayload { + pub fn new(value: ChainAddress) -> Self { + Self { value } + } +} + +impl fmt::Display for ChainAddressPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "chain={}, address={}", self.value.chain, self.value.address) + } +} + +impl From for ChainAddressPayload { + fn from(chain_address: ChainAddress) -> Self { + Self::new(chain_address) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(clippy::large_enum_variant)] +pub enum FiatWebhook { + OrderId(String), + Transaction(FiatTransactionUpdate), + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FiatWebhookPayload { + pub provider: FiatProviderName, + pub data: serde_json::Value, + pub payload: FiatWebhook, +} + +impl FiatWebhookPayload { + pub fn new(provider: FiatProviderName, data: serde_json::Value, payload: FiatWebhook) -> Self { + Self { provider, data, payload } + } +} + +impl fmt::Display for FiatWebhookPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "provider: {}", self.provider.id()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupportWebhookPayload { + pub data: serde_json::Value, +} + +impl SupportWebhookPayload { + pub fn new(data: serde_json::Value) -> Self { + Self { data } + } +} + +impl fmt::Display for SupportWebhookPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "size: {} bytes", serde_json::to_vec(&self.data).unwrap().len()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PricesPayload { + pub prices: Vec, +} + +impl PricesPayload { + pub fn new(prices: Vec) -> Self { + Self { prices } + } +} + +impl fmt::Display for PricesPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "prices: {}", self.prices.len()) + } +} + +#[cfg(test)] +mod tests { + use super::TransactionsPayload; + use primitives::Chain; + + #[test] + fn test_transactions_payload_should_notify_devices() { + assert!(!TransactionsPayload::new(Chain::Ethereum, vec![]).should_notify_devices()); + assert!(TransactionsPayload::new_with_notify(Chain::Ethereum, vec![], vec![]).should_notify_devices()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RewardsNotificationPayload { + pub event_id: i32, +} + +impl RewardsNotificationPayload { + pub fn new(event_id: i32) -> Self { + Self { event_id } + } +} + +impl fmt::Display for RewardsNotificationPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "event_id: {}", self.event_id) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RewardsRedemptionPayload { + pub redemption_id: i32, +} + +impl RewardsRedemptionPayload { + pub fn new(redemption_id: i32) -> Self { + Self { redemption_id } + } +} + +impl fmt::Display for RewardsRedemptionPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "redemption_id: {}", self.redemption_id) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InAppNotificationPayload { + pub wallet_id: i32, + pub asset_id: Option, + pub notification_type: NotificationType, + pub metadata: Option, +} + +impl InAppNotificationPayload { + pub fn new(wallet_id: i32, notification_type: NotificationType, metadata: Option) -> Self { + Self { + wallet_id, + asset_id: None, + notification_type, + metadata, + } + } + + pub fn new_with_asset(wallet_id: i32, asset_id: AssetId, notification_type: NotificationType, metadata: Option) -> Self { + Self { + wallet_id, + asset_id: Some(asset_id), + notification_type, + metadata, + } + } +} + +impl fmt::Display for InAppNotificationPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.metadata { + Some(metadata) => write!( + f, + "wallet_id: {}, notification_type: {}, metadata: {}", + self.wallet_id, + self.notification_type.as_ref(), + metadata + ), + None => write!(f, "wallet_id: {}, notification_type: {}", self.wallet_id, self.notification_type.as_ref()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletStreamPayload { + pub wallet_id: i32, + pub event: WalletStreamEvent, +} + +impl fmt::Display for WalletStreamPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "wallet_id: {}, {}", self.wallet_id, self.event) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WalletStreamEvent { + Transactions { transaction_ids: Vec, asset_ids: Vec }, + FiatTransaction, + Nft, + Perpetual, +} + +impl fmt::Display for WalletStreamEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WalletStreamEvent::Transactions { transaction_ids, asset_ids } => { + write!(f, "transactions: {}, assets: {}", transaction_ids.len(), asset_ids.len()) + } + WalletStreamEvent::FiatTransaction => write!(f, "fiat_transaction"), + WalletStreamEvent::Nft => write!(f, "nft"), + WalletStreamEvent::Perpetual => write!(f, "perpetual"), + } + } +} diff --git a/core/crates/streamer/src/queue.rs b/core/crates/streamer/src/queue.rs new file mode 100644 index 0000000000..c4cab099d0 --- /dev/null +++ b/core/crates/streamer/src/queue.rs @@ -0,0 +1,101 @@ +use std::fmt; +use strum::{EnumIter, IntoEnumIterator}; + +#[derive(Debug, Clone, PartialEq, EnumIter)] +pub enum QueueName { + // Process transactions, store and send notifications. Push assets to address_assets table and fetch new assets + StoreTransactions, + // Notifications for price alerts + NotificationsPriceAlerts, + // Notifications for transactions + NotificationsTransactions, + // Notifications for observers + NotificationsObservers, + // Notifications for support messages + NotificationsSupport, + // Notifications for rewards events + NotificationsRewards, + // Failed notifications to handle device disabling + NotificationsFailed, + // fetch new assets and store to db + FetchAssets, + // fetch prices for an asset or provider price id and store to db + FetchPrices, + // fetch new blocks and store to db + FetchBlocks, + // Fetch and store nft collection + FetchNFTCollection, + // Fetch and store nft collection assets + FetchNFTCollectionAssets, + // Fetch address token balances from providers and store to db + FetchTokenAssociations, + // Fetch address coin balances from providers and store to db + FetchCoinAssociations, + // Fetch address nft assets from providers and store to db + FetchNftAssociations, + // Fetch address transactions from providers and store to db + FetchAddressTransactions, + // Process fiat order webhooks + FiatOrderWebhooks, + // Process support webhooks + SupportWebhooks, + // Store pending transaction identifiers + StorePendingTransactions, + // Store prices to database + StorePrices, + // Rewards events (create username, invite, etc.) + RewardsEvents, + // Rewards redemptions + RewardsRedemptions, + NotificationsFiatPurchase, + NotificationsInApp, + WalletStreamEvents, +} + +impl QueueName { + pub fn all() -> Vec { + QueueName::iter().collect() + } + + pub fn chain_queues() -> Vec { + vec![ + QueueName::FetchBlocks, + QueueName::FetchTokenAssociations, + QueueName::FetchCoinAssociations, + QueueName::FetchNftAssociations, + QueueName::FetchAddressTransactions, + ] + } +} + +impl fmt::Display for QueueName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + QueueName::StoreTransactions => write!(f, "store_transactions"), + QueueName::NotificationsPriceAlerts => write!(f, "notifications_price_alerts"), + QueueName::NotificationsTransactions => write!(f, "notifications_transactions"), + QueueName::NotificationsObservers => write!(f, "notifications_observers"), + QueueName::FetchAssets => write!(f, "fetch_assets"), + QueueName::FetchPrices => write!(f, "fetch_prices"), + QueueName::FetchBlocks => write!(f, "fetch_blocks"), + QueueName::FetchNFTCollection => write!(f, "fetch_nft_collection"), + QueueName::FetchNFTCollectionAssets => write!(f, "fetch_nft_collection_assets"), + QueueName::FetchTokenAssociations => write!(f, "fetch_token_associations"), + QueueName::FetchCoinAssociations => write!(f, "fetch_coin_associations"), + QueueName::FetchAddressTransactions => write!(f, "fetch_address_transactions"), + QueueName::FetchNftAssociations => write!(f, "fetch_nft_associations"), + QueueName::FiatOrderWebhooks => write!(f, "fiat_order_webhooks"), + QueueName::SupportWebhooks => write!(f, "support_webhooks"), + QueueName::StorePendingTransactions => write!(f, "store_pending_transactions"), + QueueName::NotificationsSupport => write!(f, "notifications_support"), + QueueName::NotificationsRewards => write!(f, "notifications_rewards"), + QueueName::RewardsEvents => write!(f, "rewards_events"), + QueueName::RewardsRedemptions => write!(f, "rewards_redemptions"), + QueueName::NotificationsFailed => write!(f, "notifications_failed"), + QueueName::StorePrices => write!(f, "store_prices"), + QueueName::NotificationsFiatPurchase => write!(f, "notifications_fiat_purchase"), + QueueName::NotificationsInApp => write!(f, "notifications_in_app"), + QueueName::WalletStreamEvents => write!(f, "wallet_stream_events"), + } + } +} diff --git a/core/crates/streamer/src/steam_producer_queue.rs b/core/crates/streamer/src/steam_producer_queue.rs new file mode 100644 index 0000000000..cc0ffff4b4 --- /dev/null +++ b/core/crates/streamer/src/steam_producer_queue.rs @@ -0,0 +1,171 @@ +use std::error::Error; + +use primitives::{AssetId, Chain, NFTAssetId}; + +use crate::{ + ChainAddressPayload, ExchangeName, FetchAssetsPayload, FetchBlocksPayload, FetchNFTAssetPayload, FetchPricesPayload, InAppNotificationPayload, NotificationsFailedPayload, + NotificationsPayload, PricesPayload, QueueName, RewardsNotificationPayload, RewardsRedemptionPayload, StreamProducer, TransactionsPayload, WalletStreamPayload, +}; + +#[async_trait::async_trait] +pub trait StreamProducerQueue { + async fn publish_fetch_assets(&self, asset_ids: Vec) -> Result>; + async fn publish_fetch_nft_asset(&self, asset_id: NFTAssetId) -> Result>; + async fn publish_fetch_nft_assets(&self, asset_ids: Vec) -> Result>; + async fn publish_fetch_prices(&self, payload: FetchPricesPayload) -> Result>; + async fn publish_fetch_prices_assets(&self, asset_ids: Vec) -> Result>; + async fn publish_transactions(&self, payload: TransactionsPayload) -> Result>; + async fn publish_notifications_transactions(&self, payload: Vec) -> Result>; + async fn publish_notifications_price_alerts(&self, payload: NotificationsPayload) -> Result>; + async fn publish_notifications_observers(&self, payload: NotificationsPayload) -> Result>; + async fn publish_notifications_support(&self, payload: NotificationsPayload) -> Result>; + async fn publish_notifications_fiat_purchase(&self, payload: NotificationsPayload) -> Result>; + async fn publish_notifications_rewards(&self, payload: NotificationsPayload) -> Result>; + async fn publish_rewards_events(&self, payload: Vec) -> Result>; + async fn publish_rewards_redemption(&self, payload: RewardsRedemptionPayload) -> Result>; + async fn publish_notifications_failed(&self, payload: NotificationsFailedPayload) -> Result>; + async fn publish_prices(&self, payload: PricesPayload) -> Result>; + async fn publish_blocks(&self, chain: Chain, blocks: &[u64]) -> Result<(), Box>; + async fn publish_new_addresses(&self, payload: Vec) -> Result>; + async fn publish_in_app_notifications(&self, payload: Vec) -> Result>; + async fn publish_wallet_stream_events(&self, payload: Vec) -> Result>; +} + +#[async_trait::async_trait] +impl StreamProducerQueue for StreamProducer { + async fn publish_fetch_assets(&self, asset_ids: Vec) -> Result> { + for asset_id in &asset_ids { + let payload = FetchAssetsPayload::new(asset_id.clone()); + self.publish(QueueName::FetchAssets, &payload).await?; + } + Ok(true) + } + + async fn publish_fetch_nft_asset(&self, asset_id: NFTAssetId) -> Result> { + self.publish(QueueName::FetchNFTCollectionAssets, &FetchNFTAssetPayload::new(asset_id)).await + } + + async fn publish_fetch_nft_assets(&self, asset_ids: Vec) -> Result> { + for asset_id in asset_ids { + self.publish_fetch_nft_asset(asset_id).await?; + } + Ok(true) + } + + async fn publish_fetch_prices(&self, payload: FetchPricesPayload) -> Result> { + self.publish(QueueName::FetchPrices, &payload).await + } + + async fn publish_fetch_prices_assets(&self, asset_ids: Vec) -> Result> { + for asset_id in asset_ids { + let payload = FetchPricesPayload::AssetId(asset_id); + self.publish(QueueName::FetchPrices, &payload).await?; + } + Ok(true) + } + + async fn publish_transactions(&self, payload: TransactionsPayload) -> Result> { + if payload.transactions.is_empty() { + return Ok(true); + } + self.publish(QueueName::StoreTransactions, &payload).await + } + + async fn publish_notifications_transactions(&self, payload: Vec) -> Result> { + let payload: Vec = payload.into_iter().filter(|p| !p.notifications.is_empty()).collect(); + if payload.is_empty() { + return Ok(true); + } + self.publish_batch(QueueName::NotificationsTransactions, &payload).await + } + + async fn publish_notifications_price_alerts(&self, payload: NotificationsPayload) -> Result> { + if payload.notifications.is_empty() { + return Ok(true); + } + self.publish(QueueName::NotificationsPriceAlerts, &payload).await + } + + async fn publish_notifications_observers(&self, payload: NotificationsPayload) -> Result> { + if payload.notifications.is_empty() { + return Ok(true); + } + self.publish(QueueName::NotificationsObservers, &payload).await + } + + async fn publish_notifications_support(&self, payload: NotificationsPayload) -> Result> { + if payload.notifications.is_empty() { + return Ok(true); + } + self.publish(QueueName::NotificationsSupport, &payload).await + } + + async fn publish_notifications_fiat_purchase(&self, payload: NotificationsPayload) -> Result> { + if payload.notifications.is_empty() { + return Ok(true); + } + self.publish(QueueName::NotificationsFiatPurchase, &payload).await + } + + async fn publish_notifications_rewards(&self, payload: NotificationsPayload) -> Result> { + if payload.notifications.is_empty() { + return Ok(true); + } + self.publish(QueueName::NotificationsRewards, &payload).await + } + + async fn publish_rewards_events(&self, payload: Vec) -> Result> { + if payload.is_empty() { + return Ok(true); + } + self.publish_batch(QueueName::RewardsEvents, &payload).await + } + + async fn publish_rewards_redemption(&self, payload: RewardsRedemptionPayload) -> Result> { + self.publish(QueueName::RewardsRedemptions, &payload).await + } + + async fn publish_notifications_failed(&self, payload: NotificationsFailedPayload) -> Result> { + if payload.failures.is_empty() { + return Ok(true); + } + self.publish(QueueName::NotificationsFailed, &payload).await + } + + async fn publish_prices(&self, payload: PricesPayload) -> Result> { + if payload.prices.is_empty() { + return Ok(true); + } + self.publish(QueueName::StorePrices, &payload).await + } + + async fn publish_blocks(&self, chain: Chain, blocks: &[u64]) -> Result<(), Box> { + for block in blocks { + let payload = FetchBlocksPayload::new(chain, *block); + self.publish_with_routing_key(QueueName::FetchBlocks, chain.as_ref(), &payload).await?; + } + Ok(()) + } + + async fn publish_new_addresses(&self, payload: Vec) -> Result> { + for item in &payload { + self.publish_to_exchange_with_routing_key(ExchangeName::NewAddresses, item.value.chain.as_ref(), item) + .await?; + } + Ok(true) + } + + async fn publish_in_app_notifications(&self, payload: Vec) -> Result> { + if payload.is_empty() { + return Ok(true); + } + self.publish_batch(QueueName::NotificationsInApp, &payload).await + } + + async fn publish_wallet_stream_events(&self, payload: Vec) -> Result> { + if payload.is_empty() { + return Ok(true); + } + self.publish_batch(QueueName::WalletStreamEvents, &payload).await + } +} diff --git a/core/crates/streamer/src/stream_producer.rs b/core/crates/streamer/src/stream_producer.rs new file mode 100644 index 0000000000..3a09c28114 --- /dev/null +++ b/core/crates/streamer/src/stream_producer.rs @@ -0,0 +1,306 @@ +use std::error::Error; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use gem_tracing::info_with_fields; +use lapin::{BasicProperties, Channel, Confirmation, Connection, ConnectionProperties, ExchangeKind, options::*, types::FieldTable}; +use tokio::sync::Mutex; + +use crate::{ExchangeName, QueueName, Retry, ShutdownReceiver, StreamConnection, with_retry}; + +const ROUTING_KEY_EXCHANGE_SUFFIX: &str = "_exchange"; +const MAX_QUEUE_BYTES: i64 = 1_000_000_000; + +#[derive(Clone)] +pub struct StreamProducerConfig { + pub url: String, + pub retry: Retry, +} + +impl StreamProducerConfig { + pub fn new(url: String, retry: Retry) -> Self { + Self { url, retry } + } +} + +fn queue_args() -> FieldTable { + let mut args = FieldTable::default(); + args.insert("x-max-length-bytes".into(), MAX_QUEUE_BYTES.into()); + args +} + +#[derive(Clone)] +pub struct StreamProducer { + url: String, + connection_name: String, + retry: Retry, + shutdown_rx: ShutdownReceiver, + channel: Arc>, +} + +impl StreamProducer { + pub async fn new(config: &StreamProducerConfig, connection_name: &str, shutdown_rx: ShutdownReceiver) -> Result> { + let channel = with_retry(&config.retry, connection_name, &shutdown_rx, || Self::try_connect(&config.url, connection_name)) + .await? + .ok_or("shutdown during connect")?; + Ok(Self { + url: config.url.clone(), + connection_name: connection_name.to_string(), + retry: config.retry.clone(), + shutdown_rx, + channel: Arc::new(Mutex::new(channel)), + }) + } + + pub async fn from_connection(connection: &StreamConnection, shutdown_rx: ShutdownReceiver) -> Result> { + let channel = connection.create_channel().await?; + let retry = Retry::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(30)); + Ok(Self { + url: connection.url().to_string(), + connection_name: connection.name().to_string(), + retry, + shutdown_rx, + channel: Arc::new(Mutex::new(channel)), + }) + } + + async fn try_connect(url: &str, name: &str) -> Result> { + let options = ConnectionProperties::default().with_connection_name(name.to_string().into()); + let connection = Connection::connect(url, options).await?; + let channel = connection.create_channel().await?; + Ok(channel) + } + + async fn reconnect(&self) -> Result> { + let mut guard = self.channel.lock().await; + let channel = with_retry(&self.retry, &self.connection_name, &self.shutdown_rx, || { + Self::try_connect(&self.url, &self.connection_name) + }) + .await? + .ok_or("shutdown during reconnect")?; + *guard = channel; + Ok(guard.clone()) + } + + async fn channel(&self) -> Result> { + let channel = self.channel.lock().await.clone(); + if channel.status().connected() { + return Ok(channel); + } + self.reconnect().await + } + + async fn run(&self, mut operation: F) -> Result> + where + F: FnMut(Channel) -> Fut, + Fut: Future>>, + { + let mut delay = self.retry.delay; + let mut attempt = 0; + + loop { + if *self.shutdown_rx.borrow() { + return Err("shutdown during operation".into()); + } + + let channel = self.channel().await?; + match operation(channel).await { + Ok(value) => return Ok(value), + Err(error) => { + attempt += 1; + info_with_fields!( + "rabbitmq producer retry", + connection = self.connection_name.as_str(), + attempt = attempt, + delay_secs = delay.as_secs(), + error = error.to_string() + ); + let _ = self.reconnect().await; + let mut shutdown_rx = self.shutdown_rx.clone(); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = shutdown_rx.changed() => return Err("shutdown during operation".into()), + } + delay = next_delay(delay, &self.retry); + } + } + } + } + + // Queue methods + + pub async fn declare_queue(&self, name: &str) -> Result<(), Box> { + self.run(|channel| async move { + channel + .queue_declare( + name.into(), + QueueDeclareOptions { + durable: true, + ..Default::default() + }, + queue_args(), + ) + .await?; + Ok(()) + }) + .await + } + + pub async fn declare_queues(&self, queues: Vec) -> Result<(), Box> { + for queue in queues { + self.declare_queue(&queue.to_string()).await?; + } + Ok(()) + } + + pub async fn delete_queue(&self, queue: &str) -> Result> { + self.run(|channel| async move { Ok(channel.queue_delete(queue.into(), QueueDeleteOptions::default()).await?) }) + .await + } + + pub async fn clear_queue(&self, queue: QueueName) -> Result> { + let queue_name = queue.to_string(); + self.run(|channel| { + let queue_name = queue_name.clone(); + async move { Ok(channel.queue_purge(queue_name.as_str().into(), QueuePurgeOptions::default()).await?) } + }) + .await + } + + // Exchange methods + + pub async fn declare_exchange(&self, name: &str, kind: ExchangeKind) -> Result<(), Box> { + self.run(|channel| { + let kind = kind.clone(); + async move { + channel + .exchange_declare(name.into(), kind, ExchangeDeclareOptions::default(), FieldTable::default()) + .await?; + Ok(()) + } + }) + .await + } + + pub async fn declare_exchanges(&self, exchanges: Vec) -> Result<(), Box> { + for exchange in exchanges { + self.declare_exchange(&exchange.to_string(), exchange.kind()).await?; + } + Ok(()) + } + + // Bind methods + + pub async fn bind_queue(&self, queue: &str, exchange: &str, routing_key: &str) -> Result<(), Box> { + self.run(|channel| async move { + channel + .queue_bind(queue.into(), exchange.into(), routing_key.into(), QueueBindOptions::default(), FieldTable::default()) + .await?; + Ok(()) + }) + .await + } + + pub async fn bind_exchange(&self, exchange: ExchangeName, queues: Vec) -> Result<(), Box> { + for queue in queues { + self.bind_queue(&queue.to_string(), &exchange.to_string(), "").await?; + } + Ok(()) + } + + pub async fn bind_queue_routing_key(&self, queue: QueueName, routing_key: &str) -> Result<(), Box> { + let exchange_name = format!("{}{}", queue, ROUTING_KEY_EXCHANGE_SUFFIX); + let queue_name = format!("{}.{}", queue, routing_key); + self.declare_queue(&queue_name).await?; + self.bind_queue(&queue_name, &exchange_name, routing_key).await + } + + // Publish methods + + async fn publish_message(&self, exchange: &str, routing_key: &str, message: &T) -> Result> + where + T: serde::Serialize, + { + let data = serde_json::to_vec(message)?; + self.run(|channel| { + let data = data.clone(); + async move { + let confirm = channel + .basic_publish( + exchange.into(), + routing_key.into(), + BasicPublishOptions::default(), + &data, + BasicProperties::default().with_delivery_mode(2).with_content_type("application/json".into()), + ) + .await?; + + match confirm.await? { + Confirmation::NotRequested | Confirmation::Ack(_) => Ok(true), + Confirmation::Nack(_) => Ok(false), + } + } + }) + .await + } + + pub async fn publish(&self, queue: QueueName, message: &T) -> Result> + where + T: serde::Serialize, + { + self.publish_message("", &queue.to_string(), message).await + } + + pub async fn publish_batch(&self, queue: QueueName, messages: &[T]) -> Result> + where + T: serde::Serialize, + { + let queue_name = queue.to_string(); + for message in messages { + if !self.publish_message("", &queue_name, message).await? { + return Ok(false); + } + } + Ok(true) + } + + pub async fn publish_to_exchange(&self, exchange: ExchangeName, message: &T) -> Result> + where + T: serde::Serialize, + { + self.publish_message(&exchange.to_string(), "", message).await + } + + pub async fn publish_to_exchange_with_routing_key(&self, exchange: ExchangeName, routing_key: &str, message: &T) -> Result> + where + T: serde::Serialize, + { + self.publish_message(&exchange.to_string(), routing_key, message).await + } + + pub async fn publish_to_exchange_batch(&self, exchange: ExchangeName, messages: &[T]) -> Result> + where + T: serde::Serialize, + { + let exchange_name = exchange.to_string(); + for message in messages { + if !self.publish_message(&exchange_name, "", message).await? { + return Ok(false); + } + } + Ok(true) + } + + pub async fn publish_with_routing_key(&self, queue: QueueName, routing_key: &str, message: &T) -> Result> + where + T: serde::Serialize, + { + let exchange_name = format!("{}{}", queue, ROUTING_KEY_EXCHANGE_SUFFIX); + self.publish_message(&exchange_name, routing_key, message).await + } +} + +fn next_delay(delay: Duration, retry: &Retry) -> Duration { + (delay * 2).min(retry.timeout) +} diff --git a/core/crates/streamer/src/stream_reader.rs b/core/crates/streamer/src/stream_reader.rs new file mode 100644 index 0000000000..6b090e909b --- /dev/null +++ b/core/crates/streamer/src/stream_reader.rs @@ -0,0 +1,176 @@ +use std::error::Error; + +use futures::StreamExt; +use gem_tracing::{error_fields, error_with_fields, info_with_fields}; +use lapin::{Channel, Connection, ConnectionProperties, options::*, types::FieldTable}; +use serde::de::DeserializeOwned; + +use crate::{QueueName, Retry, ShutdownReceiver, StreamConnection, with_retry}; + +#[derive(Clone)] +pub struct StreamReaderConfig { + pub url: String, + pub name: String, + pub prefetch: u16, + pub retry: Retry, +} + +impl StreamReaderConfig { + pub fn new(url: String, name: String, prefetch: u16, retry: Retry) -> Self { + Self { url, name, prefetch, retry } + } +} + +pub struct StreamReader { + config: StreamReaderConfig, + channel: Channel, +} + +impl StreamReader { + pub async fn new(config: StreamReaderConfig, shutdown_rx: &ShutdownReceiver) -> Result, Box> { + let channel = with_retry(&config.retry, &config.name, shutdown_rx, || Self::try_connect(&config)).await?; + Ok(channel.map(|channel| Self { config, channel })) + } + + pub async fn from_connection(connection: &StreamConnection, config: StreamReaderConfig) -> Result> { + let config = StreamReaderConfig { + url: connection.url().to_string(), + name: connection.name().to_string(), + ..config + }; + let channel = Self::create_channel(connection, config.prefetch).await?; + Ok(Self { config, channel }) + } + + async fn try_connect(config: &StreamReaderConfig) -> Result> { + let options = ConnectionProperties::default().with_connection_name(config.name.clone().into()); + let connection = Connection::connect(&config.url, options).await?; + Self::configure_channel(connection.create_channel().await?, config.prefetch).await + } + + pub async fn close(self) -> Result<(), Box> { + self.channel.close(0, "Normal shutdown".into()).await?; + Ok(()) + } + + async fn create_channel(connection: &StreamConnection, prefetch: u16) -> Result> { + Self::configure_channel(connection.create_channel().await?, prefetch).await + } + + async fn configure_channel(channel: Channel, prefetch: u16) -> Result> { + channel.basic_qos(prefetch, BasicQosOptions { global: false }).await?; + Ok(channel) + } + + async fn reconnect(&mut self, shutdown_rx: &ShutdownReceiver) -> Result> { + let result = with_retry(&self.config.retry, &self.config.name, shutdown_rx, || Self::try_connect(&self.config)).await?; + + match result { + Some(channel) => { + self.channel = channel; + Ok(true) + } + None => Ok(false), + } + } + + pub async fn read(&mut self, queue: QueueName, routing_key: Option<&str>, mut callback: F, shutdown_rx: ShutdownReceiver) -> Result<(), Box> + where + T: DeserializeOwned, + F: FnMut(T) -> Result<(), Box>, + { + let (queue_name, consumer_tag) = match routing_key { + Some(key) => (format!("{}.{}", queue, key), format!("consumer-{}-{}", queue, key)), + None => (queue.to_string(), format!("consumer-{queue}")), + }; + + loop { + if *shutdown_rx.borrow() { + break; + } + + let consumer_result = self + .channel + .basic_consume( + queue_name.as_str().into(), + consumer_tag.as_str().into(), + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await; + + let mut consumer = match consumer_result { + Ok(c) => c, + Err(e) => { + error_fields!("consumer setup failed", connection = self.config.name.as_str(), error = format!("{e}")); + if !self.reconnect(&shutdown_rx).await? { + break; + } + continue; + } + }; + + let result = self.consume::(&mut consumer, &mut callback, shutdown_rx.clone()).await; + if let Ok(true) = result { + break; + } + let error = result.err().map(|e| e.to_string()); + info_with_fields!( + "consumer reconnecting", + connection = self.config.name.as_str(), + error = error.as_deref().unwrap_or("stream ended") + ); + if !self.reconnect(&shutdown_rx).await? { + break; + } + } + + Ok(()) + } + + async fn consume(&mut self, consumer: &mut lapin::Consumer, callback: &mut F, mut shutdown_rx: ShutdownReceiver) -> Result> + where + T: DeserializeOwned, + F: FnMut(T) -> Result<(), Box>, + { + loop { + let delivery = tokio::select! { + d = consumer.next() => d, + _ = shutdown_rx.changed() => return Ok(true), + }; + + match delivery { + Some(Ok(delivery)) => { + let delivery_tag = delivery.delivery_tag; + let data = serde_json::from_slice::(&delivery.data); + match data { + Ok(obj) => match callback(obj) { + Ok(_) => self.ack(delivery_tag).await?, + Err(_) => self.nack(delivery_tag, true).await?, + }, + Err(e) => { + error_with_fields!("deserialization error", &e, payload = String::from_utf8_lossy(&delivery.data).to_string()); + let _ = self.nack(delivery_tag, false).await; + } + } + } + Some(Err(e)) => return Err(Box::new(e)), + None => return Ok(false), + } + } + } + + async fn ack(&self, delivery_tag: u64) -> Result<(), Box> { + self.channel + .basic_ack(delivery_tag, BasicAckOptions { multiple: false }) + .await + .map_err(|e| Box::new(e) as Box) + } + + async fn nack(&self, delivery_tag: u64, requeue: bool) -> Result<(), Box> { + self.channel + .basic_nack(delivery_tag, BasicNackOptions { multiple: false, requeue }) + .await + .map_err(|e| Box::new(e) as Box) + } +} diff --git a/core/crates/support/Cargo.toml b/core/crates/support/Cargo.toml new file mode 100644 index 0000000000..e86bb79620 --- /dev/null +++ b/core/crates/support/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "support" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +localizer = { path = "../localizer" } +primitives = { path = "../primitives" } +storage = { path = "../storage" } +streamer = { path = "../streamer" } + +[dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } diff --git a/core/crates/support/src/client.rs b/core/crates/support/src/client.rs new file mode 100644 index 0000000000..a97a3d47a0 --- /dev/null +++ b/core/crates/support/src/client.rs @@ -0,0 +1,95 @@ +use crate::ChatwootWebhookPayload; +use localizer::LanguageLocalizer; +use primitives::{Device, GorushNotification, PushNotification, PushNotificationTypes, push_notification::PushNotificationSupport}; +use std::error::Error; +use storage::database::devices::DevicesStore; +use storage::{Database, OptionalExtension}; +use streamer::{NotificationsPayload, StreamProducer, StreamProducerQueue}; + +pub struct SupportClient { + database: Database, + stream_producer: StreamProducer, +} + +impl SupportClient { + pub fn new(database: Database, stream_producer: StreamProducer) -> Self { + Self { database, stream_producer } + } + + pub fn get_device(&self, device_id: &str) -> Result, Box> { + Ok(DevicesStore::get_device(&mut self.database.client()?, device_id).optional()?.map(|d| d.as_primitive())) + } + + pub async fn handle_message_created(&self, device: &Device, payload: &ChatwootWebhookPayload) -> Result> { + let notifications_count = if let Some(notification) = Self::build_notification(device, payload) { + self.stream_producer.publish_notifications_support(NotificationsPayload::new(vec![notification])).await?; + 1 + } else { + 0 + }; + + Ok(notifications_count) + } + + pub fn handle_conversation_updated(&self, _payload: &ChatwootWebhookPayload) -> Result<(), Box> { + Ok(()) + } + + fn build_notification(device: &Device, payload: &ChatwootWebhookPayload) -> Option { + if !payload.is_public_outgoing_message() { + return None; + } + + let title = LanguageLocalizer::new_with_language(device.locale.as_str()).notification_support_new_message_title(); + let message = payload.content.clone().unwrap_or_default(); + let data = PushNotification { + notification_type: PushNotificationTypes::Support, + data: serde_json::to_value(PushNotificationSupport {}).ok(), + }; + + GorushNotification::from_device(device.clone(), title, message, data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_notification_message_created() { + let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("../tests/testdata/chatwoot_message_created.json")).unwrap(); + + let notification = SupportClient::build_notification(&Device::mock(), &payload); + + assert!(notification.is_some()); + assert_eq!(notification.unwrap().message, "from agent"); + } + + #[test] + fn test_build_notification_conversation_updated() { + let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("../tests/testdata/chatwoot_conversation_updated.json")).unwrap(); + + let notification = SupportClient::build_notification(&Device::mock(), &payload); + + assert!(notification.is_none()); + } + + #[test] + fn test_build_notification_private_message_created() { + let payload: ChatwootWebhookPayload = + serde_json::from_str(r#"{"event": "message_created", "message_type": "outgoing", "private": true, "content": "internal note"}"#).unwrap(); + + let notification = SupportClient::build_notification(&Device::mock(), &payload); + + assert!(notification.is_none()); + } + + #[test] + fn test_build_notification_missing_private_message_created() { + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "outgoing", "content": "unknown visibility"}"#).unwrap(); + + let notification = SupportClient::build_notification(&Device::mock(), &payload); + + assert!(notification.is_none()); + } +} diff --git a/core/crates/support/src/lib.rs b/core/crates/support/src/lib.rs new file mode 100644 index 0000000000..c7de07c3d8 --- /dev/null +++ b/core/crates/support/src/lib.rs @@ -0,0 +1,5 @@ +mod client; +mod model; + +pub use client::SupportClient; +pub use model::*; diff --git a/core/crates/support/src/model.rs b/core/crates/support/src/model.rs new file mode 100644 index 0000000000..5b0048982b --- /dev/null +++ b/core/crates/support/src/model.rs @@ -0,0 +1,129 @@ +use serde::{Deserialize, Serialize}; + +pub const EVENT_MESSAGE_CREATED: &str = "message_created"; +pub const EVENT_CONVERSATION_STATUS_CHANGED: &str = "conversation_status_changed"; +pub const EVENT_CONVERSATION_UPDATED: &str = "conversation_updated"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(from = "i32", into = "i32")] +pub enum MessageType { + Incoming, + Outgoing, +} + +impl From for MessageType { + fn from(value: i32) -> Self { + match value { + 1 => MessageType::Outgoing, + _ => MessageType::Incoming, + } + } +} + +impl From for i32 { + fn from(value: MessageType) -> Self { + match value { + MessageType::Incoming => 0, + MessageType::Outgoing => 1, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + pub id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatwootWebhookPayload { + pub event: String, + pub message_type: Option, + pub private: Option, + pub unread_count: Option, + pub conversation: Option, + pub account: Option, + pub meta: Option, + pub content: Option, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Conversation { + pub id: Option, + pub meta: Meta, + pub unread_count: Option, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub id: i64, + pub content: Option, + pub message_type: MessageType, + pub private: Option, + pub sender: Option, +} + +impl Message { + pub fn is_incoming(&self) -> bool { + self.message_type == MessageType::Incoming + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Meta { + pub sender: Sender, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomAttributes { + pub device_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sender { + pub custom_attributes: Option, +} + +impl ChatwootWebhookPayload { + pub fn get_device_id(&self) -> Option { + let attrs = self.conversation.as_ref().map(|c| &c.meta).or(self.meta.as_ref())?.sender.custom_attributes.as_ref()?; + attrs.device_id.clone() + } + + pub fn get_unread(&self) -> Option { + self.unread_count.or_else(|| self.conversation.as_ref().and_then(|conversation| conversation.unread_count)) + } + + pub fn is_outgoing_message(&self) -> bool { + self.message_type.as_deref() == Some("outgoing") + } + + pub fn is_incoming_message(&self) -> bool { + self.message_type.as_deref() == Some("incoming") + } + + pub fn is_public_outgoing_message(&self) -> bool { + self.is_outgoing_message() && self.private == Some(false) + } + + pub fn get_account_id(&self) -> Option { + self.account.as_ref().map(|a| a.id) + } + + pub fn get_conversation_id(&self) -> Option { + self.conversation.as_ref().and_then(|c| c.id) + } + + pub fn get_messages(&self) -> &[Message] { + if !self.messages.is_empty() { + &self.messages + } else if let Some(conversation) = &self.conversation { + &conversation.messages + } else { + &[] + } + } +} diff --git a/core/crates/support/tests/model_tests.rs b/core/crates/support/tests/model_tests.rs new file mode 100644 index 0000000000..190c49058a --- /dev/null +++ b/core/crates/support/tests/model_tests.rs @@ -0,0 +1,114 @@ +use support::ChatwootWebhookPayload; + +#[test] +fn test_parse_conversation_updated_payload() { + let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_conversation_updated.json")).unwrap(); + assert_eq!(payload.event, "conversation_updated"); + assert_eq!(payload.get_device_id(), Some("test-device-id".to_string())); + assert_eq!(payload.get_unread(), Some(1)); + + let messages = payload.get_messages(); + assert_eq!(messages.len(), 1); + + let message = &messages[0]; + assert_eq!(message.content, Some("Test message".to_string())); + assert!(!message.is_incoming()); + assert_eq!(message.private, Some(false)); + + let sender = message.sender.as_ref().unwrap(); + assert!(sender.custom_attributes.is_none()); +} + +#[test] +fn test_parse_device_id() { + let payload: ChatwootWebhookPayload = + serde_json::from_str(r#"{"event": "conversation_updated", "meta": {"sender": {"custom_attributes": {"device_id": "test-device"}}}}"#).unwrap(); + assert_eq!(payload.get_device_id(), Some("test-device".to_string())); +} + +#[test] +fn test_parse_message_created_payload() { + let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_message_created.json")).unwrap(); + assert_eq!(payload.event, "message_created"); + assert_eq!(payload.content, Some("from agent".to_string())); + assert_eq!(payload.get_device_id(), Some("test-device-id".to_string())); + assert_eq!(payload.get_unread(), Some(1)); + assert!(payload.is_outgoing_message()); + assert!(payload.is_public_outgoing_message()); + + let messages = payload.get_messages(); + assert_eq!(messages.len(), 1); + + let message = &messages[0]; + assert!(!message.is_incoming()); + assert_eq!(message.private, Some(false)); +} + +#[test] +fn test_get_unread() { + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "test", "unread_count": 5}"#).unwrap(); + assert_eq!(payload.get_unread(), Some(5)); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "test", "conversation": {"meta": {"sender": {}}, "unread_count": 3}}"#).unwrap(); + assert_eq!(payload.get_unread(), Some(3)); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "test", "unread_count": 2, "conversation": {"meta": {"sender": {}}, "unread_count": 10}}"#).unwrap(); + assert_eq!(payload.get_unread(), Some(2)); +} + +#[test] +fn test_is_outgoing_message() { + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "outgoing"}"#).unwrap(); + assert!(payload.is_outgoing_message()); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "incoming"}"#).unwrap(); + assert!(!payload.is_outgoing_message()); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created"}"#).unwrap(); + assert!(!payload.is_outgoing_message()); +} + +#[test] +fn test_is_incoming_message() { + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "incoming"}"#).unwrap(); + assert!(payload.is_incoming_message()); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "outgoing"}"#).unwrap(); + assert!(!payload.is_incoming_message()); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created"}"#).unwrap(); + assert!(!payload.is_incoming_message()); +} + +#[test] +fn test_is_public_outgoing_message() { + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "outgoing", "private": false}"#).unwrap(); + assert!(payload.is_public_outgoing_message()); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "outgoing", "private": true}"#).unwrap(); + assert!(!payload.is_public_outgoing_message()); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "incoming", "private": false}"#).unwrap(); + assert!(!payload.is_public_outgoing_message()); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "message_created", "message_type": "outgoing"}"#).unwrap(); + assert!(!payload.is_public_outgoing_message()); +} + +#[test] +fn test_get_account_id() { + let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_message_created.json")).unwrap(); + assert_eq!(payload.get_account_id(), Some(1)); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "test"}"#).unwrap(); + assert_eq!(payload.get_account_id(), None); +} + +#[test] +fn test_get_conversation_id() { + let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_message_created.json")).unwrap(); + assert_eq!(payload.get_conversation_id(), Some(1)); + + let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "test"}"#).unwrap(); + assert_eq!(payload.get_conversation_id(), None); +} diff --git a/core/crates/support/tests/testdata/chatwoot_conversation_updated.json b/core/crates/support/tests/testdata/chatwoot_conversation_updated.json new file mode 100644 index 0000000000..3e68de970c --- /dev/null +++ b/core/crates/support/tests/testdata/chatwoot_conversation_updated.json @@ -0,0 +1,124 @@ +{ + "additional_attributes": { + "browser": { + "device_name": "test_device", + "browser_name": "Chrome", + "platform_name": "Android", + "browser_version": "143.0.0.0", + "platform_version": "16" + }, + "referer": "https://support.gemwallet.com/", + "initiated_at": { + "timestamp": "Mon Dec 22 2025 23:01:56 GMT-0800 (Pacific Standard Time)" + }, + "browser_language": "en" + }, + "can_reply": true, + "channel": "Channel::WebWidget", + "contact_inbox": { + "id": 1, + "contact_id": 1, + "inbox_id": 11, + "source_id": "test-source-id", + "created_at": "2025-12-23T07:01:33.235Z", + "updated_at": "2025-12-23T07:01:33.235Z", + "hmac_verified": false, + "pubsub_token": "test-token" + }, + "id": 1, + "inbox_id": 11, + "messages": [ + { + "id": 1, + "content": "Test message", + "account_id": 1, + "inbox_id": 11, + "conversation_id": 1, + "message_type": 1, + "created_at": 1766475695, + "updated_at": "2025-12-23T07:41:35.594Z", + "private": false, + "status": "sent", + "source_id": null, + "content_type": "text", + "content_attributes": {}, + "sender_type": "User", + "sender_id": 1, + "external_source_ids": {}, + "additional_attributes": {}, + "processed_message_content": "Test message", + "sentiment": {}, + "conversation": { + "assignee_id": null, + "unread_count": 0, + "last_activity_at": 1766475695, + "contact_inbox": { + "source_id": "test-source-id" + } + }, + "sender": { + "id": 1, + "name": "Test Agent", + "available_name": "Test Agent", + "avatar_url": "", + "type": "user", + "availability_status": "offline", + "thumbnail": "" + } + } + ], + "labels": [], + "meta": { + "sender": { + "additional_attributes": {}, + "custom_attributes": { + "os": "16", + "device": "Test Device", + "currency": "USD", + "platform": "android", + "app_version": "1.0.0", + "device_id": "test-device-id" + }, + "email": null, + "id": 1, + "identifier": null, + "name": "test-user", + "phone_number": null, + "thumbnail": "", + "blocked": false, + "type": "contact" + }, + "assignee": null, + "assignee_type": null, + "team": null, + "hmac_verified": false + }, + "status": "open", + "custom_attributes": {}, + "snoozed_until": null, + "unread_count": 1, + "first_reply_created_at": "2025-12-23T07:04:13.002Z", + "priority": null, + "waiting_since": 0, + "agent_last_seen_at": 1766475695, + "contact_last_seen_at": 1766475519, + "last_activity_at": 1766475695, + "timestamp": 1766475695, + "created_at": 1766473316, + "updated_at": 1766475695.664415, + "event": "conversation_updated", + "changed_attributes": [ + { + "updated_at": { + "previous_value": "2025-12-23T07:41:35.608Z", + "current_value": "2025-12-23T07:41:35.664Z" + } + }, + { + "waiting_since": { + "previous_value": "2025-12-23T07:23:23.816Z", + "current_value": null + } + } + ] +} diff --git a/core/crates/support/tests/testdata/chatwoot_message_created.json b/core/crates/support/tests/testdata/chatwoot_message_created.json new file mode 100644 index 0000000000..f03cca166b --- /dev/null +++ b/core/crates/support/tests/testdata/chatwoot_message_created.json @@ -0,0 +1,105 @@ +{ + "account": { + "id": 1, + "name": "Gem Wallet" + }, + "additional_attributes": {}, + "content_attributes": {}, + "content_type": "text", + "content": "from agent", + "conversation": { + "additional_attributes": {}, + "can_reply": true, + "channel": "Channel::WebWidget", + "contact_inbox": { + "id": 1, + "contact_id": 1, + "inbox_id": 11, + "source_id": "test-source-id", + "created_at": "2025-12-19T08:29:58.043Z", + "updated_at": "2025-12-19T08:29:58.043Z", + "hmac_verified": false, + "pubsub_token": "test-token" + }, + "id": 1, + "inbox_id": 11, + "messages": [ + { + "id": 1, + "content": "from agent", + "account_id": 1, + "inbox_id": 11, + "conversation_id": 1, + "message_type": 1, + "created_at": 1766478193, + "updated_at": "2025-12-23T08:23:13.554Z", + "private": false, + "status": "sent", + "source_id": null, + "content_type": "text", + "content_attributes": {}, + "sender_type": "User", + "sender_id": 1, + "external_source_ids": {}, + "additional_attributes": {}, + "processed_message_content": "from agent", + "sentiment": {} + } + ], + "labels": [], + "meta": { + "sender": { + "additional_attributes": {}, + "custom_attributes": { + "os": "16", + "device": "Test Device", + "currency": "USD", + "platform": "android", + "app_version": "1.0.0", + "device_id": "test-device-id" + }, + "email": null, + "id": 1, + "identifier": null, + "name": "test-user", + "phone_number": null, + "thumbnail": "", + "blocked": false, + "type": "contact" + }, + "assignee": null, + "assignee_type": null, + "team": null, + "hmac_verified": false + }, + "status": "open", + "custom_attributes": {}, + "snoozed_until": null, + "unread_count": 1, + "first_reply_created_at": "2025-12-23T06:12:32.683Z", + "priority": null, + "waiting_since": 0, + "agent_last_seen_at": 1766478193, + "contact_last_seen_at": 1766478049, + "last_activity_at": 1766478193, + "timestamp": 1766478193, + "created_at": 1766133025, + "updated_at": 1766478193.559521 + }, + "created_at": "2025-12-23T08:23:13.554Z", + "id": 1, + "inbox": { + "id": 11, + "name": "Gem Wallet Support" + }, + "message_type": "outgoing", + "private": false, + "sender": { + "id": 1, + "name": "Test Agent", + "email": "agent@test.com", + "type": "user" + }, + "source_id": null, + "event": "message_created" +} diff --git a/core/crates/swapper/Cargo.toml b/core/crates/swapper/Cargo.toml new file mode 100644 index 0000000000..4273332316 --- /dev/null +++ b/core/crates/swapper/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "swapper" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } + +[features] +default = [] +reqwest_provider = ["dep:reqwest"] +unit_tests = ["reqwest_provider"] +swap_integration_tests = ["reqwest_provider"] + +[dependencies] +primitives = { path = "../primitives" } +gem_solana = { path = "../gem_solana", features = ["rpc"] } +gem_ton = { path = "../gem_ton", features = ["rpc", "tvm"] } +gem_evm = { path = "../gem_evm", features = ["rpc"] } +gem_sui = { path = "../gem_sui", features = ["rpc"] } +gem_aptos = { path = "../gem_aptos", features = ["rpc"] } +gem_cosmos = { path = "../gem_cosmos" } +gem_hash = { path = "../gem_hash" } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["client"] } +gem_client = { path = "../gem_client" } +gem_hypercore = { path = "../gem_hypercore" } +serde_serializers = { path = "../serde_serializers", features = ["bigint"] } +number_formatter = { path = "../number_formatter" } +sui-types = { workspace = true } +sui-transaction-builder = { package = "sui-transaction-builder", version = "0.3.1", default-features = false } + +reqwest = { workspace = true, optional = true } +typeshare = { version = "1.0.4" } + +strum = { workspace = true } + +gem_encoding = { path = "../gem_encoding" } +serde.workspace = true +serde_json.workspace = true +serde_urlencoded.workspace = true +bcs.workspace = true +async-trait.workspace = true +chrono.workspace = true +alloy-primitives.workspace = true +alloy-sol-types.workspace = true +hex.workspace = true +num-bigint.workspace = true +num-integer.workspace = true +num-traits.workspace = true +futures.workspace = true +bs58 = { workspace = true } +base64 = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +solana-primitives = "0.2.5" +bigdecimal.workspace = true +rand.workspace = true +tracing = "0.1.44" + +[dev-dependencies] +tokio.workspace = true +primitives = { path = "../primitives", features = ["testkit"] } +gem_client = { path = "../gem_client", features = ["testkit"] } +settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/swapper/src/across/api.rs b/core/crates/swapper/src/across/api.rs new file mode 100644 index 0000000000..6e8b3cd6a0 --- /dev/null +++ b/core/crates/swapper/src/across/api.rs @@ -0,0 +1,76 @@ +use crate::SwapperError; +use alloy_primitives::U256; +use alloy_sol_types::SolEvent; +use gem_evm::{across::contracts::V3SpokePoolInterface, parse_u256, rpc::model::Log}; + +pub(crate) struct ParsedDeposit { + pub deposit_id: u64, + pub origin_chain_id: u64, + pub destination_chain_id: Option, + pub input_token: Option, + pub output_token: Option, + pub input_amount: Option, + pub output_amount: Option, +} + +fn parse_topic_u64(topic: &str) -> Option { + parse_u256(topic).map(|v| v.to::()) +} + +fn decode_token_amounts(data: &[u8]) -> Option<(String, String, String, String)> { + if data.len() < 128 { + return None; + } + let input_token = format!("0x{}", alloy_primitives::hex::encode(&data[12..32])); + let output_token = format!("0x{}", alloy_primitives::hex::encode(&data[44..64])); + let input_amount = U256::from_be_slice(&data[64..96]).to_string(); + let output_amount = U256::from_be_slice(&data[96..128]).to_string(); + Some((input_token, output_token, input_amount, output_amount)) +} + +pub(crate) fn parse_deposit_from_logs(logs: &[Log], origin_chain_id: u64) -> Result { + let event_topics = [ + (format!("{:#x}", V3SpokePoolInterface::FundsDeposited::SIGNATURE_HASH), false), + (format!("{:#x}", V3SpokePoolInterface::V3FundsDeposited::SIGNATURE_HASH), false), + (format!("{:#x}", V3SpokePoolInterface::FilledRelay::SIGNATURE_HASH), true), + ]; + + let (log, is_fill) = event_topics + .iter() + .find_map(|(topic, is_fill)| logs.iter().find(|l| l.topics.first().is_some_and(|t| t == topic)).map(|l| (l, *is_fill))) + .ok_or_else(|| SwapperError::TransactionError("FundsDeposited event not found".into()))?; + + if log.topics.len() < 3 { + return Err(SwapperError::TransactionError("invalid event topics".into())); + } + + let deposit_id = parse_topic_u64(&log.topics[2]).ok_or_else(|| SwapperError::TransactionError("failed to parse deposit ID".into()))?; + + let (origin, destination) = if is_fill { + let origin = parse_topic_u64(&log.topics[1]).ok_or_else(|| SwapperError::TransactionError("failed to parse origin chain ID".into()))?; + (origin, None) + } else { + let destination = parse_topic_u64(&log.topics[1]); + (origin_chain_id, destination) + }; + + let (input_token, output_token, input_amount, output_amount) = alloy_primitives::hex::decode(&log.data) + .ok() + .and_then(|d| decode_token_amounts(&d)) + .map(|(a, b, c, d)| (Some(a), Some(b), Some(c), Some(d))) + .unwrap_or_default(); + + Ok(ParsedDeposit { + deposit_id, + origin_chain_id: origin, + destination_chain_id: destination, + input_token, + output_token, + input_amount, + output_amount, + }) +} + +pub(crate) fn filled_relay_topic() -> String { + format!("{:#x}", V3SpokePoolInterface::FilledRelay::SIGNATURE_HASH) +} diff --git a/core/crates/swapper/src/across/config_store.rs b/core/crates/swapper/src/across/config_store.rs new file mode 100644 index 0000000000..ff8f01e6e2 --- /dev/null +++ b/core/crates/swapper/src/across/config_store.rs @@ -0,0 +1,92 @@ +use crate::{ + SwapperError, + alien::{RpcClient, RpcProvider}, + client_factory::create_client_with_chain, +}; +use alloy_primitives::{Address, hex::decode as HexDecode}; +use alloy_sol_types::SolCall; +use gem_evm::{ + across::{contracts::AcrossConfigStore, fees}, + jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, + multicall3::IMulticall3, +}; +use gem_jsonrpc::{JsonRpcClient, types::JsonRpcResult}; +use primitives::{Chain, contract_constants::ETHEREUM_ACROSS_CONFIG_STORE_CONTRACT}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; + +const CONFIG_CACHE_TTL: u64 = 60 * 60 * 24; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RateModel { + #[serde(rename = "UBar")] + pub ubar: String, + #[serde(rename = "R0")] + pub r0: String, + #[serde(rename = "R1")] + pub r1: String, + #[serde(rename = "R2")] + pub r2: String, +} + +impl From for fees::RateModel { + fn from(value: RateModel) -> Self { + Self { + ubar: value.ubar.parse().unwrap(), + r0: value.r0.parse().unwrap(), + r1: value.r1.parse().unwrap(), + r2: value.r2.parse().unwrap(), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TokenConfig { + pub rate_model: RateModel, + pub route_rate_model: HashMap, +} + +pub struct ConfigStoreClient { + pub contract: String, + pub client: JsonRpcClient, +} + +impl ConfigStoreClient { + pub fn new(provider: Arc, chain: Chain) -> ConfigStoreClient { + ConfigStoreClient { + contract: ETHEREUM_ACROSS_CONFIG_STORE_CONTRACT.into(), + client: create_client_with_chain(provider.clone(), chain), + } + } + + pub fn config_call3(&self, l1token: &Address) -> IMulticall3::Call3 { + IMulticall3::Call3 { + target: self.contract.parse().unwrap(), + allowFailure: true, + callData: AcrossConfigStore::l1TokenConfigCall { l1Token: *l1token }.abi_encode().into(), + } + } + + pub fn decoded_config_call3(&self, result: &IMulticall3::Result) -> Result { + if result.success { + let decoded = AcrossConfigStore::l1TokenConfigCall::abi_decode_returns(&result.returnData).map_err(SwapperError::from)?; + let result: TokenConfig = serde_json::from_str(&decoded).map_err(SwapperError::from)?; + Ok(result) + } else { + Err(SwapperError::ComputeQuoteError("config call failed".into())) + } + } + + pub async fn fetch_config(&self, l1token: &Address) -> Result { + let data = AcrossConfigStore::l1TokenConfigCall { l1Token: *l1token }.abi_encode(); + let call = EthereumRpc::Call(TransactionObject::new_call(&self.contract, data), BlockParameter::Latest); + let response: JsonRpcResult = self.client.call_with_cache(&call, Some(CONFIG_CACHE_TTL)).await?; + let result = response.take()?; + let hex_data = HexDecode(result).map_err(SwapperError::compute_quote_error)?; + let decoded = AcrossConfigStore::l1TokenConfigCall::abi_decode_returns(&hex_data).map_err(SwapperError::from)?; + + let result: TokenConfig = serde_json::from_str(&decoded).map_err(SwapperError::from)?; + Ok(result) + } +} diff --git a/core/crates/swapper/src/across/hubpool.rs b/core/crates/swapper/src/across/hubpool.rs new file mode 100644 index 0000000000..7e03f393e3 --- /dev/null +++ b/core/crates/swapper/src/across/hubpool.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use alloy_primitives::{Address, U256, hex::decode as HexDecode}; +use alloy_sol_types::SolCall; +use num_bigint::{BigInt, Sign}; +use primitives::{Chain, contract_constants::ETHEREUM_ACROSS_HUB_POOL_CONTRACT}; + +use crate::{ + SwapperError, + alien::{RpcClient, RpcProvider}, + client_factory::create_client_with_chain, +}; +use gem_evm::{ + across::contracts::HubPoolInterface, + jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, + multicall3::{IMulticall3, create_call3, decode_call3_return}, +}; +use gem_jsonrpc::JsonRpcClient; + +pub struct HubPoolClient { + pub contract: String, + pub client: JsonRpcClient, + pub chain: Chain, +} + +impl HubPoolClient { + pub fn new(provider: Arc, chain: Chain) -> HubPoolClient { + HubPoolClient { + contract: ETHEREUM_ACROSS_HUB_POOL_CONTRACT.into(), + client: create_client_with_chain(provider.clone(), chain), + chain, + } + } + + pub fn paused_call3(&self) -> IMulticall3::Call3 { + create_call3(&self.contract, HubPoolInterface::pausedCall {}) + } + + pub fn decoded_paused_call3(&self, result: &IMulticall3::Result) -> Result { + let value = decode_call3_return::(result).map_err(SwapperError::compute_quote_error)?; + Ok(value) + } + + pub fn sync_call3(&self, l1token: &Address) -> IMulticall3::Call3 { + IMulticall3::Call3 { + target: self.contract.parse().unwrap(), + allowFailure: true, + callData: HubPoolInterface::syncCall { l1Token: *l1token }.abi_encode().into(), + } + } + + pub fn pooled_token_call3(&self, l1token: &Address) -> IMulticall3::Call3 { + IMulticall3::Call3 { + target: self.contract.parse().unwrap(), + allowFailure: true, + callData: HubPoolInterface::pooledTokensCall { l1Token: *l1token }.abi_encode().into(), + } + } + + pub fn decoded_pooled_token_call3(&self, result: &IMulticall3::Result) -> Result { + if result.success { + let decoded = HubPoolInterface::pooledTokensCall::abi_decode_returns(&result.returnData).map_err(SwapperError::compute_quote_error)?; + Ok(decoded) + } else { + Err(SwapperError::ComputeQuoteError("pooled token call failed".into())) + } + } + + pub fn utilization_call3(&self, l1_token: &Address, amount: U256) -> IMulticall3::Call3 { + let data = if amount.is_zero() { + HubPoolInterface::liquidityUtilizationCurrentCall { l1Token: *l1_token }.abi_encode() + } else { + HubPoolInterface::liquidityUtilizationPostRelayCall { + l1Token: *l1_token, + relayedAmount: amount, + } + .abi_encode() + }; + IMulticall3::Call3 { + target: self.contract.parse().unwrap(), + allowFailure: true, + callData: data.into(), + } + } + + pub fn decoded_utilization_call3(&self, result: &IMulticall3::Result) -> Result { + if result.success { + let value = HubPoolInterface::liquidityUtilizationCurrentCall::abi_decode_returns(&result.returnData).map_err(SwapperError::from)?; + + Ok(BigInt::from_bytes_le(Sign::Plus, &value.to_le_bytes::<32>())) + } else { + Err(SwapperError::ComputeQuoteError("utilization call failed".into())) + } + } + + pub fn get_current_time(&self) -> IMulticall3::Call3 { + create_call3(&self.contract, HubPoolInterface::getCurrentTimeCall {}) + } + + pub fn decoded_current_time(&self, result: &IMulticall3::Result) -> Result { + let value = decode_call3_return::(result).map_err(SwapperError::compute_quote_error)?; + value.try_into().map_err(|_| SwapperError::ComputeQuoteError("decode current time failed".into())) + } + + pub async fn fetch_utilization(&self, pool_token: &Address, amount: U256) -> Result { + let call3 = self.utilization_call3(pool_token, amount); + let call = EthereumRpc::Call(TransactionObject::new_call(&self.contract, call3.callData.to_vec()), BlockParameter::Latest); + let result: String = self.client.request(call).await?; + let hex_data = HexDecode(result).map_err(SwapperError::compute_quote_error)?; + let value = HubPoolInterface::liquidityUtilizationCurrentCall::abi_decode_returns(&hex_data).map_err(SwapperError::from)?; + let result = BigInt::from_bytes_le(num_bigint::Sign::Plus, &value.to_le_bytes::<32>()); + Ok(result) + } +} diff --git a/core/crates/swapper/src/across/mod.rs b/core/crates/swapper/src/across/mod.rs new file mode 100644 index 0000000000..d51ff5db21 --- /dev/null +++ b/core/crates/swapper/src/across/mod.rs @@ -0,0 +1,10 @@ +pub mod provider; +pub use provider::Across; +pub mod api; +pub mod config_store; +pub mod hubpool; + +const DEFAULT_FILL_TIMEOUT: u32 = 60 * 60 * 6; // 6 hours +const DEFAULT_DEPOSIT_GAS_LIMIT: u64 = 180_000; // gwei +const DEFAULT_FILL_GAS_LIMIT: u64 = 120_000; // gwei +pub(super) const FILL_LOOKBACK_BLOCKS: u64 = 100_000; // max block range for RPC log queries diff --git a/core/crates/swapper/src/across/provider.rs b/core/crates/swapper/src/across/provider.rs new file mode 100644 index 0000000000..3ec1a2df78 --- /dev/null +++ b/core/crates/swapper/src/across/provider.rs @@ -0,0 +1,892 @@ +use super::{ + DEFAULT_FILL_TIMEOUT, FILL_LOOKBACK_BLOCKS, + api::{ParsedDeposit, filled_relay_topic, parse_deposit_from_logs}, + config_store::{ConfigStoreClient, TokenConfig}, + hubpool::HubPoolClient, +}; +use crate::{ + SwapResult, Swapper, SwapperError, SwapperProvider, SwapperQuoteData, + across::{DEFAULT_DEPOSIT_GAS_LIMIT, DEFAULT_FILL_GAS_LIMIT}, + alien::RpcProvider, + approval::{check_approval_erc20, get_swap_gas_limit_with_approval}, + chainlink::ChainlinkPriceFeed, + client_factory::create_eth_client, + cross_chain::VaultAddresses, + eth_address, + fees::{ReferralFee, default_referral_fees}, + models::*, +}; +use alloy_primitives::{ + Address, Bytes, U256, + hex::{decode as HexDecode, encode_prefixed as HexEncode}, +}; +use alloy_sol_types::{SolCall, SolValue}; +use async_trait::async_trait; +use gem_evm::{ + across::{ + contracts::{ + V3SpokePoolInterface::{self, V3RelayData}, + multicall_handler, + }, + deployment::AcrossDeployment, + fees::{self, LpFeeCalculator, RateModel, RelayerFeeCalculator}, + }, + contracts::erc20::IERC20, + jsonrpc::TransactionObject, + multicall3::IMulticall3, + weth::WETH9, +}; +use num_bigint::{BigInt, Sign}; +use primitives::{AssetId, Chain, EVMChain, TransactionSwapMetadata, known_assets::*, swap::ApprovalData}; +use serde_serializers::biguint_from_hex_str; +use std::{fmt::Debug, str::FromStr, sync::Arc}; + +fn resolve_token_asset(chain: Chain, token_address: &str) -> Option { + let evm_chain = EVMChain::from_chain(chain)?; + let address = gem_evm::ethereum_address_checksum(token_address).ok()?; + if evm_chain.weth_contract().is_some_and(|w| w == address) { + return Some(AssetId::from_chain(chain)); + } + Some(AssetId::from_token(chain, &address)) +} + +#[derive(Debug)] +pub struct Across { + pub provider: ProviderType, + rpc_provider: Arc, +} + +impl Across { + fn bigint_to_u256(value: &BigInt) -> Result { + if value.sign() == Sign::Minus { + return Err(SwapperError::ComputeQuoteError("Negative value provided for gas computation".into())); + } + + let bytes = value.to_bytes_be().1; + Ok(U256::from_be_slice(bytes.as_slice())) + } + + pub fn new(rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Across), + rpc_provider, + } + } + + pub fn boxed(rpc_provider: Arc) -> Box { + Box::new(Self::new(rpc_provider)) + } + + fn build_swap_metadata(deposit: &ParsedDeposit, destination_chain_id: u64) -> Option { + let origin_chain = Chain::from_chain_id(deposit.origin_chain_id)?; + let from_asset = resolve_token_asset(origin_chain, deposit.input_token.as_ref()?)?; + let to_chain = Chain::from_chain_id(destination_chain_id)?; + let to_asset = resolve_token_asset(to_chain, deposit.output_token.as_ref()?)?; + Some(TransactionSwapMetadata { + from_asset, + from_value: deposit.input_amount.clone()?, + to_asset, + to_value: deposit.output_amount.clone()?, + provider: Some(SwapperProvider::Across.as_ref().to_string()), + }) + } + + async fn check_fill_on_chain(&self, origin_chain_id: u64, deposit_id: u64, destination_chain: Chain) -> Result, SwapperError> { + let deployment = AcrossDeployment::deployment_by_chain(&destination_chain).ok_or(SwapperError::NotSupportedChain)?; + let client = create_eth_client(self.rpc_provider.clone(), destination_chain)?; + + let topic0 = filled_relay_topic(); + let topic1 = format!("{:#066x}", U256::from(origin_chain_id)); + let topic2 = format!("{:#066x}", U256::from(deposit_id)); + let topics = vec![Some(topic0), Some(topic1), Some(topic2)]; + + let current_block = client.get_latest_block().await.map_err(SwapperError::transaction_error)?; + let from_block = current_block.saturating_sub(FILL_LOOKBACK_BLOCKS); + let from_block_hex = format!("0x{:x}", from_block); + + let logs = client + .get_logs(deployment.spoke_pool, &topics, &from_block_hex, "latest") + .await + .map_err(SwapperError::from)?; + + Ok(logs.first().and_then(|l| l.transaction_hash.clone())) + } + + pub fn is_supported_pair(from_asset: &AssetId, to_asset: &AssetId) -> bool { + let Some(from) = eth_address::convert_native_to_weth(from_asset) else { + return false; + }; + let Some(to) = eth_address::convert_native_to_weth(to_asset) else { + return false; + }; + + AcrossDeployment::asset_mappings().into_iter().any(|x| x.set.contains(&from) && x.set.contains(&to)) + } + + pub fn get_rate_model(from_asset: &AssetId, to_asset: &AssetId, token_config: &TokenConfig) -> RateModel { + let key = format!("{}-{}", from_asset.chain.network_id(), to_asset.chain.network_id()); + let rate_model = token_config.route_rate_model.get(&key).unwrap_or(&token_config.rate_model); + rate_model.clone().into() + } + + async fn gas_price(&self, chain: Chain) -> Result { + let gas_price = create_eth_client(self.rpc_provider.clone(), chain)?.gas_price().await?; + Self::bigint_to_u256(&gas_price) + } + + async fn multicall3(&self, chain: Chain, calls: Vec) -> Result, SwapperError> { + create_eth_client(self.rpc_provider.clone(), chain)? + .multicall3(calls) + .await + .map_err(SwapperError::compute_quote_error) + } + + async fn estimate_gas_transaction(&self, chain: Chain, tx: TransactionObject) -> Result { + let client = create_eth_client(self.rpc_provider.clone(), chain)?; + let gas_hex = client.estimate_gas(tx.from.as_deref(), &tx.to, tx.value.as_deref(), Some(tx.data.as_str())).await?; + + let gas_biguint = biguint_from_hex_str(&gas_hex).map_err(|e| SwapperError::ComputeQuoteError(format!("Failed to parse gas estimate: {e}")))?; + let gas_bigint = BigInt::from_biguint(Sign::Plus, gas_biguint); + Self::bigint_to_u256(&gas_bigint) + } + + /// Return (message, referral_fee) + pub fn message_for_multicall_handler( + &self, + amount: &U256, + original_output_asset: &AssetId, + output_token: &Address, + user_address: &Address, + referral_fee: &ReferralFee, + ) -> (Vec, U256) { + if referral_fee.bps == 0 { + return (vec![], U256::from(0)); + } + let fee_address = Address::from_str(&referral_fee.address).unwrap(); + let fee_amount = amount * U256::from(referral_fee.bps) / U256::from(10000); + let user_amount = amount - fee_amount; + + let calls = if original_output_asset.is_native() { + // output_token is WETH and we need to unwrap it + Self::unwrap_weth_calls(output_token, amount, user_address, &user_amount, &fee_address, &fee_amount) + } else { + Self::erc20_transfer_calls(output_token, user_address, &user_amount, &fee_address, &fee_amount) + }; + let instructions = multicall_handler::Instructions { + calls, + fallbackRecipient: *user_address, + }; + let message = instructions.abi_encode(); + (message, fee_amount) + } + + fn unwrap_weth_calls( + weth_contract: &Address, + output_amount: &U256, + user_address: &Address, + user_amount: &U256, + fee_address: &Address, + fee_amount: &U256, + ) -> Vec { + assert!(fee_amount + user_amount == *output_amount); + let withdraw_call = WETH9::withdrawCall { wad: *output_amount }; + vec![ + multicall_handler::Call { + target: *weth_contract, + callData: withdraw_call.abi_encode().into(), + value: U256::from(0), + }, + multicall_handler::Call { + target: *user_address, + callData: Bytes::new(), + value: *user_amount, + }, + multicall_handler::Call { + target: *fee_address, + callData: Bytes::new(), + value: *fee_amount, + }, + ] + } + + fn erc20_transfer_calls(token: &Address, user_address: &Address, user_amount: &U256, fee_address: &Address, fee_amount: &U256) -> Vec { + let target = *token; + let user_transfer = IERC20::transferCall { + to: *user_address, + value: *user_amount, + }; + let fee_transfer = IERC20::transferCall { + to: *fee_address, + value: *fee_amount, + }; + vec![ + multicall_handler::Call { + target, + callData: user_transfer.abi_encode().into(), + value: U256::from(0), + }, + multicall_handler::Call { + target, + callData: fee_transfer.abi_encode().into(), + value: U256::from(0), + }, + ] + } + + pub async fn estimate_gas_limit( + &self, + amount: &U256, + is_native: bool, + input_asset: &AssetId, + output_token: &Address, + wallet_address: &Address, + message: &[u8], + deployment: &AcrossDeployment, + chain: Chain, + ) -> Result<(U256, V3RelayData), SwapperError> { + let chain_id: u32 = chain.network_id().parse().unwrap(); + + let recipient = if message.is_empty() { + *wallet_address + } else { + Address::from_str(deployment.multicall_handler().as_str()).unwrap() + }; + + let v3_relay_data = V3RelayData { + depositor: *wallet_address, + recipient, + exclusiveRelayer: Address::ZERO, + inputToken: Address::from_str(input_asset.token_id.clone().unwrap().as_ref()).unwrap(), + outputToken: *output_token, + inputAmount: *amount, + outputAmount: U256::from(100), // safe amount + originChainId: U256::from(chain_id), + depositId: u32::MAX, + fillDeadline: u32::MAX, + exclusivityDeadline: 0, + message: Bytes::from(message.to_vec()), + }; + let value = if is_native { format!("{amount:#x}") } else { String::from("0x0") }; + let data = V3SpokePoolInterface::fillV3RelayCall { + relayData: v3_relay_data.clone(), + repaymentChainId: U256::from(chain_id), + } + .abi_encode(); + let tx = TransactionObject::new_call_to_value(deployment.spoke_pool, &value, data); + let gas_limit = self.estimate_gas_transaction(chain, tx).await.unwrap_or(U256::from(Self::get_default_fill_limit(chain))); + Ok((gas_limit, v3_relay_data)) + } + + fn get_default_fill_limit(chain: Chain) -> u64 { + match chain { + Chain::Monad => DEFAULT_FILL_GAS_LIMIT * 3, + _ => DEFAULT_FILL_GAS_LIMIT, + } + } + + async fn usd_price_for_chain(&self, chain: Chain, existing_results: &[IMulticall3::Result]) -> Result { + let feed = ChainlinkPriceFeed::new_usd_feed_for_chain(chain).ok_or(SwapperError::NotSupportedChain)?; + if chain == Chain::Monad { + let results = create_eth_client(self.rpc_provider.clone(), Chain::Monad)? + .multicall3(vec![feed.latest_round_call3()]) + .await + .map_err(SwapperError::compute_quote_error)?; + ChainlinkPriceFeed::decoded_answer(&results[0]) + } else { + ChainlinkPriceFeed::decoded_answer(&existing_results[3]) + } + } + + pub fn update_v3_relay_data( + &self, + v3_relay_data: &mut V3RelayData, + user_address: &Address, + output_amount: &U256, + original_output_asset: &AssetId, + output_token: &Address, + timestamp: u32, + referral_fee: &ReferralFee, + ) -> Result<(), SwapperError> { + let (message, _) = self.message_for_multicall_handler(output_amount, original_output_asset, output_token, user_address, referral_fee); + + v3_relay_data.outputAmount = *output_amount; + v3_relay_data.fillDeadline = timestamp + DEFAULT_FILL_TIMEOUT; + v3_relay_data.message = message.into(); + + Ok(()) + } + + pub fn calculate_fee_in_token(fee_in_wei: &U256, token_price: &BigInt, token_decimals: u32) -> U256 { + let fee = BigInt::from_bytes_le(Sign::Plus, &fee_in_wei.to_le_bytes::<32>()); + let fee_in_token = fee * token_price * BigInt::from(10_u64.pow(token_decimals)) / BigInt::from(10_u64.pow(8)) / BigInt::from(10_u64.pow(18)); + U256::from_le_slice(&fee_in_token.to_bytes_le().1) + } + + pub fn get_eta_in_seconds(&self, from_chain: &Chain, to_chain: &Chain) -> Option { + let from_chain = EVMChain::from_chain(*from_chain)?; + let to_chain = EVMChain::from_chain(*to_chain)?; + let from_chain_l2 = from_chain.is_ethereum_layer2(); + let to_chain_l2 = to_chain.is_ethereum_layer2(); + Some(match (from_chain_l2, to_chain_l2) { + (true, true) => 5, // L2 to L2 + (true, false) => 10, // L2 to L1 + (false, _) => 20, // L1 to L2 + }) + } +} + +#[async_trait] +impl Swapper for Across { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + vec![ + SwapperChainAsset::Assets(Chain::Arbitrum, vec![ARBITRUM_WETH.id.clone(), ARBITRUM_USDC.id.clone(), ARBITRUM_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::Ethereum, vec![ETHEREUM_WETH.id.clone(), ETHEREUM_USDC.id.clone(), ETHEREUM_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::Base, vec![BASE_WETH.id.clone(), BASE_USDC.id.clone()]), + SwapperChainAsset::Assets(Chain::Blast, vec![BLAST_WETH.id.clone()]), + SwapperChainAsset::Assets(Chain::Linea, vec![LINEA_WETH.id.clone(), LINEA_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::Optimism, vec![OPTIMISM_WETH.id.clone(), OPTIMISM_USDC.id.clone(), OPTIMISM_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::Polygon, vec![POLYGON_WETH.id.clone()]), + SwapperChainAsset::Assets(Chain::ZkSync, vec![ZKSYNC_WETH.id.clone(), ZKSYNC_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::World, vec![WORLD_WETH.id.clone()]), + SwapperChainAsset::Assets(Chain::Ink, vec![INK_WETH.id.clone(), INK_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::Unichain, vec![UNICHAIN_WETH.id.clone(), UNICHAIN_USDC.id.clone()]), + SwapperChainAsset::Assets(Chain::Monad, vec![MONAD_USDC.id.clone(), MONAD_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::SmartChain, vec![SMARTCHAIN_ETH.id.clone()]), + SwapperChainAsset::Assets(Chain::Hyperliquid, vec![HYPEREVM_USDC.id.clone(), HYPEREVM_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::Plasma, vec![PLASMA_USDT.id.clone()]), + ] + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + if request.from_asset.chain() == request.to_asset.chain() { + return Err(SwapperError::NoQuoteAvailable); + } + + let input_is_native = request.from_asset.is_native(); + let from_chain = EVMChain::from_chain(request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + let from_amount: U256 = request.value.parse().map_err(SwapperError::from)?; + let wallet_address = eth_address::parse_str(&request.wallet_address)?; + + let _ = AcrossDeployment::deployment_by_chain(&request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + let destination_deployment = AcrossDeployment::deployment_by_chain(&request.to_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + if !Self::is_supported_pair(&request.from_asset.asset_id(), &request.to_asset.asset_id()) { + return Err(SwapperError::NoQuoteAvailable); + } + + let input_asset = eth_address::convert_native_to_weth(&request.from_asset.asset_id()).ok_or(SwapperError::NotSupportedAsset)?; + let output_asset = eth_address::convert_native_to_weth(&request.to_asset.asset_id()).ok_or(SwapperError::NotSupportedAsset)?; + let original_output_asset = request.to_asset.asset_id(); + let output_token = eth_address::parse_asset_id(&output_asset)?; + + // Get L1 token address + let mappings = AcrossDeployment::asset_mappings(); + let asset_mapping = mappings.iter().find(|x| x.set.contains(&input_asset)).unwrap(); + let asset_mainnet = asset_mapping.set.iter().find(|x| x.chain == Chain::Ethereum).unwrap(); + let mainnet_token = eth_address::parse_or_weth_address(asset_mainnet, from_chain)?; + + let hubpool_client = HubPoolClient::new(self.rpc_provider.clone(), Chain::Ethereum); + let config_client = ConfigStoreClient::new(self.rpc_provider.clone(), Chain::Ethereum); + + let calls = vec![ + hubpool_client.paused_call3(), + hubpool_client.sync_call3(&mainnet_token), + hubpool_client.pooled_token_call3(&mainnet_token), + ]; + let results = self.multicall3(hubpool_client.chain, calls).await?; + + // Check if protocol is paused + let is_paused = hubpool_client.decoded_paused_call3(&results[0])?; + if is_paused { + return Err(SwapperError::ComputeQuoteError("Across protocol is paused".into())); + } + + // Check bridge amount is too large (Across API has some limit in USD amount but we don't have that info) + if from_amount > hubpool_client.decoded_pooled_token_call3(&results[2])?.liquidReserves { + return Err(SwapperError::ComputeQuoteError("Bridge amount is too large".into())); + } + + // Prepare data for lp fee calculation (token config, utilization, current time) + let token_config_req = config_client.fetch_config(&mainnet_token); // cache is used inside config_client + let mut calls = vec![ + hubpool_client.utilization_call3(&mainnet_token, U256::from(0)), + hubpool_client.utilization_call3(&mainnet_token, from_amount), + hubpool_client.get_current_time(), + ]; + + let gas_price_feed = ChainlinkPriceFeed::new_usd_feed_for_chain(request.to_asset.chain()).unwrap_or_else(ChainlinkPriceFeed::new_eth_usd_feed); + if !input_is_native { + calls.push(gas_price_feed.latest_round_call3()); + } + + let multicall_results = self.multicall3(hubpool_client.chain, calls).await?; + let token_config = token_config_req.await?; + + let util_before = hubpool_client.decoded_utilization_call3(&multicall_results[0])?; + let util_after = hubpool_client.decoded_utilization_call3(&multicall_results[1])?; + let timestamp = hubpool_client.decoded_current_time(&multicall_results[2])?; + + let rate_model = Self::get_rate_model(&input_asset, &output_asset, &token_config); + let cost_config = &asset_mapping.capital_cost; + + // Calculate lp fee + let lpfee_calc = LpFeeCalculator::new(rate_model); + let lpfee_percent = lpfee_calc.realized_lp_fee_pct(&util_before, &util_after, false); + let lpfee = fees::multiply(from_amount, lpfee_percent, cost_config.decimals); + + // Calculate relayer fee + let relayer_calc = RelayerFeeCalculator::default(); + let relayer_fee_percent = relayer_calc.capital_fee_percent(&BigInt::from_str(&request.value).unwrap(), cost_config); + let relayer_fee = fees::multiply(from_amount, relayer_fee_percent, cost_config.decimals); + + let referral_config = default_referral_fees().evm; + + // Calculate gas limit / price for relayer + let remain_amount = from_amount - lpfee - relayer_fee; + let (message, referral_fee) = self.message_for_multicall_handler(&remain_amount, &original_output_asset, &wallet_address, &output_token, &referral_config); + + let gas_price = self.gas_price(request.to_asset.chain()).await?; + let (gas_limit, mut v3_relay_data) = self + .estimate_gas_limit( + &from_amount, + input_is_native, + &input_asset, + &output_token, + &wallet_address, + &message, + &destination_deployment, + request.to_asset.chain(), + ) + .await?; + let mut gas_fee = gas_limit * gas_price; + if !input_is_native { + let price = self.usd_price_for_chain(request.to_asset.chain(), &multicall_results).await?; + gas_fee = Self::calculate_fee_in_token(&gas_fee, &price, 6); + } + + // Check if bridge amount is too small + if remain_amount < gas_fee { + return Err(SwapperError::InputAmountError { min_amount: None }); + } + + let output_amount = remain_amount - gas_fee; + let to_value = output_amount - referral_fee; + + // Update v3 relay data (was used to estimate gas limit) with final output amount, quote timestamp and referral fee. + self.update_v3_relay_data( + &mut v3_relay_data, + &wallet_address, + &output_amount, + &original_output_asset, + &output_token, + timestamp, + &referral_config, + )?; + let route_data = HexEncode(v3_relay_data.abi_encode()); + + Ok(Quote { + from_value: request.value.clone(), + min_from_value: None, + to_value: to_value.to_string(), + data: ProviderData { + provider: self.provider().clone(), + slippage_bps: request.options.slippage.bps, + routes: vec![Route { + input: input_asset.clone(), + output: output_asset.clone(), + route_data, + }], + }, + request: request.clone(), + eta_in_seconds: self.get_eta_in_seconds(&request.from_asset.chain(), &request.to_asset.chain()), + }) + } + + async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { + let from_chain = quote.request.from_asset.chain(); + let deployment = AcrossDeployment::deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; + let dst_chain_id: u32 = quote.request.to_asset.chain().network_id().parse().unwrap(); + let route = "e.data.routes[0]; + let route_data = HexDecode(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + let v3_relay_data = V3RelayData::abi_decode(&route_data).map_err(|_| SwapperError::InvalidRoute)?; + + let deposit_v3_call = V3SpokePoolInterface::depositV3Call { + depositor: v3_relay_data.depositor, + recipient: v3_relay_data.recipient, + inputToken: v3_relay_data.inputToken, + outputToken: v3_relay_data.outputToken, + inputAmount: v3_relay_data.inputAmount, + outputAmount: v3_relay_data.outputAmount, + destinationChainId: U256::from(dst_chain_id), + exclusiveRelayer: Address::ZERO, + quoteTimestamp: v3_relay_data.fillDeadline - DEFAULT_FILL_TIMEOUT, + fillDeadline: v3_relay_data.fillDeadline, + exclusivityDeadline: 0, + message: v3_relay_data.message, + } + .abi_encode(); + + let input_is_native = quote.request.from_asset.is_native(); + let value: &str = if input_is_native { "e.from_value } else { "0" }; + + let approval: Option = { + if input_is_native { + None + } else { + check_approval_erc20( + quote.request.wallet_address.clone(), + v3_relay_data.inputToken.to_string(), + deployment.spoke_pool.into(), + v3_relay_data.inputAmount, + self.rpc_provider.clone(), + &from_chain, + ) + .await? + .approval_data() + } + }; + + let to: String = deployment.spoke_pool.into(); + let mut gas_limit = get_swap_gas_limit_with_approval(&approval, None, DEFAULT_DEPOSIT_GAS_LIMIT); + + if matches!(data, FetchQuoteData::EstimateGas) { + let hex_value = format!("{:#x}", U256::from_str(value).unwrap()); + let tx = TransactionObject::new_call_to_value(&to, &hex_value, deposit_v3_call.clone()); + gas_limit = Some(self.estimate_gas_transaction(from_chain, tx).await?.to_string()); + } + + Ok(SwapperQuoteData::new_contract( + deployment.spoke_pool.into(), + value.to_string(), + HexEncode(deposit_v3_call.clone()), + approval, + gas_limit, + )) + } + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + Ok(VaultAddresses { + deposit: AcrossDeployment::deposit_addresses(), + send: AcrossDeployment::send_addresses(), + }) + } + + async fn get_swap_result(&self, chain: Chain, transaction_hash: &str) -> Result { + let receipt = create_eth_client(self.rpc_provider.clone(), chain)? + .get_transaction_receipt(transaction_hash) + .await + .map_err(SwapperError::from)?; + + let origin_chain_id: u64 = chain.network_id().parse().map_err(|_| SwapperError::NotSupportedChain)?; + let deposit = parse_deposit_from_logs(&receipt.logs, origin_chain_id)?; + + if let Some(destination_chain_id) = deposit.destination_chain_id { + let destination_chain = Chain::from_chain_id(destination_chain_id).ok_or(SwapperError::NotSupportedChain)?; + let fill_tx = self.check_fill_on_chain(deposit.origin_chain_id, deposit.deposit_id, destination_chain).await?; + + if fill_tx.is_some() { + let metadata = Self::build_swap_metadata(&deposit, destination_chain_id); + Ok(SwapResult { + status: primitives::swap::SwapStatus::Completed, + metadata, + }) + } else { + Ok(SwapResult { + status: primitives::swap::SwapStatus::Pending, + metadata: None, + }) + } + } else { + Ok(SwapResult { + status: primitives::swap::SwapStatus::Pending, + metadata: None, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_sol_types::SolEvent; + use gem_evm::{multicall3::IMulticall3, rpc::model::Log}; + use primitives::asset_constants::*; + + #[test] + fn test_is_supported_pair() { + let weth_eth: AssetId = ETHEREUM_WETH_ASSET_ID.clone(); + let weth_op: AssetId = OPTIMISM_WETH_ASSET_ID.clone(); + let weth_arb: AssetId = ARBITRUM_WETH_ASSET_ID.clone(); + let weth_bsc: AssetId = SMARTCHAIN_ETH_ASSET_ID.clone(); + + let usdc_eth: AssetId = ETHEREUM_USDC_ASSET_ID.clone(); + let usdc_arb: AssetId = ARBITRUM_USDC_ASSET_ID.clone(); + let usdc_monad: AssetId = MONAD_USDC_ASSET_ID.clone(); + let usdt_eth: AssetId = ETHEREUM_USDT_ASSET_ID.clone(); + let usdt_monad: AssetId = MONAD_USDT_ASSET_ID.clone(); + + assert!(Across::is_supported_pair(&weth_eth, &weth_op)); + assert!(Across::is_supported_pair(&weth_op, &weth_arb)); + assert!(Across::is_supported_pair(&usdc_eth, &usdc_arb)); + assert!(Across::is_supported_pair(&usdc_monad, &usdc_eth)); + assert!(Across::is_supported_pair(&usdt_monad, &usdt_eth)); + assert!(Across::is_supported_pair(&weth_eth, &weth_bsc)); + + assert!(!Across::is_supported_pair(&weth_eth, &usdc_eth)); + + // native asset + let eth = AssetId::from(Chain::Ethereum, None); + let op = AssetId::from(Chain::Optimism, None); + let arb = AssetId::from(Chain::Arbitrum, None); + let linea = AssetId::from(Chain::Linea, None); + + assert!(Across::is_supported_pair(ð, &linea)); + assert!(Across::is_supported_pair(&op, ð)); + assert!(Across::is_supported_pair(&arb, ð)); + assert!(Across::is_supported_pair(&op, &arb)); + } + + #[test] + fn test_fee_in_token() { + let data = HexDecode("0x00000000000000000000000000000000000000000000000700000000000013430000000000000000000000000000000000000000000000000000004e17511aea00000000000000000000000000000000000000000000000000000000677e57a600000000000000000000000000000000000000000000000000000000677e57bb0000000000000000000000000000000000000000000000070000000000001343").unwrap(); + let result = IMulticall3::Result { + success: true, + returnData: data.into(), + }; + let price = ChainlinkPriceFeed::decoded_answer(&result).unwrap(); + + assert_eq!(price, BigInt::from(335398640362_u64)); + + let gas_fee = U256::from(1861602902696880_u64); + let fee_in_token = Across::calculate_fee_in_token(&gas_fee, &price, 6); + + assert_eq!(fee_in_token.to_string(), "6243790"); + } + + #[test] + fn test_resolve_token_asset_native_eth_via_weth() { + let result = resolve_token_asset(Chain::Ethereum, ETHEREUM_WETH_TOKEN_ID); + assert_eq!(result, Some(AssetId::from_chain(Chain::Ethereum))); + } + + #[test] + fn test_resolve_token_asset_native_arb_via_weth() { + let result = resolve_token_asset(Chain::Arbitrum, ARBITRUM_WETH_TOKEN_ID); + assert_eq!(result, Some(AssetId::from_chain(Chain::Arbitrum))); + } + + #[test] + fn test_resolve_token_asset_usdc_checksummed() { + let result = resolve_token_asset(Chain::Ethereum, ÐEREUM_USDC_TOKEN_ID.to_ascii_lowercase()); + assert_eq!(result, Some(ETHEREUM_USDC_ASSET_ID.clone())); + } + + #[test] + fn test_resolve_token_asset_unsupported_chain() { + let result = resolve_token_asset(Chain::Bitcoin, "0x123"); + assert_eq!(result, None); + } + + #[test] + fn test_parse_v3_funds_deposited() { + let input_amount = U256::from(1_000_000_000_000_000_000u64); + let output_amount = U256::from(999_000_000_000_000_000u64); + let log = build_event_log( + V3SpokePoolInterface::V3FundsDeposited::SIGNATURE_HASH, + &[42161, 12345, 0], + ETHEREUM_WETH_TOKEN_ID, + ARBITRUM_WETH_TOKEN_ID, + input_amount, + output_amount, + ); + + let result = parse_deposit_from_logs(&[log], 1).unwrap(); + assert_eq!(result.deposit_id, 12345); + assert_eq!(result.origin_chain_id, 1); + assert_eq!(result.destination_chain_id.unwrap(), 42161); + assert_eq!(result.input_token.unwrap(), ETHEREUM_WETH_TOKEN_ID.to_ascii_lowercase()); + assert_eq!(result.output_token.unwrap(), ARBITRUM_WETH_TOKEN_ID.to_ascii_lowercase()); + assert_eq!(result.input_amount.unwrap(), input_amount.to_string()); + assert_eq!(result.output_amount.unwrap(), output_amount.to_string()); + } + + #[test] + fn test_parse_new_funds_deposited() { + let input_amount = U256::from(20_000_000_000_000_000u64); + let output_amount = U256::from(19_900_000_000_000_000u64); + let log = build_event_log( + V3SpokePoolInterface::FundsDeposited::SIGNATURE_HASH, + &[8453, 5452553, 0], + ETHEREUM_WETH_TOKEN_ID, + BASE_WETH_TOKEN_ID, + input_amount, + output_amount, + ); + + let result = parse_deposit_from_logs(&[log], 1).unwrap(); + assert_eq!(result.deposit_id, 5452553); + assert_eq!(result.destination_chain_id.unwrap(), 8453); + assert_eq!(result.input_token.unwrap(), ETHEREUM_WETH_TOKEN_ID.to_ascii_lowercase()); + assert_eq!(result.output_token.unwrap(), BASE_WETH_TOKEN_ID.to_ascii_lowercase()); + assert_eq!(result.input_amount.unwrap(), input_amount.to_string()); + assert_eq!(result.output_amount.unwrap(), output_amount.to_string()); + } + + #[test] + fn test_parse_filled_relay() { + let input_amount = U256::from(20_200_000_000_000_000u64); + let output_amount = U256::from(20_197_000_000_000_000u64); + let log = build_event_log( + V3SpokePoolInterface::FilledRelay::SIGNATURE_HASH, + &[1, 3708468, 0], + ETHEREUM_WETH_TOKEN_ID, + BASE_WETH_TOKEN_ID, + input_amount, + output_amount, + ); + + let result = parse_deposit_from_logs(&[log], 8453).unwrap(); + assert_eq!(result.deposit_id, 3708468); + assert_eq!(result.origin_chain_id, 1); + assert!(result.destination_chain_id.is_none()); + assert_eq!(result.input_token.unwrap(), ETHEREUM_WETH_TOKEN_ID.to_ascii_lowercase()); + assert_eq!(result.output_token.unwrap(), BASE_WETH_TOKEN_ID.to_ascii_lowercase()); + } + + #[test] + fn test_parse_no_matching_event() { + let log = Log { + address: String::new(), + topics: vec!["0xdeadbeef".into()], + data: "0x".into(), + transaction_hash: None, + }; + assert!(parse_deposit_from_logs(&[log], 1).is_err()); + assert!(parse_deposit_from_logs(&[], 1).is_err()); + } + + fn build_event_log(signature: alloy_primitives::FixedBytes<32>, indexed: &[u64], input_token: &str, output_token: &str, input_amount: U256, output_amount: U256) -> Log { + let mut data = Vec::new(); + let mut buf = [0u8; 32]; + let input_bytes = alloy_primitives::hex::decode(input_token.strip_prefix("0x").unwrap_or(input_token)).unwrap(); + buf[32 - input_bytes.len()..].copy_from_slice(&input_bytes); + data.extend_from_slice(&buf); + buf = [0u8; 32]; + let output_bytes = alloy_primitives::hex::decode(output_token.strip_prefix("0x").unwrap_or(output_token)).unwrap(); + buf[32 - output_bytes.len()..].copy_from_slice(&output_bytes); + data.extend_from_slice(&buf); + data.extend_from_slice(&input_amount.to_be_bytes::<32>()); + data.extend_from_slice(&output_amount.to_be_bytes::<32>()); + data.extend_from_slice(&[0u8; 512]); + + let mut topics = vec![format!("{:#x}", signature)]; + for t in indexed { + topics.push(format!("{:#066x}", t)); + } + + Log { + address: String::new(), + topics, + data: HexEncode(&data), + transaction_hash: None, + } + } + + #[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] + mod swap_integration_tests { + use super::*; + use crate::{FetchQuoteData, NativeProvider, Options, QuoteRequest, SwapperError, fees::ReferralFee}; + use primitives::{AssetId, Chain, swap::SwapStatus}; + use std::{sync::Arc, time::SystemTime}; + + #[tokio::test] + async fn test_across_quote() -> Result<(), SwapperError> { + let network_provider = Arc::new(NativeProvider::default()); + let swap_provider = Across::boxed(network_provider.clone()); + let options = Options { + slippage: 100.into(), + use_max_amount: false, + }; + + let request = QuoteRequest { + from_asset: AssetId::from_chain(Chain::Optimism).into(), + to_asset: AssetId::from_chain(Chain::Arbitrum).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "20000000000000000".into(), // 0.02 ETH + options, + }; + + let now = SystemTime::now(); + let quote = swap_provider.get_quote(&request).await?; + let elapsed = SystemTime::now().duration_since(now).unwrap(); + + println!("<== elapsed: {:?}", elapsed); + println!("<== quote: {:?}", quote); + assert!(quote.to_value.parse::().unwrap() > 0); + + let quote_data = swap_provider.get_quote_data("e, FetchQuoteData::EstimateGas).await?; + println!("<== quote_data: {:?}", quote_data); + + Ok(()) + } + + #[tokio::test] + async fn test_across_quote_eth_usdc_to_monad_usdc() -> Result<(), SwapperError> { + let network_provider = Arc::new(NativeProvider::default()); + let swap_provider = Across::boxed(network_provider.clone()); + let options = Options { + slippage: 100.into(), + use_max_amount: false, + }; + + let wallet = "0x9b1fe00135e0ff09389bfaeff0c8f299ec818d4a"; + let from_asset: AssetId = ETHEREUM_USDC_ASSET_ID.clone(); + let to_asset: AssetId = MONAD_USDC_ASSET_ID.clone(); + let request = QuoteRequest { + from_asset: from_asset.into(), + to_asset: to_asset.into(), + wallet_address: wallet.into(), + destination_address: wallet.into(), + value: "50000000".into(), // 50 USDC + options, + }; + + let now = SystemTime::now(); + let quote = swap_provider.get_quote(&request).await?; + let elapsed = SystemTime::now().duration_since(now).unwrap(); + + println!("<== elapsed: {:?}", elapsed); + println!("<== quote: {:?}", quote); + assert!(quote.to_value.parse::().unwrap() > 0); + + let quote_data = swap_provider.get_quote_data("e, FetchQuoteData::None).await?; + println!("<== quote_data: {:?}", quote_data); + + Ok(()) + } + + #[tokio::test] + async fn test_get_swap_result() -> Result<(), Box> { + let network_provider = Arc::new(NativeProvider::default()); + let swap_provider = Across::new(network_provider.clone()); + + let tx_hash = "0x2ed43336441c830e859dada09e6eee6b5ee5b160e0e420fdf17f6e46dc240e88"; + let chain = Chain::Base; + + let result = swap_provider.get_swap_result(chain, tx_hash).await?; + + println!("Across swap result: {:?}", result); + assert_eq!(result.status, SwapStatus::Completed); + + let metadata = result.metadata.unwrap(); + assert_eq!(metadata.provider, Some("across".to_string())); + assert!(!metadata.from_value.is_empty()); + assert!(!metadata.to_value.is_empty()); + + Ok(()) + } + } +} diff --git a/core/crates/swapper/src/alien/mock.rs b/core/crates/swapper/src/alien/mock.rs new file mode 100644 index 0000000000..87c2cff0c9 --- /dev/null +++ b/core/crates/swapper/src/alien/mock.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use std::{ + fmt::{self, Debug}, + time::Duration, +}; + +use super::{AlienError, Target}; +use gem_jsonrpc::RpcResponse; +use gem_jsonrpc::rpc::RpcProvider as GenericRpcProvider; +use primitives::Chain; + +#[allow(unused)] +pub struct MockFn(pub Box String + Send + Sync>); + +impl fmt::Debug for MockFn { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("MockFn").finish() + } +} + +#[allow(unused)] +#[derive(Debug)] +pub struct ProviderMock { + pub response: MockFn, + pub timeout: Duration, +} + +#[allow(unused)] +impl ProviderMock { + pub fn new(string: String) -> Self { + Self { + response: MockFn(Box::new(move |_| string.clone())), + timeout: Duration::from_millis(100), + } + } +} + +#[async_trait] +impl GenericRpcProvider for ProviderMock { + type Error = AlienError; + + async fn request(&self, target: Target) -> Result { + Ok(RpcResponse { + status: Some(200), + data: (self.response.0)(target).into_bytes(), + }) + } + + fn get_endpoint(&self, _chain: Chain) -> Result { + Ok(String::from("http://localhost:8080")) + } +} diff --git a/core/crates/swapper/src/alien/mod.rs b/core/crates/swapper/src/alien/mod.rs new file mode 100644 index 0000000000..9c56b0463d --- /dev/null +++ b/core/crates/swapper/src/alien/mod.rs @@ -0,0 +1,6 @@ +pub mod mock; +#[cfg(feature = "reqwest_provider")] +pub mod reqwest_provider; + +pub use gem_jsonrpc::alien::{AlienError, RpcClient, RpcProvider}; +pub use gem_jsonrpc::{HttpMethod, Target}; diff --git a/core/crates/swapper/src/alien/reqwest_provider.rs b/core/crates/swapper/src/alien/reqwest_provider.rs new file mode 100644 index 0000000000..427ab41fb6 --- /dev/null +++ b/core/crates/swapper/src/alien/reqwest_provider.rs @@ -0,0 +1,114 @@ +use std::collections::HashMap; + +use super::{AlienError, HttpMethod, Target}; +use primitives::{Chain, node_config::get_nodes_for_chain}; + +use async_trait::async_trait; +use futures::TryFutureExt; +use gem_jsonrpc::RpcResponse; +use gem_jsonrpc::rpc::RpcProvider as GenericRpcProvider; +use reqwest::{Client, Method}; + +#[derive(Debug)] +pub struct NativeProvider { + pub client: Client, + debug: bool, + endpoints: HashMap, +} + +impl NativeProvider { + pub fn new() -> Self { + Self { + client: Client::new(), + debug: true, + endpoints: HashMap::new(), + } + } + + pub fn new_with_endpoints(endpoints: HashMap) -> Self { + Self { + client: Client::new(), + debug: false, + endpoints, + } + } + + pub fn set_debug(mut self, debug: bool) -> Self { + self.debug = debug; + self + } +} + +impl Default for NativeProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl GenericRpcProvider for NativeProvider { + type Error = AlienError; + + fn get_endpoint(&self, chain: Chain) -> Result { + if let Some(url) = self.endpoints.get(&chain) { + return Ok(url.clone()); + } + let nodes = get_nodes_for_chain(chain); + if nodes.is_empty() { + return Err(Self::Error::response_error(format!("not supported chain: {chain:?}"))); + } + Ok(nodes[0].url.clone()) + } + + async fn request(&self, target: Target) -> Result { + if self.debug { + println!("==> request: url: {:?}, method: {:?}", target.url, target.method); + } + let mut req = match target.method { + HttpMethod::Get => self.client.get(target.url), + HttpMethod::Post => self.client.post(target.url), + HttpMethod::Put => self.client.put(target.url), + HttpMethod::Delete => self.client.delete(target.url), + HttpMethod::Head => self.client.head(target.url), + HttpMethod::Patch => self.client.patch(target.url), + HttpMethod::Options => self.client.request(Method::OPTIONS, target.url), + }; + if let Some(headers) = target.headers { + for (key, value) in headers.iter() { + req = req.header(key, value); + } + } + if let Some(body) = target.body { + if self.debug && body.len() <= 4096 { + if let Ok(json) = serde_json::from_slice::(&body) { + println!("=== json: {json:?}"); + } else if let Ok(text) = std::str::from_utf8(&body) { + println!("=== body: {text:?}"); + } else { + println!("=== binary body size: {:?}", body.len()); + } + } + req = req.body(body); + } + + let response = req.send().map_err(|e| Self::Error::response_error(format!("reqwest send error: {e}"))).await?; + let status = response.status(); + let bytes = response.bytes().map_err(|e| Self::Error::response_error(format!("request error: {e}"))).await?; + if self.debug { + println!("<== response body size: {:?}", bytes.len()); + } + if self.debug && bytes.len() <= 4096 { + if let Ok(json) = serde_json::from_slice::(&bytes) { + println!("=== json: {json:?}"); + } else if let Ok(text) = std::str::from_utf8(&bytes) { + println!("=== body: {text:?}"); + } else { + println!("=== binary body size: {:?}", bytes.len()); + } + } + Ok(RpcResponse { + status: Some(status.as_u16()), + data: bytes.to_vec(), + }) + } +} diff --git a/core/crates/swapper/src/approval/evm.rs b/core/crates/swapper/src/approval/evm.rs new file mode 100644 index 0000000000..127f7dbf00 --- /dev/null +++ b/core/crates/swapper/src/approval/evm.rs @@ -0,0 +1,170 @@ +use crate::{ + SwapperError, + alien::RpcProvider, + client_factory::create_client_with_chain, + error::INVALID_ADDRESS, + eth_address, + models::{ApprovalType, Permit2ApprovalData}, +}; +use gem_client::Client; +use gem_jsonrpc::client::JsonRpcClient; + +use alloy_primitives::{Address, U256, hex::decode as HexDecode}; +use alloy_sol_types::SolCall; + +use gem_evm::{ + contracts::erc20::IERC20, + jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, + permit2::IAllowanceTransfer, +}; +use primitives::{Chain, swap::ApprovalData}; +use std::{ + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub async fn check_approval_erc20_with_client(owner: String, token: String, spender: String, amount: U256, client: &JsonRpcClient) -> Result +where + C: Client + Clone + std::fmt::Debug + Send + Sync + 'static, +{ + let owner: Address = owner + .as_str() + .parse() + .map_err(|_| SwapperError::TransactionError(format!("{}: {owner}", INVALID_ADDRESS)))?; + let spender: Address = spender + .as_str() + .parse() + .map_err(|_| SwapperError::TransactionError(format!("{}: {spender}", INVALID_ADDRESS)))?; + let allowance_data = IERC20::allowanceCall { owner, spender }.abi_encode(); + let allowance_call = EthereumRpc::Call(TransactionObject::new_call(&token, allowance_data), BlockParameter::Latest); + + let result: String = client.request(allowance_call).await.map_err(SwapperError::from)?; + + let decoded = HexDecode(result).map_err(|_| SwapperError::TransactionError("failed to decode allowance_call result".into()))?; + + let allowance = IERC20::allowanceCall::abi_decode_returns(&decoded).map_err(SwapperError::from)?; + + if allowance < amount { + return Ok(ApprovalType::Approve(ApprovalData { + token: token.to_string(), + spender: spender.to_string(), + value: amount.to_string(), + is_unlimited: true, + })); + } + Ok(ApprovalType::None) +} + +pub async fn check_approval_erc20( + owner: String, + token: String, + spender: String, + amount: U256, + provider: Arc, + chain: &Chain, +) -> Result { + let client = create_client_with_chain(provider.clone(), *chain); + check_approval_erc20_with_client(owner, token, spender, amount, &client).await +} + +pub async fn check_approval_permit2_with_client( + permit2_contract: &str, + owner: String, + token: String, + spender: String, + amount: U256, + client: &JsonRpcClient, +) -> Result +where + C: Client + Clone + std::fmt::Debug + Send + Sync + 'static, +{ + // Check permit2 allowance, spender is universal router + let permit2_data = IAllowanceTransfer::allowanceCall { + _0: eth_address::parse_str(&owner)?, + _1: eth_address::parse_str(&token)?, + _2: eth_address::parse_str(&spender)?, + } + .abi_encode(); + let permit2_call = EthereumRpc::Call(TransactionObject::new_call(permit2_contract, permit2_data), BlockParameter::Latest); + + let result: String = client.request(permit2_call).await.map_err(SwapperError::from)?; + + let decoded = HexDecode(result).map_err(|_| SwapperError::TransactionError("failed to decode permit2 allowance result".into()))?; + let allowance_return = IAllowanceTransfer::allowanceCall::abi_decode_returns(&decoded).map_err(SwapperError::from)?; + + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs(); + let expiration: u64 = allowance_return + ._1 + .try_into() + .map_err(|_| SwapperError::TransactionError("failed to convert expiration to u64".into()))?; + + if U256::from(allowance_return._0) < amount || expiration < timestamp { + return Ok(ApprovalType::Permit2(Permit2ApprovalData { + token, + spender, + value: amount.to_string(), + permit2_contract: permit2_contract.into(), + permit2_nonce: allowance_return + ._2 + .try_into() + .map_err(|_| SwapperError::TransactionError("failed to convert nonce to u64".into()))?, + })); + } + + Ok(ApprovalType::None) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::alien::mock::{MockFn, ProviderMock}; + use primitives::contract_constants::{OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, UNISWAP_PERMIT2_CONTRACT}; + use std::time::Duration; + + #[tokio::test] + async fn test_approval_tx_spender_is_permit2() -> Result<(), SwapperError> { + let token = "0xdC6fF44d5d932Cbd77B52E5612Ba0529DC6226F1".to_string(); + let owner = "0x1085c5f70F7F7591D97da281A64688385455c2bD".to_string(); + let spender = OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT.to_string(); + let permit2_contract = UNISWAP_PERMIT2_CONTRACT.to_string(); + let amount = U256::from(1000000000000000000u64); + let chain: Chain = Chain::Optimism; + + let token_clone = token.clone(); + let mock = ProviderMock { + response: MockFn(Box::new(move |target| { + let body = target.body.unwrap(); + let json = serde_json::from_slice::(&body).unwrap(); + let params = json["params"].as_array().unwrap(); + let param = params[0].as_object().unwrap(); + let to = param["to"].as_str().unwrap(); + if to == token_clone { + return r#"{"id":1,"jsonrpc":"2.0","result":"0x0000000000000000000000000000000000000000000000000000000000000000"}"#.to_string(); + } + r#"{"id":1,"jsonrpc":"2.0","result":"0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}"# + .to_string() + })), + timeout: Duration::from_millis(10), + }; + let provider = Arc::new(mock); + + let erc20_result = check_approval_erc20(owner.clone(), token.clone(), permit2_contract.clone(), amount, provider.clone(), &chain).await?; + let client = create_client_with_chain(provider.clone(), chain); + let permit2_result = check_approval_permit2_with_client(&permit2_contract, owner.clone(), token.clone(), spender.clone(), amount, &client).await?; + + assert_eq!( + vec![erc20_result, permit2_result], + vec![ + ApprovalType::Approve(ApprovalData::make(&token, &permit2_contract, &amount.to_string(), true)), + ApprovalType::Permit2(Permit2ApprovalData { + token: token.clone(), + spender: spender.clone(), + value: amount.to_string(), + permit2_contract, + permit2_nonce: 0, + }), + ] + ); + Ok(()) + } +} diff --git a/core/crates/swapper/src/approval/mod.rs b/core/crates/swapper/src/approval/mod.rs new file mode 100644 index 0000000000..27bbf98138 --- /dev/null +++ b/core/crates/swapper/src/approval/mod.rs @@ -0,0 +1,28 @@ +pub mod evm; + +pub use evm::*; +use primitives::swap::ApprovalData; + +pub const DEFAULT_EVM_SWAP_GAS_LIMIT: u64 = 750_000; + +/// Returns the swap transaction gas limit only when a separate approval transaction is required. +pub fn get_swap_gas_limit_with_approval(approval: &Option, swap_gas_limit: Option, default_swap_gas_limit: u64) -> Option { + approval.as_ref().map(|_| swap_gas_limit.unwrap_or_else(|| default_swap_gas_limit.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_swap_gas_limit_with_approval() { + let approval = Some(ApprovalData::make("0xtoken", "0xspender", "1000", true)); + + assert_eq!( + get_swap_gas_limit_with_approval(&approval, Some("250000".to_string()), DEFAULT_EVM_SWAP_GAS_LIMIT), + Some("250000".to_string()) + ); + assert_eq!(get_swap_gas_limit_with_approval(&approval, None, DEFAULT_EVM_SWAP_GAS_LIMIT), Some("750000".to_string())); + assert_eq!(get_swap_gas_limit_with_approval(&None, Some("250000".to_string()), DEFAULT_EVM_SWAP_GAS_LIMIT), None); + } +} diff --git a/core/crates/swapper/src/cache.rs b/core/crates/swapper/src/cache.rs new file mode 100644 index 0000000000..824491104c --- /dev/null +++ b/core/crates/swapper/src/cache.rs @@ -0,0 +1,12 @@ +use gem_client::X_CACHE_TTL; +use std::collections::HashMap; + +pub(crate) const STATIC_READ_CACHE_TTL_SECONDS: u64 = 30 * primitives::duration::DAY.as_secs(); + +pub(crate) fn cache_headers(ttl_seconds: u64) -> HashMap { + HashMap::from([(X_CACHE_TTL.to_string(), ttl_seconds.to_string())]) +} + +pub(crate) fn static_read_cache_headers() -> HashMap { + cache_headers(STATIC_READ_CACHE_TTL_SECONDS) +} diff --git a/core/crates/swapper/src/cetus_clmm/cache.rs b/core/crates/swapper/src/cetus_clmm/cache.rs new file mode 100644 index 0000000000..0d1c70a682 --- /dev/null +++ b/core/crates/swapper/src/cetus_clmm/cache.rs @@ -0,0 +1,4 @@ +use super::model::DiscoveredPool; +use crate::route_cache::DiscoveryCache; + +pub(super) type PoolCache = DiscoveryCache; diff --git a/core/crates/swapper/src/cetus_clmm/client.rs b/core/crates/swapper/src/cetus_clmm/client.rs new file mode 100644 index 0000000000..4c6dd1725d --- /dev/null +++ b/core/crates/swapper/src/cetus_clmm/client.rs @@ -0,0 +1,489 @@ +use super::{ + cache::PoolCache, + constants::{CETUS_ALL_TICK_SPACINGS, CETUS_PRIMARY_TICK_SPACINGS, KNOWN_POOLS}, + model::{DiscoveredPool, Hop, INTERMEDIATE_COIN_TYPES}, + tx_builder, +}; +use crate::{ + ProviderType, RpcProvider, SwapperError, SwapperProvider, + client_factory::create_sui_client, + fees::{ReferralFee, default_referral_fees}, +}; +use gem_sui::{EMPTY_ADDRESS, SUI_COIN_TYPE, SuiClient, coin_type_matches, full_coin_type, models::InspectResult, tx_builder::ObjectResolver}; +use primitives::AssetId; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +#[derive(Debug, Clone)] +pub(super) struct QuoteResult { + pub amount_out: u64, + pub current_sqrt_price: u128, + pub after_sqrt_price: u128, + pub is_exceed: bool, +} + +const DIRECT_PRICE_IMPACT_THRESHOLD_BPS: u32 = 50; + +#[derive(Debug, Default)] +struct PhaseResult { + acceptable_direct: Option<(Vec, u32)>, + best_route: Option<(Vec, u32)>, +} + +pub struct CetusClmm { + pub(super) provider: ProviderType, + pub(super) sui_client: SuiClient, + pool_cache: PoolCache, +} + +impl std::fmt::Debug for CetusClmm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CetusClmm") + } +} + +impl CetusClmm { + pub fn new(rpc_provider: Arc) -> Self { + let sui_client = create_sui_client(rpc_provider).expect("failed to create Sui gRPC client"); + Self::with_client(sui_client) + } + + pub fn with_client(sui_client: SuiClient) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::CetusClmm), + sui_client, + pool_cache: PoolCache::default(), + } + } + + pub(super) fn referral_fee() -> ReferralFee { + default_referral_fees().sui + } + + pub(super) fn coin_type(asset_id: &AssetId) -> String { + full_coin_type(asset_id.token_id.as_deref().unwrap_or(SUI_COIN_TYPE)) + } + + pub(super) async fn find_route_hops(&self, from: &str, to: &str, swap_amount: u64) -> Result, SwapperError> { + if let Some(cached_route) = self.pool_cache.get_route(from, to) { + let quotes = self.quote_candidates_batched(vec![cached_route], from, swap_amount).await; + if let Some((hops, _)) = quotes.into_iter().flatten().next() { + return Ok(hops); + } + } + + let primary = self.try_route_with_ticks(from, to, swap_amount, CETUS_PRIMARY_TICK_SPACINGS).await; + if let Some((hops, _)) = primary.acceptable_direct { + self.cache_winning_route(from, to, &hops); + return Ok(hops); + } + if let Some((hops, impact)) = &primary.best_route + && *impact <= DIRECT_PRICE_IMPACT_THRESHOLD_BPS + { + self.cache_winning_route(from, to, hops); + return Ok(hops.clone()); + } + + let expanded = self.try_route_with_ticks(from, to, swap_amount, CETUS_ALL_TICK_SPACINGS).await; + let (hops, _) = expanded + .acceptable_direct + .or(expanded.best_route) + .or(primary.best_route) + .ok_or(SwapperError::NoQuoteAvailable)?; + self.cache_winning_route(from, to, &hops); + Ok(hops) + } + + async fn try_route_with_ticks(&self, from: &str, to: &str, swap_amount: u64, ticks: &[u32]) -> PhaseResult { + let direct_candidates: Vec> = self.discover_direct_pools(from, to, ticks).await.into_iter().map(|pool| vec![pool]).collect(); + let direct_quotes = self.quote_candidates_batched(direct_candidates, from, swap_amount).await; + let acceptable_direct = direct_quotes + .iter() + .filter_map(|q| q.as_ref()) + .filter(|(_, impact)| *impact < DIRECT_PRICE_IMPACT_THRESHOLD_BPS) + .max_by_key(|(hops, _)| hops.last().map(|h| h.amount_out).unwrap_or_default()) + .cloned(); + if acceptable_direct.is_some() { + return PhaseResult { + acceptable_direct, + best_route: None, + }; + } + + let mut multi_hop_candidates: Vec> = Vec::new(); + for raw_intermediate in INTERMEDIATE_COIN_TYPES { + let intermediate = full_coin_type(raw_intermediate); + if coin_type_matches(from, &intermediate) || coin_type_matches(to, &intermediate) { + continue; + } + let (firsts, seconds) = futures::future::join(self.discover_direct_pools(from, &intermediate, ticks), self.discover_direct_pools(&intermediate, to, ticks)).await; + for first in &firsts { + for second in &seconds { + multi_hop_candidates.push(vec![first.clone(), second.clone()]); + } + } + } + let multi_hop_quotes = self.quote_candidates_batched(multi_hop_candidates, from, swap_amount).await; + let best_route = direct_quotes + .into_iter() + .chain(multi_hop_quotes) + .flatten() + .max_by_key(|(hops, _)| hops.last().map(|h| h.amount_out).unwrap_or_default()); + PhaseResult { + acceptable_direct: None, + best_route, + } + } + + fn cache_winning_route(&self, from: &str, to: &str, hops: &[Hop]) { + let route: Vec = hops + .iter() + .map(|hop| DiscoveredPool { + pool_id: hop.pool_id.clone(), + pool_init_version: hop.pool_init_version, + coin_a: hop.coin_a.clone(), + coin_b: hop.coin_b.clone(), + }) + .collect(); + self.pool_cache.put_route(from, to, &route); + } + + fn known_pools(from: &str, to: &str) -> Vec { + KNOWN_POOLS + .iter() + .filter(|known| { + (coin_type_matches(from, known.coin_a) && coin_type_matches(to, known.coin_b)) || (coin_type_matches(from, known.coin_b) && coin_type_matches(to, known.coin_a)) + }) + .map(|known| DiscoveredPool { + pool_id: known.pool_id.to_string(), + pool_init_version: known.pool_init_version, + coin_a: known.coin_a.to_string(), + coin_b: known.coin_b.to_string(), + }) + .collect() + } + + async fn discover_direct_pools(&self, from: &str, to: &str, ticks: &[u32]) -> Vec { + let known = Self::known_pools(from, to); + if !known.is_empty() { + return known; + } + let (cached_pools, explored) = self.pool_cache.get(from, to); + let missing: Vec = ticks.iter().filter(|t| !explored.contains(t)).copied().collect(); + if missing.is_empty() { + return cached_pools; + } + let Some(new_pools) = self.query_direct_pools(from, to, &missing).await else { + return cached_pools; + }; + self.pool_cache.put(from, to, &new_pools, &missing); + self.pool_cache.get(from, to).0 + } + + async fn query_direct_pools(&self, from: &str, to: &str, ticks: &[u32]) -> Option> { + let (coin_a, coin_b) = canonical_pair_order(from, to); + let inspects = ticks.iter().map(|tick| self.inspect_pool_id(coin_a, coin_b, *tick)); + let results = futures::future::join_all(inspects).await; + let mut candidates: Vec<(String, String, String)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for result in results { + match result { + Ok(Some(pool_id)) => { + if seen.insert(pool_id.clone()) { + candidates.push((pool_id, coin_a.to_string(), coin_b.to_string())); + } + } + Ok(None) => {} + Err(_) => return None, + } + } + if candidates.is_empty() { + return Some(Vec::new()); + } + let pool_ids: Vec = candidates.iter().map(|(id, _, _)| id.clone()).collect(); + let resolver = ObjectResolver::prefetch(&self.sui_client, pool_ids, &HashMap::new()).await.ok()?; + Some( + candidates + .into_iter() + .filter_map(|(pool_id, coin_a, coin_b)| { + let pool_init_version = resolver.initial_shared_version(&pool_id)?; + Some(DiscoveredPool { + pool_id, + pool_init_version, + coin_a, + coin_b, + }) + }) + .collect(), + ) + } + + async fn quote_candidates_batched(&self, candidates: Vec>, from: &str, swap_amount: u64) -> Vec, u32)>> { + if candidates.is_empty() { + return Vec::new(); + } + if candidates[0].len() == 1 { + self.quote_direct_batched(candidates, from, swap_amount).await + } else { + self.quote_multi_hop_fused(candidates, from, swap_amount).await + } + } + + async fn quote_direct_batched(&self, candidates: Vec>, from: &str, swap_amount: u64) -> Vec, u32)>> { + let hops: Vec = candidates.iter().map(|pools| pools[0].clone().into_hop(from, swap_amount)).collect(); + let inputs: Vec<(&Hop, u64)> = hops.iter().map(|hop| (hop, swap_amount)).collect(); + let quote_results = self.inspect_batch_quotes(&inputs).await.unwrap_or_else(|_| vec![None; candidates.len()]); + + hops.into_iter() + .zip(quote_results) + .map(|(mut hop, quote)| { + let q = quote?; + if q.amount_out == 0 || q.is_exceed { + return None; + } + hop.amount_out = q.amount_out; + hop.after_sqrt_price = q.after_sqrt_price; + let impact = price_impact_bps(q.current_sqrt_price, q.after_sqrt_price); + Some((vec![hop], impact)) + }) + .collect() + } + + async fn quote_multi_hop_fused(&self, candidates: Vec>, from: &str, swap_amount: u64) -> Vec, u32)>> { + let hop_pairs: Vec<(Hop, Hop)> = candidates + .iter() + .map(|pools| { + let hop1 = pools[0].clone().into_hop(from, swap_amount); + let intermediate = hop1.output_coin_type().to_string(); + let hop2 = pools[1].clone().into_hop(&intermediate, 0); + (hop1, hop2) + }) + .collect(); + let inputs: Vec<(&Hop, &Hop, u64)> = hop_pairs.iter().map(|(h1, h2)| (h1, h2, swap_amount)).collect(); + let fused_results = self.inspect_batch_multi_hop_quotes(&inputs).await.unwrap_or_else(|_| vec![(None, None); candidates.len()]); + + hop_pairs + .into_iter() + .zip(fused_results) + .map(|((mut hop1, mut hop2), (q1, q2))| { + let q1 = q1?; + if q1.amount_out == 0 || q1.is_exceed { + return None; + } + let q2 = q2?; + if q2.amount_out == 0 || q2.is_exceed { + return None; + } + hop1.amount_out = q1.amount_out; + hop1.after_sqrt_price = q1.after_sqrt_price; + hop2.amount_in = q1.amount_out; + hop2.amount_out = q2.amount_out; + hop2.after_sqrt_price = q2.after_sqrt_price; + let max_impact = price_impact_bps(q1.current_sqrt_price, q1.after_sqrt_price).max(price_impact_bps(q2.current_sqrt_price, q2.after_sqrt_price)); + Some((vec![hop1, hop2], max_impact)) + }) + .collect() + } + + async fn inspect_pool_id(&self, coin_a: &str, coin_b: &str, tick_spacing: u32) -> Result, SwapperError> { + let transaction = tx_builder::build_pool_id_inspect(coin_a, coin_b, tick_spacing)?; + let Some(result) = self.run_inspect(transaction).await? else { + return Ok(None); + }; + let bytes = result + .results + .last() + .and_then(|command| command.return_values.first()) + .map(|(bytes, _)| bytes) + .ok_or_else(|| SwapperError::ComputeQuoteError("Cetus CLMM pool discovery returned no value".into()))?; + if bytes.len() != 32 { + return Err(SwapperError::ComputeQuoteError("Cetus CLMM pool discovery returned invalid id".into())); + } + Ok(Some(format!("0x{}", hex::encode(bytes)))) + } + + async fn inspect_batch_quotes(&self, quotes: &[(&Hop, u64)]) -> Result>, SwapperError> { + if quotes.is_empty() { + return Ok(Vec::new()); + } + let result = self + .run_inspect(tx_builder::build_batch_quote_inspect(quotes)?) + .await? + .ok_or(SwapperError::NoQuoteAvailable)?; + Ok((0..quotes.len()).map(|i| quote_result_at(&result, i)).collect()) + } + + async fn inspect_batch_multi_hop_quotes(&self, routes: &[(&Hop, &Hop, u64)]) -> Result, Option)>, SwapperError> { + if routes.is_empty() { + return Ok(Vec::new()); + } + let result = self + .run_inspect(tx_builder::build_batch_multi_hop_quote_inspect(routes)?) + .await? + .ok_or(SwapperError::NoQuoteAvailable)?; + Ok((0..routes.len()).map(|i| (quote_result_at(&result, i * 3), quote_result_at(&result, i * 3 + 2))).collect()) + } + + async fn run_inspect(&self, transaction: Vec) -> Result, SwapperError> { + let result = self + .sui_client + .inspect_transaction_block(EMPTY_ADDRESS, &transaction, None) + .await + .map_err(SwapperError::compute_quote_error)?; + if result.error.is_some() { + return Ok(None); + } + Ok(Some(result)) + } +} + +fn canonical_pair_order<'a>(a: &'a str, b: &'a str) -> (&'a str, &'a str) { + if a > b { (a, b) } else { (b, a) } +} + +fn decode_quote_result_bytes(bytes: &[u8]) -> Result { + if bytes.len() < 66 { + return Err(SwapperError::ComputeQuoteError("Cetus CLMM quote inspect returned truncated CalculatedSwapResult".into())); + } + let amount_out = u64::from_le_bytes( + bytes[8..16] + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError("Cetus CLMM amount_out decode failed".into()))?, + ); + let after_sqrt_price = u128::from_le_bytes( + bytes[32..48] + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError("Cetus CLMM after_sqrt_price decode failed".into()))?, + ); + let is_exceed = bytes[48] != 0; + let current_sqrt_price = u128::from_le_bytes( + bytes[50..66] + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError("Cetus CLMM current_sqrt_price decode failed".into()))?, + ); + Ok(QuoteResult { + amount_out, + current_sqrt_price, + after_sqrt_price, + is_exceed, + }) +} + +fn price_impact_bps(current_sqrt_price: u128, after_sqrt_price: u128) -> u32 { + if current_sqrt_price == 0 { + return u32::MAX; + } + let (high, low) = if current_sqrt_price >= after_sqrt_price { + (current_sqrt_price, after_sqrt_price) + } else { + (after_sqrt_price, current_sqrt_price) + }; + let delta = high - low; + let bps = delta.saturating_mul(20_000) / current_sqrt_price; + u32::try_from(bps).unwrap_or(u32::MAX) +} + +fn quote_result_at(result: &InspectResult, cmd_idx: usize) -> Option { + let bytes = result.results.get(cmd_idx).and_then(|cmd| cmd.return_values.first()).map(|(bytes, _)| bytes)?; + decode_quote_result_bytes(bytes).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inspect_result_many(per_command: Vec>) -> InspectResult { + InspectResult { + effects: gem_sui::models::InspectEffects { + gas_used: gem_sui::models::InspectGasUsed { + computation_cost: 0, + storage_cost: 0, + storage_rebate: 0, + }, + }, + events: serde_json::Value::Null, + error: None, + results: per_command + .into_iter() + .map(|bytes| gem_sui::models::InspectCommandResult { + return_values: vec![(bytes, "CalculatedSwapResult".into())], + }) + .collect(), + } + } + + fn calc_swap_bytes(amount_out: u64, current_sqrt: u128, after_sqrt: u128, is_exceed: bool) -> Vec { + let mut bytes = Vec::with_capacity(66); + bytes.extend_from_slice(&997_500_u64.to_le_bytes()); + bytes.extend_from_slice(&amount_out.to_le_bytes()); + bytes.extend_from_slice(&2_500_u64.to_le_bytes()); + bytes.extend_from_slice(&2_500_u64.to_le_bytes()); + bytes.extend_from_slice(&after_sqrt.to_le_bytes()); + bytes.push(if is_exceed { 1 } else { 0 }); + bytes.push(1); + bytes.extend_from_slice(¤t_sqrt.to_le_bytes()); + bytes + } + + #[test] + fn test_price_impact_bps() { + assert_eq!(price_impact_bps(1_000_000, 1_000_000), 0); + assert_eq!(price_impact_bps(1_000_000, 995_000), 100); + assert_eq!(price_impact_bps(995_000, 1_000_000), 100); + assert_eq!(price_impact_bps(1_000_000, 990_000), 200); + assert_eq!(price_impact_bps(0, 1_000_000), u32::MAX); + } + + #[test] + fn test_canonical_pair_order() { + let sui = gem_sui::SUI_COIN_TYPE_FULL; + let usdc = "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC"; + let blue = "0xe1b45a0e641b9955a20aa0ad1c1f4ad86aad8afb07296d4085e349a50e90bdca::blue::BLUE"; + + assert_eq!(canonical_pair_order(usdc, sui), (usdc, sui)); + assert_eq!(canonical_pair_order(sui, usdc), (usdc, sui)); + assert_eq!(canonical_pair_order(blue, sui), (blue, sui)); + assert_eq!(canonical_pair_order(sui, blue), (blue, sui)); + assert_eq!(canonical_pair_order(blue, usdc), (blue, usdc)); + assert_eq!(canonical_pair_order(usdc, blue), (blue, usdc)); + } + + #[test] + fn test_quote_result_at_extracts_per_command() { + let current = 521_723_622_374_070_550_528_u128; + let after = 521_460_761_563_383_315_264_u128; + let bytes_a = calc_swap_bytes(100_000, current, after, false); + let bytes_b = calc_swap_bytes(200_000, current, after, true); + let bytes_c = calc_swap_bytes(300_000, current, after, false); + let result = inspect_result_many(vec![bytes_a, bytes_b, bytes_c]); + + assert_eq!(quote_result_at(&result, 0).unwrap().amount_out, 100_000); + assert!(!quote_result_at(&result, 0).unwrap().is_exceed); + assert_eq!(quote_result_at(&result, 1).unwrap().amount_out, 200_000); + assert!(quote_result_at(&result, 1).unwrap().is_exceed); + assert_eq!(quote_result_at(&result, 2).unwrap().amount_out, 300_000); + assert!(quote_result_at(&result, 3).is_none()); + } + + #[test] + fn test_decode_quote_result_bytes() { + let current = 521_723_622_374_070_550_528_u128; + let after = 521_460_761_563_383_315_264_u128; + let bytes = calc_swap_bytes(796_985_864, current, after, false); + let decoded = decode_quote_result_bytes(&bytes).unwrap(); + assert_eq!(decoded.amount_out, 796_985_864); + assert_eq!(decoded.after_sqrt_price, after); + assert!(!decoded.is_exceed); + + let exceeded = calc_swap_bytes(796_985_864, current, after, true); + assert!(decode_quote_result_bytes(&exceeded).unwrap().is_exceed); + + let truncated = decode_quote_result_bytes(&[0u8; 16]); + match truncated { + Err(SwapperError::ComputeQuoteError(_)) => {} + other => panic!("expected ComputeQuoteError, got {other:?}"), + } + } +} diff --git a/core/crates/swapper/src/cetus_clmm/constants.rs b/core/crates/swapper/src/cetus_clmm/constants.rs new file mode 100644 index 0000000000..35ab6e1715 --- /dev/null +++ b/core/crates/swapper/src/cetus_clmm/constants.rs @@ -0,0 +1,63 @@ +use gem_sui::SUI_COIN_TYPE_FULL; +use primitives::asset_constants::SUI_USDC_TOKEN_ID; + +pub(super) const CETUS_CLMM_PUBLISHED_AT: &str = "0x25ebb9a7c50eb17b3fa9c5a30fb8b5ad8f97caaf4928943acbcff7153dfee5e3"; + +pub(super) const CETUS_SHARED_INIT_VERSION: u64 = 1_574_190; + +pub(super) const CETUS_GLOBAL_CONFIG: &str = "0xdaa46292632c3c4d8f31f23ea0f9b36a28ff3677e9684980e4438403a67a3d8f"; +pub(super) const CETUS_POOLS_REGISTRY: &str = "0xf699e7f2276f5c9a75944b37a0c5b5d9ddfd2471bf6242483b03ab2887d198d0"; + +pub(super) const CETUS_PARTNER: &str = "0x08b1875b6541c847f05ed71d04cbcfa66e4e8619bf3b8923b07c5b5409433366"; +pub(super) const CETUS_PARTNER_INIT_VERSION: u64 = 507_739_678; + +pub(super) const MODULE_POOL: &str = "pool"; +pub(super) const MODULE_FACTORY: &str = "factory"; + +pub(super) const FUNCTION_CALCULATE_SWAP_RESULT: &str = "calculate_swap_result"; +pub(super) const FUNCTION_CALCULATED_SWAP_RESULT_AMOUNT_OUT: &str = "calculated_swap_result_amount_out"; +pub(super) const FUNCTION_FLASH_SWAP_WITH_PARTNER: &str = "flash_swap_with_partner"; +pub(super) const FUNCTION_REPAY_FLASH_SWAP_WITH_PARTNER: &str = "repay_flash_swap_with_partner"; +pub(super) const FUNCTION_NEW_POOL_KEY: &str = "new_pool_key"; +pub(super) const FUNCTION_POOL_SIMPLE_INFO: &str = "pool_simple_info"; +pub(super) const FUNCTION_POOL_ID: &str = "pool_id"; + +pub(super) const MIN_SQRT_PRICE_X64: u128 = 4_295_048_016; +pub(super) const MAX_SQRT_PRICE_X64: u128 = 79_226_673_515_401_279_992_447_579_055; + +pub(super) const CETUS_PRIMARY_TICK_SPACINGS: &[u32] = &[60, 200]; +pub(super) const CETUS_ALL_TICK_SPACINGS: &[u32] = &[60, 200, 10, 2, 40]; + +pub(super) struct KnownPool { + pub coin_a: &'static str, + pub coin_b: &'static str, + pub pool_id: &'static str, + pub pool_init_version: u64, +} + +pub(super) const KNOWN_POOLS: &[KnownPool] = &[ + KnownPool { + coin_a: SUI_USDC_TOKEN_ID, + coin_b: SUI_COIN_TYPE_FULL, + pool_id: "0xb8d7d9e66a60c239e7a60110efcf8de6c705580ed924d0dde141f4a0e2c90105", + pool_init_version: 373_623_018, + }, + KnownPool { + coin_a: SUI_USDC_TOKEN_ID, + coin_b: SUI_COIN_TYPE_FULL, + pool_id: "0x413ddc5745aa6398e9da66c4843947e479f4bf63bade39ffc94c9197b433b332", + pool_init_version: 415_321_657, + }, + KnownPool { + coin_a: SUI_USDC_TOKEN_ID, + coin_b: SUI_COIN_TYPE_FULL, + pool_id: "0x51e883ba7c0b566a26cbc8a94cd33eb0abd418a77cc1e60ad22fd9b1f29cd2ab", + pool_init_version: 376_543_995, + }, + KnownPool { + coin_a: SUI_USDC_TOKEN_ID, + coin_b: SUI_COIN_TYPE_FULL, + pool_id: "0x03d7739b33fe221a830ff101042fa81fd19188feca04a335f7dea4e37c0fca81", + pool_init_version: 373_502_515, + }, +]; diff --git a/core/crates/swapper/src/cetus_clmm/mod.rs b/core/crates/swapper/src/cetus_clmm/mod.rs new file mode 100644 index 0000000000..8bc24361d4 --- /dev/null +++ b/core/crates/swapper/src/cetus_clmm/mod.rs @@ -0,0 +1,8 @@ +mod cache; +mod client; +mod constants; +mod model; +mod provider; +mod tx_builder; + +pub use client::CetusClmm; diff --git a/core/crates/swapper/src/cetus_clmm/model.rs b/core/crates/swapper/src/cetus_clmm/model.rs new file mode 100644 index 0000000000..dcfb552f4b --- /dev/null +++ b/core/crates/swapper/src/cetus_clmm/model.rs @@ -0,0 +1,159 @@ +use gem_sui::{SUI_COIN_TYPE_FULL, coin_type_matches}; +use primitives::asset_constants::SUI_USDC_TOKEN_ID; +use serde::{Deserialize, Serialize}; + +pub(super) const INTERMEDIATE_COIN_TYPES: &[&str] = &[SUI_COIN_TYPE_FULL, SUI_USDC_TOKEN_ID]; + +const FEE_PRIORITY_COINS: &[&str] = &[SUI_COIN_TYPE_FULL, SUI_USDC_TOKEN_ID]; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum FeeSide { + Input, + Output, +} + +impl FeeSide { + pub fn select(input_coin_type: &str, output_coin_type: &str) -> Self { + for preferred in FEE_PRIORITY_COINS { + if coin_type_matches(input_coin_type, preferred) { + return Self::Input; + } + if coin_type_matches(output_coin_type, preferred) { + return Self::Output; + } + } + Self::Output + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(super) struct Hop { + pub pool_id: String, + pub pool_init_version: u64, + pub coin_a: String, + pub coin_b: String, + pub a2b: bool, + pub amount_in: u64, + pub amount_out: u64, + pub after_sqrt_price: u128, +} + +impl Hop { + pub fn input_coin_type(&self) -> &str { + if self.a2b { &self.coin_a } else { &self.coin_b } + } + + pub fn output_coin_type(&self) -> &str { + if self.a2b { &self.coin_b } else { &self.coin_a } + } + + pub fn order_by_direction(&self, side_a: T, side_b: T) -> (T, T) { + if self.a2b { (side_a, side_b) } else { (side_b, side_a) } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(super) struct PoolRoute { + pub hops: Vec, + pub fee_amount: u64, + pub fee_side: FeeSide, +} + +impl PoolRoute { + pub fn input_coin_type(&self) -> &str { + self.hops.first().expect("PoolRoute always has at least one hop").input_coin_type() + } + + pub fn output_coin_type(&self) -> &str { + self.hops.last().expect("PoolRoute always has at least one hop").output_coin_type() + } + + pub fn gross_amount_out(&self) -> u64 { + self.hops.last().map(|h| h.amount_out).unwrap_or_default() + } + + pub fn net_amount_out(&self) -> u64 { + match self.fee_side { + FeeSide::Input => self.gross_amount_out(), + FeeSide::Output => self.gross_amount_out().saturating_sub(self.fee_amount), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct DiscoveredPool { + pub pool_id: String, + pub pool_init_version: u64, + pub coin_a: String, + pub coin_b: String, +} + +impl DiscoveredPool { + pub fn into_hop(self, input_coin_type: &str, amount_in: u64) -> Hop { + let a2b = coin_type_matches(input_coin_type, &self.coin_a); + Hop { + pool_id: self.pool_id, + pool_init_version: self.pool_init_version, + coin_a: self.coin_a, + coin_b: self.coin_b, + a2b, + amount_in, + amount_out: 0, + after_sqrt_price: 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SUI_FULL: &str = gem_sui::SUI_COIN_TYPE_FULL; + const USDC: &str = SUI_USDC_TOKEN_ID; + const BLUE: &str = "0xe1b45a0e641b9955a20aa0ad1c1f4ad86aad8afb07296d4085e349a50e90bdca::blue::BLUE"; + + fn hop(coin_a: &str, coin_b: &str, a2b: bool, amount_out: u64) -> Hop { + Hop { + pool_id: "0xpool".into(), + pool_init_version: 1, + coin_a: coin_a.into(), + coin_b: coin_b.into(), + a2b, + amount_in: 1_000, + amount_out, + after_sqrt_price: 0, + } + } + + #[test] + fn test_fee_side_preference() { + assert_eq!(FeeSide::select(SUI_FULL, USDC), FeeSide::Input); + assert_eq!(FeeSide::select(USDC, SUI_FULL), FeeSide::Output); + assert_eq!(FeeSide::select(SUI_FULL, BLUE), FeeSide::Input); + assert_eq!(FeeSide::select(BLUE, SUI_FULL), FeeSide::Output); + assert_eq!(FeeSide::select(USDC, BLUE), FeeSide::Input); + assert_eq!(FeeSide::select(BLUE, USDC), FeeSide::Output); + assert_eq!(FeeSide::select(BLUE, "0xfoo::bar::BAR"), FeeSide::Output); + } + + #[test] + fn test_pool_route_traversal() { + let route = PoolRoute { + hops: vec![hop(SUI_FULL, USDC, true, 800_000)], + fee_amount: 5_000, + fee_side: FeeSide::Input, + }; + assert_eq!(route.input_coin_type(), SUI_FULL); + assert_eq!(route.output_coin_type(), USDC); + assert_eq!(route.gross_amount_out(), 800_000); + assert_eq!(route.net_amount_out(), 800_000); + + let route_output_fee = PoolRoute { + hops: vec![hop(SUI_FULL, USDC, true, 800_000)], + fee_amount: 5_000, + fee_side: FeeSide::Output, + }; + assert_eq!(route_output_fee.net_amount_out(), 795_000); + } +} diff --git a/core/crates/swapper/src/cetus_clmm/provider.rs b/core/crates/swapper/src/cetus_clmm/provider.rs new file mode 100644 index 0000000000..f574973db6 --- /dev/null +++ b/core/crates/swapper/src/cetus_clmm/provider.rs @@ -0,0 +1,241 @@ +use super::{ + client::CetusClmm, + constants::CETUS_CLMM_PUBLISHED_AT, + model::{FeeSide, PoolRoute}, + tx_builder, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, Swapper, SwapperChainAsset, SwapperError, SwapperQuoteData, fees::quote_value_after_reserve_by_chain, +}; +use async_trait::async_trait; +use gem_sui::coin_type_matches; +use primitives::Chain; + +#[async_trait] +impl Swapper for CetusClmm { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + vec![SwapperChainAsset::All(Chain::Sui)] + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_value = quote_value_after_reserve_by_chain(request)?; + let from_asset = request.from_asset.asset_id(); + let to_asset = request.to_asset.asset_id(); + let amount = from_value.parse::()?; + if amount == 0 { + return Err(SwapperError::InputAmountError { min_amount: Some("1".into()) }); + } + + let from_coin_type = CetusClmm::coin_type(&from_asset); + let to_coin_type = CetusClmm::coin_type(&to_asset); + let fee_side = FeeSide::select(&from_coin_type, &to_coin_type); + + let referral_fee = CetusClmm::referral_fee(); + let input_fee_amount = tx_builder::referral_fee_amount(amount, referral_fee.bps)?; + let swap_amount = match fee_side { + FeeSide::Input => amount + .checked_sub(input_fee_amount) + .ok_or_else(|| SwapperError::ComputeQuoteError("Cetus CLMM referral fee exceeds input amount".into()))?, + FeeSide::Output => amount, + }; + + let slippage_bps = request.options.slippage.bps; + let hops = self.find_route_hops(&from_coin_type, &to_coin_type, swap_amount).await?; + let gross_amount_out = hops.last().map(|h| h.amount_out).unwrap_or_default(); + let fee_amount = match fee_side { + FeeSide::Input => input_fee_amount, + FeeSide::Output => tx_builder::referral_fee_amount(gross_amount_out, referral_fee.bps)?, + }; + let route = PoolRoute { hops, fee_amount, fee_side }; + + Ok(Quote { + from_value, + min_from_value: None, + to_value: route.net_amount_out().to_string(), + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: from_asset, + output: to_asset, + route_data: serde_json::to_string(&route)?, + }], + slippage_bps, + }, + request: request.clone(), + eta_in_seconds: Some(0), + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route_entry = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let route: PoolRoute = serde_json::from_str(&route_entry.route_data).map_err(|_| SwapperError::InvalidRoute)?; + + let request_from = CetusClmm::coin_type("e.request.from_asset.asset_id()); + let request_to = CetusClmm::coin_type("e.request.to_asset.asset_id()); + if !coin_type_matches(&request_from, route.input_coin_type()) || !coin_type_matches(&request_to, route.output_coin_type()) { + return Err(SwapperError::InvalidRoute); + } + + tx_builder::build_quote_data(&self.sui_client, quote, &route, &CetusClmm::referral_fee(), CETUS_CLMM_PUBLISHED_AT).await + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{FetchQuoteData, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, models::Options}; + use primitives::{AssetId, asset_constants::SUI_USDC_TOKEN_ID}; + use std::sync::Arc; + + const TEST_WALLET: &str = "0x9059c9d089cebc40fbe8c365782ab1285b99959fa386f5a5fc9cdf861a3e0b17"; + const BLUE_TOKEN_ID: &str = "0xe1b45a0e641b9955a20aa0ad1c1f4ad86aad8afb07296d4085e349a50e90bdca::blue::BLUE"; + + fn print_quote(label: &str, quote: &Quote) -> Result { + let route_entry = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let route: PoolRoute = serde_json::from_str(&route_entry.route_data)?; + println!( + "{label} quote: from_value={}, to_value={}, hops={}, gross_out={}, net_out={}, fee_side={:?}, fee_amount={}", + quote.from_value, + quote.to_value, + route.hops.len(), + route.gross_amount_out(), + route.net_amount_out(), + route.fee_side, + route.fee_amount + ); + for (index, hop) in route.hops.iter().enumerate() { + println!( + "{label} hop {}: amount_in={}, amount_out={}, a2b={}, pool_id={}", + index + 1, + hop.amount_in, + hop.amount_out, + hop.a2b, + hop.pool_id + ); + } + Ok(route) + } + + fn print_quote_data(label: &str, quote_data: &SwapperQuoteData) { + println!( + "{label} quote_data: to={}, value={}, data_len={}, gas_limit={:?}", + quote_data.to, + quote_data.value, + quote_data.data.len(), + quote_data.gas_limit + ); + } + + #[tokio::test] + async fn test_cetus_clmm_provider_fetch_quote_and_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = CetusClmm::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), + to_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(SUI_USDC_TOKEN_ID.to_string()))), + wallet_address: TEST_WALLET.to_string(), + destination_address: TEST_WALLET.to_string(), + value: "1500000000".to_string(), + options: Options::new_with_slippage(50.into()), + }; + + let quote = provider.get_quote(&request).await?; + let quote_data = provider.get_quote_data("e, FetchQuoteData::None).await?; + print_quote("SUI->USDC", "e)?; + print_quote_data("SUI->USDC", "e_data); + + assert!(quote.to_value.parse::().unwrap() > 0); + assert!(!quote_data.data.is_empty()); + assert!(quote_data.gas_limit.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_cetus_clmm_provider_fetch_quote_usdc_to_sui() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = CetusClmm::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(SUI_USDC_TOKEN_ID.to_string()))), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), + wallet_address: TEST_WALLET.to_string(), + destination_address: TEST_WALLET.to_string(), + value: "100000".to_string(), + options: Options::new_with_slippage(50.into()), + }; + + let quote = provider.get_quote(&request).await?; + let quote_data = provider.get_quote_data("e, FetchQuoteData::None).await?; + print_quote("USDC->SUI", "e)?; + print_quote_data("USDC->SUI", "e_data); + + assert!(quote.to_value.parse::().unwrap() > 0); + assert!(!quote_data.data.is_empty()); + assert!(quote_data.gas_limit.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_cetus_clmm_provider_discovers_blue_sui_pool() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = CetusClmm::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), + to_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(BLUE_TOKEN_ID.to_string()))), + wallet_address: TEST_WALLET.to_string(), + destination_address: TEST_WALLET.to_string(), + value: "100000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = provider.get_quote(&request).await?; + print_quote("SUI->BLUE", "e)?; + assert!(quote.to_value.parse::().unwrap() > 0); + Ok(()) + } + + #[tokio::test] + async fn test_cetus_clmm_provider_routes_usdc_to_blue() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = CetusClmm::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(SUI_USDC_TOKEN_ID.to_string()))), + to_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(BLUE_TOKEN_ID.to_string()))), + wallet_address: TEST_WALLET.to_string(), + destination_address: TEST_WALLET.to_string(), + value: "100000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = provider.get_quote(&request).await?; + assert!(quote.to_value.parse::().unwrap() > 0); + let route = print_quote("USDC->BLUE", "e)?; + assert!(!route.hops.is_empty() && route.hops.len() <= 2); + Ok(()) + } + + #[tokio::test] + async fn test_cetus_clmm_provider_routes_blue_to_usdc() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = CetusClmm::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(BLUE_TOKEN_ID.to_string()))), + to_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(SUI_USDC_TOKEN_ID.to_string()))), + wallet_address: TEST_WALLET.to_string(), + destination_address: TEST_WALLET.to_string(), + value: "10000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = provider.get_quote(&request).await?; + assert!(quote.to_value.parse::().unwrap() > 0); + let route = print_quote("BLUE->USDC", "e)?; + assert!(!route.hops.is_empty() && route.hops.len() <= 2); + Ok(()) + } +} diff --git a/core/crates/swapper/src/cetus_clmm/tx_builder.rs b/core/crates/swapper/src/cetus_clmm/tx_builder.rs new file mode 100644 index 0000000000..e6619fceda --- /dev/null +++ b/core/crates/swapper/src/cetus_clmm/tx_builder.rs @@ -0,0 +1,407 @@ +use super::{ + constants::{ + CETUS_CLMM_PUBLISHED_AT, CETUS_GLOBAL_CONFIG, CETUS_PARTNER, CETUS_PARTNER_INIT_VERSION, CETUS_POOLS_REGISTRY, CETUS_SHARED_INIT_VERSION, FUNCTION_CALCULATE_SWAP_RESULT, + FUNCTION_CALCULATED_SWAP_RESULT_AMOUNT_OUT, FUNCTION_FLASH_SWAP_WITH_PARTNER, FUNCTION_NEW_POOL_KEY, FUNCTION_POOL_ID, FUNCTION_POOL_SIMPLE_INFO, + FUNCTION_REPAY_FLASH_SWAP_WITH_PARTNER, MAX_SQRT_PRICE_X64, MIN_SQRT_PRICE_X64, MODULE_FACTORY, MODULE_POOL, + }, + model::{FeeSide, Hop, PoolRoute}, +}; +use crate::{Quote, SwapperError, SwapperQuoteData, fees::ReferralFee, fees::apply_slippage_in_bp}; +use gem_sui::{ + EMPTY_ADDRESS, ESTIMATION_GAS_BUDGET, SuiClient, + address::SuiAddress, + gas_budget::GAS_BUDGET_MULTIPLIER, + is_sui_coin, + models::{Coin, OwnedCoins, TxOutput}, + sui_clock_object_input, + tx_builder::{ + ObjectResolver, PrefetchedTransactionData, TransactionBuilderInput, balance_value, balance_zero, build_input_coin, destroy_zero_balance, finish_transaction, from_balance, + into_balance, move_call, + }, +}; +use primitives::Address as AddressTrait; +use std::{collections::HashMap, fmt::Display}; +use sui_transaction_builder::{Argument, ObjectInput, TransactionBuilder}; +use sui_types::{Address, Digest}; + +#[derive(Clone)] +pub(super) struct BuildInput<'a> { + pub transaction: TransactionBuilderInput, + pub amount: u64, + pub from_coins: &'a OwnedCoins, +} + +impl BuildInput<'_> { + fn with_gas_budget(&self, gas_budget: u64) -> Self { + Self { + transaction: self.transaction.with_gas_budget(gas_budget), + ..self.clone() + } + } +} + +pub(super) async fn build_quote_data( + client: &SuiClient, + quote: &Quote, + route: &PoolRoute, + referral_fee: &ReferralFee, + published_at: &str, +) -> Result { + let sender = quote.request.wallet_address.as_str(); + let amount = quote.from_value.parse::()?; + let mut pinned = HashMap::from([ + (CETUS_GLOBAL_CONFIG.to_string(), CETUS_SHARED_INIT_VERSION), + (CETUS_PARTNER.to_string(), CETUS_PARTNER_INIT_VERSION), + ]); + let mut object_ids = vec![CETUS_GLOBAL_CONFIG.to_string(), CETUS_PARTNER.to_string()]; + for hop in &route.hops { + pinned.insert(hop.pool_id.clone(), hop.pool_init_version); + object_ids.push(hop.pool_id.clone()); + } + let PrefetchedTransactionData { + transaction, + input_coins, + resolver, + .. + } = PrefetchedTransactionData::prefetch(client, sender, route.input_coin_type(), None, object_ids, &pinned, ESTIMATION_GAS_BUDGET) + .await + .map_err(SwapperError::transaction_error)?; + + let input = BuildInput { + transaction, + amount, + from_coins: &input_coins, + }; + + let estimate = build_transaction(&resolver, quote, route, referral_fee, published_at, &input)?; + let dry_run = client.dry_run(estimate.base64_encoded()).await.map_err(SwapperError::transaction_error)?; + if dry_run.effects.status.status != "success" { + let detail = dry_run.effects.status.error.as_deref().unwrap_or("no details available"); + if detail.contains("checked_package_version") { + return Err(SwapperError::TransactionError( + "Cetus CLMM was upgraded since this app was built; on-chain Cetus quotes are temporarily unavailable.".into(), + )); + } + return Err(SwapperError::TransactionError(format!("Sui Cetus CLMM swap simulation failed: {detail}"))); + } + + let fee = dry_run.effects.gas_used.calculate_gas_budget().map_err(SwapperError::transaction_error)?; + let gas_budget = fee * GAS_BUDGET_MULTIPLIER / 100; + let output = build_transaction(&resolver, quote, route, referral_fee, published_at, &input.with_gas_budget(gas_budget))?; + + Ok(SwapperQuoteData::new_contract( + String::new(), + "0".to_string(), + output.base64_encoded(), + None, + Some(gas_budget.to_string()), + )) +} + +pub(super) fn build_batch_quote_inspect(quotes: &[(&Hop, u64)]) -> Result, SwapperError> { + let mut txb = TransactionBuilder::new(); + for (hop, amount_in) in quotes { + let pool = txb.object(shared_object_input(&hop.pool_id, hop.pool_init_version, false)?); + let a2b = txb.pure(&hop.a2b); + let by_amount_in = txb.pure(&true); + let amount = txb.pure(amount_in); + move_call( + &mut txb, + cetus_clmm_publish_at(), + MODULE_POOL, + FUNCTION_CALCULATE_SWAP_RESULT, + &[&hop.coin_a, &hop.coin_b], + vec![pool, a2b, by_amount_in, amount], + ) + .map_err(error)?; + } + inspect_transaction_kind_bytes(txb) +} + +pub(super) fn build_batch_multi_hop_quote_inspect(routes: &[(&Hop, &Hop, u64)]) -> Result, SwapperError> { + let mut txb = TransactionBuilder::new(); + for (hop1, hop2, amount_in) in routes { + let pool1 = txb.object(shared_object_input(&hop1.pool_id, hop1.pool_init_version, false)?); + let a2b1 = txb.pure(&hop1.a2b); + let by_amount_in_1 = txb.pure(&true); + let amount = txb.pure(amount_in); + let csr1 = move_call( + &mut txb, + cetus_clmm_publish_at(), + MODULE_POOL, + FUNCTION_CALCULATE_SWAP_RESULT, + &[&hop1.coin_a, &hop1.coin_b], + vec![pool1, a2b1, by_amount_in_1, amount], + ) + .map_err(error)?; + let amount2 = move_call(&mut txb, cetus_clmm_publish_at(), MODULE_POOL, FUNCTION_CALCULATED_SWAP_RESULT_AMOUNT_OUT, &[], vec![csr1]).map_err(error)?; + let pool2 = txb.object(shared_object_input(&hop2.pool_id, hop2.pool_init_version, false)?); + let a2b2 = txb.pure(&hop2.a2b); + let by_amount_in_2 = txb.pure(&true); + move_call( + &mut txb, + cetus_clmm_publish_at(), + MODULE_POOL, + FUNCTION_CALCULATE_SWAP_RESULT, + &[&hop2.coin_a, &hop2.coin_b], + vec![pool2, a2b2, by_amount_in_2, amount2], + ) + .map_err(error)?; + } + inspect_transaction_kind_bytes(txb) +} + +pub(super) fn build_pool_id_inspect(coin_a: &str, coin_b: &str, tick_spacing: u32) -> Result, SwapperError> { + let mut txb = TransactionBuilder::new(); + let tick = txb.pure(&tick_spacing); + let key = move_call(&mut txb, cetus_clmm_publish_at(), MODULE_FACTORY, FUNCTION_NEW_POOL_KEY, &[coin_a, coin_b], vec![tick]).map_err(error)?; + let pools = txb.object(shared_object_input(CETUS_POOLS_REGISTRY, CETUS_SHARED_INIT_VERSION, false)?); + let info = move_call(&mut txb, cetus_clmm_publish_at(), MODULE_FACTORY, FUNCTION_POOL_SIMPLE_INFO, &[], vec![pools, key]).map_err(error)?; + move_call(&mut txb, cetus_clmm_publish_at(), MODULE_FACTORY, FUNCTION_POOL_ID, &[], vec![info]).map_err(error)?; + inspect_transaction_kind_bytes(txb) +} + +struct PendingRepay<'a> { + hop: &'a Hop, + pool: Argument, + pay_input: Argument, + pay_zero_output: Argument, + receipt: Argument, +} + +fn build_transaction( + resolver: &ObjectResolver, + quote: &Quote, + route: &PoolRoute, + referral_fee: &ReferralFee, + published_at: &str, + input: &BuildInput<'_>, +) -> Result { + let mut txb = TransactionBuilder::new(); + let published_at = SuiAddress::from_str(published_at).map(Address::from)?; + let input_coin = build_input_coin(&mut txb, route.input_coin_type(), input.amount, input.from_coins).map_err(error)?; + let (swap_coin, swap_amount) = match route.fee_side { + FeeSide::Input => { + let after_fee = pay_referral_fee(&mut txb, input_coin, route.fee_amount, referral_fee)?; + let swap_amount = input + .amount + .checked_sub(route.fee_amount) + .ok_or_else(|| SwapperError::ComputeQuoteError("Cetus CLMM referral fee exceeds input amount".into()))?; + (after_fee, swap_amount) + } + FeeSide::Output => (input_coin, input.amount), + }; + + let first_hop = route.hops.first().ok_or_else(|| SwapperError::TransactionError("Cetus CLMM route has no hops".into()))?; + if first_hop.amount_in != swap_amount { + return Err(SwapperError::TransactionError("Cetus CLMM first hop amount mismatch".into())); + } + + let global_config = resolver.shared_object(&mut txb, CETUS_GLOBAL_CONFIG, false).map_err(error)?; + let partner = resolver.shared_object(&mut txb, CETUS_PARTNER, true).map_err(error)?; + let clock = txb.object(sui_clock_object_input()); + + let mut current_balance = into_balance(&mut txb, route.input_coin_type(), swap_coin).map_err(error)?; + let mut current_coin_type = route.input_coin_type().to_string(); + let mut pending_repays: Vec> = Vec::new(); + + for (idx, hop) in route.hops.iter().enumerate() { + let pool = resolver.shared_object(&mut txb, &hop.pool_id, true).map_err(error)?; + let pay_input = current_balance; + let amount_arg = if idx == 0 { + txb.pure(&hop.amount_in) + } else { + balance_value(&mut txb, ¤t_coin_type, current_balance).map_err(error)? + }; + + let zero_output = balance_zero(&mut txb, hop.output_coin_type()).map_err(error)?; + let a2b_arg = txb.pure(&hop.a2b); + let by_amount_in_arg = txb.pure(&true); + let sqrt_price_limit_arg = txb.pure(&sqrt_price_limit_with_slippage(hop, quote.request.options.slippage.bps)); + + let returns = move_call( + &mut txb, + published_at, + MODULE_POOL, + FUNCTION_FLASH_SWAP_WITH_PARTNER, + &[&hop.coin_a, &hop.coin_b], + vec![global_config, pool, partner, a2b_arg, by_amount_in_arg, amount_arg, sqrt_price_limit_arg, clock], + ) + .map_err(error)? + .to_nested(3); + let (recv_a, recv_b, receipt) = match returns.as_slice() { + [recv_a, recv_b, receipt] => (*recv_a, *recv_b, *receipt), + _ => return Err(SwapperError::TransactionError("Cetus CLMM flash_swap did not return expected outputs".into())), + }; + + let (output_balance, empty_balance) = hop.order_by_direction(recv_b, recv_a); + destroy_zero_balance(&mut txb, hop.input_coin_type(), empty_balance).map_err(error)?; + + pending_repays.push(PendingRepay { + hop, + pool, + pay_input, + pay_zero_output: zero_output, + receipt, + }); + + current_balance = output_balance; + current_coin_type = hop.output_coin_type().to_string(); + } + + for repay in pending_repays { + let (pay_a, pay_b) = repay.hop.order_by_direction(repay.pay_input, repay.pay_zero_output); + move_call( + &mut txb, + published_at, + MODULE_POOL, + FUNCTION_REPAY_FLASH_SWAP_WITH_PARTNER, + &[&repay.hop.coin_a, &repay.hop.coin_b], + vec![global_config, repay.pool, partner, pay_a, pay_b, repay.receipt], + ) + .map_err(error)?; + } + + let sender = SuiAddress::from_str("e.request.wallet_address).map(Address::from)?; + let output_coin = from_balance(&mut txb, route.output_coin_type(), current_balance).map_err(error)?; + + let pay_output_fee = match route.fee_side { + FeeSide::Output => route.fee_amount > 0, + FeeSide::Input => false, + }; + if pay_output_fee { + let fee_amount_arg = txb.pure(&route.fee_amount); + let fee_coin = txb + .split_coins(output_coin, vec![fee_amount_arg]) + .pop() + .ok_or_else(|| SwapperError::TransactionError("Cetus CLMM output fee split failed".into()))?; + let recipient = SuiAddress::from_str(&referral_fee.address).map(Address::from)?; + transfer_coin(&mut txb, fee_coin, recipient); + } + + let min_out = apply_slippage_in_bp(&route.net_amount_out(), quote.request.options.slippage.bps); + let min_out_arg = txb.pure(&min_out); + let split_off = txb + .split_coins(output_coin, vec![min_out_arg]) + .pop() + .ok_or_else(|| SwapperError::TransactionError("Cetus CLMM min-out split failed".into()))?; + txb.merge_coins(output_coin, vec![split_off]); + + let dest = SuiAddress::from_str(if quote.request.destination_address.is_empty() { + "e.request.wallet_address + } else { + "e.request.destination_address + }) + .map(Address::from)?; + if dest == sender && is_sui_coin(route.output_coin_type()) { + let gas = txb.gas(); + txb.merge_coins(gas, vec![output_coin]); + } else { + let dest_arg = txb.pure(&dest); + txb.transfer_objects(vec![output_coin], dest_arg); + } + + finish_transaction(txb, input.transaction.clone()).map_err(error) +} + +fn sqrt_price_limit_with_slippage(hop: &Hop, slippage_bps: u32) -> u128 { + if hop.after_sqrt_price == 0 { + return if hop.a2b { MIN_SQRT_PRICE_X64 } else { MAX_SQRT_PRICE_X64 }; + } + let bps = u128::from(slippage_bps); + if hop.a2b { + let limit = hop.after_sqrt_price.saturating_mul(10_000u128.saturating_sub(bps)) / 10_000; + limit.max(MIN_SQRT_PRICE_X64) + } else { + let limit = hop.after_sqrt_price.saturating_mul(10_000u128.saturating_add(bps)).saturating_add(9_999) / 10_000; + limit.min(MAX_SQRT_PRICE_X64) + } +} + +fn pay_referral_fee(txb: &mut TransactionBuilder, input_coin: Argument, fee: u64, referral_fee: &ReferralFee) -> Result { + if fee == 0 { + return Ok(input_coin); + } + let fee_amount = txb.pure(&fee); + let fee_coin = txb + .split_coins(input_coin, vec![fee_amount]) + .pop() + .ok_or_else(|| SwapperError::TransactionError("Sui referral fee split failed".into()))?; + let recipient = SuiAddress::from_str(&referral_fee.address).map(Address::from)?; + transfer_coin(txb, fee_coin, recipient); + Ok(input_coin) +} + +pub(super) fn referral_fee_amount(amount: u64, bps: u32) -> Result { + amount + .checked_mul(u64::from(bps)) + .map(|value| value / 10_000) + .ok_or_else(|| SwapperError::ComputeQuoteError("Cetus CLMM referral fee overflow".into())) +} + +fn transfer_coin(txb: &mut TransactionBuilder, coin: Argument, recipient: Address) { + let recipient = txb.pure(&recipient); + txb.transfer_objects(vec![coin], recipient); +} + +fn cetus_clmm_publish_at() -> Address { + SuiAddress::from_str(CETUS_CLMM_PUBLISHED_AT).map(Address::from).unwrap() +} + +fn shared_object_input(object_id: &str, initial_shared_version: u64, mutable: bool) -> Result { + let address = SuiAddress::from_str(object_id).map(Address::from)?; + Ok(ObjectInput::shared(address, initial_shared_version, mutable)) +} + +fn inspect_transaction_kind_bytes(mut txb: TransactionBuilder) -> Result, SwapperError> { + txb.set_sender(SuiAddress::from_str(EMPTY_ADDRESS).map(Address::from)?); + txb.set_gas_price(0); + txb.set_gas_budget(0); + txb.add_gas_objects(vec![ObjectInput::owned(Address::ZERO, 0, Digest::ZERO)]); + let tx = txb.try_build().map_err(SwapperError::compute_quote_error)?; + bcs::to_bytes(&tx.kind).map_err(SwapperError::compute_quote_error) +} + +fn error(err: impl Display) -> SwapperError { + SwapperError::transaction_error(err) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hop(a2b: bool, after_sqrt_price: u128) -> Hop { + Hop { + pool_id: "0xpool".into(), + pool_init_version: 1, + coin_a: "0xa".into(), + coin_b: "0xb".into(), + a2b, + amount_in: 1_000, + amount_out: 1_000_000, + after_sqrt_price, + } + } + + #[test] + fn test_sqrt_price_limit_with_slippage() { + assert_eq!(sqrt_price_limit_with_slippage(&hop(true, 0), 50), MIN_SQRT_PRICE_X64); + assert_eq!(sqrt_price_limit_with_slippage(&hop(false, 0), 50), MAX_SQRT_PRICE_X64); + + let after = 100_000_000_000_000u128; + let a2b_limit = sqrt_price_limit_with_slippage(&hop(true, after), 50); + assert_eq!(a2b_limit, after * 9_950 / 10_000); + assert!(a2b_limit < after); + + let b2a_limit = sqrt_price_limit_with_slippage(&hop(false, after), 50); + assert!(b2a_limit > after); + assert!(b2a_limit <= MAX_SQRT_PRICE_X64); + } + + #[test] + fn test_referral_fee_amount() { + assert_eq!(referral_fee_amount(1_000_000, 50).unwrap(), 5_000); + assert_eq!(referral_fee_amount(1_000_000, 0).unwrap(), 0); + assert!(referral_fee_amount(u64::MAX, 100).is_err()); + } +} diff --git a/core/crates/swapper/src/chainflip/broker/client.rs b/core/crates/swapper/src/chainflip/broker/client.rs new file mode 100644 index 0000000000..040822716a --- /dev/null +++ b/core/crates/swapper/src/chainflip/broker/client.rs @@ -0,0 +1,106 @@ +use super::{ + ChainflipEnvironment, ChainflipIngressEgress, VaultSwapExtras, VaultSwapResponse, + model::{ChainflipAsset, DcaParameters, DepositAddressResponse, RefundParameters}, +}; +use crate::{STATIC_READ_CACHE_TTL_SECONDS, SwapperError}; +use gem_client::Client; +use gem_jsonrpc::client::JsonRpcClient; +use serde_json::{Value, json}; +use std::fmt::Debug; + +#[derive(Clone, Debug)] +pub struct BrokerClient +where + C: Client + Clone + Debug, +{ + client: JsonRpcClient, +} + +impl BrokerClient +where + C: Client + Clone + Debug, +{ + pub fn new(client: JsonRpcClient) -> Self { + Self { client } + } + + pub async fn get_swap_limits(&self) -> Result { + let result = self + .client + .call_method_with_param("cf_environment", json!([]), Some(STATIC_READ_CACHE_TTL_SECONDS)) + .await + .map_err(SwapperError::from)?; + + let env: ChainflipEnvironment = result.take().map_err(SwapperError::from)?; + Ok(env.ingress_egress) + } + + pub async fn get_deposit_address( + &self, + src_asset: ChainflipAsset, + dst_asset: ChainflipAsset, + dst_address: String, + broker_commission_bps: u32, + boost_fee: Option, + refund_params: Option, + dca_params: Option, + ) -> Result { + let params = json!([ + src_asset, + dst_asset, + dst_address, + broker_commission_bps, + Value::Null, + boost_fee, + Vec::::new(), + refund_params, + dca_params, + ]); + + let result = self + .client + .call_method_with_param("broker_request_swap_deposit_address", params, None) + .await + .map_err(SwapperError::from)?; + + result.take().map_err(SwapperError::from) + } + + pub async fn encode_vault_swap( + &self, + source_asset: ChainflipAsset, + destination_asset: ChainflipAsset, + destination_address: String, + broker_commission: u32, + boost_fee: Option, + extra_params: VaultSwapExtras, + dca_params: Option, + ) -> Result { + let extra_params_json = match extra_params { + VaultSwapExtras::Evm(evm) => serde_json::to_value(evm).unwrap(), + VaultSwapExtras::Bitcoin(btc) => serde_json::to_value(btc).unwrap(), + VaultSwapExtras::Solana(sol) => serde_json::to_value(sol).unwrap(), + VaultSwapExtras::None => Value::Null, + }; + + let params = json!([ + source_asset, + destination_asset, + destination_address, + broker_commission, + extra_params_json, + Value::Null, + boost_fee, + Vec::::new(), + dca_params, + ]); + + let result = self + .client + .call_method_with_param("broker_request_swap_parameter_encoding", params, None) + .await + .map_err(SwapperError::from)?; + + result.take().map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/chainflip/broker/mod.rs b/core/crates/swapper/src/chainflip/broker/mod.rs new file mode 100644 index 0000000000..3c1135bc81 --- /dev/null +++ b/core/crates/swapper/src/chainflip/broker/mod.rs @@ -0,0 +1,5 @@ +mod client; +pub mod model; + +pub use client::BrokerClient; +pub use model::*; diff --git a/core/crates/swapper/src/chainflip/broker/model.rs b/core/crates/swapper/src/chainflip/broker/model.rs new file mode 100644 index 0000000000..a1c5dc0101 --- /dev/null +++ b/core/crates/swapper/src/chainflip/broker/model.rs @@ -0,0 +1,123 @@ +use alloy_primitives::U256; +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_biguint_from_hex_str, serialize_biguint_to_hex_str}; + +use crate::SwapperError; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub struct ChainflipAsset { + pub chain: String, + pub asset: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DepositAddressResponse { + pub address: String, + pub expiry_block: u64, + pub issued_block: u64, + pub channel_id: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RefundParameters { + pub retry_duration: u32, + pub refund_address: String, + pub min_price: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DcaParameters { + pub number_of_chunks: u32, + pub chunk_interval: u32, +} + +#[derive(Debug)] +pub enum VaultSwapExtras { + Evm(VaultSwapEvmExtras), + Bitcoin(VaultSwapBtcExtras), + Solana(VaultSwapSolanaExtras), + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultSwapEvmExtras { + pub chain: String, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str", serialize_with = "serialize_biguint_to_hex_str")] + pub input_amount: BigUint, + pub refund_parameters: RefundParameters, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultSwapBtcExtras { + pub chain: String, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str", serialize_with = "serialize_biguint_to_hex_str")] + pub min_output_amount: BigUint, + pub retry_duration: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultSwapSolanaExtras { + pub chain: String, + pub from: String, + pub seed: String, // random bytes (up to 32 bytes) in hex string + pub input_amount: u64, + pub refund_parameters: RefundParameters, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum VaultSwapResponse { + Evm(EvmVaultSwapResponse), + Bitcoin(BitcoinVaultSwapResponse), + Solana(SolanaVaultSwapResponse), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EvmVaultSwapResponse { + pub calldata: String, + #[serde(deserialize_with = "deserialize_biguint_from_hex_str", serialize_with = "serialize_biguint_to_hex_str")] + pub value: BigUint, + pub to: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BitcoinVaultSwapResponse { + pub nulldata_payload: String, + pub deposit_address: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SolanaVaultSwapResponse { + pub program_id: String, + pub accounts: Vec, + pub data: String, // hex string +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AccountMeta { + pub is_signer: bool, + pub is_writable: bool, + pub pubkey: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainflipEnvironment { + pub ingress_egress: ChainflipIngressEgress, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainflipIngressEgress { + pub minimum_deposit_amounts: serde_json::Value, +} + +impl ChainflipIngressEgress { + pub fn get_min_deposit_amount(&self, asset: &ChainflipAsset) -> Result { + let chain_map = self.minimum_deposit_amounts.get(&asset.chain).ok_or(SwapperError::NotSupportedChain)?; + let asset = chain_map.get(&asset.asset).ok_or(SwapperError::NotSupportedAsset)?; + let amount = asset.as_str().ok_or(SwapperError::NotSupportedAsset)?; + + let u256_value = U256::from_str_radix(amount.trim_start_matches("0x"), 16).map_err(SwapperError::from)?; + Ok(u256_value) + } +} diff --git a/core/crates/swapper/src/chainflip/capitalize.rs b/core/crates/swapper/src/chainflip/capitalize.rs new file mode 100644 index 0000000000..a94318cb72 --- /dev/null +++ b/core/crates/swapper/src/chainflip/capitalize.rs @@ -0,0 +1,7 @@ +pub fn capitalize_first_letter(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), + } +} diff --git a/core/crates/swapper/src/chainflip/client/mod.rs b/core/crates/swapper/src/chainflip/client/mod.rs new file mode 100644 index 0000000000..506117d5a0 --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod swap; + +pub use model::*; +pub use swap::ChainflipClient; diff --git a/core/crates/swapper/src/chainflip/client/model.rs b/core/crates/swapper/src/chainflip/client/model.rs new file mode 100644 index 0000000000..715012e12e --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/model.rs @@ -0,0 +1,280 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use num_bigint::BigUint; +use primitives::swap::SwapStatus; +use primitives::{AssetId, Chain, TransactionSwapMetadata, known_assets::*}; +use serde::{Deserialize, Serialize}; +use serde_serializers::{deserialize_biguint_from_str, serialize_biguint}; + +use crate::{SwapResult, SwapperChainAsset, SwapperProvider}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteRequest { + pub amount: String, + pub src_chain: String, + pub src_asset: String, + pub dest_chain: String, + pub dest_asset: String, + pub is_vault_swap: bool, + pub dca_enabled: bool, + pub broker_commission_bps: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IncludedFee { + #[serde(rename = "type")] + pub fee_type: String, + pub chain: String, + pub asset: String, + pub amount: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DcaParams { + pub number_of_chunks: u32, + pub chunk_interval_blocks: u32, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteResponse { + #[serde(deserialize_with = "deserialize_biguint_from_str", serialize_with = "serialize_biguint")] + pub egress_amount: BigUint, + pub recommended_slippage_tolerance_percent: f64, + pub estimated_duration_seconds: f64, + #[serde(rename = "type")] + pub quote_type: String, + pub deposit_amount: String, + pub is_vault_swap: bool, + pub boost_quote: Option, + pub estimated_price: String, + pub dca_params: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BoostQuote { + #[serde(deserialize_with = "deserialize_biguint_from_str", serialize_with = "serialize_biguint")] + pub egress_amount: BigUint, + pub recommended_slippage_tolerance_percent: f64, + pub estimated_duration_seconds: f64, + pub estimated_boost_fee_bps: u32, + pub max_boost_fee_bps: u32, + pub estimated_price: String, + pub dca_params: Option, +} + +impl QuoteResponse { + pub fn slippage_bps(&self) -> u32 { + (self.recommended_slippage_tolerance_percent * 100.0) as u32 + } +} + +impl BoostQuote { + pub fn slippage_bps(&self) -> u32 { + (self.recommended_slippage_tolerance_percent * 100.0) as u32 + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapTxResponse { + pub state: String, + pub src_asset: String, + pub src_chain: String, + pub dest_asset: String, + pub dest_chain: String, + pub deposit: Option, + pub swap: Option, + pub refund_egress: Option, +} + +impl SwapTxResponse { + pub fn swap_status(&self) -> SwapStatus { + match self.state.as_str() { + "COMPLETED" if self.refund_egress.is_some() => SwapStatus::Failed, + "COMPLETED" => SwapStatus::Completed, + "FAILED" => SwapStatus::Failed, + _ => SwapStatus::Pending, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapDeposit { + pub amount: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapDetail { + pub swapped_output_amount: String, +} + +fn chainflip_chain_to_chain(chain: &str) -> Option { + match chain { + "Ethereum" => Some(Chain::Ethereum), + "Bitcoin" => Some(Chain::Bitcoin), + "Solana" => Some(Chain::Solana), + "Arbitrum" => Some(Chain::Arbitrum), + _ => None, + } +} + +static CHAINFLIP_ASSETS: LazyLock> = LazyLock::new(|| { + vec![ + (Chain::Bitcoin, "BTC", AssetId::from_chain(Chain::Bitcoin)), + (Chain::Ethereum, "ETH", AssetId::from_chain(Chain::Ethereum)), + (Chain::Ethereum, "USDC", ETHEREUM_USDC.id.clone()), + (Chain::Ethereum, "USDT", ETHEREUM_USDT.id.clone()), + (Chain::Ethereum, "WBTC", ETHEREUM_WBTC.id.clone()), + (Chain::Ethereum, "FLIP", ETHEREUM_FLIP.id.clone()), + (Chain::Solana, "SOL", AssetId::from_chain(Chain::Solana)), + (Chain::Solana, "USDC", SOLANA_USDC.id.clone()), + (Chain::Solana, "USDT", SOLANA_USDT.id.clone()), + (Chain::Arbitrum, "ETH", AssetId::from_chain(Chain::Arbitrum)), + (Chain::Arbitrum, "USDC", ARBITRUM_USDC.id.clone()), + (Chain::Arbitrum, "USDT", ARBITRUM_USDT.id.clone()), + ] +}); + +pub static CHAINFLIP_SUPPORTED_ASSETS: LazyLock> = LazyLock::new(|| { + let mut chains: BTreeMap> = BTreeMap::new(); + for (chain, _, asset_id) in CHAINFLIP_ASSETS.iter() { + let tokens = chains.entry(*chain).or_default(); + if asset_id.token_id.is_some() { + tokens.push(asset_id.clone()); + } + } + chains.into_iter().map(|(chain, tokens)| SwapperChainAsset::Assets(chain, tokens)).collect() +}); + +fn chainflip_asset_to_asset_id(chain: Chain, asset: &str) -> Option { + CHAINFLIP_ASSETS.iter().find(|(c, s, _)| *c == chain && *s == asset).map(|(_, _, id)| id.clone()) +} + +pub fn map_swap_result(response: &SwapTxResponse) -> SwapResult { + let status = response.swap_status(); + + let metadata = if status != SwapStatus::Pending { + let from_chain = chainflip_chain_to_chain(&response.src_chain); + let to_chain = chainflip_chain_to_chain(&response.dest_chain); + + from_chain.zip(to_chain).and_then(|(fc, tc)| { + let from_asset = chainflip_asset_to_asset_id(fc, &response.src_asset)?; + let to_asset = chainflip_asset_to_asset_id(tc, &response.dest_asset)?; + let from_value = response.deposit.as_ref()?.amount.clone(); + let to_value = response.swap.as_ref().map(|s| s.swapped_output_amount.clone()).unwrap_or_default(); + Some(TransactionSwapMetadata { + from_asset, + from_value, + to_asset, + to_value, + provider: Some(SwapperProvider::Chainflip.as_ref().to_string()), + }) + }) + } else { + None + }; + + SwapResult { status, metadata } +} + +#[cfg(test)] +pub mod test { + use super::*; + use primitives::{ + AssetId, + asset_constants::{ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDT_ASSET_ID}, + }; + + fn swap_response(json: &str) -> SwapTxResponse { + serde_json::from_str(json).unwrap() + } + + #[test] + pub fn get_quote_response() { + let quote_response = serde_json::from_str::>(include_str!("./test/btc_eth_quote.json")).unwrap(); + + assert!(quote_response[0].boost_quote.is_some()); + } + + #[test] + fn test_map_swap_result_eth_to_btc() { + assert_eq!( + map_swap_result(&swap_response(include_str!("./test/swap_eth_to_btc.json"))), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: AssetId::from_chain(Chain::Ethereum), + from_value: "140000000000000000".to_string(), + to_asset: AssetId::from_chain(Chain::Bitcoin), + to_value: "405772".to_string(), + provider: Some("chainflip".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_usdc_to_sol() { + assert_eq!( + map_swap_result(&swap_response(include_str!("./test/swap_usdc_to_sol.json"))), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: ETHEREUM_USDC_ASSET_ID.clone(), + from_value: "100000000".to_string(), + to_asset: AssetId::from_chain(Chain::Solana), + to_value: "1143469990".to_string(), + provider: Some("chainflip".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_sol_to_btc() { + assert_eq!( + map_swap_result(&swap_response(include_str!("./test/swap_sol_to_btc.json"))), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: AssetId::from_chain(Chain::Solana), + from_value: "150000000".to_string(), + to_asset: AssetId::from_chain(Chain::Bitcoin), + to_value: "17567".to_string(), + provider: Some("chainflip".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_pending() { + let result = map_swap_result(&swap_response(include_str!("./test/swap_usdc_to_btc_pending.json"))); + assert_eq!(result.status, SwapStatus::Pending); + assert!(result.metadata.is_none()); + } + + #[test] + fn test_map_swap_result_refunded() { + assert_eq!( + map_swap_result(&swap_response(include_str!("./test/swap_btc_to_usdt_refunded.json"))), + SwapResult { + status: SwapStatus::Failed, + metadata: Some(TransactionSwapMetadata { + from_asset: AssetId::from_chain(Chain::Bitcoin), + from_value: "1508475".to_string(), + to_asset: ETHEREUM_USDT_ASSET_ID.clone(), + to_value: "0".to_string(), + provider: Some("chainflip".to_string()), + }), + } + ); + } +} diff --git a/core/crates/swapper/src/chainflip/client/swap.rs b/core/crates/swapper/src/chainflip/client/swap.rs new file mode 100644 index 0000000000..309cb6c6bb --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/swap.rs @@ -0,0 +1,47 @@ +use super::{ + SwapTxResponse, + model::{QuoteRequest, QuoteResponse}, +}; +use crate::SwapperError; +use gem_client::{Client, ClientExt}; +use serde_json::Value; +use serde_urlencoded; +use std::fmt::Debug; + +const QUOTE_PATH: &str = "/v2/quote"; +const SWAP_PATH: &str = "/v2/swaps"; + +#[derive(Clone, Debug)] +pub struct ChainflipClient +where + C: Client + Clone + Debug, +{ + client: C, +} + +impl ChainflipClient +where + C: Client + Clone + Debug, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_quote(&self, request: &QuoteRequest) -> Result, SwapperError> { + let query = serde_urlencoded::to_string(request).map_err(SwapperError::from)?; + let path = format!("{QUOTE_PATH}?{query}"); + let value: Value = self.client.get(&path).await.map_err(SwapperError::from)?; + + if let Some(message) = value.get("message").and_then(Value::as_str) { + return Err(SwapperError::compute_quote_error(message)); + } + + let quotes = serde_json::from_value(value).map_err(SwapperError::from)?; + Ok(quotes) + } + + pub async fn get_tx_status(&self, tx_hash: &str) -> Result { + let path = format!("{SWAP_PATH}/{tx_hash}"); + self.client.get(&path).await.map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/chainflip/client/test/btc_eth_quote.json b/core/crates/swapper/src/chainflip/client/test/btc_eth_quote.json new file mode 100644 index 0000000000..29399d14e3 --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/test/btc_eth_quote.json @@ -0,0 +1,162 @@ +[ + { + "intermediateAmount": "964759906", + "egressAmount": "533584963872668039", + "recommendedSlippageTolerancePercent": 1.5, + "includedFees": [ + { + "type": "INGRESS", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "175" + }, + { + "type": "NETWORK", + "chain": "Ethereum", + "asset": "USDC", + "amount": "965726" + }, + { + "type": "EGRESS", + "chain": "Ethereum", + "asset": "ETH", + "amount": "165403829640000" + } + ], + "lowLiquidityWarning": false, + "poolInfo": [ + { + "baseAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Bitcoin", + "asset": "BTC", + "amount": "499" + } + }, + { + "baseAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "USDC", + "amount": "482379" + } + } + ], + "estimatedDurationsSeconds": { + "swap": 12, + "deposit": 1806, + "egress": 102 + }, + "estimatedDurationSeconds": 1920, + "estimatedPrice": "53.3843790365622022854", + "type": "REGULAR", + "srcAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "destAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "depositAmount": "1000000", + "isVaultSwap": true, + "boostQuote": { + "intermediateAmount": "964277200", + "egressAmount": "533317994508265049", + "recommendedSlippageTolerancePercent": 1, + "includedFees": [ + { + "type": "BOOST", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "500" + }, + { + "type": "INGRESS", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "175" + }, + { + "type": "NETWORK", + "chain": "Ethereum", + "asset": "USDC", + "amount": "965242" + }, + { + "type": "EGRESS", + "chain": "Ethereum", + "asset": "ETH", + "amount": "165403829640000" + } + ], + "lowLiquidityWarning": false, + "poolInfo": [ + { + "baseAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Bitcoin", + "asset": "BTC", + "amount": "499" + } + }, + { + "baseAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "USDC", + "amount": "482138" + } + } + ], + "estimatedDurationsSeconds": { + "swap": 12, + "deposit": 606, + "egress": 102 + }, + "estimatedDurationSeconds": 720, + "estimatedPrice": "53.38437428643384774723", + "type": "REGULAR", + "srcAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "destAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "depositAmount": "1000000", + "isVaultSwap": true, + "estimatedBoostFeeBps": 5, + "maxBoostFeeBps": 30 + } + } + ] \ No newline at end of file diff --git a/core/crates/swapper/src/chainflip/client/test/swap_btc_to_usdt_refunded.json b/core/crates/swapper/src/chainflip/client/test/swap_btc_to_usdt_refunded.json new file mode 100644 index 0000000000..79b642bece --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/test/swap_btc_to_usdt_refunded.json @@ -0,0 +1,16 @@ +{ + "state": "COMPLETED", + "srcAsset": "BTC", + "srcChain": "Bitcoin", + "destAsset": "USDT", + "destChain": "Ethereum", + "deposit": { + "amount": "1508475" + }, + "swap": { + "swappedOutputAmount": "0" + }, + "refundEgress": { + "amount": "1506979" + } +} diff --git a/core/crates/swapper/src/chainflip/client/test/swap_eth_to_btc.json b/core/crates/swapper/src/chainflip/client/test/swap_eth_to_btc.json new file mode 100644 index 0000000000..a302aa93a1 --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/test/swap_eth_to_btc.json @@ -0,0 +1,13 @@ +{ + "state": "COMPLETED", + "srcAsset": "ETH", + "srcChain": "Ethereum", + "destAsset": "BTC", + "destChain": "Bitcoin", + "deposit": { + "amount": "140000000000000000" + }, + "swap": { + "swappedOutputAmount": "405772" + } +} diff --git a/core/crates/swapper/src/chainflip/client/test/swap_sol_to_btc.json b/core/crates/swapper/src/chainflip/client/test/swap_sol_to_btc.json new file mode 100644 index 0000000000..5224998fdf --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/test/swap_sol_to_btc.json @@ -0,0 +1,13 @@ +{ + "state": "COMPLETED", + "srcAsset": "SOL", + "srcChain": "Solana", + "destAsset": "BTC", + "destChain": "Bitcoin", + "deposit": { + "amount": "150000000" + }, + "swap": { + "swappedOutputAmount": "17567" + } +} diff --git a/core/crates/swapper/src/chainflip/client/test/swap_usdc_to_btc_pending.json b/core/crates/swapper/src/chainflip/client/test/swap_usdc_to_btc_pending.json new file mode 100644 index 0000000000..14d5881425 --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/test/swap_usdc_to_btc_pending.json @@ -0,0 +1,13 @@ +{ + "state": "SENDING", + "srcAsset": "USDC", + "srcChain": "Solana", + "destAsset": "BTC", + "destChain": "Bitcoin", + "deposit": { + "amount": "495625000" + }, + "swap": { + "swappedOutputAmount": "695407" + } +} diff --git a/core/crates/swapper/src/chainflip/client/test/swap_usdc_to_sol.json b/core/crates/swapper/src/chainflip/client/test/swap_usdc_to_sol.json new file mode 100644 index 0000000000..2c5f94b658 --- /dev/null +++ b/core/crates/swapper/src/chainflip/client/test/swap_usdc_to_sol.json @@ -0,0 +1,13 @@ +{ + "state": "COMPLETED", + "srcAsset": "USDC", + "srcChain": "Ethereum", + "destAsset": "SOL", + "destChain": "Solana", + "deposit": { + "amount": "100000000" + }, + "swap": { + "swappedOutputAmount": "1143469990" + } +} diff --git a/core/crates/swapper/src/chainflip/default.rs b/core/crates/swapper/src/chainflip/default.rs new file mode 100644 index 0000000000..8ea5dc5fa7 --- /dev/null +++ b/core/crates/swapper/src/chainflip/default.rs @@ -0,0 +1,21 @@ +use super::{broker::BrokerClient, client::ChainflipClient, provider::ChainflipProvider}; +use crate::{ + alien::{RpcClient, RpcProvider}, + config::get_swap_proxy_url, +}; +use gem_jsonrpc::client::JsonRpcClient; +use std::sync::Arc; + +impl ChainflipProvider { + pub fn new(rpc_provider: Arc) -> Self { + let api_url = get_swap_proxy_url("chainflip-swap"); + let broker_url = get_swap_proxy_url("chainflip-broker/rpc"); + + let api_client = RpcClient::new(api_url, rpc_provider.clone()); + let chainflip_client = ChainflipClient::new(api_client.clone()); + + let broker_client = BrokerClient::new(JsonRpcClient::new(RpcClient::new(broker_url, rpc_provider.clone()))); + + Self::with_clients(chainflip_client, broker_client, rpc_provider) + } +} diff --git a/core/crates/swapper/src/chainflip/mod.rs b/core/crates/swapper/src/chainflip/mod.rs new file mode 100644 index 0000000000..a89e3d5b44 --- /dev/null +++ b/core/crates/swapper/src/chainflip/mod.rs @@ -0,0 +1,12 @@ +pub mod broker; +pub mod capitalize; +pub mod client; +pub mod default; +pub mod model; +pub mod price; +pub mod provider; +pub mod seed; +pub mod tx_builder; + +pub use model::*; +pub use provider::ChainflipProvider; diff --git a/core/crates/swapper/src/chainflip/model.rs b/core/crates/swapper/src/chainflip/model.rs new file mode 100644 index 0000000000..c93b66fa63 --- /dev/null +++ b/core/crates/swapper/src/chainflip/model.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +use super::broker::DcaParameters; + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct ChainflipRouteData { + pub boost_fee: Option, + pub fee_bps: u32, + pub estimated_price: String, + pub dca_parameters: Option, +} diff --git a/core/crates/swapper/src/chainflip/price.rs b/core/crates/swapper/src/chainflip/price.rs new file mode 100644 index 0000000000..a3180d0bad --- /dev/null +++ b/core/crates/swapper/src/chainflip/price.rs @@ -0,0 +1,36 @@ +use bigdecimal::BigDecimal; +use num_bigint::BigInt; +use num_bigint::ToBigInt; +use num_traits::FromPrimitive; + +pub fn apply_slippage(original_price: f64, slippage_bps: u32) -> f64 { + original_price * (1.0 - slippage_bps as f64 / 10000.0) +} +/// https://docs.chainflip.io/lp/integrations/lp-api#hex-price +pub fn price_to_hex_price(price: f64, quote_asset_decimals: u32, base_asset_decimals: u32) -> Result { + if price.is_nan() || price.is_infinite() { + return Err(format!("Input price ({price}) is NaN or Infinity.")); + } + let price = BigDecimal::from_f64(price).ok_or("Failed to convert price to BigDecimal")?; + let shifted = price * BigInt::from(2).pow(128); + let hex_price = shifted * BigInt::from(10).pow(quote_asset_decimals) / BigDecimal::from_bigint(BigInt::from(10).pow(base_asset_decimals), 0); + let hex_price_str = hex_price.to_bigint().map(|x| x.to_str_radix(16)).ok_or("Failed to convert to BigInt")?; + let padded = format!("0x{}{}", if hex_price_str.len() % 2 == 0 { "" } else { "0" }, hex_price_str); + Ok(padded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_slippage_sell() { + assert_eq!(apply_slippage(100.0, 100), 99.0); + } + + #[test] + fn test_example_10000_usdc_eth() { + // 10000 USDC/ETH, base asset is USDC, quote asset is ETH + assert_eq!(price_to_hex_price(10000.0, 6, 18).unwrap(), "0x2af31dc4611873bf3f70834acd"); + } +} diff --git a/core/crates/swapper/src/chainflip/provider.rs b/core/crates/swapper/src/chainflip/provider.rs new file mode 100644 index 0000000000..e08e19ec9b --- /dev/null +++ b/core/crates/swapper/src/chainflip/provider.rs @@ -0,0 +1,425 @@ +use alloy_primitives::{U256, hex}; +use async_trait::async_trait; +use gem_client::Client; +use num_bigint::BigUint; +use num_traits::ToPrimitive; +use std::{fmt::Debug, sync::Arc}; + +use super::{ + ChainflipRouteData, + broker::{BrokerClient, ChainflipAsset, DcaParameters, RefundParameters, VaultSwapBtcExtras, VaultSwapEvmExtras, VaultSwapExtras, VaultSwapResponse, VaultSwapSolanaExtras}, + capitalize::capitalize_first_letter, + client::{CHAINFLIP_SUPPORTED_ASSETS, ChainflipClient, QuoteRequest as ChainflipQuoteRequest, QuoteResponse, map_swap_result}, + price::{apply_slippage, price_to_hex_price}, + seed::generate_random_seed, + tx_builder, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData, + alien::RpcProvider, + amount_to_value, + approval::{check_approval_erc20, get_swap_gas_limit_with_approval}, + cross_chain::VaultAddresses, + fees::{DEFAULT_CHAINFLIP_FEE_BPS, apply_slippage_in_bp, quote_value_after_reserve_by_chain}, + solana::DEFAULT_SWAP_GAS_LIMIT, +}; +use primitives::{ChainType, chain::Chain, swap::QuoteAsset}; + +const DEFAULT_SWAP_ERC20_GAS_LIMIT: u64 = 100_000; + +const VAULT_ETH: &str = "0xF5e10380213880111522dd0efD3dbb45b9f62Bcc"; +const VAULT_ARB: &str = "0x79001a5e762f3bEFC8e5871b42F6734e00498920"; +const VAULT_SOL: &str = "J88B7gmadHzTNGiy54c9Ms8BsEXNdB2fntFyhKpk3qoT"; + +// Solana vault swap: tx fee (5K) + createAccount rent (2.31M) + event transfer (223K) + wallet rent exemption (891K) ≈ 3.43M lamports +const SOLANA_VAULT_SWAP_RESERVE: u64 = 4_000_000; + +#[derive(Debug)] +pub struct ChainflipProvider +where + CX: Client + Clone + Send + Sync + Debug + 'static, + BR: Client + Clone + Send + Sync + Debug + 'static, +{ + provider: ProviderType, + chainflip_client: ChainflipClient, + broker_client: BrokerClient
, + rpc_provider: Arc, +} + +impl ChainflipProvider +where + CX: Client + Clone + Send + Sync + Debug + 'static, + BR: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn with_clients(chainflip_client: ChainflipClient, broker_client: BrokerClient
, rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Chainflip), + chainflip_client, + broker_client, + rpc_provider, + } + } + + fn map_asset_id(asset: &QuoteAsset) -> ChainflipAsset { + let asset_id = asset.asset_id(); + let chain_name = capitalize_first_letter(asset_id.chain.as_ref()); + ChainflipAsset { + chain: chain_name, + asset: asset.symbol.clone(), + } + } + + fn get_quote_value(request: &QuoteRequest) -> Result { + let value = quote_value_after_reserve_by_chain(request)?; + if !request.options.use_max_amount || !request.from_asset.asset_id().is_native() { + return Ok(value); + } + match request.from_asset.chain() { + Chain::Solana => { + let amount: u64 = value.parse().map_err(|_| SwapperError::ComputeQuoteError(format!("invalid amount: {value}")))?; + let reserved = amount.saturating_sub(SOLANA_VAULT_SWAP_RESERVE); + if reserved == 0 { + return Err(SwapperError::InputAmountError { + min_amount: Some(SOLANA_VAULT_SWAP_RESERVE.to_string()), + }); + } + Ok(reserved.to_string()) + } + _ => Ok(value), + } + } +} + +fn get_best_quote(mut quotes: Vec, fee_bps: u32) -> (BigUint, u32, u32, ChainflipRouteData) { + quotes.sort_by(|a, b| b.egress_amount.cmp(&a.egress_amount)); + let quote = "es[0]; + + let (egress_amount, slippage_bps, eta_in_seconds, boost_fee, estimated_price, dca_parameters) = if let Some(boost_quote) = "e.boost_quote { + ( + boost_quote.egress_amount.clone(), + boost_quote.slippage_bps(), + boost_quote.estimated_duration_seconds as u32, + Some(boost_quote.estimated_boost_fee_bps), + boost_quote.estimated_price.clone(), + boost_quote.dca_params.as_ref().map(|dca| DcaParameters { + number_of_chunks: dca.number_of_chunks, + chunk_interval: dca.chunk_interval_blocks, + }), + ) + } else { + ( + quote.egress_amount.clone(), + quote.slippage_bps(), + quote.estimated_duration_seconds as u32, + None, + quote.estimated_price.clone(), + quote.dca_params.as_ref().map(|dca| DcaParameters { + number_of_chunks: dca.number_of_chunks, + chunk_interval: dca.chunk_interval_blocks, + }), + ) + }; + + ( + egress_amount, + slippage_bps, + eta_in_seconds, + ChainflipRouteData { + boost_fee, + fee_bps, + estimated_price, + dca_parameters, + }, + ) +} + +fn map_chainflip_quote_error(error: SwapperError, from_decimals: u32) -> SwapperError { + match error { + SwapperError::ComputeQuoteError(message) => { + let lower = message.to_ascii_lowercase(); + if lower.contains("expected amount is below minimum swap amount") { + SwapperError::InputAmountError { + min_amount: parse_min_amount(&message, from_decimals), + } + } else { + SwapperError::ComputeQuoteError(message) + } + } + other => other, + } +} + +fn parse_min_amount(message: &str, decimals: u32) -> Option { + let open = message.rfind('(')?; + let close = message[open + 1..].find(')')? + open + 1; + let token = message[open + 1..close].trim(); + amount_to_value(token, decimals) +} + +#[async_trait] +impl Swapper for ChainflipProvider +where + CX: Client + Clone + Send + Sync + Debug + 'static, + BR: Client + Clone + Send + Sync + Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + CHAINFLIP_SUPPORTED_ASSETS.clone() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + if request.from_asset.chain().chain_type() == ChainType::Bitcoin { + return Err(SwapperError::NoQuoteAvailable); + } + + let from_value = Self::get_quote_value(request)?; + let src_asset = Self::map_asset_id(&request.from_asset); + let dest_asset = Self::map_asset_id(&request.to_asset); + + let fee_bps = DEFAULT_CHAINFLIP_FEE_BPS; + let quote_request = ChainflipQuoteRequest { + amount: from_value.clone(), + src_chain: src_asset.chain.clone(), + src_asset: src_asset.asset.clone(), + dest_chain: dest_asset.chain, + dest_asset: dest_asset.asset, + is_vault_swap: true, + dca_enabled: true, + broker_commission_bps: Some(fee_bps), + }; + + let quotes = match self.chainflip_client.get_quote("e_request).await { + Ok(quotes) => quotes, + Err(err) => return Err(map_chainflip_quote_error(err, request.from_asset.decimals)), + }; + if quotes.is_empty() { + return Err(SwapperError::NoQuoteAvailable); + } + + let (egress_amount, slippage_bps, eta_in_seconds, route_data) = get_best_quote(quotes, fee_bps); + + Ok(Quote { + from_value, + min_from_value: None, + to_value: egress_amount.to_string(), + data: ProviderData { + provider: self.provider.clone(), + slippage_bps, + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: serde_json::to_string(&route_data).unwrap(), + }], + }, + eta_in_seconds: Some(eta_in_seconds), + request: request.clone(), + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let from_asset = quote.request.from_asset.asset_id(); + let source_asset = Self::map_asset_id("e.request.from_asset); + let destination_asset = Self::map_asset_id("e.request.to_asset); + + let input_amount: BigUint = quote.from_value.parse()?; + + let route_data: ChainflipRouteData = serde_json::from_str("e.data.routes[0].route_data)?; + let chain = source_asset.chain.clone(); + let price = route_data.estimated_price.parse::().map_err(|_| SwapperError::transaction_error("Invalid price"))?; + let price_slippage = apply_slippage(price, quote.data.slippage_bps); + let quote_asset_decimals = quote.request.to_asset.decimals; + let base_asset_decimals = quote.request.from_asset.decimals; + let min_price = price_to_hex_price(price_slippage, quote_asset_decimals, base_asset_decimals).map_err(SwapperError::TransactionError)?; + let extra_params = if from_asset.chain.chain_type() == ChainType::Ethereum { + VaultSwapExtras::Evm(VaultSwapEvmExtras { + chain, + input_amount: input_amount.clone(), + refund_parameters: RefundParameters { + retry_duration: 150, + refund_address: quote.request.wallet_address.clone(), + min_price, + }, + }) + } else if from_asset.chain.chain_type() == ChainType::Bitcoin { + let output_amount: U256 = quote.to_value.parse()?; + let min_output_amount = apply_slippage_in_bp(&output_amount, quote.data.slippage_bps); + VaultSwapExtras::Bitcoin(VaultSwapBtcExtras { + chain, + min_output_amount: BigUint::from_bytes_le(&min_output_amount.to_le_bytes::<32>()), + retry_duration: 6, + }) + } else if from_asset.chain.chain_type() == ChainType::Solana { + VaultSwapExtras::Solana(VaultSwapSolanaExtras { + from: quote.request.wallet_address.clone(), + seed: hex::encode_prefixed(generate_random_seed(32)), + chain, + input_amount: input_amount.to_u64().unwrap(), + refund_parameters: RefundParameters { + retry_duration: 10, + refund_address: quote.request.wallet_address.clone(), + min_price, + }, + }) + } else { + VaultSwapExtras::None + }; + + let response = self + .broker_client + .encode_vault_swap( + source_asset, + destination_asset, + quote.request.destination_address.clone(), + route_data.fee_bps, + route_data.boost_fee, + extra_params, + route_data.dca_parameters, + ) + .await?; + + match response { + VaultSwapResponse::Evm(response) => { + let value = if from_asset.is_native() { quote.from_value.clone() } else { "0".to_string() }; + + let approval = if from_asset.chain.chain_type() == ChainType::Ethereum && !from_asset.is_native() { + let approval = check_approval_erc20( + quote.request.wallet_address.clone(), + from_asset.token_id.unwrap(), + response.to.clone(), + U256::from_le_slice(&input_amount.to_bytes_le()), + self.rpc_provider.clone(), + &from_asset.chain, + ) + .await?; + approval.approval_data() + } else { + None + }; + + let gas_limit = get_swap_gas_limit_with_approval(&approval, None, DEFAULT_SWAP_ERC20_GAS_LIMIT); + + Ok(SwapperQuoteData::new_contract(response.to, value, response.calldata, approval, gas_limit)) + } + VaultSwapResponse::Bitcoin(response) => Ok(SwapperQuoteData::new_contract( + response.deposit_address, + quote.from_value.clone(), + response.nulldata_payload, + None, + None, + )), + VaultSwapResponse::Solana(response) => { + let data = tx_builder::build_solana_tx("e.request.wallet_address, &response, self.rpc_provider.clone()) + .await + .map_err(SwapperError::TransactionError)?; + Ok(SwapperQuoteData::new_contract( + response.program_id, + "".into(), + data, + None, + Some(DEFAULT_SWAP_GAS_LIMIT.to_string()), + )) + } + } + } + + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + let deposit = vec![VAULT_ETH.to_string(), VAULT_ARB.to_string(), VAULT_SOL.to_string()]; + Ok(VaultAddresses { deposit, send: vec![] }) + } + + async fn get_swap_result(&self, _chain: Chain, transaction_hash: &str) -> Result { + let response = self.chainflip_client.get_tx_status(transaction_hash).await?; + Ok(map_swap_result(&response)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chainflip_min_amount_error() { + let message = "expected amount is below minimum swap amount (68000000)".to_string(); + let err = map_chainflip_quote_error(SwapperError::ComputeQuoteError(message), 6); + assert_eq!( + err, + SwapperError::InputAmountError { + min_amount: Some("68000000".into()) + } + ); + + let message = "expected amount is below minimum swap amount (1.23)".to_string(); + let err = map_chainflip_quote_error(SwapperError::ComputeQuoteError(message), 6); + assert_eq!( + err, + SwapperError::InputAmountError { + min_amount: Some("1230000".into()) + } + ); + } + + #[test] + fn test_best_quote() { + let quotes: Vec = serde_json::from_str(include_str!("./test/chainflip_quotes.json")).unwrap(); + let (egress_amount, slippage_bps, eta_in_seconds, route_data) = get_best_quote(quotes, DEFAULT_CHAINFLIP_FEE_BPS); + + assert_eq!(egress_amount.to_string(), "145118751424"); + assert_eq!(slippage_bps, 250); + assert_eq!(eta_in_seconds, 192); + assert_eq!( + route_data, + ChainflipRouteData { + boost_fee: None, + fee_bps: DEFAULT_CHAINFLIP_FEE_BPS, + estimated_price: "14.5118765424".to_string(), + dca_parameters: None, + } + ); + } + + #[test] + fn test_best_boost_quote() { + let quotes: Vec = serde_json::from_str(include_str!("./test/chainflip_boost_quotes.json")).unwrap(); + let (egress_amount, slippage_bps, eta_in_seconds, route_data) = get_best_quote(quotes, DEFAULT_CHAINFLIP_FEE_BPS); + + assert_eq!(egress_amount.to_string(), "4080936927013539226"); + assert_eq!(slippage_bps, 100); + assert_eq!(eta_in_seconds, 744); + assert_eq!( + route_data, + ChainflipRouteData { + boost_fee: Some(5), + fee_bps: DEFAULT_CHAINFLIP_FEE_BPS, + estimated_price: "40.83388759199201533512".to_string(), + dca_parameters: Some(DcaParameters { + number_of_chunks: 3, + chunk_interval: 2 + }), + } + ); + } + + #[tokio::test] + #[cfg(feature = "swap_integration_tests")] + async fn test_get_swap_result() -> Result<(), Box> { + use crate::alien::reqwest_provider::NativeProvider; + use primitives::swap::SwapStatus; + + let network_provider = Arc::new(NativeProvider::default()); + let swap_provider = ChainflipProvider::new(network_provider.clone()); + + // Swap ID: 902663 + let tx_hash = "3sbA7vTDa8tmuokNeQxWJBPpxG3A1Vw5rhDxSm63w7hW31bo2nbci8CfLr27JsbhcebLwcJcwqbL8UP5aVCMFLGb"; + let chain = Chain::Solana; + + let result = swap_provider.get_swap_result(chain, tx_hash).await?; + + println!("Chainflip swap result: {:?}", result); + assert_eq!(result.status, SwapStatus::Completed); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/chainflip/seed.rs b/core/crates/swapper/src/chainflip/seed.rs new file mode 100644 index 0000000000..7c458b2807 --- /dev/null +++ b/core/crates/swapper/src/chainflip/seed.rs @@ -0,0 +1,15 @@ +use rand::{Rng, RngExt}; + +pub fn generate_random_seed(max_bytes: usize) -> Vec { + if max_bytes == 0 || max_bytes > 32 { + return vec![]; + } + + let mut rng = rand::rng(); + let num_bytes = rng.random_range(1..=max_bytes); + + let mut seed_bytes = vec![0u8; num_bytes]; + rng.fill_bytes(&mut seed_bytes); + + seed_bytes +} diff --git a/core/crates/swapper/src/chainflip/test/chainflip_boost_quotes.json b/core/crates/swapper/src/chainflip/test/chainflip_boost_quotes.json new file mode 100644 index 0000000000..d868b65e5e --- /dev/null +++ b/core/crates/swapper/src/chainflip/test/chainflip_boost_quotes.json @@ -0,0 +1,330 @@ +[ + { + "intermediateAmount": "10421438288", + "egressAmount": "4082976513112383071", + "recommendedSlippageTolerancePercent": 1.5, + "includedFees": [ + { + "type": "INGRESS", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "166" + }, + { + "type": "NETWORK", + "chain": "Ethereum", + "asset": "USDC", + "amount": "10431870" + }, + { + "type": "EGRESS", + "chain": "Ethereum", + "asset": "ETH", + "amount": "342353552660000" + } + ], + "lowLiquidityWarning": false, + "poolInfo": [ + { + "baseAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Bitcoin", + "asset": "BTC", + "amount": "4999" + } + }, + { + "baseAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "USDC", + "amount": "5210719" + } + } + ], + "estimatedDurationsSeconds": { + "swap": 12, + "deposit": 1806, + "egress": 102 + }, + "estimatedDurationSeconds": 1920, + "estimatedPrice": "40.83386650883447736232", + "type": "REGULAR", + "srcAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "destAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "depositAmount": "10000000", + "isVaultSwap": true, + "boostQuote": { + "intermediateAmount": "10416226960", + "egressAmount": "4080934615929730944", + "recommendedSlippageTolerancePercent": 1, + "includedFees": [ + { + "type": "BOOST", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "5000" + }, + { + "type": "INGRESS", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "166" + }, + { + "type": "NETWORK", + "chain": "Ethereum", + "asset": "USDC", + "amount": "10426654" + }, + { + "type": "EGRESS", + "chain": "Ethereum", + "asset": "ETH", + "amount": "342353552660000" + } + ], + "lowLiquidityWarning": false, + "poolInfo": [ + { + "baseAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Bitcoin", + "asset": "BTC", + "amount": "4997" + } + }, + { + "baseAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "USDC", + "amount": "5208113" + } + } + ], + "estimatedDurationsSeconds": { + "swap": 12, + "deposit": 606, + "egress": 102 + }, + "estimatedDurationSeconds": 720, + "estimatedPrice": "40.83386446920870265579", + "type": "REGULAR", + "srcAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "destAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "depositAmount": "10000000", + "isVaultSwap": true, + "estimatedBoostFeeBps": 5, + "maxBoostFeeBps": 30 + } + }, + { + "intermediateAmount": "10421437242", + "egressAmount": "4082978824588206358", + "recommendedSlippageTolerancePercent": 1.5, + "includedFees": [ + { + "type": "INGRESS", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "166" + }, + { + "type": "NETWORK", + "chain": "Ethereum", + "asset": "USDC", + "amount": "10431870" + }, + { + "type": "EGRESS", + "chain": "Ethereum", + "asset": "ETH", + "amount": "342353552660000" + } + ], + "lowLiquidityWarning": false, + "poolInfo": [ + { + "baseAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Bitcoin", + "asset": "BTC", + "amount": "4999" + } + }, + { + "baseAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "USDC", + "amount": "5210718" + } + } + ], + "estimatedDurationsSeconds": { + "swap": 36, + "deposit": 1806, + "egress": 102 + }, + "estimatedDurationSeconds": 1944, + "estimatedPrice": "40.8338896239764215886", + "type": "DCA", + "srcAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "destAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "depositAmount": "10000000", + "isVaultSwap": true, + "dcaParams": { + "numberOfChunks": 3, + "chunkIntervalBlocks": 2 + }, + "boostQuote": { + "intermediateAmount": "10416225915", + "egressAmount": "4080936927013539226", + "recommendedSlippageTolerancePercent": 1, + "includedFees": [ + { + "type": "BOOST", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "5000" + }, + { + "type": "INGRESS", + "chain": "Bitcoin", + "asset": "BTC", + "amount": "166" + }, + { + "type": "NETWORK", + "chain": "Ethereum", + "asset": "USDC", + "amount": "10426653" + }, + { + "type": "EGRESS", + "chain": "Ethereum", + "asset": "ETH", + "amount": "342353552660000" + } + ], + "lowLiquidityWarning": false, + "poolInfo": [ + { + "baseAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Bitcoin", + "asset": "BTC", + "amount": "4997" + } + }, + { + "baseAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "USDC", + "amount": "5208112" + } + } + ], + "estimatedDurationsSeconds": { + "swap": 36, + "deposit": 606, + "egress": 102 + }, + "estimatedDurationSeconds": 744, + "estimatedPrice": "40.83388759199201533512", + "type": "DCA", + "srcAsset": { + "chain": "Bitcoin", + "asset": "BTC" + }, + "destAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "depositAmount": "10000000", + "isVaultSwap": true, + "dcaParams": { + "numberOfChunks": 3, + "chunkIntervalBlocks": 2 + }, + "estimatedBoostFeeBps": 5, + "maxBoostFeeBps": 30 + } + } + ] \ No newline at end of file diff --git a/core/crates/swapper/src/chainflip/test/chainflip_quotes.json b/core/crates/swapper/src/chainflip/test/chainflip_quotes.json new file mode 100644 index 0000000000..ff6c2d729a --- /dev/null +++ b/core/crates/swapper/src/chainflip/test/chainflip_quotes.json @@ -0,0 +1,158 @@ +[ + { + "intermediateAmount": "25516863276", + "egressAmount": "145118751424", + "recommendedSlippageTolerancePercent": 2.5, + "includedFees": [ + { + "type": "INGRESS", + "chain": "Ethereum", + "asset": "ETH", + "amount": "0" + }, + { + "type": "NETWORK", + "chain": "Ethereum", + "asset": "USDC", + "amount": "25542406" + }, + { + "type": "EGRESS", + "chain": "Solana", + "asset": "SOL", + "amount": "14000" + } + ], + "lowLiquidityWarning": false, + "poolInfo": [ + { + "baseAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "ETH", + "amount": "5000000000000000" + } + }, + { + "baseAsset": { + "chain": "Solana", + "asset": "SOL" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "USDC", + "amount": "12758431" + } + } + ], + "estimatedDurationsSeconds": { + "swap": 12, + "deposit": 90, + "egress": 90.8 + }, + "estimatedDurationSeconds": 192.8, + "estimatedPrice": "14.5118765424", + "type": "REGULAR", + "srcAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "destAsset": { + "chain": "Solana", + "asset": "SOL" + }, + "depositAmount": "10000000000000000000", + "isVaultSwap": true + }, + { + "intermediateAmount": "25516863264", + "egressAmount": "145118751172", + "recommendedSlippageTolerancePercent": 2.5, + "includedFees": [ + { + "type": "INGRESS", + "chain": "Ethereum", + "asset": "ETH", + "amount": "0" + }, + { + "type": "NETWORK", + "chain": "Ethereum", + "asset": "USDC", + "amount": "25542408" + }, + { + "type": "EGRESS", + "chain": "Solana", + "asset": "SOL", + "amount": "14000" + } + ], + "lowLiquidityWarning": false, + "poolInfo": [ + { + "baseAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "ETH", + "amount": "5000000000000000" + } + }, + { + "baseAsset": { + "chain": "Solana", + "asset": "SOL" + }, + "quoteAsset": { + "chain": "Ethereum", + "asset": "USDC" + }, + "fee": { + "chain": "Ethereum", + "asset": "USDC", + "amount": "12758431" + } + } + ], + "estimatedDurationsSeconds": { + "swap": 72, + "deposit": 90, + "egress": 90.8 + }, + "estimatedDurationSeconds": 252.8, + "estimatedPrice": "14.5118765172", + "type": "DCA", + "srcAsset": { + "chain": "Ethereum", + "asset": "ETH" + }, + "destAsset": { + "chain": "Solana", + "asset": "SOL" + }, + "depositAmount": "10000000000000000000", + "isVaultSwap": true, + "dcaParams": { + "numberOfChunks": 6, + "chunkIntervalBlocks": 2 + } + } + ] \ No newline at end of file diff --git a/core/crates/swapper/src/chainflip/test/chainflip_sol_arb_usdc_quote_data.json b/core/crates/swapper/src/chainflip/test/chainflip_sol_arb_usdc_quote_data.json new file mode 100644 index 0000000000..04b1e28cff --- /dev/null +++ b/core/crates/swapper/src/chainflip/test/chainflip_sol_arb_usdc_quote_data.json @@ -0,0 +1,41 @@ +{ + "jsonrpc": "2.0", + "result": { + "chain": "Solana", + "program_id": "J88B7gmadHzTNGiy54c9Ms8BsEXNdB2fntFyhKpk3qoT", + "accounts": [ + { + "pubkey": "ACLMuTFvDAb3oecQQGkTVqpUbhCKHG3EZ9uNXHK1W9ka", + "is_signer": false, + "is_writable": false + }, + { + "pubkey": "3tJ67qa2GDfvv2wcMYNUfN5QBZrFpTwcU8ASZKMvCTVU", + "is_signer": false, + "is_writable": true + }, + { + "pubkey": "A21o4asMbFHYadqXdLusT9Bvx9xaC5YV9gcaidjqtdXC", + "is_signer": true, + "is_writable": true + }, + { + "pubkey": "GrPKTiTrwut3V4Pnp92UjRXXzta5KmQGsPPyjHNsccJB", + "is_signer": false, + "is_writable": true + }, + { + "pubkey": "FmAcjWaRFUxGWBfGT7G3CzcFeJFsewQ4KPJVG4f6fcob", + "is_signer": false, + "is_writable": true + }, + { + "pubkey": "11111111111111111111111111111111", + "is_signer": false, + "is_writable": false + } + ], + "data": "0xa3265ce2f3698dc4801d2c04000000000400000014000000514bcb1f9aabb904e6106bd1052b66d2706dbbb707000000006c000000000a00000085fba93ee29c604fa858a351688c01290841eafb19c63a70a475d3c7bc3bef9fcaa145b6f3fdd478e926fa7302d075310000000000000000000000000000000000001e83d2972d3dca3a330d60c2777ee5b8d25683c63fa359116985609830f42054050004002d1100000038924cc334561890e3a819407353710e09" + }, + "id": 1757035218 + } \ No newline at end of file diff --git a/core/crates/swapper/src/chainflip/tx_builder.rs b/core/crates/swapper/src/chainflip/tx_builder.rs new file mode 100644 index 0000000000..b7e7640fe0 --- /dev/null +++ b/core/crates/swapper/src/chainflip/tx_builder.rs @@ -0,0 +1,85 @@ +use super::broker::SolanaVaultSwapResponse; +#[cfg(test)] +use crate::solana::gas_limit_from_transaction; +use crate::{alien::RpcProvider, client_factory::create_client_with_chain, solana}; + +use alloy_primitives::hex; +use gem_encoding::encode_base64; +use gem_solana::{jsonrpc::SolanaRpc, models::LatestBlockhash, try_decode_blockhash}; +use primitives::Chain; +use solana_primitives::{AccountMeta, InstructionBuilder, Pubkey, TransactionBuilder, compute_budget::set_compute_unit_limit}; +use std::{str::FromStr, sync::Arc}; + +pub async fn build_solana_tx(fee_payer: &str, response: &SolanaVaultSwapResponse, provider: Arc) -> Result { + let fee_payer = Pubkey::from_str(fee_payer).map_err(|_| "Invalid fee payer".to_string())?; + let program_id = Pubkey::from_str(response.program_id.as_str()).map_err(|_| "Invalid program ID".to_string())?; + let data = hex::decode(response.data.as_str()).map_err(|_| "Invalid data".to_string())?; + + let rpc_client = create_client_with_chain(provider, Chain::Solana); + let blockhash_response: LatestBlockhash = rpc_client.request(SolanaRpc::GetLatestBlockhash).await.map_err(|e| e.to_string())?; + let blockhash_array = try_decode_blockhash(&blockhash_response.value.blockhash).ok_or_else(|| "Invalid Solana blockhash".to_string())?; + + let mut instruction = InstructionBuilder::new(program_id).data(data).build(); + response.accounts.iter().for_each(|account| { + instruction.accounts.push(AccountMeta { + is_signer: account.is_signer, + is_writable: account.is_writable, + pubkey: Pubkey::from_str(account.pubkey.as_str()).unwrap(), + }); + }); + + let mut transaction_builder = TransactionBuilder::new(fee_payer, blockhash_array); + transaction_builder.add_instruction(set_compute_unit_limit(solana::DEFAULT_SWAP_GAS_LIMIT)); + transaction_builder.add_instruction(instruction); + + let transaction = transaction_builder.build().map_err(|e| e.to_string())?; + let bytes = transaction.serialize_legacy().map_err(|e| e.to_string())?; + + Ok(encode_base64(&bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + alien::mock::{MockFn, ProviderMock}, + chainflip::broker::SolanaVaultSwapResponse, + }; + use gem_jsonrpc::types::JsonRpcResponse; + use std::time::Duration; + + #[tokio::test] + async fn test_build_solana_tx_with_mocked_blockhash() -> Result<(), String> { + let wallet_address = "A21o4asMbFHYadqXdLusT9Bvx9xaC5YV9gcaidjqtdXC"; + let blockhash_b58 = "BZcyEKqjBNG5bEY6i5ev6PfPTgDSB9LwovJE1hJfJoHF".to_string(); + let mock = ProviderMock { + response: MockFn(Box::new(move |_| { + serde_json::json!({ + "jsonrpc": "2.0", + "result": { + "value": { + "blockhash": blockhash_b58, + "lastValidBlockHeight": 342893948 + } + }, + "id": 1757035220 + }) + .to_string() + })), + timeout: Duration::from_millis(10), + }; + + let provider = Arc::new(mock); + let response: JsonRpcResponse = serde_json::from_str(include_str!("./test/chainflip_sol_arb_usdc_quote_data.json")).map_err(|e| e.to_string())?; + + let tx_b64 = build_solana_tx(wallet_address, &response.result, provider).await?; + + assert_eq!(gas_limit_from_transaction(&tx_b64).map_err(|e| e.to_string())?, u64::from(solana::DEFAULT_SWAP_GAS_LIMIT)); + assert_eq!( + tx_b64, + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAQIhfupPuKcYE+oWKNRaIwBKQhB6vsZxjpwpHXTx7w7758q21EdC4D4NruUv9F26xeVqhYm0WXVWkSIjeQIxD3II9tUC6aOjrGBy017zEItREWS3QDEQI/vMhwSVTo/1e2664X/uFi6gx6sRwFnSAPu1ODmcAsu2sf8IuwYArWOf4gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAiKB2TmOdpVByNvc2jO/SqWcRJnwnp6i4PhwcXOdR2sf+adsEMvxMdgZ9RYJ0BKLVq++GfFFu+oFIYBJkEkLMJpzwID++OVGHruXrGUzSEC5Cyny69vOfFr8T0fbCq+HOAgUABQKgaAYABwYGAQADAgS2AaMmXOLzaY3EgB0sBAAAAAAEAAAAFAAAAFFLyx+aq7kE5hBr0QUrZtJwbbu3BwAAAABsAAAAAAoAAACF+6k+4pxgT6hYo1FojAEpCEHq+xnGOnCkddPHvDvvn8qhRbbz/dR46Sb6cwLQdTEAAAAAAAAAAAAAAAAAAAAAAAAeg9KXLT3KOjMNYMJ3fuW40laDxj+jWRFphWCYMPQgVAUABAAtEQAAADiSTMM0VhiQ46gZQHNTcQ4J" + ); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/chainlink.rs b/core/crates/swapper/src/chainlink.rs new file mode 100644 index 0000000000..7181ec732c --- /dev/null +++ b/core/crates/swapper/src/chainlink.rs @@ -0,0 +1,45 @@ +use num_bigint::BigInt; +use num_traits::FromBytes; + +use crate::SwapperError; +use gem_evm::{ + chainlink::contract::AggregatorInterface, + multicall3::{IMulticall3, create_call3, decode_call3_return}, +}; +use primitives::contract_constants::{ETHEREUM_CHAINLINK_ETH_USD_FEED_CONTRACT, MONAD_CHAINLINK_USD_FEED_CONTRACT}; + +pub struct ChainlinkPriceFeed { + pub contract: String, +} + +impl ChainlinkPriceFeed { + pub fn new_eth_usd_feed() -> ChainlinkPriceFeed { + ChainlinkPriceFeed { + contract: ETHEREUM_CHAINLINK_ETH_USD_FEED_CONTRACT.into(), + } + } + + pub fn new_usd_feed_for_chain(chain: primitives::Chain) -> Option { + match chain { + primitives::Chain::Monad => Some(Self::new_mon_usd_feed()), + _ => Some(Self::new_eth_usd_feed()), + } + } + + pub fn new_mon_usd_feed() -> ChainlinkPriceFeed { + ChainlinkPriceFeed { + contract: MONAD_CHAINLINK_USD_FEED_CONTRACT.into(), + } + } + + pub fn latest_round_call3(&self) -> IMulticall3::Call3 { + create_call3(&self.contract, AggregatorInterface::latestRoundDataCall {}) + } + + // Price is in 8 decimals + pub fn decoded_answer(result: &IMulticall3::Result) -> Result { + let decoded = decode_call3_return::(result).map_err(|_| SwapperError::ComputeQuoteError("failed to decode answer".into()))?; + let price = BigInt::from_le_bytes(&decoded.answer.to_le_bytes::<32>()); + Ok(price) + } +} diff --git a/core/crates/swapper/src/client_factory.rs b/core/crates/swapper/src/client_factory.rs new file mode 100644 index 0000000000..261774479b --- /dev/null +++ b/core/crates/swapper/src/client_factory.rs @@ -0,0 +1,47 @@ +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::alien::{self, RpcClient, RpcProvider}; +use gem_jsonrpc::client::JsonRpcClient; +use gem_jsonrpc::grpc::AlienGrpcTransport; +use gem_sui::rpc::client::SuiClient; +use primitives::{Chain, EVMChain}; +use std::sync::Arc; + +use crate::SwapperError; + +pub fn create_client_with_chain(provider: Arc, chain: Chain) -> JsonRpcClient { + alien::create_client(provider, chain).expect("failed to create client for chain") +} + +pub fn create_sui_client(provider: Arc) -> Result { + let endpoint = provider.get_endpoint(Chain::Sui).map_err(|_| SwapperError::NotSupportedChain)?; + Ok(SuiClient::new_with_transport(endpoint, Arc::new(AlienGrpcTransport::new(provider)))) +} + +pub fn create_eth_client(provider: Arc, chain: Chain) -> Result, SwapperError> { + let evm_chain = EVMChain::from_chain(chain).ok_or(SwapperError::NotSupportedChain)?; + let client = alien::create_client(provider, chain).map_err(|_| SwapperError::NotSupportedChain)?; + Ok(EthereumClient::new(client, evm_chain)) +} + +#[cfg(all(test, feature = "reqwest_provider", feature = "swap_integration_tests"))] +mod tests { + use super::*; + use crate::NativeProvider; + use gem_solana::{jsonrpc::SolanaRpc, models::blockhash::SolanaBlockhashResult, try_decode_blockhash}; + use std::sync::Arc; + + #[tokio::test] + async fn test_solana_json_rpc() -> Result<(), String> { + let rpc_client = create_client_with_chain(Arc::new(NativeProvider::default()), Chain::Solana); + let response: SolanaBlockhashResult = rpc_client.request(SolanaRpc::GetLatestBlockhash).await.map_err(|e| e.to_string())?; + let recent_blockhash = response.value.blockhash; + + println!("recent_blockhash: {}", recent_blockhash); + + let blockhash_array = try_decode_blockhash(&recent_blockhash).ok_or_else(|| "Invalid Solana blockhash".to_string())?; + + assert_eq!(blockhash_array.len(), 32); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/config.rs b/core/crates/swapper/src/config.rs new file mode 100644 index 0000000000..7e0580fb8a --- /dev/null +++ b/core/crates/swapper/src/config.rs @@ -0,0 +1,47 @@ +use crate::{SwapperSlippage, SwapperSlippageMode}; +use primitives::Chain; + +pub const DEFAULT_SLIPPAGE_BPS: u32 = 100; +pub const DEFAULT_SWAP_FEE_BPS: u32 = 50; +pub const DEFAULT_CHAINFLIP_FEE_BPS: u32 = 45; + +pub const API_BASE_URL: &str = "https://api.gemwallet.com"; +pub const API_BASE_URL_ENV: &str = "GEM_API_BASE_URL"; + +pub fn get_swap_proxy_url(path: &str) -> String { + let base_url = std::env::var(API_BASE_URL_ENV).unwrap_or_else(|_| API_BASE_URL.to_string()); + format!("{}/proxy/swap/{path}", base_url.trim_end_matches('/')) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Config { + pub default_slippage: SwapperSlippage, + pub permit2_expiration: u64, + pub permit2_sig_deadline: u64, + pub high_price_impact_percent: u32, +} + +pub fn get_swap_config() -> Config { + Config { + default_slippage: SwapperSlippage { + bps: DEFAULT_SLIPPAGE_BPS, + mode: SwapperSlippageMode::Exact, + }, + permit2_expiration: 2_592_000, // 30 days + permit2_sig_deadline: 1800, // 30 minutes + high_price_impact_percent: 10, + } +} + +pub fn get_default_slippage(chain: &Chain) -> SwapperSlippage { + match chain { + Chain::Solana => SwapperSlippage { + bps: DEFAULT_SLIPPAGE_BPS * 3, + mode: SwapperSlippageMode::Exact, + }, + _ => SwapperSlippage { + bps: DEFAULT_SLIPPAGE_BPS, + mode: SwapperSlippageMode::Exact, + }, + } +} diff --git a/core/crates/swapper/src/cross_chain.rs b/core/crates/swapper/src/cross_chain.rs new file mode 100644 index 0000000000..d88846d6c5 --- /dev/null +++ b/core/crates/swapper/src/cross_chain.rs @@ -0,0 +1,151 @@ +use std::collections::HashMap; + +use primitives::Transaction; + +use crate::SwapperProvider; +use crate::thorchain::memo::ThorchainMemo; + +#[derive(Debug, serde::Serialize)] +pub struct VaultAddresses { + pub deposit: Vec, + pub send: Vec, +} + +pub type DepositAddressMap = HashMap; +pub type SendAddressMap = HashMap; + +pub fn swap_provider_with_vault_addresses(transaction: &Transaction, deposit_addresses: &DepositAddressMap) -> Option { + deposit_addresses + .get(&transaction.to) + .copied() + .or_else(|| transaction.output_addresses().into_iter().find_map(|addr| deposit_addresses.get(&addr).copied())) + .filter(|provider| is_valid_swap_transaction(provider, transaction)) +} + +fn is_valid_swap_transaction(provider: &SwapperProvider, transaction: &Transaction) -> bool { + match provider { + SwapperProvider::Thorchain => transaction.memo.as_deref().is_some_and(ThorchainMemo::is_swap), + _ => true, + } +} + +pub fn is_cross_chain_swap(transaction: &Transaction, deposit_addresses: &DepositAddressMap) -> bool { + swap_provider_with_vault_addresses(transaction, deposit_addresses).is_some() +} + +pub fn is_from_vault_address(transaction: &Transaction, send_addresses: &SendAddressMap) -> bool { + send_addresses.contains_key(&transaction.from) || transaction.input_addresses().iter().any(|addr| send_addresses.contains_key(addr)) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::TransactionUtxoInput; + + #[test] + fn test_vault_address_detected() { + let vault = "TMoD2uJiUAvB2RhLGm1BmzCVVzi5VLFDVt".to_string(); + let deposit_addresses = DepositAddressMap::from([(vault.clone(), SwapperProvider::NearIntents)]); + let transaction = Transaction { to: vault, ..Transaction::mock() }; + assert_eq!(swap_provider_with_vault_addresses(&transaction, &deposit_addresses), Some(SwapperProvider::NearIntents)); + } + + #[test] + fn test_no_vault_address() { + let empty = DepositAddressMap::new(); + assert!(!is_cross_chain_swap(&Transaction::mock(), &empty)); + } + + #[test] + fn test_thorchain_vault_with_swap_memo() { + let vault = "bc1qvault".to_string(); + let deposit_addresses = DepositAddressMap::from([(vault.clone(), SwapperProvider::Thorchain)]); + let transaction = Transaction { + to: vault, + memo: Some("=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0".to_string()), + ..Transaction::mock() + }; + assert!(is_cross_chain_swap(&transaction, &deposit_addresses)); + } + + #[test] + fn test_thorchain_vault_without_memo() { + let vault = "bc1qvault".to_string(); + let deposit_addresses = DepositAddressMap::from([(vault.clone(), SwapperProvider::Thorchain)]); + let transaction = Transaction { to: vault, ..Transaction::mock() }; + assert!(!is_cross_chain_swap(&transaction, &deposit_addresses)); + } + + #[test] + fn test_thorchain_vault_with_non_swap_memo() { + let vault = "bc1qvault".to_string(); + let deposit_addresses = DepositAddressMap::from([(vault.clone(), SwapperProvider::Thorchain)]); + let transaction = Transaction { + to: vault, + memo: Some("ADD:ETH.ETH:0x123".to_string()), + ..Transaction::mock() + }; + assert!(!is_cross_chain_swap(&transaction, &deposit_addresses)); + } + + #[test] + fn test_thorchain_router_with_swap_memo() { + let vault = "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146".to_string(); + let deposit_addresses = DepositAddressMap::from([(vault.clone(), SwapperProvider::Thorchain)]); + let transaction = Transaction { + to: vault, + memo: Some("=:BTC:bc1qaddress:0/1/0:affiliate:150".to_string()), + ..Transaction::mock() + }; + assert!(is_cross_chain_swap(&transaction, &deposit_addresses)); + } + + #[test] + fn test_utxo_vault_address_in_outputs() { + let vault = "vault_address".to_string(); + let deposit_addresses = DepositAddressMap::from([(vault.clone(), SwapperProvider::NearIntents)]); + let transaction = Transaction::mock_utxo( + vec![TransactionUtxoInput::new("sender".into(), "50000".into())], + vec![TransactionUtxoInput::new(vault, "40000".into()), TransactionUtxoInput::new("change".into(), "9000".into())], + ); + assert_eq!(swap_provider_with_vault_addresses(&transaction, &deposit_addresses), Some(SwapperProvider::NearIntents)); + } + + #[test] + fn test_utxo_no_vault_address_in_outputs() { + let deposit_addresses = DepositAddressMap::from([("vault_address".to_string(), SwapperProvider::NearIntents)]); + let transaction = Transaction::mock_utxo( + vec![TransactionUtxoInput::new("sender".into(), "50000".into())], + vec![TransactionUtxoInput::new("recipient".into(), "40000".into())], + ); + assert!(!is_cross_chain_swap(&transaction, &deposit_addresses)); + } + + #[test] + fn test_is_from_vault_address() { + let vault = "vault_address".to_string(); + let send_addresses = SendAddressMap::from([(vault.clone(), SwapperProvider::NearIntents)]); + let transaction = Transaction { + from: vault, + ..Transaction::mock() + }; + assert!(is_from_vault_address(&transaction, &send_addresses)); + } + + #[test] + fn test_is_from_vault_address_utxo() { + let vault = "vault_address".to_string(); + let send_addresses = SendAddressMap::from([(vault.clone(), SwapperProvider::NearIntents)]); + let transaction = Transaction::mock_utxo( + vec![TransactionUtxoInput::new(vault, "50000".into())], + vec![TransactionUtxoInput::new("recipient".into(), "40000".into())], + ); + assert!(is_from_vault_address(&transaction, &send_addresses)); + } + + #[test] + fn test_is_not_from_vault_address() { + let send_addresses = SendAddressMap::from([("vault_address".to_string(), SwapperProvider::NearIntents)]); + assert!(!is_from_vault_address(&Transaction::mock(), &send_addresses)); + } +} diff --git a/core/crates/swapper/src/error.rs b/core/crates/swapper/src/error.rs new file mode 100644 index 0000000000..65ec630f37 --- /dev/null +++ b/core/crates/swapper/src/error.rs @@ -0,0 +1,188 @@ +use crate::alien::AlienError; +use crate::proxy::ProxyError; +use crate::thorchain::model::ErrorResponse as ThorchainError; +use gem_client::ClientError; +use gem_jsonrpc::types::JsonRpcError; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use typeshare::typeshare; + +pub const INVALID_AMOUNT: &str = "Invalid amount"; +pub const INVALID_ADDRESS: &str = "Invalid address"; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case", tag = "type", content = "message")] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +pub enum SwapperError { + NotSupportedChain, + NotSupportedAsset, + NoAvailableProvider, + InputAmountError { min_amount: Option }, + InvalidRoute, + ComputeQuoteError(String), + TransactionError(String), + NoQuoteAvailable, +} + +impl std::fmt::Display for SwapperError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotSupportedAsset => write!(f, "Not supported asset"), + Self::NotSupportedChain => write!(f, "Not supported chain"), + Self::NoAvailableProvider => write!(f, "No available provider"), + Self::InputAmountError { min_amount } => { + if let Some(min) = min_amount { + write!(f, "Input amount is too small (minimum {min})") + } else { + write!(f, "Input amount is too small") + } + } + Self::InvalidRoute => write!(f, "Invalid route or route data"), + Self::ComputeQuoteError(msg) => write!(f, "Compute quote error: {}", msg), + Self::TransactionError(msg) => write!(f, "Transaction error: {}", msg), + Self::NoQuoteAvailable => write!(f, "No quote available"), + } + } +} + +impl std::error::Error for SwapperError {} + +impl SwapperError { + pub fn compute_quote_error(error: impl std::fmt::Display) -> Self { + Self::ComputeQuoteError(error.to_string()) + } + + pub fn transaction_error(error: impl std::fmt::Display) -> Self { + Self::TransactionError(error.to_string()) + } +} + +impl From for SwapperError { + fn from(err: AlienError) -> Self { + match err { + AlienError::RequestError { msg } => Self::ComputeQuoteError(msg), + AlienError::ResponseError { msg } => Self::ComputeQuoteError(msg), + AlienError::Http { status, .. } => Self::ComputeQuoteError(format!("HTTP error: status {}", status)), + } + } +} + +impl From for SwapperError { + fn from(err: JsonRpcError) -> Self { + Self::ComputeQuoteError(format!("JSON RPC error: {err}")) + } +} + +impl From for SwapperError { + fn from(err: ClientError) -> Self { + match err { + ClientError::Network(msg) => Self::ComputeQuoteError(msg), + ClientError::Timeout => Self::ComputeQuoteError("Request timed out".into()), + ClientError::Http { status, ref body } => { + if let Ok(proxy_error) = serde_json::from_slice::(body) { + return proxy_error.err; + } + if let Ok(thorchain_error) = serde_json::from_slice::(body) + && thorchain_error.is_input_amount_error() + { + return Self::InputAmountError { + min_amount: thorchain_error.parse_min_amount(), + }; + } + Self::ComputeQuoteError(format!("HTTP error: status {}", status)) + } + ClientError::Serialization(msg) => Self::ComputeQuoteError(msg), + } + } +} + +impl From for SwapperError { + fn from(err: alloy_primitives::AddressError) -> Self { + Self::ComputeQuoteError(format!("{INVALID_ADDRESS}: {err}")) + } +} + +impl From for SwapperError { + fn from(err: alloy_primitives::hex::FromHexError) -> Self { + Self::ComputeQuoteError(format!("{INVALID_ADDRESS}: {err}")) + } +} + +impl From for SwapperError { + fn from(err: serde_json::Error) -> Self { + Self::ComputeQuoteError(format!("serde_json::Error: {err}")) + } +} + +impl From for SwapperError { + fn from(err: serde_urlencoded::ser::Error) -> Self { + Self::ComputeQuoteError(format!("Request query error: {err}")) + } +} + +impl From for SwapperError { + fn from(err: gem_ton::tvm::TvmError) -> Self { + Self::ComputeQuoteError(format!("TVM error: {err}")) + } +} + +impl From for SwapperError { + fn from(err: gem_solana::SolanaError) -> Self { + Self::ComputeQuoteError(format!("Solana error: {err}")) + } +} + +impl From for SwapperError { + fn from(err: primitives::AddressError) -> Self { + Self::ComputeQuoteError(format!("{INVALID_ADDRESS}: {err}")) + } +} + +impl From for SwapperError { + fn from(err: primitives::HexError) -> Self { + Self::ComputeQuoteError(err.to_string()) + } +} + +impl From for SwapperError { + fn from(err: alloy_sol_types::Error) -> Self { + Self::ComputeQuoteError(format!("AlloyError: {err}")) + } +} + +impl From for SwapperError { + fn from(err: alloy_primitives::ruint::ParseError) -> Self { + Self::ComputeQuoteError(format!("{}: {err}", INVALID_AMOUNT)) + } +} + +impl From for SwapperError { + fn from(err: std::num::ParseIntError) -> Self { + Self::ComputeQuoteError(format!("{}: {err}", INVALID_AMOUNT)) + } +} + +impl From for SwapperError { + fn from(err: num_bigint::ParseBigIntError) -> Self { + Self::ComputeQuoteError(format!("{}: {err}", INVALID_AMOUNT)) + } +} + +impl From for SwapperError { + fn from(err: number_formatter::NumberFormatterError) -> Self { + Self::ComputeQuoteError(format!("{}: {err}", INVALID_AMOUNT)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_solana_error_mapping() { + assert_eq!( + SwapperError::from(gem_solana::SolanaError::InvalidTransaction), + SwapperError::ComputeQuoteError("Solana error: Invalid transaction".to_string()) + ); + } +} diff --git a/core/crates/swapper/src/eth_address.rs b/core/crates/swapper/src/eth_address.rs new file mode 100644 index 0000000000..847679686a --- /dev/null +++ b/core/crates/swapper/src/eth_address.rs @@ -0,0 +1,34 @@ +use alloy_primitives::Address; + +use super::error::{INVALID_ADDRESS, SwapperError}; +use primitives::{AssetId, EVMChain}; + +pub(crate) fn convert_native_to_weth(asset: &AssetId) -> Option { + if asset.is_native() { + let evm_chain = EVMChain::from_chain(asset.chain)?; + let weth = evm_chain.weth_contract()?; + return AssetId::from_token(asset.chain, weth).into(); + } + asset.clone().into() +} + +pub(crate) fn parse_or_weth_address(asset: &AssetId, evm_chain: EVMChain) -> Result { + if let Some(token_id) = &asset.token_id { + parse_str(token_id) + } else { + let weth = evm_chain.weth_contract().ok_or(SwapperError::NotSupportedChain)?; + parse_str(weth) + } +} + +pub(crate) fn parse_asset_id(asset: &AssetId) -> Result { + if let Some(token_id) = &asset.token_id { + parse_str(token_id) + } else { + Err(SwapperError::ComputeQuoteError(format!("{}: {}", INVALID_ADDRESS, asset))) + } +} + +pub(crate) fn parse_str(str: &str) -> Result { + str.parse::
().map_err(|_| SwapperError::ComputeQuoteError(format!("{}: {}", INVALID_ADDRESS, str))) +} diff --git a/core/crates/swapper/src/fee_token.rs b/core/crates/swapper/src/fee_token.rs new file mode 100644 index 0000000000..b2e1f39eab --- /dev/null +++ b/core/crates/swapper/src/fee_token.rs @@ -0,0 +1,84 @@ +use crate::fees::is_stablecoin_symbol; +use alloy_primitives::Address; +use primitives::{AssetId, Chain, EVMChain, contract_constants::SOLANA_WRAPPED_SOL_TOKEN_ADDRESS}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FeeTokenPriority { + Native, + Stable, + Other, +} + +pub(crate) struct FeeToken<'a> { + pub address: Address, + pub symbol: &'a str, +} + +impl<'a> FeeToken<'a> { + pub(crate) fn new(address: Address, symbol: &'a str) -> Self { + Self { address, symbol } + } +} + +impl FeeTokenPriority { + pub(crate) fn from_asset(asset_id: &AssetId, symbol: &str) -> Self { + if asset_id.is_native() || is_wrapped_native_token(asset_id) { + return Self::Native; + } + if is_stablecoin_symbol(symbol) { + return Self::Stable; + } + Self::Other + } + + pub(crate) fn rank(self) -> u8 { + match self { + Self::Native => 3, + Self::Stable => 2, + Self::Other => 1, + } + } +} + +fn is_wrapped_native_token(asset_id: &AssetId) -> bool { + let Some(token_id) = asset_id.token_id.as_deref() else { + return false; + }; + if asset_id.chain == Chain::Solana { + return token_id == SOLANA_WRAPPED_SOL_TOKEN_ADDRESS; + } + let Some(chain) = EVMChain::from_chain(asset_id.chain) else { + return false; + }; + chain.weth_contract().is_some_and(|wrapped| token_id.eq_ignore_ascii_case(wrapped)) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{ + asset_constants::{SMARTCHAIN_CAKE_ASSET_ID, SMARTCHAIN_USDC_ASSET_ID}, + contract_constants::SOLANA_WRAPPED_SOL_TOKEN_ADDRESS, + }; + + #[test] + fn test_fee_token_priority_from_asset() { + let bnb = AssetId::from_chain(Chain::SmartChain); + let wbnb = AssetId::from_token(Chain::SmartChain, EVMChain::SmartChain.weth_contract().unwrap()); + let usdc = SMARTCHAIN_USDC_ASSET_ID.clone(); + let cake = SMARTCHAIN_CAKE_ASSET_ID.clone(); + let wsol = AssetId::from_token(Chain::Solana, SOLANA_WRAPPED_SOL_TOKEN_ADDRESS); + + assert_eq!(FeeTokenPriority::from_asset(&bnb, "BNB"), FeeTokenPriority::Native); + assert_eq!(FeeTokenPriority::from_asset(&wbnb, "WBNB"), FeeTokenPriority::Native); + assert_eq!(FeeTokenPriority::from_asset(&usdc, "USDC"), FeeTokenPriority::Stable); + assert_eq!(FeeTokenPriority::from_asset(&cake, "CAKE"), FeeTokenPriority::Other); + assert_eq!(FeeTokenPriority::from_asset(&wsol, "SOL"), FeeTokenPriority::Native); + } + + #[test] + fn test_fee_token_priority_ordering() { + assert!(FeeTokenPriority::Native.rank() > FeeTokenPriority::Stable.rank()); + assert!(FeeTokenPriority::Stable.rank() > FeeTokenPriority::Other.rank()); + } +} diff --git a/core/crates/swapper/src/fees/mod.rs b/core/crates/swapper/src/fees/mod.rs new file mode 100644 index 0000000000..6bf117544a --- /dev/null +++ b/core/crates/swapper/src/fees/mod.rs @@ -0,0 +1,16 @@ +mod referral; +mod reserve; +mod slippage; + +pub use referral::{ReferralFee, ReferralFees, default_referral_address, default_referral_fees}; +pub use reserve::{RESERVED_NATIVE_FEES, quote_value_after_reserve, quote_value_after_reserve_by_chain, reserved_tx_fees}; +pub use slippage::{BasisPointConvert, apply_slippage_in_bp, bps_to_percent_string}; + +pub const DEFAULT_SWAP_FEE_BPS: u32 = 50; +pub const DEFAULT_AGGREGATOR_FEE_BPS: u32 = 70; +pub const DEFAULT_CHAINFLIP_FEE_BPS: u32 = 45; +pub const DEFAULT_REFERRER: &str = "gemwallet"; + +pub(crate) fn is_stablecoin_symbol(symbol: &str) -> bool { + symbol.to_ascii_uppercase().contains("USD") +} diff --git a/core/crates/swapper/src/fees/referral.rs b/core/crates/swapper/src/fees/referral.rs new file mode 100644 index 0000000000..85f0a7b082 --- /dev/null +++ b/core/crates/swapper/src/fees/referral.rs @@ -0,0 +1,104 @@ +use super::DEFAULT_SWAP_FEE_BPS; +use primitives::{Chain, ChainType}; + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct ReferralFees { + pub evm: ReferralFee, + pub solana: ReferralFee, + pub thorchain: ReferralFee, + pub sui: ReferralFee, + pub ton: ReferralFee, + pub tron: ReferralFee, + pub near: ReferralFee, + pub aptos: ReferralFee, + pub cosmos: ReferralFee, + pub injective: ReferralFee, +} + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct ReferralFee { + pub address: String, + pub bps: u32, +} + +impl ReferralFees { + pub fn evm(evm: ReferralFee) -> Self { + Self { evm, ..Default::default() } + } + + pub fn for_chain(&self, chain: Chain) -> Option<&ReferralFee> { + let fee = match chain.chain_type() { + ChainType::Ethereum => &self.evm, + ChainType::Solana => &self.solana, + ChainType::Sui => &self.sui, + ChainType::Ton => &self.ton, + ChainType::Tron => &self.tron, + ChainType::Near => &self.near, + ChainType::Aptos => &self.aptos, + ChainType::Cosmos => match chain { + Chain::Thorchain => &self.thorchain, + Chain::Injective => &self.injective, + _ => &self.cosmos, + }, + ChainType::Bitcoin | ChainType::Xrp | ChainType::Stellar | ChainType::Algorand | ChainType::Polkadot | ChainType::Cardano | ChainType::HyperCore => return None, + }; + Some(fee) + } + + pub fn bps_for_chain(&self, chain: Chain) -> u32 { + self.for_chain(chain).map(|fee| fee.bps).unwrap_or(0) + } +} + +pub fn default_referral_fees() -> ReferralFees { + ReferralFees { + evm: ReferralFee { + address: "0x0D9DAB1A248f63B0a48965bA8435e4de7497a3dC".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + solana: ReferralFee { + address: "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + thorchain: ReferralFee { + address: "g1".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + sui: ReferralFee { + address: "0x9d6b98b18fd26b5efeec68d020dcf1be7a94c2c315353779bc6b3aed44188ddf".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + ton: ReferralFee { + address: "UQDxJKarPSp0bCta9DFgp81Mpt5hpGbuVcSxwfeza0Bin201".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + tron: ReferralFee { + address: "TYeyZXywpA921LEtw2PF3obK4B8Jjgpp32".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + near: ReferralFee { + address: "0x0d9dab1a248f63b0a48965ba8435e4de7497a3dc".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + aptos: ReferralFee { + address: "0xc09d385527743bb03ed7847bb9180b5ff2263d38d5a93f1c9b3068f8505f6488".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + cosmos: ReferralFee { + address: "cosmos1knwywgnzs3a2p39k7337klt6daqrhyvnh8vz27".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + injective: ReferralFee { + address: "inj1pkw6kx3y3a3mpfyfvkaggd0yme6f0g7uylvm5y".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + } +} + +fn default_referral_fee(chain: Chain) -> ReferralFee { + default_referral_fees().for_chain(chain).cloned().unwrap_or_default() +} + +pub fn default_referral_address(chain: Chain) -> String { + default_referral_fee(chain).address +} diff --git a/core/crates/swapper/src/fees/reserve.rs b/core/crates/swapper/src/fees/reserve.rs new file mode 100644 index 0000000000..5dd8c6eb63 --- /dev/null +++ b/core/crates/swapper/src/fees/reserve.rs @@ -0,0 +1,67 @@ +use alloy_primitives::U256; +use primitives::Chain; +use std::{collections::HashMap, str::FromStr, sync::LazyLock}; + +use crate::{QuoteRequest, SwapperError}; + +pub static RESERVED_NATIVE_FEES: LazyLock> = LazyLock::new(|| { + HashMap::from([ + (Chain::Near, "50000000000000000000000"), // 0.05 NEAR + (Chain::Ethereum, "1000000000000000"), // 0.001 ETH + (Chain::Arbitrum, "300000000000000"), // 0.0003 ARB ETH + (Chain::Base, "300000000000000"), // 0.0003 BASE ETH + (Chain::Optimism, "500000000000000"), // 0.0005 OP ETH + (Chain::AvalancheC, "3000000000000000"), // 0.003 AVAX + (Chain::SmartChain, "2000000000000000"), // 0.002 BNB + (Chain::Polygon, "20000000000000000"), // 0.02 MATIC + (Chain::Gnosis, "5000000000000000"), // 0.005 XDAI + (Chain::Berachain, "5000000000000000"), // 0.005 BERA + (Chain::Sui, "50000000"), // 0.05 SUI + (Chain::Solana, "20000"), // 0.00002 SOL + (Chain::Ton, "20000000"), // 0.02 TON + (Chain::Tron, "20000000"), // 20 TRX + (Chain::Bitcoin, "40000"), // 0.0004 BTC + (Chain::Zcash, "1000000"), // 0.01 ZEC + (Chain::Doge, "500000000"), // 5 DOGE + (Chain::Xrp, "2000000"), // 2 XRP + (Chain::Cardano, "2000000"), // 2 ADA + (Chain::Aptos, "20000000"), // 0.2 APT + (Chain::Stellar, "100000"), // 0.01 XLM + (Chain::Litecoin, "100000"), // 0.001 LTC + (Chain::BitcoinCash, "100000"), // 0.001 BCH + (Chain::Monad, "5000000000000000"), // 0.005 MON + (Chain::XLayer, "5000000000000000"), // 0.005 OKB + (Chain::Plasma, "5000000000000000"), // 0.005 XPL + (Chain::Cosmos, "39000"), // 0.039 ATOM + (Chain::Osmosis, "130000"), // 0.13 OSMO + (Chain::Celestia, "39000"), // 0.039 TIA + (Chain::Injective, "1300000000000000"), // 0.0013 INJ + (Chain::Sei, "1300000"), // 1.3 SEI + (Chain::Noble, "25000"), // 0.025 USDC + ]) +}); + +pub fn reserved_tx_fees(chain: Chain) -> Option<&'static str> { + RESERVED_NATIVE_FEES.get(&chain).copied() +} + +pub fn quote_value_after_reserve(request: &QuoteRequest, reserved: &str) -> Result { + if !request.options.use_max_amount || !request.from_asset.asset_id().is_native() { + return Ok(request.value.clone()); + } + let reserved_fee = U256::from_str(reserved).map_err(|_| SwapperError::ComputeQuoteError(format!("invalid reserved fee: {reserved}")))?; + let amount = U256::from_str(&request.value).map_err(|_| SwapperError::ComputeQuoteError(format!("invalid amount: {}", request.value)))?; + if amount <= reserved_fee { + return Err(SwapperError::InputAmountError { + min_amount: Some(reserved_fee.to_string()), + }); + } + Ok((amount - reserved_fee).to_string()) +} + +pub fn quote_value_after_reserve_by_chain(request: &QuoteRequest) -> Result { + let Some(reserved) = reserved_tx_fees(request.from_asset.chain()) else { + return Ok(request.value.clone()); + }; + quote_value_after_reserve(request, reserved) +} diff --git a/core/crates/swapper/src/fees/slippage.rs b/core/crates/swapper/src/fees/slippage.rs new file mode 100644 index 0000000000..f39dee4825 --- /dev/null +++ b/core/crates/swapper/src/fees/slippage.rs @@ -0,0 +1,64 @@ +use alloy_primitives::U256; +use number_formatter::{BigNumberFormatter, NumberFormatterError}; +use std::ops::{Div, Mul}; + +const HUNDRED_PERCENT_IN_BPS: u32 = 10000; +const BPS_PER_PERCENT_DECIMALS: i32 = 2; + +pub trait BasisPointConvert: Sized + Copy { + fn from_u32(value: u32) -> Self; +} + +impl BasisPointConvert for U256 { + fn from_u32(value: u32) -> Self { + Self::from(value) + } +} + +impl BasisPointConvert for u128 { + fn from_u32(value: u32) -> Self { + value as u128 + } +} + +impl BasisPointConvert for u64 { + fn from_u32(value: u32) -> Self { + value as u64 + } +} + +pub fn apply_slippage_in_bp(amount: &T, bps: u32) -> T +where + T: BasisPointConvert + Mul + Div, +{ + let basis_points = T::from_u32(HUNDRED_PERCENT_IN_BPS); + let slippage = T::from_u32(HUNDRED_PERCENT_IN_BPS - bps.min(HUNDRED_PERCENT_IN_BPS)); + (*amount * slippage) / basis_points +} + +pub fn bps_to_percent_string(bps: u32) -> Result { + BigNumberFormatter::value(&bps.to_string(), BPS_PER_PERCENT_DECIMALS) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_slippage_in_bp() { + assert_eq!(apply_slippage_in_bp(&U256::from(100), 300), U256::from(97)); + assert_eq!(apply_slippage_in_bp(&100_u128, 300), 97_u128); + assert_eq!(apply_slippage_in_bp(&1000_u64, 500), 950_u64); + assert_eq!(apply_slippage_in_bp(&U256::from(1000), 0), U256::from(1000)); + assert_eq!(apply_slippage_in_bp(&U256::from(1000), HUNDRED_PERCENT_IN_BPS), U256::ZERO); + } + + #[test] + fn test_bps_to_percent_string() { + assert_eq!(bps_to_percent_string(100).unwrap(), "1"); + assert_eq!(bps_to_percent_string(50).unwrap(), "0.5"); + assert_eq!(bps_to_percent_string(200).unwrap(), "2"); + assert_eq!(bps_to_percent_string(10).unwrap(), "0.1"); + assert_eq!(bps_to_percent_string(0).unwrap(), "0"); + } +} diff --git a/core/crates/swapper/src/hyperliquid/mod.rs b/core/crates/swapper/src/hyperliquid/mod.rs new file mode 100644 index 0000000000..b98054de41 --- /dev/null +++ b/core/crates/swapper/src/hyperliquid/mod.rs @@ -0,0 +1,2 @@ +pub mod provider; +pub use provider::{HyperCoreBridge, HyperCoreSpot, Hyperliquid}; diff --git a/core/crates/swapper/src/hyperliquid/provider/bridge.rs b/core/crates/swapper/src/hyperliquid/provider/bridge.rs new file mode 100644 index 0000000000..5c1303def5 --- /dev/null +++ b/core/crates/swapper/src/hyperliquid/provider/bridge.rs @@ -0,0 +1,109 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use gem_hypercore::core::{actions::user::spot_send::SpotSend, hypercore::transfer_to_hyper_evm_typed_data}; +use number_formatter::BigNumberFormatter; + +use primitives::{ + Chain, + asset_constants::HYPERCORE_CORE_HYPE_TOKEN_ID, + contract_constants::HYPERCORE_SYSTEM_ADDRESS, + known_assets::{HYPERCORE_HYPE, HYPEREVM_HYPE}, + swap::{SwapResult, SwapStatus}, +}; + +use crate::{FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData}; + +use super::spot::scale_quote_value; + +#[derive(Debug)] +pub struct HyperCoreBridge { + provider: ProviderType, +} + +impl HyperCoreBridge { + pub fn new() -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Hyperliquid), + } + } +} + +impl Default for HyperCoreBridge { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Swapper for HyperCoreBridge { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + vec![ + SwapperChainAsset::Assets(Chain::HyperCore, vec![HYPERCORE_HYPE.id.clone()]), + SwapperChainAsset::Assets(Chain::Hyperliquid, vec![HYPEREVM_HYPE.id.clone()]), + ] + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let to_value = scale_quote_value(&request.value, request.from_asset.decimals, request.to_asset.decimals)?; + + let quote = Quote { + from_value: request.value.clone(), + min_from_value: None, + to_value, + data: ProviderData { + provider: self.provider.clone(), + slippage_bps: 0, + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: "".to_string(), + }], + }, + request: request.clone(), + eta_in_seconds: None, + }; + + Ok(quote) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + match quote.request.from_asset.asset_id().chain { + Chain::HyperCore => { + let decimals: i32 = quote.request.from_asset.decimals.try_into().unwrap(); + let amount = BigNumberFormatter::value("e.request.value, decimals)?; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_millis() as u64; + + let spot_send = SpotSend::new(amount, HYPERCORE_SYSTEM_ADDRESS.to_string(), timestamp, HYPERCORE_CORE_HYPE_TOKEN_ID.to_string()); + let typed_data = transfer_to_hyper_evm_typed_data(spot_send); + + Ok(SwapperQuoteData::new_contract( + HYPERCORE_SYSTEM_ADDRESS.to_string(), + quote.request.value.clone(), + typed_data, + None, + None, + )) + } + Chain::Hyperliquid => Ok(SwapperQuoteData::new_contract( + HYPERCORE_SYSTEM_ADDRESS.to_string(), + quote.request.value.clone(), + "0x".to_string(), + None, + None, + )), + _ => Err(SwapperError::NotSupportedChain), + } + } + + async fn get_swap_result(&self, _chain: Chain, _transaction_hash: &str) -> Result { + Ok(SwapResult { + status: SwapStatus::Completed, + metadata: None, + }) + } +} diff --git a/core/crates/swapper/src/hyperliquid/provider/hyperliquid.rs b/core/crates/swapper/src/hyperliquid/provider/hyperliquid.rs new file mode 100644 index 0000000000..3d19905fff --- /dev/null +++ b/core/crates/swapper/src/hyperliquid/provider/hyperliquid.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use primitives::{ + Chain, + known_assets::{HYPERCORE_HYPE, HYPEREVM_HYPE}, + swap::SwapResult, +}; + +use crate::{FetchQuoteData, ProviderType, Quote, QuoteRequest, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData, alien::RpcProvider}; +use gem_hypercore::is_spot_swap; + +use super::{bridge::HyperCoreBridge, spot::HyperCoreSpot}; + +#[derive(Debug)] +pub struct Hyperliquid { + provider: ProviderType, + spot: HyperCoreSpot, + bridge: HyperCoreBridge, +} + +impl Hyperliquid { + pub fn new(rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Hyperliquid), + spot: HyperCoreSpot::new(rpc_provider), + bridge: HyperCoreBridge::new(), + } + } + + fn is_spot_request(request: &QuoteRequest) -> bool { + let from_chain = request.from_asset.chain(); + let to_chain = request.to_asset.chain(); + is_spot_swap(from_chain, to_chain) + } + + fn is_bridge_request(request: &QuoteRequest) -> bool { + let from_id = request.from_asset.asset_id(); + let to_id = request.to_asset.asset_id(); + (from_id == HYPERCORE_HYPE.id && to_id == HYPEREVM_HYPE.id) || (from_id == HYPEREVM_HYPE.id && to_id == HYPERCORE_HYPE.id) + } +} + +#[async_trait] +impl Swapper for Hyperliquid { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + let mut assets = self.spot.supported_assets(); + assets.extend(self.bridge.supported_assets()); + assets + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + if Self::is_spot_request(request) { + return self.spot.get_quote(request).await; + } + + if Self::is_bridge_request(request) { + return self.bridge.get_quote(request).await; + } + + Err(SwapperError::NoQuoteAvailable) + } + + async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { + if Self::is_spot_request("e.request) { + return self.spot.get_quote_data(quote, data).await; + } + if Self::is_bridge_request("e.request) { + return self.bridge.get_quote_data(quote, data).await; + } + Err(SwapperError::NoQuoteAvailable) + } + + async fn get_swap_result(&self, chain: Chain, transaction_hash: &str) -> Result { + self.bridge.get_swap_result(chain, transaction_hash).await + } +} diff --git a/core/crates/swapper/src/hyperliquid/provider/mod.rs b/core/crates/swapper/src/hyperliquid/provider/mod.rs new file mode 100644 index 0000000000..0ae3cb36dc --- /dev/null +++ b/core/crates/swapper/src/hyperliquid/provider/mod.rs @@ -0,0 +1,7 @@ +pub mod bridge; +pub mod hyperliquid; +pub mod spot; + +pub use bridge::HyperCoreBridge; +pub use hyperliquid::Hyperliquid; +pub use spot::HyperCoreSpot; diff --git a/core/crates/swapper/src/hyperliquid/provider/spot/math.rs b/core/crates/swapper/src/hyperliquid/provider/spot/math.rs new file mode 100644 index 0000000000..2c79c38989 --- /dev/null +++ b/core/crates/swapper/src/hyperliquid/provider/spot/math.rs @@ -0,0 +1,207 @@ +use std::{cmp::Ordering, str::FromStr}; + +use bigdecimal::{BigDecimal, Zero}; +use num_bigint::BigUint; +use num_integer::Integer; +use num_traits::{ToPrimitive, Zero as NumZero}; +use number_formatter::BigNumberFormatter; + +use crate::SwapperError; + +pub(super) const SPOT_ASSET_OFFSET: u32 = 10_000; +const MAX_DECIMAL_SCALE: u32 = 6; + +#[derive(Debug, Clone, Copy)] +pub(super) enum SpotSide { + Buy, + Sell, +} + +impl SpotSide { + pub(super) fn is_buy(self) -> bool { + matches!(self, SpotSide::Buy) + } +} + +pub(super) fn format_decimal(value: &BigDecimal) -> String { + format_decimal_with_scale(value, MAX_DECIMAL_SCALE) +} + +pub(super) fn format_decimal_with_scale(value: &BigDecimal, scale: u32) -> String { + BigNumberFormatter::decimal_to_string(value, scale) +} + +pub(super) fn round_size_down(amount: &BigDecimal, decimals: u32) -> BigDecimal { + amount.with_scale_round(decimals as i64, bigdecimal::RoundingMode::Down) +} + +pub(super) fn format_order_size(amount: &BigDecimal, decimals: u32) -> String { + let rounded = round_size_down(amount, decimals); + BigNumberFormatter::decimal_to_string(&rounded, decimals) +} + +pub(super) fn spot_asset_index(market_index: u32) -> u32 { + SPOT_ASSET_OFFSET + market_index +} + +pub fn scale_units(value: BigUint, from_decimals: u32, to_decimals: u32) -> Result { + match from_decimals.cmp(&to_decimals) { + Ordering::Equal => Ok(value), + Ordering::Less => { + let factor = BigUint::from(10u32).pow(to_decimals - from_decimals); + Ok(value * factor) + } + Ordering::Greater => { + let factor = BigUint::from(10u32).pow(from_decimals - to_decimals); + let (quotient, remainder) = value.div_rem(&factor); + if !NumZero::is_zero(&remainder) { + return Err(SwapperError::ComputeQuoteError("amount precision loss".into())); + } + Ok(quotient) + } + } +} + +pub fn scale_quote_value(value: &str, from_decimals: u32, to_decimals: u32) -> Result { + let amount = BigUint::from_str(value)?; + scale_units(amount, from_decimals, to_decimals).map(|v| v.to_string()) +} + +pub(super) fn apply_slippage(limit_price: &BigDecimal, side: SpotSide, slippage_bps: u32, price_decimals: u32) -> Result { + if limit_price <= &BigDecimal::zero() { + return Err(SwapperError::ComputeQuoteError("invalid limit price".into())); + } + + let limit_price_f64 = limit_price.to_f64().ok_or_else(|| SwapperError::ComputeQuoteError("failed to convert price".into()))?; + + let slippage_fraction = slippage_bps as f64 / 10_000.0; + let multiplier = if side.is_buy() { 1.0 + slippage_fraction } else { 1.0 - slippage_fraction }; + + if multiplier <= 0.0 { + return Err(SwapperError::ComputeQuoteError("slippage multiplier not positive".into())); + } + + let adjusted = limit_price_f64 * multiplier; + let rounded = round_to_significant_and_decimal(adjusted, 5, price_decimals); + + let formatted = if price_decimals == 0 { + format!("{rounded:.0}") + } else { + format!("{rounded:.price_decimals$}", price_decimals = price_decimals as usize) + }; + + BigDecimal::from_str(&formatted).map_err(|_| SwapperError::ComputeQuoteError("failed to format limit price".into())) +} + +fn round_to_decimals(value: f64, decimals: u32) -> f64 { + let factor = 10f64.powi(decimals as i32); + (value * factor).round() / factor +} + +fn round_to_significant_and_decimal(value: f64, sig_figs: u32, max_decimals: u32) -> f64 { + if value == 0.0 { + return 0.0; + } + + let abs_value = value.abs(); + let magnitude = abs_value.log10().floor() as i32; + let scale = 10f64.powi(sig_figs as i32 - magnitude - 1); + let rounded = (abs_value * scale).round() / scale; + round_to_decimals(rounded.copysign(value), max_decimals) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_format_order_size_rounds_down() { + // Rounds down, not to nearest + let value = BigDecimal::from_str("0.131").unwrap(); + assert_eq!(format_order_size(&value, 2), "0.13"); + + let value = BigDecimal::from_str("0.189834").unwrap(); + assert_eq!(format_order_size(&value, 2), "0.18"); + + let value = BigDecimal::from_str("0.10").unwrap(); + assert_eq!(format_order_size(&value, 2), "0.1"); + + let value = BigDecimal::from_str("9.999").unwrap(); + assert_eq!(format_order_size(&value, 2), "9.99"); + } + + #[test] + fn test_round_size_down() { + let value = BigDecimal::from_str("9.523768").unwrap(); + let rounded = round_size_down(&value, 2); + assert_eq!(rounded, BigDecimal::from_str("9.52").unwrap()); + + let value = BigDecimal::from_str("10.12345").unwrap(); + let rounded = round_size_down(&value, 2); + assert_eq!(rounded, BigDecimal::from_str("10.12").unwrap()); + + // Zero decimals + let value = BigDecimal::from_str("123.999").unwrap(); + let rounded = round_size_down(&value, 0); + assert_eq!(rounded, BigDecimal::from_str("123").unwrap()); + } + + #[test] + fn test_spot_asset_index_offset() { + assert_eq!(spot_asset_index(0), SPOT_ASSET_OFFSET); + assert_eq!(spot_asset_index(107), SPOT_ASSET_OFFSET + 107); + } + + #[test] + fn test_apply_slippage_buy_increases_price() { + let price = BigDecimal::from_str("100").unwrap(); + let adjusted = apply_slippage(&price, SpotSide::Buy, 1000, 2).unwrap(); + assert_eq!(BigNumberFormatter::decimal_to_string(&adjusted, 2), "110"); + } + + #[test] + fn test_apply_slippage_sell_decreases_price() { + let price = BigDecimal::from_str("100").unwrap(); + let adjusted = apply_slippage(&price, SpotSide::Sell, 500, 2).unwrap(); + assert_eq!(BigNumberFormatter::decimal_to_string(&adjusted, 2), "95"); + } + + #[test] + fn test_apply_slippage_zero_returns_same_price() { + let price = BigDecimal::from_str("42.123456").unwrap(); + let adjusted = apply_slippage(&price, SpotSide::Sell, 0, 4).unwrap(); + assert_eq!(BigNumberFormatter::decimal_to_string(&adjusted, 4), "42.123"); + } + + #[test] + fn test_apply_slippage_invalid_when_multiplier_non_positive() { + let price = BigDecimal::from_str("10").unwrap(); + assert!(apply_slippage(&price, SpotSide::Sell, 10001, 2).is_err()); + } + + #[test] + fn test_scale_units_increase_precision() { + let base = BigUint::from(123u32); + let scaled = scale_units(base.clone(), 8, 18).unwrap(); + assert_eq!(scaled, BigUint::from(10u32).pow(10) * base); + } + + #[test] + fn test_scale_units_reduce_precision() { + let value = BigUint::from(123u32) * BigUint::from(10u32).pow(10); + let scaled = scale_units(value, 18, 8).unwrap(); + assert_eq!(scaled, BigUint::from(123u32)); + } + + #[test] + fn test_scale_units_precision_loss_rejected() { + assert!(scale_units(BigUint::from(5u32), 3, 1).is_err()); + } + + #[test] + fn test_scale_quote_value() { + assert_eq!(scale_quote_value("123000000", 6, 8).unwrap(), "12300000000"); + assert_eq!(scale_quote_value("12300000000", 8, 6).unwrap(), "123000000"); + } +} diff --git a/core/crates/swapper/src/hyperliquid/provider/spot/mod.rs b/core/crates/swapper/src/hyperliquid/provider/spot/mod.rs new file mode 100644 index 0000000000..74f91ca970 --- /dev/null +++ b/core/crates/swapper/src/hyperliquid/provider/spot/mod.rs @@ -0,0 +1,6 @@ +pub mod math; +mod provider; +mod simulator; + +pub use math::{scale_quote_value, scale_units}; +pub use provider::HyperCoreSpot; diff --git a/core/crates/swapper/src/hyperliquid/provider/spot/provider.rs b/core/crates/swapper/src/hyperliquid/provider/spot/provider.rs new file mode 100644 index 0000000000..7b6edcbee5 --- /dev/null +++ b/core/crates/swapper/src/hyperliquid/provider/spot/provider.rs @@ -0,0 +1,312 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bigdecimal::{BigDecimal, Zero}; +use gem_hypercore::{ + core::actions::agent::order::{Builder, PlaceOrder, make_market_order}, + models::{ + spot::{OrderbookResponse, SpotMarket, SpotMeta}, + token::SpotToken, + }, + rpc::client::HyperCoreClient, +}; +use num_bigint::BigUint; +use number_formatter::{BigNumberFormatter, NumberFormatterError}; +use primitives::{ + Chain, + known_assets::{HYPERCORE_HYPE, HYPERCORE_SPOT_HYPE, HYPERCORE_SPOT_UBTC, HYPERCORE_SPOT_USDC}, +}; + +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteAsset, SwapperQuoteData, + alien::{RpcClient, RpcProvider}, + error::INVALID_AMOUNT, +}; + +use super::{ + math::{SpotSide, apply_slippage, format_decimal, format_decimal_with_scale, format_order_size, round_size_down, scale_units, spot_asset_index}, + simulator::{simulate_buy, simulate_sell}, +}; + +const MIN_QUOTE_AMOUNT: i64 = 10; + +fn compute_actual_from(use_max_amount: bool, amount: &str, decimals: u32) -> Result, NumberFormatterError> { + if !use_max_amount { + return Ok(None); + } + BigNumberFormatter::value_from_amount_biguint(amount, decimals).map(Some) +} + +#[derive(Debug)] +pub struct HyperCoreSpot { + provider: ProviderType, + rpc_provider: Arc, + client: Mutex>>>, +} + +impl HyperCoreSpot { + pub fn new(rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Hyperliquid), + rpc_provider, + client: Mutex::new(None), + } + } + + fn client(&self) -> Result>, SwapperError> { + if let Some(client) = self.client.lock().unwrap().as_ref() { + return Ok(client.clone()); + } + + let endpoint = self.rpc_provider.get_endpoint(Chain::HyperCore)?; + let client = Arc::new(HyperCoreClient::new(RpcClient::new(endpoint, self.rpc_provider.clone()))); + *self.client.lock().unwrap() = Some(client.clone()); + Ok(client) + } + + async fn load_spot_meta(&self) -> Result { + let client = self.client()?; + client.get_spot_meta().await.map_err(SwapperError::compute_quote_error) + } + + async fn load_orderbook(&self, coin: &str) -> Result { + let client = self.client()?; + client.get_spot_orderbook(coin).await.map_err(SwapperError::compute_quote_error) + } + + fn resolve_token<'a>(&self, meta: &'a SpotMeta, asset: &'a SwapperQuoteAsset) -> Result<&'a SpotToken, SwapperError> { + let asset_id = asset.asset_id(); + let components = asset_id.token_components().or_else(|| { + if asset_id == HYPERCORE_HYPE.id { + HYPERCORE_SPOT_HYPE.id.token_components() + } else { + None + } + }); + + let (symbol, contract, index) = components.ok_or(SwapperError::NotSupportedAsset)?; + let token = meta.tokens.iter().find(|token| token.name == symbol).ok_or(SwapperError::NotSupportedAsset)?; + + if let Some(contract) = contract + && token.token_id != contract + { + return Err(SwapperError::NotSupportedAsset); + } + if let Some(index) = index + && token.index != index + { + return Err(SwapperError::NotSupportedAsset); + } + + Ok(token) + } + + fn find_direct_market<'a>( + &self, + meta: &'a SpotMeta, + from_token: &'a SpotToken, + to_token: &'a SpotToken, + ) -> Result<(&'a SpotMarket, &'a SpotToken, &'a SpotToken, SpotSide), SwapperError> { + for market in meta.universe.iter().filter(|m| m.tokens.len() == 2) { + if market.tokens[0] == from_token.index && market.tokens[1] == to_token.index { + return Ok((market, from_token, to_token, SpotSide::Sell)); + } + if market.tokens[0] == to_token.index && market.tokens[1] == from_token.index { + return Ok((market, to_token, from_token, SpotSide::Buy)); + } + } + Err(SwapperError::NotSupportedAsset) + } +} + +#[async_trait] +impl Swapper for HyperCoreSpot { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + vec![SwapperChainAsset::Assets( + Chain::HyperCore, + vec![HYPERCORE_SPOT_HYPE.id.clone(), HYPERCORE_SPOT_USDC.id.clone(), HYPERCORE_SPOT_UBTC.id.clone()], + )] + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let client = self.client()?; + let meta = self.load_spot_meta().await?; + let from_token = self.resolve_token(&meta, &request.from_asset)?; + let to_token = self.resolve_token(&meta, &request.to_asset)?; + + let amount_in = BigNumberFormatter::big_decimal_value(&request.value, request.from_asset.decimals)?; + if amount_in <= BigDecimal::zero() { + return Err(SwapperError::ComputeQuoteError("amount must be greater than zero".into())); + } + + let (market, base_token, _quote_token, side) = self.find_direct_market(&meta, from_token, to_token)?; + let coin = format!("@{}", market.index); + let orderbook = self.load_orderbook(&coin).await?; + if orderbook.levels.len() < 2 { + return Err(SwapperError::NoQuoteAvailable); + } + + // Round to sz_decimals before simulation to ensure quote matches execution. + let (raw_output, base_limit_price, size_rounded, actual_from_value) = match side { + SpotSide::Sell => { + let rounded_input = round_size_down(&amount_in, base_token.sz_decimals); + if rounded_input <= BigDecimal::zero() { + return Err(SwapperError::ComputeQuoteError("amount too small after rounding".into())); + } + let result = simulate_sell(&rounded_input, &orderbook.levels[0])?; + let actual_from = compute_actual_from(request.options.use_max_amount, &format_decimal(&rounded_input), request.from_asset.decimals)?; + (result.amount_out, result.limit_price, rounded_input, actual_from) + } + SpotSide::Buy => { + let result = simulate_buy(&amount_in, &orderbook.levels[1])?; + let rounded_output = round_size_down(&result.amount_out, base_token.sz_decimals); + if rounded_output <= BigDecimal::zero() { + return Err(SwapperError::ComputeQuoteError("output too small after rounding".into())); + } + let actual_from = compute_actual_from( + request.options.use_max_amount, + &format_decimal(&(&rounded_output * &result.limit_price)), + request.from_asset.decimals, + )?; + (rounded_output.clone(), result.limit_price, rounded_output, actual_from) + } + }; + + // Check minimum USD value (quote token is USDC) + let quote_amount = match side { + SpotSide::Sell => &raw_output, + SpotSide::Buy => &amount_in, + }; + if quote_amount < &BigDecimal::from(MIN_QUOTE_AMOUNT) { + return Err(SwapperError::InputAmountError { min_amount: None }); + } + + let builder_fee = client.config.max_builder_fee_bps; + let fee_factor = BigDecimal::from(100_000 - builder_fee as i64) / BigDecimal::from(100_000); + let output_amount = &raw_output * fee_factor; + + let token_decimals: u32 = to_token + .wei_decimals + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError(format!("{} precision: {}", INVALID_AMOUNT, to_token.wei_decimals)))?; + + let token_units = BigNumberFormatter::value_from_amount_biguint(&format_decimal(&output_amount), token_decimals) + .map_err(|err| SwapperError::ComputeQuoteError(format!("{}: {err}", INVALID_AMOUNT)))?; + let scaled_units = scale_units(token_units, token_decimals, request.to_asset.decimals)?; + let to_value = scaled_units.to_string(); + + let price_decimals = 8u32.saturating_sub(base_token.sz_decimals); + let limit_price = apply_slippage(&base_limit_price, side, request.options.slippage.bps, price_decimals)?; + let limit_price = format_decimal_with_scale(&limit_price, price_decimals); + + let order_size = format_order_size(&size_rounded, base_token.sz_decimals); + + let asset_index = spot_asset_index(market.index); + + // Adjust from_value for use_max_amount to reflect actual swapped amount after sz_decimals rounding. + let from_value = actual_from_value.map(|v| v.to_string()).unwrap_or_else(|| request.value.clone()); + + let quote = Quote { + from_value, + min_from_value: None, + to_value, + data: ProviderData { + provider: self.provider.clone(), + slippage_bps: request.options.slippage.bps, + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: serde_json::to_string(&make_market_order( + asset_index, + side.is_buy(), + &limit_price, + &order_size, + false, + Some(Builder { + builder_address: client.config.builder_address.clone(), + fee: client.config.max_builder_fee_bps, + }), + )) + .map_err(SwapperError::compute_quote_error)?, + }], + }, + request: request.clone(), + eta_in_seconds: None, + }; + + Ok(quote) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let order: PlaceOrder = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + let order_json = serde_json::to_string(&order).map_err(SwapperError::transaction_error)?; + + Ok(SwapperQuoteData::new_contract("".to_string(), quote.request.value.clone(), order_json, None, None)) + } +} + +#[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] +mod tests { + use super::*; + use crate::{hyperliquid::provider::spot::math::SPOT_ASSET_OFFSET, testkit::mock_quote}; + use primitives::swap::SwapQuoteDataType; + use std::str::FromStr; + + fn quote_asset(asset: &primitives::Asset) -> SwapperQuoteAsset { + SwapperQuoteAsset { + id: asset.id.to_string(), + symbol: asset.symbol.clone(), + decimals: asset.decimals as u32, + } + } + + async fn assert_spot_quote(from_asset: SwapperQuoteAsset, to_asset: SwapperQuoteAsset) { + let spot = HyperCoreSpot::new(Arc::new(crate::NativeProvider::new())); + + let mut request = mock_quote(from_asset, to_asset); + request.value = "2000000000".into(); + + let quote = spot.get_quote(&request).await.unwrap(); + + let order: PlaceOrder = serde_json::from_str("e.data.routes[0].route_data).unwrap(); + assert_eq!(order.r#type, "order"); + assert!(order.orders[0].asset >= SPOT_ASSET_OFFSET); + + let quote_data = spot.get_quote_data("e, FetchQuoteData::None).await.unwrap(); + assert_eq!(quote.data.provider.id, SwapperProvider::Hyperliquid); + assert!(!quote.to_value.is_empty()); + assert!(matches!(quote_data.data_type, SwapQuoteDataType::Contract)); + + let from_amount = BigDecimal::from_str(&BigNumberFormatter::value("e.from_value, quote.request.from_asset.decimals as i32).unwrap()).unwrap(); + let to_amount = BigDecimal::from_str(&BigNumberFormatter::value("e.to_value, quote.request.to_asset.decimals as i32).unwrap()).unwrap(); + + assert!(!from_amount.is_zero()); + assert!(!to_amount.is_zero()); + + println!( + "HyperCoreSpot: {} {} -> {} {} (rate: {})", + from_amount, + quote.request.from_asset.symbol, + to_amount, + quote.request.to_asset.symbol, + &to_amount / &from_amount + ); + } + + #[tokio::test] + async fn test_spot_quote_hype_usdc() { + assert_spot_quote(quote_asset(&HYPERCORE_SPOT_HYPE), quote_asset(&HYPERCORE_SPOT_USDC)).await; + assert_spot_quote(quote_asset(&HYPERCORE_SPOT_USDC), quote_asset(&HYPERCORE_SPOT_HYPE)).await; + } + + #[tokio::test] + async fn test_spot_quote_ubtc_usdc() { + assert_spot_quote(quote_asset(&HYPERCORE_SPOT_UBTC), quote_asset(&HYPERCORE_SPOT_USDC)).await; + assert_spot_quote(quote_asset(&HYPERCORE_SPOT_USDC), quote_asset(&HYPERCORE_SPOT_UBTC)).await; + } +} diff --git a/core/crates/swapper/src/hyperliquid/provider/spot/simulator.rs b/core/crates/swapper/src/hyperliquid/provider/spot/simulator.rs new file mode 100644 index 0000000000..a6df2b0a56 --- /dev/null +++ b/core/crates/swapper/src/hyperliquid/provider/spot/simulator.rs @@ -0,0 +1,145 @@ +use std::str::FromStr; + +use bigdecimal::{BigDecimal, Zero}; +use gem_hypercore::models::spot::OrderbookLevel; + +use crate::SwapperError; + +#[derive(Debug, Clone)] +pub(super) struct SimulationResult { + pub amount_out: BigDecimal, + pub limit_price: BigDecimal, +} + +pub(super) fn simulate_sell(amount: &BigDecimal, bids: &[OrderbookLevel]) -> Result { + let mut remaining = amount.clone(); + let mut quote_total = BigDecimal::zero(); + let mut min_price: Option = None; + + for level in bids { + let level_size = parse_decimal(&level.sz)?; + let price = parse_decimal(&level.px)?; + if level_size <= BigDecimal::zero() { + continue; + } + + let trade_size = remaining.clone().min(level_size); + quote_total += &trade_size * &price; + remaining -= &trade_size; + min_price = Some(min_price.map_or(price.clone(), |p| p.min(price.clone()))); + + if remaining <= BigDecimal::zero() { + return Ok(SimulationResult { + amount_out: quote_total, + limit_price: min_price.unwrap(), + }); + } + } + + Err(SwapperError::NoQuoteAvailable) +} + +pub(super) fn simulate_buy(amount: &BigDecimal, asks: &[OrderbookLevel]) -> Result { + let mut remaining_quote = amount.clone(); + let mut base_total = BigDecimal::zero(); + let mut max_price: Option = None; + + for level in asks { + let level_size = parse_decimal(&level.sz)?; + let price = parse_decimal(&level.px)?; + if level_size <= BigDecimal::zero() || price <= BigDecimal::zero() { + continue; + } + + let level_quote = &level_size * &price; + if remaining_quote > level_quote { + base_total += &level_size; + remaining_quote -= level_quote; + max_price = Some(max_price.map_or(price.clone(), |p| p.max(price.clone()))); + } else { + base_total += &remaining_quote / &price; + max_price = Some(max_price.map_or(price.clone(), |p| p.max(price.clone()))); + remaining_quote = BigDecimal::zero(); + break; + } + } + + if remaining_quote > BigDecimal::zero() || base_total <= BigDecimal::zero() { + return Err(SwapperError::NoQuoteAvailable); + } + + Ok(SimulationResult { + amount_out: base_total, + limit_price: max_price.unwrap(), + }) +} + +fn parse_decimal(value: &str) -> Result { + BigDecimal::from_str(value).map_err(|_| SwapperError::compute_quote_error("failed to parse orderbook level")) +} + +#[cfg(test)] +mod tests { + use super::*; + use bigdecimal::BigDecimal; + use number_formatter::BigNumberFormatter; + use std::str::FromStr; + + fn level(px: &str, sz: &str) -> OrderbookLevel { + OrderbookLevel { + px: px.to_string(), + sz: sz.to_string(), + } + } + + #[test] + fn test_simulate_sell() { + let amount = BigDecimal::from_str("7").unwrap(); + let bids = vec![level("2", "3"), level("1.5", "5")]; + let SimulationResult { + amount_out: quote_out, + limit_price: min_price, + } = simulate_sell(&amount, &bids).unwrap(); + let expected = BigDecimal::from_str("12").unwrap(); + + let quote_str = BigNumberFormatter::decimal_to_string("e_out, 6); + let expected_str = BigNumberFormatter::decimal_to_string(&expected, 6); + assert_eq!(quote_str, expected_str); + + let avg_total = quote_out.clone() / amount.clone() * amount; + let avg_total_str = BigNumberFormatter::decimal_to_string(&avg_total, 6); + assert_eq!(avg_total_str, expected_str); + assert_eq!(min_price, BigDecimal::from_str("1.5").unwrap()); + } + + #[test] + fn test_simulate_sell_insufficient_depth() { + let amount = BigDecimal::from_str("10").unwrap(); + let bids = vec![level("2", "3"), level("1.5", "5")]; + assert!(matches!(simulate_sell(&amount, &bids), Err(SwapperError::NoQuoteAvailable))); + } + + #[test] + fn test_simulate_buy() { + let amount = BigDecimal::from_str("10").unwrap(); + let asks = vec![level("2", "3"), level("3", "5")]; + let SimulationResult { + amount_out: base_out, + limit_price: max_price, + } = simulate_buy(&amount, &asks).unwrap(); + let avg_price = &amount / &base_out; + let product = avg_price * base_out.clone(); + let product_str = BigNumberFormatter::decimal_to_string(&product, 6); + let amount_str = BigNumberFormatter::decimal_to_string(&amount, 6); + assert_eq!(product_str, amount_str); + assert!(base_out > BigDecimal::zero()); + assert_eq!(max_price, BigDecimal::from_str("3").unwrap()); + } + + #[test] + fn test_simulate_buy_insufficient_depth() { + let amount = BigDecimal::from_str("25").unwrap(); + let asks = vec![level("2", "3"), level("3", "5")]; + assert!(matches!(simulate_buy(&amount, &asks), Err(SwapperError::NoQuoteAvailable))); + } +} diff --git a/core/crates/swapper/src/jupiter/client.rs b/core/crates/swapper/src/jupiter/client.rs new file mode 100644 index 0000000000..47266d56d4 --- /dev/null +++ b/core/crates/swapper/src/jupiter/client.rs @@ -0,0 +1,31 @@ +use super::model::*; +use gem_client::{CONTENT_TYPE, Client, ClientError, ClientExt}; +use std::collections::HashMap; + +#[derive(Clone, Debug)] +pub struct JupiterClient +where + C: Client + Clone, +{ + client: C, +} + +impl JupiterClient +where + C: Client + Clone, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_swap_quote(&self, request: QuoteRequest) -> Result { + let query_string = serde_urlencoded::to_string(&request).map_err(|e| ClientError::Serialization(e.to_string()))?; + let path = format!("/swap/v1/quote?{}", query_string); + self.client.get(&path).await + } + + pub async fn get_swap_quote_data(&self, request: &QuoteDataRequest) -> Result { + let headers = HashMap::from([(CONTENT_TYPE.to_string(), "application/json".into())]); + self.client.post_with_headers("/swap/v1/swap", request, headers).await + } +} diff --git a/core/crates/swapper/src/jupiter/default.rs b/core/crates/swapper/src/jupiter/default.rs new file mode 100644 index 0000000000..f36ef2dd25 --- /dev/null +++ b/core/crates/swapper/src/jupiter/default.rs @@ -0,0 +1,18 @@ +use super::{client::JupiterClient, provider::Jupiter}; +use crate::{ + alien::{RpcClient, RpcProvider}, + config, +}; +use gem_jsonrpc::client::JsonRpcClient; +use primitives::Chain; +use std::sync::Arc; + +impl Jupiter { + pub fn new(provider: Arc) -> Self { + let url = config::get_swap_proxy_url("jupiter"); + let http_client = JupiterClient::new(RpcClient::new(url, provider.clone())); + let solana_endpoint = provider.get_endpoint(Chain::Solana).expect("Failed to get Solana endpoint for Jupiter provider"); + let rpc_client = JsonRpcClient::new(RpcClient::new(solana_endpoint, provider)); + Self::with_clients(http_client, rpc_client) + } +} diff --git a/core/crates/swapper/src/jupiter/mod.rs b/core/crates/swapper/src/jupiter/mod.rs new file mode 100644 index 0000000000..c507d7f0e6 --- /dev/null +++ b/core/crates/swapper/src/jupiter/mod.rs @@ -0,0 +1,7 @@ +mod client; +mod default; +mod model; +mod provider; +pub use provider::Jupiter; + +pub const PROGRAM_ADDRESS: &str = gem_solana::JUPITER_PROGRAM_ID; diff --git a/core/crates/swapper/src/jupiter/model.rs b/core/crates/swapper/src/jupiter/model.rs new file mode 100644 index 0000000000..c0383fb484 --- /dev/null +++ b/core/crates/swapper/src/jupiter/model.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteRequest { + pub input_mint: String, + pub output_mint: String, + pub amount: String, + pub slippage_bps: u32, + pub platform_fee_bps: u32, + pub instruction_version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteResponse { + pub input_mint: String, + pub in_amount: String, + pub output_mint: String, + pub out_amount: String, + pub other_amount_threshold: String, + pub swap_mode: String, + pub slippage_bps: u32, + pub platform_fee: Value, + pub price_impact_pct: String, + pub route_plan: Value, + pub context_slot: i64, + pub time_taken: f64, + pub instruction_version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteDataResponse { + pub swap_transaction: String, + pub simulation_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimulationError { + pub error: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteDataRequest { + pub user_public_key: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub fee_account: String, + pub quote_response: QuoteResponse, + pub prioritization_fee_lamports: i64, +} diff --git a/core/crates/swapper/src/jupiter/provider.rs b/core/crates/swapper/src/jupiter/provider.rs new file mode 100644 index 0000000000..581213d7a1 --- /dev/null +++ b/core/crates/swapper/src/jupiter/provider.rs @@ -0,0 +1,237 @@ +use super::{ + PROGRAM_ADDRESS, + client::JupiterClient, + model::{QuoteDataRequest, QuoteRequest as JupiterRequest, QuoteResponse}, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData, error::INVALID_ADDRESS, + fees::default_referral_fees, solana, +}; +use alloy_primitives::U256; +use async_trait::async_trait; +use gem_client::Client; +use gem_jsonrpc::{client::JsonRpcClient, types::JsonRpcResult}; +use gem_solana::{ + SolanaRpc, TOKEN_PROGRAM, USDC_TOKEN_MINT, USDS_TOKEN_MINT, USDT_TOKEN_MINT, WSOL_TOKEN_ADDRESS, get_pubkey_by_str, + models::{AccountData, ValueResult}, + token_account::get_token_account, +}; +use primitives::{AssetId, Chain}; +use std::collections::HashSet; + +const INSTRUCTION_VERSION: &str = "V2"; + +#[derive(Debug)] +pub struct Jupiter +where + C: Client + Clone + Send + Sync + 'static, + R: Client + Clone + Send + Sync + 'static, +{ + pub provider: ProviderType, + pub fee_mints: HashSet<&'static str>, + http_client: JupiterClient, + rpc_client: JsonRpcClient, +} + +impl Jupiter +where + C: Client + Clone + Send + Sync + 'static, + R: Client + Clone + Send + Sync + 'static, +{ + pub fn with_clients(http_client: JupiterClient, rpc_client: JsonRpcClient) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Jupiter), + fee_mints: HashSet::from([USDC_TOKEN_MINT, USDT_TOKEN_MINT, USDS_TOKEN_MINT, WSOL_TOKEN_ADDRESS]), + http_client, + rpc_client, + } + } + + pub fn get_asset_address(&self, asset_id: &str) -> Result { + get_pubkey_by_str(asset_id) + .map(|x| x.to_string()) + .ok_or_else(|| SwapperError::ComputeQuoteError(format!("{}: {asset_id}", INVALID_ADDRESS))) + } + + fn get_fee_mint(&self, input: &str, output: &str) -> String { + if self.fee_mints.contains(output) { + return output.to_string(); + } + input.to_string() + } + + fn get_fee_token_account(&self, mint: &str, token_program: &str) -> Result, SwapperError> { + let fee = default_referral_fees().solana; + if fee.address.is_empty() { + return Ok(None); + } + let fee_account = get_token_account(&fee.address, mint, token_program)?; + Ok(Some(fee_account)) + } + + async fn fetch_token_program(&self, mint: &str) -> Result { + let rpc_call = SolanaRpc::GetAccountInfo(mint.to_string()); + let rpc_result: JsonRpcResult>> = self.rpc_client.call_with_cache(&rpc_call, Some(u64::MAX)).await.map_err(SwapperError::from)?; + let value = rpc_result.take()?; + + value.value.map(|x| x.owner).ok_or_else(|| SwapperError::compute_quote_error("fetch_token_program error")) + } + + async fn fetch_fee_account(&self, input_mint: &str, output_mint: &str) -> Result { + let fee_mint = self.get_fee_mint(input_mint, output_mint); + // if fee_mint is in preset, no need to fetch token program + let token_program = if self.fee_mints.contains(fee_mint.as_str()) { + return Ok(self.get_fee_token_account(fee_mint.as_str(), TOKEN_PROGRAM)?.unwrap_or_default()); + } else { + self.fetch_token_program(&fee_mint).await? + }; + + let mut fee_account = self.get_fee_token_account(&fee_mint, &token_program)?.unwrap_or_default(); + if fee_account.is_empty() { + return Ok(fee_account); + } + + // check fee token account exists, if not, set fee_account to empty string + let rpc_call = SolanaRpc::GetAccountInfo(fee_account.clone()); + let rpc_result: JsonRpcResult>> = self.rpc_client.call_with_cache(&rpc_call, None).await.map_err(SwapperError::from)?; + if matches!(rpc_result, JsonRpcResult::Error(_)) || matches!(rpc_result, JsonRpcResult::Value(ref resp) if resp.result.value.is_none()) { + fee_account = String::from(""); + } + Ok(fee_account) + } +} + +#[async_trait] +impl Swapper for Jupiter +where + C: Client + Clone + Send + Sync + 'static, + R: Client + Clone + Send + Sync + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + vec![SwapperChainAsset::All(Chain::Solana)] + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let input_mint = self.get_asset_address(&request.from_asset.id)?; + let output_mint = self.get_asset_address(&request.to_asset.id)?; + let slippage_bps = request.options.slippage.bps; + let platform_fee_bps = default_referral_fees().solana.bps; + + let quote_request = JupiterRequest { + input_mint: input_mint.clone(), + output_mint: output_mint.clone(), + amount: request.value.clone(), + platform_fee_bps, + slippage_bps, + instruction_version: INSTRUCTION_VERSION.to_string(), + }; + let swap_quote = self.http_client.get_swap_quote(quote_request).await?; + + // Updated docs: https://dev.jup.ag/docs/api/swap-api/quote + // The value includes platform fees and DEX fees, excluding slippage. + let out_amount: U256 = swap_quote.out_amount.parse().map_err(SwapperError::from)?; + + let quote = Quote { + from_value: request.value.clone(), + min_from_value: None, + to_value: out_amount.to_string(), + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: AssetId::from(Chain::Solana, Some(input_mint)), + output: AssetId::from(Chain::Solana, Some(output_mint)), + route_data: serde_json::to_string(&swap_quote).unwrap_or_default(), + }], + slippage_bps: swap_quote.slippage_bps, + }, + request: request.clone(), + eta_in_seconds: None, + }; + Ok(quote) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + if quote.data.routes.is_empty() { + return Err(SwapperError::InvalidRoute); + } + let route = "e.data.routes[0]; + let input_mint = route.input.token_id.clone().unwrap(); + let output_mint = route.output.token_id.clone().unwrap(); + + let quote_response: QuoteResponse = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + let fee_account = self.fetch_fee_account(&input_mint, &output_mint).await?; + + let request = QuoteDataRequest { + user_public_key: quote.request.wallet_address.clone(), + fee_account, + quote_response, + prioritization_fee_lamports: 500_000, + }; + + let quote_data = self.http_client.get_swap_quote_data(&request).await?; + + if let Some(simulation_error) = quote_data.simulation_error { + return Err(SwapperError::TransactionError(simulation_error.error)); + } + + let gas_limit = solana::gas_limit_from_transaction("e_data.swap_transaction)?; + Ok(SwapperQuoteData::new_contract( + PROGRAM_ADDRESS.to_string(), + "".to_string(), + quote_data.swap_transaction, + None, + Some(gas_limit.to_string()), + )) + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{FetchQuoteData, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, models::Options}; + use primitives::AssetId; + use std::sync::Arc; + + #[tokio::test] + async fn test_jupiter_provider_fetch_quote() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = Jupiter::new(rpc_provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Solana)), + to_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Solana, Some(USDC_TOKEN_MINT.to_string()))), + wallet_address: "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(), + destination_address: "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(), + value: "1000000000".to_string(), + options, + }; + + let quote = provider.get_quote(&request).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert_eq!(quote.data.provider, provider.provider().clone()); + assert_eq!(quote.data.routes.len(), 1); + + let route = "e.data.routes[0]; + assert_eq!(route.input, AssetId::from(Chain::Solana, Some(WSOL_TOKEN_ADDRESS.to_string()))); + assert_eq!(route.output, AssetId::from(Chain::Solana, Some(USDC_TOKEN_MINT.to_string()))); + assert!(!route.route_data.is_empty()); + + let quote_response: QuoteResponse = serde_json::from_str(&route.route_data)?; + assert_eq!(quote_response.input_mint, WSOL_TOKEN_ADDRESS); + assert_eq!(quote_response.output_mint, USDC_TOKEN_MINT); + + let quote_data = provider.get_quote_data("e, FetchQuoteData::None).await?; + assert_eq!(quote_data.to, PROGRAM_ADDRESS); + assert!(!quote_data.data.is_empty()); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/lib.rs b/core/crates/swapper/src/lib.rs new file mode 100644 index 0000000000..949baf0688 --- /dev/null +++ b/core/crates/swapper/src/lib.rs @@ -0,0 +1,70 @@ +mod alien; +mod approval; +mod cache; +mod chainlink; +pub mod cross_chain; +mod eth_address; +mod fee_token; +pub mod fees; +mod swapper_trait; + +#[cfg(test)] +pub mod testkit; + +pub mod across; +pub mod cetus_clmm; +pub mod chainflip; +pub mod client_factory; +pub mod config; +pub mod error; +pub mod hyperliquid; +pub mod jupiter; +pub mod mayan; +pub mod models; +pub mod near_intents; +pub mod okx; +pub mod panora; +pub mod permit2_data; +pub mod proxy; +pub mod relay; +mod route_cache; +mod solana; +pub mod squid; +pub mod stonfi; +pub mod swapper; +pub mod thorchain; +pub mod uniswap; + +use number_formatter::BigNumberFormatter; + +pub(crate) use cache::{STATIC_READ_CACHE_TTL_SECONDS, cache_headers, static_read_cache_headers}; + +/// Converts a human-readable amount string to base units value. +pub fn amount_to_value(token: &str, decimals: u32) -> Option { + let cleaned = token.replace([',', '_'], ""); + if cleaned.is_empty() { + return None; + } + if cleaned.contains('.') { + BigNumberFormatter::value_from_amount(&cleaned, decimals).ok() + } else { + Some(cleaned) + } +} + +#[cfg(feature = "reqwest_provider")] +pub use alien::reqwest_provider::NativeProvider; +pub use alien::{AlienError, HttpMethod, RpcClient, RpcProvider, Target}; +pub use error::SwapperError; +pub use models::*; +pub(crate) use swapper_trait::Swapper; + +pub type SwapperProvider = primitives::SwapProvider; +pub type SwapperProviderMode = primitives::swap::SwapProviderMode; +pub type SwapperQuoteAsset = primitives::swap::QuoteAsset; +pub type SwapperSlippage = primitives::swap::Slippage; +pub type SwapperSlippageMode = primitives::swap::SlippageMode; +pub type SwapperQuoteData = primitives::swap::SwapQuoteData; +pub type SwapperSwapStatus = primitives::swap::SwapStatus; +pub type SwapperTransactionSwapMetadata = primitives::TransactionSwapMetadata; +pub type SwapperSwapResult = primitives::swap::SwapResult; diff --git a/core/crates/swapper/src/mayan/asset.rs b/core/crates/swapper/src/mayan/asset.rs new file mode 100644 index 0000000000..245961a00e --- /dev/null +++ b/core/crates/swapper/src/mayan/asset.rs @@ -0,0 +1,137 @@ +use crate::SwapperChainAsset; +use gem_evm::{EVM_ZERO_ADDRESS, ethereum_address_checksum}; +use gem_solana::WSOL_TOKEN_ADDRESS; +use gem_sui::SUI_COIN_TYPE; +use primitives::{ + AssetId, Chain, ChainType, + asset_constants::{ + ARBITRUM_USDC_ASSET_ID, ARBITRUM_USDT_ASSET_ID, AVALANCHE_USDC_ASSET_ID, AVALANCHE_USDT_ASSET_ID, BASE_CBBTC_ASSET_ID, BASE_USDC_ASSET_ID, BASE_USDS_ASSET_ID, + BASE_WBTC_ASSET_ID, ETHEREUM_CBBTC_ASSET_ID, ETHEREUM_DAI_ASSET_ID, ETHEREUM_STETH_ASSET_ID, ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDS_ASSET_ID, ETHEREUM_USDT_ASSET_ID, + ETHEREUM_WBTC_ASSET_ID, ETHEREUM_WETH_ASSET_ID, HYPERCORE_SPOT_USDC_ASSET_ID, HYPERCORE_SPOT_USDC_TOKEN_ID, HYPEREVM_USDC_ASSET_ID, HYPEREVM_USDT_ASSET_ID, + LINEA_USDC_E_ASSET_ID, LINEA_USDT_ASSET_ID, MONAD_USDC_ASSET_ID, MONAD_USDT_ASSET_ID, OPTIMISM_USDC_ASSET_ID, OPTIMISM_USDT_ASSET_ID, POLYGON_USDC_ASSET_ID, + POLYGON_USDT_ASSET_ID, SMARTCHAIN_USDC_ASSET_ID, SMARTCHAIN_USDT_ASSET_ID, SMARTCHAIN_WBTC_ASSET_ID, SOLANA_CBBTC_ASSET_ID, SOLANA_JITO_SOL_ASSET_ID, SOLANA_USDC_ASSET_ID, + SOLANA_USDS_ASSET_ID, SOLANA_USDT_ASSET_ID, SOLANA_WBTC_ASSET_ID, SUI_SBUSDT_ASSET_ID, SUI_USDC_ASSET_ID, SUI_WAL_ASSET_ID, UNICHAIN_DAI_ASSET_ID, UNICHAIN_USDC_ASSET_ID, + }, +}; + +use super::constants::HYPERCORE_SPOT_USDC_CONTRACT; + +pub fn supported_assets() -> Vec { + vec![ + SwapperChainAsset::assets( + Chain::Ethereum, + [ + ETHEREUM_USDT_ASSET_ID.clone(), + ETHEREUM_USDC_ASSET_ID.clone(), + ETHEREUM_DAI_ASSET_ID.clone(), + ETHEREUM_USDS_ASSET_ID.clone(), + ETHEREUM_WBTC_ASSET_ID.clone(), + ETHEREUM_WETH_ASSET_ID.clone(), + ETHEREUM_STETH_ASSET_ID.clone(), + ETHEREUM_CBBTC_ASSET_ID.clone(), + ], + ), + SwapperChainAsset::assets( + Chain::Solana, + [ + SOLANA_USDC_ASSET_ID.clone(), + SOLANA_USDT_ASSET_ID.clone(), + SOLANA_USDS_ASSET_ID.clone(), + SOLANA_CBBTC_ASSET_ID.clone(), + SOLANA_WBTC_ASSET_ID.clone(), + SOLANA_JITO_SOL_ASSET_ID.clone(), + ], + ), + SwapperChainAsset::assets(Chain::Sui, [SUI_USDC_ASSET_ID.clone(), SUI_SBUSDT_ASSET_ID.clone(), SUI_WAL_ASSET_ID.clone()]), + SwapperChainAsset::assets( + Chain::SmartChain, + [SMARTCHAIN_USDT_ASSET_ID.clone(), SMARTCHAIN_USDC_ASSET_ID.clone(), SMARTCHAIN_WBTC_ASSET_ID.clone()], + ), + SwapperChainAsset::assets( + Chain::Base, + [ + BASE_USDC_ASSET_ID.clone(), + BASE_CBBTC_ASSET_ID.clone(), + BASE_WBTC_ASSET_ID.clone(), + BASE_USDS_ASSET_ID.clone(), + ], + ), + SwapperChainAsset::assets(Chain::Polygon, [POLYGON_USDC_ASSET_ID.clone(), POLYGON_USDT_ASSET_ID.clone()]), + SwapperChainAsset::assets(Chain::AvalancheC, [AVALANCHE_USDT_ASSET_ID.clone(), AVALANCHE_USDC_ASSET_ID.clone()]), + SwapperChainAsset::assets(Chain::Arbitrum, [ARBITRUM_USDC_ASSET_ID.clone(), ARBITRUM_USDT_ASSET_ID.clone()]), + SwapperChainAsset::assets(Chain::Optimism, [OPTIMISM_USDC_ASSET_ID.clone(), OPTIMISM_USDT_ASSET_ID.clone()]), + SwapperChainAsset::assets(Chain::Linea, [LINEA_USDC_E_ASSET_ID.clone(), LINEA_USDT_ASSET_ID.clone()]), + SwapperChainAsset::assets(Chain::Unichain, [UNICHAIN_USDC_ASSET_ID.clone(), UNICHAIN_DAI_ASSET_ID.clone()]), + SwapperChainAsset::assets(Chain::Monad, [Chain::Monad.as_asset_id(), MONAD_USDC_ASSET_ID.clone(), MONAD_USDT_ASSET_ID.clone()]), + SwapperChainAsset::assets(Chain::Hyperliquid, [HYPEREVM_USDT_ASSET_ID.clone(), HYPEREVM_USDC_ASSET_ID.clone()]), + SwapperChainAsset::assets(Chain::HyperCore, [HYPERCORE_SPOT_USDC_ASSET_ID.clone()]), + ] +} + +pub fn token_id_for_asset(asset_id: &AssetId) -> String { + match (asset_id.chain, asset_id.token_id.as_deref()) { + (Chain::Sui, None) => SUI_COIN_TYPE.to_string(), + (Chain::HyperCore, Some(HYPERCORE_SPOT_USDC_TOKEN_ID)) => HYPERCORE_SPOT_USDC_CONTRACT.to_string(), + (_, None) => EVM_ZERO_ADDRESS.to_string(), + (_, Some(token_id)) => token_id.to_string(), + } +} + +pub fn asset_id_for_token(chain: Chain, token_address: &str) -> Option { + match chain { + Chain::Solana => match token_address { + EVM_ZERO_ADDRESS | WSOL_TOKEN_ADDRESS => Some(AssetId::from_chain(chain)), + _ => Some(AssetId::from_token(chain, token_address)), + }, + Chain::Sui => match token_address { + SUI_COIN_TYPE => Some(AssetId::from_chain(chain)), + _ => Some(AssetId::from_token(chain, token_address)), + }, + Chain::HyperCore => { + if token_address.eq_ignore_ascii_case(HYPERCORE_SPOT_USDC_CONTRACT) { + Some(HYPERCORE_SPOT_USDC_ASSET_ID.clone()) + } else { + Some(AssetId::from_token(chain, token_address)) + } + } + _ if chain.chain_type() == ChainType::Ethereum => match token_address { + EVM_ZERO_ADDRESS => Some(AssetId::from_chain(chain)), + _ => ethereum_address_checksum(token_address).ok().map(|address| AssetId::from_token(chain, &address)), + }, + _ => match chain.as_denom() { + Some(denom) if denom == token_address => Some(AssetId::from_chain(chain)), + _ => Some(AssetId::from_token(chain, token_address)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::asset_constants::ETHEREUM_USDC_ASSET_ID; + + #[test] + fn test_asset_id_for_token() { + assert_eq!(asset_id_for_token(Chain::Ethereum, EVM_ZERO_ADDRESS), Some(AssetId::from_chain(Chain::Ethereum))); + assert_eq!(asset_id_for_token(Chain::Sui, SUI_COIN_TYPE), Some(AssetId::from_chain(Chain::Sui))); + assert_eq!(asset_id_for_token(Chain::Solana, EVM_ZERO_ADDRESS), Some(AssetId::from_chain(Chain::Solana))); + assert_eq!(asset_id_for_token(Chain::Solana, WSOL_TOKEN_ADDRESS), Some(AssetId::from_chain(Chain::Solana))); + assert_eq!( + asset_id_for_token(Chain::Ethereum, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), + Some(ETHEREUM_USDC_ASSET_ID.clone()) + ); + assert_eq!( + asset_id_for_token(Chain::HyperCore, HYPERCORE_SPOT_USDC_CONTRACT), + Some(HYPERCORE_SPOT_USDC_ASSET_ID.clone()) + ); + } + + #[test] + fn test_token_id_for_asset() { + assert_eq!(token_id_for_asset(&AssetId::from_chain(Chain::Ethereum)), EVM_ZERO_ADDRESS); + assert_eq!(token_id_for_asset(&AssetId::from_chain(Chain::Solana)), EVM_ZERO_ADDRESS); + assert_eq!(token_id_for_asset(&AssetId::from_chain(Chain::Sui)), SUI_COIN_TYPE); + assert_eq!(token_id_for_asset(&HYPERCORE_SPOT_USDC_ASSET_ID), HYPERCORE_SPOT_USDC_CONTRACT); + assert_eq!(token_id_for_asset(ÐEREUM_USDC_ASSET_ID), ETHEREUM_USDC_ASSET_ID.token_id.clone().unwrap()); + } +} diff --git a/core/crates/swapper/src/mayan/cctp_domain.rs b/core/crates/swapper/src/mayan/cctp_domain.rs new file mode 100644 index 0000000000..bf07bc7116 --- /dev/null +++ b/core/crates/swapper/src/mayan/cctp_domain.rs @@ -0,0 +1,69 @@ +use crate::{SwapperError, mayan::wormhole_chain::WormholeChain}; + +pub(in crate::mayan) const CCTP_TOKEN_DECIMALS: u32 = 6; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub(in crate::mayan) enum CCTPDomain { + Ethereum = 0, + Avalanche = 1, + Optimism = 2, + Arbitrum = 3, + Solana = 5, + Base = 6, + Polygon = 7, + Sui = 8, + Unichain = 10, + Linea = 11, + Sonic = 13, + Monad = 15, + Hyperevm = 19, +} + +impl CCTPDomain { + pub(in crate::mayan) const fn id(self) -> u32 { + self as u32 + } +} + +impl TryFrom for CCTPDomain { + type Error = SwapperError; + + fn try_from(chain: WormholeChain) -> Result { + match chain { + WormholeChain::Ethereum => Ok(Self::Ethereum), + WormholeChain::Avalanche => Ok(Self::Avalanche), + WormholeChain::Optimism => Ok(Self::Optimism), + WormholeChain::Arbitrum => Ok(Self::Arbitrum), + WormholeChain::Solana => Ok(Self::Solana), + WormholeChain::Base => Ok(Self::Base), + WormholeChain::Polygon => Ok(Self::Polygon), + WormholeChain::Sui => Ok(Self::Sui), + WormholeChain::Unichain => Ok(Self::Unichain), + WormholeChain::Linea => Ok(Self::Linea), + WormholeChain::Sonic => Ok(Self::Sonic), + WormholeChain::Monad => Ok(Self::Monad), + WormholeChain::Hyperevm => Ok(Self::Hyperevm), + _ => Err(SwapperError::NotSupportedChain), + } + } +} + +pub(in crate::mayan) fn domain_for_wormhole_chain(chain: &str) -> Result { + CCTPDomain::try_from(WormholeChain::from_name(chain)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_domain_for_wormhole_chain() { + assert_eq!(domain_for_wormhole_chain(WormholeChain::Ethereum.name()).unwrap(), CCTPDomain::Ethereum); + assert_eq!(domain_for_wormhole_chain(WormholeChain::Base.name()).unwrap(), CCTPDomain::Base); + assert_eq!(domain_for_wormhole_chain(WormholeChain::Sui.name()).unwrap(), CCTPDomain::Sui); + assert_eq!(domain_for_wormhole_chain(WormholeChain::Solana.name()).unwrap(), CCTPDomain::Solana); + assert_eq!(domain_for_wormhole_chain(WormholeChain::Hyperevm.name()).unwrap(), CCTPDomain::Hyperevm); + assert_eq!(domain_for_wormhole_chain("bitcoin").unwrap_err(), SwapperError::NotSupportedChain); + } +} diff --git a/core/crates/swapper/src/mayan/client/explorer.rs b/core/crates/swapper/src/mayan/client/explorer.rs new file mode 100644 index 0000000000..9eb1a8246c --- /dev/null +++ b/core/crates/swapper/src/mayan/client/explorer.rs @@ -0,0 +1,20 @@ +use super::MayanClient; +use crate::{ + SwapperError, + mayan::model::{MayanChain, MayanTransactionResult}, +}; +use gem_client::{Client, ClientExt}; +use std::fmt::Debug; + +impl MayanClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub async fn get_chains(&self) -> Result, SwapperError> { + self.client.get("/chains").await.map_err(SwapperError::from) + } + + pub async fn get_transaction_status(&self, hash: &str) -> Result { + self.client.get(&format!("/swap/trx/{hash}")).await.map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/mayan/client/get_swap.rs b/core/crates/swapper/src/mayan/client/get_swap.rs new file mode 100644 index 0000000000..0bbec76d96 --- /dev/null +++ b/core/crates/swapper/src/mayan/client/get_swap.rs @@ -0,0 +1,27 @@ +use super::MayanClient; +use crate::SwapperError; +use gem_client::{Client, ClientExt, build_path_with_query}; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt::Debug; + +impl MayanClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub(in crate::mayan) async fn get_swap(&self, path: &str, params: T) -> Result + where + T: Serialize, + U: DeserializeOwned + Send, + { + let path = build_path_with_query(path, ¶ms)?; + self.client.get(&path).await.map_err(SwapperError::from) + } + + pub(in crate::mayan) async fn post_swap(&self, path: &str, params: T) -> Result + where + T: Serialize + Send + Sync, + U: DeserializeOwned + Send, + { + self.client.post(path, ¶ms).await.map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/mayan/client/mod.rs b/core/crates/swapper/src/mayan/client/mod.rs new file mode 100644 index 0000000000..b706128085 --- /dev/null +++ b/core/crates/swapper/src/mayan/client/mod.rs @@ -0,0 +1,23 @@ +mod explorer; +mod get_swap; +mod quote; + +use gem_client::Client; +use std::fmt::Debug; + +#[derive(Clone, Debug)] +pub struct MayanClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub(super) client: C, +} + +impl MayanClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new(client: C) -> Self { + Self { client } + } +} diff --git a/core/crates/swapper/src/mayan/client/quote.rs b/core/crates/swapper/src/mayan/client/quote.rs new file mode 100644 index 0000000000..628bf76d38 --- /dev/null +++ b/core/crates/swapper/src/mayan/client/quote.rs @@ -0,0 +1,161 @@ +use super::MayanClient; +use crate::{ + SwapperError, + mayan::{ + constants::{MAYAN_FORWARDER, MAYAN_PROGRAM_ID, SDK_VERSION}, + model::{ErrorResponse, MayanQuote, QuoteParams, QuoteResponse}, + }, +}; +use gem_client::{Client, ClientError, ClientExt}; +use serde::Serialize; +use std::fmt::Debug; + +const QUOTE_DEFAULTS: [(&str, &str); 12] = [ + ("wormhole", "false"), + ("swift", "true"), + ("mctp", "true"), + ("shuttle", "false"), + ("fastMctp", "true"), + ("gasless", "false"), + ("onlyDirect", "false"), + ("fullList", "false"), + ("monoChain", "true"), + ("solanaProgram", MAYAN_PROGRAM_ID), + ("forwarderAddress", MAYAN_FORWARDER), + ("slippageBps", "auto"), +]; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct QuoteDynamicQuery { + amount_in64: String, + from_token: String, + from_chain: String, + to_token: String, + to_chain: String, + referrer: String, + referrer_bps: u32, + sdk_version: &'static str, +} + +impl From for QuoteDynamicQuery { + fn from(params: QuoteParams) -> Self { + Self { + amount_in64: params.amount_in64, + from_token: params.from_token, + from_chain: params.from_chain, + to_token: params.to_token, + to_chain: params.to_chain, + referrer: params.referrer, + referrer_bps: params.referrer_bps, + sdk_version: SDK_VERSION, + } + } +} + +impl MayanClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub async fn get_quotes(&self, params: QuoteParams, input_decimals: u32) -> Result, SwapperError> { + let path = quote_path(params)?; + let response = self.client.get::(&path).await.map_err(|err| map_quote_error(err, input_decimals))?; + Ok(response.quotes) + } +} + +fn quote_path(params: QuoteParams) -> Result { + let defaults = serde_urlencoded::to_string(QUOTE_DEFAULTS)?; + let query = serde_urlencoded::to_string(QuoteDynamicQuery::from(params))?; + Ok(format!("/quote?{defaults}&{query}")) +} + +fn map_quote_error(err: ClientError, decimals: u32) -> SwapperError { + match err { + ClientError::Http { status, body } => { + if let Ok(response) = serde_json::from_slice::(&body) { + return map_response_error(&response, decimals); + } + SwapperError::ComputeQuoteError(format!("HTTP error: status {}", status)) + } + other => SwapperError::from(other), + } +} + +fn map_response_error(response: &ErrorResponse, decimals: u32) -> SwapperError { + if response.is_input_amount_error() { + return SwapperError::InputAmountError { + min_amount: response.min_amount_in(decimals), + }; + } + + SwapperError::compute_quote_error(response.message().unwrap_or("Unknown Mayan error")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mayan::model::ErrorData; + + #[test] + fn test_quote_path() { + let path = quote_path(QuoteParams { + amount_in64: "1000000".to_string(), + from_token: "0x0000000000000000000000000000000000000000".to_string(), + from_chain: "ethereum".to_string(), + to_token: "So11111111111111111111111111111111111111112".to_string(), + to_chain: "solana".to_string(), + referrer: "0x1111111111111111111111111111111111111111".to_string(), + referrer_bps: 50, + }) + .unwrap(); + + assert_eq!( + path, + "/quote?wormhole=false&swift=true&mctp=true&shuttle=false&fastMctp=true&gasless=false&onlyDirect=false&fullList=false&monoChain=true&solanaProgram=FC4eXxkyrMPTjiYUpp4EAnkmwMbQyZ6NDCh1kfLn6vsf&forwarderAddress=0x337685fdaB40D39bd02028545a4FfA7D287cC3E2&slippageBps=auto&amountIn64=1000000&fromToken=0x0000000000000000000000000000000000000000&fromChain=ethereum&toToken=So11111111111111111111111111111111111111112&toChain=solana&referrer=0x1111111111111111111111111111111111111111&referrerBps=50&sdkVersion=14_1_0" + ); + } + + #[test] + fn test_map_response_error() { + let response = ErrorResponse { + msg: Some("Amount too small (min ~0.0004349 ETH)".to_string()), + message: None, + data: Some(ErrorData { + min_amount_in: Some(serde_json::json!(0.0004349)), + }), + }; + assert_eq!( + map_response_error(&response, 18), + SwapperError::InputAmountError { + min_amount: Some("434900000000000".to_string()) + } + ); + + let response = ErrorResponse { + msg: Some("Amount too small (min ~1,234.5 USDC)".to_string()), + message: None, + data: None, + }; + assert_eq!( + map_response_error(&response, 6), + SwapperError::InputAmountError { + min_amount: Some("1234500000".to_string()) + } + ); + + let response = ErrorResponse { + msg: Some("Amount too small".to_string()), + message: None, + data: None, + }; + assert_eq!(map_response_error(&response, 6), SwapperError::InputAmountError { min_amount: None }); + + let response = ErrorResponse { + msg: None, + message: Some("Route not found".to_string()), + data: None, + }; + assert_eq!(map_response_error(&response, 6), SwapperError::ComputeQuoteError("Route not found".to_string())); + } +} diff --git a/core/crates/swapper/src/mayan/constants.rs b/core/crates/swapper/src/mayan/constants.rs new file mode 100644 index 0000000000..d399dbd753 --- /dev/null +++ b/core/crates/swapper/src/mayan/constants.rs @@ -0,0 +1,28 @@ +pub const MAYAN_FORWARDER: &str = "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2"; +pub const MAYAN_MCTP: &str = "0x875d6d37EC55c8cF220B9E5080717549d8Aa8EcA"; +pub const MAYAN_SWIFT: &str = "0xC38e4e6A15593f908255214653d3D947CA1c2338"; +pub const MAYAN_FULFILL_HELPER: &str = "0xBC0663ef502F0Ee9676626ED5B418037252eFeb2"; +pub const MAYAN_PROGRAM_ID: &str = "FC4eXxkyrMPTjiYUpp4EAnkmwMbQyZ6NDCh1kfLn6vsf"; +pub const MAYAN_MCTP_PROGRAM_ID: &str = "dkpZqrxHFrhziEMQ931GLtfy11nFkCsfMftH9u6QwBU"; +pub const MAYAN_FAST_MCTP_PROGRAM_ID: &str = "Gx9rivpS3YR8pBFwMuP6omYqVxunpLvLkNn7ubNyuZZ5"; +pub const MAYAN_SWIFT_V2_PROGRAM_ID: &str = "mayan34VedncxdK2XobtvWFDXQASUTBXhUVzt2kKgny"; +pub const MAYAN_FEE_MANAGER_PROGRAM_ID: &str = "5VtQHnhs2pfVEr68qQsbTRwKh4JV5GTu9mBHgHFxpHeQ"; +pub const MAYAN_LOOKUP_TABLE_SOLANA: &str = "Ff3yi1meWQQ19VPZMzGg6H8JQQeRudiV7QtVtyzJyoht"; +pub const MAYAN_CPI_PROXY_PROGRAM_ID: &str = "D8C8iW6zmoKg5TRr8nQ7h14TMWqQX8FiBdj2ju5MF3wa"; +pub const MAYAN_PAYLOAD_WRITER_PROGRAM_ID: &str = "DwMLtdtJqJQkHzNcrdTBuWHJByJfgpKBnvFvzyKdy3cU"; +pub const HC_HYPEREVM_DEPOSIT_PROCESSOR: &str = "0x56032241c0adab58a29b13e94fb595a4bc414e33"; +pub const HYPERCORE_SPOT_USDC_CONTRACT: &str = "0x000000000000000000000000000000000000ffff"; +pub const SUI_MCTP_PACKAGE_ID: &str = "0xb5bd3599ec7f4ae86afd84398f6f2d862deecce965e8ace2d8d8c8108d5076df"; +pub const SUI_MCTP_STATE: &str = "0xb787fe0f7530b4fd2162fa0cc92f4f6c5a97c54b4c5c55eb04ab29f4b803ac9c"; +pub const SUI_MCTP_FEE_MANAGER_STATE: &str = "0xa1b4a96ce93d36dd0bbce0adc39533a07d2f32928918c80cd6fe7868320978f2"; +pub const SUI_CCTP_CORE_STATE: &str = "0xf68268c3d9b1df3215f2439400c1c4ea08ac4ef4bb7d6f3ca6a2a239e17510af"; +pub const SUI_CCTP_TOKEN_PACKAGE_ID: &str = "0x2aa6c5d56376c371f88a6cc42e852824994993cb9bab8d3e6450cbe3cb32b94e"; +pub const SUI_CCTP_TOKEN_STATE: &str = "0x45993eecc0382f37419864992c12faee2238f5cfe22b98ad3bf455baf65c8a2f"; +pub const SUI_WORMHOLE_PACKAGE_ID: &str = "0x5306f64e312b581766351c07af79c72fcb1cd25147157fdc2f8ad76de9a3fb6a"; +pub const SUI_WORMHOLE_STATE: &str = "0xaeab97f96cf9877fee2883315d459552b2b921edc16d7ceac6eab944dd88919c"; +pub const SUI_LOGGER_PACKAGE_ID: &str = "0x05680e9030c147b413a489f7891273acc221d49bd061c433e5771bc170fc37ac"; +pub const SUI_CCTP_DENY_LIST: &str = "0x403"; +pub const SDK_VERSION: &str = "14_1_0"; + +pub const MAYAN_DEPOSIT_CONTRACTS: [&str; 4] = [MAYAN_FORWARDER, MAYAN_MCTP, MAYAN_SWIFT, SUI_MCTP_PACKAGE_ID]; +pub const MAYAN_SEND_CONTRACTS: [&str; 1] = [MAYAN_FULFILL_HELPER]; diff --git a/core/crates/swapper/src/mayan/mapper.rs b/core/crates/swapper/src/mayan/mapper.rs new file mode 100644 index 0000000000..70fe0c345b --- /dev/null +++ b/core/crates/swapper/src/mayan/mapper.rs @@ -0,0 +1,95 @@ +use super::{ + asset::asset_id_for_token, + model::{MayanClientStatus, MayanTransactionResult}, + wormhole_chain, +}; +use crate::{SwapResult, SwapperProvider}; +use primitives::TransactionSwapMetadata; + +pub fn map_swap_result(result: &MayanTransactionResult) -> SwapResult { + let status = result.client_status.swap_status(); + + let from_chain = result.from_token_chain.parse::().ok().and_then(wormhole_chain::chain_from_id); + let to_chain = result.to_token_chain.parse::().ok().and_then(wormhole_chain::chain_from_id); + + let metadata = if result.client_status != MayanClientStatus::InProgress { + from_chain.zip(to_chain).and_then(|(from_chain, to_chain)| { + Some(TransactionSwapMetadata { + from_asset: asset_id_for_token(from_chain, &result.from_token_address)?, + from_value: result.from_amount64.clone()?, + to_asset: asset_id_for_token(to_chain, &result.to_token_address)?, + to_value: result.to_amount64.clone()?, + provider: Some(SwapperProvider::Mayan.as_ref().to_string()), + }) + }) + } else { + None + }; + + SwapResult { status, metadata } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{AssetId, Chain, asset_constants::POLYGON_USDT_ASSET_ID, swap::SwapStatus}; + + fn result(json: &str) -> MayanTransactionResult { + serde_json::from_str(json).unwrap() + } + + #[test] + fn test_map_swap_result() { + let missing_to_amount64 = map_swap_result(&result(include_str!("test/eth_to_sui_swift.json"))); + assert_eq!(missing_to_amount64.status, SwapStatus::Completed); + assert!(missing_to_amount64.metadata.is_none()); + + assert_eq!( + map_swap_result(&result(include_str!("test/pol_to_bnb_swift.json"))), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: AssetId::from_chain(Chain::Polygon), + from_value: "21782666".to_string(), + to_asset: AssetId::from_chain(Chain::SmartChain), + to_value: "33060513057817862".to_string(), + provider: Some("mayan".to_string()), + }), + } + ); + assert_eq!( + map_swap_result(&result(include_str!("test/usdt_to_owb_swift.json"))), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: POLYGON_USDT_ASSET_ID.clone(), + from_value: "35245466".to_string(), + to_asset: AssetId::from_token(Chain::Base, "0xEF5997c2cf2f6c138196f8A6203afc335206b3c1"), + to_value: "398724622644505839482".to_string(), + provider: Some("mayan".to_string()), + }), + } + ); + assert_eq!( + map_swap_result(&result(include_str!("test/btcbr_to_radr_swift.json"))), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: AssetId::from_token(Chain::SmartChain, "0x0cF8e180350253271f4b917CcFb0aCCc4862F262"), + from_value: "10686571736749000000".to_string(), + to_asset: AssetId::from_token(Chain::Solana, "CzFvsLdUazabdiu9TYXujj4EY495fG7VgJJ3vQs6bonk"), + to_value: "278080608518046".to_string(), + provider: Some("mayan".to_string()), + }), + } + ); + + let pending = map_swap_result(&result(include_str!("test/mctp_pending.json"))); + assert_eq!(pending.status, SwapStatus::Pending); + assert!(pending.metadata.is_none()); + + let refunded = map_swap_result(&result(include_str!("test/swift_refunded.json"))); + assert_eq!(refunded.status, SwapStatus::Failed); + assert!(refunded.metadata.is_none()); + } +} diff --git a/core/crates/swapper/src/mayan/mod.rs b/core/crates/swapper/src/mayan/mod.rs new file mode 100644 index 0000000000..f42c858884 --- /dev/null +++ b/core/crates/swapper/src/mayan/mod.rs @@ -0,0 +1,13 @@ +mod asset; +mod cctp_domain; +mod client; +mod constants; +mod mapper; +mod model; +mod provider; +#[cfg(test)] +mod testkit; +mod tx_builder; +mod wormhole_chain; + +pub use provider::Mayan; diff --git a/core/crates/swapper/src/mayan/model.rs b/core/crates/swapper/src/mayan/model.rs new file mode 100644 index 0000000000..861909cf40 --- /dev/null +++ b/core/crates/swapper/src/mayan/model.rs @@ -0,0 +1,693 @@ +use super::{ + constants::{MAYAN_FORWARDER, SDK_VERSION}, + wormhole_chain, +}; +use crate::{SwapperError, amount_to_value, fees::default_referral_address}; +use num_bigint::BigUint; +use number_formatter::BigNumberFormatter; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::BTreeSet, ops::Deref, str::FromStr}; + +use gem_evm::ethereum_address_checksum; +pub use gem_sui::tx_builder::transaction_json::TransactionArgument as SuiTransactionArgument; +use primitives::SolanaInstruction; +use primitives::swap::SwapStatus; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteResponse { + pub quotes: Vec, +} + +#[derive(Debug, Clone)] +pub struct QuoteParams { + pub amount_in64: String, + pub from_token: String, + pub from_chain: String, + pub to_token: String, + pub to_chain: String, + pub referrer: String, + pub referrer_bps: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MayanQuote { + Swift(Box), + Mctp(Box), + FastMctp(Box), + MonoChain(Box), +} + +impl MayanQuote { + pub fn common(&self) -> &MayanQuoteCommon { + match self { + Self::Swift(route) => &route.common, + Self::Mctp(route) => &route.common, + Self::FastMctp(route) => &route.common, + Self::MonoChain(route) => &route.common, + } + } + + pub fn as_swift(&self) -> Option<&MayanSwiftQuote> { + match self { + Self::Swift(route) => Some(route.as_ref()), + Self::Mctp(_) | Self::FastMctp(_) | Self::MonoChain(_) => None, + } + } + + pub fn as_mctp(&self) -> Option<&MayanMctpQuote> { + match self { + Self::Mctp(route) => Some(route.as_ref()), + Self::Swift(_) | Self::FastMctp(_) | Self::MonoChain(_) => None, + } + } + + pub fn as_fast_mctp(&self) -> Option<&MayanFastMctpQuote> { + match self { + Self::FastMctp(route) => Some(route.as_ref()), + Self::Swift(_) | Self::Mctp(_) | Self::MonoChain(_) => None, + } + } + + pub fn as_mono_chain(&self) -> Option<&MayanMonoChainQuote> { + match self { + Self::MonoChain(route) => Some(route.as_ref()), + Self::Swift(_) | Self::Mctp(_) | Self::FastMctp(_) => None, + } + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MayanQuoteCommon { + pub effective_amount_in64: String, + pub expected_amount_out: Value, + pub min_amount_out: Value, + pub gas_drop: Value, + pub eta_seconds: u32, + pub from_token: MayanToken, + pub to_token: MayanToken, + pub from_chain: String, + pub to_chain: String, + pub slippage_bps: u32, + pub deadline64: Option, + pub referrer_bps: Option, + pub expected_amount_out_base_units: Option, +} + +impl MayanQuoteCommon { + pub(in crate::mayan) fn expected_output_value(&self, output_decimals: u32) -> Result { + let output_decimals = if output_decimals == 0 { self.to_token.decimals } else { output_decimals }; + if let Some(value) = &self.expected_amount_out_base_units { + return BigUint::from_str(value) + .map(|amount| rescale_base_units(amount, self.to_token.decimals, output_decimals).to_string()) + .map_err(SwapperError::from); + } + + let amount = match &self.expected_amount_out { + Value::Number(number) => number.to_string(), + Value::String(value) => value.clone(), + Value::Null | Value::Bool(_) | Value::Array(_) | Value::Object(_) => return Err(SwapperError::InvalidRoute), + }; + BigNumberFormatter::value_from_amount(&amount, output_decimals).map_err(SwapperError::from) + } +} + +fn rescale_base_units(amount: BigUint, from_decimals: u32, to_decimals: u32) -> BigUint { + match to_decimals.cmp(&from_decimals) { + std::cmp::Ordering::Equal => amount, + std::cmp::Ordering::Greater => amount * BigUint::from(10_u32).pow(to_decimals - from_decimals), + std::cmp::Ordering::Less => amount / BigUint::from(10_u32).pow(from_decimals - to_decimals), + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MayanSwiftQuote { + #[serde(flatten)] + pub common: MayanQuoteCommon, + pub refund_relayer_fee64: Option, + pub cancel_relayer_fee64: Option, + pub submit_relayer_fee64: Option, + pub protocol_bps: Option, + pub min_middle_amount: Option, + pub swift_mayan_contract: Option, + pub swift_auction_mode: Option, + pub swift_input_contract: Option, + pub swift_input_decimals: Option, + pub swift_input_contract_standard: Option, + pub swift_wrap_and_lock: Option, + pub swift_version: Option, + pub hc_swift_deposit: Option, + pub suggested_priority_fee: Option, + pub gasless: Option, + pub quote_id: Option, + pub max_swap_accounts: Option, + pub max_swap_data_length: Option, + pub memo_hex: Option, +} + +impl Deref for MayanSwiftQuote { + type Target = MayanQuoteCommon; + + fn deref(&self) -> &Self::Target { + &self.common + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MayanMctpQuote { + #[serde(flatten)] + pub common: MayanQuoteCommon, + pub min_middle_amount: Option, + pub has_auction: Option, + pub cheaper_chain: Option, + pub bridge_fee: Option, + pub redeem_relayer_fee: Option, + pub mctp_input_contract: Option, + pub mctp_verified_input_address: Option, + pub mctp_input_treasury: Option, + pub mctp_mayan_contract: Option, + pub solana_relayer_fee64: Option, + pub relayer: Option, + pub suggested_priority_fee: Option, + pub max_swap_accounts: Option, + pub max_swap_data_length: Option, +} + +impl Deref for MayanMctpQuote { + type Target = MayanQuoteCommon; + + fn deref(&self) -> &Self::Target { + &self.common + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MayanFastMctpQuote { + #[serde(flatten)] + pub common: MayanQuoteCommon, + pub refund_relayer_fee64: Option, + pub redeem_relayer_fee64: Option, + pub redeem_relayer_fee: Option, + pub min_middle_amount: Option, + pub has_auction: Option, + pub cheaper_chain: Option, + pub fast_mctp_mayan_contract: Option, + pub fast_mctp_input_contract: Option, + pub fast_mctp_min_finality: Option, + pub circle_max_fee64: Option, + pub solana_relayer_fee64: Option, + pub relayer: Option, + pub suggested_priority_fee: Option, + pub max_swap_accounts: Option, + pub max_swap_data_length: Option, +} + +impl Deref for MayanFastMctpQuote { + type Target = MayanQuoteCommon; + + fn deref(&self) -> &Self::Target { + &self.common + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MayanMonoChainQuote { + #[serde(flatten)] + pub common: MayanQuoteCommon, + pub mono_chain_mayan_contract: String, + pub evm_swap_router_address: Option, + pub evm_swap_router_calldata: Option, +} + +impl Deref for MayanMonoChainQuote { + type Target = MayanQuoteCommon; + + fn deref(&self) -> &Self::Target { + &self.common + } +} + +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum QuoteType { + #[default] + Swift, + Mctp, + FastMctp, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +pub enum SwiftVersion { + V1, + V2, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MayanToken { + pub contract: String, + pub w_chain_id: u16, + pub decimals: u32, + pub verified_address: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HcSwiftDeposit { + pub relayer_fee64: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSwapEvmParams { + pub forwarder_address: &'static str, + pub slippage_bps: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer_address: Option, + pub from_token: String, + pub middle_token: String, + pub chain_name: String, + pub amount_in64: String, + pub sdk_version: &'static str, +} + +impl GetSwapEvmParams { + pub fn swift(route: &MayanSwiftQuote, amount_in64: String, middle_token: String) -> Self { + Self { + forwarder_address: MAYAN_FORWARDER, + slippage_bps: route.slippage_bps, + referrer_address: wormhole_chain::chain_for_name(&route.from_chain) + .ok() + .map(default_referral_address) + .filter(|address| !address.is_empty()), + from_token: route.from_token.contract.clone(), + middle_token, + chain_name: route.from_chain.clone(), + amount_in64, + sdk_version: SDK_VERSION, + } + } + + pub fn mctp(route: &MayanMctpQuote, amount_in64: String, middle_token: String, referrer_address: Option) -> Self { + Self { + forwarder_address: MAYAN_FORWARDER, + slippage_bps: route.slippage_bps, + referrer_address, + from_token: route.from_token.contract.clone(), + middle_token, + chain_name: route.from_chain.clone(), + amount_in64, + sdk_version: SDK_VERSION, + } + } + + pub fn fast_mctp(route: &MayanFastMctpQuote, amount_in64: String, middle_token: String, referrer_address: Option) -> Self { + Self { + forwarder_address: MAYAN_FORWARDER, + slippage_bps: route.slippage_bps, + referrer_address, + from_token: route.from_token.contract.clone(), + middle_token, + chain_name: route.from_chain.clone(), + amount_in64, + sdk_version: SDK_VERSION, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSwapEvmResponse { + pub swap_router_address: String, + pub swap_router_calldata: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSwapSolanaParams { + pub min_middle_amount: String, + pub middle_token: String, + pub user_wallet: String, + pub slippage_bps: u32, + pub from_token: String, + pub amount_in64: String, + pub deposit_mode: &'static str, + pub fill_max_accounts: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub tpm_token_account: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer_address: Option, + pub chain_name: String, + pub user_ledger: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_swap_accounts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_swap_data_length: Option, + pub sdk_version: &'static str, +} + +impl GetSwapSolanaParams { + pub fn swift( + route: &MayanSwiftQuote, + min_middle_amount: String, + middle_token: String, + user_wallet: String, + amount_in64: String, + referrer_address: Option, + user_ledger: String, + ) -> Self { + Self { + min_middle_amount, + middle_token, + user_wallet, + slippage_bps: route.slippage_bps, + from_token: route.from_token.contract.clone(), + amount_in64, + deposit_mode: "SWIFT", + fill_max_accounts: false, + tpm_token_account: None, + referrer_address, + chain_name: route.from_chain.clone(), + user_ledger, + max_swap_accounts: route.max_swap_accounts, + max_swap_data_length: route.max_swap_data_length, + sdk_version: SDK_VERSION, + } + } + + pub fn mctp( + route: &MayanMctpQuote, + min_middle_amount: String, + middle_token: String, + user_wallet: String, + amount_in64: String, + deposit_mode: &'static str, + referrer_address: Option, + user_ledger: String, + ) -> Self { + Self { + min_middle_amount, + middle_token, + user_wallet, + slippage_bps: route.slippage_bps, + from_token: route.from_token.contract.clone(), + amount_in64, + deposit_mode, + fill_max_accounts: false, + tpm_token_account: None, + referrer_address, + chain_name: route.from_chain.clone(), + user_ledger, + max_swap_accounts: route.max_swap_accounts, + max_swap_data_length: route.max_swap_data_length, + sdk_version: SDK_VERSION, + } + } + + pub fn fast_mctp( + route: &MayanFastMctpQuote, + min_middle_amount: String, + middle_token: String, + user_wallet: String, + amount_in64: String, + deposit_mode: &'static str, + referrer_address: Option, + user_ledger: String, + ) -> Self { + Self { + min_middle_amount, + middle_token, + user_wallet, + slippage_bps: route.slippage_bps, + from_token: route.from_token.contract.clone(), + amount_in64, + deposit_mode, + fill_max_accounts: false, + tpm_token_account: None, + referrer_address, + chain_name: route.from_chain.clone(), + user_ledger, + max_swap_accounts: route.max_swap_accounts, + max_swap_data_length: route.max_swap_data_length, + sdk_version: SDK_VERSION, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaClientSwap { + pub compute_budget_instructions: Option>, + pub setup_instructions: Option>, + pub swap_instruction: SolanaInstruction, + pub cleanup_instruction: Option, + pub address_lookup_table_addresses: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSwapSuiParams { + pub amount_in64: String, + pub input_coin_type: String, + pub middle_coin_type: String, + pub user_wallet: String, + pub with_wh_fee: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer_address: Option, + pub slippage_bps: u32, + pub chain_name: String, + pub sdk_version: &'static str, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuiClientSwap { + pub tx: String, + pub out_coin: SuiTransactionArgument, + pub wh_fee_coin: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorData { + pub min_amount_in: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ErrorResponse { + pub msg: Option, + pub message: Option, + pub data: Option, +} + +impl ErrorResponse { + const AMOUNT_TOO_SMALL: &str = "amount too small"; + + pub fn message(&self) -> Option<&str> { + self.msg.as_deref().or(self.message.as_deref()) + } + + pub fn is_input_amount_error(&self) -> bool { + if self.data.as_ref().and_then(|data| data.min_amount_in.as_ref()).is_some() { + return true; + } + + match self.message() { + Some(message) => message.to_ascii_lowercase().contains(Self::AMOUNT_TOO_SMALL), + None => false, + } + } + + pub fn min_amount_in(&self, decimals: u32) -> Option { + self.data + .as_ref() + .and_then(|data| data.min_amount_in.as_ref()) + .and_then(|amount| amount_value(amount, decimals)) + .or_else(|| self.message().and_then(|message| extract_min_amount(message, decimals))) + } +} + +fn amount_value(value: &Value, decimals: u32) -> Option { + match value { + Value::Number(number) => amount_to_value(&number.to_string(), decimals), + Value::String(value) => amount_to_value(value, decimals), + Value::Null | Value::Bool(_) | Value::Array(_) | Value::Object(_) => None, + } +} + +fn extract_min_amount(message: &str, decimals: u32) -> Option { + let lowercased = message.to_ascii_lowercase(); + let start = lowercased.find("min")?; + let amount = message[start + "min".len()..] + .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == ',' || c == '_')) + .find(|part| part.chars().any(|c| c.is_ascii_digit()))?; + amount_to_value(amount, decimals) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MayanTransactionResult { + pub from_token_address: String, + pub to_token_address: String, + pub from_token_chain: String, + pub to_token_chain: String, + pub from_amount64: Option, + pub to_amount64: Option, + pub client_status: MayanClientStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "UPPERCASE")] +pub enum MayanClientStatus { + Completed, + InProgress, + Refunded, +} + +impl MayanClientStatus { + pub fn swap_status(&self) -> SwapStatus { + match self { + MayanClientStatus::Completed => SwapStatus::Completed, + MayanClientStatus::Refunded => SwapStatus::Failed, + MayanClientStatus::InProgress => SwapStatus::Pending, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MayanChain { + pub mayan_address: String, +} + +fn checksum_address(address: &str) -> String { + ethereum_address_checksum(address).unwrap_or_else(|_| address.to_string()) +} + +impl MayanChain { + pub fn unique_addresses(chains: Vec) -> Vec { + chains + .into_iter() + .filter(|c| !c.mayan_address.is_empty()) + .map(|c| checksum_address(&c.mayan_address)) + .collect::>() + .into_iter() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_quote_response() { + let response: QuoteResponse = serde_json::from_str(include_str!("test/quote_response_swift.json")).unwrap(); + + let value = serde_json::to_value(&response.quotes[0]).unwrap(); + assert_eq!(value.get("type").and_then(Value::as_str), Some("SWIFT")); + + let quote = response.quotes[0].as_swift().unwrap(); + assert_eq!(quote.swift_version, Some(SwiftVersion::V2)); + assert_eq!(quote.from_token.w_chain_id, 2); + assert_eq!(quote.to_token.decimals, 9); + } + + #[test] + fn test_decode_fast_mctp_quote() { + let route: MayanQuote = serde_json::from_str(include_str!("test/fast_mctp_quote.json")).unwrap(); + + let route = route.as_fast_mctp().unwrap(); + assert_eq!(route.fast_mctp_input_contract.as_deref(), Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")); + assert_eq!(route.fast_mctp_min_finality, Some(1000)); + assert_eq!(route.circle_max_fee64.as_deref(), Some("500")); + assert_eq!(route.redeem_relayer_fee64.as_deref(), Some("100000")); + } + + #[test] + fn test_expected_output_value() { + let mut route = MayanQuoteCommon { + expected_amount_out_base_units: Some("1237897283".to_string()), + to_token: MayanToken { + decimals: 9, + ..Default::default() + }, + ..Default::default() + }; + assert_eq!(route.expected_output_value(9).unwrap(), "1237897283"); + + route.expected_amount_out_base_units = None; + route.expected_amount_out = serde_json::json!(1.237897283); + route.to_token = MayanToken { + decimals: 9, + ..Default::default() + }; + assert_eq!(route.expected_output_value(9).unwrap(), "1237897283"); + } + + #[test] + fn test_expected_output_value_rescales_base_units_to_asset_decimals() { + let route = MayanQuoteCommon { + expected_amount_out_base_units: Some("6023337".to_string()), + to_token: MayanToken { + decimals: 6, + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!(route.expected_output_value(8).unwrap(), "602333700"); + } + + #[test] + fn test_decode_sui_client_swap() { + let response: SuiClientSwap = serde_json::from_str(include_str!("test/sui_client_swap.json")).unwrap(); + + assert_eq!(response.out_coin, SuiTransactionArgument::Result { result: 4 }); + assert_eq!(response.wh_fee_coin, Some(SuiTransactionArgument::NestedResult { nested_result: [0, 0] })); + } + + #[test] + fn test_decode_mayan_transaction_result() { + let result: MayanTransactionResult = serde_json::from_str(include_str!("test/eth_to_sui_swift.json")).unwrap(); + assert_eq!(result.client_status, MayanClientStatus::Completed); + assert_eq!(result.from_amount64, Some("18124254".to_string())); + assert!(result.to_amount64.is_none()); + + let result: MayanTransactionResult = serde_json::from_str(include_str!("test/mctp_pending.json")).unwrap(); + assert_eq!(result.client_status, MayanClientStatus::InProgress); + assert_eq!(result.from_amount64, Some("529066169".to_string())); + assert!(result.to_amount64.is_none()); + + let result: MayanTransactionResult = serde_json::from_str(include_str!("test/swift_refunded.json")).unwrap(); + assert_eq!(result.client_status, MayanClientStatus::Refunded); + assert_eq!(result.from_amount64, Some("4000000000000000".to_string())); + assert!(result.to_amount64.is_none()); + } + + #[test] + fn test_token_chain_fields() { + let result: MayanTransactionResult = serde_json::from_str(include_str!("test/eth_to_sui_swift.json")).unwrap(); + assert_eq!(result.from_token_chain, "2"); + assert_eq!(result.to_token_chain, "21"); + assert_eq!(result.from_token_address, "0x0000000000000000000000000000000000000000"); + assert_eq!(result.to_token_address, "0x2::sui::SUI"); + + let result: MayanTransactionResult = serde_json::from_str(include_str!("test/mctp_pending.json")).unwrap(); + assert_eq!(result.from_token_chain, "30"); + assert_eq!(result.to_token_chain, "23"); + assert_eq!(result.from_token_address, "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"); + assert_eq!(result.to_token_address, "0xaf88d065e77c8cc2239327c5edb3a432268e5831"); + + let result: MayanTransactionResult = serde_json::from_str(include_str!("test/swift_refunded.json")).unwrap(); + assert_eq!(result.from_token_chain, "2"); + assert_eq!(result.to_token_chain, "1"); + } +} diff --git a/core/crates/swapper/src/mayan/provider.rs b/core/crates/swapper/src/mayan/provider.rs new file mode 100644 index 0000000000..c099243944 --- /dev/null +++ b/core/crates/swapper/src/mayan/provider.rs @@ -0,0 +1,620 @@ +use super::{ + asset::{supported_assets as mayan_supported_assets, token_id_for_asset}, + client::MayanClient, + constants::{MAYAN_DEPOSIT_CONTRACTS, MAYAN_SEND_CONTRACTS}, + mapper::map_swap_result, + model::{MayanChain, MayanQuote, QuoteParams, SwiftVersion}, + tx_builder::{fast_mctp, mctp, mono_chain, swift}, + wormhole_chain, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, + SwapperQuoteData, + config::get_swap_proxy_url, + cross_chain::VaultAddresses, + fees::{default_referral_address, default_referral_fees, quote_value_after_reserve, quote_value_after_reserve_by_chain}, +}; +use async_trait::async_trait; +use gem_client::Client; +use primitives::{Chain, ChainType}; +use std::{collections::BTreeSet, fmt::Debug, sync::Arc}; + +const SOLANA_NATIVE_SWAP_RESERVE: &str = "5000000"; + +#[derive(Debug)] +pub struct Mayan +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + provider: ProviderType, + price_client: MayanClient, + explorer_client: MayanClient, + rpc_provider: Arc, +} + +impl Mayan { + pub fn new(rpc_provider: Arc) -> Self { + Self::with_clients( + MayanClient::new(RpcClient::new(get_swap_proxy_url("mayan/price/v3"), rpc_provider.clone())), + MayanClient::new(RpcClient::new(get_swap_proxy_url("mayan/explorer/v3"), rpc_provider.clone())), + rpc_provider, + ) + } +} + +impl Mayan +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn with_clients(price_client: MayanClient, explorer_client: MayanClient, rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Mayan), + price_client, + explorer_client, + rpc_provider, + } + } + + fn supported_source_chain(chain: Chain) -> bool { + match chain.chain_type() { + ChainType::Ethereum | ChainType::Solana | ChainType::Sui => true, + ChainType::Bitcoin + | ChainType::Cosmos + | ChainType::Ton + | ChainType::Tron + | ChainType::Aptos + | ChainType::Xrp + | ChainType::Near + | ChainType::Stellar + | ChainType::Algorand + | ChainType::Polkadot + | ChainType::Cardano + | ChainType::HyperCore => false, + } + } + + fn supports_chain_pair(&self, from_chain: Chain, to_chain: Chain) -> bool { + let supported_assets = mayan_supported_assets(); + Self::supported_source_chain(from_chain) + && supported_assets.iter().any(|asset| asset.get_chain() == from_chain) + && supported_assets.iter().any(|asset| asset.get_chain() == to_chain) + } +} + +#[async_trait] +impl Swapper for Mayan +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + mayan_supported_assets() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + if !self.supports_chain_pair(request.from_asset.chain(), request.to_asset.chain()) { + return Err(SwapperError::NotSupportedChain); + } + + let from_value = quote_value_after_mayan_reserve(request)?; + let from_asset = request.from_asset.asset_id(); + let to_asset = request.to_asset.asset_id(); + let referral_fees = default_referral_fees(); + let routes = self + .price_client + .get_quotes( + QuoteParams { + amount_in64: from_value.clone(), + from_token: token_id_for_asset(&from_asset), + from_chain: wormhole_chain::name_for_chain(from_asset.chain)?.to_string(), + to_token: token_id_for_asset(&to_asset), + to_chain: wormhole_chain::name_for_chain(to_asset.chain)?.to_string(), + referrer: default_referral_address(Chain::Solana), + referrer_bps: referral_fees.bps_for_chain(from_asset.chain), + }, + request.from_asset.decimals, + ) + .await?; + let route = Self::select_route(&routes, from_asset.chain, to_asset.chain).ok_or(SwapperError::NoQuoteAvailable)?; + let to_value = route.common().expected_output_value(request.to_asset.decimals)?; + + Ok(Quote { + from_value, + min_from_value: None, + to_value, + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: from_asset, + output: to_asset, + route_data: serde_json::to_string(route)?, + }], + slippage_bps: route.common().slippage_bps, + }, + request: request.clone(), + eta_in_seconds: Some(route.common().eta_seconds), + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let route: MayanQuote = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + match (quote.request.from_asset.chain().chain_type(), &route) { + (ChainType::Ethereum, MayanQuote::Swift(route)) => swift::evm::build_quote_data(&self.price_client, quote, route, self.rpc_provider.clone()).await, + (ChainType::Ethereum, MayanQuote::Mctp(route)) => mctp::evm::build_quote_data(&self.price_client, quote, route, self.rpc_provider.clone()).await, + (ChainType::Ethereum, MayanQuote::FastMctp(route)) => fast_mctp::evm::build_quote_data(&self.price_client, quote, route, self.rpc_provider.clone()).await, + (ChainType::Ethereum, MayanQuote::MonoChain(route)) => mono_chain::evm::build_quote_data(quote, route, self.rpc_provider.clone()).await, + (ChainType::Solana, MayanQuote::Swift(route)) => swift::solana::build_quote_data(&self.price_client, quote, route, self.rpc_provider.clone()).await, + (ChainType::Solana, MayanQuote::Mctp(route)) => mctp::solana::build_quote_data(&self.price_client, quote, route, self.rpc_provider.clone()).await, + (ChainType::Solana, MayanQuote::FastMctp(route)) => fast_mctp::solana::build_quote_data(&self.price_client, quote, route, self.rpc_provider.clone()).await, + (ChainType::Sui, MayanQuote::Mctp(route)) => mctp::sui::build_quote_data(&self.price_client, quote, route, self.rpc_provider.clone()).await, + (ChainType::Solana, MayanQuote::MonoChain(_)) | (ChainType::Sui, MayanQuote::Swift(_) | MayanQuote::FastMctp(_) | MayanQuote::MonoChain(_)) => { + Err(SwapperError::InvalidRoute) + } + ( + ChainType::Bitcoin + | ChainType::Cosmos + | ChainType::Ton + | ChainType::Tron + | ChainType::Aptos + | ChainType::Xrp + | ChainType::Near + | ChainType::Stellar + | ChainType::Algorand + | ChainType::Polkadot + | ChainType::Cardano + | ChainType::HyperCore, + _, + ) => Err(SwapperError::NotSupportedChain), + } + } + + async fn get_swap_result(&self, _chain: Chain, transaction_hash: &str) -> Result { + let result = self.explorer_client.get_transaction_status(transaction_hash).await?; + Ok(map_swap_result(&result)) + } + + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + let api_addresses = MayanChain::unique_addresses(self.price_client.get_chains().await?); + let deposit: BTreeSet = MAYAN_DEPOSIT_CONTRACTS.iter().map(|s| s.to_string()).chain(api_addresses.iter().cloned()).collect(); + let send: BTreeSet = MAYAN_SEND_CONTRACTS.iter().map(|s| s.to_string()).chain(api_addresses).collect(); + + Ok(VaultAddresses { + deposit: deposit.into_iter().collect(), + send: send.into_iter().collect(), + }) + } +} + +fn quote_value_after_mayan_reserve(request: &QuoteRequest) -> Result { + if request.options.use_max_amount && request.from_asset.chain() == Chain::Solana && request.from_asset.is_native() { + return quote_value_after_reserve(request, SOLANA_NATIVE_SWAP_RESERVE); + } + quote_value_after_reserve_by_chain(request) +} + +impl Mayan +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + fn select_route(routes: &[MayanQuote], source_chain: Chain, destination_chain: Chain) -> Option<&MayanQuote> { + if source_chain == Chain::Hyperliquid && destination_chain == Chain::HyperCore { + return routes + .iter() + .find(|route| route.as_mono_chain().is_some()) + .or_else(|| { + routes + .iter() + .find(|route| route.as_swift().is_some_and(|swift| swift.swift_version == Some(SwiftVersion::V2))) + }) + .or_else(|| routes.iter().find(|route| route.as_mctp().is_some())); + } + + match source_chain.chain_type() { + ChainType::Sui => routes.iter().find(|route| route.as_mctp().is_some()), + ChainType::Ethereum => routes + .iter() + .find(|route| route.as_swift().is_some_and(|swift| swift.swift_version == Some(SwiftVersion::V2))) + .or_else(|| routes.iter().find(|route| route.as_mono_chain().is_some())) + .or_else(|| routes.iter().find(|route| route.as_fast_mctp().is_some())) + .or_else(|| routes.iter().find(|route| route.as_mctp().is_some())), + ChainType::Solana => routes + .iter() + .find(|route| route.as_swift().is_some_and(|swift| swift.swift_version == Some(SwiftVersion::V2))) + .or_else(|| { + if destination_chain == Chain::Sui { + None + } else { + routes.iter().find(|route| route.as_fast_mctp().is_some()) + } + }) + .or_else(|| routes.iter().find(|route| route.as_mctp().is_some())), + _ => routes + .iter() + .find(|route| route.as_swift().is_some_and(|swift| swift.swift_version == Some(SwiftVersion::V2))) + .or_else(|| routes.iter().find(|route| route.as_mctp().is_some())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mayan::model::{MayanFastMctpQuote, MayanMctpQuote, MayanMonoChainQuote}; + use crate::models::Options; + use crate::{SwapperQuoteAsset, alien::mock::ProviderMock}; + use gem_client::testkit::MockClient; + use primitives::{ + AssetId, + asset_constants::{ARBITRUM_USDC_ASSET_ID, HYPERCORE_SPOT_USDC_ASSET_ID, SOLANA_USDC_TOKEN_ID}, + }; + use std::collections::BTreeSet; + + #[tokio::test] + async fn test_get_vault_addresses() { + let price_client = MockClient::new().with_get(|path| { + assert_eq!(path, "/chains"); + Ok(br#"[ + {"mayanAddress":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"mayanAddress":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"mayanAddress":""} + ]"# + .to_vec()) + }); + let provider = Mayan::with_clients( + MayanClient::new(price_client), + MayanClient::new(MockClient::new()), + Arc::new(ProviderMock::new("{}".to_string())), + ); + + let addresses = provider.get_vault_addresses(None).await.unwrap(); + let api_address = gem_evm::ethereum_address_checksum("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); + let expected_deposit = MAYAN_DEPOSIT_CONTRACTS + .iter() + .map(|address| address.to_string()) + .chain([api_address.clone()]) + .collect::>() + .into_iter() + .collect::>(); + let expected_send = MAYAN_SEND_CONTRACTS + .iter() + .map(|address| address.to_string()) + .chain([api_address]) + .collect::>() + .into_iter() + .collect::>(); + + assert_eq!(addresses.deposit, expected_deposit); + assert_eq!(addresses.send, expected_send); + } + + #[tokio::test] + async fn test_get_quote_rescales_mayan_base_units_to_destination_asset_decimals() { + let price_client = MockClient::new().with_get(|path| { + assert!(path.starts_with("/quote?")); + Ok(include_bytes!("test/quote_response_swift_hypercore.json").to_vec()) + }); + let provider = Mayan::with_clients( + MayanClient::new(price_client), + MayanClient::new(MockClient::new()), + Arc::new(ProviderMock::new("{}".to_string())), + ); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset { + id: ARBITRUM_USDC_ASSET_ID.to_string(), + symbol: "USDC".to_string(), + decimals: 6, + }, + to_asset: SwapperQuoteAsset { + id: HYPERCORE_SPOT_USDC_ASSET_ID.to_string(), + symbol: "USDC".to_string(), + decimals: 8, + }, + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "7000000".to_string(), + options: Options::new_with_slippage(5.into()), + }; + + let quote = provider.get_quote(&request).await.unwrap(); + + assert_eq!(quote.to_value, "602333700"); + } + + #[test] + fn test_select_route_falls_back_to_mctp_for_non_sui_source() { + let routes = vec![MayanQuote::Mctp(Box::new(MayanMctpQuote::mock()))]; + + assert!(Mayan::::select_route(&routes, Chain::Ethereum, Chain::Sui).unwrap().as_mctp().is_some()); + assert!(Mayan::::select_route(&routes, Chain::Solana, Chain::Sui).unwrap().as_mctp().is_some()); + } + + #[test] + fn test_select_route_prefers_fast_mctp_before_mctp_when_supported() { + let routes = vec![ + MayanQuote::Mctp(Box::new(MayanMctpQuote::mock())), + MayanQuote::FastMctp(Box::new(MayanFastMctpQuote::mock())), + ]; + + assert!(Mayan::::select_route(&routes, Chain::Ethereum, Chain::Base).unwrap().as_fast_mctp().is_some()); + assert!(Mayan::::select_route(&routes, Chain::Solana, Chain::Base).unwrap().as_fast_mctp().is_some()); + } + + #[test] + fn test_select_route_keeps_mctp_for_solana_to_sui_fast_mctp_gap() { + let routes = vec![ + MayanQuote::FastMctp(Box::new(MayanFastMctpQuote::mock())), + MayanQuote::Mctp(Box::new(MayanMctpQuote::mock())), + ]; + + assert!(Mayan::::select_route(&routes, Chain::Solana, Chain::Sui).unwrap().as_mctp().is_some()); + } + + #[test] + fn test_select_route_prefers_mono_chain_for_hyperevm_to_hypercore() { + let routes = vec![ + MayanQuote::Mctp(Box::new(MayanMctpQuote::mock())), + MayanQuote::MonoChain(Box::new(MayanMonoChainQuote::default())), + ]; + + assert!( + Mayan::::select_route(&routes, Chain::Hyperliquid, Chain::HyperCore) + .unwrap() + .as_mono_chain() + .is_some() + ); + } + + #[test] + fn test_supports_chain_pair_allows_hyperevm_to_hypercore_only() { + let provider = Mayan::with_clients( + MayanClient::new(MockClient::new()), + MayanClient::new(MockClient::new()), + Arc::new(ProviderMock::new("{}".to_string())), + ); + + assert!(provider.supports_chain_pair(Chain::Hyperliquid, Chain::HyperCore)); + assert!(!provider.supports_chain_pair(Chain::HyperCore, Chain::Hyperliquid)); + } + + #[test] + fn test_quote_value_after_mayan_reserve_uses_larger_solana_native_reserve() { + let mut request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Solana)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), + wallet_address: "address".to_string(), + destination_address: "address".to_string(), + value: "105814789".to_string(), + options: Options { + use_max_amount: true, + ..Default::default() + }, + }; + + assert_eq!(quote_value_after_mayan_reserve(&request).unwrap(), "100814789"); + + request.from_asset = SwapperQuoteAsset::from(AssetId::from_token(Chain::Solana, SOLANA_USDC_TOKEN_ID)); + assert_eq!(quote_value_after_mayan_reserve(&request).unwrap(), "105814789"); + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{FetchQuoteData, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, mayan::constants::MAYAN_FORWARDER, models::Options}; + use primitives::{ + AssetId, + asset_constants::{BASE_USDC_ASSET_ID, HYPERCORE_SPOT_USDC_ASSET_ID, HYPEREVM_USDC_ASSET_ID, POLYGON_USDT_ASSET_ID, SOLANA_USDC_ASSET_ID, SUI_USDC_ASSET_ID}, + swap::SwapStatus, + }; + use std::{future::Future, time::Instant}; + + async fn timed(label: &str, future: impl Future>) -> Result { + let started_at = Instant::now(); + let result = future.await; + println!("{label}: {:?}", started_at.elapsed()); + result + } + + fn mayan_route(quote: &Quote) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + serde_json::from_str(&route.route_data).map_err(SwapperError::from) + } + + #[tokio::test] + async fn test_mayan_provider_get_swift_evm_quote_and_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default().set_debug(false)); + let provider = Mayan::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Solana)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(), + value: "50000000000000000".to_string(), + options: Options::new_with_slippage(200.into()), + }; + + let quote = timed("mayan swift evm quote", provider.get_quote(&request)).await?; + let quote_data = timed("mayan swift evm quote data", provider.get_quote_data("e, FetchQuoteData::None)).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert_eq!(quote.data.provider, provider.provider().clone()); + assert_eq!(quote.data.routes.len(), 1); + let MayanQuote::Swift(route) = mayan_route("e)? else { + return Err(SwapperError::InvalidRoute); + }; + assert_eq!(route.from_chain, wormhole_chain::WormholeChain::Ethereum.name()); + assert_eq!(route.to_chain, wormhole_chain::WormholeChain::Solana.name()); + assert!(!quote_data.to.is_empty()); + assert!(!quote_data.data.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_mayan_provider_get_swift_solana_quote_and_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default().set_debug(false)); + let provider = Mayan::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(SOLANA_USDC_ASSET_ID.clone()), + to_asset: SwapperQuoteAsset::from(BASE_USDC_ASSET_ID.clone()), + wallet_address: "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "5000000".to_string(), + options: Options::new_with_slippage(200.into()), + }; + + let quote = timed("mayan swift solana quote", provider.get_quote(&request)).await?; + let quote_data = timed("mayan swift solana quote data", provider.get_quote_data("e, FetchQuoteData::None)).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert_eq!(quote.data.provider, provider.provider().clone()); + assert_eq!(quote.data.routes.len(), 1); + let MayanQuote::Swift(route) = mayan_route("e)? else { + return Err(SwapperError::InvalidRoute); + }; + assert_eq!(route.from_chain, wormhole_chain::WormholeChain::Solana.name()); + assert_eq!(route.to_chain, wormhole_chain::WormholeChain::Base.name()); + assert!(quote_data.to.is_empty()); + assert_eq!(quote_data.value, "0"); + assert!(!quote_data.data.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_mayan_provider_get_mctp_sui_quote_and_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default().set_debug(false)); + let provider = Mayan::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), + to_asset: SwapperQuoteAsset::from(BASE_USDC_ASSET_ID.clone()), + wallet_address: "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "1000000000".to_string(), + options: Options::new_with_slippage(200.into()), + }; + + let quote = timed("mayan mctp sui quote", provider.get_quote(&request)).await?; + let quote_data = timed("mayan mctp sui quote data", provider.get_quote_data("e, FetchQuoteData::None)).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert_eq!(quote.data.provider, provider.provider().clone()); + assert_eq!(quote.data.routes.len(), 1); + let MayanQuote::Mctp(route) = mayan_route("e)? else { + return Err(SwapperError::InvalidRoute); + }; + assert_eq!(route.from_chain, wormhole_chain::WormholeChain::Sui.name()); + assert_eq!(route.to_chain, wormhole_chain::WormholeChain::Base.name()); + assert!(quote_data.to.is_empty()); + assert_eq!(quote_data.value, "0"); + assert!(!quote_data.data.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_mayan_provider_get_mctp_solana_to_sui_quote_and_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default().set_debug(false)); + let provider = Mayan::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(SOLANA_USDC_ASSET_ID.clone()), + to_asset: SwapperQuoteAsset::from(SUI_USDC_ASSET_ID.clone()), + wallet_address: "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(), + destination_address: "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".to_string(), + value: "1000000".to_string(), + options: Options::new_with_slippage(200.into()), + }; + + let quote = timed("mayan mctp solana to sui quote", provider.get_quote(&request)).await?; + let quote_data = timed("mayan mctp solana to sui quote data", provider.get_quote_data("e, FetchQuoteData::None)).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + let MayanQuote::Mctp(route) = mayan_route("e)? else { + return Err(SwapperError::InvalidRoute); + }; + assert_eq!(route.from_chain, wormhole_chain::WormholeChain::Solana.name()); + assert_eq!(route.to_chain, wormhole_chain::WormholeChain::Sui.name()); + assert!(quote_data.to.is_empty()); + assert_eq!(quote_data.value, "0"); + assert!(!quote_data.data.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_mayan_provider_get_mctp_evm_to_sui_quote_and_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default().set_debug(false)); + let provider = Mayan::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + to_asset: SwapperQuoteAsset::from(SUI_USDC_ASSET_ID.clone()), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".to_string(), + value: "100000000000000000".to_string(), + options: Options::new_with_slippage(200.into()), + }; + + let quote = timed("mayan mctp evm to sui quote", provider.get_quote(&request)).await?; + let quote_data = timed("mayan mctp evm to sui quote data", provider.get_quote_data("e, FetchQuoteData::None)).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + let MayanQuote::Mctp(route) = mayan_route("e)? else { + return Err(SwapperError::InvalidRoute); + }; + assert_eq!(route.from_chain, wormhole_chain::WormholeChain::Ethereum.name()); + assert_eq!(route.to_chain, wormhole_chain::WormholeChain::Sui.name()); + assert_eq!(quote_data.to, MAYAN_FORWARDER); + assert!(!quote_data.data.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_mayan_provider_get_mono_chain_hyperevm_to_hypercore_quote_and_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default().set_debug(false)); + let provider = Mayan::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(HYPEREVM_USDC_ASSET_ID.clone()), + to_asset: SwapperQuoteAsset::from(HYPERCORE_SPOT_USDC_ASSET_ID.clone()), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "1000000".to_string(), + options: Options::new_with_slippage(200.into()), + }; + + let quote = timed("mayan mono-chain hyperevm to hypercore quote", provider.get_quote(&request)).await?; + let quote_data = timed("mayan mono-chain hyperevm to hypercore quote data", provider.get_quote_data("e, FetchQuoteData::None)).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + let MayanQuote::MonoChain(route) = mayan_route("e)? else { + return Err(SwapperError::InvalidRoute); + }; + assert_eq!(route.from_chain, wormhole_chain::WormholeChain::Hyperevm.name()); + assert_eq!(route.to_chain, wormhole_chain::WormholeChain::Hypercore.name()); + assert_eq!(quote_data.to, MAYAN_FORWARDER); + assert!(!quote_data.data.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_mayan_get_swap_result() -> Result<(), Box> { + let rpc_provider = Arc::new(NativeProvider::default().set_debug(false)); + let provider = Mayan::new(rpc_provider); + let hash = "0xfb2464f06d38f39a274b2a5e3414dbed43ad405a06295aaeaded8865efc7d4f4"; + let result = provider.get_swap_result(Chain::Ethereum, hash).await?; + + assert_eq!(result.status, SwapStatus::Completed); + let metadata = result.metadata.unwrap(); + assert_eq!(metadata.from_asset, POLYGON_USDT_ASSET_ID.clone()); + assert_eq!(metadata.from_value, "35245466"); + assert_eq!(metadata.to_asset, AssetId::from_token(Chain::Base, "0xEF5997c2cf2f6c138196f8A6203afc335206b3c1")); + assert_eq!(metadata.to_value, "398724622644505839482"); + assert_eq!(metadata.provider, Some("mayan".to_string())); + Ok(()) + } +} diff --git a/core/crates/swapper/src/mayan/test/btcbr_to_radr_swift.json b/core/crates/swapper/src/mayan/test/btcbr_to_radr_swift.json new file mode 100644 index 0000000000..73fb4e3c33 --- /dev/null +++ b/core/crates/swapper/src/mayan/test/btcbr_to_radr_swift.json @@ -0,0 +1,166 @@ +{ + "id": "4d159d41-11e4-483b-b951-fb53f8283225", + "trader": "0xf6974901CE9B441C3B3239D5d318a0213a6b37b0", + "traderLedger": null, + "sourceTxHash": "0x9c92e6a40426160fe4f2387973212ae8dac5c79fcd1661402079bc6cc17f6cdd", + "orderId": "SWIFT_0x9ccb0b6a3e33603aec474f03a612b71b6aa24d5aa88fb7e2bc65b8f6234e0955", + "sourceTxBlockNo": 84878621, + "status": "ORDER_SETTLED", + "transferSequence": null, + "swapSequence": null, + "redeemSequence": null, + "refundSequence": null, + "fulfillSequence": null, + "deadline": "2026-03-05T20:46:44.000Z", + "sourceChain": "4", + "swapChain": "1", + "destChain": "1", + "destAddress": "6KNiHHVhqP4K5f2cDoEpAqXE3VQv2F8EPExiq21BKf1", + "fromTokenAddress": "0x0cF8e180350253271f4b917CcFb0aCCc4862F262", + "fromTokenChain": "4", + "fromTokenSymbol": "BTCBR", + "fromAmount": "1500000000000000.000000000000102072", + "fromAmount64": "10686571736749000000", + "toTokenAddress": "CzFvsLdUazabdiu9TYXujj4EY495fG7VgJJ3vQs6bonk", + "toTokenChain": "1", + "toTokenSymbol": "RADR", + "sourceStateAddr": null, + "stateAddr": "BM12znTQW2xCnocsHbTwbCv89Q1A5gimZgtKqA4piCUF", + "stateNonce": "254", + "toAmount": "278080.608518046", + "toAmount64": "278080608518046", + "estimateMarketToAmount": null, + "transferSignedVaa": null, + "swapSignedVaa": null, + "redeemSignedVaa": null, + "refundSignedVaa": null, + "fulfillSignedVaa": null, + "savedAt": "2026-03-05T20:11:27.523Z", + "initiatedAt": "2026-03-05T20:11:27.000Z", + "completedAt": "2026-03-05T20:11:34.000Z", + "attestedAt": null, + "insufficientFees": false, + "retries": 0, + "swapRelayerFee": null, + "redeemRelayerFee": "0.32558143", + "refundRelayerFee": "0.00893964", + "submissionRelayerFee": null, + "statusUpdatedAt": "2026-03-05T20:11:27.523Z", + "syncRequestedAt": null, + "bridgeFee": "0", + "redeemTxHash": null, + "refundTxHash": null, + "fulfillTxHash": "3TgEkDqCrNcGPWD71cgzaGRr9Gax9LGBeoBnj5YMkHCQYPvxThXQqxoH93o7RGv7H541mP68UxFXvD5Z9HX62Yz", + "createTxHash": "0x9c92e6a40426160fe4f2387973212ae8dac5c79fcd1661402079bc6cc17f6cdd", + "unlockTxHash": null, + "postedBatchUnlockIndex": null, + "auctionAddress": "9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H", + "unwrapRedeem": null, + "unwrapRefund": null, + "driverAddress": "EnxFkSMoJkEzYUkWHubEXYKw2y9HQVUEHGmXrMyQniKu", + "relayerAddress": "EnxFkSMoJkEzYUkWHubEXYKw2y9HQVUEHGmXrMyQniKu", + "auctionMode": 2, + "stateOpen": null, + "cctpReceivedSuiId": null, + "cctpMessage": null, + "cctpMessageHash": null, + "cctpSolMessageAccount": null, + "cctpNonce": null, + "cctpAttestation": null, + "batchFulfilled": true, + "gaslessSignature": null, + "gaslessPermit": null, + "gaslessTx": null, + "gasless": null, + "initiateContractAddress": "0x337685fdab40d39bd02028545a4ffa7d287cc3e2", + "posAddress": "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2", + "mayanAddress": "0xC38e4e6A15593f908255214653d3D947CA1c2338", + "mayanBps": 3, + "referrerAddress": "11111111111111111111111111111111", + "referrerBps": 0, + "unlockRecipient": null, + "lockedFundObjectId": null, + "forwardedFromAmount": "1500000000000000.000000000000102072", + "forwardedTokenAddress": "0x0cF8e180350253271f4b917CcFb0aCCc4862F262", + "forwardedTokenSymbol": "BTCBR", + "penaltyPeriod": null, + "baseBond": null, + "perBpsBond": null, + "gasDrop": "0", + "gasDrop64": "0", + "payloadId": null, + "orderHash": "0x9ccb0b6a3e33603aec474f03a612b71b6aa24d5aa88fb7e2bc65b8f6234e0955", + "randomKey": "0x92ebdbda0eb9c8e47174521f8cfb04ca704894f0302c0218aeea7c1f48ec774e", + "auctionStateAddr": "9ZvS9iYZKbVdkbutULaAh96ZRYoSBDt1fdo18YjiiYFZ", + "auctionStateNonce": "252", + "minAmountOut": "268807.06277799", + "minAmountOut64": "26880706277799", + "service": "SWIFT_SWAP", + "toTokenPrice": 3.975331203770233e-05, + "fromTokenPrice": 0.99994379, + "redeemTxFee": null, + "refundTxFee": null, + "fromChainNativeTokenPrice": 650.12024637, + "toChainNativeTokenPrice": 89.11275, + "customPayload": null, + "meta": { + "quote_expectedAmountOut": 277120.683276287 + }, + "refundAmount": "278080.608518046", + "refundTokenLogoUri": "https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png?1547042389", + "refundTokenScannerUrl": "https://bscscan.com/token/0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "refundTokenSymbol": "USDC", + "fromTokenLogoUri": "https://logo.moralis.io/0x38_0x0cf8e180350253271f4b917ccfb0accc4862f262_e7dd5ef7af59f2afdad4a00713941ca2.webp", + "toTokenLogoUri": "https://coin-images.coingecko.com/coins/images/70002/small/photo_2025-08-03_12-54-15.jpg?1760340357", + "fromTokenScannerUrl": "https://bscscan.com/token/0x0cF8e180350253271f4b917CcFb0aCCc4862F262", + "toTokenScannerUrl": "https://solscan.io/token/CzFvsLdUazabdiu9TYXujj4EY495fG7VgJJ3vQs6bonk", + "txs": [ + { + "txHash": "0x9c92e6a40426160fe4f2387973212ae8dac5c79fcd1661402079bc6cc17f6cdd", + "goals": [ + "SEND" + ], + "scannerUrl": "https://bscscan.com/tx/0x9c92e6a40426160fe4f2387973212ae8dac5c79fcd1661402079bc6cc17f6cdd" + }, + { + "txHash": "3TgEkDqCrNcGPWD71cgzaGRr9Gax9LGBeoBnj5YMkHCQYPvxThXQqxoH93o7RGv7H541mP68UxFXvD5Z9HX62Yz", + "goals": [ + "SETTLE" + ], + "scannerUrl": "https://solscan.io/tx/3TgEkDqCrNcGPWD71cgzaGRr9Gax9LGBeoBnj5YMkHCQYPvxThXQqxoH93o7RGv7H541mP68UxFXvD5Z9HX62Yz" + }, + { + "txHash": "4mcQzUT5SvE4PjjosxzawtZWXqJVf7nEpaDrRPKLq11Q6CsqDh1F5Gugt8Pq1wpU3hbwwZytJKoiS1JoM9mUDRop", + "goals": [ + "FULFILL" + ], + "scannerUrl": "https://solscan.io/tx/4mcQzUT5SvE4PjjosxzawtZWXqJVf7nEpaDrRPKLq11Q6CsqDh1F5Gugt8Pq1wpU3hbwwZytJKoiS1JoM9mUDRop" + }, + { + "txHash": "2dYsBpZdc9eNEyHrvc2f65tFhMZ8FCTQVZcTYA3mxuV8wuBA4CCVksuNEz6J3FBA48sRKyoBP4XtGwZgvXnnjC9h", + "goals": [ + "REGISTER_ORDER" + ], + "scannerUrl": "https://solscan.io/tx/2dYsBpZdc9eNEyHrvc2f65tFhMZ8FCTQVZcTYA3mxuV8wuBA4CCVksuNEz6J3FBA48sRKyoBP4XtGwZgvXnnjC9h" + } + ], + "steps": [ + { + "title": "Order created with 1,500,000,000,000,000 BTCBR on Bsc", + "status": "COMPLETED", + "type": "INFO" + }, + { + "title": "Fullfill 278080.608518046 RADR on Solana", + "status": "COMPLETED", + "type": "INFO" + } + ], + "clientStatus": "COMPLETED", + "refundChain": "4", + "refundTokenAddress": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "clientRelayerFeeSuccess": null, + "clientRelayerFeeRefund": "0.33452107", + "protocolFeeUsd": 0.0032057913133655036, + "referrerFeeUsd": 0 +} diff --git a/core/crates/swapper/src/mayan/test/eth_to_sui_swift.json b/core/crates/swapper/src/mayan/test/eth_to_sui_swift.json new file mode 100644 index 0000000000..a59ba1068f --- /dev/null +++ b/core/crates/swapper/src/mayan/test/eth_to_sui_swift.json @@ -0,0 +1,153 @@ +{ + "id": "61207beb-a331-4d9f-bf88-dbb14ebff021", + "trader": "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7", + "traderLedger": null, + "sourceTxHash": "0x56acc6a58fc0bdd9e9be5cc2a3ff079b91b933f562cf0fe760f1d8d6b76f4876", + "orderId": "MCTP_0x56acc6a58fc0bdd9e9be5cc2a3ff079b91b933f562cf0fe760f1d8d6b76f4876__0", + "sourceTxBlockNo": 22187675, + "status": "SWAPPED_ON_SUI_MCTP", + "transferSequence": null, + "swapSequence": "3050", + "redeemSequence": null, + "refundSequence": null, + "fulfillSequence": null, + "deadline": "2025-04-03T11:20:39.000Z", + "sourceChain": "2", + "swapChain": "1", + "destChain": "21", + "destAddress": "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1", + "fromTokenAddress": "0x0000000000000000000000000000000000000000", + "fromTokenChain": "2", + "fromTokenSymbol": "ETH", + "fromAmount": "0.01", + "fromAmount64": "18124254", + "toTokenAddress": "0x2::sui::SUI", + "toTokenChain": "21", + "toTokenSymbol": "SUI", + "stateAddr": "F4udcUFCjJg8WakNkXP6Sfk5vuaL1WAAeqfhPYgcvYCF", + "stateNonce": null, + "toAmount": "7.534906306", + "estimateMarketToAmount": null, + "transferSignedVaa": null, + "swapSignedVaa": "01000000040d00a51d319390668c037e0221f9de3078a0cacd6849b68abbcf0f7c197e38c341711c2325fd3c29bbd3f523f88a2ad061664c50976877fde27e3f9a8f7e458976770101123f2d788112a27b606dcf6937a475dd4eae874574c54d473de73c64710a92ca04cebb518475b957f330414b3ad8546515beeda7092cb5c9c2d888692f96ebd200029941e5b72683895a2a00abff095afbbc84b4b1b35fd95af6b5b58bc18059c9c23979ab8ec46ceb7419a6aea4db221294ba6f2841a29e79da1130f45c0bcf3aa900038205b50f4071a56cb855b59707b0a69704b2ef02f69bb3c945f35a2c6939e34573f0a8d4f5af3616a9d97d8cfc7b2732f396e1773341d86ffd4d28d47eb02b33010680d652a77a6307981711a335ba685d2f64970c5578b996b4a9c32b6a03807da565a6ccb0d936a34cde386f24aac75ce270db9bf14a47f16f646deaba6a461e010107a36f3adbc6d155320f61b0110f9b84c14414676b4212cf3affdf7551b998495b25d2be1d714d9bf15c1a7b031c3f722db310e0e25e4baadfecb6bb40acce4d570008e1e79ce32ba5f41fc76b0250c3f7dbae3b2938458d61a0f66bbb514d98cd1d5c510d9be1a0cff0922346ae8ae10f31d116c5e8a0cf5f8c84120285cde69f79090009d8251d6210d49d7fa06507ea3d89d177c3863ca493b3a375d8cfc4dac7f735fa6fddb4371327e54eb7ce2a3fe64b8001f03093a8fc14754abd016988acf51325000a14f577a6c86c287fe65ec89b6a5e5591cab34c60ea41895b47cc5881a5555f8632db7667e486290382b5ab1543c2c9cd0d4a0c06182e594175a1996b7f1ff05f010d4890940eea0886702292b098f179cf77bbef3adb7df3dc0399974e9d931632a63b508f8d2b339909b29bdc44e0ffdaf691d2a4b4367dbcb63ea79e6109dd4f36000fe28137ea1a8bf4933ecd4ec99ef2e7c2b8c409973792a10b543f32540d80c2c815278adde3b1137979be4adef23d3b51ecd4a1633fc028564ae94d75257b2a38011084381a0cfd6ea3c39a8f29735cc7e5f1d76929479adcb2bae215173f4922e98a0d2740ad85dd2a232de47601404442a8248d8fa2c8a69f62744079353a00e78f01121bda11dc1f19b3ac27d788b7ae5323d11e7b54ca52a4111dfbf90dd3734a0b576965614c264bb33f534b90bbbf76628fed8b906fe8fb50344fe6acde83d089980067ee5d63000000000002000000000000000000000000875d6d37ec55c8cf220b9e5080717549d8aa8eca0000000000000bea011560fba8f239ca6ce11fa9b81244d02ec1efa1d28d146963ce40ad85c85efcac", + "redeemSignedVaa": null, + "refundSignedVaa": null, + "fulfillSignedVaa": null, + "savedAt": "2025-04-03T10:05:54.092Z", + "initiatedAt": "2025-04-03T10:05:23.000Z", + "completedAt": "2025-04-03T10:25:37.323Z", + "insufficientFees": false, + "retries": 0, + "swapRelayerFee": "0", + "redeemRelayerFee": "0.860592", + "refundRelayerFee": "0", + "submissionRelayerFee": null, + "statusUpdatedAt": "2025-04-03T10:25:37.323Z", + "syncRequestedAt": "2025-04-03T10:24:11.684Z", + "bridgeFee": "0", + "redeemTxHash": "GLs1TUZ6jQdWBBDHVBYFumaBMf6kVNcb2NxQnapXqJUL", + "refundTxHash": null, + "fulfillTxHash": "GLs1TUZ6jQdWBBDHVBYFumaBMf6kVNcb2NxQnapXqJUL", + "createTxHash": null, + "unlockTxHash": null, + "auctionAddress": "Awm2zSgzMGTRraAVjRvshqLehy7mJ2Qr3maURDsoDmwi", + "unwrapRedeem": false, + "unwrapRefund": false, + "driverAddress": null, + "relayerAddress": null, + "auctionMode": null, + "stateOpen": null, + "cctpReceivedSuiId": "0x0b861d294e99b9965ad50a6f6e36935b1c39cd471e7eca564ba1be94ca724917", + "cctpMessage": "00000000000000000000000800000000000372e6000000000000000000000000bd3fa81b58ba92a82136038b25adec7066af3155afdc792c79ad11215d6661cc06320d48b9f7dd46f4e9d4901c45986fa430e3c8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4839bb1dfbe50fbafa713369a6203ab56705fcbfc98cc6473f28ac57503479844b0000000000000000000000000000000000000000000000000000000001148dde000000000000000000000000875d6d37ec55c8cf220b9e5080717549d8aa8eca", + "cctpMessageHash": "1814361e516de45de391ab0b68093d2fcb7671e1201e223797ff59022d91a65e", + "cctpSolMessageAccount": null, + "cctpNonce": "226022", + "cctpAttestation": "bd7ea7f7e1f99f89a0887560f4cb123e7d33290fcf0d339a66c28bd75177272b196169275aad984cf648fc0281099d3a908278adf020b3735a292cb47580c35b1ce1d85c7f8cdcdea52d3e03da79bad956a4faa9a3f2f3fa61b3931edb0c15ac507349e9c08110f68ed4794c3b72a993d16c37e19e509cc756c7fae4ebc02256581b", + "batchFulfilled": null, + "gaslessSignature": null, + "gaslessPermit": null, + "gaslessTx": null, + "gasless": null, + "initiateContractAddress": "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2", + "posAddress": "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2", + "mayanAddress": "0x875d6d37EC55c8cF220B9E5080717549d8Aa8EcA", + "mayanBps": 3, + "referrerAddress": "0x9d6b98b18fd26b5efeec68d020dcf1be7a94c2c315353779bc6b3aed44188ddf", + "referrerBps": 50, + "unlockRecipient": null, + "lockedFundObjectId": null, + "forwardedFromAmount": "0.01", + "forwardedTokenAddress": "0x0000000000000000000000000000000000000000", + "forwardedTokenSymbol": "ETH", + "penaltyPeriod": null, + "baseBond": null, + "perBpsBond": null, + "gasDrop": "0", + "gasDrop64": "0", + "payloadId": 1, + "orderHash": "1560fba8f239ca6ce11fa9b81244d02ec1efa1d28d146963ce40ad85c85efcac", + "randomKey": null, + "auctionStateAddr": null, + "auctionStateNonce": null, + "minAmountOut": "7.22009332", + "minAmountOut64": "722009332", + "service": "MCTP_SWAP", + "toTokenPrice": 2.2914626, + "fromTokenPrice": 0.9999516900000001, + "redeemTxFee": null, + "refundTxFee": null, + "fromChainNativeTokenPrice": 1812.81376306, + "toChainNativeTokenPrice": 2.2914626, + "customPayload": null, + "refundAmount": "18.124254", + "refundTokenLogoUri": "https://statics.mayan.finance/eth.png", + "refundTokenScannerUrl": "https://etherscan.io/token/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "refundTokenSymbol": "USDC", + "fromTokenLogoUri": "https://statics.mayan.finance/eth.png", + "toTokenLogoUri": "https://cdn.mayan.finance/sui_coin.png", + "fromTokenScannerUrl": "https://etherscan.io/token/0x0000000000000000000000000000000000000000", + "toTokenScannerUrl": "https://suiscan.xyz/mainnet/coin/0x2::sui::SUI/txs", + "txs": [ + { + "txHash": "0x56acc6a58fc0bdd9e9be5cc2a3ff079b91b933f562cf0fe760f1d8d6b76f4876", + "goals": [ + "SEND" + ], + "scannerUrl": "https://etherscan.io/tx/0x56acc6a58fc0bdd9e9be5cc2a3ff079b91b933f562cf0fe760f1d8d6b76f4876" + }, + { + "txHash": "GLs1TUZ6jQdWBBDHVBYFumaBMf6kVNcb2NxQnapXqJUL", + "goals": [ + "SETTLE" + ], + "scannerUrl": "https://suiscan.xyz/mainnet/tx/GLs1TUZ6jQdWBBDHVBYFumaBMf6kVNcb2NxQnapXqJUL" + } + ], + "steps": [ + { + "title": "Transfer 0.01 ETH from ethereum", + "status": "COMPLETED", + "type": "INFO" + }, + { + "title": "Get CCTP attestation", + "status": "COMPLETED", + "type": "BLOCK_COUNTER", + "meta": { + "startBlock": 22187675, + "wChainId": 2, + "minimumConfirmation": 100 + } + }, + { + "title": "Transfer 7.534906306 SUI to sui", + "status": "COMPLETED", + "type": "INFO" + } + ], + "clientStatus": "COMPLETED", + "refundChain": "21", + "refundTokenAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "clientRelayerFeeSuccess": "0.860592", + "clientRelayerFeeRefund": "0.860592" +} \ No newline at end of file diff --git a/core/crates/swapper/src/mayan/test/fast_mctp_quote.json b/core/crates/swapper/src/mayan/test/fast_mctp_quote.json new file mode 100644 index 0000000000..9e43ba8a37 --- /dev/null +++ b/core/crates/swapper/src/mayan/test/fast_mctp_quote.json @@ -0,0 +1,33 @@ +{ + "type": "FAST_MCTP", + "effectiveAmountIn64": "1000000", + "expectedAmountOut": 0.9, + "minAmountOut": 0.89, + "gasDrop": 0, + "etaSeconds": 20, + "fromToken": { + "contract": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "wChainId": 1, + "decimals": 6 + }, + "toToken": { + "contract": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "wChainId": 30, + "decimals": 6 + }, + "fromChain": "solana", + "toChain": "base", + "slippageBps": 50, + "deadline64": "1779326929", + "referrerBps": 50, + "redeemRelayerFee": 0.1, + "redeemRelayerFee64": "100000", + "refundRelayerFee64": "1000", + "minMiddleAmount": 0.99, + "hasAuction": true, + "fastMctpMayanContract": "0x875d6d37EC55c8cF220B9E5080717549d8Aa8EcA", + "fastMctpInputContract": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "fastMctpMinFinality": 1000, + "circleMaxFee64": "500", + "solanaRelayerFee64": "179182" +} diff --git a/core/crates/swapper/src/mayan/test/mctp_pending.json b/core/crates/swapper/src/mayan/test/mctp_pending.json new file mode 100644 index 0000000000..6835cd883f --- /dev/null +++ b/core/crates/swapper/src/mayan/test/mctp_pending.json @@ -0,0 +1,146 @@ +{ + "id": "dab51129-0293-4618-842e-c1aafda841de", + "trader": "0x867D8D20009B8b875C46dB5904c2CB40380587CD", + "traderLedger": null, + "sourceTxHash": "0xf151c62aa530d86de11d5c2691b97fc67f0af52e474f9f2abae7fcf794f48594", + "orderId": "MCTP_0xf151c62aa530d86de11d5c2691b97fc67f0af52e474f9f2abae7fcf794f48594__0", + "sourceTxBlockNo": 28475094, + "status": "INITIATED_ON_EVM_MCTP", + "transferSequence": "-1", + "swapSequence": "6450", + "redeemSequence": null, + "refundSequence": null, + "fulfillSequence": null, + "deadline": "2025-04-11T04:05:39.198Z", + "sourceChain": "30", + "swapChain": "1", + "destChain": "23", + "destAddress": "0x867d8d20009b8b875c46db5904c2cb40380587cd", + "fromTokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "fromTokenChain": "30", + "fromTokenSymbol": "USDC", + "fromAmount": "529.066169", + "fromAmount64": "529066169", + "toTokenAddress": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "toTokenChain": "23", + "toTokenSymbol": "USDC", + "stateAddr": null, + "stateNonce": null, + "toAmount": "529.033933", + "estimateMarketToAmount": null, + "transferSignedVaa": null, + "swapSignedVaa": null, + "redeemSignedVaa": null, + "refundSignedVaa": null, + "fulfillSignedVaa": null, + "savedAt": "2025-04-04T04:05:39.203Z", + "initiatedAt": "2025-04-04T04:05:35.000Z", + "completedAt": null, + "insufficientFees": false, + "retries": 0, + "swapRelayerFee": "0", + "redeemRelayerFee": "0.032236", + "refundRelayerFee": "0", + "submissionRelayerFee": null, + "statusUpdatedAt": "2025-04-04T04:05:39.203Z", + "syncRequestedAt": "2025-04-04T04:11:41.849Z", + "bridgeFee": "0", + "redeemTxHash": null, + "refundTxHash": null, + "fulfillTxHash": null, + "createTxHash": null, + "unlockTxHash": null, + "auctionAddress": "Awm2zSgzMGTRraAVjRvshqLehy7mJ2Qr3maURDsoDmwi", + "unwrapRedeem": false, + "unwrapRefund": false, + "driverAddress": null, + "relayerAddress": null, + "auctionMode": null, + "stateOpen": null, + "cctpReceivedSuiId": null, + "cctpMessage": "000000000000000600000003000000000007da720000000000000000000000001682ae6375c4e4a97e4b583bc394c861a46d896200000000000000000000000019330d10d9cc8751218eaf51e8885d058642e08a000000000000000000000000875d6d37ec55c8cf220b9e5080717549d8aa8eca00000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000875d6d37ec55c8cf220b9e5080717549d8aa8eca000000000000000000000000000000000000000000000000000000001f88e8b9000000000000000000000000875d6d37ec55c8cf220b9e5080717549d8aa8eca", + "cctpMessageHash": "fb001a71fb1f03324164b634c9c284220d1de37fb98f80ae876ae9b531113ab8", + "cctpSolMessageAccount": null, + "cctpNonce": "514674", + "cctpAttestation": null, + "batchFulfilled": null, + "gaslessSignature": null, + "gaslessPermit": null, + "gaslessTx": null, + "gasless": null, + "initiateContractAddress": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE", + "posAddress": "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2", + "mayanAddress": "0x875d6d37EC55c8cF220B9E5080717549d8Aa8EcA", + "mayanBps": null, + "referrerAddress": null, + "referrerBps": null, + "unlockRecipient": null, + "lockedFundObjectId": null, + "forwardedFromAmount": null, + "forwardedTokenAddress": null, + "forwardedTokenSymbol": null, + "penaltyPeriod": null, + "baseBond": null, + "perBpsBond": null, + "gasDrop": "0", + "gasDrop64": "0", + "payloadId": 1, + "orderHash": null, + "randomKey": null, + "auctionStateAddr": null, + "auctionStateNonce": null, + "minAmountOut": null, + "minAmountOut64": null, + "service": "MCTP_BRIDGE", + "toTokenPrice": 0.9999797, + "fromTokenPrice": 0.9999797, + "redeemTxFee": null, + "refundTxFee": null, + "fromChainNativeTokenPrice": 1791.1836677, + "toChainNativeTokenPrice": 1791.1836677, + "customPayload": "0x0000000000000000000000000000000000000000000000000000000000000000", + "refundAmount": "529.066169", + "refundTokenLogoUri": "https://assets.coingecko.com/coins/images/6319/small/usdc.png?1696506694", + "refundTokenScannerUrl": "https://basescan.org/token/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "refundTokenSymbol": "USDC", + "fromTokenLogoUri": "https://assets.coingecko.com/coins/images/6319/small/usdc.png?1696506694", + "toTokenLogoUri": "https://assets.coingecko.com/coins/images/6319/small/usdc.png?1696506694", + "fromTokenScannerUrl": "https://basescan.org/token/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "toTokenScannerUrl": "https://arbiscan.io/token/0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "txs": [ + { + "txHash": "0xf151c62aa530d86de11d5c2691b97fc67f0af52e474f9f2abae7fcf794f48594", + "goals": [ + "SEND" + ], + "scannerUrl": "https://basescan.org/tx/0xf151c62aa530d86de11d5c2691b97fc67f0af52e474f9f2abae7fcf794f48594" + } + ], + "steps": [ + { + "title": "Transfer 529.066169 USDC from base", + "status": "COMPLETED", + "type": "INFO" + }, + { + "title": "Get CCTP attestation", + "status": "ACTIVE", + "type": "BLOCK_COUNTER", + "meta": { + "startBlock": 28475094, + "wChainId": 30, + "minimumConfirmation": 10000 + } + }, + { + "title": "Redeem trade on arbitrum", + "status": "PENDING", + "type": "INFO" + } + ], + "clientStatus": "INPROGRESS", + "refundChain": "23", + "refundTokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "clientRelayerFeeSuccess": "0.032236", + "clientRelayerFeeRefund": "0.032236" +} \ No newline at end of file diff --git a/core/crates/swapper/src/mayan/test/pol_to_bnb_swift.json b/core/crates/swapper/src/mayan/test/pol_to_bnb_swift.json new file mode 100644 index 0000000000..70551250ce --- /dev/null +++ b/core/crates/swapper/src/mayan/test/pol_to_bnb_swift.json @@ -0,0 +1,146 @@ +{ + "id": "cec77e92-1b4a-4ea1-9d16-77897df3e14b", + "trader": "0xF5421eFCe6a6FEde2AB38bAA02e9E87BF16ef534", + "traderLedger": null, + "sourceTxHash": "0xd854dc62d4602bc82e360e8825fd1f8f97635974d27278252b418a05a6a4b83d", + "orderId": "SWIFT_V2_0xb7b477ca7152e86ae6efd45e1530e0c7e629a0a707e6a63310a5c194b832c15c", + "sourceTxBlockNo": 83787645, + "status": "ORDER_SETTLED", + "transferSequence": null, + "swapSequence": null, + "redeemSequence": null, + "refundSequence": null, + "fulfillSequence": null, + "deadline": "2026-03-05T07:42:14.000Z", + "sourceChain": "5", + "swapChain": "1", + "destChain": "4", + "destAddress": "0xf5421efce6a6fede2ab38baa02e9e87bf16ef534", + "fromTokenAddress": "0x0000000000000000000000000000000000000000", + "fromTokenChain": "5", + "fromTokenSymbol": "POL", + "fromAmount": "212", + "fromAmount64": "21782666", + "toTokenAddress": "0x0000000000000000000000000000000000000000", + "toTokenChain": "4", + "toTokenSymbol": "BNB", + "sourceStateAddr": null, + "stateAddr": "73XpYuzRXgwdaZxBg9JdM5GVCPTD37amtvUU9S9LSXyL", + "stateNonce": "254", + "toAmount": "0.033060513057817862", + "toAmount64": "33060513057817862", + "estimateMarketToAmount": "0.03322546447500412", + "transferSignedVaa": null, + "swapSignedVaa": null, + "redeemSignedVaa": null, + "refundSignedVaa": null, + "fulfillSignedVaa": null, + "savedAt": "2026-03-05T07:22:02.087Z", + "initiatedAt": "2026-03-05T07:22:01.000Z", + "completedAt": "2026-03-05T07:22:34.000Z", + "attestedAt": "2026-03-05T07:22:34.000Z", + "insufficientFees": false, + "retries": 0, + "swapRelayerFee": null, + "redeemRelayerFee": "0.016195", + "refundRelayerFee": "0.002451", + "submissionRelayerFee": null, + "statusUpdatedAt": "2026-03-05T07:22:35.357Z", + "syncRequestedAt": null, + "bridgeFee": "0", + "redeemTxHash": null, + "refundTxHash": null, + "fulfillTxHash": "0x51df554e8abf0d366dbb321fd9834dcd723cc21aa2d701a656d66342b80969fc", + "createTxHash": "0xd854dc62d4602bc82e360e8825fd1f8f97635974d27278252b418a05a6a4b83d", + "unlockTxHash": null, + "postedBatchUnlockIndex": null, + "auctionAddress": "9bh7SPjkNPgmq7HHWQxgCFJEnMPvAPdLcBEQL1FSG1YR", + "unwrapRedeem": null, + "unwrapRefund": null, + "driverAddress": "0x754DcfB2861547015B221e963b4133A71dBdC024", + "relayerAddress": null, + "auctionMode": 2, + "stateOpen": null, + "cctpReceivedSuiId": null, + "cctpMessage": null, + "cctpMessageHash": null, + "cctpSolMessageAccount": null, + "cctpNonce": null, + "cctpAttestation": null, + "batchFulfilled": true, + "gaslessSignature": null, + "gaslessPermit": null, + "gaslessTx": null, + "gasless": null, + "initiateContractAddress": "0x337685fdab40d39bd02028545a4ffa7d287cc3e2", + "posAddress": "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2", + "mayanAddress": "0x40fFE85A28DC9993541449464d7529a922142960", + "mayanBps": 3, + "referrerAddress": "0x0d9dab1a248f63b0a48965ba8435e4de7497a3dc", + "referrerBps": 50, + "unlockRecipient": null, + "lockedFundObjectId": null, + "forwardedFromAmount": "212", + "forwardedTokenAddress": "0x0000000000000000000000000000000000000000", + "forwardedTokenSymbol": "POL", + "penaltyPeriod": null, + "baseBond": null, + "perBpsBond": null, + "gasDrop": "0", + "gasDrop64": "0", + "payloadId": 1, + "orderHash": "0xb7b477ca7152e86ae6efd45e1530e0c7e629a0a707e6a63310a5c194b832c15c", + "randomKey": "0x67e2fa3ac0a06325ffd7966bea87043e4442eb6527c3f9bb060bb7260622acb0", + "auctionStateAddr": "45ww3z7Kxcnwp43JfPsFNzrZ2J3qpnCAbp1njckG57a3", + "auctionStateNonce": "254", + "minAmountOut": "0.03267994", + "minAmountOut64": "3267994", + "service": "SWIFT_V2", + "toTokenPrice": 654.35871732, + "fromTokenPrice": 1.00010471, + "redeemTxFee": null, + "refundTxFee": null, + "fromChainNativeTokenPrice": 0.10285495, + "toChainNativeTokenPrice": 654.35871732, + "customPayload": null, + "meta": null, + "refundAmount": "0.033060513057817862", + "refundTokenLogoUri": "https://assets.coingecko.com/coins/images/53705/standard/usdt0.jpg?1737086183", + "refundTokenScannerUrl": "https://polygonscan.com/token/0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "refundTokenSymbol": "USD₮0", + "fromTokenLogoUri": "https://statics.mayan.finance/polygon.png", + "toTokenLogoUri": "https://statics.mayan.finance/bsc.png", + "fromTokenScannerUrl": "https://polygonscan.com/token/0x0000000000000000000000000000000000000000", + "toTokenScannerUrl": "https://bscscan.com/token/0x0000000000000000000000000000000000000000", + "txs": [ + { + "txHash": "0xd854dc62d4602bc82e360e8825fd1f8f97635974d27278252b418a05a6a4b83d", + "goals": ["SEND"], + "scannerUrl": "https://polygonscan.com/tx/0xd854dc62d4602bc82e360e8825fd1f8f97635974d27278252b418a05a6a4b83d" + }, + { + "txHash": "0x51df554e8abf0d366dbb321fd9834dcd723cc21aa2d701a656d66342b80969fc", + "goals": ["FULFILL"], + "scannerUrl": "https://bscscan.com/tx/0x51df554e8abf0d366dbb321fd9834dcd723cc21aa2d701a656d66342b80969fc" + } + ], + "steps": [ + { + "title": "Order created with 212 POL on Polygon", + "status": "COMPLETED", + "type": "INFO" + }, + { + "title": "Fullfill 0.033060513057817862 BNB on Bsc", + "status": "COMPLETED", + "type": "INFO" + } + ], + "clientStatus": "COMPLETED", + "refundChain": "5", + "refundTokenAddress": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "clientRelayerFeeSuccess": null, + "clientRelayerFeeRefund": null, + "protocolFeeUsd": 0.006535484058887057, + "referrerFeeUsd": 0.10892473431478429 +} diff --git a/core/crates/swapper/src/mayan/test/quote_response_swift.json b/core/crates/swapper/src/mayan/test/quote_response_swift.json new file mode 100644 index 0000000000..51a45dc5dc --- /dev/null +++ b/core/crates/swapper/src/mayan/test/quote_response_swift.json @@ -0,0 +1,26 @@ +{ + "quotes": [ + { + "type": "SWIFT", + "effectiveAmountIn64": "50000000000000000", + "expectedAmountOut": 1.2, + "minAmountOut": 1.1, + "gasDrop": 0, + "etaSeconds": 3, + "fromToken": { + "contract": "0x0000000000000000000000000000000000000000", + "wChainId": 2, + "decimals": 18 + }, + "toToken": { + "contract": "0x0000000000000000000000000000000000000000", + "wChainId": 1, + "decimals": 9 + }, + "fromChain": "ethereum", + "toChain": "solana", + "slippageBps": 75, + "swiftVersion": "V2" + } + ] +} diff --git a/core/crates/swapper/src/mayan/test/quote_response_swift_hypercore.json b/core/crates/swapper/src/mayan/test/quote_response_swift_hypercore.json new file mode 100644 index 0000000000..0979c83841 --- /dev/null +++ b/core/crates/swapper/src/mayan/test/quote_response_swift_hypercore.json @@ -0,0 +1,27 @@ +{ + "quotes": [ + { + "type": "SWIFT", + "effectiveAmountIn64": "7000000", + "expectedAmountOut": 6.023337, + "expectedAmountOutBaseUnits": "6023337", + "minAmountOut": 6.520075, + "gasDrop": 0, + "etaSeconds": 3, + "fromToken": { + "contract": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "wChainId": 23, + "decimals": 6 + }, + "toToken": { + "contract": "0x000000000000000000000000000000000000ffff", + "wChainId": 65000, + "decimals": 6 + }, + "fromChain": "arbitrum", + "toChain": "hypercore", + "slippageBps": 5, + "swiftVersion": "V2" + } + ] +} diff --git a/core/crates/swapper/src/mayan/test/quote_swift_solana.json b/core/crates/swapper/src/mayan/test/quote_swift_solana.json new file mode 100644 index 0000000000..31dfced419 --- /dev/null +++ b/core/crates/swapper/src/mayan/test/quote_swift_solana.json @@ -0,0 +1,38 @@ +{ + "effectiveAmountIn64": "5000000", + "expectedAmountOut": 4.98, + "minAmountOut": 4.9, + "gasDrop": 0, + "etaSeconds": 3, + "fromToken": { + "contract": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "wChainId": 1, + "decimals": 6 + }, + "toToken": { + "contract": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "wChainId": 30, + "decimals": 6 + }, + "fromChain": "solana", + "toChain": "base", + "slippageBps": 75, + "deadline64": "1778652330", + "referrerBps": 50, + "expectedAmountOutBaseUnits": "4980000", + "refundRelayerFee64": "4325", + "cancelRelayerFee64": "456", + "submitRelayerFee64": "1000", + "protocolBps": 3, + "minMiddleAmount": 4.95, + "swiftAuctionMode": 2, + "swiftInputContract": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "swiftInputDecimals": 6, + "swiftInputContractStandard": "spl", + "swiftWrapAndLock": false, + "swiftVersion": "V2", + "suggestedPriorityFee": 0, + "gasless": false, + "quoteId": "0x01", + "memoHex": "0x02" +} diff --git a/core/crates/swapper/src/mayan/test/sui_client_swap.json b/core/crates/swapper/src/mayan/test/sui_client_swap.json new file mode 100644 index 0000000000..bc52409cd2 --- /dev/null +++ b/core/crates/swapper/src/mayan/test/sui_client_swap.json @@ -0,0 +1,14 @@ +{ + "tx": "{\"version\":2,\"inputs\":[],\"commands\":[]}", + "outCoin": { + "$kind": "Result", + "Result": 4 + }, + "whFeeCoin": { + "$kind": "NestedResult", + "NestedResult": [ + 0, + 0 + ] + } +} diff --git a/core/crates/swapper/src/mayan/test/swift_quote_evm_to_solana.json b/core/crates/swapper/src/mayan/test/swift_quote_evm_to_solana.json new file mode 100644 index 0000000000..5c6672f148 --- /dev/null +++ b/core/crates/swapper/src/mayan/test/swift_quote_evm_to_solana.json @@ -0,0 +1,52 @@ +{ + "type": "SWIFT", + "effectiveAmountIn64": "50000000000000000", + "expectedAmountOut": 1.2, + "minAmountOut": 1.18381871, + "gasDrop": 0, + "etaSeconds": 3, + "fromToken": { + "contract": "0x0000000000000000000000000000000000000000", + "wChainId": 2, + "decimals": 18, + "verifiedAddress": null + }, + "toToken": { + "contract": "0x0000000000000000000000000000000000000000", + "wChainId": 1, + "decimals": 9, + "verifiedAddress": null + }, + "fromChain": "ethereum", + "toChain": "solana", + "slippageBps": 75, + "deadline64": "1778652330", + "referrerBps": 50, + "refundRelayerFee64": "4325", + "cancelRelayerFee64": "456", + "submitRelayerFee64": "1000", + "protocolBps": 3, + "minMiddleAmount": 0.04975, + "hasAuction": null, + "cheaperChain": null, + "bridgeFee": null, + "redeemRelayerFee": null, + "mctpInputContract": null, + "mctpVerifiedInputAddress": null, + "mctpInputTreasury": null, + "swiftMayanContract": "0x40fFE85A28DC9993541449464d7529a922142960", + "swiftAuctionMode": 2, + "swiftInputContract": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "swiftInputDecimals": 18, + "swiftInputContractStandard": "erc20", + "swiftWrapAndLock": true, + "swiftVersion": "V2", + "hcSwiftDeposit": null, + "suggestedPriorityFee": null, + "gasless": false, + "quoteId": "0x01", + "expectedAmountOutBaseUnits": "1192749421", + "maxSwapAccounts": null, + "maxSwapDataLength": null, + "memoHex": "0x02" +} diff --git a/core/crates/swapper/src/mayan/test/swift_refunded.json b/core/crates/swapper/src/mayan/test/swift_refunded.json new file mode 100644 index 0000000000..ac6c635858 --- /dev/null +++ b/core/crates/swapper/src/mayan/test/swift_refunded.json @@ -0,0 +1,181 @@ +{ + "id": "aa5dea3b-747f-40ed-a319-a3bd27b9f468", + "trader": "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7", + "traderLedger": null, + "sourceTxHash": "0xcb10f22a381a463e748e10013575a502b7b61489435bb8581b16eb9eed3bbc9a", + "orderId": "SWIFT_0xa2142d256b229f8583dbc7d93fdd167038da67b4fa6586bd446b0ee7425c431d", + "sourceTxBlockNo": 22167246, + "status": "ORDER_REFUNDED", + "transferSequence": null, + "swapSequence": null, + "redeemSequence": null, + "refundSequence": "145551", + "fulfillSequence": null, + "deadline": "2025-03-31T15:42:45.000Z", + "sourceChain": "2", + "swapChain": "1", + "destChain": "1", + "destAddress": "A21o4asMbFHYadqXdLusT9Bvx9xaC5YV9gcaidjqtdXC", + "fromTokenAddress": "0x0000000000000000000000000000000000000000", + "fromTokenChain": "2", + "fromTokenSymbol": "ETH", + "fromAmount": "0.004", + "fromAmount64": "4000000000000000", + "toTokenAddress": "0x0000000000000000000000000000000000000000", + "toTokenChain": "1", + "toTokenSymbol": "SOL", + "stateAddr": "JAaWGcS4Kqbwyv1fCduMmBd2ab45Ja2twYbFHQgFeQrk", + "stateNonce": "255", + "toAmount": "0.00334223", + "estimateMarketToAmount": null, + "transferSignedVaa": null, + "swapSignedVaa": null, + "redeemSignedVaa": null, + "refundSignedVaa": null, + "fulfillSignedVaa": null, + "savedAt": "2025-03-31T13:38:12.519Z", + "initiatedAt": "2025-03-31T13:37:59.000Z", + "completedAt": "2025-03-31T15:47:12.516Z", + "insufficientFees": false, + "retries": 0, + "swapRelayerFee": null, + "redeemRelayerFee": "0.0002717", + "refundRelayerFee": "0.00038607", + "submissionRelayerFee": null, + "statusUpdatedAt": "2025-03-31T15:47:12.516Z", + "syncRequestedAt": "2025-03-31T15:46:11.148Z", + "bridgeFee": "0", + "redeemTxHash": null, + "refundTxHash": "0xfb70313c44c1ce1e476e83c11b6948fc2d970a2c6147a0d3e27afb0e25c250c1", + "fulfillTxHash": null, + "createTxHash": null, + "unlockTxHash": null, + "auctionAddress": "9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H", + "unwrapRedeem": null, + "unwrapRefund": null, + "driverAddress": null, + "relayerAddress": "B88xH3Jmhq4WEaiRno2mYmsxV35MmgSY45ZmQnbL8yft", + "auctionMode": 1, + "stateOpen": false, + "cctpReceivedSuiId": null, + "cctpMessage": null, + "cctpMessageHash": null, + "cctpSolMessageAccount": null, + "cctpNonce": null, + "cctpAttestation": null, + "batchFulfilled": null, + "gaslessSignature": null, + "gaslessPermit": null, + "gaslessTx": null, + "gasless": null, + "initiateContractAddress": "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2", + "posAddress": "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2", + "mayanAddress": "0xC38e4e6A15593f908255214653d3D947CA1c2338", + "mayanBps": 50, + "referrerAddress": "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy", + "referrerBps": 50, + "unlockRecipient": null, + "lockedFundObjectId": null, + "forwardedFromAmount": null, + "forwardedTokenAddress": null, + "forwardedTokenSymbol": null, + "penaltyPeriod": null, + "baseBond": null, + "perBpsBond": null, + "gasDrop": "0", + "gasDrop64": "0", + "payloadId": null, + "orderHash": "0xa2142d256b229f8583dbc7d93fdd167038da67b4fa6586bd446b0ee7425c431d", + "randomKey": "0x8f778bb5467a8ba917b8927a419ddb4dae9a2278323923c7e03008df725eede5", + "auctionStateAddr": "Dkr4MQtfyJaM8c5ApNM3Lh2BkaCQDWNK24CJ3JfqXx5N", + "auctionStateNonce": "252", + "minAmountOut": "0.50132595", + "minAmountOut64": "50132595", + "service": "SWIFT_SWAP", + "toTokenPrice": 124.7854625, + "fromTokenPrice": 1810.73022616, + "redeemTxFee": null, + "refundTxFee": null, + "fromChainNativeTokenPrice": 1810.73022616, + "toChainNativeTokenPrice": 124.7854625, + "customPayload": null, + "refundAmount": "0.00334223", + "refundTokenLogoUri": "https://statics.mayan.finance/eth.png", + "refundTokenScannerUrl": "https://etherscan.io/token/0x0000000000000000000000000000000000000000", + "refundTokenSymbol": "ETH", + "fromTokenLogoUri": "https://statics.mayan.finance/eth.png", + "toTokenLogoUri": "https://statics.mayan.finance/SOL.png", + "fromTokenScannerUrl": "https://etherscan.io/token/0x0000000000000000000000000000000000000000", + "toTokenScannerUrl": "https://solscan.io/token/0x0000000000000000000000000000000000000000", + "txs": [ + { + "txHash": "0xcb10f22a381a463e748e10013575a502b7b61489435bb8581b16eb9eed3bbc9a", + "goals": [ + "SEND" + ], + "scannerUrl": "https://etherscan.io/tx/0xcb10f22a381a463e748e10013575a502b7b61489435bb8581b16eb9eed3bbc9a" + }, + { + "txHash": "3GG6eTGy41EFd7mLbYGjtSQzwySbcM3UyzpA2MSLBrhTQycA65MAcpuGNcTzHZLnEBqoGCESdxv2MhtBcgrr6AY6", + "goals": [ + "CLOSE" + ], + "scannerUrl": "https://solscan.io/tx/3GG6eTGy41EFd7mLbYGjtSQzwySbcM3UyzpA2MSLBrhTQycA65MAcpuGNcTzHZLnEBqoGCESdxv2MhtBcgrr6AY6" + }, + { + "txHash": "2bQ1n8xzay9wowERzfa8ErN7wXA3neTcaB54TP5NVPosA7YvZyi477XGSnsE7qzbN4uYJBGcPPv7eiouBDnPGk1a", + "goals": [ + "CANCEL" + ], + "scannerUrl": "https://solscan.io/tx/2bQ1n8xzay9wowERzfa8ErN7wXA3neTcaB54TP5NVPosA7YvZyi477XGSnsE7qzbN4uYJBGcPPv7eiouBDnPGk1a" + }, + { + "txHash": "4rVNdcPF8a6t4TDog5pZHwWRUzM3sbja68eXCy5CZXZ8XSTapcz3jgJ7PuMHhXaqnVs9nzJgpeEP1mh9rDzBTEV6", + "goals": [ + "REGISTER_ORDER" + ], + "scannerUrl": "https://solscan.io/tx/4rVNdcPF8a6t4TDog5pZHwWRUzM3sbja68eXCy5CZXZ8XSTapcz3jgJ7PuMHhXaqnVs9nzJgpeEP1mh9rDzBTEV6" + }, + { + "txHash": "oZSHVjoUS56rpcm2iNsLaNLtFfk3dq8q4rueZsb5WcVztopqiurtaxFLSC8fDBN3F2o8HndhFp2BVa34NsuXqZY", + "goals": [ + "REGISTER_ORDER" + ], + "scannerUrl": "https://solscan.io/tx/oZSHVjoUS56rpcm2iNsLaNLtFfk3dq8q4rueZsb5WcVztopqiurtaxFLSC8fDBN3F2o8HndhFp2BVa34NsuXqZY" + }, + { + "txHash": "0xfb70313c44c1ce1e476e83c11b6948fc2d970a2c6147a0d3e27afb0e25c250c1", + "goals": [ + "SETTLE" + ], + "scannerUrl": "https://etherscan.io/tx/0xfb70313c44c1ce1e476e83c11b6948fc2d970a2c6147a0d3e27afb0e25c250c1" + } + ], + "steps": [ + { + "title": "Order created with 0.004 ETH on ethereum", + "status": "COMPLETED", + "type": "INFO" + }, + { + "title": "Fullfill SOL on solana", + "status": "FAILED", + "type": "INFO" + }, + { + "title": "Order canceled", + "status": "COMPLETED", + "type": "INFO" + }, + { + "title": "Order refunded on ethereum", + "status": "COMPLETED", + "type": "INFO" + } + ], + "clientStatus": "REFUNDED", + "refundChain": "2", + "refundTokenAddress": "0x0000000000000000000000000000000000000000", + "clientRelayerFeeSuccess": null, + "clientRelayerFeeRefund": "0.00065777" +} \ No newline at end of file diff --git a/core/crates/swapper/src/mayan/test/usdt_to_owb_swift.json b/core/crates/swapper/src/mayan/test/usdt_to_owb_swift.json new file mode 100644 index 0000000000..b242530987 --- /dev/null +++ b/core/crates/swapper/src/mayan/test/usdt_to_owb_swift.json @@ -0,0 +1,148 @@ +{ + "id": "89028c42-abe5-4dee-84c4-0743278a04fd", + "trader": "0x5108Fda4370F3a64716929E5148ed26441A38d12", + "traderLedger": null, + "sourceTxHash": "0xfb2464f06d38f39a274b2a5e3414dbed43ad405a06295aaeaded8865efc7d4f4", + "orderId": "SWIFT_0x8358dae0ab32bcd0be00e13fce4566c466998f7e321e2108ca3bb2637b7b2d41", + "sourceTxBlockNo": 83809993, + "status": "ORDER_SETTLED", + "transferSequence": null, + "swapSequence": null, + "redeemSequence": null, + "refundSequence": null, + "fulfillSequence": null, + "deadline": "2026-03-05T20:04:49.000Z", + "sourceChain": "5", + "swapChain": "1", + "destChain": "30", + "destAddress": "0x5108fda4370f3a64716929e5148ed26441a38d12", + "fromTokenAddress": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "fromTokenChain": "5", + "fromTokenSymbol": "USD₮0", + "fromAmount": "35.243141", + "fromAmount64": "35245466", + "toTokenAddress": "0xef5997c2cf2f6c138196f8a6203afc335206b3c1", + "toTokenChain": "30", + "toTokenSymbol": "OWB", + "sourceStateAddr": null, + "stateAddr": "7dPy9NtUwtWHtE1w6XUFKucAqpaSRgZM8zDdXCovMPcv", + "stateNonce": "249", + "toAmount": "398.724622644505839482", + "toAmount64": "398724622644505839482", + "estimateMarketToAmount": null, + "transferSignedVaa": null, + "swapSignedVaa": null, + "redeemSignedVaa": null, + "refundSignedVaa": null, + "fulfillSignedVaa": null, + "savedAt": "2026-03-05T19:46:59.216Z", + "initiatedAt": "2026-03-05T19:46:57.000Z", + "completedAt": "2026-03-05T19:47:39.000Z", + "attestedAt": "2026-03-05T19:47:39.000Z", + "insufficientFees": false, + "retries": 0, + "swapRelayerFee": null, + "redeemRelayerFee": "0.00162", + "refundRelayerFee": "0.004285", + "submissionRelayerFee": null, + "statusUpdatedAt": "2026-03-05T19:47:39.805Z", + "syncRequestedAt": null, + "bridgeFee": "0", + "redeemTxHash": null, + "refundTxHash": null, + "fulfillTxHash": "0xe6d6160fc4318fa6200492d8dc170e7729aa6473fecd19cf83b980f441d554e5", + "createTxHash": "0xfb2464f06d38f39a274b2a5e3414dbed43ad405a06295aaeaded8865efc7d4f4", + "unlockTxHash": null, + "postedBatchUnlockIndex": null, + "auctionAddress": "9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H", + "unwrapRedeem": null, + "unwrapRefund": null, + "driverAddress": "0xDfd122610A14Ac12D934898c02dBEc1f72708116", + "relayerAddress": null, + "auctionMode": 2, + "stateOpen": null, + "cctpReceivedSuiId": null, + "cctpMessage": null, + "cctpMessageHash": null, + "cctpSolMessageAccount": null, + "cctpNonce": null, + "cctpAttestation": null, + "batchFulfilled": true, + "gaslessSignature": null, + "gaslessPermit": null, + "gaslessTx": null, + "gasless": null, + "initiateContractAddress": "0x69460570c93f9de5e2edbc3052bf10125f0ca22d", + "posAddress": "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2", + "mayanAddress": "0xC38e4e6A15593f908255214653d3D947CA1c2338", + "mayanBps": 3, + "referrerAddress": "0xc84f14c250128357c82e1b737bf19e6efb1111bc", + "referrerBps": 0, + "unlockRecipient": null, + "lockedFundObjectId": null, + "forwardedFromAmount": "35.243141", + "forwardedTokenAddress": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", + "forwardedTokenSymbol": "USD₮0", + "penaltyPeriod": null, + "baseBond": null, + "perBpsBond": null, + "gasDrop": "0", + "gasDrop64": "0", + "payloadId": null, + "orderHash": "0x8358dae0ab32bcd0be00e13fce4566c466998f7e321e2108ca3bb2637b7b2d41", + "randomKey": "0x8583fe7c280e4a81a294169061ffa96d5ce2c1cfd3cde5d6a8fc1e5c61384b0c", + "auctionStateAddr": "CrhtJPauJq4atZFmsfsjiATR892wX7CeZv6DpoMTD3tv", + "auctionStateNonce": "255", + "minAmountOut": "385.23103094", + "minAmountOut64": "38523103094", + "service": "SWIFT_SWAP", + "toTokenPrice": 0.09148184847755204, + "fromTokenPrice": 0.99989164, + "redeemTxFee": null, + "refundTxFee": null, + "fromChainNativeTokenPrice": 0.10110422, + "toChainNativeTokenPrice": 2079.70374128, + "customPayload": null, + "meta": { + "quote_expectedAmountOut": 401.2823238999724 + }, + "refundAmount": "398.724622644505839482", + "refundTokenLogoUri": "https://assets.coingecko.com/coins/images/6319/small/usdc.png?1696506694", + "refundTokenScannerUrl": "https://polygonscan.com/token/0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "refundTokenSymbol": "USDC", + "fromTokenLogoUri": "https://assets.coingecko.com/coins/images/53705/standard/usdt0.jpg?1737086183", + "toTokenLogoUri": "https://coin-images.coingecko.com/coins/images/70956/small/owb.png?1764799678", + "fromTokenScannerUrl": "https://polygonscan.com/token/0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "toTokenScannerUrl": "https://basescan.org/token/0xef5997c2cf2f6c138196f8a6203afc335206b3c1", + "txs": [ + { + "txHash": "0xfb2464f06d38f39a274b2a5e3414dbed43ad405a06295aaeaded8865efc7d4f4", + "goals": ["SEND"], + "scannerUrl": "https://polygonscan.com/tx/0xfb2464f06d38f39a274b2a5e3414dbed43ad405a06295aaeaded8865efc7d4f4" + }, + { + "txHash": "0xe6d6160fc4318fa6200492d8dc170e7729aa6473fecd19cf83b980f441d554e5", + "goals": ["FULFILL"], + "scannerUrl": "https://basescan.org/tx/0xe6d6160fc4318fa6200492d8dc170e7729aa6473fecd19cf83b980f441d554e5" + } + ], + "steps": [ + { + "title": "Order created with 35.243141 USD₮0 on Polygon", + "status": "COMPLETED", + "type": "INFO" + }, + { + "title": "Fullfill 398.724622644505839482 OWB on Base", + "status": "COMPLETED", + "type": "INFO" + } + ], + "clientStatus": "COMPLETED", + "refundChain": "5", + "refundTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "clientRelayerFeeSuccess": null, + "clientRelayerFeeRefund": "0.005905", + "protocolFeeUsd": 0.010572494040391272, + "referrerFeeUsd": 0 +} diff --git a/core/crates/swapper/src/mayan/testkit.rs b/core/crates/swapper/src/mayan/testkit.rs new file mode 100644 index 0000000000..6cf5673594 --- /dev/null +++ b/core/crates/swapper/src/mayan/testkit.rs @@ -0,0 +1,82 @@ +use super::{ + constants::MAYAN_MCTP, + model::{MayanFastMctpQuote, MayanMctpQuote, MayanQuoteCommon, MayanToken}, +}; +use primitives::asset_constants::{BASE_USDC_TOKEN_ID, ETHEREUM_USDC_TOKEN_ID, SOLANA_USDC_TOKEN_ID, SUI_USDC_TOKEN_ID}; + +impl MayanMctpQuote { + pub fn mock() -> Self { + Self { + common: MayanQuoteCommon { + effective_amount_in64: "1000000".to_string(), + from_token: MayanToken { + contract: SUI_USDC_TOKEN_ID.to_string(), + w_chain_id: 21, + decimals: 6, + verified_address: Some("0x0000000000000000000000000000000000000000000000000000000000000001".to_string()), + }, + from_chain: "sui".to_string(), + ..Default::default() + }, + mctp_input_contract: Some(SUI_USDC_TOKEN_ID.to_string()), + mctp_verified_input_address: Some("0x0000000000000000000000000000000000000000000000000000000000000002".to_string()), + mctp_input_treasury: Some("0x0000000000000000000000000000000000000000000000000000000000000003".to_string()), + ..Default::default() + } + } +} + +impl MayanFastMctpQuote { + pub fn mock() -> Self { + Self { + common: MayanQuoteCommon { + effective_amount_in64: "1000000".to_string(), + min_amount_out: serde_json::json!(0.9), + gas_drop: serde_json::json!(0), + eta_seconds: 20, + from_token: MayanToken { + contract: SOLANA_USDC_TOKEN_ID.to_string(), + w_chain_id: 1, + decimals: 6, + verified_address: None, + }, + to_token: MayanToken { + contract: BASE_USDC_TOKEN_ID.to_string(), + w_chain_id: 30, + decimals: 6, + verified_address: None, + }, + from_chain: "solana".to_string(), + to_chain: "base".to_string(), + slippage_bps: 50, + deadline64: Some("1779326929".to_string()), + referrer_bps: Some(50), + expected_amount_out_base_units: Some("900000".to_string()), + expected_amount_out: serde_json::json!(0.9), + ..Default::default() + }, + fast_mctp_input_contract: Some(SOLANA_USDC_TOKEN_ID.to_string()), + fast_mctp_mayan_contract: Some(MAYAN_MCTP.to_string()), + fast_mctp_min_finality: Some(1000), + circle_max_fee64: Some("500".to_string()), + redeem_relayer_fee: Some(serde_json::json!(0.1)), + redeem_relayer_fee64: Some("1000".to_string()), + refund_relayer_fee64: Some("2000".to_string()), + solana_relayer_fee64: Some("179182".to_string()), + ..Default::default() + } + } + + pub fn mock_evm() -> Self { + let mut route = Self::mock(); + route.common.from_token = MayanToken { + contract: ETHEREUM_USDC_TOKEN_ID.to_string(), + w_chain_id: 2, + decimals: 6, + verified_address: None, + }; + route.common.from_chain = "ethereum".to_string(); + route.fast_mctp_input_contract = Some(ETHEREUM_USDC_TOKEN_ID.to_string()); + route + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/address.rs b/core/crates/swapper/src/mayan/tx_builder/address.rs new file mode 100644 index 0000000000..497c950c6b --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/address.rs @@ -0,0 +1,35 @@ +use crate::{SwapperError, mayan::wormhole_chain}; +use gem_evm::address::EthereumAddress; +use gem_solana::SolanaAddress; +use gem_sui::address::SuiAddress; +use primitives::{Address as AddressTrait, ChainType, decode_hex_array}; + +pub(super) fn native_address_to_bytes32(address: &str, wormhole_chain_id: u16) -> Result<[u8; 32], SwapperError> { + let chain = wormhole_chain::chain_from_id(wormhole_chain_id).ok_or(SwapperError::NotSupportedChain)?; + match chain.chain_type() { + ChainType::Solana => address_bytes::(address, "Solana"), + ChainType::Ethereum => evm_address_bytes32(address), + ChainType::Sui => address_bytes::(address, "Sui"), + ChainType::Ton => decode_hex_array::<32>(address).map_err(SwapperError::from), + _ => Err(SwapperError::NotSupportedChain), + } +} + +pub(super) fn evm_address_bytes(address: &str) -> Result<[u8; 20], SwapperError> { + address_bytes::(address, "EVM") +} + +fn evm_address_bytes32(address: &str) -> Result<[u8; 32], SwapperError> { + let bytes = evm_address_bytes(address)?; + let mut padded = [0u8; 32]; + padded[12..].copy_from_slice(&bytes); + Ok(padded) +} + +fn address_bytes(address: &str, chain: &str) -> Result<[u8; N], SwapperError> { + A::from_str(address) + .map_err(|err| SwapperError::ComputeQuoteError(format!("Invalid {chain} address: {err}")))? + .as_bytes() + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError(format!("Invalid {chain} address length"))) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/amount.rs b/core/crates/swapper/src/mayan/tx_builder/amount.rs new file mode 100644 index 0000000000..e683dd6721 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/amount.rs @@ -0,0 +1,84 @@ +use crate::{ + SwapperError, + mayan::{model::QuoteType, wormhole_chain::WormholeChain}, +}; +use number_formatter::BigNumberFormatter; +use serde_json::Value; +use std::str::FromStr; + +pub(super) fn fractional_amount(amount: &Value, decimals: u32) -> Result +where + T: FromStr, + SwapperError: From, +{ + Ok(fractional_amount_value(amount, decimals)?.parse::()?) +} + +pub(super) fn min_amount_out(amount: &Value, token_decimals: u32, chain: &str, quote_type: &QuoteType) -> Result { + fractional_amount(amount, token_decimals.min(amount_decimals_cap(chain, quote_type)?)) +} + +pub(super) fn gas_drop_amount(amount: &Value, chain: &str, quote_type: &QuoteType, hypercore_deposit: bool) -> Result { + if hypercore_deposit { + return Ok(0); + } + fractional_amount(amount, gas_decimals(chain)?.min(amount_decimals_cap(chain, quote_type)?)) +} + +pub(super) fn optional_bps_u8(value: Option) -> Result { + let Some(value) = value else { + return Ok(0); + }; + value.try_into().map_err(|_| SwapperError::InvalidRoute) +} + +pub(super) fn fractional_amount_value(amount: &Value, decimals: u32) -> Result { + let amount = value_to_query(amount)?; + BigNumberFormatter::value_from_amount_truncated(&amount, decimals).map_err(|_| SwapperError::InvalidRoute) +} + +pub(super) fn value_to_query(amount: &Value) -> Result { + match amount { + Value::Number(number) => Ok(number.to_string()), + Value::String(value) => Ok(value.clone()), + _ => Err(SwapperError::InvalidRoute), + } +} + +pub(super) fn gas_decimals(chain: &str) -> Result { + match WormholeChain::from_name(chain)? { + WormholeChain::Solana | WormholeChain::Sui | WormholeChain::Ton => Ok(9), + _ => Ok(18), + } +} + +fn amount_decimals_cap(chain: &str, quote_type: &QuoteType) -> Result { + match WormholeChain::from_name(chain)? { + WormholeChain::Ton if quote_type != &QuoteType::Swift => Err(SwapperError::InvalidRoute), + WormholeChain::Ton => Ok(u32::MAX), + _ => Ok(8), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fractional_amount_value() { + assert_eq!(fractional_amount::(&serde_json::json!(1.183818719), 9).unwrap(), 1_183_818_719); + assert_eq!( + fractional_amount_value(&serde_json::json!("123456789012345678.123456789"), 18).unwrap(), + "123456789012345678123456789000000000" + ); + assert_eq!(fractional_amount::(&serde_json::json!("0.000000009"), 9).unwrap(), 9); + assert_eq!(fractional_amount_value(&serde_json::json!("-1"), 9), Err(SwapperError::InvalidRoute)); + } + + #[test] + fn test_protocol_amounts_keep_eight_decimal_cap() { + let amount = serde_json::json!(1.183818719); + assert_eq!(min_amount_out(&amount, 9, WormholeChain::Solana.name(), &QuoteType::Swift).unwrap(), 118_381_871); + assert_eq!(min_amount_out(&amount, 9, WormholeChain::Ton.name(), &QuoteType::Swift).unwrap(), 1_183_818_719); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/evm.rs b/core/crates/swapper/src/mayan/tx_builder/evm.rs new file mode 100644 index 0000000000..8dd60d7324 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/evm.rs @@ -0,0 +1,177 @@ +use crate::{ + Quote, RpcProvider, SwapperError, SwapperQuoteData, + approval::{DEFAULT_EVM_SWAP_GAS_LIMIT, check_approval_erc20, get_swap_gas_limit_with_approval}, + mayan::constants::MAYAN_FORWARDER, +}; +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::{SolCall, sol}; +use futures::try_join; +use primitives::{AssetId, ChainType, decode_hex, hex, swap::ApprovalData}; +use std::{future::Future, str::FromStr, sync::Arc}; + +sol! { + interface MayanForwarder { + #[derive(Default)] + struct PermitParams { + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + } + + function forwardERC20(address tokenIn, uint256 amountIn, PermitParams permitParams, address mayanProtocol, bytes protocolData) external payable; + function swapAndForwardERC20( + address tokenIn, + uint256 amountIn, + PermitParams permitParams, + address swapProtocol, + bytes swapData, + address middleToken, + uint256 minMiddleAmount, + address mayanProtocol, + bytes mayanData + ) external payable; + function swapAndForwardEth( + uint256 amountIn, + address swapProtocol, + bytes swapData, + address middleToken, + uint256 minMiddleAmount, + address mayanProtocol, + bytes mayanData + ) external payable; + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::mayan::tx_builder) struct EvmTransaction { + pub(in crate::mayan::tx_builder) to: String, + pub(in crate::mayan::tx_builder) value: String, + pub(in crate::mayan::tx_builder) data: String, +} + +impl EvmTransaction { + pub(in crate::mayan::tx_builder) fn forwarder(value: impl Into, data: Vec) -> Self { + Self { + to: MAYAN_FORWARDER.to_string(), + value: value.into(), + data: hex::encode_with_0x(&data), + } + } +} + +#[derive(Debug, Clone)] +pub(in crate::mayan::tx_builder) struct EvmForwarderProtocolCall { + pub amount_in: U256, + pub protocol_address: Address, + pub data: Vec, +} + +impl EvmForwarderProtocolCall { + pub(in crate::mayan::tx_builder) fn new(amount_in: U256, protocol_address: Address, data: Vec) -> Self { + Self { + amount_in, + protocol_address, + data, + } + } +} + +#[derive(Debug, Clone)] +pub(in crate::mayan::tx_builder) struct EvmSwapForwardData { + swap_router_address: Address, + swap_router_calldata: Bytes, + middle_token: Address, + min_middle_amount: U256, +} + +impl EvmSwapForwardData { + pub(in crate::mayan::tx_builder) fn new(swap_router_address: &str, swap_router_calldata: &str, middle_token: &str, min_middle_amount: U256) -> Result { + Ok(Self { + swap_router_address: Address::from_str(swap_router_address)?, + swap_router_calldata: Bytes::from(decode_hex(swap_router_calldata)?), + middle_token: Address::from_str(middle_token)?, + min_middle_amount, + }) + } +} + +pub(in crate::mayan::tx_builder) fn build_forward_erc20_transaction(token_in: Address, protocol_call: &EvmForwarderProtocolCall, value: impl Into) -> EvmTransaction { + let data = MayanForwarder::forwardERC20Call { + tokenIn: token_in, + amountIn: protocol_call.amount_in, + permitParams: MayanForwarder::PermitParams::default(), + mayanProtocol: protocol_call.protocol_address, + protocolData: Bytes::from(protocol_call.data.clone()), + } + .abi_encode(); + EvmTransaction::forwarder(value, data) +} + +pub(in crate::mayan::tx_builder) fn build_swap_and_forward_eth_transaction( + protocol_call: &EvmForwarderProtocolCall, + swap: EvmSwapForwardData, + amount_in: U256, + value: impl Into, +) -> EvmTransaction { + let data = MayanForwarder::swapAndForwardEthCall { + amountIn: amount_in, + swapProtocol: swap.swap_router_address, + swapData: swap.swap_router_calldata, + middleToken: swap.middle_token, + minMiddleAmount: swap.min_middle_amount, + mayanProtocol: protocol_call.protocol_address, + mayanData: Bytes::from(protocol_call.data.clone()), + } + .abi_encode(); + EvmTransaction::forwarder(value, data) +} + +pub(in crate::mayan::tx_builder) fn build_swap_and_forward_erc20_transaction( + token_in: Address, + protocol_call: &EvmForwarderProtocolCall, + swap: EvmSwapForwardData, + value: impl Into, +) -> EvmTransaction { + let data = MayanForwarder::swapAndForwardERC20Call { + tokenIn: token_in, + amountIn: protocol_call.amount_in, + permitParams: MayanForwarder::PermitParams::default(), + swapProtocol: swap.swap_router_address, + swapData: swap.swap_router_calldata, + middleToken: swap.middle_token, + minMiddleAmount: swap.min_middle_amount, + mayanProtocol: protocol_call.protocol_address, + mayanData: Bytes::from(protocol_call.data.clone()), + } + .abi_encode(); + EvmTransaction::forwarder(value, data) +} + +pub(in crate::mayan::tx_builder) async fn build_quote_data( + transaction: impl Future>, + quote: &Quote, + rpc_provider: Arc, +) -> Result { + let approval = approval_data( + quote.request.wallet_address.clone(), + quote.request.from_asset.asset_id(), + MAYAN_FORWARDER, + U256::from_str("e.from_value)?, + rpc_provider, + ); + let (transaction, approval) = try_join!(transaction, approval)?; + let gas_limit = get_swap_gas_limit_with_approval(&approval, None, DEFAULT_EVM_SWAP_GAS_LIMIT); + Ok(SwapperQuoteData::new_contract(transaction.to, transaction.value, transaction.data, approval, gas_limit)) +} + +async fn approval_data(wallet_address: String, asset: AssetId, spender: &str, amount: U256, rpc_provider: Arc) -> Result, SwapperError> { + if asset.is_native() || asset.chain.chain_type() != ChainType::Ethereum { + return Ok(None); + } + let token = asset.token_id.ok_or(SwapperError::NotSupportedAsset)?; + Ok(check_approval_erc20(wallet_address, token, spender.to_string(), amount, rpc_provider, &asset.chain) + .await? + .approval_data()) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/fast_mctp/evm.rs b/core/crates/swapper/src/mayan/tx_builder/fast_mctp/evm.rs new file mode 100644 index 0000000000..b7de96ac12 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/fast_mctp/evm.rs @@ -0,0 +1,204 @@ +use super::{ + FAST_MCTP_PAYLOAD_TYPE_DEFAULT, FAST_MCTP_PAYLOAD_TYPE_ORDER, circle_max_fee64, destination_referrer_address, fast_mctp_contract, fast_mctp_input_contract, + fast_mctp_min_finality, redeem_relayer_fee, referrer_bytes, refund_relayer_fee64, token_out, +}; +use crate::{ + Quote, RpcProvider, SwapperError, SwapperQuoteData, + mayan::{ + cctp_domain::{CCTP_TOKEN_DECIMALS, domain_for_wormhole_chain}, + client::MayanClient, + model::{GetSwapEvmParams, GetSwapEvmResponse, MayanFastMctpQuote, QuoteType}, + tx_builder::{ + address::native_address_to_bytes32, + amount::{fractional_amount, gas_drop_amount, min_amount_out, optional_bps_u8}, + evm::{self as evm_builder, EvmForwarderProtocolCall, EvmSwapForwardData, EvmTransaction}, + route::quote_destination_address, + }, + wormhole_chain::id_for_name as wormhole_chain_id, + }, +}; +use alloy_primitives::{Address, Bytes, FixedBytes, U256}; +use alloy_sol_types::{SolCall, sol}; +use gem_client::Client; +use gem_evm::EVM_ZERO_ADDRESS; +use std::{fmt::Debug, str::FromStr, sync::Arc}; + +sol! { + interface MayanFastMctp { + struct OrderPayload { + uint8 payloadType; + bytes32 destAddr; + bytes32 tokenOut; + uint64 amountOutMin; + uint64 gasDrop; + uint64 redeemFee; + uint64 refundFee; + uint64 deadline; + bytes32 referrerAddr; + uint8 referrerBps; + } + + function bridge( + address tokenIn, + uint256 amountIn, + uint64 redeemFee, + uint256 circleMaxFee, + uint64 gasDrop, + bytes32 destAddr, + uint32 destDomain, + bytes32 referrerAddress, + uint8 referrerBps, + uint8 payloadType, + uint32 minFinalityThreshold, + bytes customPayload + ) external; + function createOrder(address tokenIn, uint256 amountIn, uint256 circleMaxFee, uint32 destDomain, uint32 minFinalityThreshold, OrderPayload orderPayload) external; + } +} + +fn fast_mctp_protocol_call(quote: &Quote, route: &MayanFastMctpQuote) -> Result { + if route.has_auction == Some(true) { + fast_mctp_create_order_call(quote, route) + } else { + fast_mctp_bridge_call(quote, route) + } +} + +fn fast_mctp_create_order_call(quote: &Quote, route: &MayanFastMctpQuote) -> Result { + let contract_address = Address::from_str(fast_mctp_contract(route)?)?; + let destination_chain_id = wormhole_chain_id(&route.to_chain)?; + let destination_address = FixedBytes::from(native_address_to_bytes32(quote_destination_address(quote), destination_chain_id)?); + let amount_in = U256::from_str(&route.effective_amount_in64)?; + let token_in = Address::from_str(fast_mctp_input_contract(route)?)?; + let circle_max_fee = U256::from_str(circle_max_fee64(route)?)?; + let data = MayanFastMctp::createOrderCall { + tokenIn: token_in, + amountIn: amount_in, + circleMaxFee: circle_max_fee, + destDomain: domain_for_wormhole_chain(&route.to_chain)?.id(), + minFinalityThreshold: fast_mctp_min_finality(route)?, + orderPayload: MayanFastMctp::OrderPayload { + payloadType: FAST_MCTP_PAYLOAD_TYPE_ORDER, + destAddr: destination_address, + tokenOut: FixedBytes::from(token_out(route)?), + amountOutMin: min_amount_out(&route.min_amount_out, route.to_token.decimals, &route.to_chain, &QuoteType::FastMctp)?, + gasDrop: gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::FastMctp, false)?, + redeemFee: redeem_relayer_fee(route)?, + refundFee: refund_relayer_fee64(route)?, + deadline: route.deadline64.as_deref().ok_or(SwapperError::InvalidRoute)?.parse::()?, + referrerAddr: FixedBytes::from(referrer_bytes(route)?), + referrerBps: optional_bps_u8(route.referrer_bps)?, + }, + } + .abi_encode(); + Ok(EvmForwarderProtocolCall::new(amount_in, contract_address, data)) +} + +fn fast_mctp_bridge_call(quote: &Quote, route: &MayanFastMctpQuote) -> Result { + let contract_address = Address::from_str(fast_mctp_contract(route)?)?; + let destination_chain_id = wormhole_chain_id(&route.to_chain)?; + let amount_in = U256::from_str(&route.effective_amount_in64)?; + let token_in = Address::from_str(fast_mctp_input_contract(route)?)?; + let data = MayanFastMctp::bridgeCall { + tokenIn: token_in, + amountIn: amount_in, + redeemFee: redeem_relayer_fee(route)?, + circleMaxFee: U256::from_str(circle_max_fee64(route)?)?, + gasDrop: gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::FastMctp, false)?, + destAddr: FixedBytes::from(native_address_to_bytes32(quote_destination_address(quote), destination_chain_id)?), + destDomain: domain_for_wormhole_chain(&route.to_chain)?.id(), + referrerAddress: FixedBytes::from(referrer_bytes(route)?), + referrerBps: optional_bps_u8(route.referrer_bps)?, + payloadType: FAST_MCTP_PAYLOAD_TYPE_DEFAULT, + minFinalityThreshold: fast_mctp_min_finality(route)?, + customPayload: Bytes::new(), + } + .abi_encode(); + Ok(EvmForwarderProtocolCall::new(amount_in, contract_address, data)) +} + +pub async fn build_quote_data(client: &MayanClient, quote: &Quote, route: &MayanFastMctpQuote, rpc_provider: Arc) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + evm_builder::build_quote_data(build(client, quote, route), quote, rpc_provider).await +} + +async fn build(client: &MayanClient, quote: &Quote, route: &MayanFastMctpQuote) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let protocol_call = fast_mctp_protocol_call(quote, route)?; + if route.from_token.contract.eq_ignore_ascii_case(fast_mctp_input_contract(route)?) { + return build_direct_forward_transaction(route, &protocol_call); + } + + build_swap_forward_transaction(client, route, &protocol_call).await +} + +fn build_direct_forward_transaction(route: &MayanFastMctpQuote, protocol_call: &EvmForwarderProtocolCall) -> Result { + if route.from_token.contract.eq_ignore_ascii_case(EVM_ZERO_ADDRESS) { + return Err(SwapperError::transaction_error("Mayan FastMCTP does not support direct native order creation")); + } + + Ok(evm_builder::build_forward_erc20_transaction( + Address::from_str(&route.from_token.contract)?, + protocol_call, + "0", + )) +} + +async fn build_swap_forward_transaction(client: &MayanClient, route: &MayanFastMctpQuote, protocol_call: &EvmForwarderProtocolCall) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let fast_mctp_input_contract = fast_mctp_input_contract(route)?; + let min_middle_amount = fractional_amount::(route.min_middle_amount.as_ref().ok_or(SwapperError::InvalidRoute)?, CCTP_TOKEN_DECIMALS)?; + let swap: GetSwapEvmResponse = client + .get_swap( + "/get-swap/evm", + GetSwapEvmParams::fast_mctp( + route, + route.effective_amount_in64.clone(), + fast_mctp_input_contract.to_string(), + destination_referrer_address(route)?, + ), + ) + .await?; + let swap = EvmSwapForwardData::new(&swap.swap_router_address, &swap.swap_router_calldata, fast_mctp_input_contract, min_middle_amount)?; + + if route.from_token.contract.eq_ignore_ascii_case(EVM_ZERO_ADDRESS) { + return Ok(evm_builder::build_swap_and_forward_eth_transaction( + protocol_call, + swap, + protocol_call.amount_in, + protocol_call.amount_in.to_string(), + )); + } + + Ok(evm_builder::build_swap_and_forward_erc20_transaction( + Address::from_str(&route.from_token.contract)?, + protocol_call, + swap, + "0", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Chain, asset_constants::ETHEREUM_USDC_TOKEN_ID, hex}; + + #[test] + fn test_build_direct_forward_transaction_wraps_fast_mctp_call() { + let mut quote = Quote::mock(Chain::Ethereum, Some(ETHEREUM_USDC_TOKEN_ID)); + quote.request.wallet_address = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(); + quote.request.destination_address = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(); + let route = MayanFastMctpQuote::mock_evm(); + let protocol_call = fast_mctp_protocol_call("e, &route).unwrap(); + let transaction = build_direct_forward_transaction(&route, &protocol_call).unwrap(); + + assert_eq!(transaction.value, "0"); + assert_eq!(transaction.data[..10], hex::encode_with_0x(&evm_builder::MayanForwarder::forwardERC20Call::SELECTOR)); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/fast_mctp/mod.rs b/core/crates/swapper/src/mayan/tx_builder/fast_mctp/mod.rs new file mode 100644 index 0000000000..3061a4d7d8 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/fast_mctp/mod.rs @@ -0,0 +1,74 @@ +pub(in crate::mayan) mod evm; +pub(in crate::mayan) mod solana; + +use crate::{ + SwapperError, + fees::default_referral_address, + mayan::{ + cctp_domain::CCTP_TOKEN_DECIMALS, + model::MayanFastMctpQuote, + tx_builder::{address::native_address_to_bytes32, amount::fractional_amount, swift::token_address_to_bytes32}, + wormhole_chain::{self, WormholeChain, id_for_name as wormhole_chain_id}, + }, +}; +use gem_evm::EVM_ZERO_ADDRESS; +use gem_solana::SYSTEM_PROGRAM_ID; +use primitives::Chain; + +const FAST_MCTP_PAYLOAD_TYPE_DEFAULT: u8 = 1; +const FAST_MCTP_PAYLOAD_TYPE_ORDER: u8 = 3; + +fn destination_referrer_address(route: &MayanFastMctpQuote) -> Result, SwapperError> { + let chain = wormhole_chain::chain_for_name(&route.to_chain)?; + if chain == Chain::Sui { + return Ok(None); + } + let address = default_referral_address(chain); + Ok((!address.is_empty()).then_some(address)) +} + +fn referrer_bytes(route: &MayanFastMctpQuote) -> Result<[u8; 32], SwapperError> { + let Some(address) = destination_referrer_address(route)? else { + return native_address_to_bytes32(SYSTEM_PROGRAM_ID, wormhole_chain_id(WormholeChain::Solana.name())?); + }; + native_address_to_bytes32(&address, wormhole_chain_id(&route.to_chain)?) +} + +fn redeem_relayer_fee(route: &MayanFastMctpQuote) -> Result { + fractional_amount(route.redeem_relayer_fee.as_ref().ok_or(SwapperError::InvalidRoute)?, CCTP_TOKEN_DECIMALS) +} + +fn fast_mctp_input_contract(route: &MayanFastMctpQuote) -> Result<&str, SwapperError> { + route.fast_mctp_input_contract.as_deref().ok_or(SwapperError::InvalidRoute) +} + +fn fast_mctp_contract(route: &MayanFastMctpQuote) -> Result<&str, SwapperError> { + route.fast_mctp_mayan_contract.as_deref().ok_or(SwapperError::InvalidRoute) +} + +fn fast_mctp_min_finality(route: &MayanFastMctpQuote) -> Result { + route.fast_mctp_min_finality.ok_or(SwapperError::InvalidRoute) +} + +fn circle_max_fee64(route: &MayanFastMctpQuote) -> Result<&str, SwapperError> { + route.circle_max_fee64.as_deref().ok_or(SwapperError::InvalidRoute) +} + +fn refund_relayer_fee64(route: &MayanFastMctpQuote) -> Result { + route + .refund_relayer_fee64 + .as_deref() + .ok_or(SwapperError::InvalidRoute)? + .parse::() + .map_err(SwapperError::from) +} + +fn token_out(route: &MayanFastMctpQuote) -> Result<[u8; 32], SwapperError> { + if route.to_token.contract == EVM_ZERO_ADDRESS { + return native_address_to_bytes32(SYSTEM_PROGRAM_ID, wormhole_chain_id(WormholeChain::Solana.name())?); + } + if route.to_chain == WormholeChain::Sui.name() { + return token_address_to_bytes32(route.to_token.verified_address.as_deref().ok_or(SwapperError::InvalidRoute)?, WormholeChain::Sui.name()); + } + native_address_to_bytes32(&route.to_token.contract, route.to_token.w_chain_id) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/fast_mctp/solana.rs b/core/crates/swapper/src/mayan/tx_builder/fast_mctp/solana.rs new file mode 100644 index 0000000000..0a7b797135 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/fast_mctp/solana.rs @@ -0,0 +1,352 @@ +use super::{circle_max_fee64, destination_referrer_address, fast_mctp_input_contract, fast_mctp_min_finality, referrer_bytes, refund_relayer_fee64, token_out}; +use crate::{ + Quote, RpcProvider, SwapperError, SwapperQuoteData, + mayan::{ + cctp_domain::{CCTP_TOKEN_DECIMALS, domain_for_wormhole_chain}, + client::MayanClient, + constants::{MAYAN_FAST_MCTP_PROGRAM_ID, MAYAN_LOOKUP_TABLE_SOLANA}, + model::{GetSwapSolanaParams, MayanFastMctpQuote, QuoteType, SolanaClientSwap}, + tx_builder::{ + address::native_address_to_bytes32, + amount::{fractional_amount, gas_drop_amount, min_amount_out, optional_bps_u8, value_to_query}, + route::quote_destination_address, + solana::{ + self as solana_builder, SolanaLedgerDeposit, SolanaTransaction, append_client_swap_instructions, append_ledger_deposit_instructions, solana_error, + wrap_instruction_in_cpi_proxy, + }, + }, + wormhole_chain::{WormholeChain, id_for_name as wormhole_chain_id}, + }, +}; +use gem_client::Client; +use gem_solana::SolanaAddress; +use rand::Rng; +use solana_primitives::anchor::global_discriminator; +use solana_primitives::associated_token::get_associated_token_address_with_program_id; +use solana_primitives::instructions::program_ids; +use solana_primitives::{AccountMeta, Instruction, Pubkey, find_program_address}; +use std::{fmt::Debug, sync::Arc}; + +const LEDGER_ORDER_SEED: &[u8] = b"LEDGER_ORDER"; +const LEDGER_BRIDGE_SEED: &[u8] = b"LEDGER_BRIDGE"; +const FAST_MCTP_MODE_BRIDGE: u8 = 1; +const FAST_MCTP_MODE_ORDER: u8 = 2; + +struct FastMctpBuildContext { + user: Pubkey, + relayer: Pubkey, + ledger: Pubkey, + ledger_account: Pubkey, + random_key: u16, + fast_mctp_input_mint: Pubkey, + fast_mctp_input_contract: String, + destination_address: String, + token_out: [u8; 32], + referrer_address: Option, +} + +impl FastMctpBuildContext { + fn new(quote: &Quote, route: &MayanFastMctpQuote) -> Result { + if route.to_chain == WormholeChain::Solana.name() || route.to_chain == WormholeChain::Sui.name() { + return Err(SwapperError::InvalidRoute); + } + + let wallet_address = quote.request.wallet_address.as_str(); + let relayer_address = route.relayer.as_deref().unwrap_or(wallet_address); + if relayer_address != wallet_address { + return Err(SwapperError::InvalidRoute); + } + + let user: Pubkey = SolanaAddress::parse(wallet_address).map_err(solana_error)?.into(); + let relayer: Pubkey = SolanaAddress::parse(relayer_address).map_err(solana_error)?.into(); + let fast_mctp_program: Pubkey = SolanaAddress::parse(MAYAN_FAST_MCTP_PROGRAM_ID).map_err(solana_error)?.into(); + let fast_mctp_input_contract = fast_mctp_input_contract(route)?.to_string(); + let fast_mctp_input_mint: Pubkey = SolanaAddress::parse(&fast_mctp_input_contract).map_err(solana_error)?.into(); + let random_key = random_u16(); + let seed_prefix = if route.has_auction == Some(true) { LEDGER_ORDER_SEED } else { LEDGER_BRIDGE_SEED }; + let (ledger, _) = find_program_address(&fast_mctp_program, &[seed_prefix, user.as_bytes(), &random_key.to_le_bytes()]).map_err(solana_error)?; + let ledger_account = get_associated_token_address_with_program_id(&ledger, &fast_mctp_input_mint, &program_ids::token_program()); + let destination_address = quote_destination_address(quote).to_string(); + let token_out = token_out(route)?; + let referrer_address = destination_referrer_address(route)?; + + Ok(Self { + user, + relayer, + ledger, + ledger_account, + random_key, + fast_mctp_input_mint, + fast_mctp_input_contract, + destination_address, + token_out, + referrer_address, + }) + } +} + +pub async fn build_quote_data(client: &MayanClient, quote: &Quote, route: &MayanFastMctpQuote, rpc_provider: Arc) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let transaction = build(client, quote, route).await?; + solana_builder::build_quote_data(quote, transaction, rpc_provider).await +} + +async fn build(client: &MayanClient, quote: &Quote, route: &MayanFastMctpQuote) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let context = FastMctpBuildContext::new(quote, route)?; + let mut instructions = Vec::new(); + let mut lookup_table_addresses = vec![MAYAN_LOOKUP_TABLE_SOLANA.to_string()]; + + if route.from_token.contract.as_str() == context.fast_mctp_input_contract.as_str() { + add_direct_fast_mctp_instructions(route, &context, &mut instructions)?; + } else { + lookup_table_addresses.extend(add_swap_instructions(client, quote, route, &context, &mut instructions).await?); + } + + Ok(SolanaTransaction::new(instructions, lookup_table_addresses)) +} + +fn add_direct_fast_mctp_instructions(route: &MayanFastMctpQuote, context: &FastMctpBuildContext, instructions: &mut Vec) -> Result<(), SwapperError> { + let amount = route.effective_amount_in64.parse::()?; + append_ledger_deposit_instructions( + instructions, + SolanaLedgerDeposit { + user: &context.user, + relayer: &context.relayer, + ledger: &context.ledger, + ledger_account: &context.ledger_account, + mint: &context.fast_mctp_input_mint, + amount, + suggested_priority_fee: route.suggested_priority_fee, + }, + )?; + + add_ledger_instruction(route, context, amount, solana_relayer_fee(route)?, instructions) +} + +async fn add_swap_instructions( + client: &MayanClient, + quote: &Quote, + route: &MayanFastMctpQuote, + context: &FastMctpBuildContext, + instructions: &mut Vec, +) -> Result, SwapperError> +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let min_middle_amount = route.min_middle_amount.as_ref().ok_or(SwapperError::InvalidRoute)?; + let deposit_mode = if route.has_auction == Some(true) { "FAST_MCTP_ORDER" } else { "FAST_MCTP_BRIDGE" }; + let swap: SolanaClientSwap = client + .get_swap( + "/get-swap/solana", + GetSwapSolanaParams::fast_mctp( + route, + value_to_query(min_middle_amount)?, + context.fast_mctp_input_contract.clone(), + quote.request.wallet_address.clone(), + route.effective_amount_in64.clone(), + deposit_mode, + context.referrer_address.clone(), + context.ledger.to_string(), + ), + ) + .await?; + + let lookup_table_addresses = append_client_swap_instructions( + instructions, + swap, + &context.user, + &context.relayer, + &route.from_token.contract, + &route.effective_amount_in64, + )?; + + add_ledger_instruction( + route, + context, + fractional_amount(min_middle_amount, CCTP_TOKEN_DECIMALS)?, + solana_relayer_fee(route)?, + instructions, + )?; + + Ok(lookup_table_addresses) +} + +fn add_ledger_instruction( + route: &MayanFastMctpQuote, + context: &FastMctpBuildContext, + amount_in_min64: u64, + fee_solana: u64, + instructions: &mut Vec, +) -> Result<(), SwapperError> { + if route.has_auction == Some(true) { + instructions.push(wrap_instruction_in_cpi_proxy(create_fast_mctp_order_ledger_instruction( + route, + context, + amount_in_min64, + fee_solana, + )?)?); + } else { + instructions.push(wrap_instruction_in_cpi_proxy(create_fast_mctp_bridge_ledger_instruction( + route, + context, + amount_in_min64, + fee_solana, + )?)?); + } + Ok(()) +} + +fn create_fast_mctp_bridge_ledger_instruction( + route: &MayanFastMctpQuote, + context: &FastMctpBuildContext, + amount_in_min64: u64, + fee_solana: u64, +) -> Result { + let fast_mctp_program = SolanaAddress::parse(MAYAN_FAST_MCTP_PROGRAM_ID).map_err(solana_error)?.into(); + let data = bridge_ledger_data(route, context, amount_in_min64, fee_solana)?; + Ok(Instruction { + program_id: fast_mctp_program, + accounts: vec![ + AccountMeta::new_signer_writable(context.user), + AccountMeta::new_writable(context.ledger), + AccountMeta::new_signer_writable(context.relayer), + AccountMeta::new_readonly(context.ledger_account), + AccountMeta::new_readonly(fast_mctp_program), + AccountMeta::new_readonly(context.fast_mctp_input_mint), + AccountMeta::new_readonly(program_ids::system_program()), + ], + data, + }) +} + +fn create_fast_mctp_order_ledger_instruction( + route: &MayanFastMctpQuote, + context: &FastMctpBuildContext, + amount_in_min64: u64, + fee_solana: u64, +) -> Result { + let fast_mctp_program = SolanaAddress::parse(MAYAN_FAST_MCTP_PROGRAM_ID).map_err(solana_error)?.into(); + let mut data = Vec::with_capacity(159); + data.extend_from_slice(&global_discriminator("init_order_ledger")); + data.extend_from_slice(&destination_address(route, context)?); + data.extend_from_slice(&amount_in_min64.to_le_bytes()); + data.extend_from_slice(&gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::FastMctp, false)?.to_le_bytes()); + data.extend_from_slice(&redeem_relayer_fee64(route)?.to_le_bytes()); + data.extend_from_slice(&refund_relayer_fee64(route)?.to_le_bytes()); + data.extend_from_slice(&fee_solana.to_le_bytes()); + data.extend_from_slice(&domain_for_wormhole_chain(&route.to_chain)?.id().to_le_bytes()); + data.extend_from_slice(&context.random_key.to_le_bytes()); + data.push(FAST_MCTP_MODE_ORDER); + data.extend_from_slice(&context.token_out); + data.extend_from_slice(&min_amount_out(&route.min_amount_out, route.to_token.decimals, &route.to_chain, &QuoteType::FastMctp)?.to_le_bytes()); + data.extend_from_slice(&route.deadline64.as_deref().ok_or(SwapperError::InvalidRoute)?.parse::()?.to_le_bytes()); + data.extend_from_slice(&referrer_bytes(route)?); + data.push(optional_bps_u8(route.referrer_bps)?); + data.extend_from_slice(&circle_max_fee64(route)?.parse::()?.to_le_bytes()); + data.extend_from_slice(&fast_mctp_min_finality(route)?.to_le_bytes()); + + Ok(Instruction { + program_id: fast_mctp_program, + accounts: vec![ + AccountMeta::new_signer_writable(context.user), + AccountMeta::new_writable(context.ledger), + AccountMeta::new_signer_writable(context.relayer), + AccountMeta::new_readonly(context.ledger_account), + AccountMeta::new_readonly(context.fast_mctp_input_mint), + AccountMeta::new_readonly(program_ids::system_program()), + ], + data, + }) +} + +fn bridge_ledger_data(route: &MayanFastMctpQuote, context: &FastMctpBuildContext, amount_in_min64: u64, fee_solana: u64) -> Result, SwapperError> { + let mut data = Vec::with_capacity(124); + data.extend_from_slice(&global_discriminator("init_bridge_ledger")); + data.extend_from_slice(&destination_address(route, context)?); + data.extend_from_slice(&amount_in_min64.to_le_bytes()); + data.extend_from_slice(&gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::FastMctp, false)?.to_le_bytes()); + data.extend_from_slice(&redeem_relayer_fee64(route)?.to_le_bytes()); + data.extend_from_slice(&fee_solana.to_le_bytes()); + data.extend_from_slice(&domain_for_wormhole_chain(&route.to_chain)?.id().to_le_bytes()); + data.extend_from_slice(&referrer_bytes(route)?); + data.push(optional_bps_u8(route.referrer_bps)?); + data.extend_from_slice(&context.random_key.to_le_bytes()); + data.extend_from_slice(&circle_max_fee64(route)?.parse::()?.to_le_bytes()); + data.extend_from_slice(&fast_mctp_min_finality(route)?.to_le_bytes()); + data.push(FAST_MCTP_MODE_BRIDGE); + Ok(data) +} + +fn destination_address(route: &MayanFastMctpQuote, context: &FastMctpBuildContext) -> Result<[u8; 32], SwapperError> { + native_address_to_bytes32(&context.destination_address, wormhole_chain_id(&route.to_chain)?) +} + +fn redeem_relayer_fee64(route: &MayanFastMctpQuote) -> Result { + route + .redeem_relayer_fee64 + .as_deref() + .ok_or(SwapperError::InvalidRoute)? + .parse::() + .map_err(SwapperError::from) +} + +fn solana_relayer_fee(route: &MayanFastMctpQuote) -> Result { + route + .solana_relayer_fee64 + .as_deref() + .ok_or(SwapperError::InvalidRoute)? + .parse::() + .map_err(SwapperError::from) +} + +fn random_u16() -> u16 { + let mut bytes = [0u8; 2]; + rand::rng().fill_bytes(&mut bytes); + u16::from_le_bytes(bytes) % 65000 +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Chain, asset_constants::SOLANA_USDC_TOKEN_ID}; + + #[test] + fn test_create_fast_mctp_bridge_ledger_instruction() { + let mut quote = Quote::mock(Chain::Solana, Some(SOLANA_USDC_TOKEN_ID)); + quote.request.wallet_address = "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(); + quote.request.destination_address = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(); + let route = MayanFastMctpQuote::mock(); + let context = FastMctpBuildContext::new("e, &route).unwrap(); + let instruction = create_fast_mctp_bridge_ledger_instruction(&route, &context, 1_000_000, 179_182).unwrap(); + + assert_eq!(instruction.program_id.to_string(), MAYAN_FAST_MCTP_PROGRAM_ID); + assert_eq!(instruction.accounts.len(), 7); + assert_eq!(instruction.accounts[4].pubkey.to_string(), MAYAN_FAST_MCTP_PROGRAM_ID); + assert_eq!(instruction.accounts[5].pubkey.to_string(), SOLANA_USDC_TOKEN_ID); + assert_eq!(&instruction.data[..8], global_discriminator("init_bridge_ledger").as_slice()); + assert_eq!(u32::from_le_bytes(instruction.data[72..76].try_into().unwrap()), 6); + assert_eq!(instruction.data[123], FAST_MCTP_MODE_BRIDGE); + } + + #[test] + fn test_create_fast_mctp_order_ledger_instruction_uses_order_mode() { + let mut quote = Quote::mock(Chain::Solana, Some(SOLANA_USDC_TOKEN_ID)); + quote.request.wallet_address = "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(); + quote.request.destination_address = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(); + let mut route = MayanFastMctpQuote::mock(); + route.has_auction = Some(true); + let context = FastMctpBuildContext::new("e, &route).unwrap(); + let instruction = create_fast_mctp_order_ledger_instruction(&route, &context, 1_000_000, 179_182).unwrap(); + + assert_eq!(instruction.program_id.to_string(), MAYAN_FAST_MCTP_PROGRAM_ID); + assert_eq!(instruction.accounts.len(), 6); + assert_eq!(&instruction.data[..8], global_discriminator("init_order_ledger").as_slice()); + assert_eq!(u32::from_le_bytes(instruction.data[80..84].try_into().unwrap()), 6); + assert_eq!(instruction.data[86], FAST_MCTP_MODE_ORDER); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/hypercore.rs b/core/crates/swapper/src/mayan/tx_builder/hypercore.rs new file mode 100644 index 0000000000..8bd109d1fc --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/hypercore.rs @@ -0,0 +1,33 @@ +use super::{address::evm_address_bytes, route::is_hypercore_deposit}; +use crate::{ + SwapperError, + mayan::{constants::HYPERCORE_SPOT_USDC_CONTRACT, model::MayanSwiftQuote}, +}; +use gem_evm::EVM_ZERO_ADDRESS; + +pub(super) fn hypercore_custom_payload(route: &MayanSwiftQuote, destination_address: &str) -> Result>, SwapperError> { + if !is_hypercore_deposit(route) { + return Ok(None); + } + + let destination = evm_address_bytes(destination_address)?; + let dex = hypercore_deposit_dex(&route.to_token.contract)?; + let relayer_fee = route.hc_swift_deposit.as_ref().ok_or(SwapperError::InvalidRoute)?.relayer_fee64.parse::()?; + + let mut payload = vec![0u8; 32]; + payload[..20].copy_from_slice(&destination); + payload[20..24].copy_from_slice(&dex.to_be_bytes()); + payload[24..32].copy_from_slice(&relayer_fee.to_be_bytes()); + Ok(Some(payload)) +} + +pub(super) fn hypercore_deposit_dex(contract: &str) -> Result { + if contract != EVM_ZERO_ADDRESS && !contract.eq_ignore_ascii_case(HYPERCORE_SPOT_USDC_CONTRACT) { + return Err(SwapperError::NotSupportedAsset); + } + let bytes = evm_address_bytes(contract)?; + if bytes[..16].iter().any(|value| *value != 0) { + return Err(SwapperError::InvalidRoute); + } + Ok(u32::from_be_bytes(bytes[16..20].try_into().map_err(|_| SwapperError::InvalidRoute)?)) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/evm.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/evm.rs new file mode 100644 index 0000000000..b974cc6db7 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/evm.rs @@ -0,0 +1,215 @@ +use super::{destination_referrer_address, redeem_relayer_fee}; +use crate::{ + Quote, RpcProvider, SwapperError, SwapperQuoteData, + mayan::{ + cctp_domain::{CCTP_TOKEN_DECIMALS, domain_for_wormhole_chain}, + client::MayanClient, + model::{GetSwapEvmParams, GetSwapEvmResponse, MayanMctpQuote, QuoteType}, + tx_builder::{ + address::native_address_to_bytes32, + amount::{fractional_amount, gas_decimals, gas_drop_amount, min_amount_out, optional_bps_u8}, + evm::{self as evm_builder, EvmForwarderProtocolCall, EvmSwapForwardData, EvmTransaction}, + route::quote_destination_address, + swift::{referrer_bytes, swift_to_token}, + }, + wormhole_chain::id_for_name as wormhole_chain_id, + }, +}; +use alloy_primitives::{Address, Bytes, FixedBytes, U256}; +use alloy_sol_types::{SolCall, sol}; +use gem_client::Client; +use gem_evm::EVM_ZERO_ADDRESS; +use std::{fmt::Debug, str::FromStr, sync::Arc}; + +const MCTP_PAYLOAD_TYPE_DEFAULT: u8 = 1; + +sol! { + interface MayanCircle { + struct OrderParams { + address tokenIn; + uint256 amountIn; + uint64 gasDrop; + bytes32 destAddr; + uint16 destChain; + bytes32 tokenOut; + uint64 minAmountOut; + uint64 deadline; + uint64 redeemFee; + bytes32 referrerAddr; + uint8 referrerBps; + } + + function bridgeWithFee( + address tokenIn, + uint256 amountIn, + uint64 redeemFee, + uint64 gasDrop, + bytes32 destAddr, + uint32 destDomain, + uint8 payloadType, + bytes customPayload + ) external payable returns (uint64 sequence); + function bridgeWithLockedFee(address tokenIn, uint256 amountIn, uint64 gasDrop, uint256 redeemFee, uint32 destDomain, bytes32 destAddr) external returns (uint64 cctpNonce); + function createOrder(OrderParams params) external payable returns (uint64 sequence); + } +} + +fn mctp_protocol_call(quote: &Quote, route: &MayanMctpQuote) -> Result { + if route.has_auction == Some(true) { + mctp_create_order_call(quote, route) + } else { + mctp_bridge_call(quote, route) + } +} + +fn mctp_create_order_call(quote: &Quote, route: &MayanMctpQuote) -> Result { + let contract_address = mctp_contract_address(route)?; + let destination_chain_id = wormhole_chain_id(&route.to_chain)?; + let destination_address = native_address_to_bytes32(quote_destination_address(quote), destination_chain_id)?; + let amount_in = U256::from_str(&route.effective_amount_in64)?; + let token_in = Address::from_str(mctp_input_contract(route)?)?; + let referrer = referrer_bytes(&route.to_chain)?; + let data = MayanCircle::createOrderCall { + params: MayanCircle::OrderParams { + tokenIn: token_in, + amountIn: amount_in, + gasDrop: gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::Mctp, false)?, + destAddr: FixedBytes::from(destination_address), + destChain: destination_chain_id, + tokenOut: FixedBytes::from(swift_to_token(route)?), + minAmountOut: min_amount_out(&route.min_amount_out, route.to_token.decimals, &route.to_chain, &QuoteType::Mctp)?, + deadline: route.deadline64.as_deref().ok_or(SwapperError::InvalidRoute)?.parse::()?, + redeemFee: redeem_relayer_fee(route)?, + referrerAddr: FixedBytes::from(referrer), + referrerBps: optional_bps_u8(route.referrer_bps)?, + }, + } + .abi_encode(); + Ok(EvmForwarderProtocolCall::new(amount_in, contract_address, data)) +} + +fn mctp_bridge_call(quote: &Quote, route: &MayanMctpQuote) -> Result { + let contract_address = mctp_contract_address(route)?; + let amount_in = U256::from_str(&route.effective_amount_in64)?; + let token_in = Address::from_str(mctp_input_contract(route)?)?; + let destination_chain_id = wormhole_chain_id(&route.to_chain)?; + let destination_address = FixedBytes::from(native_address_to_bytes32(quote_destination_address(quote), destination_chain_id)?); + let gas_drop = gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::Mctp, false)?; + let redeem_fee = redeem_relayer_fee(route)?; + let destination_domain = domain_for_wormhole_chain(&route.to_chain)?.id(); + + let data = if route.cheaper_chain.as_deref() == Some(route.from_chain.as_str()) { + MayanCircle::bridgeWithLockedFeeCall { + tokenIn: token_in, + amountIn: amount_in, + gasDrop: gas_drop, + redeemFee: U256::from(redeem_fee), + destDomain: destination_domain, + destAddr: destination_address, + } + .abi_encode() + } else { + MayanCircle::bridgeWithFeeCall { + tokenIn: token_in, + amountIn: amount_in, + redeemFee: redeem_fee, + gasDrop: gas_drop, + destAddr: destination_address, + destDomain: destination_domain, + payloadType: MCTP_PAYLOAD_TYPE_DEFAULT, + customPayload: Bytes::new(), + } + .abi_encode() + }; + + Ok(EvmForwarderProtocolCall::new(amount_in, contract_address, data)) +} + +pub async fn build_quote_data(client: &MayanClient, quote: &Quote, route: &MayanMctpQuote, rpc_provider: Arc) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + evm_builder::build_quote_data(build(client, quote, route), quote, rpc_provider).await +} + +async fn build(client: &MayanClient, quote: &Quote, route: &MayanMctpQuote) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let protocol_call = mctp_protocol_call(quote, route)?; + let bridge_fee = bridge_fee(route)?; + if route.from_token.contract.eq_ignore_ascii_case(mctp_input_contract(route)?) { + return build_direct_forward_transaction(route, &protocol_call, bridge_fee); + } + + build_swap_forward_transaction(client, route, &protocol_call, bridge_fee).await +} + +fn build_direct_forward_transaction(route: &MayanMctpQuote, protocol_call: &EvmForwarderProtocolCall, bridge_fee: U256) -> Result { + if route.from_token.contract.eq_ignore_ascii_case(EVM_ZERO_ADDRESS) { + return Err(SwapperError::transaction_error("Mayan MCTP does not support direct native order creation")); + } + + Ok(evm_builder::build_forward_erc20_transaction( + Address::from_str(&route.from_token.contract)?, + protocol_call, + bridge_fee.to_string(), + )) +} + +async fn build_swap_forward_transaction( + client: &MayanClient, + route: &MayanMctpQuote, + protocol_call: &EvmForwarderProtocolCall, + bridge_fee: U256, +) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let mctp_input_contract = mctp_input_contract(route)?; + let min_middle_amount = fractional_amount::(route.min_middle_amount.as_ref().ok_or(SwapperError::InvalidRoute)?, CCTP_TOKEN_DECIMALS)?; + let swap: GetSwapEvmResponse = client + .get_swap( + "/get-swap/evm", + GetSwapEvmParams::mctp( + route, + route.effective_amount_in64.clone(), + mctp_input_contract.to_string(), + destination_referrer_address(route)?, + ), + ) + .await?; + let swap = EvmSwapForwardData::new(&swap.swap_router_address, &swap.swap_router_calldata, mctp_input_contract, min_middle_amount)?; + + if route.from_token.contract.eq_ignore_ascii_case(EVM_ZERO_ADDRESS) { + let amount_in = protocol_call + .amount_in + .checked_sub(bridge_fee) + .ok_or_else(|| SwapperError::transaction_error("Amount in is less than bridge fee"))?; + return Ok(evm_builder::build_swap_and_forward_eth_transaction( + protocol_call, + swap, + amount_in, + protocol_call.amount_in.to_string(), + )); + } + + Ok(evm_builder::build_swap_and_forward_erc20_transaction( + Address::from_str(&route.from_token.contract)?, + protocol_call, + swap, + bridge_fee.to_string(), + )) +} + +fn mctp_contract_address(route: &MayanMctpQuote) -> Result { + Address::from_str(route.mctp_mayan_contract.as_deref().ok_or(SwapperError::InvalidRoute)?).map_err(SwapperError::from) +} + +fn mctp_input_contract(route: &MayanMctpQuote) -> Result<&str, SwapperError> { + route.mctp_input_contract.as_deref().ok_or(SwapperError::InvalidRoute) +} + +fn bridge_fee(route: &MayanMctpQuote) -> Result { + fractional_amount(route.bridge_fee.as_ref().ok_or(SwapperError::InvalidRoute)?, gas_decimals(&route.from_chain)?) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/mod.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/mod.rs new file mode 100644 index 0000000000..193921b6c0 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/mod.rs @@ -0,0 +1,19 @@ +use crate::{ + SwapperError, + fees::default_referral_address, + mayan::{cctp_domain::CCTP_TOKEN_DECIMALS, model::MayanMctpQuote, tx_builder::amount::fractional_amount, wormhole_chain}, +}; + +pub(in crate::mayan) mod evm; +pub(in crate::mayan) mod solana; +pub(in crate::mayan) mod sui; + +fn destination_referrer_address(route: &MayanMctpQuote) -> Result, SwapperError> { + let chain = wormhole_chain::chain_for_name(&route.to_chain)?; + let address = default_referral_address(chain); + Ok((!address.is_empty()).then_some(address)) +} + +fn redeem_relayer_fee(route: &MayanMctpQuote) -> Result { + fractional_amount(route.redeem_relayer_fee.as_ref().ok_or(SwapperError::InvalidRoute)?, CCTP_TOKEN_DECIMALS) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/solana.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/solana.rs new file mode 100644 index 0000000000..cd1d76a979 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/solana.rs @@ -0,0 +1,410 @@ +use super::{destination_referrer_address, redeem_relayer_fee}; +use crate::{ + Quote, RpcProvider, SwapperError, SwapperQuoteData, + mayan::{ + cctp_domain::CCTP_TOKEN_DECIMALS, + client::MayanClient, + constants::{MAYAN_LOOKUP_TABLE_SOLANA, MAYAN_MCTP_PROGRAM_ID}, + model::{GetSwapSolanaParams, MayanMctpQuote, QuoteType, SolanaClientSwap}, + tx_builder::{ + address::native_address_to_bytes32, + amount::{fractional_amount, gas_drop_amount, min_amount_out, optional_bps_u8, value_to_query}, + route::quote_destination_address, + solana::{ + self as solana_builder, SolanaLedgerDeposit, SolanaTransaction, append_client_swap_instructions, append_ledger_deposit_instructions, solana_error, + wrap_instruction_in_cpi_proxy, + }, + }, + wormhole_chain::{WormholeChain, id_for_name as wormhole_chain_id}, + }, +}; +use gem_client::Client; +use gem_solana::SolanaAddress; +use rand::Rng; +use solana_primitives::anchor::global_discriminator; +use solana_primitives::associated_token::get_associated_token_address_with_program_id; +use solana_primitives::instructions::program_ids; +use solana_primitives::{AccountMeta, Instruction, Pubkey, find_program_address}; +use std::{fmt::Debug, sync::Arc}; + +const LEDGER_ORDER_SEED: &[u8] = b"LEDGER_ORDER"; +const LEDGER_BRIDGE_SEED: &[u8] = b"LEDGER_BRIDGE"; +const MCTP_MODE_WITH_FEE: u8 = 1; +const MCTP_MODE_LOCK_FEE: u8 = 2; +const MCTP_MODE_SWAP: u8 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BridgeMode { + WithFee, + LockFee, +} + +impl BridgeMode { + fn for_route(route: &MayanMctpQuote) -> Self { + if route.cheaper_chain.as_deref() == Some(WormholeChain::Solana.name()) { + Self::LockFee + } else { + Self::WithFee + } + } + + const fn ledger_mode(self) -> u8 { + match self { + Self::WithFee => MCTP_MODE_WITH_FEE, + Self::LockFee => MCTP_MODE_LOCK_FEE, + } + } + + const fn deposit_mode(self) -> &'static str { + match self { + Self::WithFee => "WITH_FEE", + Self::LockFee => "LOCK_FEE", + } + } +} + +struct MctpBuildContext { + user: Pubkey, + relayer: Pubkey, + ledger: Pubkey, + ledger_account: Pubkey, + random_key: Pubkey, + mctp_input_mint: Pubkey, + mctp_input_contract: String, + destination_address: String, + token_out: String, + referrer_address: Option, + bridge_mode: BridgeMode, +} + +impl MctpBuildContext { + fn new(quote: &Quote, route: &MayanMctpQuote) -> Result { + if route.to_chain == WormholeChain::Solana.name() { + return Err(SwapperError::NotSupportedChain); + } + + let wallet_address = quote.request.wallet_address.as_str(); + let relayer_address = route.relayer.as_deref().unwrap_or(wallet_address); + if relayer_address != wallet_address { + return Err(SwapperError::InvalidRoute); + } + + let user: Pubkey = SolanaAddress::parse(wallet_address).map_err(solana_error)?.into(); + let relayer: Pubkey = SolanaAddress::parse(relayer_address).map_err(solana_error)?.into(); + let mctp_program: Pubkey = SolanaAddress::parse(MAYAN_MCTP_PROGRAM_ID).map_err(solana_error)?.into(); + let mctp_input_contract = route.mctp_input_contract.clone().ok_or(SwapperError::InvalidRoute)?; + let mctp_input_mint: Pubkey = SolanaAddress::parse(&mctp_input_contract).map_err(solana_error)?.into(); + let random_key = random_pubkey(); + let seed_prefix = if route.has_auction == Some(true) { LEDGER_ORDER_SEED } else { LEDGER_BRIDGE_SEED }; + let (ledger, _) = find_program_address(&mctp_program, &[seed_prefix, user.as_bytes(), random_key.as_bytes()]).map_err(solana_error)?; + let ledger_account = get_associated_token_address_with_program_id(&ledger, &mctp_input_mint, &program_ids::token_program()); + let destination_address = quote_destination_address(quote).to_string(); + let token_out = mctp_token_out(route)?.to_string(); + let referrer_address = destination_referrer_address(route)?; + let bridge_mode = BridgeMode::for_route(route); + + Ok(Self { + user, + relayer, + ledger, + ledger_account, + random_key, + mctp_input_mint, + mctp_input_contract, + destination_address, + token_out, + referrer_address, + bridge_mode, + }) + } +} + +pub async fn build_quote_data(client: &MayanClient, quote: &Quote, route: &MayanMctpQuote, rpc_provider: Arc) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let transaction = build(client, quote, route).await?; + solana_builder::build_quote_data(quote, transaction, rpc_provider).await +} + +async fn build(client: &MayanClient, quote: &Quote, route: &MayanMctpQuote) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let context = MctpBuildContext::new(quote, route)?; + let mut instructions = Vec::new(); + let mut lookup_table_addresses = vec![MAYAN_LOOKUP_TABLE_SOLANA.to_string()]; + + if route.from_token.contract.as_str() == context.mctp_input_contract.as_str() { + add_direct_mctp_instructions(route, &context, &mut instructions)?; + } else { + lookup_table_addresses.extend(add_swap_instructions(client, quote, route, &context, &mut instructions).await?); + } + + Ok(SolanaTransaction::new(instructions, lookup_table_addresses)) +} + +fn add_direct_mctp_instructions(route: &MayanMctpQuote, context: &MctpBuildContext, instructions: &mut Vec) -> Result<(), SwapperError> { + let amount = route.effective_amount_in64.parse::()?; + append_ledger_deposit_instructions( + instructions, + SolanaLedgerDeposit { + user: &context.user, + relayer: &context.relayer, + ledger: &context.ledger, + ledger_account: &context.ledger_account, + mint: &context.mctp_input_mint, + amount, + suggested_priority_fee: route.suggested_priority_fee, + }, + )?; + + add_ledger_instruction(route, context, amount, solana_relayer_fee(route)?, instructions) +} + +async fn add_swap_instructions( + client: &MayanClient, + quote: &Quote, + route: &MayanMctpQuote, + context: &MctpBuildContext, + instructions: &mut Vec, +) -> Result, SwapperError> +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let min_middle_amount = route.min_middle_amount.as_ref().ok_or(SwapperError::InvalidRoute)?; + let deposit_mode = if route.has_auction == Some(true) { "SWAP" } else { context.bridge_mode.deposit_mode() }; + let swap: SolanaClientSwap = client + .get_swap( + "/get-swap/solana", + GetSwapSolanaParams::mctp( + route, + value_to_query(min_middle_amount)?, + context.mctp_input_contract.clone(), + quote.request.wallet_address.clone(), + route.effective_amount_in64.clone(), + deposit_mode, + context.referrer_address.clone(), + context.ledger.to_string(), + ), + ) + .await?; + + let lookup_table_addresses = append_client_swap_instructions( + instructions, + swap, + &context.user, + &context.relayer, + &route.from_token.contract, + &route.effective_amount_in64, + )?; + + add_ledger_instruction( + route, + context, + fractional_amount(min_middle_amount, CCTP_TOKEN_DECIMALS)?, + solana_relayer_fee(route)?, + instructions, + )?; + + Ok(lookup_table_addresses) +} + +fn add_ledger_instruction( + route: &MayanMctpQuote, + context: &MctpBuildContext, + amount_in_min64: u64, + fee_solana: u64, + instructions: &mut Vec, +) -> Result<(), SwapperError> { + if route.has_auction == Some(true) { + instructions.push(wrap_instruction_in_cpi_proxy(create_mctp_swap_ledger_instruction( + route, + context, + amount_in_min64, + fee_solana, + )?)?); + } else { + instructions.push(wrap_instruction_in_cpi_proxy(create_mctp_bridge_ledger_instruction( + route, + context, + amount_in_min64, + fee_solana, + )?)?); + } + Ok(()) +} + +fn create_mctp_bridge_ledger_instruction(route: &MayanMctpQuote, context: &MctpBuildContext, amount_in_min64: u64, fee_solana: u64) -> Result { + let destination_chain_id = wormhole_chain_id(&route.to_chain)?; + let mctp_program = SolanaAddress::parse(MAYAN_MCTP_PROGRAM_ID).map_err(solana_error)?.into(); + let data = bridge_ledger_data(route, context, amount_in_min64, fee_solana, destination_chain_id)?; + Ok(Instruction { + program_id: mctp_program, + accounts: vec![ + AccountMeta::new_signer_writable(context.user), + AccountMeta::new_writable(context.ledger), + AccountMeta::new_readonly(context.ledger_account), + AccountMeta::new_readonly(mctp_program), + AccountMeta::new_readonly(context.mctp_input_mint), + AccountMeta::new_readonly(program_ids::system_program()), + AccountMeta::new_signer_writable(context.relayer), + AccountMeta::new_readonly(mctp_referrer_pubkey(route)?), + ], + data, + }) +} + +fn create_mctp_swap_ledger_instruction(route: &MayanMctpQuote, context: &MctpBuildContext, amount_in_min64: u64, fee_solana: u64) -> Result { + let destination_chain_id = wormhole_chain_id(&route.to_chain)?; + let mctp_program = SolanaAddress::parse(MAYAN_MCTP_PROGRAM_ID).map_err(solana_error)?.into(); + let mut data = bridge_ledger_data(route, context, amount_in_min64, fee_solana, destination_chain_id)?; + data[..8].copy_from_slice(&global_discriminator("init_order_ledger_gasless")); + data[8 + 32 + 8 + 8 + 8 + 8 + 2 + 32] = MCTP_MODE_SWAP; + data.extend_from_slice(&native_address_to_bytes32(&context.token_out, destination_chain_id)?); + data.extend_from_slice(&min_amount_out(&route.min_amount_out, route.to_token.decimals, &route.to_chain, &QuoteType::Mctp)?.to_le_bytes()); + data.extend_from_slice(&route.deadline64.as_deref().ok_or(SwapperError::InvalidRoute)?.parse::()?.to_le_bytes()); + data.extend_from_slice(mctp_referrer_pubkey(route)?.as_bytes()); + data.push(optional_bps_u8(route.referrer_bps)?); + + Ok(Instruction { + program_id: mctp_program, + accounts: vec![ + AccountMeta::new_signer_writable(context.user), + AccountMeta::new_writable(context.ledger), + AccountMeta::new_readonly(context.ledger_account), + AccountMeta::new_readonly(context.mctp_input_mint), + AccountMeta::new_readonly(program_ids::system_program()), + AccountMeta::new_signer_writable(context.relayer), + ], + data, + }) +} + +fn bridge_ledger_data(route: &MayanMctpQuote, context: &MctpBuildContext, amount_in_min64: u64, fee_solana: u64, destination_chain_id: u16) -> Result, SwapperError> { + let mut data = Vec::with_capacity(107); + data.extend_from_slice(&global_discriminator("init_bridge_ledger_gasless")); + data.extend_from_slice(&native_address_to_bytes32(&context.destination_address, destination_chain_id)?); + data.extend_from_slice(&amount_in_min64.to_le_bytes()); + data.extend_from_slice(&gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::Mctp, false)?.to_le_bytes()); + data.extend_from_slice(&redeem_relayer_fee(route)?.to_le_bytes()); + data.extend_from_slice(&fee_solana.to_le_bytes()); + data.extend_from_slice(&destination_chain_id.to_le_bytes()); + data.extend_from_slice(context.random_key.as_bytes()); + data.push(context.bridge_mode.ledger_mode()); + Ok(data) +} + +fn mctp_token_out(route: &MayanMctpQuote) -> Result<&str, SwapperError> { + if route.to_chain == WormholeChain::Sui.name() { + return route.to_token.verified_address.as_deref().ok_or(SwapperError::InvalidRoute); + } + Ok(&route.to_token.contract) +} + +fn mctp_referrer_pubkey(route: &MayanMctpQuote) -> Result { + let Some(address) = destination_referrer_address(route)? else { + return Ok(program_ids::system_program()); + }; + let chain_id = wormhole_chain_id(&route.to_chain)?; + Ok(Pubkey::new(native_address_to_bytes32(&address, chain_id)?)) +} + +fn solana_relayer_fee(route: &MayanMctpQuote) -> Result { + route + .solana_relayer_fee64 + .as_deref() + .ok_or(SwapperError::InvalidRoute)? + .parse::() + .map_err(SwapperError::from) +} + +fn random_pubkey() -> Pubkey { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + Pubkey::new(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mayan::model::{MayanQuoteCommon, MayanToken}; + use primitives::{ + Chain, + asset_constants::{SOLANA_USDC_TOKEN_ID, SUI_USDC_TOKEN_ID}, + }; + + fn mctp_route() -> MayanMctpQuote { + MayanMctpQuote { + common: MayanQuoteCommon { + effective_amount_in64: "1000000".to_string(), + min_amount_out: serde_json::json!(0.7996), + gas_drop: serde_json::json!(0), + eta_seconds: 60, + from_token: MayanToken { + contract: SOLANA_USDC_TOKEN_ID.to_string(), + w_chain_id: 1, + decimals: 6, + verified_address: None, + }, + to_token: MayanToken { + contract: SUI_USDC_TOKEN_ID.to_string(), + w_chain_id: 21, + decimals: 6, + verified_address: Some("0x69b7a7c3c200439c1b5f3b19d7d495d5966d5f08de66c69276152f8db3992ec6".to_string()), + }, + from_chain: WormholeChain::Solana.name().to_string(), + to_chain: WormholeChain::Sui.name().to_string(), + slippage_bps: 0, + deadline64: Some("1779326929".to_string()), + referrer_bps: Some(50), + expected_amount_out_base_units: Some("799600".to_string()), + expected_amount_out: serde_json::json!(0.7996), + }, + min_middle_amount: Some(serde_json::json!(1)), + has_auction: Some(false), + cheaper_chain: Some(WormholeChain::Sui.name().to_string()), + bridge_fee: Some(serde_json::json!(0)), + redeem_relayer_fee: Some(serde_json::json!(0.2004)), + mctp_input_contract: Some(SOLANA_USDC_TOKEN_ID.to_string()), + mctp_mayan_contract: Some(MAYAN_MCTP_PROGRAM_ID.to_string()), + solana_relayer_fee64: Some("179182".to_string()), + suggested_priority_fee: Some(30000), + ..Default::default() + } + } + + #[test] + fn test_create_mctp_bridge_ledger_instruction() { + let mut quote = Quote::mock(Chain::Solana, Some(SOLANA_USDC_TOKEN_ID)); + quote.request.wallet_address = "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(); + quote.request.destination_address = "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".to_string(); + let route = mctp_route(); + let context = MctpBuildContext::new("e, &route).unwrap(); + let instruction = create_mctp_bridge_ledger_instruction(&route, &context, 1_000_000, 179_182).unwrap(); + + assert_eq!(instruction.program_id.to_string(), MAYAN_MCTP_PROGRAM_ID); + assert_eq!(instruction.accounts.len(), 8); + assert_eq!(instruction.accounts[3].pubkey.to_string(), MAYAN_MCTP_PROGRAM_ID); + assert_eq!(instruction.accounts[4].pubkey.to_string(), SOLANA_USDC_TOKEN_ID); + assert_eq!(instruction.accounts[7].pubkey, mctp_referrer_pubkey(&route).unwrap()); + assert_eq!(&instruction.data[..8], global_discriminator("init_bridge_ledger_gasless").as_slice()); + assert_eq!(instruction.data[8 + 32 + 8 + 8 + 8 + 8 + 2 + 32], MCTP_MODE_WITH_FEE); + } + + #[test] + fn test_create_mctp_swap_ledger_instruction_uses_order_discriminator() { + let mut quote = Quote::mock(Chain::Solana, Some(SOLANA_USDC_TOKEN_ID)); + quote.request.wallet_address = "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(); + quote.request.destination_address = "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".to_string(); + let mut route = mctp_route(); + route.has_auction = Some(true); + let context = MctpBuildContext::new("e, &route).unwrap(); + let instruction = create_mctp_swap_ledger_instruction(&route, &context, 1_000_000, 179_182).unwrap(); + + assert_eq!(instruction.program_id.to_string(), MAYAN_MCTP_PROGRAM_ID); + assert_eq!(instruction.accounts.len(), 6); + assert_eq!(&instruction.data[..8], global_discriminator("init_order_ledger_gasless").as_slice()); + assert_eq!(instruction.data[8 + 32 + 8 + 8 + 8 + 8 + 2 + 32], MCTP_MODE_SWAP); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/sui.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/sui.rs new file mode 100644 index 0000000000..965fce2691 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/sui.rs @@ -0,0 +1,113 @@ +mod prefetch; +mod transaction; + +use self::{prefetch::PrefetchedSuiData, transaction::build_mctp_transaction}; +use crate::mayan::{ + client::MayanClient, + constants::SDK_VERSION, + model::{GetSwapSuiParams, MayanMctpQuote, SuiClientSwap}, + tx_builder::route::quote_destination_address, + wormhole_chain::{self, WormholeChain}, +}; +use crate::{Quote, RpcProvider, SwapperError, SwapperQuoteData, client_factory::create_sui_client, fees::default_referral_address}; +use futures::try_join; +use gem_sui::tx_builder::prepare_transaction_json_replay; +use gem_sui::{ESTIMATION_GAS_BUDGET, gas_budget::GAS_BUDGET_MULTIPLIER}; +use std::{fmt::Debug, fmt::Display, sync::Arc}; + +pub async fn build_quote_data(client: &MayanClient, quote: &Quote, route: &MayanMctpQuote, rpc_provider: Arc) -> Result +where + C: gem_client::Client + Clone + Send + Sync + Debug + 'static, +{ + let sender = quote.request.wallet_address.as_str(); + let sui_client = create_sui_client(rpc_provider)?; + let destination_address = quote_destination_address(quote); + let mctp_input_contract = route.mctp_input_contract.as_deref().ok_or(SwapperError::InvalidRoute)?; + let referrer_address = wormhole_chain::chain_for_name(&route.to_chain) + .ok() + .map(default_referral_address) + .filter(|address| !address.is_empty()); + let prefetched = PrefetchedSuiData::prefetch(&sui_client, sender, route, ESTIMATION_GAS_BUDGET); + let swap = async { + let swap = get_swap_transaction(client, quote, route, mctp_input_contract, referrer_address).await?; + let swap_replay = match &swap { + Some(swap) => Some(prepare_transaction_json_replay(&sui_client, &swap.tx).await.map_err(sui_error)?), + None => None, + }; + Ok::<_, SwapperError>((swap, swap_replay)) + }; + let (prefetched, (swap, swap_replay)) = try_join!(prefetched, swap)?; + + let estimate = build_mctp_transaction(quote, route, &prefetched, destination_address, swap.as_ref(), swap_replay.as_ref(), ESTIMATION_GAS_BUDGET)?; + let dry_run = sui_client.dry_run(estimate.base64_encoded()).await.map_err(SwapperError::transaction_error)?; + if dry_run.effects.status.status != "success" { + let detail = dry_run.effects.status.error.as_deref().unwrap_or("no details available"); + return Err(SwapperError::TransactionError(format!("Sui swap simulation failed: {detail}"))); + } + + let fee = dry_run.effects.gas_used.calculate_gas_budget().map_err(SwapperError::transaction_error)?; + let gas_budget = fee * GAS_BUDGET_MULTIPLIER / 100; + let output = build_mctp_transaction(quote, route, &prefetched, destination_address, swap.as_ref(), swap_replay.as_ref(), gas_budget)?; + + Ok(SwapperQuoteData::new_contract( + String::new(), + "0".to_string(), + output.base64_encoded(), + None, + Some(gas_budget.to_string()), + )) +} + +async fn get_swap_transaction( + client: &MayanClient, + quote: &Quote, + route: &MayanMctpQuote, + mctp_input_contract: &str, + referrer_address: Option, +) -> Result, SwapperError> +where + C: gem_client::Client + Clone + Send + Sync + Debug + 'static, +{ + if route.from_token.contract.as_str() == mctp_input_contract { + return Ok(None); + } + + client + .post_swap( + "/get-swap/sui", + GetSwapSuiParams { + amount_in64: route.effective_amount_in64.clone(), + input_coin_type: route.from_token.contract.clone(), + middle_coin_type: mctp_input_contract.to_string(), + user_wallet: quote.request.wallet_address.clone(), + with_wh_fee: route.has_auction == Some(true) || route.cheaper_chain.as_deref() != Some(WormholeChain::Sui.name()), + referrer_address, + slippage_bps: route.slippage_bps, + chain_name: route.from_chain.clone(), + sdk_version: SDK_VERSION, + }, + ) + .await + .map(Some) +} + +fn sui_error(err: impl Display) -> SwapperError { + SwapperError::TransactionError(format!("Sui transaction error: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_sui::SUI_COIN_TYPE; + + #[test] + fn test_mctp_route_amounts() { + let route = MayanMctpQuote::mock(); + assert_eq!(transaction::bridge_amount(&route, route.mctp_input_contract.as_deref().unwrap()).unwrap(), 1000000); + + let mut route = route; + route.common.from_token.contract = SUI_COIN_TYPE.to_string(); + route.min_middle_amount = Some(serde_json::json!(0.99)); + assert_eq!(transaction::bridge_amount(&route, route.mctp_input_contract.as_deref().unwrap()).unwrap(), 990000); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/sui/prefetch.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/prefetch.rs new file mode 100644 index 0000000000..38e26c705b --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/prefetch.rs @@ -0,0 +1,162 @@ +use super::sui_error; +use crate::{ + SwapperError, + mayan::{ + constants::{SUI_CCTP_CORE_STATE, SUI_CCTP_DENY_LIST, SUI_CCTP_TOKEN_STATE, SUI_MCTP_FEE_MANAGER_STATE, SUI_MCTP_STATE, SUI_WORMHOLE_STATE}, + model::MayanMctpQuote, + }, +}; +use futures::try_join; +use gem_sui::{ + SuiClient, is_sui_coin, + models::{Coin, OwnedCoins}, + tx_builder::{ResolvedObjectInput, TransactionBuilderInput}, +}; +use serde::Deserialize; +use std::collections::HashMap; + +pub(super) struct PrefetchedSuiData { + pub(super) transaction: TransactionBuilderInput, + pub(super) input_coins: OwnedCoins, + pub(super) objects: HashMap, + pub(super) mctp_package_id: String, + pub(super) fee_manager_package_id: Option, + pub(super) mctp_input_contract: String, + pub(super) from_token_verified_address: String, + pub(super) mctp_verified_input_address: String, + pub(super) mctp_input_treasury: String, +} + +impl PrefetchedSuiData { + pub(super) async fn prefetch(client: &SuiClient, sender: &str, route: &MayanMctpQuote, gas_budget: u64) -> Result { + let mctp_input_contract = route.mctp_input_contract.clone().ok_or(SwapperError::InvalidRoute)?; + let from_token_verified_address = route.from_token.verified_address.clone().ok_or(SwapperError::InvalidRoute)?; + let mctp_verified_input_address = route.mctp_verified_input_address.clone().ok_or(SwapperError::InvalidRoute)?; + let mctp_input_treasury = route.mctp_input_treasury.clone().ok_or(SwapperError::InvalidRoute)?; + let has_auction = route.has_auction == Some(true); + let transaction = async { TransactionBuilderInput::prefetch(client, sender, gas_budget).await.map_err(sui_error) }; + let input_coins = async { + if route.from_token.contract.as_str() != mctp_input_contract.as_str() || is_sui_coin(&mctp_input_contract) { + Ok(OwnedCoins::default()) + } else { + client.get_coins(sender, &mctp_input_contract).await.map_err(sui_error) + } + }; + let object_ids = sui_object_ids(has_auction, &from_token_verified_address, &mctp_verified_input_address, &mctp_input_treasury); + let resolved_objects = async { + let objects = ResolvedObjectInput::get_multiple(client, object_ids.clone()).await.map_err(sui_error)?; + if objects.len() != object_ids.len() { + return Err(SwapperError::transaction_error("Failed to prefetch all Mayan Sui objects")); + } + Ok(objects) + }; + let mctp_package_id = get_mayan_sui_package_id(client, SUI_MCTP_STATE); + let fee_manager_package_id = async { + if has_auction { + get_mayan_sui_package_id(client, SUI_MCTP_FEE_MANAGER_STATE).await.map(Some) + } else { + Ok(None) + } + }; + let (transaction, input_coins, resolved_objects, mctp_package_id, fee_manager_package_id) = + try_join!(transaction, input_coins, resolved_objects, mctp_package_id, fee_manager_package_id)?; + let objects = object_ids.into_iter().zip(resolved_objects).collect(); + + Ok(Self { + transaction, + input_coins, + objects, + mctp_package_id, + fee_manager_package_id, + mctp_input_contract, + from_token_verified_address, + mctp_verified_input_address, + mctp_input_treasury, + }) + } +} + +fn sui_object_ids(has_auction: bool, from_token_verified_address: &str, mctp_verified_input_address: &str, mctp_input_treasury: &str) -> Vec { + let mut object_ids = vec![ + SUI_MCTP_STATE.to_string(), + SUI_CCTP_CORE_STATE.to_string(), + SUI_CCTP_TOKEN_STATE.to_string(), + SUI_CCTP_DENY_LIST.to_string(), + SUI_WORMHOLE_STATE.to_string(), + from_token_verified_address.to_string(), + mctp_verified_input_address.to_string(), + mctp_input_treasury.to_string(), + ]; + if has_auction { + object_ids.push(SUI_MCTP_FEE_MANAGER_STATE.to_string()); + } + object_ids +} + +async fn get_mayan_sui_package_id(client: &SuiClient, state_object_id: &str) -> Result { + let state: MayanStateObject = client.get_object_json(state_object_id.to_string()).await.map_err(sui_error)?; + Ok(state.latest_package_id()) +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum MayanStateObject { + Direct(MayanStateFields), + Fields { fields: MayanStateFields }, + Content { content: MayanStateContent }, + Data { data: MayanStateContent }, +} + +impl MayanStateObject { + fn latest_package_id(self) -> String { + match self { + Self::Direct(fields) => fields.latest_package_id, + Self::Fields { fields } => fields.latest_package_id, + Self::Content { content } => content.fields.latest_package_id, + Self::Data { data } => data.fields.latest_package_id, + } + } +} + +#[derive(Debug, Deserialize)] +struct MayanStateContent { + fields: MayanStateFields, +} + +#[derive(Debug, Deserialize)] +struct MayanStateFields { + #[serde(alias = "latestPackageId")] + latest_package_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mayan_state_object_latest_package_id() { + let state: MayanStateObject = serde_json::from_value(serde_json::json!({ + "latest_package_id": "0x123" + })) + .unwrap(); + assert_eq!(state.latest_package_id(), "0x123"); + + let state: MayanStateObject = serde_json::from_value(serde_json::json!({ + "fields": { + "latest_package_id": "0xabc" + } + })) + .unwrap(); + assert_eq!(state.latest_package_id(), "0xabc"); + + let state: MayanStateObject = serde_json::from_value(serde_json::json!({ + "content": { + "fields": { + "latestPackageId": "0xdef" + } + } + })) + .unwrap(); + assert_eq!(state.latest_package_id(), "0xdef"); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction.rs new file mode 100644 index 0000000000..135e7b0c8b --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction.rs @@ -0,0 +1,157 @@ +mod bridge; +mod fees; +mod order; + +use self::{ + bridge::{add_bridge_locked_fee_move_calls, add_bridge_with_fee_move_calls}, + order::add_init_order_move_calls, +}; +use super::{prefetch::PrefetchedSuiData, sui_error}; +use crate::mayan::{ + constants::{SUI_CCTP_CORE_STATE, SUI_CCTP_DENY_LIST, SUI_CCTP_TOKEN_PACKAGE_ID, SUI_CCTP_TOKEN_STATE, SUI_LOGGER_PACKAGE_ID, SUI_WORMHOLE_PACKAGE_ID, SUI_WORMHOLE_STATE}, + model::{MayanMctpQuote, SuiClientSwap}, + wormhole_chain::WormholeChain, +}; +use crate::{ + Quote, SwapperError, + mayan::tx_builder::{amount::optional_bps_u8, swift::referrer_bytes}, +}; +use gem_sui::{ + SUI_COIN_TYPE, + address::SuiAddress, + models::{OwnedCoins, TxOutput}, + sui_clock_object_input, + tx_builder::{TransactionJsonReplay, build_input_coin, finish_transaction, move_call}, +}; +use sui_transaction_builder::{Argument, TransactionBuilder}; +use sui_types::Address; + +#[cfg(test)] +pub(super) use fees::bridge_amount; + +pub(super) fn build_mctp_transaction( + quote: &Quote, + route: &MayanMctpQuote, + prefetched: &PrefetchedSuiData, + destination_address: &str, + swap: Option<&SuiClientSwap>, + swap_replay: Option<&TransactionJsonReplay>, + gas_budget: u64, +) -> Result { + let (mut txb, input_coin, wh_fee_coin) = input_coin(route, prefetched, swap, swap_replay)?; + + if route.has_auction == Some(true) { + add_init_order_move_calls(&mut txb, route, quote, prefetched, input_coin, wh_fee_coin, destination_address)?; + } else if route.cheaper_chain.as_deref() == Some(WormholeChain::Sui.name()) { + add_bridge_locked_fee_move_calls(&mut txb, route, prefetched, input_coin, destination_address)?; + } else { + add_bridge_with_fee_move_calls(&mut txb, route, prefetched, input_coin, wh_fee_coin, destination_address)?; + } + + log_initialize_mctp(&mut txb, route, prefetched)?; + log_referrer(&mut txb, route)?; + + finish_transaction(txb, prefetched.transaction.with_gas_budget(gas_budget)).map_err(sui_error) +} + +fn input_coin( + route: &MayanMctpQuote, + prefetched: &PrefetchedSuiData, + swap: Option<&SuiClientSwap>, + swap_replay: Option<&TransactionJsonReplay>, +) -> Result<(TransactionBuilder, Argument, Option), SwapperError> { + if let Some(swap) = swap { + let replayed = swap_replay.ok_or(SwapperError::InvalidRoute)?.replay().map_err(sui_error)?; + let input_coin = replayed.argument(&swap.out_coin).map_err(sui_error)?; + let wh_fee_coin = swap.wh_fee_coin.as_ref().map(|argument| replayed.argument(argument).map_err(sui_error)).transpose()?; + return Ok((replayed.txb, input_coin, wh_fee_coin)); + } + + let mut txb = TransactionBuilder::new(); + let input_coin = build_input_coin( + &mut txb, + &prefetched.mctp_input_contract, + route.effective_amount_in64.parse::()?, + &prefetched.input_coins, + ) + .map_err(sui_error)?; + Ok((txb, input_coin, None)) +} + +fn deposit_for_burn_with_auth( + txb: &mut TransactionBuilder, + prefetched: &PrefetchedSuiData, + mctp_input_contract: &str, + auth_module: &str, + deposit_ticket: Argument, + treasury: &str, +) -> Result { + let cctp_token_state = txb.object(prefetched.objects[SUI_CCTP_TOKEN_STATE].input(true)); + let cctp_core_state = txb.object(prefetched.objects[SUI_CCTP_CORE_STATE].input(true)); + let deny_list = txb.object(prefetched.objects[SUI_CCTP_DENY_LIST].input(false)); + let treasury = txb.object(prefetched.objects[treasury].input(true)); + let auth_type = format!("{}::{auth_module}::CircleAuth", prefetched.mctp_package_id); + let package = SuiAddress::parse(SUI_CCTP_TOKEN_PACKAGE_ID).map_err(sui_error)?.into(); + move_call( + txb, + package, + "deposit_for_burn", + "deposit_for_burn_with_caller_with_package_auth", + &[mctp_input_contract, &auth_type], + vec![deposit_ticket, cctp_token_state, cctp_core_state, deny_list, treasury], + ) + .map_err(sui_error) +} + +fn add_publish_wormhole_message( + txb: &mut TransactionBuilder, + prefetched: &PrefetchedSuiData, + wormhole_message: Argument, + bridge_fee: u64, + wh_fee_coin: Option, +) -> Result<(), SwapperError> { + let fee_coin = match wh_fee_coin { + Some(coin) => coin, + None => build_input_coin(txb, SUI_COIN_TYPE, bridge_fee, &OwnedCoins::default()).map_err(sui_error)?, + }; + let clock = txb.object(sui_clock_object_input()); + let wormhole_state = txb.object(prefetched.objects[SUI_WORMHOLE_STATE].input(true)); + let package = SuiAddress::parse(SUI_WORMHOLE_PACKAGE_ID).map_err(sui_error)?.into(); + move_call( + txb, + package, + "publish_message", + "publish_message", + &[], + vec![wormhole_state, fee_coin, wormhole_message, clock], + ) + .map_err(sui_error)?; + Ok(()) +} + +fn log_initialize_mctp(txb: &mut TransactionBuilder, route: &MayanMctpQuote, prefetched: &PrefetchedSuiData) -> Result<(), SwapperError> { + let amount = route.effective_amount_in64.parse::()?; + let verified_input = txb.object(prefetched.objects[&prefetched.from_token_verified_address].input(false)); + let amount = txb.pure(&amount); + let payload = txb.pure(&Vec::::new()); + let package = SuiAddress::parse(&prefetched.mctp_package_id).map_err(sui_error)?.into(); + move_call( + txb, + package, + "init_order", + "log_initialize_mctp", + &[&route.from_token.contract], + vec![amount, verified_input, payload], + ) + .map_err(sui_error)?; + Ok(()) +} + +fn log_referrer(txb: &mut TransactionBuilder, route: &MayanMctpQuote) -> Result<(), SwapperError> { + let referrer = referrer_bytes(&route.to_chain)?; + let referrer = txb.pure(&Address::new(referrer)); + let referrer_bps = txb.pure(&optional_bps_u8(route.referrer_bps)?); + let package = SuiAddress::parse(SUI_LOGGER_PACKAGE_ID).map_err(sui_error)?.into(); + move_call(txb, package, "referrer_logger", "log_referrer", &[], vec![referrer, referrer_bps]).map_err(sui_error)?; + Ok(()) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/bridge.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/bridge.rs new file mode 100644 index 0000000000..4b32cbc60a --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/bridge.rs @@ -0,0 +1,164 @@ +use super::{ + add_publish_wormhole_message, deposit_for_burn_with_auth, + fees::{bridge_amount, bridge_fee, redeem_fee}, +}; +use crate::{ + SwapperError, + mayan::{ + cctp_domain::domain_for_wormhole_chain, + constants::{SUI_CCTP_CORE_STATE, SUI_MCTP_STATE}, + model::{MayanMctpQuote, QuoteType}, + tx_builder::{address::native_address_to_bytes32, amount::gas_drop_amount}, + wormhole_chain::id_for_name as wormhole_chain_id, + }, +}; +use gem_sui::{address::SuiAddress, tx_builder::move_call}; +use sui_transaction_builder::{Argument, TransactionBuilder}; +use sui_types::Address; + +use super::super::prefetch::PrefetchedSuiData; +use super::sui_error; + +const MCTP_PAYLOAD_TYPE_DEFAULT: u8 = 1; +const BRIDGE_WITH_FEE_MODULE: &str = "bridge_with_fee"; +const BRIDGE_LOCKED_FEE_MODULE: &str = "bridge_locked_fee"; + +struct BridgeParams { + amount_in: u64, + destination: Address, + domain: u32, + gas_drop: u64, + redeem_fee: u64, +} + +impl BridgeParams { + fn new(route: &MayanMctpQuote, mctp_input_contract: &str, destination_address: &str) -> Result { + let destination_chain_id = wormhole_chain_id(&route.to_chain)?; + Ok(Self { + amount_in: bridge_amount(route, mctp_input_contract)?, + destination: Address::new(native_address_to_bytes32(destination_address, destination_chain_id)?), + domain: domain_for_wormhole_chain(&route.to_chain)?.id(), + gas_drop: gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::Mctp, false)?, + redeem_fee: redeem_fee(route)?, + }) + } +} + +pub(super) fn add_bridge_with_fee_move_calls( + txb: &mut TransactionBuilder, + route: &MayanMctpQuote, + prefetched: &PrefetchedSuiData, + input_coin: Argument, + wh_fee_coin: Option, + destination_address: &str, +) -> Result<(), SwapperError> { + let mctp_input_contract = prefetched.mctp_input_contract.as_str(); + let bridge = BridgeParams::new(route, mctp_input_contract, destination_address)?; + let payload = Vec::::new(); + let mctp_package = mctp_package_address(prefetched)?; + let bridge_arguments = vec![ + txb.pure(&MCTP_PAYLOAD_TYPE_DEFAULT), + input_coin, + txb.pure(&bridge.amount_in), + txb.pure(&bridge.destination), + txb.pure(&bridge.domain), + txb.pure(&bridge.gas_drop), + txb.pure(&bridge.redeem_fee), + txb.pure(&payload), + ]; + let bridge_ticket = move_call( + txb, + mctp_package, + BRIDGE_WITH_FEE_MODULE, + "prepare_bridge_with_fee", + &[mctp_input_contract], + bridge_arguments, + ) + .map_err(sui_error)?; + let (burn_request, cctp_message) = complete_bridge(txb, prefetched, mctp_input_contract, BRIDGE_WITH_FEE_MODULE, bridge_ticket)?; + let mctp_state = txb.object(prefetched.objects[SUI_MCTP_STATE].input(true)); + let wormhole_message = move_call( + txb, + mctp_package, + BRIDGE_WITH_FEE_MODULE, + "publish_bridge_with_fee", + &[], + vec![mctp_state, burn_request, cctp_message], + ) + .map_err(sui_error)?; + add_publish_wormhole_message(txb, prefetched, wormhole_message, bridge_fee(route)?, wh_fee_coin)?; + Ok(()) +} + +pub(super) fn add_bridge_locked_fee_move_calls( + txb: &mut TransactionBuilder, + route: &MayanMctpQuote, + prefetched: &PrefetchedSuiData, + input_coin: Argument, + destination_address: &str, +) -> Result<(), SwapperError> { + let mctp_input_contract = prefetched.mctp_input_contract.as_str(); + let bridge = BridgeParams::new(route, mctp_input_contract, destination_address)?; + let mctp_package = mctp_package_address(prefetched)?; + let bridge_arguments = vec![ + input_coin, + txb.pure(&bridge.amount_in), + txb.pure(&bridge.destination), + txb.pure(&bridge.domain), + txb.pure(&bridge.gas_drop), + txb.pure(&bridge.redeem_fee), + ]; + let bridge_ticket = move_call( + txb, + mctp_package, + BRIDGE_LOCKED_FEE_MODULE, + "prepare_bridge_locked_fee", + &[mctp_input_contract], + bridge_arguments, + ) + .map_err(sui_error)?; + let (burn_request, cctp_message) = complete_bridge(txb, prefetched, mctp_input_contract, BRIDGE_LOCKED_FEE_MODULE, bridge_ticket)?; + let mctp_state = txb.object(prefetched.objects[SUI_MCTP_STATE].input(true)); + let verified_input = txb.object(prefetched.objects[&prefetched.mctp_verified_input_address].input(false)); + move_call( + txb, + mctp_package, + BRIDGE_LOCKED_FEE_MODULE, + "store_bridge_locked_fee", + &[mctp_input_contract], + vec![mctp_state, verified_input, burn_request, cctp_message], + ) + .map_err(sui_error)?; + Ok(()) +} + +fn complete_bridge( + txb: &mut TransactionBuilder, + prefetched: &PrefetchedSuiData, + mctp_input_contract: &str, + module: &str, + bridge_ticket: Argument, +) -> Result<(Argument, Argument), SwapperError> { + let mctp_package = mctp_package_address(prefetched)?; + let mctp_state = txb.object(prefetched.objects[SUI_MCTP_STATE].input(true)); + let cctp_core_state = txb.object(prefetched.objects[SUI_CCTP_CORE_STATE].input(true)); + let verified_input = txb.object(prefetched.objects[&prefetched.mctp_verified_input_address].input(false)); + let bridge_result = move_call( + txb, + mctp_package, + module, + module, + &[mctp_input_contract], + vec![mctp_state, cctp_core_state, verified_input, bridge_ticket], + ) + .map_err(sui_error)?; + let bridge_result = bridge_result.to_nested(2); + let burn_request = bridge_result[0]; + let deposit_ticket = bridge_result[1]; + let cctp_message = deposit_for_burn_with_auth(txb, prefetched, mctp_input_contract, module, deposit_ticket, &prefetched.mctp_input_treasury)?.to_nested(2)[1]; + Ok((burn_request, cctp_message)) +} + +fn mctp_package_address(prefetched: &PrefetchedSuiData) -> Result { + SuiAddress::parse(&prefetched.mctp_package_id).map(Address::from).map_err(sui_error) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/fees.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/fees.rs new file mode 100644 index 0000000000..90a4c955e7 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/fees.rs @@ -0,0 +1,26 @@ +use crate::{ + SwapperError, + mayan::{ + cctp_domain::CCTP_TOKEN_DECIMALS, + model::MayanMctpQuote, + tx_builder::amount::{fractional_amount, value_to_query}, + tx_builder::mctp::redeem_relayer_fee, + }, +}; + +pub(super) fn redeem_fee(route: &MayanMctpQuote) -> Result { + redeem_relayer_fee(route) +} + +pub(in crate::mayan::tx_builder::mctp::sui) fn bridge_amount(route: &MayanMctpQuote, mctp_input_contract: &str) -> Result { + if route.from_token.contract.as_str() == mctp_input_contract { + return route.effective_amount_in64.parse::().map_err(SwapperError::from); + } + fractional_amount(route.min_middle_amount.as_ref().ok_or(SwapperError::InvalidRoute)?, CCTP_TOKEN_DECIMALS) +} + +pub(super) fn bridge_fee(route: &MayanMctpQuote) -> Result { + value_to_query(route.bridge_fee.as_ref().ok_or(SwapperError::InvalidRoute)?)? + .parse::() + .map_err(SwapperError::from) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/order.rs b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/order.rs new file mode 100644 index 0000000000..191d5e391c --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction/order.rs @@ -0,0 +1,106 @@ +use super::{ + add_publish_wormhole_message, deposit_for_burn_with_auth, + fees::{bridge_amount, bridge_fee, redeem_fee}, + sui_error, +}; +use crate::{ + Quote, SwapperError, + mayan::{ + cctp_domain::domain_for_wormhole_chain, + constants::{SUI_CCTP_CORE_STATE, SUI_MCTP_FEE_MANAGER_STATE, SUI_MCTP_STATE}, + model::{MayanMctpQuote, QuoteType}, + tx_builder::{ + address::native_address_to_bytes32, + amount::{gas_drop_amount, min_amount_out, optional_bps_u8}, + swift::{referrer_bytes, swift_to_token}, + }, + wormhole_chain::id_for_name as wormhole_chain_id, + }, +}; +use gem_sui::{address::SuiAddress, tx_builder::move_call}; +use sui_transaction_builder::{Argument, TransactionBuilder}; +use sui_types::Address; + +use super::super::prefetch::PrefetchedSuiData; + +const MCTP_INIT_ORDER_PAYLOAD_ID: u8 = 1; + +pub(super) fn add_init_order_move_calls( + txb: &mut TransactionBuilder, + route: &MayanMctpQuote, + quote: &Quote, + prefetched: &PrefetchedSuiData, + input_coin: Argument, + wh_fee_coin: Option, + destination_address: &str, +) -> Result<(), SwapperError> { + let destination_chain_id = wormhole_chain_id(&route.to_chain)?; + let mctp_input_contract = prefetched.mctp_input_contract.as_str(); + let fee_manager_package_id = prefetched.fee_manager_package_id.as_deref().ok_or(SwapperError::InvalidRoute)?; + let referrer = referrer_bytes(&route.to_chain)?; + let amount_out_min = min_amount_out(&route.min_amount_out, route.to_token.decimals, &route.to_chain, &QuoteType::Mctp)?; + let gas_drop = gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::Mctp, false)?; + let cctp_domain = domain_for_wormhole_chain(&route.to_chain)?.id(); + let fee_manager_package = Address::from(SuiAddress::parse(fee_manager_package_id).map_err(sui_error)?); + let mctp_package = Address::from(SuiAddress::parse(&prefetched.mctp_package_id).map_err(sui_error)?); + + let common_arguments = vec![ + txb.object(prefetched.objects[&prefetched.mctp_verified_input_address].input(false)), + txb.pure(&MCTP_INIT_ORDER_PAYLOAD_ID), + txb.pure(&Address::from(SuiAddress::parse("e.request.wallet_address).map_err(sui_error)?)), + input_coin, + txb.pure(&Address::new(native_address_to_bytes32(destination_address, destination_chain_id)?)), + txb.pure(&destination_chain_id), + txb.pure(&Address::new(swift_to_token(route)?)), + txb.pure(&amount_out_min), + txb.pure(&gas_drop), + txb.pure(&redeem_fee(route)?), + txb.pure(&route.deadline64.as_deref().ok_or(SwapperError::InvalidRoute)?.parse::()?), + txb.pure(&Address::new(referrer)), + txb.pure(&optional_bps_u8(route.referrer_bps)?), + ]; + let fee_ticket = move_call( + txb, + fee_manager_package, + "calculate_mctp_fee", + "prepare_calc_mctp_fee", + &[mctp_input_contract], + common_arguments.clone(), + ) + .map_err(sui_error)?; + let fee_manager_state = txb.object(prefetched.objects[SUI_MCTP_FEE_MANAGER_STATE].input(false)); + let fee_params = move_call( + txb, + fee_manager_package, + "calculate_mctp_fee", + "calculate_mctp_fee", + &[], + vec![fee_manager_state, fee_ticket], + ) + .map_err(sui_error)?; + + let mut init_arguments = common_arguments[1..].to_vec(); + init_arguments.push(txb.pure(&cctp_domain)); + init_arguments.push(txb.pure(&bridge_amount(route, mctp_input_contract)?)); + let init_ticket = move_call(txb, mctp_package, "init_order", "prepare_initialize_order", &[mctp_input_contract], init_arguments).map_err(sui_error)?; + let mctp_state = txb.object(prefetched.objects[SUI_MCTP_STATE].input(true)); + let cctp_core_state = txb.object(prefetched.objects[SUI_CCTP_CORE_STATE].input(true)); + let verified_input = txb.object(prefetched.objects[&prefetched.mctp_verified_input_address].input(false)); + let initialize_result = move_call( + txb, + mctp_package, + "init_order", + "initialize_order", + &[mctp_input_contract], + vec![mctp_state, cctp_core_state, verified_input, init_ticket, fee_params], + ) + .map_err(sui_error)?; + let initialize_result = initialize_result.to_nested(2); + let burn_request = initialize_result[0]; + let deposit_ticket = initialize_result[1]; + let cctp_message = deposit_for_burn_with_auth(txb, prefetched, mctp_input_contract, "init_order", deposit_ticket, &prefetched.mctp_input_treasury)?.to_nested(2)[1]; + let mctp_state = txb.object(prefetched.objects[SUI_MCTP_STATE].input(true)); + let wormhole_message = move_call(txb, mctp_package, "init_order", "publish_init_order", &[], vec![mctp_state, burn_request, cctp_message]).map_err(sui_error)?; + add_publish_wormhole_message(txb, prefetched, wormhole_message, bridge_fee(route)?, wh_fee_coin)?; + Ok(()) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mod.rs b/core/crates/swapper/src/mayan/tx_builder/mod.rs new file mode 100644 index 0000000000..85ff6b937d --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mod.rs @@ -0,0 +1,10 @@ +mod address; +mod amount; +mod evm; +pub(super) mod fast_mctp; +mod hypercore; +pub(super) mod mctp; +pub(super) mod mono_chain; +mod route; +mod solana; +pub(super) mod swift; diff --git a/core/crates/swapper/src/mayan/tx_builder/mono_chain/evm.rs b/core/crates/swapper/src/mayan/tx_builder/mono_chain/evm.rs new file mode 100644 index 0000000000..f78047407c --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mono_chain/evm.rs @@ -0,0 +1,145 @@ +use crate::{ + Quote, RpcProvider, SwapperError, SwapperQuoteData, + fees::default_referral_address, + mayan::{ + cctp_domain::CCTP_TOKEN_DECIMALS, + model::MayanMonoChainQuote, + tx_builder::{ + amount::fractional_amount, + evm::{self as evm_builder, EvmForwarderProtocolCall, EvmSwapForwardData, EvmTransaction}, + hypercore::hypercore_deposit_dex, + route::quote_destination_address, + }, + wormhole_chain::WormholeChain, + }, +}; +use alloy_primitives::{Address, U256}; +use alloy_sol_types::{SolCall, sol}; +use gem_evm::EVM_ZERO_ADDRESS; +use primitives::asset_constants::HYPEREVM_USDC_TOKEN_ID; +use std::{str::FromStr, sync::Arc}; + +const MAX_BPS: u32 = 10_000; + +sol! { + interface MayanHyperCoreDeposit { + function depositToHyperCore(address tokenIn, uint256 amountIn, uint16 referrerBps, address referrerAddr, address destAddr, uint32 destDex) external; + } +} + +fn hypercore_deposit_call(quote: &Quote, route: &MayanMonoChainQuote) -> Result { + if route.from_chain != WormholeChain::Hyperevm.name() || route.to_chain != WormholeChain::Hypercore.name() { + return Err(SwapperError::InvalidRoute); + } + + let contract_address = Address::from_str(&route.mono_chain_mayan_contract)?; + let amount_in = U256::from_str(&route.effective_amount_in64)?; + let referrer_address = default_referral_address(quote.request.from_asset.chain()); + let has_referrer = !referrer_address.is_empty(); + let referrer_bps = match route.referrer_bps { + Some(value) if value <= MAX_BPS => value as u16, + Some(_) => return Err(SwapperError::InvalidRoute), + None => 0, + }; + let data = MayanHyperCoreDeposit::depositToHyperCoreCall { + tokenIn: Address::from_str(HYPEREVM_USDC_TOKEN_ID)?, + amountIn: amount_in, + referrerBps: if has_referrer { referrer_bps } else { 0 }, + referrerAddr: if has_referrer { Address::from_str(&referrer_address)? } else { Address::ZERO }, + destAddr: Address::from_str(quote_destination_address(quote))?, + destDex: hypercore_deposit_dex(&route.to_token.contract)?, + } + .abi_encode(); + + Ok(EvmForwarderProtocolCall::new(amount_in, contract_address, data)) +} + +pub async fn build_quote_data(quote: &Quote, route: &MayanMonoChainQuote, rpc_provider: Arc) -> Result { + evm_builder::build_quote_data(build(quote, route), quote, rpc_provider).await +} + +async fn build(quote: &Quote, route: &MayanMonoChainQuote) -> Result { + let deposit_call = hypercore_deposit_call(quote, route)?; + if route.from_token.contract.eq_ignore_ascii_case(HYPEREVM_USDC_TOKEN_ID) { + return build_direct_forward_transaction(&deposit_call); + } + + build_swap_forward_transaction(route, &deposit_call) +} + +fn build_direct_forward_transaction(deposit_call: &EvmForwarderProtocolCall) -> Result { + Ok(evm_builder::build_forward_erc20_transaction(Address::from_str(HYPEREVM_USDC_TOKEN_ID)?, deposit_call, "0")) +} + +fn build_swap_forward_transaction(route: &MayanMonoChainQuote, deposit_call: &EvmForwarderProtocolCall) -> Result { + let min_middle_amount = fractional_amount::(&route.min_amount_out, CCTP_TOKEN_DECIMALS)?; + let swap = EvmSwapForwardData::new( + route.evm_swap_router_address.as_deref().ok_or(SwapperError::InvalidRoute)?, + route.evm_swap_router_calldata.as_deref().ok_or(SwapperError::InvalidRoute)?, + HYPEREVM_USDC_TOKEN_ID, + min_middle_amount, + )?; + + if route.from_token.contract.eq_ignore_ascii_case(EVM_ZERO_ADDRESS) { + return Ok(evm_builder::build_swap_and_forward_eth_transaction( + deposit_call, + swap, + deposit_call.amount_in, + deposit_call.amount_in.to_string(), + )); + } + + Ok(evm_builder::build_swap_and_forward_erc20_transaction( + Address::from_str(&route.from_token.contract)?, + deposit_call, + swap, + "0", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mayan::constants::MAYAN_FORWARDER; + use crate::mayan::model::{MayanQuoteCommon, MayanToken}; + use primitives::{Chain, asset_constants::HYPERCORE_SPOT_USDC_TOKEN_ID}; + + #[tokio::test] + async fn test_build_direct_hyperevm_to_hypercore_transaction() { + let mut quote = Quote::mock(Chain::Hyperliquid, Some(HYPEREVM_USDC_TOKEN_ID)); + quote.request.destination_address = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(); + quote.from_value = "1000000".to_string(); + let route = MayanMonoChainQuote { + common: MayanQuoteCommon { + effective_amount_in64: "1000000".to_string(), + min_amount_out: serde_json::json!(1), + from_chain: WormholeChain::Hyperevm.name().to_string(), + to_chain: WormholeChain::Hypercore.name().to_string(), + from_token: MayanToken { + contract: HYPEREVM_USDC_TOKEN_ID.to_string(), + w_chain_id: 47, + decimals: 6, + verified_address: None, + }, + to_token: MayanToken { + contract: crate::mayan::constants::HYPERCORE_SPOT_USDC_CONTRACT.to_string(), + w_chain_id: 65000, + decimals: 6, + verified_address: Some(HYPERCORE_SPOT_USDC_TOKEN_ID.to_string()), + }, + referrer_bps: Some(50), + ..Default::default() + }, + mono_chain_mayan_contract: "0xd788230d2d3d5460b030cc4f21f17250276399d1".to_string(), + evm_swap_router_address: None, + evm_swap_router_calldata: None, + }; + + let transaction = build("e, &route).await.unwrap(); + + assert_eq!(transaction.to, MAYAN_FORWARDER); + assert_eq!(transaction.value, "0"); + assert!(transaction.data.starts_with("0x")); + assert!(!transaction.data.is_empty()); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/mono_chain/mod.rs b/core/crates/swapper/src/mayan/tx_builder/mono_chain/mod.rs new file mode 100644 index 0000000000..534023b311 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/mono_chain/mod.rs @@ -0,0 +1 @@ +pub(in crate::mayan) mod evm; diff --git a/core/crates/swapper/src/mayan/tx_builder/route.rs b/core/crates/swapper/src/mayan/tx_builder/route.rs new file mode 100644 index 0000000000..f040d5bbd9 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/route.rs @@ -0,0 +1,37 @@ +use crate::{ + Quote, SwapperError, + mayan::{ + constants::HC_HYPEREVM_DEPOSIT_PROCESSOR, + model::{MayanQuoteCommon, MayanSwiftQuote}, + wormhole_chain::{self, WormholeChain}, + }, +}; +use std::borrow::Cow; + +pub(super) fn quote_destination_address(quote: &Quote) -> &str { + if quote.request.destination_address.is_empty() { + quote.request.wallet_address.as_str() + } else { + quote.request.destination_address.as_str() + } +} + +pub(super) fn swift_destination_address<'a>(quote: &'a Quote, route: &MayanSwiftQuote) -> Cow<'a, str> { + if is_hypercore_deposit(route) { + Cow::Borrowed(HC_HYPEREVM_DEPOSIT_PROCESSOR) + } else { + Cow::Borrowed(quote_destination_address(quote)) + } +} + +pub(super) fn swift_destination_chain(route: &MayanSwiftQuote) -> &str { + if is_hypercore_deposit(route) { WormholeChain::Hyperevm.name() } else { &route.to_chain } +} + +pub(super) fn swift_destination_chain_id(route: &MayanSwiftQuote) -> Result { + wormhole_chain::id_for_name(swift_destination_chain(route)) +} + +pub(super) fn is_hypercore_deposit(route: &MayanQuoteCommon) -> bool { + route.to_chain == WormholeChain::Hypercore.name() +} diff --git a/core/crates/swapper/src/mayan/tx_builder/solana.rs b/core/crates/swapper/src/mayan/tx_builder/solana.rs new file mode 100644 index 0000000000..b71b87f5b5 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/solana.rs @@ -0,0 +1,206 @@ +use crate::{ + Quote, RpcProvider, SwapperError, SwapperQuoteData, + client_factory::create_client_with_chain, + mayan::{constants::MAYAN_CPI_PROXY_PROGRAM_ID, model::SolanaClientSwap}, +}; +use futures::try_join; +use gem_encoding::decode_base64; +use gem_evm::EVM_ZERO_ADDRESS; +use gem_solana::{ + ASSOCIATED_TOKEN_ACCOUNT_PROGRAM, SYSTEM_PROGRAM_ID, SolanaAddress, SolanaClient, WSOL_TOKEN_ADDRESS, encode_v0_transaction, instruction_from_primitive, + instructions_from_primitives, +}; +use primitives::{Chain, SolanaInstruction}; +use solana_primitives::associated_token::{create_associated_token_account_idempotent_with_address, get_associated_token_address_with_program_id}; +use solana_primitives::instructions::program_ids; +use solana_primitives::{AccountMeta, Instruction, Pubkey, compute_budget, system, token}; +use std::{fmt::Display, sync::Arc}; + +#[derive(Debug)] +pub(in crate::mayan::tx_builder) struct SolanaTransaction { + instructions: Vec, + lookup_table_addresses: Vec, +} + +impl SolanaTransaction { + pub(in crate::mayan::tx_builder) fn new(instructions: Vec, lookup_table_addresses: Vec) -> Self { + Self { + instructions, + lookup_table_addresses, + } + } +} + +pub(in crate::mayan::tx_builder) async fn build_quote_data( + quote: &Quote, + transaction: SolanaTransaction, + rpc_provider: Arc, +) -> Result { + let rpc_client = SolanaClient::new(create_client_with_chain(rpc_provider, Chain::Solana)); + let lookup_tables = async { rpc_client.get_address_lookup_tables(transaction.lookup_table_addresses).await.map_err(solana_error) }; + let blockhash = async { rpc_client.get_latest_blockhash().await.map(|response| response.value.blockhash).map_err(SwapperError::from) }; + let (lookup_tables, blockhash) = try_join!(lookup_tables, blockhash)?; + let fee_payer = SolanaAddress::parse("e.request.wallet_address).map_err(solana_error)?.into(); + let data = encode_v0_transaction(fee_payer, &blockhash, &transaction.instructions, &lookup_tables).map_err(solana_error)?; + let gas_limit = compute_budget::get_compute_unit_limit(&transaction.instructions).map(|limit| limit.to_string()); + + Ok(SwapperQuoteData::new_contract(String::new(), "0".to_string(), data, None, gas_limit)) +} + +pub(in crate::mayan::tx_builder) struct SolanaLedgerDeposit<'a> { + pub user: &'a Pubkey, + pub relayer: &'a Pubkey, + pub ledger: &'a Pubkey, + pub ledger_account: &'a Pubkey, + pub mint: &'a Pubkey, + pub amount: u64, + pub suggested_priority_fee: Option, +} + +pub(in crate::mayan::tx_builder) fn append_ledger_deposit_instructions(instructions: &mut Vec, deposit: SolanaLedgerDeposit<'_>) -> Result<(), SwapperError> { + if let Some(priority_fee) = deposit.suggested_priority_fee.filter(|&fee| fee > 0) { + instructions.push(compute_budget::set_compute_unit_price(priority_fee)); + } + + instructions.push(wrap_instruction_in_cpi_proxy(create_associated_token_account_idempotent_with_address( + deposit.relayer, + deposit.ledger_account, + deposit.ledger, + deposit.mint, + &program_ids::token_program(), + ))?); + + let source_account = get_associated_token_address_with_program_id(deposit.user, deposit.mint, &program_ids::token_program()); + instructions.push(wrap_instruction_in_cpi_proxy(token::transfer( + &source_account, + deposit.ledger_account, + deposit.user, + deposit.amount, + ))?); + Ok(()) +} + +pub(in crate::mayan::tx_builder) fn append_client_swap_instructions( + instructions: &mut Vec, + swap: SolanaClientSwap, + user: &Pubkey, + relayer: &Pubkey, + from_token_contract: &str, + amount_in64: &str, +) -> Result, SwapperError> { + let setup = swap.setup_instructions.unwrap_or_default(); + let compute_budget = swap.compute_budget_instructions.unwrap_or_default(); + instructions.extend(instructions_from_primitives(compute_budget).map_err(solana_error)?); + if from_token_contract == EVM_ZERO_ADDRESS && !setup_wraps_native_sol(&setup, user)? { + instructions.extend(wrap_native_sol_instructions(user, amount_in64.parse::()?)?); + } + instructions.extend(setup_instructions(setup, relayer)?); + instructions.push(instruction_from_primitive(swap.swap_instruction).map_err(solana_error)?); + if let Some(cleanup_instruction) = swap.cleanup_instruction { + instructions.push(wrap_instruction_in_cpi_proxy(instruction_from_primitive(cleanup_instruction).map_err(solana_error)?)?); + } + Ok(swap.address_lookup_table_addresses) +} + +pub(in crate::mayan::tx_builder) fn setup_instructions(instructions: Vec, payer: &Pubkey) -> Result, SwapperError> { + instructions + .into_iter() + .map(|instruction| { + override_setup_payer(instruction, payer) + .and_then(|instruction| instruction_from_primitive(instruction).map_err(solana_error)) + .and_then(wrap_instruction_in_cpi_proxy) + }) + .collect() +} + +pub(in crate::mayan::tx_builder) fn setup_wraps_native_sol(instructions: &[SolanaInstruction], owner: &Pubkey) -> Result { + let wrapped_account = wrapped_sol_account(owner)?; + Ok(instructions.iter().any(|instruction| { + instruction.program_id == SYSTEM_PROGRAM_ID + && instruction.accounts.get(1).is_some_and(|account| account.pubkey == wrapped_account.to_string()) + && decode_base64(&instruction.data).is_ok_and(|data| data.starts_with(&[2, 0, 0, 0])) + })) +} + +pub(in crate::mayan::tx_builder) fn wrap_native_sol_instructions(owner: &Pubkey, amount: u64) -> Result, SwapperError> { + let wrapped_mint = wrapped_sol_mint()?; + let wrapped_account = get_associated_token_address_with_program_id(owner, &wrapped_mint, &program_ids::token_program()); + Ok(vec![ + wrap_instruction_in_cpi_proxy(create_associated_token_account_idempotent_with_address( + owner, + &wrapped_account, + owner, + &wrapped_mint, + &program_ids::token_program(), + ))?, + wrap_instruction_in_cpi_proxy(system::transfer(owner, &wrapped_account, amount))?, + wrap_instruction_in_cpi_proxy(token::sync_native(&wrapped_account))?, + ]) +} + +fn override_setup_payer(mut instruction: SolanaInstruction, payer: &Pubkey) -> Result { + if instruction.accounts.is_empty() { + return Ok(instruction); + } + let data = decode_base64(&instruction.data).map_err(solana_error)?; + let should_override = match instruction.program_id.as_str() { + SYSTEM_PROGRAM_ID => data.starts_with(&[0, 0, 0, 0]), + ASSOCIATED_TOKEN_ACCOUNT_PROGRAM => data.is_empty() || data.as_slice() == [1], + _ => false, + }; + if should_override { + instruction.accounts[0].pubkey = payer.to_string(); + } + Ok(instruction) +} + +fn wrapped_sol_account(owner: &Pubkey) -> Result { + Ok(get_associated_token_address_with_program_id(owner, &wrapped_sol_mint()?, &program_ids::token_program())) +} + +fn wrapped_sol_mint() -> Result { + Ok(SolanaAddress::parse(WSOL_TOKEN_ADDRESS).map_err(solana_error)?.into()) +} + +pub(in crate::mayan::tx_builder) fn wrap_instruction_in_cpi_proxy(instruction: Instruction) -> Result { + let mut accounts = Vec::with_capacity(instruction.accounts.len() + 1); + accounts.push(AccountMeta::new_readonly(instruction.program_id)); + accounts.extend(instruction.accounts); + Ok(Instruction { + program_id: SolanaAddress::parse(MAYAN_CPI_PROXY_PROGRAM_ID).map_err(solana_error)?.into(), + accounts, + data: instruction.data, + }) +} + +pub(in crate::mayan::tx_builder) fn solana_error(err: impl Display) -> SwapperError { + SwapperError::transaction_error(err) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wrap_instruction_in_cpi_proxy_uses_deployed_program_id() { + let program_id = Pubkey::new([1; 32]); + let account = Pubkey::new([2; 32]); + let instruction = Instruction { + program_id, + accounts: vec![AccountMeta::new_readonly(account)], + data: vec![1, 2, 3], + }; + + let wrapped = wrap_instruction_in_cpi_proxy(instruction).unwrap(); + + assert_eq!(wrapped.program_id.to_string(), MAYAN_CPI_PROXY_PROGRAM_ID); + assert_eq!(wrapped.accounts.len(), 2); + assert_eq!(wrapped.accounts[0].pubkey, program_id); + assert!(!wrapped.accounts[0].is_signer); + assert!(!wrapped.accounts[0].is_writable); + assert_eq!(wrapped.accounts[1].pubkey, account); + assert!(!wrapped.accounts[1].is_signer); + assert!(!wrapped.accounts[1].is_writable); + assert_eq!(wrapped.data, vec![1, 2, 3]); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/evm.rs b/core/crates/swapper/src/mayan/tx_builder/swift/evm.rs new file mode 100644 index 0000000000..49b31bece7 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/evm.rs @@ -0,0 +1,85 @@ +mod contracts; +mod order; +mod transaction; + +use crate::mayan::{client::MayanClient, model::MayanSwiftQuote}; +use crate::{Quote, SwapperError, SwapperQuoteData, mayan::tx_builder::evm as evm_builder}; +use gem_client::Client; +use std::{fmt::Debug, sync::Arc}; + +use crate::alien::RpcProvider; + +pub async fn build_quote_data(client: &MayanClient, quote: &Quote, route: &MayanSwiftQuote, rpc_provider: Arc) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + evm_builder::build_quote_data(transaction::build(client, quote, route), quote, rpc_provider).await +} + +#[cfg(test)] +mod tests { + use super::order::swift_order; + use super::*; + use crate::mayan::{ + constants::HYPERCORE_SPOT_USDC_CONTRACT, + model::{HcSwiftDeposit, MayanQuote}, + tx_builder::{address::native_address_to_bytes32, hypercore::hypercore_custom_payload, route::quote_destination_address}, + wormhole_chain::WormholeChain, + }; + use gem_evm::EVM_ZERO_ADDRESS; + use gem_solana::SYSTEM_PROGRAM_ID; + use primitives::Chain; + use primitives::decode_hex; + + fn swift_route() -> MayanSwiftQuote { + let route: MayanQuote = serde_json::from_str(include_str!("../../test/swift_quote_evm_to_solana.json")).unwrap(); + route.as_swift().unwrap().clone() + } + + #[test] + fn test_native_address_to_bytes32() { + assert_eq!(native_address_to_bytes32(SYSTEM_PROGRAM_ID, 1).unwrap(), [0u8; 32]); + assert_eq!(native_address_to_bytes32(EVM_ZERO_ADDRESS, 2).unwrap(), [0u8; 32]); + } + + #[test] + fn test_swift_random_key_with_memo() { + let random = super::super::swift_random_key(&swift_route()).unwrap(); + let mut expected = [0u8; 32]; + expected[15] = 1; + expected[31] = 2; + assert_eq!(random, expected); + } + + #[test] + fn test_swift_order() { + let mut quote = crate::Quote::mock(Chain::Ethereum, None); + quote.request.wallet_address = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(); + quote.request.destination_address = "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(); + let order = swift_order("e, &swift_route(), 2, quote_destination_address("e), None).unwrap(); + assert_eq!(order.destChainId, 1); + assert_eq!(order.minAmountOut, 118_381_871); + assert_eq!(order.referrerBps, 50); + assert_eq!(order.auctionMode, 2); + } + + #[test] + fn test_hypercore_custom_payload() { + let mut route = swift_route(); + route.common.to_chain = WormholeChain::Hypercore.name().to_string(); + route.common.to_token.contract = HYPERCORE_SPOT_USDC_CONTRACT.to_string(); + route.common.to_token.w_chain_id = 65000; + route.common.to_token.decimals = 6; + route.hc_swift_deposit = Some(HcSwiftDeposit { + relayer_fee64: "500000".to_string(), + }); + + let destination = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"; + let payload = hypercore_custom_payload(&route, destination).unwrap().unwrap(); + + assert_eq!(payload.len(), 32); + assert_eq!(&payload[..20], decode_hex(destination).unwrap().as_slice()); + assert_eq!(&payload[20..24], 65535u32.to_be_bytes().as_slice()); + assert_eq!(&payload[24..32], 500000u64.to_be_bytes().as_slice()); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/evm/contracts.rs b/core/crates/swapper/src/mayan/tx_builder/swift/evm/contracts.rs new file mode 100644 index 0000000000..52cd340547 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/evm/contracts.rs @@ -0,0 +1,24 @@ +use alloy_sol_types::sol; + +sol! { + interface MayanSwiftV2 { + struct OrderParams { + uint8 payloadType; + bytes32 trader; + bytes32 destAddr; + uint16 destChainId; + bytes32 referrerAddr; + bytes32 tokenOut; + uint64 minAmountOut; + uint64 gasDrop; + uint64 cancelFee; + uint64 refundFee; + uint64 deadline; + uint8 referrerBps; + uint8 auctionMode; + bytes32 random; + } + + function createOrderWithToken(address tokenIn, uint256 amountIn, OrderParams params, bytes customPayload) external payable returns (bytes32 orderHash); + } +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/evm/order.rs b/core/crates/swapper/src/mayan/tx_builder/swift/evm/order.rs new file mode 100644 index 0000000000..890a68cb2c --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/evm/order.rs @@ -0,0 +1,39 @@ +use super::contracts::MayanSwiftV2; +use crate::{ + Quote, SwapperError, + mayan::{ + model::MayanSwiftQuote, + tx_builder::{ + address::native_address_to_bytes32, + swift::{SwiftOrderFields, swift_payload_type, swift_random_key}, + }, + }, +}; +use alloy_primitives::FixedBytes; + +pub(super) fn swift_order( + quote: &Quote, + route: &MayanSwiftQuote, + source_chain_id: u16, + destination_address: &str, + custom_payload: Option<&[u8]>, +) -> Result { + let fields = SwiftOrderFields::new(route)?; + + Ok(MayanSwiftV2::OrderParams { + payloadType: swift_payload_type(custom_payload), + trader: FixedBytes::from(native_address_to_bytes32("e.request.wallet_address, source_chain_id)?), + destAddr: FixedBytes::from(native_address_to_bytes32(destination_address, fields.destination_chain_id)?), + destChainId: fields.destination_chain_id, + referrerAddr: FixedBytes::from(fields.referrer), + tokenOut: FixedBytes::from(fields.token_out), + minAmountOut: fields.amount_out_min, + gasDrop: fields.gas_drop, + cancelFee: fields.cancel_fee, + refundFee: fields.refund_fee, + deadline: fields.deadline, + referrerBps: fields.referrer_bps, + auctionMode: fields.auction_mode, + random: FixedBytes::from(swift_random_key(route)?), + }) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/evm/transaction.rs b/core/crates/swapper/src/mayan/tx_builder/swift/evm/transaction.rs new file mode 100644 index 0000000000..546d0423b1 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/evm/transaction.rs @@ -0,0 +1,110 @@ +use super::{contracts::MayanSwiftV2, order::swift_order}; +use crate::{ + Quote, SwapperError, + mayan::{ + client::MayanClient, + model::{GetSwapEvmParams, GetSwapEvmResponse, MayanSwiftQuote}, + tx_builder::{ + amount::fractional_amount, + evm::{self as evm_builder, EvmForwarderProtocolCall, EvmSwapForwardData, EvmTransaction}, + hypercore::hypercore_custom_payload, + route::{quote_destination_address, swift_destination_address}, + swift::swift_input_contract as route_swift_input_contract, + }, + wormhole_chain::id_for_name as wormhole_chain_id, + }, +}; +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::SolCall; +use gem_client::Client; +use gem_evm::EVM_ZERO_ADDRESS; +use std::{fmt::Debug, str::FromStr}; + +struct EvmSwiftContext { + swift_input_contract: String, + swift_token_in: Address, + protocol_call: EvmForwarderProtocolCall, +} + +impl EvmSwiftContext { + fn new(quote: &Quote, route: &MayanSwiftQuote) -> Result { + let source_chain_id = wormhole_chain_id(&route.from_chain)?; + let amount_in = U256::from_str(&route.effective_amount_in64)?; + let swift_input_contract = route_swift_input_contract(route)?.to_string(); + let swift_contract_address = Address::from_str(route.swift_mayan_contract.as_deref().ok_or(SwapperError::InvalidRoute)?)?; + let swift_token_in = if route.swift_wrap_and_lock == Some(true) { + Address::ZERO + } else { + Address::from_str(&swift_input_contract)? + }; + let destination_address = swift_destination_address(quote, route); + let custom_payload = hypercore_custom_payload(route, quote_destination_address(quote))?; + let order = swift_order(quote, route, source_chain_id, destination_address.as_ref(), custom_payload.as_deref())?; + let data = MayanSwiftV2::createOrderWithTokenCall { + tokenIn: swift_token_in, + amountIn: amount_in, + params: order, + customPayload: Bytes::from(custom_payload.unwrap_or_default()), + } + .abi_encode(); + + Ok(Self { + swift_input_contract, + swift_token_in, + protocol_call: EvmForwarderProtocolCall::new(amount_in, swift_contract_address, data), + }) + } +} + +pub(super) async fn build(client: &MayanClient, quote: &Quote, route: &MayanSwiftQuote) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let context = EvmSwiftContext::new(quote, route)?; + if route.from_token.contract.eq_ignore_ascii_case(&context.swift_input_contract) { + build_direct_forward_transaction(route, &context) + } else { + build_swap_forward_transaction(client, route, &context).await + } +} + +fn build_direct_forward_transaction(route: &MayanSwiftQuote, context: &EvmSwiftContext) -> Result { + if route.from_token.contract.eq_ignore_ascii_case(EVM_ZERO_ADDRESS) { + return Err(SwapperError::transaction_error("Mayan Swift V2 does not support direct native order creation")); + } + + Ok(evm_builder::build_forward_erc20_transaction(context.swift_token_in, &context.protocol_call, "0")) +} + +async fn build_swap_forward_transaction(client: &MayanClient, route: &MayanSwiftQuote, context: &EvmSwiftContext) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let min_middle_amount = fractional_amount::( + route.min_middle_amount.as_ref().ok_or(SwapperError::InvalidRoute)?, + route.swift_input_decimals.ok_or(SwapperError::InvalidRoute)?, + )?; + let swap: GetSwapEvmResponse = client + .get_swap( + "/get-swap/evm", + GetSwapEvmParams::swift(route, route.effective_amount_in64.clone(), context.swift_input_contract.clone()), + ) + .await?; + let swap = EvmSwapForwardData::new(&swap.swap_router_address, &swap.swap_router_calldata, &context.swift_input_contract, min_middle_amount)?; + + if route.from_token.contract.eq_ignore_ascii_case(EVM_ZERO_ADDRESS) { + return Ok(evm_builder::build_swap_and_forward_eth_transaction( + &context.protocol_call, + swap, + context.protocol_call.amount_in, + context.protocol_call.amount_in.to_string(), + )); + } + + Ok(evm_builder::build_swap_and_forward_erc20_transaction( + Address::from_str(&route.from_token.contract)?, + &context.protocol_call, + swap, + "0", + )) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/mod.rs b/core/crates/swapper/src/mayan/tx_builder/swift/mod.rs new file mode 100644 index 0000000000..9d1b1db794 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/mod.rs @@ -0,0 +1,142 @@ +pub(in crate::mayan) mod evm; +pub(in crate::mayan) mod solana; + +use super::{ + address::native_address_to_bytes32, + amount::{gas_drop_amount, min_amount_out, optional_bps_u8}, + route::{is_hypercore_deposit, swift_destination_chain, swift_destination_chain_id}, +}; +use crate::{ + SwapperError, + fees::default_referral_address, + mayan::{ + model::{MayanQuoteCommon, MayanSwiftQuote, QuoteType}, + wormhole_chain::{self, WormholeChain, id_for_name as wormhole_chain_id}, + }, +}; +use gem_evm::EVM_ZERO_ADDRESS; +use gem_hash::keccak::keccak256; +use gem_solana::SYSTEM_PROGRAM_ID; +use primitives::{asset_constants::HYPEREVM_USDC_TOKEN_ID, decode_hex}; +use rand::Rng; + +const SWIFT_PAYLOAD_TYPE_DEFAULT: u8 = 1; +const SWIFT_PAYLOAD_TYPE_CUSTOM_PAYLOAD: u8 = 2; + +pub(super) struct SwiftOrderFields { + pub(super) destination_chain_id: u16, + pub(super) referrer: [u8; 32], + pub(super) token_out: [u8; 32], + pub(super) amount_out_min: u64, + pub(super) gas_drop: u64, + pub(super) cancel_fee: u64, + pub(super) refund_fee: u64, + pub(super) deadline: u64, + pub(super) referrer_bps: u8, + pub(super) auction_mode: u8, +} + +impl SwiftOrderFields { + pub(super) fn new(route: &MayanSwiftQuote) -> Result { + let destination_chain_id = swift_destination_chain_id(route)?; + if !is_hypercore_deposit(route) && route.to_token.w_chain_id != destination_chain_id { + return Err(SwapperError::InvalidRoute); + } + + let destination_chain = swift_destination_chain(route); + Ok(Self { + destination_chain_id, + referrer: referrer_bytes(&route.from_chain)?, + token_out: swift_to_token(route)?, + amount_out_min: min_amount_out(&route.min_amount_out, route.to_token.decimals, destination_chain, &QuoteType::Swift)?, + gas_drop: gas_drop_amount(&route.gas_drop, &route.to_chain, &QuoteType::Swift, is_hypercore_deposit(route))?, + cancel_fee: required_u64(route.cancel_relayer_fee64.as_deref())?, + refund_fee: required_u64(route.refund_relayer_fee64.as_deref())?, + deadline: required_u64(route.deadline64.as_deref())?, + referrer_bps: optional_bps_u8(route.referrer_bps)?, + auction_mode: route.swift_auction_mode.ok_or(SwapperError::InvalidRoute)?, + }) + } +} + +pub(super) fn swift_input_contract(route: &MayanSwiftQuote) -> Result<&str, SwapperError> { + route.swift_input_contract.as_deref().ok_or(SwapperError::InvalidRoute) +} + +pub(super) fn swift_to_token(route: &MayanQuoteCommon) -> Result<[u8; 32], SwapperError> { + let (address, chain) = swift_to_token_address(route)?; + token_address_to_bytes32(address, chain) +} + +pub(super) fn token_address_to_bytes32(token_address: &str, chain: &str) -> Result<[u8; 32], SwapperError> { + let address = if chain == WormholeChain::Solana.name() && token_address == EVM_ZERO_ADDRESS { + SYSTEM_PROGRAM_ID + } else { + token_address + }; + native_address_to_bytes32(address, wormhole_chain_id(chain)?) +} + +pub(super) fn referrer_bytes(chain: &str) -> Result<[u8; 32], SwapperError> { + let referrer_chain = wormhole_chain::chain_for_name(chain)?; + let referrer = default_referral_address(referrer_chain); + if referrer.is_empty() { + return Err(SwapperError::InvalidRoute); + } + token_address_to_bytes32(&referrer, chain) +} + +fn swift_to_token_address(route: &MayanQuoteCommon) -> Result<(&str, &str), SwapperError> { + if is_hypercore_deposit(route) { + return Ok((HYPEREVM_USDC_TOKEN_ID, WormholeChain::Hyperevm.name())); + } + if route.to_chain == WormholeChain::Sui.name() { + return Ok((route.to_token.verified_address.as_deref().ok_or(SwapperError::InvalidRoute)?, WormholeChain::Sui.name())); + } + Ok((&route.to_token.contract, &route.to_chain)) +} + +pub(super) fn swift_random_key(route: &MayanSwiftQuote) -> Result<[u8; 32], SwapperError> { + let quote_id = route.quote_id.as_deref().ok_or(SwapperError::InvalidRoute)?; + let id = left_pad_16(&decode_hex(quote_id)?)?; + let memo_or_random = if let Some(memo_hex) = &route.memo_hex { + left_pad_16(&decode_hex(memo_hex)?)? + } else { + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + bytes + }; + let mut random = [0u8; 32]; + random[..16].copy_from_slice(&id); + random[16..].copy_from_slice(&memo_or_random); + Ok(random) +} + +pub(super) fn swift_payload_type(custom_payload: Option<&[u8]>) -> u8 { + if custom_payload.is_some() { + SWIFT_PAYLOAD_TYPE_CUSTOM_PAYLOAD + } else { + SWIFT_PAYLOAD_TYPE_DEFAULT + } +} + +pub(super) fn swift_custom_payload_hash(custom_payload: Option<&[u8]>) -> Result<[u8; 32], SwapperError> { + if let Some(custom_payload) = custom_payload { + Ok(keccak256(custom_payload)) + } else { + native_address_to_bytes32(SYSTEM_PROGRAM_ID, wormhole_chain_id(WormholeChain::Solana.name())?) + } +} + +fn left_pad_16(bytes: &[u8]) -> Result<[u8; 16], SwapperError> { + if bytes.len() > 16 { + return Err(SwapperError::InvalidRoute); + } + let mut padded = [0u8; 16]; + padded[16 - bytes.len()..].copy_from_slice(bytes); + Ok(padded) +} + +fn required_u64(value: Option<&str>) -> Result { + value.ok_or(SwapperError::InvalidRoute)?.parse::().map_err(SwapperError::from) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/solana.rs b/core/crates/swapper/src/mayan/tx_builder/swift/solana.rs new file mode 100644 index 0000000000..73f9fae3d2 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/solana.rs @@ -0,0 +1,16 @@ +mod order; +mod payload; +mod transaction; + +use crate::mayan::{client::MayanClient, model::MayanSwiftQuote, tx_builder::solana as solana_builder}; +use crate::{Quote, RpcProvider, SwapperError, SwapperQuoteData}; +use gem_client::Client; +use std::{fmt::Debug, sync::Arc}; + +pub async fn build_quote_data(client: &MayanClient, quote: &Quote, route: &MayanSwiftQuote, rpc_provider: Arc) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let transaction = transaction::build(client, quote, route).await?; + solana_builder::build_quote_data(quote, transaction, rpc_provider).await +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/solana/order.rs b/core/crates/swapper/src/mayan/tx_builder/swift/solana/order.rs new file mode 100644 index 0000000000..fb808b00bf --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/solana/order.rs @@ -0,0 +1,124 @@ +use crate::{ + SwapperError, + mayan::{ + constants::{MAYAN_FEE_MANAGER_PROGRAM_ID, MAYAN_SWIFT_V2_PROGRAM_ID}, + model::MayanSwiftQuote, + tx_builder::{ + address::native_address_to_bytes32, + amount::{fractional_amount, optional_bps_u8}, + solana::solana_error, + swift::{SwiftOrderFields, swift_custom_payload_hash, swift_input_contract as route_swift_input_contract, swift_payload_type, token_address_to_bytes32}, + }, + wormhole_chain::id_for_name as wormhole_chain_id, + }, +}; +use gem_evm::EVM_ZERO_ADDRESS; +use gem_hash::keccak::keccak256; +use gem_solana::{SYSTEM_PROGRAM_ID, SolanaAddress}; +use solana_primitives::anchor::global_discriminator; +use solana_primitives::{AccountMeta, Instruction, Pubkey}; + +const SWIFT_ORDER_DATA_SIZE_V2: usize = 272; + +pub(super) fn create_init_instruction( + route: &MayanSwiftQuote, + state: &Pubkey, + trader: &Pubkey, + relayer: &Pubkey, + state_account: &Pubkey, + relayer_account: &Pubkey, + swift_input_mint: &Pubkey, + token_program: &Pubkey, + destination_address: &str, + random_key: [u8; 32], + custom_payload_account: Option<&Pubkey>, + fields: &SwiftOrderFields, +) -> Result { + let swift_input_contract = route_swift_input_contract(route)?; + let amount_in_min = if route.from_token.contract.as_str() == swift_input_contract { + route.effective_amount_in64.parse::()? + } else { + fractional_amount::( + route.min_middle_amount.as_ref().ok_or(SwapperError::InvalidRoute)?, + route.swift_input_decimals.ok_or(SwapperError::InvalidRoute)?, + )? + }; + + let mut data = Vec::new(); + data.extend_from_slice(&global_discriminator("init_order")); + data.extend_from_slice(&amount_in_min.to_le_bytes()); + data.push(u8::from(swift_input_contract == EVM_ZERO_ADDRESS)); + data.extend_from_slice(&route.submit_relayer_fee64.as_deref().ok_or(SwapperError::InvalidRoute)?.parse::()?.to_le_bytes()); + data.extend_from_slice(&native_address_to_bytes32(destination_address, fields.destination_chain_id)?); + data.extend_from_slice(&fields.destination_chain_id.to_le_bytes()); + data.extend_from_slice(&fields.token_out); + data.extend_from_slice(&fields.amount_out_min.to_le_bytes()); + data.extend_from_slice(&fields.gas_drop.to_le_bytes()); + data.extend_from_slice(&fields.cancel_fee.to_le_bytes()); + data.extend_from_slice(&fields.refund_fee.to_le_bytes()); + data.extend_from_slice(&fields.deadline.to_le_bytes()); + data.extend_from_slice(&fields.referrer); + data.push(fields.referrer_bps); + data.push(optional_bps_u8(route.protocol_bps)?); + data.push(fields.auction_mode); + data.extend_from_slice(&random_key); + + let swift_program = SolanaAddress::parse(MAYAN_SWIFT_V2_PROGRAM_ID).map_err(solana_error)?.into(); + let fee_manager = SolanaAddress::parse(MAYAN_FEE_MANAGER_PROGRAM_ID).map_err(solana_error)?.into(); + let system_program = SolanaAddress::parse(SYSTEM_PROGRAM_ID).map_err(solana_error)?.into(); + + Ok(Instruction { + program_id: swift_program, + accounts: vec![ + AccountMeta::new_readonly(*trader), + AccountMeta::new(*relayer, true, true), + AccountMeta::new_writable(*state), + AccountMeta::new_writable(*state_account), + AccountMeta::new_writable(*relayer_account), + AccountMeta::new_readonly(custom_payload_account.copied().unwrap_or(swift_program)), + AccountMeta::new_readonly(*swift_input_mint), + AccountMeta::new_readonly(fee_manager), + AccountMeta::new_readonly(*token_program), + AccountMeta::new_readonly(system_program), + ], + data, + }) +} + +pub(super) fn create_order_hash( + route: &MayanSwiftQuote, + swapper_address: &str, + destination_address: &str, + random_key: &[u8; 32], + custom_payload: Option<&[u8]>, + fields: &SwiftOrderFields, +) -> Result<[u8; 32], SwapperError> { + let source_chain_id = wormhole_chain_id(&route.from_chain)?; + let swift_input_contract = route_swift_input_contract(route)?; + let token_in = token_address_to_bytes32(swift_input_contract, &route.from_chain)?; + + let mut data = Vec::with_capacity(SWIFT_ORDER_DATA_SIZE_V2); + data.push(swift_payload_type(custom_payload)); + data.extend_from_slice(&native_address_to_bytes32(swapper_address, source_chain_id)?); + data.extend_from_slice(&source_chain_id.to_be_bytes()); + data.extend_from_slice(&token_in); + data.extend_from_slice(&native_address_to_bytes32(destination_address, fields.destination_chain_id)?); + data.extend_from_slice(&fields.destination_chain_id.to_be_bytes()); + data.extend_from_slice(&fields.token_out); + data.extend_from_slice(&fields.amount_out_min.to_be_bytes()); + data.extend_from_slice(&fields.gas_drop.to_be_bytes()); + data.extend_from_slice(&fields.cancel_fee.to_be_bytes()); + data.extend_from_slice(&fields.refund_fee.to_be_bytes()); + data.extend_from_slice(&fields.deadline.to_be_bytes()); + data.extend_from_slice(&fields.referrer); + data.push(fields.referrer_bps); + data.push(optional_bps_u8(route.protocol_bps)?); + data.push(fields.auction_mode); + data.extend_from_slice(random_key); + data.extend_from_slice(&swift_custom_payload_hash(custom_payload)?); + + if data.len() != SWIFT_ORDER_DATA_SIZE_V2 { + return Err(SwapperError::InvalidRoute); + } + Ok(keccak256(&data)) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/solana/payload.rs b/core/crates/swapper/src/mayan/tx_builder/swift/solana/payload.rs new file mode 100644 index 0000000000..4844074f93 --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/solana/payload.rs @@ -0,0 +1,35 @@ +use crate::{SwapperError, mayan::constants::MAYAN_PAYLOAD_WRITER_PROGRAM_ID, mayan::tx_builder::solana::solana_error}; +use gem_solana::{SYSTEM_PROGRAM_ID, SolanaAddress}; +use solana_primitives::anchor::global_discriminator; +use solana_primitives::{AccountMeta, Instruction, Pubkey}; + +pub(super) fn create_payload_writer_create_instruction(payer: &Pubkey, payload_account: &Pubkey, payload: &[u8], nonce: u16) -> Result { + let mut data = Vec::with_capacity(8 + 2 + 4 + payload.len()); + data.extend_from_slice(&global_discriminator("create_simple")); + data.extend_from_slice(&nonce.to_le_bytes()); + data.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + data.extend_from_slice(payload); + let payload_writer = SolanaAddress::parse(MAYAN_PAYLOAD_WRITER_PROGRAM_ID).map_err(solana_error)?.into(); + let system_program = SolanaAddress::parse(SYSTEM_PROGRAM_ID).map_err(solana_error)?.into(); + Ok(Instruction { + program_id: payload_writer, + accounts: vec![ + AccountMeta::new_signer_writable(*payer), + AccountMeta::new_writable(*payload_account), + AccountMeta::new_readonly(system_program), + ], + data, + }) +} + +pub(super) fn create_payload_writer_close_instruction(payer: &Pubkey, payload_account: &Pubkey, nonce: u16) -> Result { + let mut data = Vec::with_capacity(8 + 2); + data.extend_from_slice(&global_discriminator("close")); + data.extend_from_slice(&nonce.to_le_bytes()); + let payload_writer = SolanaAddress::parse(MAYAN_PAYLOAD_WRITER_PROGRAM_ID).map_err(solana_error)?.into(); + Ok(Instruction { + program_id: payload_writer, + accounts: vec![AccountMeta::new_signer_writable(*payer), AccountMeta::new_writable(*payload_account)], + data, + }) +} diff --git a/core/crates/swapper/src/mayan/tx_builder/swift/solana/transaction.rs b/core/crates/swapper/src/mayan/tx_builder/swift/solana/transaction.rs new file mode 100644 index 0000000000..7483a0785b --- /dev/null +++ b/core/crates/swapper/src/mayan/tx_builder/swift/solana/transaction.rs @@ -0,0 +1,221 @@ +use super::{ + order::{create_init_instruction, create_order_hash}, + payload::{create_payload_writer_close_instruction, create_payload_writer_create_instruction}, +}; +use crate::{ + Quote, SwapperError, + fees::default_referral_address, + mayan::{ + client::MayanClient, + constants::{MAYAN_LOOKUP_TABLE_SOLANA, MAYAN_PAYLOAD_WRITER_PROGRAM_ID, MAYAN_SWIFT_V2_PROGRAM_ID}, + model::{GetSwapSolanaParams, MayanSwiftQuote, SolanaClientSwap}, + tx_builder::{ + amount::value_to_query, + hypercore::hypercore_custom_payload, + route::{quote_destination_address, swift_destination_address}, + solana::{SolanaTransaction, append_client_swap_instructions, solana_error, wrap_instruction_in_cpi_proxy}, + swift::{SwiftOrderFields, swift_input_contract as route_swift_input_contract, swift_random_key}, + }, + }, +}; +use gem_client::Client; +use gem_evm::EVM_ZERO_ADDRESS; +use gem_solana::{SolanaAddress, WSOL_TOKEN_ADDRESS}; +use primitives::Chain; +use rand::RngExt; +use solana_primitives::associated_token::{create_associated_token_account_idempotent_with_address, get_associated_token_address_with_program_id}; +use solana_primitives::instructions::program_ids; +use solana_primitives::{Instruction, Pubkey, compute_budget, find_program_address, system, token}; +use std::fmt::Debug; + +struct SwiftBuildContext { + trader: Pubkey, + destination_address: String, + custom_payload: Option>, + referrer_address: Option, + random_key: [u8; 32], + state: Pubkey, + token_program: Pubkey, + swift_input_contract: String, + swift_input_mint: Pubkey, + state_account: Pubkey, + relayer: Pubkey, + relayer_account: Pubkey, + order_fields: SwiftOrderFields, +} + +impl SwiftBuildContext { + fn new(quote: &Quote, route: &MayanSwiftQuote) -> Result { + let trader = SolanaAddress::parse("e.request.wallet_address).map_err(solana_error)?.into(); + let destination_address = swift_destination_address(quote, route).into_owned(); + let custom_payload = hypercore_custom_payload(route, quote_destination_address(quote))?; + let referrer_address = Some(default_referral_address(Chain::Solana)); + let random_key = swift_random_key(route)?; + let order_fields = SwiftOrderFields::new(route)?; + let order_hash = create_order_hash( + route, + "e.request.wallet_address, + &destination_address, + &random_key, + custom_payload.as_deref(), + &order_fields, + )?; + let destination_chain_bytes = order_fields.destination_chain_id.to_le_bytes(); + let swift_program = SolanaAddress::parse(MAYAN_SWIFT_V2_PROGRAM_ID).map_err(solana_error)?.into(); + let (state, _) = find_program_address(&swift_program, &[b"STATE_SOURCE", &order_hash, &destination_chain_bytes]).map_err(solana_error)?; + let token_program = swift_token_program(route); + let swift_input_contract = route_swift_input_contract(route)?.to_string(); + let swift_input_mint = if swift_input_contract == EVM_ZERO_ADDRESS { + SolanaAddress::parse(WSOL_TOKEN_ADDRESS).map_err(solana_error)?.into() + } else { + SolanaAddress::parse(&swift_input_contract).map_err(solana_error)?.into() + }; + let state_account = get_associated_token_address_with_program_id(&state, &swift_input_mint, &token_program); + let relayer = trader; + let relayer_account = get_associated_token_address_with_program_id(&relayer, &swift_input_mint, &token_program); + + Ok(Self { + trader, + destination_address, + custom_payload, + referrer_address, + random_key, + state, + token_program, + swift_input_contract, + swift_input_mint, + state_account, + relayer, + relayer_account, + order_fields, + }) + } +} + +pub(super) async fn build(client: &MayanClient, quote: &Quote, route: &MayanSwiftQuote) -> Result +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let context = SwiftBuildContext::new(quote, route)?; + let mut instructions = Vec::new(); + let mut lookup_table_addresses = vec![MAYAN_LOOKUP_TABLE_SOLANA.to_string()]; + let custom_payload_account = create_custom_payload_account(&mut instructions, &context.relayer, context.custom_payload.as_deref())?; + + if route.from_token.contract.as_str() == context.swift_input_contract.as_str() { + add_direct_swift_instructions(route, &context, &mut instructions)?; + } else { + lookup_table_addresses.extend(add_swap_instructions(client, quote, route, &context, &mut instructions).await?); + } + + instructions.push(wrap_instruction_in_cpi_proxy(create_init_instruction( + route, + &context.state, + &context.trader, + &context.relayer, + &context.state_account, + &context.relayer_account, + &context.swift_input_mint, + &context.token_program, + &context.destination_address, + context.random_key, + custom_payload_account.as_ref().map(|(account, _)| account), + &context.order_fields, + )?)?); + + if let Some((payload_account, nonce)) = custom_payload_account { + instructions.push(wrap_instruction_in_cpi_proxy(create_payload_writer_close_instruction( + &context.relayer, + &payload_account, + nonce, + )?)?); + } + + Ok(SolanaTransaction::new(instructions, lookup_table_addresses)) +} + +fn add_direct_swift_instructions(route: &MayanSwiftQuote, context: &SwiftBuildContext, instructions: &mut Vec) -> Result<(), SwapperError> { + if let Some(priority_fee) = route.suggested_priority_fee.filter(|&fee| fee > 0) { + instructions.push(wrap_instruction_in_cpi_proxy(compute_budget::set_compute_unit_price(priority_fee))?); + } + instructions.push(wrap_instruction_in_cpi_proxy(create_associated_token_account_idempotent_with_address( + &context.relayer, + &context.state_account, + &context.state, + &context.swift_input_mint, + &context.token_program, + ))?); + + let amount_in = route.effective_amount_in64.parse::()?; + if context.swift_input_contract == EVM_ZERO_ADDRESS { + instructions.push(wrap_instruction_in_cpi_proxy(system::transfer(&context.trader, &context.state_account, amount_in))?); + instructions.push(wrap_instruction_in_cpi_proxy(token::sync_native(&context.state_account))?); + return Ok(()); + } + + let source_account = get_associated_token_address_with_program_id(&context.trader, &context.swift_input_mint, &context.token_program); + let mut transfer = token::transfer(&source_account, &context.state_account, &context.trader, amount_in); + transfer.program_id = context.token_program; + instructions.push(wrap_instruction_in_cpi_proxy(transfer)?); + Ok(()) +} + +async fn add_swap_instructions( + client: &MayanClient, + quote: &Quote, + route: &MayanSwiftQuote, + context: &SwiftBuildContext, + instructions: &mut Vec, +) -> Result, SwapperError> +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + let min_middle_amount = value_to_query(route.min_middle_amount.as_ref().ok_or(SwapperError::InvalidRoute)?)?; + let swap: SolanaClientSwap = client + .get_swap( + "/get-swap/solana", + GetSwapSolanaParams::swift( + route, + min_middle_amount, + context.swift_input_contract.clone(), + quote.request.wallet_address.clone(), + route.effective_amount_in64.clone(), + context.referrer_address.clone(), + context.state.to_string(), + ), + ) + .await?; + + append_client_swap_instructions( + instructions, + swap, + &context.trader, + &context.relayer, + &route.from_token.contract, + &route.effective_amount_in64, + ) +} + +fn create_custom_payload_account(instructions: &mut Vec, relayer: &Pubkey, custom_payload: Option<&[u8]>) -> Result, SwapperError> { + let Some(custom_payload) = custom_payload else { + return Ok(None); + }; + let nonce = rand::rng().random::(); + let nonce_bytes = nonce.to_le_bytes(); + let payload_writer = SolanaAddress::parse(MAYAN_PAYLOAD_WRITER_PROGRAM_ID).map_err(solana_error)?.into(); + let seeds: [&[u8]; 3] = [b"PAYLOAD", relayer.as_bytes(), &nonce_bytes]; + let (payload_account, _) = find_program_address(&payload_writer, &seeds).map_err(solana_error)?; + instructions.push(wrap_instruction_in_cpi_proxy(create_payload_writer_create_instruction( + relayer, + &payload_account, + custom_payload, + nonce, + )?)?); + Ok(Some((payload_account, nonce))) +} + +fn swift_token_program(route: &MayanSwiftQuote) -> Pubkey { + match route.swift_input_contract_standard.as_deref() { + Some("spl2022") => program_ids::token_2022_program(), + _ => program_ids::token_program(), + } +} diff --git a/core/crates/swapper/src/mayan/wormhole_chain.rs b/core/crates/swapper/src/mayan/wormhole_chain.rs new file mode 100644 index 0000000000..237a1c75ab --- /dev/null +++ b/core/crates/swapper/src/mayan/wormhole_chain.rs @@ -0,0 +1,148 @@ +use crate::SwapperError; +use primitives::Chain; +use std::str::FromStr; +use strum::{EnumString, IntoStaticStr}; + +macro_rules! wormhole_chains { + ($($variant:ident => { id: $id:expr, chain: $chain:path, name: $name:literal }),+ $(,)?) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] + pub(in crate::mayan) enum WormholeChain { + $( + #[strum(serialize = $name)] + $variant, + )+ + } + + impl WormholeChain { + pub(in crate::mayan) fn from_name(name: &str) -> Result { + Self::from_str(name).map_err(|_| SwapperError::NotSupportedChain) + } + + fn from_id(chain_id: u16) -> Option { + match chain_id { + $($id => Some(Self::$variant),)+ + _ => None, + } + } + + fn from_chain(chain: Chain) -> Option { + match chain { + $($chain => Some(Self::$variant),)+ + _ => None, + } + } + + pub(in crate::mayan) const fn id(self) -> u16 { + match self { + $(Self::$variant => $id,)+ + } + } + + const fn chain(self) -> Chain { + match self { + $(Self::$variant => $chain,)+ + } + } + + pub(in crate::mayan) fn name(self) -> &'static str { + self.into() + } + } + }; +} + +wormhole_chains! { + Solana => { id: 1, chain: Chain::Solana, name: "solana" }, + Ethereum => { id: 2, chain: Chain::Ethereum, name: "ethereum" }, + Bsc => { id: 4, chain: Chain::SmartChain, name: "bsc" }, + Polygon => { id: 5, chain: Chain::Polygon, name: "polygon" }, + Avalanche => { id: 6, chain: Chain::AvalancheC, name: "avalanche" }, + Fantom => { id: 10, chain: Chain::Fantom, name: "fantom" }, + Ton => { id: 13, chain: Chain::Ton, name: "ton" }, + Celo => { id: 14, chain: Chain::Celo, name: "celo" }, + Near => { id: 15, chain: Chain::Near, name: "near" }, + Sui => { id: 21, chain: Chain::Sui, name: "sui" }, + Aptos => { id: 22, chain: Chain::Aptos, name: "aptos" }, + Arbitrum => { id: 23, chain: Chain::Arbitrum, name: "arbitrum" }, + Optimism => { id: 24, chain: Chain::Optimism, name: "optimism" }, + Base => { id: 30, chain: Chain::Base, name: "base" }, + Linea => { id: 38, chain: Chain::Linea, name: "linea" }, + Berachain => { id: 39, chain: Chain::Berachain, name: "berachain" }, + Unichain => { id: 44, chain: Chain::Unichain, name: "unichain" }, + World => { id: 45, chain: Chain::World, name: "world" }, + Hyperevm => { id: 47, chain: Chain::Hyperliquid, name: "hyperevm" }, + Monad => { id: 48, chain: Chain::Monad, name: "monad" }, + Sonic => { id: 52, chain: Chain::Sonic, name: "sonic" }, + Plasma => { id: 58, chain: Chain::Plasma, name: "plasma" }, + Hypercore => { id: 65000, chain: Chain::HyperCore, name: "hypercore" }, +} + +pub fn chain_from_id(chain_id: u16) -> Option { + WormholeChain::from_id(chain_id).map(WormholeChain::chain) +} + +pub fn chain_for_name(chain: &str) -> Result { + WormholeChain::from_name(chain).map(WormholeChain::chain) +} + +pub fn id_for_name(chain: &str) -> Result { + WormholeChain::from_name(chain).map(WormholeChain::id) +} + +pub fn name_for_chain(chain: Chain) -> Result<&'static str, SwapperError> { + WormholeChain::from_chain(chain).map(WormholeChain::name).ok_or(SwapperError::NotSupportedChain) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chain_from_id() { + assert_eq!(chain_from_id(1), Some(Chain::Solana)); + assert_eq!(chain_from_id(2), Some(Chain::Ethereum)); + assert_eq!(chain_from_id(13), Some(Chain::Ton)); + assert_eq!(chain_from_id(21), Some(Chain::Sui)); + assert_eq!(chain_from_id(30), Some(Chain::Base)); + assert_eq!(chain_from_id(9999), None); + } + + #[test] + fn test_id_for_name() { + assert_eq!(id_for_name("solana").unwrap(), 1); + assert_eq!(id_for_name("ethereum").unwrap(), 2); + assert_eq!(id_for_name("bsc").unwrap(), 4); + assert_eq!(id_for_name("sui").unwrap(), 21); + assert_eq!(id_for_name("base").unwrap(), 30); + assert_eq!(id_for_name("hyperevm").unwrap(), 47); + assert_eq!(id_for_name("hypercore").unwrap(), 65000); + assert_eq!(id_for_name("bitcoin").unwrap_err(), SwapperError::NotSupportedChain); + } + + #[test] + fn test_chain_for_name() { + assert_eq!(chain_for_name("solana").unwrap(), Chain::Solana); + assert_eq!(chain_for_name("ethereum").unwrap(), Chain::Ethereum); + assert_eq!(chain_for_name("avalanche").unwrap(), Chain::AvalancheC); + assert_eq!(chain_for_name("hyperevm").unwrap(), Chain::Hyperliquid); + assert_eq!(chain_for_name("bitcoin").unwrap_err(), SwapperError::NotSupportedChain); + } + + #[test] + fn test_name_for_chain() { + assert_eq!(name_for_chain(Chain::SmartChain).unwrap(), "bsc"); + assert_eq!(name_for_chain(Chain::AvalancheC).unwrap(), "avalanche"); + assert_eq!(name_for_chain(Chain::Hyperliquid).unwrap(), "hyperevm"); + assert_eq!(name_for_chain(Chain::HyperCore).unwrap(), "hypercore"); + assert_eq!(name_for_chain(Chain::Bitcoin).unwrap_err(), SwapperError::NotSupportedChain); + } + + #[test] + fn test_wormhole_chain_names() { + assert_eq!(WormholeChain::Bsc.name(), "bsc"); + assert_eq!(WormholeChain::Avalanche.name(), "avalanche"); + assert_eq!(WormholeChain::Hyperevm.name(), "hyperevm"); + assert_eq!(WormholeChain::Hypercore.name(), "hypercore"); + assert_eq!(WormholeChain::from_name("bitcoin").unwrap_err(), SwapperError::NotSupportedChain); + } +} diff --git a/core/crates/swapper/src/models.rs b/core/crates/swapper/src/models.rs new file mode 100644 index 0000000000..c6b7bf5fd3 --- /dev/null +++ b/core/crates/swapper/src/models.rs @@ -0,0 +1,200 @@ +use super::permit2_data::Permit2Data; +use crate::{SwapperProvider, SwapperQuoteAsset, SwapperSlippage, config::DEFAULT_SLIPPAGE_BPS}; +pub use primitives::swap::SwapResult; +use primitives::{ + AssetId, Chain, + swap::{ApprovalData, SwapProviderMode}, +}; +use serde::Serialize; +use std::fmt::Debug; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ProviderType { + pub id: SwapperProvider, + pub name: String, + pub protocol: String, + pub protocol_id: String, + pub mode: SwapProviderMode, +} + +impl ProviderType { + pub fn new(id: SwapperProvider) -> Self { + Self { + id, + name: id.name().to_string(), + protocol: id.protocol_name().to_string(), + protocol_id: id.id().to_string(), + mode: ProviderType::mode(id), + } + } + + pub fn mode(id: SwapperProvider) -> SwapProviderMode { + match id { + SwapperProvider::UniswapV3 + | SwapperProvider::UniswapV4 + | SwapperProvider::PancakeswapV3 + | SwapperProvider::Panora + | SwapperProvider::Jupiter + | SwapperProvider::Oku + | SwapperProvider::Wagmi + | SwapperProvider::CetusAggregator + | SwapperProvider::CetusClmm + | SwapperProvider::StonfiV2 + | SwapperProvider::Aerodrome + | SwapperProvider::Orca + | SwapperProvider::Okx => SwapProviderMode::OnChain, + SwapperProvider::Mayan | SwapperProvider::Chainflip | SwapperProvider::NearIntents | SwapperProvider::Squid => SwapProviderMode::CrossChain, + SwapperProvider::Thorchain => SwapProviderMode::OmniChain(vec![Chain::Thorchain, Chain::Tron]), + SwapperProvider::Relay => SwapProviderMode::OmniChain(vec![Chain::Hyperliquid, Chain::Berachain]), + SwapperProvider::Across => SwapProviderMode::Bridge, + SwapperProvider::Hyperliquid => SwapProviderMode::OmniChain(vec![Chain::HyperCore, Chain::Hyperliquid]), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct QuoteRequest { + pub from_asset: SwapperQuoteAsset, + pub to_asset: SwapperQuoteAsset, + pub wallet_address: String, + pub destination_address: String, + pub value: String, + #[serde(skip_serializing)] + pub options: Options, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Options { + pub slippage: SwapperSlippage, + pub use_max_amount: bool, +} + +impl Options { + pub fn new_with_slippage(slippage: SwapperSlippage) -> Self { + Self { slippage, ..Default::default() } + } +} + +impl Default for Options { + fn default() -> Self { + Self { + slippage: DEFAULT_SLIPPAGE_BPS.into(), + use_max_amount: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Quote { + pub from_value: String, + pub min_from_value: Option, + pub to_value: String, + pub data: ProviderData, + pub request: QuoteRequest, + pub eta_in_seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SwapQuotes { + pub quotes: Vec, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SwapQuoteError { + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + pub error: String, +} + +impl SwapQuoteError { + pub fn new(provider: Option, error: String) -> Self { + Self { provider, error } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ApprovalType { + Approve(ApprovalData), + Permit2(Permit2ApprovalData), + None, +} + +impl ApprovalType { + pub fn approval_data(&self) -> Option { + match self { + Self::Approve(data) => Some(data.clone()), + _ => None, + } + } + pub fn permit2_data(&self) -> Option { + match self { + Self::Permit2(data) => Some(data.clone()), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Permit2ApprovalData { + pub token: String, + pub spender: String, + pub value: String, + pub permit2_contract: String, + pub permit2_nonce: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ProviderData { + pub provider: ProviderType, + pub slippage_bps: u32, + pub routes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Route { + pub input: AssetId, + pub output: AssetId, + pub route_data: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum FetchQuoteData { + Permit2(Permit2Data), + EstimateGas, + None, +} + +impl FetchQuoteData { + pub fn permit2_data(&self) -> Option { + match self { + Self::Permit2(data) => Some(data.clone()), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SwapperChainAsset { + All(Chain), + Assets(Chain, Vec), +} + +impl SwapperChainAsset { + pub fn assets(chain: Chain, assets: impl IntoIterator) -> Self { + Self::Assets(chain, assets.into_iter().collect()) + } + + pub fn get_chain(&self) -> Chain { + match self { + Self::All(chain) => *chain, + Self::Assets(chain, _) => *chain, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AssetList { + pub chains: Vec, + pub asset_ids: Vec, +} diff --git a/core/crates/swapper/src/near_intents/assets.rs b/core/crates/swapper/src/near_intents/assets.rs new file mode 100644 index 0000000000..9d36f87218 --- /dev/null +++ b/core/crates/swapper/src/near_intents/assets.rs @@ -0,0 +1,290 @@ +use crate::{SwapperError, SwapperQuoteAsset, models::SwapperChainAsset}; +use primitives::{ + AssetId, Chain, + asset_constants::{ + APTOS_USDT_ASSET_ID, ARBITRUM_ARB_ASSET_ID, ARBITRUM_USDC_ASSET_ID, ARBITRUM_USDT_ASSET_ID, AVALANCHE_USDC_ASSET_ID, AVALANCHE_USDT_ASSET_ID, BASE_CBBTC_ASSET_ID, + BASE_USDC_ASSET_ID, BERACHAIN_USDT_ASSET_ID, ETHEREUM_AAVE_ASSET_ID, ETHEREUM_CBBTC_ASSET_ID, ETHEREUM_DAI_ASSET_ID, ETHEREUM_LINK_ASSET_ID, ETHEREUM_UNI_ASSET_ID, + ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDT_ASSET_ID, ETHEREUM_WBTC_ASSET_ID, GNOSIS_USDC_ASSET_ID, GNOSIS_USDT_ASSET_ID, MONAD_USDC_ASSET_ID, MONAD_USDT_ASSET_ID, + OPTIMISM_OP_ASSET_ID, OPTIMISM_USDC_ASSET_ID, OPTIMISM_USDT_ASSET_ID, PLASMA_USDT_ASSET_ID, POLYGON_USDC_ASSET_ID, POLYGON_USDT_ASSET_ID, SMARTCHAIN_USDC_ASSET_ID, + SMARTCHAIN_USDT_ASSET_ID, SOLANA_USDC_ASSET_ID, SOLANA_USDT_ASSET_ID, SUI_USDC_ASSET_ID, TON_USDT_ASSET_ID, TRON_USDT_ASSET_ID, XLAYER_USDC_ASSET_ID, XLAYER_USDT_ASSET_ID, + }, +}; +use std::{collections::HashMap, sync::LazyLock}; + +pub const NEAR_INTENTS_WRAP_NEAR: &str = "nep141:wrap.near"; +// pub const NEAR_INTENTS_NEAR_USDC: &str = "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1"; +// pub const NEAR_INTENTS_NEAR_USDT: &str = "nep141:usdt.tether-token.near"; +pub const NEAR_INTENTS_ETH_NATIVE: &str = "nep141:eth.omft.near"; +pub const NEAR_INTENTS_ETH_USDC: &str = "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near"; +pub const NEAR_INTENTS_ETH_USDT: &str = "nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near"; +pub const NEAR_INTENTS_ETH_WBTC: &str = "nep141:eth-0x2260fac5e5542a773aa44fbcfedf7c193bc2c599.omft.near"; +pub const NEAR_INTENTS_ETH_DAI: &str = "nep141:eth-0x6b175474e89094c44da98b954eedeac495271d0f.omft.near"; +pub const NEAR_INTENTS_ETH_CBBTC: &str = "nep141:eth-0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf.omft.near"; +pub const NEAR_INTENTS_ETH_LINK: &str = "nep141:eth-0x514910771af9ca656af840dff83e8264ecf986ca.omft.near"; +pub const NEAR_INTENTS_ETH_UNI: &str = "nep141:eth-0x1f9840a85d5af5bf1d1762f925bdaddc4201f984.omft.near"; +pub const NEAR_INTENTS_ETH_AAVE: &str = "nep141:eth-0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9.omft.near"; +pub const NEAR_INTENTS_BTC_NATIVE: &str = "1cs_v1:btc:native:coin"; +pub const NEAR_INTENTS_SOL_NATIVE: &str = "nep141:sol.omft.near"; +pub const NEAR_INTENTS_SOL_USDC: &str = "nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near"; +pub const NEAR_INTENTS_SOL_USDT: &str = "nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near"; +pub const NEAR_INTENTS_SUI_NATIVE: &str = "nep141:sui.omft.near"; +pub const NEAR_INTENTS_SUI_USDC: &str = "nep141:sui-c1b81ecaf27933252d31a963bc5e9458f13c18ce.omft.near"; +pub const NEAR_INTENTS_ARB_NATIVE: &str = "nep141:arb.omft.near"; +pub const NEAR_INTENTS_ARB_USDC: &str = "nep141:arb-0xaf88d065e77c8cc2239327c5edb3a432268e5831.omft.near"; +pub const NEAR_INTENTS_ARB_USDT: &str = "nep141:arb-0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9.omft.near"; +pub const NEAR_INTENTS_ARB_ARB: &str = "nep141:arb-0x912ce59144191c1204e64559fe8253a0e49e6548.omft.near"; +pub const NEAR_INTENTS_BASE_NATIVE: &str = "nep141:base.omft.near"; +pub const NEAR_INTENTS_BASE_USDC: &str = "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near"; +pub const NEAR_INTENTS_BASE_CBBTC: &str = "nep141:base-0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf.omft.near"; +pub const NEAR_INTENTS_OPT_NATIVE: &str = "nep245:v2_1.omni.hot.tg:10_11111111111111111111"; +pub const NEAR_INTENTS_OPT_USDC: &str = "nep245:v2_1.omni.hot.tg:10_A2ewyUyDp6qsue1jqZsGypkCxRJ"; +pub const NEAR_INTENTS_OPT_USDT: &str = "nep245:v2_1.omni.hot.tg:10_359RPSJVdTxwTJT9TyGssr2rFoWo"; +pub const NEAR_INTENTS_OPT_OP: &str = "nep245:v2_1.omni.hot.tg:10_vLAiSt9KfUGKpw5cD3vsSyNYBo7"; +pub const NEAR_INTENTS_AVAX_NATIVE: &str = "nep245:v2_1.omni.hot.tg:43114_11111111111111111111"; +pub const NEAR_INTENTS_AVAX_USDC: &str = "nep245:v2_1.omni.hot.tg:43114_3atVJH3r5c4GqiSYmg9fECvjc47o"; +pub const NEAR_INTENTS_AVAX_USDT: &str = "nep245:v2_1.omni.hot.tg:43114_372BeH7ENZieCaabwkbWkBiTTgXp"; +pub const NEAR_INTENTS_BSC_NATIVE: &str = "nep245:v2_1.omni.hot.tg:56_11111111111111111111"; +pub const NEAR_INTENTS_BSC_USDC: &str = "nep245:v2_1.omni.hot.tg:56_2w93GqMcEmQFDru84j3HZZWt557r"; +pub const NEAR_INTENTS_BSC_USDT: &str = "nep245:v2_1.omni.hot.tg:56_2CMMyVTGZkeyNZTSvS5sarzfir6g"; +pub const NEAR_INTENTS_POL_NATIVE: &str = "nep245:v2_1.omni.hot.tg:137_11111111111111111111"; +pub const NEAR_INTENTS_POL_USDC: &str = "nep245:v2_1.omni.hot.tg:137_qiStmoQJDQPTebaPjgx5VBxZv6L"; +pub const NEAR_INTENTS_POL_USDT: &str = "nep245:v2_1.omni.hot.tg:137_3hpYoaLtt8MP1Z2GH1U473DMRKgr"; +pub const NEAR_INTENTS_TON_NATIVE: &str = "nep245:v2_1.omni.hot.tg:1117_"; +pub const NEAR_INTENTS_TON_USDT: &str = "nep245:v2_1.omni.hot.tg:1117_3tsdfyziyc7EJbP2aULWSKU4toBaAcN4FdTgfm5W1mC4ouR"; +pub const NEAR_INTENTS_TRON_NATIVE: &str = "nep141:tron.omft.near"; +pub const NEAR_INTENTS_TRON_USDT: &str = "nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near"; +pub const NEAR_INTENTS_DOGE_NATIVE: &str = "nep141:doge.omft.near"; +pub const NEAR_INTENTS_XRP_NATIVE: &str = "nep141:xrp.omft.near"; +pub const NEAR_INTENTS_CARDANO_NATIVE: &str = "nep141:cardano.omft.near"; +pub const NEAR_INTENTS_BERA_NATIVE: &str = "nep141:bera.omft.near"; +pub const NEAR_INTENTS_GNOSIS_NATIVE: &str = "nep141:gnosis.omft.near"; +pub const NEAR_INTENTS_GNOSIS_USDC: &str = "nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near"; +pub const NEAR_INTENTS_APT_NATIVE: &str = "nep141:aptos.omft.near"; +pub const NEAR_INTENTS_APT_USDT: &str = "nep141:aptos-88cb7619440a914fe6400149a12b443c3ac21d59.omft.near"; +pub const NEAR_INTENTS_ZEC_NATIVE: &str = "nep141:zec.omft.near"; +pub const NEAR_INTENTS_STELLAR_NATIVE: &str = "nep245:v2_1.omni.hot.tg:1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz"; +// pub const NEAR_INTENTS_STELLAR_USDC: &str = "nep245:v2_1.omni.hot.tg:1100_111bzQBB65GxAPAVoxqmMcgYo5oS3txhqs1Uh1cgahKQUeTUq1TJu"; +pub const NEAR_INTENTS_LTC_NATIVE: &str = "nep141:ltc.omft.near"; +pub const NEAR_INTENTS_BCH_NATIVE: &str = "nep141:bch.omft.near"; +pub const NEAR_INTENTS_BERA_USDT: &str = "nep141:bera-0x779ded0c9e1022225f8e0630b35a9b54be713736.omft.near"; +pub const NEAR_INTENTS_GNOSIS_USDT: &str = "nep141:gnosis-0x4ecaba5870353805a9f068101a40e0f32ed605c6.omft.near"; +pub const NEAR_INTENTS_MONAD_NATIVE: &str = "nep245:v2_1.omni.hot.tg:143_11111111111111111111"; +pub const NEAR_INTENTS_MONAD_USDT: &str = "nep245:v2_1.omni.hot.tg:143_4EJiJxSALvGoTZbnc8K7Ft9533et"; +pub const NEAR_INTENTS_MONAD_USDC: &str = "nep245:v2_1.omni.hot.tg:143_2dmLwYWkCQKyTjeUPAsGJuiVLbFx"; +pub const NEAR_INTENTS_XLAYER_NATIVE: &str = "nep245:v2_1.omni.hot.tg:196_11111111111111111111"; +pub const NEAR_INTENTS_XLAYER_USDT: &str = "nep245:v2_1.omni.hot.tg:196_2fezDCvVYRsG8wrK6deJ2VRPiAS1"; +pub const NEAR_INTENTS_XLAYER_USDC: &str = "nep245:v2_1.omni.hot.tg:196_2dK9kLNR7Ekq7su8FxNGiUW3djTw"; +pub const NEAR_INTENTS_PLASMA_NATIVE: &str = "nep245:v2_1.omni.hot.tg:9745_11111111111111111111"; +pub const NEAR_INTENTS_PLASMA_USDT: &str = "nep245:v2_1.omni.hot.tg:9745_3aL9skCy1yhPoDB8oKMmRHRN7SJW"; + +type AssetsMap = HashMap; + +pub static NEAR_INTENTS_ASSETS: LazyLock> = LazyLock::new(|| { + let mut map: HashMap = HashMap::new(); + + map.insert(Chain::Near, HashMap::from([(Chain::Near.as_asset_id(), NEAR_INTENTS_WRAP_NEAR)])); + + map.insert( + Chain::Ethereum, + HashMap::from([ + (Chain::Ethereum.as_asset_id(), NEAR_INTENTS_ETH_NATIVE), + (ETHEREUM_USDC_ASSET_ID.clone(), NEAR_INTENTS_ETH_USDC), + (ETHEREUM_USDT_ASSET_ID.clone(), NEAR_INTENTS_ETH_USDT), + (ETHEREUM_WBTC_ASSET_ID.clone(), NEAR_INTENTS_ETH_WBTC), + (ETHEREUM_DAI_ASSET_ID.clone(), NEAR_INTENTS_ETH_DAI), + (ETHEREUM_CBBTC_ASSET_ID.clone(), NEAR_INTENTS_ETH_CBBTC), + (ETHEREUM_LINK_ASSET_ID.clone(), NEAR_INTENTS_ETH_LINK), + (ETHEREUM_UNI_ASSET_ID.clone(), NEAR_INTENTS_ETH_UNI), + (ETHEREUM_AAVE_ASSET_ID.clone(), NEAR_INTENTS_ETH_AAVE), + ]), + ); + + map.insert(Chain::Bitcoin, HashMap::from([(Chain::Bitcoin.as_asset_id(), NEAR_INTENTS_BTC_NATIVE)])); + + map.insert( + Chain::Solana, + HashMap::from([ + (Chain::Solana.as_asset_id(), NEAR_INTENTS_SOL_NATIVE), + (SOLANA_USDC_ASSET_ID.clone(), NEAR_INTENTS_SOL_USDC), + (SOLANA_USDT_ASSET_ID.clone(), NEAR_INTENTS_SOL_USDT), + ]), + ); + + map.insert( + Chain::Sui, + HashMap::from([(Chain::Sui.as_asset_id(), NEAR_INTENTS_SUI_NATIVE), (SUI_USDC_ASSET_ID.clone(), NEAR_INTENTS_SUI_USDC)]), + ); + + map.insert( + Chain::Arbitrum, + HashMap::from([ + (Chain::Arbitrum.as_asset_id(), NEAR_INTENTS_ARB_NATIVE), + (ARBITRUM_USDC_ASSET_ID.clone(), NEAR_INTENTS_ARB_USDC), + (ARBITRUM_USDT_ASSET_ID.clone(), NEAR_INTENTS_ARB_USDT), + (ARBITRUM_ARB_ASSET_ID.clone(), NEAR_INTENTS_ARB_ARB), + ]), + ); + + map.insert( + Chain::Base, + HashMap::from([ + (Chain::Base.as_asset_id(), NEAR_INTENTS_BASE_NATIVE), + (BASE_USDC_ASSET_ID.clone(), NEAR_INTENTS_BASE_USDC), + (BASE_CBBTC_ASSET_ID.clone(), NEAR_INTENTS_BASE_CBBTC), + ]), + ); + + map.insert( + Chain::Optimism, + HashMap::from([ + (Chain::Optimism.as_asset_id(), NEAR_INTENTS_OPT_NATIVE), + (OPTIMISM_USDC_ASSET_ID.clone(), NEAR_INTENTS_OPT_USDC), + (OPTIMISM_USDT_ASSET_ID.clone(), NEAR_INTENTS_OPT_USDT), + (OPTIMISM_OP_ASSET_ID.clone(), NEAR_INTENTS_OPT_OP), + ]), + ); + + map.insert( + Chain::AvalancheC, + HashMap::from([ + (Chain::AvalancheC.as_asset_id(), NEAR_INTENTS_AVAX_NATIVE), + (AVALANCHE_USDC_ASSET_ID.clone(), NEAR_INTENTS_AVAX_USDC), + (AVALANCHE_USDT_ASSET_ID.clone(), NEAR_INTENTS_AVAX_USDT), + ]), + ); + + map.insert( + Chain::SmartChain, + HashMap::from([ + (Chain::SmartChain.as_asset_id(), NEAR_INTENTS_BSC_NATIVE), + (SMARTCHAIN_USDC_ASSET_ID.clone(), NEAR_INTENTS_BSC_USDC), + (SMARTCHAIN_USDT_ASSET_ID.clone(), NEAR_INTENTS_BSC_USDT), + ]), + ); + + map.insert( + Chain::Polygon, + HashMap::from([ + (Chain::Polygon.as_asset_id(), NEAR_INTENTS_POL_NATIVE), + (POLYGON_USDC_ASSET_ID.clone(), NEAR_INTENTS_POL_USDC), + (POLYGON_USDT_ASSET_ID.clone(), NEAR_INTENTS_POL_USDT), + ]), + ); + + map.insert( + Chain::Ton, + HashMap::from([(Chain::Ton.as_asset_id(), NEAR_INTENTS_TON_NATIVE), (TON_USDT_ASSET_ID.clone(), NEAR_INTENTS_TON_USDT)]), + ); + + map.insert( + Chain::Tron, + HashMap::from([(Chain::Tron.as_asset_id(), NEAR_INTENTS_TRON_NATIVE), (TRON_USDT_ASSET_ID.clone(), NEAR_INTENTS_TRON_USDT)]), + ); + + map.insert(Chain::Doge, HashMap::from([(Chain::Doge.as_asset_id(), NEAR_INTENTS_DOGE_NATIVE)])); + map.insert(Chain::Xrp, HashMap::from([(Chain::Xrp.as_asset_id(), NEAR_INTENTS_XRP_NATIVE)])); + map.insert(Chain::Cardano, HashMap::from([(Chain::Cardano.as_asset_id(), NEAR_INTENTS_CARDANO_NATIVE)])); + map.insert( + Chain::Berachain, + HashMap::from([ + (Chain::Berachain.as_asset_id(), NEAR_INTENTS_BERA_NATIVE), + (BERACHAIN_USDT_ASSET_ID.clone(), NEAR_INTENTS_BERA_USDT), + ]), + ); + map.insert( + Chain::Aptos, + HashMap::from([(Chain::Aptos.as_asset_id(), NEAR_INTENTS_APT_NATIVE), (APTOS_USDT_ASSET_ID.clone(), NEAR_INTENTS_APT_USDT)]), + ); + map.insert(Chain::Zcash, HashMap::from([(Chain::Zcash.as_asset_id(), NEAR_INTENTS_ZEC_NATIVE)])); + + map.insert( + Chain::Gnosis, + HashMap::from([ + (Chain::Gnosis.as_asset_id(), NEAR_INTENTS_GNOSIS_NATIVE), + (GNOSIS_USDC_ASSET_ID.clone(), NEAR_INTENTS_GNOSIS_USDC), + (GNOSIS_USDT_ASSET_ID.clone(), NEAR_INTENTS_GNOSIS_USDT), + ]), + ); + + map.insert(Chain::Stellar, HashMap::from([(Chain::Stellar.as_asset_id(), NEAR_INTENTS_STELLAR_NATIVE)])); + + map.insert(Chain::Litecoin, HashMap::from([(Chain::Litecoin.as_asset_id(), NEAR_INTENTS_LTC_NATIVE)])); + map.insert(Chain::BitcoinCash, HashMap::from([(Chain::BitcoinCash.as_asset_id(), NEAR_INTENTS_BCH_NATIVE)])); + + map.insert( + Chain::Monad, + HashMap::from([ + (Chain::Monad.as_asset_id(), NEAR_INTENTS_MONAD_NATIVE), + (MONAD_USDT_ASSET_ID.clone(), NEAR_INTENTS_MONAD_USDT), + (MONAD_USDC_ASSET_ID.clone(), NEAR_INTENTS_MONAD_USDC), + ]), + ); + + map.insert( + Chain::XLayer, + HashMap::from([ + (Chain::XLayer.as_asset_id(), NEAR_INTENTS_XLAYER_NATIVE), + (XLAYER_USDT_ASSET_ID.clone(), NEAR_INTENTS_XLAYER_USDT), + (XLAYER_USDC_ASSET_ID.clone(), NEAR_INTENTS_XLAYER_USDC), + ]), + ); + + map.insert( + Chain::Plasma, + HashMap::from([ + (Chain::Plasma.as_asset_id(), NEAR_INTENTS_PLASMA_NATIVE), + (PLASMA_USDT_ASSET_ID.clone(), NEAR_INTENTS_PLASMA_USDT), + ]), + ); + + map +}); + +pub fn get_near_asset_id(asset: &SwapperQuoteAsset) -> Result { + let asset_id = asset.asset_id(); + let chain_assets = NEAR_INTENTS_ASSETS.get(&asset_id.chain).ok_or(SwapperError::NotSupportedChain)?; + + chain_assets.get(&asset_id).map(|value| (*value).to_string()).ok_or(SwapperError::NotSupportedAsset) +} + +pub fn get_asset_id_from_near_asset(near_asset_id: &str) -> Option { + NEAR_INTENTS_ASSETS + .values() + .flat_map(|assets| assets.iter()) + .find(|(_, v)| **v == near_asset_id) + .map(|(k, _)| k.clone()) +} + +pub fn supported_assets() -> Vec { + NEAR_INTENTS_ASSETS + .iter() + .map(|(chain, assets)| SwapperChainAsset::Assets(*chain, assets.keys().cloned().collect())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_asset_id() { + let asset = SwapperQuoteAsset { + id: "ethereum_0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".into(), + symbol: "USDC".into(), + decimals: 6, + }; + + let result = get_near_asset_id(&asset).unwrap(); + assert_eq!(result, NEAR_INTENTS_ETH_USDC); + } + + #[test] + fn test_supported_assets_contains_near() { + let supported = supported_assets(); + let contains_near = supported.iter().any(|entry| match entry { + SwapperChainAsset::All(chain) => *chain == Chain::Near, + SwapperChainAsset::Assets(chain, _) => *chain == Chain::Near, + }); + assert!(contains_near); + } +} diff --git a/core/crates/swapper/src/near_intents/client.rs b/core/crates/swapper/src/near_intents/client.rs new file mode 100644 index 0000000000..c596f3e60c --- /dev/null +++ b/core/crates/swapper/src/near_intents/client.rs @@ -0,0 +1,63 @@ +use crate::{SwapperError, config::get_swap_proxy_url}; +use gem_client::{Client, ClientExt}; +use std::{collections::HashMap, fmt::Debug}; + +use super::model::{ExplorerTransaction, QuoteRequest, QuoteResponseResult}; + +pub fn base_url() -> String { + get_swap_proxy_url("near-intents/1click") +} + +pub fn explorer_url() -> String { + get_swap_proxy_url("near-intents/explorer") +} + +#[derive(Clone, Debug)] +pub struct NearIntentsClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + client: C, + api_token: Option, +} + +impl NearIntentsClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new(client: C, api_key: Option) -> Self { + Self { client, api_token: api_key } + } + + fn build_headers(&self) -> HashMap { + self.api_token + .as_ref() + .map(|token| HashMap::from([(String::from("Authorization"), format!("Bearer {token}"))])) + .unwrap_or_default() + } + + pub async fn fetch_quote(&self, request: &QuoteRequest) -> Result { + self.client.post_with_headers("/v0/quote", request, self.build_headers()).await.map_err(SwapperError::from) + } +} + +#[derive(Debug)] +pub struct NearIntentsExplorer { + client: C, +} + +impl NearIntentsExplorer { + pub fn new(client: C) -> Self { + Self { client } + } + + async fn get_transactions(&self, query: &str) -> Result, SwapperError> { + let path = format!("/api/v0/transactions?{query}"); + self.client.get::>(&path).await.map_err(SwapperError::from) + } + + pub async fn search_transaction(&self, hash: &str) -> Result, SwapperError> { + let transactions = self.get_transactions(&format!("search={hash}&numberOfTransactions=10")).await?; + Ok(transactions.into_iter().find(|tx| tx.origin_chain_tx_hashes.iter().any(|h| h.eq_ignore_ascii_case(hash)))) + } +} diff --git a/core/crates/swapper/src/near_intents/config.rs b/core/crates/swapper/src/near_intents/config.rs new file mode 100644 index 0000000000..06fc8c204c --- /dev/null +++ b/core/crates/swapper/src/near_intents/config.rs @@ -0,0 +1,9 @@ +use primitives::Chain; + +pub(crate) fn deposit_memo_chains() -> &'static [Chain] { + &[Chain::Stellar] +} + +pub(crate) fn auto_quote_time_chains() -> &'static [Chain] { + &[Chain::Gnosis] +} diff --git a/core/crates/swapper/src/near_intents/mod.rs b/core/crates/swapper/src/near_intents/mod.rs new file mode 100644 index 0000000000..f196a52b21 --- /dev/null +++ b/core/crates/swapper/src/near_intents/mod.rs @@ -0,0 +1,14 @@ +mod assets; +mod client; +mod config; +mod model; +mod provider; + +pub use client::base_url; +pub use model::{QuoteResponse, QuoteResponseError, QuoteResponseResult}; +pub use provider::NearIntents; + +pub(crate) use assets::{get_asset_id_from_near_asset, get_near_asset_id, supported_assets}; +pub(crate) use client::{NearIntentsClient, NearIntentsExplorer}; +pub(crate) use config::{auto_quote_time_chains, deposit_memo_chains}; +pub(crate) use model::{AppFee, DepositMode, QuoteRequest, SwapType}; diff --git a/core/crates/swapper/src/near_intents/model.rs b/core/crates/swapper/src/near_intents/model.rs new file mode 100644 index 0000000000..a33dda3fbe --- /dev/null +++ b/core/crates/swapper/src/near_intents/model.rs @@ -0,0 +1,103 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_biguint_from_str; + +pub const DEPOSIT_TYPE_ORIGIN: &str = "ORIGIN_CHAIN"; +pub const RECIPIENT_TYPE_DESTINATION: &str = "DESTINATION_CHAIN"; +pub const DEFAULT_WAIT_TIME_MS: u32 = 1_024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppFee { + pub recipient: String, + pub fee: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteRequest { + pub origin_asset: String, + pub destination_asset: String, + pub amount: String, + pub referral: String, + pub recipient: String, + pub swap_type: SwapType, + pub slippage_tolerance: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_fees: Option>, + pub deposit_type: String, + pub refund_to: String, + pub refund_type: String, + pub recipient_type: String, + pub deadline: String, + pub quote_waiting_time_ms: Option, + pub dry: bool, + pub deposit_mode: DepositMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SwapType { + ExactInput, + FlexInput, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DepositMode { + #[default] + Simple, + Memo, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteResponse { + pub quote_request: QuoteRequest, + pub quote: Quote, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct QuoteResponseError { + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum QuoteResponseResult { + Ok(Box), + Err(QuoteResponseError), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Quote { + pub deposit_address: Option, + pub deposit_memo: Option, + pub deposit_mode: Option, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub amount_in: BigUint, + pub amount_in_formatted: String, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub min_amount_in: BigUint, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub amount_out: BigUint, + pub amount_out_formatted: String, + #[serde(deserialize_with = "deserialize_biguint_from_str")] + pub min_amount_out: BigUint, + pub deadline: Option, + pub time_when_inactive: Option, + pub time_estimate: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExplorerTransaction { + pub deposit_address: String, + pub status: String, + pub origin_asset: String, + pub destination_asset: String, + pub amount_in: String, + pub amount_out: String, + pub origin_chain_tx_hashes: Vec, +} diff --git a/core/crates/swapper/src/near_intents/provider.rs b/core/crates/swapper/src/near_intents/provider.rs new file mode 100644 index 0000000000..b980ec8e6c --- /dev/null +++ b/core/crates/swapper/src/near_intents/provider.rs @@ -0,0 +1,632 @@ +use super::{ + AppFee, DepositMode, NearIntentsClient, NearIntentsExplorer, QuoteRequest as NearQuoteRequest, QuoteResponse, QuoteResponseError, QuoteResponseResult, SwapType, + auto_quote_time_chains, deposit_memo_chains, get_asset_id_from_near_asset, get_near_asset_id, + model::{DEFAULT_WAIT_TIME_MS, DEPOSIT_TYPE_ORIGIN, ExplorerTransaction, RECIPIENT_TYPE_DESTINATION}, + supported_assets, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, + SwapperQuoteAsset, SwapperQuoteData, amount_to_value, + client_factory::create_sui_client, + cross_chain::VaultAddresses, + fees::DEFAULT_REFERRER, + fees::default_referral_fees, + near_intents::client::{base_url, explorer_url}, +}; +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use gem_sui::{SuiClient, build_transfer_message_bytes}; +use primitives::{Chain, TransactionSwapMetadata, swap::SwapStatus}; +use std::{fmt::Debug, sync::Arc}; + +const DEFAULT_DEADLINE_MINUTES: i64 = 30; +const BITCOIN_DEADLINE_MINUTES: i64 = 60; + +// Supported-chain subset of https://docs.near-intents.org/security-compliance/treasury-addresses +const TREASURY_ADDRESSES: [&str; 16] = [ + "0x2CfF890f0378a11913B6129B2E97417a2c302680", // EVM chains + "0x233c5370CCfb3cD7409d9A3fb98ab94dE94Cb4Cd", // Monad, XLayer + "1C6XJtNXiuXvk4oUAVMkKF57CRpaTrN5Ra", // Bitcoin + "1LxByjYMdnogW9Nc73srT4NCbS8oPVaXvZ", // Bitcoin Cash + "DRmCnxzL9U11EJzLmWkm2ikaZikPFbLuQD", // Dogecoin + "LQjEMkuiA2pCwFeUPwsu6ktzUubBVLsahX", // Litecoin + "t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML", // Zcash + "intents.near", // NEAR + "HWjmoUNYckccg9Qrwi43JTzBcGcM1nbdAtATf9GXmz16", // Solana + "UQAfoBd_f0pIvNpUPAkOguUrFWpGWV9TWBeZs_5TXE95_trZ", // TON + "GDJ4JZXZELZD737NVFORH4PSSQDWFDZTKW3AIDKHYQG23ZXBPDGGQBJK", // Stellar + "0x00ea18889868519abd2f238966cab9875750bb2859ed3a34debec37781520138", // Sui + "0xd1a1c1804e91ba85a569c7f018bb7502d2f13d4742d2611953c9c14681af6446", // Aptos + "TX5XiRXdyz7sdFwF5mnhT1QoGCpbkncpke", // TRON + "r9R8jciZBYGq32DxxQrBPi5ysZm67iQitH", // XRP + "addr1v8wfpcg4qfhmnzprzysj6j9c53u5j56j8rvhyjp08s53s6g07rfjm", // Cardano +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepositData { + pub to: String, + pub value: String, + pub data: String, + pub memo: Option, +} + +pub struct NearIntents +where + C: gem_client::Client + Clone + Send + Sync + Debug + 'static, +{ + provider: ProviderType, + client: NearIntentsClient, + explorer: NearIntentsExplorer, + supported_assets: Vec, + sui_client: Arc, +} + +impl std::fmt::Debug for NearIntents +where + C: gem_client::Client + Clone + Send + Sync + Debug + 'static, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NearIntents") + .field("provider", &self.provider) + .field("client", &self.client) + .field("explorer", &self.explorer) + .field("supported_assets", &self.supported_assets) + .field("sui_client", &"SuiClient") + .finish() + } +} + +impl NearIntents { + pub fn new(rpc_provider: Arc) -> Self { + let client = NearIntentsClient::new(RpcClient::new(base_url(), rpc_provider.clone()), None); + let explorer = NearIntentsExplorer::new(RpcClient::new(explorer_url(), rpc_provider.clone())); + let sui_client = Arc::new(create_sui_client(rpc_provider.clone()).expect("failed to create Sui gRPC client")); + Self::with_client(client, explorer, sui_client) + } + + pub fn boxed(rpc_provider: Arc) -> Box { + Box::new(Self::new(rpc_provider)) + } +} + +impl NearIntents +where + C: gem_client::Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn with_client(client: NearIntentsClient, explorer: NearIntentsExplorer, sui_client: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::NearIntents), + client, + explorer, + supported_assets: supported_assets(), + sui_client, + } + } + fn build_app_fee() -> Option> { + let fee = default_referral_fees().near; + if fee.address.is_empty() || fee.bps == 0 { + return None; + } + Some(vec![AppFee { + recipient: fee.address, + fee: fee.bps, + }]) + } + + fn build_quote_request(request: &QuoteRequest, mode: SwapType, dry: bool) -> Result { + let origin_asset = get_near_asset_id(&request.from_asset)?; + let destination_asset = get_near_asset_id(&request.to_asset)?; + let deposit_mode = Self::resolve_deposit_mode(&request.from_asset); + let from_chain = request.from_asset.asset_id().chain; + let to_chain = request.to_asset.asset_id().chain; + let quote_waiting_time_ms = Some(Self::resolve_quote_waiting_time(from_chain, to_chain)); + + let deadline_minutes = Self::get_deadline_by_chain(from_chain).max(Self::get_deadline_by_chain(to_chain)); + let deadline = (Utc::now() + Duration::minutes(deadline_minutes)).to_rfc3339(); + + Ok(NearQuoteRequest { + origin_asset, + destination_asset, + amount: request.value.clone(), + referral: DEFAULT_REFERRER.to_string(), + recipient: request.destination_address.clone(), + swap_type: mode, + slippage_tolerance: request.options.slippage.bps, + app_fees: Self::build_app_fee(), + deposit_type: DEPOSIT_TYPE_ORIGIN.to_string(), + refund_to: request.wallet_address.clone(), + refund_type: DEPOSIT_TYPE_ORIGIN.to_string(), + recipient_type: RECIPIENT_TYPE_DESTINATION.to_string(), + deadline, + quote_waiting_time_ms, + dry, + deposit_mode, + }) + } + + fn map_transaction_status(status: &str) -> SwapStatus { + match status { + "SWAP_COMPLETED" | "SWAP_COMPLETED_TX" | "SUCCESS" => SwapStatus::Completed, + "REFUNDED" | "SWAP_REFUNDED" => SwapStatus::Failed, + "SWAP_FAILED" | "FAILED" | "SWAP_LIQUIDITY_TIMEOUT" | "SWAP_RISK_FAILED" => SwapStatus::Failed, + "KNOWN_DEPOSIT_TX" | "PENDING_DEPOSIT" | "INCOMPLETE_DEPOSIT" | "PROCESSING" => SwapStatus::Pending, + _ => SwapStatus::Pending, + } + } + + fn build_swap_metadata(tx: &ExplorerTransaction) -> Option { + let from_asset = get_asset_id_from_near_asset(&tx.origin_asset)?; + let to_asset = get_asset_id_from_near_asset(&tx.destination_asset)?; + Some(TransactionSwapMetadata { + from_asset, + from_value: tx.amount_in.clone(), + to_asset, + to_value: tx.amount_out.clone(), + provider: Some(SwapperProvider::NearIntents.as_ref().to_string()), + }) + } + + fn resolve_deposit_mode(asset: &SwapperQuoteAsset) -> DepositMode { + if deposit_memo_chains().contains(&asset.asset_id().chain) { + DepositMode::Memo + } else { + DepositMode::Simple + } + } + + fn resolve_quote_waiting_time(from_chain: Chain, to_chain: Chain) -> u32 { + if auto_quote_time_chains().contains(&from_chain) || auto_quote_time_chains().contains(&to_chain) { + 0 + } else { + DEFAULT_WAIT_TIME_MS + } + } + + fn get_deadline_by_chain(chain: Chain) -> i64 { + if chain == Chain::Bitcoin { BITCOIN_DEADLINE_MINUTES } else { DEFAULT_DEADLINE_MINUTES } + } + + async fn build_deposit_data( + &self, + deposit_memo: Option, + from_asset: &SwapperQuoteAsset, + wallet_address: &str, + deposit_address: &str, + amount_in: &str, + ) -> Result { + if from_asset.asset_id().chain == Chain::Sui { + return self.build_sui_deposit_data(from_asset, wallet_address, deposit_address, amount_in).await; + } + + Ok(DepositData { + to: deposit_address.to_string(), + value: amount_in.to_string(), + data: String::new(), + memo: deposit_memo, + }) + } + + async fn build_sui_deposit_data(&self, from_asset: &SwapperQuoteAsset, wallet_address: &str, deposit_address: &str, amount_in: &str) -> Result { + let amount = amount_in + .parse::() + .map_err(|_| SwapperError::ComputeQuoteError("Invalid Sui amount provided for deposit".into()))?; + + let message_bytes = build_transfer_message_bytes(self.sui_client.as_ref(), wallet_address, deposit_address, amount, from_asset.asset_id().token_id.as_deref()) + .await + .map_err(|err| SwapperError::TransactionError(format!("Failed to build Sui deposit data: {err}")))?; + + Ok(DepositData { + to: deposit_address.to_string(), + value: amount_in.to_string(), + data: message_bytes, + memo: None, + }) + } + + fn extract_quote(response: QuoteResponseResult, from_decimals: u32) -> Result { + match response { + QuoteResponseResult::Ok(quote) => Ok(*quote), + QuoteResponseResult::Err(error) => Err(map_quote_error(&error, from_decimals)), + } + } +} + +fn map_quote_error(error: &QuoteResponseError, from_decimals: u32) -> SwapperError { + let lower = error.message.to_ascii_lowercase(); + if lower.contains("too low") { + SwapperError::InputAmountError { + min_amount: parse_min_amount(&error.message, from_decimals), + } + } else { + SwapperError::ComputeQuoteError(format!("Near Intents quote error: {}", error.message)) + } +} + +fn parse_min_amount(message: &str, decimals: u32) -> Option { + let marker = "try at least "; + let lower = message.to_ascii_lowercase(); + let start = lower.find(marker)? + marker.len(); + let tail = message.get(start..)?; + let token = extract_numeric_token(tail)?; + amount_to_value(&token, decimals) +} + +fn extract_numeric_token(message: &str) -> Option { + let mut current = String::new(); + + for ch in message.chars() { + if ch.is_ascii_digit() || ch == '.' || ch == ',' || ch == '_' { + current.push(ch); + } else if !current.is_empty() { + return Some(current); + } + } + + if current.is_empty() { None } else { Some(current) } +} + +#[async_trait] +impl Swapper for NearIntents +where + C: gem_client::Client + Clone + Send + Sync + Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + self.supported_assets.clone() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let quote_request = Self::build_quote_request(request, SwapType::FlexInput, true)?; + let amount = quote_request.amount.clone(); + let response = Self::extract_quote(self.client.fetch_quote("e_request).await?, request.from_asset.decimals)?; + + let eta = response.quote.time_estimate; + let min_amount_in = response.quote.min_amount_in.to_string(); + let amount_out = response.quote.amount_out.to_string(); + let route_data = serde_json::to_string("e_request)?; + + Ok(Quote { + from_value: amount, + min_from_value: Some(min_amount_in), + to_value: amount_out, + data: ProviderData { + provider: self.provider.clone(), + slippage_bps: request.options.slippage.bps, + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data, + }], + }, + request: request.clone(), + eta_in_seconds: Some(eta), + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let mut quote_request: NearQuoteRequest = serde_json::from_str(&route.route_data)?; + let request_deposit_mode = quote_request.deposit_mode.clone(); + quote_request.dry = false; + + let response: QuoteResponse = Self::extract_quote(self.client.fetch_quote("e_request).await?, quote.request.from_asset.decimals)?; + let QuoteResponse { + quote_request: _, + quote: near_quote, + } = response; + + let deposit_address = near_quote + .deposit_address + .ok_or_else(|| SwapperError::ComputeQuoteError("Missing depositAddress in Near Intents response".into()))?; + let amount_in = near_quote.amount_in.to_string(); + let deposit_mode = near_quote + .deposit_mode + .or(Some(request_deposit_mode)) + .ok_or_else(|| SwapperError::ComputeQuoteError("Near Intents response missing deposit mode".into()))?; + let from_asset = "e.request.from_asset; + + let memo_required = deposit_memo_chains().contains(&from_asset.asset_id().chain); + let deposit_memo = near_quote.deposit_memo.filter(|memo| !memo.is_empty()); + + if memo_required && deposit_mode != DepositMode::Memo { + return Err(SwapperError::ComputeQuoteError("Near Intents Stellar deposits require a memo".into())); + } + if memo_required && deposit_memo.is_none() { + return Err(SwapperError::ComputeQuoteError("Near Intents Stellar deposit missing memo".into())); + } + + let data = self + .build_deposit_data(deposit_memo, from_asset, "e.request.wallet_address, &deposit_address, &amount_in) + .await?; + + let DepositData { to, value, data: payload, memo } = data; + + Ok(SwapperQuoteData { + data: payload, + ..SwapperQuoteData::new_tranfer(to, value, memo) + }) + } + + async fn get_swap_result(&self, _chain: Chain, hash: &str) -> Result { + let Some(tx) = self.explorer.search_transaction(hash).await? else { + return Ok(SwapResult { + status: SwapStatus::Pending, + metadata: None, + }); + }; + + let status = Self::map_transaction_status(&tx.status); + let metadata = Self::build_swap_metadata(&tx); + + Ok(SwapResult { status, metadata }) + } + + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + Ok(VaultAddresses { + deposit: vec![], + send: TREASURY_ADDRESSES.iter().map(|s| s.to_string()).collect(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{SwapperError, SwapperQuoteAsset}; + use primitives::{AssetId, Chain, asset_constants::TON_USDT_ASSET_ID}; + use serde_json::json; + + fn status(json: &str) -> SwapResult { + let transactions: Vec = serde_json::from_str(json).unwrap(); + let tx = &transactions[0]; + let status = NearIntents::::map_transaction_status(&tx.status); + let metadata = NearIntents::::build_swap_metadata(tx); + SwapResult { status, metadata } + } + + #[test] + fn max_quote_keeps_transfer_amount() { + let mut request = QuoteRequest::mock(Chain::Tron, None); + request.to_asset = SwapperQuoteAsset::from(AssetId::from_chain(Chain::Near)); + request.value = "37000000".to_string(); + request.options.use_max_amount = true; + + let quote_request = NearIntents::::build_quote_request(&request, SwapType::FlexInput, true).unwrap(); + + assert_eq!(quote_request.amount, "37000000"); + } + + #[test] + fn swap_result_avax_to_smartchain() { + let result = status(include_str!("testdata/tx_status_avax_to_smartchain.json")); + + assert_eq!( + result, + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: AssetId::from_chain(Chain::AvalancheC), + from_value: "28000000000000000".to_string(), + to_asset: AssetId::from_chain(Chain::SmartChain), + to_value: "399605209991817".to_string(), + provider: Some("near_intents".to_string()), + }), + } + ); + } + + #[test] + fn swap_result_solana_to_bitcoin() { + let result = status(include_str!("testdata/tx_status_solana_to_bitcoin.json")); + + assert_eq!( + result, + SwapResult { + status: SwapStatus::Pending, + metadata: Some(TransactionSwapMetadata { + from_asset: AssetId::from_chain(Chain::Solana), + from_value: "646605458".to_string(), + to_asset: AssetId::from_chain(Chain::Bitcoin), + to_value: "69086".to_string(), + provider: Some("near_intents".to_string()), + }), + } + ); + } + + #[test] + fn swap_result_ton_to_smartchain_refunded() { + let result = status(include_str!("testdata/tx_status_ton_to_smartchain_refunded.json")); + + assert_eq!( + result, + SwapResult { + status: SwapStatus::Failed, + metadata: Some(TransactionSwapMetadata { + from_asset: TON_USDT_ASSET_ID.clone(), + from_value: "6321766".to_string(), + to_asset: AssetId::from_chain(Chain::SmartChain), + to_value: "9690124016594003".to_string(), + provider: Some("near_intents".to_string()), + }), + } + ); + } + + #[test] + fn map_transaction_status_values() { + let map = NearIntents::::map_transaction_status; + + assert_eq!(map("SUCCESS"), SwapStatus::Completed); + assert_eq!(map("SWAP_COMPLETED"), SwapStatus::Completed); + assert_eq!(map("SWAP_COMPLETED_TX"), SwapStatus::Completed); + + assert_eq!(map("FAILED"), SwapStatus::Failed); + assert_eq!(map("SWAP_FAILED"), SwapStatus::Failed); + assert_eq!(map("REFUNDED"), SwapStatus::Failed); + assert_eq!(map("SWAP_REFUNDED"), SwapStatus::Failed); + assert_eq!(map("SWAP_LIQUIDITY_TIMEOUT"), SwapStatus::Failed); + assert_eq!(map("SWAP_RISK_FAILED"), SwapStatus::Failed); + + assert_eq!(map("PENDING_DEPOSIT"), SwapStatus::Pending); + assert_eq!(map("PROCESSING"), SwapStatus::Pending); + assert_eq!(map("KNOWN_DEPOSIT_TX"), SwapStatus::Pending); + assert_eq!(map("INCOMPLETE_DEPOSIT"), SwapStatus::Pending); + assert_eq!(map("UNKNOWN_STATUS"), SwapStatus::Pending); + } + + #[test] + fn decode_quote_response_error_message() { + let payload = json!({ + "message": "Amount is too low for bridge, try at least 8516130", + }); + + let decoded: QuoteResponseResult = serde_json::from_value(payload).unwrap(); + + let QuoteResponseResult::Err(err) = decoded else { + panic!("expected error variant"); + }; + assert_eq!(err.message, "Amount is too low for bridge, try at least 8516130"); + assert_eq!( + map_quote_error(&err, 6), + SwapperError::InputAmountError { + min_amount: Some("8516130".into()) + } + ); + } +} + +#[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] +mod swap_integration_tests { + use super::*; + use crate::near_intents::assets::NEAR_INTENTS_BTC_NATIVE; + use crate::{FetchQuoteData, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, models::Options}; + use primitives::{ + AssetId, Chain, + asset_constants::{ARBITRUM_USDC_ASSET_ID, BASE_USDC_ASSET_ID}, + }; + use std::sync::Arc; + + #[tokio::test] + async fn test_near_intents_quote() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::new().set_debug(true)); + let provider = NearIntents::new(rpc_provider); + + let options = Options::mock_exact(100); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(ARBITRUM_USDC_ASSET_ID.clone()), + to_asset: SwapperQuoteAsset::from(BASE_USDC_ASSET_ID.clone()), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "500000".to_string(), + options, + }; + + let quote = provider.get_quote(&request).await?; + assert!(!quote.to_value.is_empty()); + + let quote_data = provider.get_quote_data("e, FetchQuoteData::None).await?; + assert!(!quote_data.to.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_near_intents_bitcoin_quotes() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::new().set_debug(true)); + let provider = NearIntents::new(rpc_provider); + + let from_bitcoin_request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + to_asset: SwapperQuoteAsset::from(BASE_USDC_ASSET_ID.clone()), + wallet_address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "100000".to_string(), + options: Options::mock_exact(100), + }; + + let quote = provider.get_quote(&from_bitcoin_request).await?; + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let quote_request: NearQuoteRequest = serde_json::from_str(&route.route_data)?; + + assert_eq!(quote_request.origin_asset, NEAR_INTENTS_BTC_NATIVE); + assert!(!quote.to_value.is_empty()); + + println!( + "Near Intents BTC quote: from_value={}, to_value={}, eta={:?}", + quote.from_value, quote.to_value, quote.eta_in_seconds + ); + println!("Near Intents BTC quote request: {}", route.route_data); + + let to_bitcoin_request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(BASE_USDC_ASSET_ID.clone()), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), + value: "10000000".to_string(), + options: Options::mock_exact(100), + }; + + let quote = provider.get_quote(&to_bitcoin_request).await?; + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let quote_request: NearQuoteRequest = serde_json::from_str(&route.route_data)?; + + assert_eq!(quote_request.destination_asset, NEAR_INTENTS_BTC_NATIVE); + assert!(!quote.to_value.is_empty()); + + println!( + "Near Intents to BTC quote: from_value={}, to_value={}, eta={:?}", + quote.from_value, quote.to_value, quote.eta_in_seconds + ); + println!("Near Intents to BTC quote request: {}", route.route_data); + + Ok(()) + } + + #[tokio::test] + async fn test_near_intents_stellar_requires_memo() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::new().set_debug(true)); + let provider = NearIntents::new(rpc_provider); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Stellar)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Near)), + wallet_address: "GBZXN7PIRZGNMHGA3RSSOEV56YXG54FSNTJDGQI3GHDVBKSXRZ5B6KJT".to_string(), + destination_address: "test.near".to_string(), + value: "20000000".to_string(), + options: Options::mock_exact(100), + }; + + let quote = match provider.get_quote(&request).await { + Ok(quote) => quote, + Err(SwapperError::ComputeQuoteError(_)) => return Ok(()), + Err(error) => return Err(error), + }; + let quote_data = match provider.get_quote_data("e, FetchQuoteData::None).await { + Ok(data) => data, + Err(SwapperError::TransactionError(_)) => return Ok(()), + Err(error) => return Err(error), + }; + + assert!(!quote_data.data.is_empty(), "expected deposit memo for Stellar swaps via Near Intents"); + + Ok(()) + } + + #[tokio::test] + async fn test_near_intents_status() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::new().set_debug(true)); + let provider = NearIntents::new(rpc_provider); + let deposit_address = "18gB9wZz1Q4CzniurLye1KdUUqjWjo3ePr"; + + let swap_result = provider.get_swap_result(Chain::Bitcoin, deposit_address).await?; + + println!("swap_result: {swap_result:?}"); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/near_intents/testdata/tx_status_avax_to_smartchain.json b/core/crates/swapper/src/near_intents/testdata/tx_status_avax_to_smartchain.json new file mode 100644 index 0000000000..68a7018c16 --- /dev/null +++ b/core/crates/swapper/src/near_intents/testdata/tx_status_avax_to_smartchain.json @@ -0,0 +1,13 @@ +[ + { + "originAsset": "nep245:v2_1.omni.hot.tg:43114_11111111111111111111", + "destinationAsset": "nep245:v2_1.omni.hot.tg:56_11111111111111111111", + "depositAddress": "0x3ded70a0b68583572aA0a7581F50b5AFd8820175", + "status": "SUCCESS", + "amountIn": "28000000000000000", + "amountOut": "399605209991817", + "originChainTxHashes": [ + "0x60d445fe823f8fd3778f8ce5192e6ec0fd96e7313bbeefa85c6aaea3907f4ce4" + ] + } +] diff --git a/core/crates/swapper/src/near_intents/testdata/tx_status_solana_to_bitcoin.json b/core/crates/swapper/src/near_intents/testdata/tx_status_solana_to_bitcoin.json new file mode 100644 index 0000000000..fa50e6fd00 --- /dev/null +++ b/core/crates/swapper/src/near_intents/testdata/tx_status_solana_to_bitcoin.json @@ -0,0 +1,13 @@ +[ + { + "originAsset": "nep141:sol.omft.near", + "destinationAsset": "1cs_v1:btc:native:coin", + "depositAddress": "Ds6hfv9TMJWtbHX4gkKimBJ2DoLYVXuEM2atQ69T8MzP", + "status": "PROCESSING", + "amountIn": "646605458", + "amountOut": "69086", + "originChainTxHashes": [ + "4bADLaRWEXmDQsMSGdnDF5TwDQChvmW7WNroEbnGcWbtzHoJdcpieHFwfyaKK3XfZWeqsPgSyS7SuZBjVtUx1KBn" + ] + } +] diff --git a/core/crates/swapper/src/near_intents/testdata/tx_status_ton_to_smartchain_refunded.json b/core/crates/swapper/src/near_intents/testdata/tx_status_ton_to_smartchain_refunded.json new file mode 100644 index 0000000000..2ea0c17606 --- /dev/null +++ b/core/crates/swapper/src/near_intents/testdata/tx_status_ton_to_smartchain_refunded.json @@ -0,0 +1,14 @@ +[ + { + "originAsset": "nep245:v2_1.omni.hot.tg:1117_3tsdfyziyc7EJbP2aULWSKU4toBaAcN4FdTgfm5W1mC4ouR", + "destinationAsset": "nep245:v2_1.omni.hot.tg:56_11111111111111111111", + "depositAddress": "UQAqln5N_XeZTa6SfGlWaINzJU5xCV9XnKylr-S1mZ_8Na3s", + "status": "REFUNDED", + "amountIn": "6321766", + "amountOut": "9690124016594003", + "originChainTxHashes": [ + "145c3d667b373efdb3023476dd1f2199096ce65878723a04929964dc2304d09a", + "e0c0768baf51bf44e0ae1136618e8bb33453a3cf7c490a66df2237451f56554a" + ] + } +] diff --git a/core/crates/swapper/src/okx/auth.rs b/core/crates/swapper/src/okx/auth.rs new file mode 100644 index 0000000000..08b3280193 --- /dev/null +++ b/core/crates/swapper/src/okx/auth.rs @@ -0,0 +1,70 @@ +use super::model::OkxClientConfig; +use crate::SwapperError; +use gem_encoding::encode_base64; +use hmac::{Hmac, KeyInit, Mac}; +use serde::Serialize; +use sha2::Sha256; +use std::collections::HashMap; + +pub const HEADER_KEY: &str = "OK-ACCESS-KEY"; +pub const HEADER_SIGN: &str = "OK-ACCESS-SIGN"; +pub const HEADER_TIMESTAMP: &str = "OK-ACCESS-TIMESTAMP"; +pub const HEADER_PASSPHRASE: &str = "OK-ACCESS-PASSPHRASE"; +pub const HEADER_PROJECT: &str = "OK-ACCESS-PROJECT"; + +pub fn build_query_string(params: &T) -> Result { + let encoded = serde_urlencoded::to_string(params)?; + if encoded.is_empty() { Ok(String::new()) } else { Ok(format!("?{encoded}")) } +} + +pub fn sign(timestamp: &str, method: &str, path: &str, secret_key: &str) -> String { + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC accepts any key length"); + mac.update(timestamp.as_bytes()); + mac.update(method.as_bytes()); + mac.update(path.as_bytes()); + encode_base64(&mac.finalize().into_bytes()) +} + +pub fn build_headers(config: &OkxClientConfig, timestamp: &str, full_path: &str) -> HashMap { + HashMap::from([ + (HEADER_KEY.to_string(), config.api_key.clone()), + (HEADER_SIGN.to_string(), sign(timestamp, "GET", full_path, &config.secret_key)), + (HEADER_TIMESTAMP.to_string(), timestamp.to_string()), + (HEADER_PASSPHRASE.to_string(), config.passphrase.clone()), + (HEADER_PROJECT.to_string(), config.project.clone()), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sign() { + let s = sign("2024-01-01T00:00:00.000Z", "GET", "/api/v6/dex/aggregator/quote", "test_secret"); + assert_eq!(s, sign("2024-01-01T00:00:00.000Z", "GET", "/api/v6/dex/aggregator/quote", "test_secret")); + assert!(!s.is_empty()); + assert_ne!(s, sign("2024-01-01T00:00:00.001Z", "GET", "/api/v6/dex/aggregator/quote", "test_secret")); + assert_ne!(s, sign("2024-01-01T00:00:00.000Z", "POST", "/api/v6/dex/aggregator/quote", "test_secret")); + assert_ne!(s, sign("2024-01-01T00:00:00.000Z", "GET", "/api/v6/dex/aggregator/swap", "test_secret")); + assert_ne!(s, sign("2024-01-01T00:00:00.000Z", "GET", "/api/v6/dex/aggregator/quote", "other_secret")); + assert_ne!(sign("ts", "GET", "/path", "secret"), sign("ts/path", "GET", "", "secret")); + } + + #[test] + fn test_build_headers() { + let config = OkxClientConfig { + api_key: "key".to_string(), + secret_key: "secret".to_string(), + passphrase: "pass".to_string(), + project: "proj".to_string(), + }; + let headers = build_headers(&config, "2024-01-01T00:00:00.000Z", "/api/v6/dex/aggregator/quote?a=1"); + assert_eq!(headers.get(HEADER_KEY).unwrap(), "key"); + assert_eq!(headers.get(HEADER_TIMESTAMP).unwrap(), "2024-01-01T00:00:00.000Z"); + assert_eq!(headers.get(HEADER_PASSPHRASE).unwrap(), "pass"); + assert_eq!(headers.get(HEADER_PROJECT).unwrap(), "proj"); + assert!(!headers.get(HEADER_SIGN).unwrap().is_empty()); + } +} diff --git a/core/crates/swapper/src/okx/client.rs b/core/crates/swapper/src/okx/client.rs new file mode 100644 index 0000000000..de408a0750 --- /dev/null +++ b/core/crates/swapper/src/okx/client.rs @@ -0,0 +1,46 @@ +use super::{ + auth::{build_headers, build_query_string}, + model::{OkxApiResponse, OkxClientConfig, QuoteData, QuoteParams, SwapDataResult, SwapParams}, +}; +use crate::SwapperError; +use chrono::{SecondsFormat, Utc}; +use gem_client::{Client, ClientExt}; +use std::fmt::Debug; + +#[derive(Clone, Debug)] +pub(super) struct OkxDexClient +where + C: Client + Clone + Debug, +{ + client: C, + config: OkxClientConfig, +} + +impl OkxDexClient +where + C: Client + Clone + Debug, +{ + pub fn new(client: C, config: OkxClientConfig) -> Self { + Self { client, config } + } + + pub async fn get_quote(&self, params: &QuoteParams) -> Result, SwapperError> { + self.signed_get("/api/v6/dex/aggregator/quote", params).await + } + + pub async fn get_swap_data(&self, params: &SwapParams) -> Result, SwapperError> { + self.signed_get("/api/v6/dex/aggregator/swap", params).await + } + + async fn signed_get(&self, path: &str, params: &P) -> Result + where + P: serde::Serialize, + R: serde::de::DeserializeOwned + Send, + { + let query = build_query_string(params)?; + let full_path = format!("{path}{query}"); + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let headers = build_headers(&self.config, ×tamp, &full_path); + self.client.get_with_headers(&full_path, headers).await.map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/okx/constants.rs b/core/crates/swapper/src/okx/constants.rs new file mode 100644 index 0000000000..9889b66a7d --- /dev/null +++ b/core/crates/swapper/src/okx/constants.rs @@ -0,0 +1,52 @@ +use primitives::Chain; + +pub const BASE_URL: &str = "https://web3.okx.com"; + +pub const EVM_NATIVE_TOKEN_ADDRESS: &str = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +pub const SOLANA_NATIVE_TOKEN_ADDRESS: &str = "11111111111111111111111111111111"; + +const DEFAULT_EVM_GAS_LIMIT: u64 = 920_000; + +const SOLANA_DEX_IDS: &str = "277,278,279,343,72,103,284,338,372,403,444,483,357,345"; + +pub fn chain_index(chain: Chain) -> Option<&'static str> { + match chain { + Chain::Solana => Some("501"), + Chain::Ethereum + | Chain::SmartChain + | Chain::Polygon + | Chain::Arbitrum + | Chain::Optimism + | Chain::Base + | Chain::AvalancheC + | Chain::OpBNB + | Chain::Fantom + | Chain::Gnosis + | Chain::Manta + | Chain::Blast + | Chain::ZkSync + | Chain::Linea + | Chain::Mantle + | Chain::Celo + | Chain::Sonic + | Chain::Abstract + | Chain::Berachain + | Chain::Unichain + | Chain::Monad + | Chain::XLayer => Some(chain.config().network_id), + _ => None, + } +} + +pub fn dex_ids(chain: Chain) -> Option<&'static str> { + if chain == Chain::Solana { Some(SOLANA_DEX_IDS) } else { None } +} + +pub fn evm_gas_limit(chain: Chain) -> u64 { + match chain { + Chain::Manta => 600_000, + Chain::ZkSync => 2_000_000, + Chain::Mantle => 2_000_000_000, + _ => DEFAULT_EVM_GAS_LIMIT, + } +} diff --git a/core/crates/swapper/src/okx/mod.rs b/core/crates/swapper/src/okx/mod.rs new file mode 100644 index 0000000000..a1dd17bdde --- /dev/null +++ b/core/crates/swapper/src/okx/mod.rs @@ -0,0 +1,9 @@ +mod auth; +mod client; +mod constants; +mod model; +mod provider; +mod referral; + +pub use model::OkxClientConfig; +pub use provider::OkxProvider; diff --git a/core/crates/swapper/src/okx/model.rs b/core/crates/swapper/src/okx/model.rs new file mode 100644 index 0000000000..8f71bdc180 --- /dev/null +++ b/core/crates/swapper/src/okx/model.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq)] +pub struct OkxClientConfig { + pub api_key: String, + pub secret_key: String, + pub passphrase: String, + pub project: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct OkxApiResponse { + pub code: String, + #[serde(default)] + pub msg: String, + #[serde(default = "Vec::new")] + pub data: Vec, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(super) struct TokenInfo { + pub token_contract_address: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct QuoteData { + pub from_token: TokenInfo, + pub to_token: TokenInfo, + pub to_token_amount: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct TransactionData { + #[serde(default)] + pub data: String, + #[serde(default)] + pub to: String, + #[serde(default)] + pub value: String, + #[serde(default)] + pub gas: String, + #[serde(default)] + pub signature_data: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct QuoteParams { + pub chain_index: String, + pub amount: String, + pub from_token_address: String, + pub to_token_address: String, + pub slippage_percent: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub dex_ids: Option<&'static str>, + pub fee_percent: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SwapParams { + pub chain_index: String, + pub amount: String, + pub from_token_address: String, + pub to_token_address: String, + pub user_wallet_address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub approve_transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approve_amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slippage_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_slippage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_auto_slippage_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dex_ids: Option<&'static str>, + pub fee_percent: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_token_referrer_wallet_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to_token_referrer_wallet_address: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct SwapDataResult { + pub tx: TransactionData, +} diff --git a/core/crates/swapper/src/okx/provider.rs b/core/crates/swapper/src/okx/provider.rs new file mode 100644 index 0000000000..1419113a52 --- /dev/null +++ b/core/crates/swapper/src/okx/provider.rs @@ -0,0 +1,435 @@ +use super::{ + client::OkxDexClient, + constants::{BASE_URL, EVM_NATIVE_TOKEN_ADDRESS, SOLANA_NATIVE_TOKEN_ADDRESS, chain_index, dex_ids, evm_gas_limit}, + model::{OkxApiResponse, OkxClientConfig, QuoteData, QuoteParams, SwapParams, TransactionData}, + referral::referrer_wallet_addresses, +}; +use crate::{ + SwapperError, + alien::{RpcClient, RpcProvider}, + approval::check_approval_erc20, + fees::bps_to_percent_string, + models::ApprovalType, +}; +use alloy_primitives::U256; +use gem_client::Client; +use gem_encoding::encode_base64; +use num_bigint::BigUint; +use primitives::{ + Chain, ChainType, + swap::{ApprovalData, ProxyQuote, ProxyQuoteRequest, QuoteAsset, SwapQuoteData}, +}; +use serde_json::Value; +use std::{fmt::Debug, str::FromStr, sync::Arc}; + +const HUNDRED_PERCENT_IN_BPS: u32 = 10_000; +const OKX_GAS_LIMIT_BUFFER_PERCENT: u64 = 50; + +#[derive(Debug)] +pub struct OkxProvider +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + client: OkxDexClient, + rpc_provider: Arc, +} + +impl OkxProvider { + pub fn new(config: OkxClientConfig, rpc_provider: Arc) -> Self { + Self::new_with_client(RpcClient::new(BASE_URL.to_string(), rpc_provider.clone()), config, rpc_provider) + } +} + +impl OkxProvider +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new_with_client(client: C, config: OkxClientConfig, rpc_provider: Arc) -> Self { + Self { + client: OkxDexClient::new(client, config), + rpc_provider, + } + } + + pub async fn get_quote(&self, request: ProxyQuoteRequest) -> Result { + let chain = request.from_asset.chain(); + if request.to_asset.chain() != chain { + return Err(SwapperError::NotSupportedChain); + } + + let params = QuoteParams { + chain_index: chain_index(chain).ok_or(SwapperError::NotSupportedChain)?.to_string(), + amount: request.from_value.clone(), + from_token_address: asset_to_token_address(&request.from_asset)?, + to_token_address: asset_to_token_address(&request.to_asset)?, + slippage_percent: slippage_percent(request.slippage_bps), + dex_ids: dex_ids(chain), + fee_percent: bps_to_percent_string(request.referral_bps)?, + }; + + let response = self.client.get_quote(¶ms).await?; + let route = first_data(response, "Failed to fetch OKX quote")?; + + let output_min_value = output_min_value(&route.to_token_amount, request.slippage_bps)?; + let route_data = serde_json::to_value(&route)?; + + Ok(ProxyQuote { + output_value: route.to_token_amount.clone(), + output_min_value, + route_data, + eta_in_seconds: 0, + quote: request, + }) + } + + pub async fn get_quote_data(&self, quote: ProxyQuote) -> Result { + let route: QuoteData = serde_json::from_value(quote.route_data.clone()).map_err(|_| SwapperError::InvalidRoute)?; + let request = "e.quote; + let chain = request.from_asset.chain(); + let is_token_swap = chain.chain_type() == ChainType::Ethereum && request.from_asset.asset_id().token_id.is_some(); + let params = build_swap_params(request, &route, chain, is_token_swap)?; + + let response = self.client.get_swap_data(¶ms).await?; + let swap_data = first_data(response, "Failed to fetch OKX quote data")?; + let tx = swap_data.tx; + if tx.data.is_empty() { + return Err(SwapperError::InvalidRoute); + } + + match chain.chain_type() { + ChainType::Ethereum => self.build_evm_quote_data(&tx, &request.from_asset, &request.from_value, chain, &request.from_address).await, + ChainType::Solana => build_solana_quote_data(&tx), + _ => Err(SwapperError::NotSupportedChain), + } + } + + async fn build_evm_quote_data(&self, tx: &TransactionData, from_asset: &QuoteAsset, from_value: &str, chain: Chain, owner: &str) -> Result { + let approval = self.build_evm_approval(from_asset, tx.signature_data.as_deref(), from_value, chain, owner).await?; + let gas_limit = approval.is_some().then(|| apply_gas_multiplier_or_default(&tx.gas, chain)); + let value = if tx.value.is_empty() { "0".to_string() } else { tx.value.clone() }; + Ok(SwapQuoteData::new_contract(tx.to.clone(), value, tx.data.clone(), approval, gas_limit)) + } + + async fn build_evm_approval( + &self, + from_asset: &QuoteAsset, + signature_data: Option<&[String]>, + from_value: &str, + chain: Chain, + owner: &str, + ) -> Result, SwapperError> { + let Some(token) = from_asset.asset_id().token_id else { + return Ok(None); + }; + let Some(spender) = extract_spender(signature_data) else { + return Ok(None); + }; + let amount = U256::from_str(from_value)?; + match check_approval_erc20(owner.to_string(), token, spender, amount, self.rpc_provider.clone(), &chain).await? { + ApprovalType::Approve(data) => Ok(Some(data)), + _ => Ok(None), + } + } +} + +fn first_data(response: OkxApiResponse, fallback: &str) -> Result { + if response.code != "0" { + let message = if response.msg.is_empty() { fallback.to_string() } else { response.msg }; + return Err(SwapperError::ComputeQuoteError(message)); + } + response.data.into_iter().next().ok_or(SwapperError::NoQuoteAvailable) +} + +fn asset_to_token_address(asset: &QuoteAsset) -> Result { + let asset_id = asset.asset_id(); + if asset_id.chain == Chain::Solana { + return Ok(asset_id.token_id.unwrap_or_else(|| SOLANA_NATIVE_TOKEN_ADDRESS.to_string())); + } + if asset_id.chain.chain_type() == ChainType::Ethereum { + return Ok(asset_id.token_id.unwrap_or_else(|| EVM_NATIVE_TOKEN_ADDRESS.to_string())); + } + Err(SwapperError::NotSupportedChain) +} + +fn slippage_percent(slippage_bps: u32) -> String { + let bps = if slippage_bps == 0 { 100 } else { slippage_bps.min(100) }; + bps_to_percent_string(bps).unwrap_or_else(|_| "1".to_string()) +} + +fn max_auto_slippage_percent(slippage_bps: u32) -> Option { + if slippage_bps == 0 { + return None; + } + bps_to_percent_string(slippage_bps.saturating_mul(2)).ok() +} + +fn build_swap_params(request: &ProxyQuoteRequest, route: &QuoteData, chain: Chain, approve_transaction: bool) -> Result { + let referrers = referrer_wallet_addresses(&request.from_asset, &request.to_asset, chain); + Ok(SwapParams { + chain_index: chain_index(chain).ok_or(SwapperError::NotSupportedChain)?.to_string(), + amount: request.from_value.clone(), + from_token_address: route.from_token.token_contract_address.clone(), + to_token_address: route.to_token.token_contract_address.clone(), + user_wallet_address: request.from_address.clone(), + approve_transaction: approve_transaction.then_some(true), + approve_amount: approve_transaction.then(|| request.from_value.clone()), + slippage_percent: Some(slippage_percent(request.slippage_bps)), + auto_slippage: Some(true), + max_auto_slippage_percent: max_auto_slippage_percent(request.slippage_bps), + dex_ids: dex_ids(chain), + fee_percent: bps_to_percent_string(request.referral_bps)?, + from_token_referrer_wallet_address: referrers.from_token, + to_token_referrer_wallet_address: referrers.to_token, + }) +} + +fn output_min_value(to_token_amount: &str, slippage_bps: u32) -> Result { + let amount = BigUint::from_str(to_token_amount)?; + let bps = if slippage_bps == 0 { 100 } else { slippage_bps }; + let remaining = HUNDRED_PERCENT_IN_BPS.saturating_sub(bps.min(HUNDRED_PERCENT_IN_BPS)); + let result = (amount * BigUint::from(remaining)) / BigUint::from(HUNDRED_PERCENT_IN_BPS); + Ok(result.to_string()) +} + +fn extract_spender(signature_data: Option<&[String]>) -> Option { + signature_data + .unwrap_or(&[]) + .iter() + .filter_map(|s| serde_json::from_str::(s).ok()) + .find_map(|v| v.get("approveContract").and_then(|c| c.as_str()).map(str::to_owned)) + .filter(|s| !s.is_empty()) +} + +fn apply_gas_multiplier_or_default(gas: &str, chain: Chain) -> String { + match gas.parse::() { + Ok(value) if value > 0 => value.saturating_mul(100 + OKX_GAS_LIMIT_BUFFER_PERCENT).div_ceil(100).to_string(), + _ => evm_gas_limit(chain).to_string(), + } +} + +fn build_solana_quote_data(tx: &TransactionData) -> Result { + let bytes = bs58::decode(&tx.data) + .into_vec() + .map_err(|err| SwapperError::TransactionError(format!("invalid swap tx data: {err}")))?; + Ok(SwapQuoteData::new_contract(tx.to.clone(), "0".to_string(), encode_base64(&bytes), None, None)) +} + +#[cfg(test)] +mod tests { + use super::super::model::TokenInfo; + use super::*; + use crate::fees::default_referral_address; + use primitives::{ + AssetId, + asset_constants::{ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDC_TOKEN_ID, SMARTCHAIN_CAKE_TOKEN_ID, SOLANA_USDC_ASSET_ID, SOLANA_USDC_TOKEN_ID}, + }; + + fn quote_asset(id: &str) -> QuoteAsset { + quote_asset_with_symbol(id, "") + } + + fn quote_asset_with_symbol(id: &str, symbol: &str) -> QuoteAsset { + QuoteAsset { + id: id.to_string(), + symbol: symbol.to_string(), + decimals: 18, + } + } + + fn proxy_request_with_assets(from_asset: QuoteAsset, to_asset: QuoteAsset, slippage_bps: u32, referral_bps: u32) -> ProxyQuoteRequest { + ProxyQuoteRequest { + from_address: "0xabc".to_string(), + to_address: "0xabc".to_string(), + from_asset, + to_asset, + from_value: "1000000000000000000".to_string(), + referral_bps, + slippage_bps, + use_max_amount: false, + } + } + + fn proxy_request(from_id: &str, to_id: &str, slippage_bps: u32, referral_bps: u32) -> ProxyQuoteRequest { + proxy_request_with_assets(quote_asset(from_id), quote_asset(to_id), slippage_bps, referral_bps) + } + + fn quote_data(from_token: &str, to_token: &str) -> QuoteData { + QuoteData { + from_token: TokenInfo { + token_contract_address: from_token.to_string(), + }, + to_token: TokenInfo { + token_contract_address: to_token.to_string(), + }, + to_token_amount: "200".to_string(), + } + } + + #[test] + fn test_slippage_percent() { + assert_eq!(slippage_percent(0), "1"); + assert_eq!(slippage_percent(10), "0.1"); + assert_eq!(slippage_percent(50), "0.5"); + assert_eq!(slippage_percent(100), "1"); + assert_eq!(slippage_percent(500), "1"); + } + + #[test] + fn test_asset_to_token_address() { + let sol = AssetId::from_chain(Chain::Solana).to_string(); + let eth = AssetId::from_chain(Chain::Ethereum).to_string(); + assert_eq!(asset_to_token_address("e_asset(&sol)).unwrap(), SOLANA_NATIVE_TOKEN_ADDRESS); + assert_eq!(asset_to_token_address("e_asset(ð)).unwrap(), EVM_NATIVE_TOKEN_ADDRESS); + assert_eq!(asset_to_token_address("e_asset(ÐEREUM_USDC_ASSET_ID.to_string())).unwrap(), ETHEREUM_USDC_TOKEN_ID); + } + + #[test] + fn test_output_min_value() { + assert_eq!(output_min_value("1000", 0).unwrap(), "990"); + assert_eq!(output_min_value("1000", 100).unwrap(), "990"); + assert_eq!(output_min_value("1000", 300).unwrap(), "970"); + assert_eq!(output_min_value("10000000000000000000", 50).unwrap(), "9950000000000000000"); + assert_eq!(output_min_value("1000", 11_000).unwrap(), "0"); + } + + #[test] + fn test_first_data() { + let err_response: OkxApiResponse = OkxApiResponse { + code: "50011".to_string(), + msg: "Request frequency too high".to_string(), + data: vec![], + }; + assert!(matches!(first_data(err_response, "fallback"), Err(SwapperError::ComputeQuoteError(msg)) if msg == "Request frequency too high")); + + let empty_response: OkxApiResponse = OkxApiResponse { + code: "0".to_string(), + msg: String::new(), + data: vec![], + }; + assert!(matches!(first_data(empty_response, "fallback"), Err(SwapperError::NoQuoteAvailable))); + } + + #[test] + fn test_apply_gas_multiplier_or_default() { + assert_eq!(apply_gas_multiplier_or_default("200000", Chain::Ethereum), "300000"); + assert_eq!(apply_gas_multiplier_or_default("", Chain::Ethereum), "920000"); + assert_eq!(apply_gas_multiplier_or_default("0", Chain::Ethereum), "920000"); + assert_eq!(apply_gas_multiplier_or_default("0", Chain::Mantle), "2000000000"); + } + + #[test] + fn test_extract_spender() { + let valid = vec![r#"{"approveContract":"0x40aA958dd87FC8305b97f2BA922CDdCa374bcD7f"}"#.to_string()]; + assert_eq!(extract_spender(Some(&valid)).unwrap(), "0x40aA958dd87FC8305b97f2BA922CDdCa374bcD7f"); + assert!(extract_spender(Some(&["not json".to_string()])).is_none()); + assert!(extract_spender(Some(&[r#"{"approveContract":""}"#.to_string()])).is_none()); + assert!(extract_spender(None).is_none()); + } + + #[test] + fn test_build_solana_quote_data() { + let tx = TransactionData { + data: "Cn8eVZg".to_string(), + to: "ToAddr".to_string(), + value: String::new(), + gas: String::new(), + signature_data: None, + }; + let data = build_solana_quote_data(&tx).unwrap(); + assert_eq!(data.data, "aGVsbG8="); + assert_eq!(data.value, "0"); + assert_eq!(data.to, "ToAddr"); + assert!(data.gas_limit.is_none()); + assert!(data.approval.is_none()); + + let invalid = TransactionData { + data: "0OIl".to_string(), + to: String::new(), + value: String::new(), + gas: String::new(), + signature_data: None, + }; + assert!(matches!(build_solana_quote_data(&invalid), Err(SwapperError::TransactionError(_)))); + } + + #[test] + fn test_build_swap_params() { + let eth = AssetId::from_chain(Chain::Ethereum).to_string(); + let evm_request = proxy_request(ÐEREUM_USDC_ASSET_ID.to_string(), ð, 100, 50); + let evm_route = quote_data(ETHEREUM_USDC_TOKEN_ID, EVM_NATIVE_TOKEN_ADDRESS); + let evm_params = build_swap_params(&evm_request, &evm_route, Chain::Ethereum, true).unwrap(); + assert_eq!(evm_params.chain_index, "1"); + assert_eq!(evm_params.approve_transaction, Some(true)); + assert_eq!(evm_params.approve_amount.as_deref(), Some("1000000000000000000")); + assert_eq!(evm_params.fee_percent, "0.5"); + assert_eq!(evm_params.auto_slippage, Some(true)); + assert_eq!(evm_params.dex_ids, None); + assert_eq!(evm_params.max_auto_slippage_percent.as_deref(), Some("2")); + assert!(evm_params.to_token_referrer_wallet_address.is_some()); + assert!(evm_params.from_token_referrer_wallet_address.is_none()); + + let bnb = AssetId::from_chain(Chain::SmartChain).to_string(); + let cake = AssetId::from_token(Chain::SmartChain, SMARTCHAIN_CAKE_TOKEN_ID).to_string(); + let bsc_request = proxy_request_with_assets(quote_asset_with_symbol(&bnb, "BNB"), quote_asset_with_symbol(&cake, "CAKE"), 100, 70); + let bsc_route = quote_data(EVM_NATIVE_TOKEN_ADDRESS, SMARTCHAIN_CAKE_TOKEN_ID); + let bsc_params = build_swap_params(&bsc_request, &bsc_route, Chain::SmartChain, false).unwrap(); + let evm_referrer = default_referral_address(Chain::SmartChain); + assert_eq!(bsc_params.from_token_referrer_wallet_address.as_deref(), Some(evm_referrer.as_str())); + assert_eq!(bsc_params.to_token_referrer_wallet_address, None); + + let sol = AssetId::from_chain(Chain::Solana).to_string(); + let sol_request = proxy_request(&sol, &SOLANA_USDC_ASSET_ID.to_string(), 300, 50); + let sol_route = quote_data(SOLANA_NATIVE_TOKEN_ADDRESS, SOLANA_USDC_TOKEN_ID); + let sol_params = build_swap_params(&sol_request, &sol_route, Chain::Solana, false).unwrap(); + assert_eq!(sol_params.chain_index, "501"); + assert!(sol_params.approve_transaction.is_none()); + assert!(sol_params.approve_amount.is_none()); + assert!(sol_params.dex_ids.is_some()); + assert_eq!(sol_params.fee_percent, "0.5"); + assert!(sol_params.from_token_referrer_wallet_address.is_some()); + assert!(sol_params.to_token_referrer_wallet_address.is_none()); + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::alien::reqwest_provider::NativeProvider; + use primitives::{AssetId, asset_constants::SOLANA_USDC_ASSET_ID, swap::QuoteAsset, testkit::signer_mock::TEST_SOLANA_SENDER}; + + fn okx_provider() -> OkxProvider { + let settings = settings::testkit::get_test_settings(); + let config = OkxClientConfig { + api_key: settings.swap.okx.key.public, + secret_key: settings.swap.okx.key.secret, + passphrase: settings.swap.okx.passphrase, + project: settings.swap.okx.project, + }; + OkxProvider::new(config, Arc::new(NativeProvider::default())) + } + + #[tokio::test] + async fn test_okx_fetch_quote_and_quote_data_sol_to_usdc() -> Result<(), SwapperError> { + let provider = okx_provider(); + let request = ProxyQuoteRequest { + from_address: TEST_SOLANA_SENDER.to_string(), + to_address: TEST_SOLANA_SENDER.to_string(), + from_asset: QuoteAsset::from(AssetId::from_chain(Chain::Solana)), + to_asset: QuoteAsset::from(SOLANA_USDC_ASSET_ID.clone()), + from_value: "100000000".to_string(), + referral_bps: 50, + slippage_bps: 300, + use_max_amount: false, + }; + + let quote = provider.get_quote(request).await?; + assert!(quote.output_value.parse::().unwrap() > 0); + assert!(quote.output_min_value.parse::().unwrap() > 0); + + let quote_data = provider.get_quote_data(quote).await?; + assert!(!quote_data.to.is_empty()); + assert_eq!(quote_data.value, "0"); + assert!(!quote_data.data.is_empty()); + Ok(()) + } +} diff --git a/core/crates/swapper/src/okx/referral.rs b/core/crates/swapper/src/okx/referral.rs new file mode 100644 index 0000000000..732e1e832c --- /dev/null +++ b/core/crates/swapper/src/okx/referral.rs @@ -0,0 +1,87 @@ +use crate::{fee_token::FeeTokenPriority, fees::default_referral_address}; +use primitives::{Chain, swap::QuoteAsset}; + +pub(super) struct ReferrerWalletAddresses { + pub(super) from_token: Option, + pub(super) to_token: Option, +} + +pub(super) fn referrer_wallet_addresses(from_asset: &QuoteAsset, to_asset: &QuoteAsset, chain: Chain) -> ReferrerWalletAddresses { + let referrer = default_referral_address(chain); + if prefer_input_as_fee_token(from_asset, to_asset) { + ReferrerWalletAddresses { + from_token: Some(referrer), + to_token: None, + } + } else { + ReferrerWalletAddresses { + from_token: None, + to_token: Some(referrer), + } + } +} + +fn fee_token_priority(asset: &QuoteAsset) -> FeeTokenPriority { + let asset_id = asset.asset_id(); + FeeTokenPriority::from_asset(&asset_id, &asset.symbol) +} + +fn prefer_input_as_fee_token(from_asset: &QuoteAsset, to_asset: &QuoteAsset) -> bool { + fee_token_priority(from_asset).rank() > fee_token_priority(to_asset).rank() +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{ + AssetId, EVMChain, + asset_constants::{SMARTCHAIN_CAKE_TOKEN_ID, SMARTCHAIN_USDC_TOKEN_ID, SOLANA_USDC_ASSET_ID}, + contract_constants::SOLANA_WRAPPED_SOL_TOKEN_ADDRESS, + }; + + const SOLANA_BONK_TOKEN_ID: &str = "DezXAZ8z7PnrnRJjz3RhxPntipBqJJfMaB3Uy7VxwkL"; + + #[test] + fn test_prefer_input_as_fee_token() { + let bnb = QuoteAsset::mock_with_asset_id(AssetId::from_chain(Chain::SmartChain), "BNB", 18); + let wbnb = QuoteAsset::mock_with_asset_id(AssetId::from_token(Chain::SmartChain, EVMChain::SmartChain.weth_contract().unwrap()), "WBNB", 18); + let usdc = QuoteAsset::mock_with_asset_id(AssetId::from_token(Chain::SmartChain, SMARTCHAIN_USDC_TOKEN_ID), "USDC", 6); + let cake = QuoteAsset::mock_with_asset_id(AssetId::from_token(Chain::SmartChain, SMARTCHAIN_CAKE_TOKEN_ID), "CAKE", 18); + let wsol = QuoteAsset::mock_with_asset_id(AssetId::from_token(Chain::Solana, SOLANA_WRAPPED_SOL_TOKEN_ADDRESS), "SOL", 9); + let bonk = QuoteAsset::mock_with_asset_id(AssetId::from_token(Chain::Solana, SOLANA_BONK_TOKEN_ID), "BONK", 5); + + assert!(prefer_input_as_fee_token(&bnb, &cake)); + assert!(!prefer_input_as_fee_token(&cake, &bnb)); + assert!(prefer_input_as_fee_token(&wbnb, &cake)); + assert!(!prefer_input_as_fee_token(&cake, &wbnb)); + assert!(prefer_input_as_fee_token(&usdc, &cake)); + assert!(!prefer_input_as_fee_token(&cake, &usdc)); + assert!(prefer_input_as_fee_token(&wbnb, &usdc)); + assert!(!prefer_input_as_fee_token(&usdc, &wbnb)); + assert!(!prefer_input_as_fee_token(&cake, &cake)); + assert!(prefer_input_as_fee_token(&wsol, &bonk)); + assert!(!prefer_input_as_fee_token(&bonk, &wsol)); + } + + #[test] + fn test_referrer_wallet_addresses() { + let bnb = QuoteAsset::mock_with_asset_id(AssetId::from_chain(Chain::SmartChain), "BNB", 18); + let cake = QuoteAsset::mock_with_asset_id(AssetId::from_token(Chain::SmartChain, SMARTCHAIN_CAKE_TOKEN_ID), "CAKE", 18); + let evm_referrer = default_referral_address(Chain::SmartChain); + + let input_referrer = referrer_wallet_addresses(&bnb, &cake, Chain::SmartChain); + assert_eq!(input_referrer.from_token.as_deref(), Some(evm_referrer.as_str())); + assert_eq!(input_referrer.to_token, None); + + let output_referrer = referrer_wallet_addresses(&cake, &bnb, Chain::SmartChain); + assert_eq!(output_referrer.from_token, None); + assert_eq!(output_referrer.to_token.as_deref(), Some(evm_referrer.as_str())); + + let sol = QuoteAsset::mock_with_asset_id(AssetId::from_chain(Chain::Solana), "SOL", 9); + let usdc = QuoteAsset::mock_with_asset_id(SOLANA_USDC_ASSET_ID.clone(), "USDC", 6); + let solana_referrer = default_referral_address(Chain::Solana); + let solana_input_referrer = referrer_wallet_addresses(&sol, &usdc, Chain::Solana); + assert_eq!(solana_input_referrer.from_token.as_deref(), Some(solana_referrer.as_str())); + assert_eq!(solana_input_referrer.to_token, None); + } +} diff --git a/core/crates/swapper/src/panora/client.rs b/core/crates/swapper/src/panora/client.rs new file mode 100644 index 0000000000..02db3b127b --- /dev/null +++ b/core/crates/swapper/src/panora/client.rs @@ -0,0 +1,26 @@ +use super::model::{QuoteRequest, QuoteResponse}; +use crate::SwapperError; +use gem_client::{Client, ClientExt, build_path_with_query}; +use std::fmt::Debug; + +#[derive(Clone, Debug)] +pub struct PanoraClient +where + C: Client + Clone + Debug, +{ + client: C, +} + +impl PanoraClient +where + C: Client + Clone + Debug, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_quote(&self, request: &QuoteRequest) -> Result { + let path = build_path_with_query("/swap", request)?; + self.client.post(&path, &serde_json::json!({})).await.map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/panora/mod.rs b/core/crates/swapper/src/panora/mod.rs new file mode 100644 index 0000000000..526cf441f0 --- /dev/null +++ b/core/crates/swapper/src/panora/mod.rs @@ -0,0 +1,5 @@ +mod client; +mod model; +mod provider; + +pub use provider::Panora; diff --git a/core/crates/swapper/src/panora/model.rs b/core/crates/swapper/src/panora/model.rs new file mode 100644 index 0000000000..cf3e87d8c4 --- /dev/null +++ b/core/crates/swapper/src/panora/model.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteRequest { + pub from_token_address: String, + pub to_token_address: String, + pub from_token_amount: String, + pub to_wallet_address: String, + pub slippage_percentage: String, + pub integrator_fee_percentage: String, + pub integrator_fee_address: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Token { + pub decimals: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct QuoteEntry { + pub to_token_amount: String, + #[serde(rename = "txData")] + pub transaction_data: TransactionData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TransactionData { + pub function: String, + #[serde(default)] + pub type_arguments: Vec, + #[serde(default)] + pub arguments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct QuoteResponse { + pub to_token: Token, + pub quotes: Vec, +} diff --git a/core/crates/swapper/src/panora/provider.rs b/core/crates/swapper/src/panora/provider.rs new file mode 100644 index 0000000000..5ab76cdcfc --- /dev/null +++ b/core/crates/swapper/src/panora/provider.rs @@ -0,0 +1,212 @@ +use super::{client::PanoraClient, model}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteAsset, + SwapperQuoteData, + config::get_swap_proxy_url, + fees::{ReferralFee, bps_to_percent_string, default_referral_fees, quote_value_after_reserve_by_chain}, +}; +use async_trait::async_trait; +use gem_aptos::{APTOS_NATIVE_COIN, ENTRY_FUNCTION_PAYLOAD_TYPE, TransactionPayload}; +use gem_client::Client; +use number_formatter::BigNumberFormatter; +use primitives::Chain; +use std::{fmt::Debug, sync::Arc}; + +#[derive(Debug)] +pub struct Panora +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + provider: ProviderType, + client: PanoraClient, +} + +impl Panora { + pub fn new(rpc_provider: Arc) -> Self { + Self::new_with_client(RpcClient::new(get_swap_proxy_url("panora"), rpc_provider)) + } +} + +impl Panora +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new_with_client(client: C) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Panora), + client: PanoraClient::new(client), + } + } + + fn referral_fee() -> ReferralFee { + default_referral_fees().aptos + } + + fn build_request(request: &QuoteRequest, from_value: &str) -> Result { + let referral = Self::referral_fee(); + Ok(model::QuoteRequest { + from_token_address: token_address(&request.from_asset), + to_token_address: token_address(&request.to_asset), + from_token_amount: BigNumberFormatter::value(from_value, request.from_asset.decimals as i32)?, + to_wallet_address: request.destination_address.clone(), + slippage_percentage: bps_to_percent_string(request.options.slippage.bps)?, + integrator_fee_percentage: bps_to_percent_string(referral.bps)?, + integrator_fee_address: referral.address, + }) + } +} + +#[async_trait] +impl Swapper for Panora +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + vec![SwapperChainAsset::All(Chain::Aptos)] + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_value = quote_value_after_reserve_by_chain(request)?; + let response = self.client.get_quote(&Self::build_request(request, &from_value)?).await?; + let quote = response.quotes.first().ok_or(SwapperError::NoQuoteAvailable)?; + + Ok(Quote { + from_value, + min_from_value: None, + to_value: BigNumberFormatter::value_from_amount("e.to_token_amount, response.to_token.decimals)?, + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: serde_json::to_string(&response)?, + }], + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: None, + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let response: model::QuoteResponse = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + let transaction = response.quotes.first().ok_or(SwapperError::InvalidRoute)?.transaction_data.clone(); + + let payload = TransactionPayload { + function: Some(transaction.function), + type_arguments: transaction.type_arguments, + arguments: transaction.arguments, + payload_type: ENTRY_FUNCTION_PAYLOAD_TYPE.to_string(), + }; + + Ok(SwapperQuoteData::new_contract(String::new(), "0".to_string(), serde_json::to_string(&payload)?, None, None)) + } +} + +fn token_address(asset: &SwapperQuoteAsset) -> String { + asset.asset_id().token_id.unwrap_or_else(|| APTOS_NATIVE_COIN.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Options, SwapperQuoteAsset, fees::default_referral_fees}; + use primitives::asset_constants::APTOS_USDT_ASSET_ID; + + const TEST_WALLET: &str = "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef"; + const QUOTE_RESPONSE: &str = include_str!("testdata/quote_response.json"); + + fn quote_request() -> QuoteRequest { + QuoteRequest { + from_asset: SwapperQuoteAsset { + id: Chain::Aptos.as_ref().to_string(), + symbol: "APT".to_string(), + decimals: 8, + }, + to_asset: SwapperQuoteAsset { + id: APTOS_USDT_ASSET_ID.to_string(), + symbol: "USDT".to_string(), + decimals: 6, + }, + wallet_address: TEST_WALLET.to_string(), + destination_address: TEST_WALLET.to_string(), + value: "100000000".to_string(), + options: Options { + slippage: 100.into(), + use_max_amount: false, + }, + } + } + + #[test] + fn test_build_request() { + let request = quote_request(); + let referral = default_referral_fees().aptos; + + let built = Panora::::build_request(&request, "80000000").unwrap(); + + assert_eq!(built.from_token_address, APTOS_NATIVE_COIN); + assert_eq!(built.to_token_address, token_address(&request.to_asset)); + assert_eq!(built.from_token_amount, "0.8"); + assert_eq!(built.to_wallet_address, TEST_WALLET); + assert_eq!(built.slippage_percentage, "1"); + assert_eq!(built.integrator_fee_percentage, "0.5"); + assert_eq!(built.integrator_fee_address, referral.address); + } + + #[test] + fn test_parse_quote_response() { + let response: model::QuoteResponse = serde_json::from_str(QUOTE_RESPONSE).unwrap(); + let entry = response.quotes.first().unwrap(); + + assert_eq!(response.to_token.decimals, 6); + assert_eq!(entry.to_token_amount, "0.891234"); + assert_eq!( + entry.transaction_data.function, + "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap::router_entry" + ); + assert_eq!(entry.transaction_data.type_arguments, vec![APTOS_NATIVE_COIN]); + assert_eq!(entry.transaction_data.arguments.len(), 20); + assert_eq!(entry.transaction_data.arguments[2], serde_json::json!(1)); + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{Options, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider}; + use primitives::{AssetId, asset_constants::APTOS_USDT_ASSET_ID}; + + const TEST_APTOS_WALLET: &str = "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef"; + + #[tokio::test] + async fn test_panora_fetch_quote_and_quote_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = Panora::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Aptos)), + to_asset: SwapperQuoteAsset::from(APTOS_USDT_ASSET_ID.clone()), + wallet_address: TEST_APTOS_WALLET.to_string(), + destination_address: TEST_APTOS_WALLET.to_string(), + value: "100000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = provider.get_quote(&request).await?; + assert!(quote.to_value.parse::().unwrap() > 0); + assert_eq!(quote.data.provider, provider.provider().clone()); + assert_eq!(quote.data.routes.len(), 1); + + let quote_data = provider.get_quote_data("e, FetchQuoteData::None).await?; + assert_eq!(quote_data.to, ""); + assert_eq!(quote_data.value, "0"); + assert!(!quote_data.data.is_empty()); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/panora/testdata/quote_response.json b/core/crates/swapper/src/panora/testdata/quote_response.json new file mode 100644 index 0000000000..3e5f2cc154 --- /dev/null +++ b/core/crates/swapper/src/panora/testdata/quote_response.json @@ -0,0 +1,95 @@ +{ + "toToken": { + "decimals": 6 + }, + "quotes": [ + { + "toTokenAmount": "0.891234", + "txData": { + "function": "0x1c3206329806286fd2223647c9f9b130e66baeb6d7224a18c1f642ffe48f3b4c::panora_swap::router_entry", + "type_arguments": [ + "0x1::aptos_coin::AptosCoin" + ], + "arguments": [ + null, + "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + 1, + "2", + [ + 1, + 2, + "0x0f" + ], + [ + [ + [ + 1, + 2 + ] + ] + ], + [ + [ + [ + 1, + "2" + ] + ] + ], + [ + [ + [ + 1, + false, + "" + ] + ] + ], + [ + [ + 1, + 2 + ], + [ + 3 + ] + ], + [ + [ + [ + "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef" + ] + ] + ], + [ + [ + "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef" + ] + ], + [ + [ + "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef" + ] + ], + null, + [ + [ + [ + 3 + ] + ] + ], + null, + "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef", + [ + 1, + "2" + ], + 100, + "200", + "0x4eb20e735591a85bb58921ef2e6b55c385bba10e817ffe1e02e50deb6c594aef" + ] + } + } + ] +} diff --git a/core/crates/swapper/src/permit2_data.rs b/core/crates/swapper/src/permit2_data.rs new file mode 100644 index 0000000000..efa8bc7b60 --- /dev/null +++ b/core/crates/swapper/src/permit2_data.rs @@ -0,0 +1,131 @@ +use crate::SwapperError; + +use alloy_primitives::{ + Bytes, U256, + aliases::{U48, U160}, +}; +use serde::{Deserialize, Serialize, Serializer}; +use std::{ + fmt::{Debug, Display}, + str::FromStr, +}; + +use gem_evm::{ + eip712::EIP712Domain, + permit2::{IAllowanceTransfer, Permit2Types}, + uniswap::command::Permit2Permit, +}; +use primitives::Chain; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Permit2Detail { + pub token: String, + pub amount: String, + #[serde(serialize_with = "serialize_as_string")] + pub expiration: u64, + #[serde(serialize_with = "serialize_as_string")] + pub nonce: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PermitSingle { + pub details: Permit2Detail, + pub spender: String, + #[serde(rename = "sigDeadline", serialize_with = "serialize_as_string")] + pub sig_deadline: u64, +} + +impl From for IAllowanceTransfer::PermitSingle { + fn from(val: PermitSingle) -> Self { + IAllowanceTransfer::PermitSingle { + details: IAllowanceTransfer::PermitDetails { + token: val.details.token.as_str().parse().unwrap(), + amount: U160::from_str(&val.details.amount).unwrap(), + expiration: U48::from(val.details.expiration), + nonce: U48::from(val.details.nonce), + }, + spender: val.spender.as_str().parse().unwrap(), + sigDeadline: U256::from(val.sig_deadline), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Permit2Data { + pub permit_single: PermitSingle, + pub signature: Vec, +} + +impl From for Permit2Permit { + fn from(val: Permit2Data) -> Self { + Permit2Permit { + permit_single: val.permit_single.into(), + signature: Bytes::from(val.signature), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Permit2Message { + domain: EIP712Domain, + types: Permit2Types, + #[serde(rename = "primaryType")] + primary_type: String, + message: PermitSingle, +} + +fn serialize_as_string(value: &T, serializer: S) -> Result +where + T: Display, + S: Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +pub fn permit2_data_to_eip712_json(chain: Chain, data: PermitSingle, contract: &str) -> Result { + let chain_id = chain.network_id(); + let message = Permit2Message { + domain: EIP712Domain { + name: Some("Permit2".to_string()), + version: None, + chain_id: Some(chain_id.parse::().unwrap()), + verifying_contract: Some(contract.to_string()), + salts: None, + }, + types: Permit2Types::default(), + primary_type: "PermitSingle".into(), + message: data, + }; + let json = serde_json::to_string(&message).map_err(|_| SwapperError::TransactionError("failed to serialize EIP712 message to JSON".into()))?; + Ok(json) +} + +#[cfg(test)] +mod tests { + use gem_evm::uniswap::deployment::get_uniswap_permit2_by_chain; + use primitives::{ + asset_constants::ETHEREUM_USDT_TOKEN_ID, + contract_constants::{ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, UNISWAP_PERMIT2_CONTRACT}, + }; + + use super::*; + #[test] + fn test_permit2_data_eip712_json() { + let data = PermitSingle { + details: Permit2Detail { + token: ETHEREUM_USDT_TOKEN_ID.into(), + amount: "1461501637330902918203684832716283019655932542975".into(), + expiration: 1732780554, + nonce: 0, + }, + spender: ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT.into(), + sig_deadline: 1730190354, + }; + + let json = permit2_data_to_eip712_json(Chain::Ethereum, data, get_uniswap_permit2_by_chain(&Chain::Ethereum).unwrap()).unwrap(); + let expected = format!( + r#"{{"domain":{{"name":"Permit2","chainId":1,"verifyingContract":"{UNISWAP_PERMIT2_CONTRACT}"}},"types":{{"EIP712Domain":[{{"name":"name","type":"string"}},{{"name":"chainId","type":"uint256"}},{{"name":"verifyingContract","type":"address"}}],"PermitSingle":[{{"name":"details","type":"PermitDetails"}},{{"name":"spender","type":"address"}},{{"name":"sigDeadline","type":"uint256"}}],"PermitDetails":[{{"name":"token","type":"address"}},{{"name":"amount","type":"uint160"}},{{"name":"expiration","type":"uint48"}},{{"name":"nonce","type":"uint48"}}]}},"primaryType":"PermitSingle","message":{{"details":{{"token":"{ETHEREUM_USDT_TOKEN_ID}","amount":"1461501637330902918203684832716283019655932542975","expiration":"1732780554","nonce":"0"}},"spender":"{ETHEREUM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT}","sigDeadline":"1730190354"}}}}"# + ); + assert_eq!(json, expected); + } +} diff --git a/core/crates/swapper/src/proxy/client.rs b/core/crates/swapper/src/proxy/client.rs new file mode 100644 index 0000000000..9900594907 --- /dev/null +++ b/core/crates/swapper/src/proxy/client.rs @@ -0,0 +1,103 @@ +use crate::SwapperError; +use gem_client::{Client, ClientExt}; +use primitives::swap::{ProxyQuote, ProxyQuoteRequest, SwapQuoteData}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::fmt::Debug; + +#[derive(Debug, Deserialize)] +pub struct ProxyError { + pub err: SwapperError, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ProxyResponse { + Ok { ok: T }, + Err { err: SwapperError }, +} + +impl From> for ProxyResponse { + fn from(result: Result) -> Self { + match result { + Ok(ok) => Self::Ok { ok }, + Err(err) => Self::Err { err }, + } + } +} + +impl From> for Result { + fn from(response: ProxyResponse) -> Self { + match response { + ProxyResponse::Ok { ok } => Ok(ok), + ProxyResponse::Err { err } => Err(err), + } + } +} + +#[derive(Clone, Debug)] +pub struct ProxyClient { + client: C, +} + +impl ProxyClient { + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_quote(&self, request: ProxyQuoteRequest) -> Result { + self.post("/quote", &request).await + } + + pub async fn get_quote_data(&self, quote: ProxyQuote) -> Result { + self.post("/quote_data", "e).await + } + + async fn post(&self, path: &str, body: &Req) -> Result { + let response: ProxyResponse = self.client.post(path, body).await?; + response.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_proxy_error_deserialization() { + let json = r#"{"err": {"type": "compute_quote_error", "message": "Quote failed"}}"#; + assert_eq!( + serde_json::from_str::(json).unwrap().err, + SwapperError::ComputeQuoteError("Quote failed".into()) + ); + + let json = r#"{"err": {"type": "input_amount_error", "message": {"min_amount": "19620000"}}}"#; + assert_eq!( + serde_json::from_str::(json).unwrap().err, + SwapperError::InputAmountError { + min_amount: Some("19620000".into()) + } + ); + + let json = r#"{"err": {"type": "input_amount_error", "message": {"min_amount": null}}}"#; + assert_eq!(serde_json::from_str::(json).unwrap().err, SwapperError::InputAmountError { min_amount: None }); + + let json = r#"{"err": {"type": "no_quote_available"}}"#; + assert_eq!(serde_json::from_str::(json).unwrap().err, SwapperError::NoQuoteAvailable); + + let json = r#"{"err": {"type": "transaction_error", "message": "tx failed"}}"#; + assert_eq!(serde_json::from_str::(json).unwrap().err, SwapperError::TransactionError("tx failed".into())); + } + + #[test] + fn test_swapper_error_serialization() { + assert_eq!( + serde_json::to_string(&SwapperError::InputAmountError { min_amount: Some("100".into()) }).unwrap(), + r#"{"type":"input_amount_error","message":{"min_amount":"100"}}"# + ); + assert_eq!( + serde_json::to_string(&SwapperError::ComputeQuoteError("error".into())).unwrap(), + r#"{"type":"compute_quote_error","message":"error"}"# + ); + assert_eq!(serde_json::to_string(&SwapperError::NoQuoteAvailable).unwrap(), r#"{"type":"no_quote_available"}"#); + } +} diff --git a/core/crates/swapper/src/proxy/mod.rs b/core/crates/swapper/src/proxy/mod.rs new file mode 100644 index 0000000000..acad5606ee --- /dev/null +++ b/core/crates/swapper/src/proxy/mod.rs @@ -0,0 +1,6 @@ +mod client; + +pub mod provider; +pub mod provider_factory; + +pub use client::{ProxyError, ProxyResponse}; diff --git a/core/crates/swapper/src/proxy/provider.rs b/core/crates/swapper/src/proxy/provider.rs new file mode 100644 index 0000000000..ccde53a9bb --- /dev/null +++ b/core/crates/swapper/src/proxy/provider.rs @@ -0,0 +1,285 @@ +use alloy_primitives::U256; +use async_trait::async_trait; +use std::{fmt::Debug, str::FromStr, sync::Arc}; + +use super::client::ProxyClient; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, SwapResult, Swapper, SwapperError, SwapperProvider, SwapperProviderMode, SwapperQuoteData, + alien::{RpcClient, RpcProvider}, + approval::{DEFAULT_EVM_SWAP_GAS_LIMIT, check_approval_erc20, get_swap_gas_limit_with_approval}, + config::get_swap_proxy_url, + cross_chain::VaultAddresses, + fees::{DEFAULT_AGGREGATOR_FEE_BPS, DEFAULT_SWAP_FEE_BPS}, + models::SwapperChainAsset, +}; +use gem_client::Client; +use primitives::{ + Chain, ChainType, + swap::{ApprovalData, ProxyQuote, ProxyQuoteRequest, SwapQuoteData}, +}; + +#[derive(Debug)] +pub struct ProxyProvider +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub provider: ProviderType, + pub assets: Vec, + client: ProxyClient, + pub(crate) rpc_provider: Arc, +} + +impl ProxyProvider +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + fn new_with_client(provider: SwapperProvider, client: ProxyClient, assets: Vec, rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(provider), + assets, + client, + rpc_provider, + } + } + + pub async fn check_approval_and_limit(&self, quote: &Quote, quote_data: &SwapQuoteData) -> Result<(Option, Option), SwapperError> { + if let Some(ref approval) = quote_data.approval { + let approval = Some(approval.clone()); + let gas_limit = get_swap_gas_limit_with_approval(&approval, quote_data.gas_limit.clone(), DEFAULT_EVM_SWAP_GAS_LIMIT); + return Ok((approval, gas_limit)); + } + + let request = "e.request; + let from_asset = request.from_asset.asset_id(); + + match from_asset.chain.chain_type() { + ChainType::Ethereum => { + if from_asset.is_native() { + Ok((None, None)) + } else { + let token = from_asset.token_id.ok_or(SwapperError::NotSupportedAsset)?; + self.check_evm_approval( + request.wallet_address.clone(), + token, + quote_data.to.clone(), + U256::from_str("e.from_value).map_err(SwapperError::from)?, + &from_asset.chain, + quote_data.gas_limit.clone(), + ) + .await + } + } + _ => Ok((None, quote_data.gas_limit.clone())), + } + } + + async fn check_evm_approval( + &self, + wallet_address: String, + token: String, + spender: String, + amount: U256, + chain: &Chain, + swap_gas_limit: Option, + ) -> Result<(Option, Option), SwapperError> { + let approval = check_approval_erc20(wallet_address, token, spender, amount, self.rpc_provider.clone(), chain).await?; + let approval = approval.approval_data(); + let gas_limit = get_swap_gas_limit_with_approval(&approval, swap_gas_limit, DEFAULT_EVM_SWAP_GAS_LIMIT); + Ok((approval, gas_limit)) + } + + fn referral_bps(&self) -> u32 { + match self.provider.id { + SwapperProvider::Okx => DEFAULT_AGGREGATOR_FEE_BPS, + _ => DEFAULT_SWAP_FEE_BPS, + } + } +} + +impl ProxyProvider { + fn new_with_path(provider: SwapperProvider, path: &str, assets: Vec, rpc_provider: Arc) -> Self { + let base_url = get_swap_proxy_url(&format!("swapper/{path}")); + let client = ProxyClient::new(RpcClient::new(base_url, rpc_provider.clone())); + Self::new_with_client(provider, client, assets, rpc_provider) + } + + pub fn new_okx(rpc_provider: Arc) -> Self { + Self::new_with_path( + SwapperProvider::Okx, + "okx", + vec![ + SwapperChainAsset::All(Chain::Solana), + SwapperChainAsset::All(Chain::Ethereum), + SwapperChainAsset::All(Chain::SmartChain), + SwapperChainAsset::All(Chain::Polygon), + SwapperChainAsset::All(Chain::Arbitrum), + SwapperChainAsset::All(Chain::Optimism), + SwapperChainAsset::All(Chain::Base), + SwapperChainAsset::All(Chain::AvalancheC), + SwapperChainAsset::All(Chain::OpBNB), + SwapperChainAsset::All(Chain::Fantom), + SwapperChainAsset::All(Chain::Gnosis), + SwapperChainAsset::All(Chain::Manta), + SwapperChainAsset::All(Chain::Blast), + SwapperChainAsset::All(Chain::ZkSync), + SwapperChainAsset::All(Chain::Linea), + SwapperChainAsset::All(Chain::Mantle), + SwapperChainAsset::All(Chain::Celo), + SwapperChainAsset::All(Chain::Sonic), + SwapperChainAsset::All(Chain::Abstract), + SwapperChainAsset::All(Chain::Berachain), + SwapperChainAsset::All(Chain::Unichain), + SwapperChainAsset::All(Chain::Monad), + SwapperChainAsset::All(Chain::XLayer), + ], + rpc_provider, + ) + } +} + +#[async_trait] +impl Swapper for ProxyProvider +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + self.assets.clone() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let quote_request = ProxyQuoteRequest { + from_address: request.wallet_address.clone(), + to_address: request.destination_address.clone(), + from_asset: request.from_asset.clone(), + to_asset: request.to_asset.clone(), + from_value: request.value.clone(), + referral_bps: self.referral_bps(), + slippage_bps: request.options.slippage.bps, + use_max_amount: request.options.use_max_amount, + }; + + let quote = self.client.get_quote(quote_request.clone()).await?; + + Ok(Quote { + from_value: request.value.clone(), + min_from_value: None, + to_value: quote.output_value.clone(), + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: serde_json::to_string("e).map_err(SwapperError::compute_quote_error)?, + }], + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: Some(quote.eta_in_seconds), + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let route_data: ProxyQuote = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + + let data = self.client.get_quote_data(route_data).await?; + let (approval, gas_limit) = self.check_approval_and_limit(quote, &data).await?; + + Ok(SwapperQuoteData::new_contract(data.to, data.value, data.data, approval, gas_limit)) + } + + async fn get_swap_result(&self, _chain: Chain, _transaction_hash: &str) -> Result { + if self.provider.mode == SwapperProviderMode::OnChain { + Ok(SwapResult { + status: primitives::swap::SwapStatus::Completed, + metadata: None, + }) + } else { + Err(SwapperError::NotSupportedAsset) + } + } + + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + Ok(VaultAddresses { deposit: vec![], send: vec![] }) + } +} + +#[cfg(test)] +mod tests { + use super::super::client::ProxyClient; + use super::*; + use crate::{ + alien::mock::ProviderMock, + fees::{DEFAULT_AGGREGATOR_FEE_BPS, DEFAULT_SWAP_FEE_BPS}, + }; + use gem_client::testkit::MockClient; + use primitives::swap::{ApprovalData, SwapQuoteData}; + + fn mock_provider(provider: SwapperProvider) -> ProxyProvider { + let rpc_provider = Arc::new(ProviderMock::new("{}".to_string())); + ProxyProvider::new_with_client(provider, ProxyClient::new(MockClient::new()), vec![], rpc_provider) + } + + #[test] + fn test_referral_bps() { + assert_eq!(mock_provider(SwapperProvider::Okx).referral_bps(), DEFAULT_AGGREGATOR_FEE_BPS); + assert_eq!(mock_provider(SwapperProvider::StonfiV2).referral_bps(), DEFAULT_SWAP_FEE_BPS); + } + + #[tokio::test] + async fn test_solana_preserves_provider_gas_limit() { + let provider = mock_provider(SwapperProvider::Okx); + let quote = Quote::mock(Chain::Solana, None); + let data = SwapQuoteData::mock_with_gas_limit(Some("550000".to_string())); + + let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); + + assert!(approval.is_none()); + assert_eq!(gas_limit, Some("550000".to_string())); + + let data = SwapQuoteData::mock_with_gas_limit(None); + + let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); + + assert!(approval.is_none()); + assert!(gas_limit.is_none()); + } + + #[tokio::test] + async fn test_evm_native_ignores_provider_gas_limit() { + let provider = mock_provider(SwapperProvider::Okx); + let quote = Quote::mock(Chain::Ethereum, None); + let data = SwapQuoteData::mock_with_gas_limit(Some("550000".to_string())); + + let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); + + assert!(approval.is_none()); + assert!(gas_limit.is_none()); + } + + #[tokio::test] + async fn test_evm_provider_approval_uses_swap_gas_limit() { + let provider = mock_provider(SwapperProvider::Okx); + let quote = Quote::mock(Chain::Ethereum, None); + + let data = SwapQuoteData { + approval: Some(ApprovalData::mock()), + ..SwapQuoteData::mock_with_gas_limit(Some("250000".to_string())) + }; + let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); + assert!(approval.is_some()); + assert_eq!(gas_limit, Some("250000".to_string())); + + let data = SwapQuoteData { + approval: Some(ApprovalData::mock()), + ..SwapQuoteData::mock_with_gas_limit(None) + }; + let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); + assert!(approval.is_some()); + assert_eq!(gas_limit, Some(DEFAULT_EVM_SWAP_GAS_LIMIT.to_string())); + } +} diff --git a/core/crates/swapper/src/proxy/provider_factory.rs b/core/crates/swapper/src/proxy/provider_factory.rs new file mode 100644 index 0000000000..f32c323326 --- /dev/null +++ b/core/crates/swapper/src/proxy/provider_factory.rs @@ -0,0 +1,8 @@ +use std::sync::Arc; + +use super::provider::ProxyProvider; +use crate::alien::{RpcClient, RpcProvider}; + +pub fn new_okx(rpc_provider: Arc) -> ProxyProvider { + ProxyProvider::new_okx(rpc_provider) +} diff --git a/core/crates/swapper/src/relay/asset.rs b/core/crates/swapper/src/relay/asset.rs new file mode 100644 index 0000000000..fd7a05a3eb --- /dev/null +++ b/core/crates/swapper/src/relay/asset.rs @@ -0,0 +1,94 @@ +use std::sync::LazyLock; + +use crate::{SwapperChainAsset, SwapperError}; +use gem_evm::address::ethereum_address_checksum; +use gem_solana::{SYSTEM_PROGRAM_ID, WSOL_TOKEN_ADDRESS}; +use primitives::{ + AssetId, Chain, ChainType, + asset_constants::{ + ARBITRUM_USDC_ASSET_ID, ARBITRUM_USDT_ASSET_ID, AVALANCHE_USDC_ASSET_ID, AVALANCHE_USDT_ASSET_ID, BASE_USDC_ASSET_ID, ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDT_ASSET_ID, + HYPEREVM_USDC_ASSET_ID, HYPEREVM_USDT_ASSET_ID, LINEA_USDT_ASSET_ID, OPTIMISM_USDC_ASSET_ID, OPTIMISM_USDT_ASSET_ID, POLYGON_USDC_ASSET_ID, POLYGON_USDT_ASSET_ID, + SEIEVM_USDC_ASSET_ID, SEIEVM_USDT_ASSET_ID, SMARTCHAIN_USDC_ASSET_ID, SMARTCHAIN_USDT_ASSET_ID, ZKSYNC_USDT_ASSET_ID, + }, + contract_constants::EVM_ZERO_ADDRESS, +}; + +fn is_native_currency(chain: Chain, currency: &str) -> bool { + match chain { + Chain::Bitcoin => true, + Chain::Solana => currency == SYSTEM_PROGRAM_ID || currency == WSOL_TOKEN_ADDRESS, + _ if currency == EVM_ZERO_ADDRESS => true, + _ => false, + } +} + +pub fn map_currency_to_asset_id(chain: Chain, currency: &str) -> AssetId { + if is_native_currency(chain, currency) { + return AssetId::from_chain(chain); + } + if let ChainType::Ethereum = chain.chain_type() + && let Ok(address) = ethereum_address_checksum(currency) + { + return AssetId::from_token(chain, &address); + } + AssetId::from_token(chain, currency) +} + +pub static SUPPORTED_CHAINS: LazyLock> = LazyLock::new(|| { + vec![ + SwapperChainAsset::Assets(Chain::Ethereum, vec![ETHEREUM_USDC_ASSET_ID.clone(), ETHEREUM_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::SmartChain, vec![SMARTCHAIN_USDC_ASSET_ID.clone(), SMARTCHAIN_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::Base, vec![BASE_USDC_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::Arbitrum, vec![ARBITRUM_USDC_ASSET_ID.clone(), ARBITRUM_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::Optimism, vec![OPTIMISM_USDC_ASSET_ID.clone(), OPTIMISM_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::Polygon, vec![POLYGON_USDC_ASSET_ID.clone(), POLYGON_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::AvalancheC, vec![AVALANCHE_USDC_ASSET_ID.clone(), AVALANCHE_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::Linea, vec![LINEA_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::ZkSync, vec![ZKSYNC_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::Hyperliquid, vec![HYPEREVM_USDC_ASSET_ID.clone(), HYPEREVM_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::SeiEvm, vec![SEIEVM_USDC_ASSET_ID.clone(), SEIEVM_USDT_ASSET_ID.clone()]), + SwapperChainAsset::Assets(Chain::Berachain, vec![]), + SwapperChainAsset::Assets(Chain::Manta, vec![]), + SwapperChainAsset::Assets(Chain::Sonic, vec![]), + SwapperChainAsset::Assets(Chain::Abstract, vec![]), + SwapperChainAsset::Assets(Chain::Celo, vec![]), + SwapperChainAsset::Assets(Chain::Stable, vec![]), + ] +}); + +pub fn asset_to_currency(asset_id: &AssetId) -> Result { + match asset_id.chain.chain_type() { + ChainType::Ethereum => { + if asset_id.is_native() { + Ok(EVM_ZERO_ADDRESS.to_string()) + } else { + asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset) + } + } + _ => Err(SwapperError::NotSupportedChain), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Chain, asset_constants::ETHEREUM_USDC_TOKEN_ID}; + + #[test] + fn test_evm_native_asset() { + let result = asset_to_currency(&AssetId::from_chain(Chain::Ethereum)).unwrap(); + assert_eq!(result, EVM_ZERO_ADDRESS); + } + + #[test] + fn test_evm_token_asset() { + let token_address = ETHEREUM_USDC_TOKEN_ID; + let result = asset_to_currency(&AssetId::from_token(Chain::Ethereum, token_address)).unwrap(); + assert_eq!(result, token_address); + } + + #[test] + fn test_non_evm_asset_not_supported() { + assert_eq!(asset_to_currency(&AssetId::from_chain(Chain::Solana)), Err(SwapperError::NotSupportedChain)); + } +} diff --git a/core/crates/swapper/src/relay/chain.rs b/core/crates/swapper/src/relay/chain.rs new file mode 100644 index 0000000000..c92f0d9991 --- /dev/null +++ b/core/crates/swapper/src/relay/chain.rs @@ -0,0 +1,42 @@ +use primitives::{Chain, chain_evm::EVMChain}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayChain { + Evm(EVMChain), +} + +impl RelayChain { + pub fn chain_id(&self) -> u64 { + match self { + Self::Evm(chain) => chain.chain_id(), + } + } + + pub fn from_chain(chain: &Chain) -> Option { + Some(Self::Evm(EVMChain::from_chain(*chain)?)) + } + + pub fn to_chain(self) -> Chain { + match self { + Self::Evm(chain) => chain.to_chain(), + } + } + + pub fn from_chain_id(chain_id: u64) -> Option { + Some(Self::Evm(EVMChain::all().into_iter().find(|chain| chain.chain_id() == chain_id)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_chain() { + assert_eq!(RelayChain::from_chain(&Chain::Ethereum).unwrap().chain_id(), EVMChain::Ethereum.chain_id()); + assert_eq!(RelayChain::from_chain(&Chain::SmartChain).unwrap().chain_id(), EVMChain::SmartChain.chain_id()); + assert!(RelayChain::from_chain(&Chain::Solana).is_none()); + assert!(RelayChain::from_chain(&Chain::Bitcoin).is_none()); + assert!(RelayChain::from_chain(&Chain::Cosmos).is_none()); + } +} diff --git a/core/crates/swapper/src/relay/client.rs b/core/crates/swapper/src/relay/client.rs new file mode 100644 index 0000000000..939c24444a --- /dev/null +++ b/core/crates/swapper/src/relay/client.rs @@ -0,0 +1,37 @@ +use std::{collections::HashMap, fmt::Debug}; + +use gem_client::{CONTENT_TYPE, Client, ClientExt, ContentType}; + +use super::model::{RelayChainsResponse, RelayQuoteRequest, RelayQuoteResponse, RelayRequestsResponse}; +use crate::SwapperError; + +#[derive(Clone, Debug)] +pub struct RelayClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + client: C, +} + +impl RelayClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_quote(&self, request: RelayQuoteRequest) -> Result { + let headers = HashMap::from([(CONTENT_TYPE.to_string(), ContentType::ApplicationJson.as_str().into())]); + self.client.post_with("/quote/v2", &request, headers).await.map_err(SwapperError::from) + } + + pub async fn get_request(&self, transaction_hash: &str) -> Result { + let path = format!("/requests/v2?hash={}", transaction_hash); + self.client.get(&path).await.map_err(SwapperError::from) + } + + pub async fn get_chains(&self) -> Result { + self.client.get("/chains").await.map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/relay/mapper.rs b/core/crates/swapper/src/relay/mapper.rs new file mode 100644 index 0000000000..f4637dae64 --- /dev/null +++ b/core/crates/swapper/src/relay/mapper.rs @@ -0,0 +1,137 @@ +use primitives::{TransactionSwapMetadata, swap::ApprovalData}; + +use super::{ + DEFAULT_SWAP_GAS_LIMIT, + asset::map_currency_to_asset_id, + chain::RelayChain, + model::{RelayQuoteResponse, RelayRequest, StepData}, +}; +use crate::{SwapResult, SwapperError, SwapperProvider, SwapperQuoteData, approval::get_swap_gas_limit_with_approval}; + +pub fn map_quote_data(quote_response: &RelayQuoteResponse, approval: Option) -> Result { + let step_data = quote_response.step_data().ok_or(SwapperError::InvalidRoute)?; + + match step_data { + StepData::Evm(evm) => { + let gas_limit = get_swap_gas_limit_with_approval(&approval, None, DEFAULT_SWAP_GAS_LIMIT); + let call_data = evm.data.clone().unwrap_or_default(); + Ok(SwapperQuoteData::new_contract(evm.to.clone(), evm.value.clone(), call_data, approval, gas_limit)) + } + } +} + +pub fn map_swap_result(request: &RelayRequest) -> SwapResult { + let metadata = request.data.as_ref().and_then(|d| d.metadata.as_ref()).and_then(|m| { + let currency_in = m.currency_in.as_ref()?; + let currency_out = m.currency_out.as_ref()?; + let from_chain = RelayChain::from_chain_id(currency_in.currency.chain_id)?.to_chain(); + let to_chain = RelayChain::from_chain_id(currency_out.currency.chain_id)?.to_chain(); + Some(TransactionSwapMetadata { + from_asset: map_currency_to_asset_id(from_chain, ¤cy_in.currency.address), + from_value: currency_in.amount.clone()?, + to_asset: map_currency_to_asset_id(to_chain, ¤cy_out.currency.address), + to_value: currency_out.amount.clone()?, + provider: Some(SwapperProvider::Relay.as_ref().to_string()), + }) + }); + + SwapResult { + status: request.status.clone().into_swap_status(), + metadata, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay::model::{CurrencyAmount, QuoteDetails, RelayCurrencyDetail, RelayQuoteResponse, RelayRequest, RelayRequestMetadata, RelayStatus, Step}; + use primitives::{AssetId, Chain, swap::SwapStatus}; + + #[test] + fn test_map_evm_quote_data() { + let quote_response = RelayQuoteResponse { + steps: vec![Step::mock_transaction("swap", "0xrouter", "1000000000000000000", "0xabcdef")], + details: QuoteDetails { + currency_out: CurrencyAmount { amount: "0".to_string() }, + time_estimate: None, + swap_impact: None, + }, + fees: None, + }; + + let result = map_quote_data("e_response, None).unwrap(); + + assert_eq!(result.to, "0xrouter"); + assert_eq!(result.value, "1000000000000000000"); + assert_eq!(result.data, "0xabcdef"); + assert!(result.approval.is_none()); + assert!(result.gas_limit.is_none()); + } + + #[test] + fn test_map_evm_quote_data_with_approval() { + let quote_response = RelayQuoteResponse { + steps: vec![Step::mock_transaction("swap", "0xrouter", "0", "0xabcdef")], + details: QuoteDetails { + currency_out: CurrencyAmount { amount: "0".to_string() }, + time_estimate: None, + swap_impact: None, + }, + fees: None, + }; + let approval = ApprovalData::make("0xtoken", "0xrouter", "1000", false); + + let result = map_quote_data("e_response, Some(approval.clone())).unwrap(); + + assert_eq!(result.to, "0xrouter"); + assert_eq!(result.approval, Some(approval)); + assert_eq!(result.gas_limit, Some(DEFAULT_SWAP_GAS_LIMIT.to_string())); + } + + #[test] + fn test_map_swap_result_evm_to_evm() { + let request = RelayRequest::mock( + RelayStatus::Success, + Some(RelayRequestMetadata { + currency_in: Some(RelayCurrencyDetail::mock("0x0000000000000000000000000000000000000000", 1, "1000000000000000000")), + currency_out: Some(RelayCurrencyDetail::mock("0x0000000000000000000000000000000000000000", 8453, "999000000000000000")), + }), + ); + + let result = map_swap_result(&request); + + assert_eq!(result.status, SwapStatus::Completed); + let metadata = result.metadata.unwrap(); + assert_eq!(metadata.from_asset, AssetId::from_chain(Chain::Ethereum)); + assert_eq!(metadata.from_value, "1000000000000000000"); + assert_eq!(metadata.to_asset, AssetId::from_chain(Chain::Base)); + assert_eq!(metadata.to_value, "999000000000000000"); + assert_eq!(metadata.provider, Some("relay".to_string())); + } + + #[test] + fn test_map_swap_result_status() { + let pending = map_swap_result(&RelayRequest::mock(RelayStatus::Pending, None)); + assert_eq!(pending.status, SwapStatus::Pending); + assert!(pending.metadata.is_none()); + + let failed = map_swap_result(&RelayRequest::mock(RelayStatus::Failed, None)); + assert_eq!(failed.status, SwapStatus::Failed); + assert!(failed.metadata.is_none()); + } + + #[test] + fn test_map_quote_data_without_step_data() { + let quote_response = RelayQuoteResponse { + steps: vec![Step::mock_empty("approve", "transaction")], + details: QuoteDetails { + currency_out: CurrencyAmount { amount: "0".to_string() }, + time_estimate: None, + swap_impact: None, + }, + fees: None, + }; + + assert!(map_quote_data("e_response, None).is_err()); + } +} diff --git a/core/crates/swapper/src/relay/mod.rs b/core/crates/swapper/src/relay/mod.rs new file mode 100644 index 0000000000..6a0dbe7124 --- /dev/null +++ b/core/crates/swapper/src/relay/mod.rs @@ -0,0 +1,12 @@ +mod asset; +mod chain; +mod client; +mod mapper; +mod model; +mod provider; +#[cfg(test)] +mod testkit; + +const DEFAULT_SWAP_GAS_LIMIT: u64 = 150_000; + +pub use provider::Relay; diff --git a/core/crates/swapper/src/relay/model.rs b/core/crates/swapper/src/relay/model.rs new file mode 100644 index 0000000000..8d77778ed3 --- /dev/null +++ b/core/crates/swapper/src/relay/model.rs @@ -0,0 +1,339 @@ +use std::collections::BTreeSet; + +use gem_evm::address::ethereum_address_checksum; +use primitives::swap::SwapStatus; +use serde::{Deserialize, Serialize}; + +const STEP_SWAP: &str = "swap"; +const STEP_DEPOSIT: &str = "deposit"; +const STEP_APPROVE: &str = "approve"; +const STEP_TRANSACTION: &str = "transaction"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayQuoteRequest { + pub user: String, + pub origin_chain_id: u64, + pub destination_chain_id: u64, + pub origin_currency: String, + pub destination_currency: String, + pub amount: String, + pub recipient: String, + pub trade_type: String, + pub refund_to: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub app_fees: Vec, + pub max_route_length: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RelayAppFee { + pub recipient: String, + pub fee: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayFees { + pub gas: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayFeeAmount { + pub amount: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayQuoteResponse { + pub steps: Vec, + pub details: QuoteDetails, + pub fees: Option, +} + +impl RelayQuoteResponse { + pub fn step_data(&self) -> Option<&StepData> { + self.steps + .iter() + .find(|step| step.id == STEP_SWAP || step.id == STEP_DEPOSIT) + .or_else(|| self.steps.iter().find(|step| step.kind == STEP_TRANSACTION && step.id != STEP_APPROVE)) + .or_else(|| self.steps.iter().find(|step| step.step_data().is_some())) + .and_then(Step::step_data) + } + + pub fn router_address(&self) -> Option { + self.steps.iter().filter(|step| step.id != STEP_APPROVE).find_map(Step::to_address) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Step { + pub id: String, + pub kind: String, + pub items: Option>, +} + +impl Step { + pub fn step_data(&self) -> Option<&StepData> { + self.items.as_ref()?.first()?.data.as_ref() + } + + pub fn to_address(&self) -> Option { + Some(self.step_data()?.to_address()) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StepItem { + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum StepData { + Evm(EvmStepData), +} + +impl StepData { + pub fn to_address(&self) -> String { + match self { + Self::Evm(evm) => evm.to.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EvmStepData { + pub to: String, + pub data: Option, + pub value: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteDetails { + pub currency_out: CurrencyAmount, + pub time_estimate: Option, + pub swap_impact: Option, +} + +impl QuoteDetails { + pub fn time_estimate_u32(&self) -> Option { + let value = self.time_estimate?; + if !value.is_finite() || value < 0.0 || value > u32::MAX as f64 { + return None; + } + Some(value.ceil() as u32) + } + + pub fn slippage_bps(&self) -> Option { + let percent: f64 = self.swap_impact.as_ref()?.percent.as_ref()?.parse().ok()?; + Some((percent.abs() * 100.0) as u32) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapImpact { + pub percent: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrencyAmount { + pub amount: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RelayStatus { + Pending, + Waiting, + Success, + Completed, + Failed, + Failure, + Refunded, + #[serde(other)] + Unknown, +} + +impl RelayStatus { + pub fn into_swap_status(self) -> SwapStatus { + match self { + RelayStatus::Pending | RelayStatus::Waiting | RelayStatus::Unknown => SwapStatus::Pending, + RelayStatus::Success | RelayStatus::Completed => SwapStatus::Completed, + RelayStatus::Failed | RelayStatus::Failure | RelayStatus::Refunded => SwapStatus::Failed, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayRequestsResponse { + pub requests: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayRequest { + pub status: RelayStatus, + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayRequestData { + pub metadata: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayRequestMetadata { + pub currency_in: Option, + pub currency_out: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayCurrencyDetail { + pub currency: RelayCurrency, + pub amount: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayCurrency { + pub chain_id: u64, + pub address: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayChainsResponse { + pub chains: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayChainInfo { + #[serde(default)] + pub solver_addresses: Vec, + pub protocol: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayProtocol { + pub v2: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayProtocolV2 { + pub depository: Option, +} + +impl RelayChainsResponse { + pub fn deposit_addresses(&self) -> Vec { + self.chains + .iter() + .filter_map(|chain| chain.protocol.as_ref()?.v2.as_ref()?.depository.as_ref()) + .map(|address| ethereum_address_checksum(address).unwrap_or_else(|_| address.clone())) + .collect::>() + .into_iter() + .collect() + } + + pub fn send_addresses(&self) -> Vec { + self.chains + .iter() + .flat_map(|chain| chain.solver_addresses.iter()) + .map(|address| ethereum_address_checksum(address).unwrap_or_else(|_| address.clone())) + .collect::>() + .into_iter() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deposit_addresses() { + let depository = "0x4cd00e387622c35bddb9b4c962c136462338bc31"; + let response = RelayChainsResponse { + chains: vec![ + RelayChainInfo { + solver_addresses: vec![], + protocol: Some(RelayProtocol { + v2: Some(RelayProtocolV2 { + depository: Some(depository.to_string()), + }), + }), + }, + RelayChainInfo { + solver_addresses: vec![], + protocol: Some(RelayProtocol { + v2: Some(RelayProtocolV2 { + depository: Some("0x59916da825d2d2ec1bf878d71c88826f6633ecca".to_string()), + }), + }), + }, + ], + }; + + assert_eq!( + response.deposit_addresses(), + vec![ + ethereum_address_checksum(depository).unwrap(), + ethereum_address_checksum("0x59916da825d2d2ec1bf878d71c88826f6633ecca").unwrap(), + ] + ); + } + + #[test] + fn test_send_addresses() { + let solver = "0xf70da97812cb96acdf810712aa562db8dfa3dbef"; + let response = RelayChainsResponse { + chains: vec![RelayChainInfo { + solver_addresses: vec![solver.to_string(), solver.to_string()], + protocol: None, + }], + }; + + assert_eq!(response.send_addresses(), vec![ethereum_address_checksum(solver).unwrap()]); + } + + #[test] + fn test_deposit_addresses_skips_missing_depository() { + let depository = "0x4cd00e387622c35bddb9b4c962c136462338bc31"; + let response = RelayChainsResponse { + chains: vec![ + RelayChainInfo { + solver_addresses: vec![], + protocol: Some(RelayProtocol { + v2: Some(RelayProtocolV2 { + depository: Some(depository.to_string()), + }), + }), + }, + RelayChainInfo { + solver_addresses: vec![], + protocol: Some(RelayProtocol { + v2: Some(RelayProtocolV2 { depository: None }), + }), + }, + ], + }; + + assert_eq!(response.deposit_addresses(), vec![ethereum_address_checksum(depository).unwrap()]); + } +} diff --git a/core/crates/swapper/src/relay/provider.rs b/core/crates/swapper/src/relay/provider.rs new file mode 100644 index 0000000000..149ea5cb50 --- /dev/null +++ b/core/crates/swapper/src/relay/provider.rs @@ -0,0 +1,237 @@ +use std::sync::Arc; + +use alloy_primitives::U256; +use async_trait::async_trait; +use gem_client::Client; +use primitives::{AssetId, Chain, ChainType, swap::ApprovalData}; + +use super::{ + asset::{SUPPORTED_CHAINS, asset_to_currency}, + chain::RelayChain, + client::RelayClient, + mapper, + model::{RelayAppFee, RelayQuoteRequest, RelayQuoteResponse}, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, + SwapperQuoteData, approval::check_approval_erc20, config::get_swap_proxy_url, cross_chain::VaultAddresses, fees::DEFAULT_REFERRER, fees::default_referral_fees, + fees::quote_value_after_reserve_by_chain, +}; + +#[derive(Debug)] +pub struct Relay +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + provider: ProviderType, + rpc_provider: Arc, + client: RelayClient, +} + +impl Relay { + pub fn new(rpc_provider: Arc) -> Self { + let url = get_swap_proxy_url("relay"); + let client = RelayClient::new(RpcClient::new(url, rpc_provider.clone())); + Self { + provider: ProviderType::new(SwapperProvider::Relay), + rpc_provider, + client, + } + } +} + +fn resolve_app_fees() -> Vec { + let fee = default_referral_fees().evm; + if fee.address.is_empty() { + return vec![]; + } + vec![RelayAppFee { + recipient: fee.address, + fee: fee.bps.to_string(), + }] +} + +#[async_trait] +impl Swapper for Relay +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + SUPPORTED_CHAINS.clone() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_chain = RelayChain::from_chain(&request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + let to_chain = RelayChain::from_chain(&request.to_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + + let from_asset_id = request.from_asset.asset_id(); + let to_asset_id = request.to_asset.asset_id(); + + let origin_currency = asset_to_currency(&from_asset_id)?; + let destination_currency = asset_to_currency(&to_asset_id)?; + let app_fees = resolve_app_fees(); + let from_value = quote_value_after_reserve_by_chain(request)?; + + let relay_request = RelayQuoteRequest { + user: request.wallet_address.clone(), + origin_chain_id: from_chain.chain_id(), + destination_chain_id: to_chain.chain_id(), + origin_currency, + destination_currency, + amount: from_value.clone(), + recipient: request.destination_address.clone(), + trade_type: "EXACT_INPUT".to_string(), + referrer: if app_fees.is_empty() { None } else { Some(DEFAULT_REFERRER.to_string()) }, + app_fees, + refund_to: request.wallet_address.clone(), + max_route_length: 6, + }; + + let response = self.client.get_quote(relay_request).await?; + + let to_value = response.details.currency_out.amount.clone(); + let eta_in_seconds = response.details.time_estimate_u32(); + + let quote = Quote { + from_value, + min_from_value: None, + to_value, + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: from_asset_id, + output: to_asset_id, + route_data: serde_json::to_string(&response).map_err(SwapperError::compute_quote_error)?, + }], + slippage_bps: response.details.slippage_bps().unwrap_or(request.options.slippage.bps), + }, + request: request.clone(), + eta_in_seconds, + }; + + Ok(quote) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let response: RelayQuoteResponse = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + + let from_asset_id = quote.request.from_asset.asset_id(); + let approval = self.check_evm_approval(quote, &response, &from_asset_id).await?; + mapper::map_quote_data(&response, approval) + } + + async fn get_swap_result(&self, _chain: Chain, transaction_hash: &str) -> Result { + let response = self.client.get_request(transaction_hash).await?; + let request = response.requests.first().ok_or(SwapperError::InvalidRoute)?; + Ok(mapper::map_swap_result(request)) + } + + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + let response = self.client.get_chains().await?; + Ok(VaultAddresses { + deposit: response.deposit_addresses(), + send: response.send_addresses(), + }) + } +} + +impl Relay +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + async fn check_evm_approval(&self, quote: &Quote, quote_response: &RelayQuoteResponse, from_asset_id: &AssetId) -> Result, SwapperError> { + match from_asset_id.chain.chain_type() { + ChainType::Ethereum if !from_asset_id.is_native() => { + let spender = quote_response.router_address().ok_or(SwapperError::InvalidRoute)?; + + let token = from_asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset)?; + let amount: U256 = quote.from_value.parse().map_err(SwapperError::from)?; + + Ok(check_approval_erc20( + quote.request.wallet_address.clone(), + token, + spender, + amount, + self.rpc_provider.clone(), + &from_asset_id.chain, + ) + .await? + .approval_data()) + } + _ => Ok(None), + } + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, models::Options}; + use primitives::AssetId; + + #[tokio::test] + async fn test_relay_eth_to_base() -> Result<(), Box> { + use primitives::asset_constants::{ARBITRUM_USDC_ASSET_ID, BASE_USDC_ASSET_ID}; + + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(ARBITRUM_USDC_ASSET_ID.clone()), + to_asset: SwapperQuoteAsset::from(BASE_USDC_ASSET_ID.clone()), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "500000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = relay.get_quote(&request).await?; + let quote_data = relay.get_quote_data("e, FetchQuoteData::None).await?; + + println!("quote: from_value={}, to_value={}", quote.from_value, quote.to_value); + println!("quote_data: to={}, value={}, data_len={}", quote_data.to, quote_data.value, quote_data.data.len()); + + assert_eq!(quote.from_value, request.value); + assert!(!quote.to_value.is_empty()); + assert!(!quote_data.data.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_usdt_eth_to_base() -> Result<(), Box> { + use primitives::asset_constants::ETHEREUM_USDT_ASSET_ID; + + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(ETHEREUM_USDT_ASSET_ID.clone()), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Base)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "5000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = relay.get_quote(&request).await?; + let quote_data = relay.get_quote_data("e, FetchQuoteData::None).await?; + + println!("quote: from_value={}, to_value={}", quote.from_value, quote.to_value); + println!("quote_data: to={}, value={}, data_len={}", quote_data.to, quote_data.value, quote_data.data.len()); + println!("approval: {:?}", quote_data.approval); + + assert_eq!(quote.from_value, request.value); + assert!(!quote.to_value.is_empty()); + assert!(!quote_data.data.is_empty()); + assert!(!quote_data.to.is_empty()); + assert!(quote_data.approval.is_some()); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/relay/testdata/request_bsc_usdt_to_sol.json b/core/crates/swapper/src/relay/testdata/request_bsc_usdt_to_sol.json new file mode 100644 index 0000000000..f80af330fb --- /dev/null +++ b/core/crates/swapper/src/relay/testdata/request_bsc_usdt_to_sol.json @@ -0,0 +1,38 @@ +{ + "requests": [ + { + "id": "0xd2bcbf6c8e155411f6633067a29b1ee511d3d98b25db49393dd4ce58b00ee0f8", + "status": "success", + "data": { + "metadata": { + "sender": "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7", + "recipient": "A21o4asMbFHYadqXdLusT9Bvx9xaC5YV9gcaidjqtdXC", + "currencyIn": { + "currency": { + "chainId": 56, + "address": "0x55d398326f99059ff775485246999027b3197955", + "symbol": "USDT", + "name": "Tether USD", + "decimals": 18 + }, + "amount": "6000000000000000000", + "amountFormatted": "6.0", + "amountUsd": "6.000000" + }, + "currencyOut": { + "currency": { + "chainId": 792703809, + "address": "So11111111111111111111111111111111111111112", + "symbol": "WSOL", + "name": "Wrapped SOL", + "decimals": 9 + }, + "amount": "74432990", + "amountFormatted": "0.07443299", + "amountUsd": "5.806518" + } + } + } + } + ] +} diff --git a/core/crates/swapper/src/relay/testdata/request_eth_to_btc.json b/core/crates/swapper/src/relay/testdata/request_eth_to_btc.json new file mode 100644 index 0000000000..3a20d33a88 --- /dev/null +++ b/core/crates/swapper/src/relay/testdata/request_eth_to_btc.json @@ -0,0 +1,42 @@ +{ + "requests": [ + { + "id": "0x66dfacf8279d88615a013041f7bb19e7a634b367e6d80d54c96b33339a55078f", + "status": "success", + "user": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "recipient": "bc1qe7qlndxgfv76c0ulnfhh7j0vdwkqdkkl4yf9gm", + "data": { + "metadata": { + "sender": "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7", + "recipient": "bc1qe7qlndxgfv76c0ulnfhh7j0vdwkqdkkl4yf9gm", + "currencyIn": { + "currency": { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ether", + "decimals": 18 + }, + "amount": "10000000000000000", + "amountFormatted": "0.01", + "amountUsd": "19.937560" + }, + "currencyOut": { + "currency": { + "chainId": 8253038, + "address": "bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8", + "symbol": "BTC", + "name": "Bitcoin", + "decimals": 8 + }, + "amount": "28619", + "amountFormatted": "0.00028619", + "amountUsd": "19.445550" + } + } + }, + "createdAt": "2026-03-03T06:28:25.979Z", + "updatedAt": "2026-03-03T07:11:36.416Z" + } + ] +} diff --git a/core/crates/swapper/src/relay/testkit.rs b/core/crates/swapper/src/relay/testkit.rs new file mode 100644 index 0000000000..6b86b8a899 --- /dev/null +++ b/core/crates/swapper/src/relay/testkit.rs @@ -0,0 +1,46 @@ +use super::model::{EvmStepData, RelayCurrency, RelayCurrencyDetail, RelayRequest, RelayRequestData, RelayRequestMetadata, RelayStatus, Step, StepData, StepItem}; + +impl RelayRequest { + pub fn mock(status: RelayStatus, metadata: Option) -> Self { + Self { + status, + data: metadata.map(|m| RelayRequestData { metadata: Some(m) }), + } + } +} + +impl RelayCurrencyDetail { + pub fn mock(address: &str, chain_id: u64, amount: &str) -> Self { + Self { + currency: RelayCurrency { + chain_id, + address: address.to_string(), + }, + amount: Some(amount.to_string()), + } + } +} + +impl Step { + pub fn mock_transaction(id: &str, to: &str, value: &str, data: &str) -> Self { + Self { + id: id.to_string(), + kind: "transaction".to_string(), + items: Some(vec![StepItem { + data: Some(StepData::Evm(EvmStepData { + to: to.to_string(), + data: Some(data.to_string()), + value: value.to_string(), + })), + }]), + } + } + + pub fn mock_empty(id: &str, kind: &str) -> Self { + Self { + id: id.to_string(), + kind: kind.to_string(), + items: None, + } + } +} diff --git a/core/crates/swapper/src/route_cache.rs b/core/crates/swapper/src/route_cache.rs new file mode 100644 index 0000000000..beb286aa21 --- /dev/null +++ b/core/crates/swapper/src/route_cache.rs @@ -0,0 +1,192 @@ +use std::{ + collections::HashMap, + hash::Hash, + sync::{Arc, Mutex, MutexGuard}, +}; + +#[derive(Debug, Clone)] +struct Discovery { + candidates: Vec, + explored: Vec, +} + +impl Default for Discovery { + fn default() -> Self { + Self { + candidates: Vec::new(), + explored: Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct DiscoveryCache { + candidates: Arc>>>, + routes: Arc>>>, +} + +#[derive(Debug, Clone)] +pub(crate) struct ValueCache { + values: Arc>>, +} + +impl Default for DiscoveryCache { + fn default() -> Self { + Self { + candidates: Arc::new(Mutex::new(HashMap::new())), + routes: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl Default for ValueCache { + fn default() -> Self { + Self { + values: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl DiscoveryCache +where + Candidate: Clone + PartialEq, + Probe: Clone + PartialEq, +{ + pub fn get(&self, from: &str, to: &str) -> (Vec, Vec) { + let cache = lock(&self.candidates); + match cache.get(&Self::pool_key(from, to)) { + Some(discovery) => (discovery.candidates.clone(), discovery.explored.clone()), + None => (Vec::new(), Vec::new()), + } + } + + pub fn put(&self, from: &str, to: &str, candidates: &[Candidate], explored: &[Probe]) { + if candidates.is_empty() && explored.is_empty() { + return; + } + let mut cache = lock(&self.candidates); + let entry = cache.entry(Self::pool_key(from, to)).or_default(); + for candidate in candidates { + if !entry.candidates.contains(candidate) { + entry.candidates.push(candidate.clone()); + } + } + for probe in explored { + if !entry.explored.contains(probe) { + entry.explored.push(probe.clone()); + } + } + } + + pub fn get_route(&self, from: &str, to: &str) -> Option> { + let cache = lock(&self.routes); + cache.get(&Self::route_key(from, to)).cloned() + } + + pub fn put_route(&self, from: &str, to: &str, route: &[Candidate]) { + if route.is_empty() { + return; + } + let mut cache = lock(&self.routes); + cache.insert(Self::route_key(from, to), route.to_vec()); + } + + fn pool_key(from: &str, to: &str) -> (String, String) { + let (a, b) = if from <= to { (from, to) } else { (to, from) }; + (a.to_string(), b.to_string()) + } + + fn route_key(from: &str, to: &str) -> (String, String) { + (from.to_string(), to.to_string()) + } +} + +impl ValueCache +where + K: Eq + Hash, + V: Clone, +{ + pub fn get(&self, key: &K) -> Option { + let values = lock(&self.values); + values.get(key).cloned() + } + + pub fn put(&self, key: K, value: V) { + let mut values = lock(&self.values); + values.insert(key, value); + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pool(id: &str) -> String { + id.to_string() + } + + #[test] + fn test_pool_key_is_direction_insensitive() { + assert_eq!(DiscoveryCache::::pool_key("0xa", "0xb"), DiscoveryCache::::pool_key("0xb", "0xa")); + } + + #[test] + fn test_route_key_is_direction_sensitive() { + assert_ne!( + DiscoveryCache::::route_key("0xa", "0xb"), + DiscoveryCache::::route_key("0xb", "0xa") + ); + } + + #[test] + fn test_cache_merges_candidates_and_probes_across_passes() { + let cache = DiscoveryCache::default(); + cache.put("0xa", "0xb", &[pool("0x1")], &[60, 200]); + cache.put("0xa", "0xb", &[pool("0x2"), pool("0x1")], &[10, 2]); + + let (pools, probes) = cache.get("0xa", "0xb"); + assert_eq!(pools, vec![pool("0x1"), pool("0x2")]); + assert_eq!(probes, vec![60, 200, 10, 2]); + } + + #[test] + fn test_cache_tracks_explored_probes_when_no_candidates_found() { + let cache = DiscoveryCache::::default(); + cache.put("0xa", "0xb", &[], &[60, 200]); + let (pools, probes) = cache.get("0xa", "0xb"); + assert!(pools.is_empty()); + assert_eq!(probes, vec![60, 200]); + } + + #[test] + fn test_route_roundtrip() { + let cache = DiscoveryCache::::default(); + let route = vec![pool("0x1"), pool("0x2")]; + cache.put_route("USDC", "WAL", &route); + assert_eq!(cache.get_route("USDC", "WAL").unwrap(), route); + assert!(cache.get_route("WAL", "USDC").is_none()); + } + + #[test] + fn test_put_route_skips_empty() { + let cache = DiscoveryCache::::default(); + cache.put_route("USDC", "WAL", &[]); + assert!(cache.get_route("USDC", "WAL").is_none()); + } + + #[test] + fn test_value_cache_roundtrip() { + let cache = ValueCache::default(); + cache.put(("router".to_string(), "jetton".to_string()), "wallet".to_string()); + + assert_eq!(cache.get(&("router".to_string(), "jetton".to_string())), Some("wallet".to_string())); + assert_eq!(cache.get(&("router".to_string(), "other".to_string())), None); + } +} diff --git a/core/crates/swapper/src/solana.rs b/core/crates/swapper/src/solana.rs new file mode 100644 index 0000000000..bac3fa17fb --- /dev/null +++ b/core/crates/swapper/src/solana.rs @@ -0,0 +1,10 @@ +use crate::SwapperError; +use gem_solana::decode_transaction; + +pub use gem_solana::DEFAULT_SWAP_GAS_LIMIT; + +pub fn gas_limit_from_transaction(transaction_base64: &str) -> Result { + let transaction = decode_transaction(transaction_base64).map_err(SwapperError::TransactionError)?; + + Ok(u64::from(transaction.get_compute_unit_limit().unwrap_or(DEFAULT_SWAP_GAS_LIMIT))) +} diff --git a/core/crates/swapper/src/squid/client.rs b/core/crates/swapper/src/squid/client.rs new file mode 100644 index 0000000000..09110864c8 --- /dev/null +++ b/core/crates/swapper/src/squid/client.rs @@ -0,0 +1,32 @@ +use std::fmt::Debug; + +use gem_client::{Client, ClientExt}; + +use super::model::{SquidRouteRequest, SquidRouteResponse, SquidStatusResponse}; +use crate::SwapperError; + +#[derive(Clone, Debug)] +pub struct SquidClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + client: C, +} + +impl SquidClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_route(&self, request: &SquidRouteRequest) -> Result { + self.client.post("/v2/route", request).await.map_err(SwapperError::from) + } + + pub async fn get_status(&self, tx_hash: &str) -> Result { + let path = format!("/v2/status?transactionId={tx_hash}"); + self.client.get(&path).await.map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/squid/mod.rs b/core/crates/swapper/src/squid/mod.rs new file mode 100644 index 0000000000..23e13e83fd --- /dev/null +++ b/core/crates/swapper/src/squid/mod.rs @@ -0,0 +1,32 @@ +mod client; +mod model; +mod provider; + +use std::sync::LazyLock; + +use crate::models::SwapperChainAsset; +use primitives::{ + AssetId, Chain, + asset_constants::{COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID}, +}; + +pub use provider::Squid; + +const SQUID_COSMOS_MULTICALL: &str = "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"; + +static SUPPORTED_CHAINS: LazyLock> = LazyLock::new(|| { + vec![ + SwapperChainAsset::Assets(Chain::Cosmos, vec![AssetId::from_token(Chain::Cosmos, COSMOS_USDC_TOKEN_ID)]), + SwapperChainAsset::Assets( + Chain::Osmosis, + vec![ + AssetId::from_token(Chain::Osmosis, OSMOSIS_USDC_TOKEN_ID), + AssetId::from_token(Chain::Osmosis, OSMOSIS_USDT_TOKEN_ID), + ], + ), + SwapperChainAsset::Assets(Chain::Celestia, vec![]), + SwapperChainAsset::Assets(Chain::Injective, vec![AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)]), + SwapperChainAsset::Assets(Chain::Sei, vec![AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)]), + SwapperChainAsset::Assets(Chain::Noble, vec![]), + ] +}); diff --git a/core/crates/swapper/src/squid/model.rs b/core/crates/swapper/src/squid/model.rs new file mode 100644 index 0000000000..03eb40789f --- /dev/null +++ b/core/crates/swapper/src/squid/model.rs @@ -0,0 +1,98 @@ +use primitives::swap::SwapStatus; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidRouteRequest { + pub from_chain: String, + pub to_chain: String, + pub from_token: String, + pub to_token: String, + pub from_amount: String, + pub from_address: String, + pub to_address: String, + pub slippage_config: SlippageConfig, + pub quote_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SlippageConfig { + pub auto_mode: u32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SquidRouteResponse { + pub route: SquidRoute, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidRoute { + pub estimate: SquidEstimate, + #[serde(deserialize_with = "deserialize_transaction_request")] + pub transaction_request: Option, +} + +fn deserialize_transaction_request<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let value = Option::::deserialize(deserializer)?; + match value { + Some(v) if v.as_object().is_some_and(|m| m.contains_key("data")) => serde_json::from_value(v).map(Some).map_err(serde::de::Error::custom), + _ => Ok(None), + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidEstimate { + pub to_amount: String, + pub estimated_route_duration: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidTransactionRequest { + pub target: String, + pub data: String, + pub value: String, + pub gas_limit: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidStatusResponse { + pub squid_transaction_status: SquidStatus, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SquidStatus { + Success, + Ongoing, + PartialSuccess, + NeedsGas, + NotFound, + Refund, +} + +impl SquidStatus { + pub fn swap_status(&self) -> SwapStatus { + match self { + Self::Success | Self::PartialSuccess => SwapStatus::Completed, + Self::Ongoing | Self::NeedsGas | Self::NotFound => SwapStatus::Pending, + Self::Refund => SwapStatus::Failed, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_status_response() { + let result: SquidStatusResponse = serde_json::from_str(include_str!("../../testdata/squid/status_response.json")).unwrap(); + assert_eq!(result.squid_transaction_status, SquidStatus::Success); + assert_eq!(result.squid_transaction_status.swap_status(), SwapStatus::Completed); + } +} diff --git a/core/crates/swapper/src/squid/provider.rs b/core/crates/swapper/src/squid/provider.rs new file mode 100644 index 0000000000..af39cb3458 --- /dev/null +++ b/core/crates/swapper/src/squid/provider.rs @@ -0,0 +1,261 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use gem_client::Client; +use gem_cosmos::{address::CosmosAddress, models::message::send_msg_json}; +use primitives::{AssetId, Chain, chain_cosmos::CosmosChain, swap::SwapQuoteDataType}; + +use super::{SQUID_COSMOS_MULTICALL, SUPPORTED_CHAINS, client::SquidClient, model::*}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, + SwapperQuoteData, + config::{DEFAULT_SWAP_FEE_BPS, get_swap_proxy_url}, + cross_chain::VaultAddresses, + fees::{default_referral_fees, quote_value_after_reserve_by_chain}, +}; + +#[derive(Debug)] +pub struct Squid +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + provider: ProviderType, + client: SquidClient, +} + +impl Squid { + pub fn new(rpc_provider: Arc) -> Self { + let client = SquidClient::new(RpcClient::new(get_swap_proxy_url("squid"), rpc_provider)); + Self { + provider: ProviderType::new(SwapperProvider::Squid), + client, + } + } +} + +impl Squid +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + fn get_network_id(chain: &Chain) -> Result<&str, SwapperError> { + CosmosChain::from_chain(*chain).map(|_| chain.network_id()).ok_or(SwapperError::NotSupportedChain) + } + + fn get_token_id(asset_id: &AssetId) -> Result { + if asset_id.is_native() { + asset_id.chain.as_denom().map(|d| d.to_string()).ok_or(SwapperError::NotSupportedAsset) + } else { + asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset) + } + } + + fn get_fee_address(request: &QuoteRequest) -> Option { + let fees = default_referral_fees(); + let chain = request.from_asset.chain(); + match chain { + Chain::Injective => Some(fees.injective.address).filter(|a: &String| !a.is_empty()), + Chain::Cosmos => Some(fees.cosmos.address).filter(|a: &String| !a.is_empty()), + _ => { + let cosmos_chain = CosmosChain::from_chain(chain)?; + if fees.cosmos.address.is_empty() { + return None; + } + CosmosAddress::convert(&fees.cosmos.address, cosmos_chain.hrp()).ok() + } + } + } + + fn build_route_request(request: &QuoteRequest, from_value: &str, quote_only: bool) -> Result { + let from_asset_id = request.from_asset.asset_id(); + let to_asset_id = request.to_asset.asset_id(); + Ok(SquidRouteRequest { + from_chain: Self::get_network_id(&from_asset_id.chain)?.to_string(), + to_chain: Self::get_network_id(&to_asset_id.chain)?.to_string(), + from_token: Self::get_token_id(&from_asset_id)?, + to_token: Self::get_token_id(&to_asset_id)?, + from_amount: from_value.to_string(), + from_address: request.wallet_address.clone(), + to_address: request.destination_address.clone(), + slippage_config: SlippageConfig { auto_mode: 1 }, + quote_only, + }) + } +} + +impl Squid +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + async fn fetch_route(&self, request: &QuoteRequest, from_value: &str, quote_only: bool) -> Result<(SquidRouteResponse, u128, Option), SwapperError> { + let fee_address = Self::get_fee_address(request); + let value: u128 = from_value.parse().unwrap_or(0); + let fee = if fee_address.is_some() { value * DEFAULT_SWAP_FEE_BPS as u128 / 10_000 } else { 0 }; + let swap_value = (value - fee).to_string(); + let squid_request = Self::build_route_request(request, &swap_value, quote_only)?; + let response = self.client.get_route(&squid_request).await?; + Ok((response, fee, fee_address)) + } +} + +#[async_trait] +impl Swapper for Squid +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + SUPPORTED_CHAINS.clone() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_value = quote_value_after_reserve_by_chain(request)?; + let (response, _, _) = self.fetch_route(request, &from_value, true).await?; + + Ok(Quote { + from_value, + min_from_value: None, + to_value: response.route.estimate.to_amount, + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: String::new(), + }], + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: Some(response.route.estimate.estimated_route_duration), + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let (response, fee, fee_address) = self.fetch_route("e.request, "e.from_value, false).await?; + let tx = response.route.transaction_request.ok_or(SwapperError::InvalidRoute)?; + + let swap_msg: serde_json::Value = serde_json::from_str(&tx.data).map_err(SwapperError::transaction_error)?; + let messages = match fee_address { + Some(addr) if fee > 0 => { + let denom = Self::get_token_id("e.request.from_asset.asset_id())?; + vec![send_msg_json("e.request.wallet_address, &addr, &denom, &fee.to_string()), swap_msg] + } + _ => vec![swap_msg], + }; + let data = serde_json::to_string(&messages).map_err(SwapperError::transaction_error)?; + + Ok(SwapperQuoteData { + to: tx.target, + data_type: SwapQuoteDataType::Contract, + value: tx.value, + data, + memo: None, + approval: None, + gas_limit: Some(tx.gas_limit), + }) + } + + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + let address = SQUID_COSMOS_MULTICALL.to_string(); + Ok(VaultAddresses { + deposit: vec![address.clone()], + send: vec![address], + }) + } + + async fn get_swap_result(&self, _chain: Chain, transaction_hash: &str) -> Result { + let result = self.client.get_status(transaction_hash).await?; + Ok(SwapResult { + status: result.squid_transaction_status.swap_status(), + metadata: None, + }) + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{SwapperQuoteAsset, models::Options}; + use primitives::swap::SwapStatus; + + const OSMOSIS_ADDRESS: &str = "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"; + const COSMOS_ADDRESS: &str = "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"; + + fn create_provider() -> Squid { + let provider = Arc::new(crate::NativeProvider::default()); + Squid::new(provider) + } + + #[tokio::test] + async fn test_squid_osmo_to_atom() -> Result<(), Box> { + let squid = create_provider(); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Osmosis)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Cosmos)), + wallet_address: OSMOSIS_ADDRESS.to_string(), + destination_address: COSMOS_ADDRESS.to_string(), + value: "10000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = squid.get_quote(&request).await?; + println!( + "OSMO->ATOM quote: from={}, to={}, eta={}s", + quote.from_value, + quote.to_value, + quote.eta_in_seconds.unwrap_or(0) + ); + assert_eq!(quote.from_value, "10000000"); + assert!(quote.to_value.parse::().unwrap() > 0); + + let quote_data = squid.get_quote_data("e, FetchQuoteData::None).await?; + println!("OSMO->ATOM data: to={}, value={}, gasLimit={:?}", quote_data.to, quote_data.value, quote_data.gas_limit); + assert!(!quote_data.data.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_squid_atom_to_osmo() -> Result<(), Box> { + let squid = create_provider(); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Cosmos)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Osmosis)), + wallet_address: COSMOS_ADDRESS.to_string(), + destination_address: OSMOSIS_ADDRESS.to_string(), + value: "1000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = squid.get_quote(&request).await?; + println!( + "ATOM->OSMO quote: from={}, to={}, eta={}s", + quote.from_value, + quote.to_value, + quote.eta_in_seconds.unwrap_or(0) + ); + assert_eq!(quote.from_value, "1000000"); + assert!(quote.to_value.parse::().unwrap() > 0); + + let quote_data = squid.get_quote_data("e, FetchQuoteData::None).await?; + println!("ATOM->OSMO data: to={}, value={}, gasLimit={:?}", quote_data.to, quote_data.value, quote_data.gas_limit); + assert!(!quote_data.data.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_squid_swap_status() -> Result<(), Box> { + let squid = create_provider(); + let result = squid + .get_swap_result(Chain::Cosmos, "D68723CEADAB65795B176FAE0B84B0ED5923DA9AAEC69502F8D30554431250A9") + .await?; + println!("status: {:?}", result.status); + assert_eq!(result.status, SwapStatus::Completed); + Ok(()) + } +} diff --git a/core/crates/swapper/src/stonfi/client.rs b/core/crates/swapper/src/stonfi/client.rs new file mode 100644 index 0000000000..86e825db21 --- /dev/null +++ b/core/crates/swapper/src/stonfi/client.rs @@ -0,0 +1,160 @@ +use super::{constants::RouterInfo, quote::PoolData}; +use crate::{Quote, SwapperError, route_cache::ValueCache, static_read_cache_headers}; +use gem_client::Client; +use gem_ton::{ + address::Address, + constants::TON_PROXY_JETTON_ADDRESS, + models::{RunGetMethodResult, StackArg, StackEntry}, + rpc::client::TonClient, +}; +use num_bigint::BigUint; +use num_traits::{Num, ToPrimitive}; +use primitives::Address as PrimitiveAddress; +use std::{fmt::Debug, str::FromStr}; + +const GET_WALLET_ADDRESS_METHOD: &str = "get_wallet_address"; +const GET_POOL_ADDRESS_METHOD: &str = "get_pool_address"; +const GET_POOL_DATA_METHOD: &str = "get_pool_data"; + +#[derive(Debug)] +pub(super) struct StonfiClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + ton_client: TonClient, + jetton_wallet_cache: ValueCache<(String, String), String>, +} + +impl StonfiClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub(super) fn new(ton_client: TonClient) -> Self { + Self { + ton_client, + jetton_wallet_cache: ValueCache::default(), + } + } + + pub(super) async fn router_jetton_wallet(&self, router: &RouterInfo, token: &str) -> Result { + if token == TON_PROXY_JETTON_ADDRESS { + return Ok(router.pton_wallet.to_string()); + } + self.jetton_wallet(router.address, token).await + } + + pub(super) async fn get_pool_address(&self, router: &RouterInfo, wallet0: &str, wallet1: &str) -> Result { + let token0 = Address::parse(wallet0)?; + let token1 = Address::parse(wallet1)?; + let result = self + .run_static_get_method( + router.address, + GET_POOL_ADDRESS_METHOD, + vec![StackArg::slice(token0.to_boc_base64()?), StackArg::slice(token1.to_boc_base64()?)], + ) + .await?; + stack_cell_address(&result.stack, 0) + } + + pub(super) async fn get_pool_data(&self, pool_address: &str) -> Result { + let result = self.run_get_method(pool_address, GET_POOL_DATA_METHOD, Vec::new()).await?; + parse_pool_data(&result) + } + + pub(super) async fn sender_jetton_wallet(&self, quote: &Quote) -> Result, SwapperError> { + if quote.request.from_asset.is_native() { + return Ok(None); + } + let token_id = quote.request.from_asset.asset_id().token_id.ok_or(SwapperError::NotSupportedAsset)?; + Ok(Some(self.jetton_wallet("e.request.wallet_address, &token_id).await?)) + } + + async fn jetton_wallet(&self, owner: &str, token: &str) -> Result { + let key = (owner.to_string(), token.to_string()); + if let Some(wallet) = self.jetton_wallet_cache.get(&key) { + return Ok(wallet); + } + let owner_address = Address::parse(owner)?; + let result = self + .run_static_get_method(token, GET_WALLET_ADDRESS_METHOD, vec![StackArg::slice(owner_address.to_boc_base64()?)]) + .await?; + let wallet = stack_cell_address(&result.stack, 0)?; + self.jetton_wallet_cache.put(key, wallet.clone()); + Ok(wallet) + } + + async fn run_get_method(&self, address: &str, method: &str, stack: Vec) -> Result { + let result = self.ton_client.run_get_method(address, method, stack).await.map_err(SwapperError::compute_quote_error)?; + validate_run_get_method(method, result) + } + + async fn run_static_get_method(&self, address: &str, method: &str, stack: Vec) -> Result { + let result = self + .ton_client + .run_get_method_with_headers(address, method, stack, static_read_cache_headers()) + .await + .map_err(SwapperError::compute_quote_error)?; + validate_run_get_method(method, result) + } +} + +fn validate_run_get_method(method: &str, result: RunGetMethodResult) -> Result { + if result.exit_code != 0 { + return Err(SwapperError::ComputeQuoteError(format!("TON get-method {method} exit code {}", result.exit_code))); + } + Ok(result) +} + +fn parse_pool_data(result: &RunGetMethodResult) -> Result { + match result.stack.len() { + 12.. => Ok(PoolData { + is_locked: stack_num(&result.stack, 0)? != BigUint::from(0u8), + reserve0: stack_num(&result.stack, 3)?, + reserve1: stack_num(&result.stack, 4)?, + token0_wallet: stack_cell_address(&result.stack, 5)?, + token1_wallet: stack_cell_address(&result.stack, 6)?, + lp_fee: stack_num_u32(&result.stack, 7)?, + protocol_fee: stack_num_u32(&result.stack, 8)?, + }), + 10.. => Ok(PoolData { + is_locked: false, + reserve0: stack_num(&result.stack, 0)?, + reserve1: stack_num(&result.stack, 1)?, + token0_wallet: stack_cell_address(&result.stack, 2)?, + token1_wallet: stack_cell_address(&result.stack, 3)?, + lp_fee: stack_num_u32(&result.stack, 4)?, + protocol_fee: stack_num_u32(&result.stack, 5)?, + }), + _ => Err(SwapperError::ComputeQuoteError("STON.fi get_pool_data returned truncated stack".into())), + } +} + +fn stack_cell_address(stack: &[StackEntry], index: usize) -> Result { + let bytes = stack + .get(index) + .and_then(StackEntry::as_cell_bytes) + .ok_or_else(|| SwapperError::ComputeQuoteError("missing TON address stack cell".into()))?; + Ok(PrimitiveAddress::encode(&Address::from_boc_base64(bytes)?)) +} + +fn stack_num(stack: &[StackEntry], index: usize) -> Result { + let value = stack + .get(index) + .and_then(StackEntry::as_num) + .ok_or_else(|| SwapperError::ComputeQuoteError("missing TON number stack entry".into()))?; + parse_ton_num(value) +} + +fn stack_num_u32(stack: &[StackEntry], index: usize) -> Result { + stack_num(stack, index)? + .to_u32() + .ok_or_else(|| SwapperError::ComputeQuoteError("TON stack number does not fit u32".into())) +} + +fn parse_ton_num(value: &str) -> Result { + if let Some(value) = value.strip_prefix("0x") { + Ok(BigUint::from_str_radix(value, 16)?) + } else { + Ok(BigUint::from_str(value)?) + } +} diff --git a/core/crates/swapper/src/stonfi/constants.rs b/core/crates/swapper/src/stonfi/constants.rs new file mode 100644 index 0000000000..9da19d4c1e --- /dev/null +++ b/core/crates/swapper/src/stonfi/constants.rs @@ -0,0 +1,76 @@ +use gem_ton::constants::TON_PROXY_JETTON_ADDRESS; +use primitives::asset_constants::TON_USDT_TOKEN_ID; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct RouterInfo { + pub address: &'static str, + pub major_version: u8, + pub minor_version: u8, + pub pton_wallet: &'static str, +} + +impl RouterInfo { + pub(super) fn is_supported_v2(&self) -> bool { + self.major_version == 2 && (self.minor_version == 1 || self.minor_version == 2) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct StaticPool { + pub token0: &'static str, + pub token1: &'static str, + pub pool_address: &'static str, + pub router: RouterInfo, + pub token0_wallet: &'static str, + pub token1_wallet: &'static str, + pub lp_fee_bps: Option, +} + +#[rustfmt::skip] +const PRIMARY_ROUTER: RouterInfo = router("EQCS4UEa5UaJLzOyyKieqQOQ2P9M-7kXpkO5HnP3Bv250cN3", 2, 2, "EQCSIMGBps_qzRG3uPYhON8bucyCtu0mYdL1-u4gSz77IBa3"); +#[rustfmt::skip] +const V1_ROUTER: RouterInfo = router("EQB3ncyBUTjZUA5EnFKR5_EnOMI9V1tTEAAPaiU71gc4TiUt", 1, 0, "EQARULUYsmJq1RiZ-YiH-IJLcAZUVkVff-KBPwEmmaQGH6aC"); +#[rustfmt::skip] +const NOT_TON_ROUTER: RouterInfo = router("EQDx--jUU9PUtHltPYZX7wdzIi0SPY3KZ8nvOs0iZvQJd6Ql", 2, 2, "EQDwOyDlewGw8MkeXgZ_oOmPTIhJIlaJwhJmf4ffIPKv-294"); + +#[rustfmt::skip] +pub(super) const FALLBACK_ROUTERS: &[RouterInfo] = &[PRIMARY_ROUTER, V1_ROUTER]; + +pub(super) const STATIC_POOLS: &[StaticPool] = &[ + StaticPool { + token0: TON_PROXY_JETTON_ADDRESS, + token1: TON_USDT_TOKEN_ID, + pool_address: "EQCGScrZe1xbyWqWDvdI6mzP-GAcAWFv6ZXuaJOuSqemxku4", + router: PRIMARY_ROUTER, + token0_wallet: "EQCSIMGBps_qzRG3uPYhON8bucyCtu0mYdL1-u4gSz77IBa3", + token1_wallet: "EQCSLWJ9fY7b0A5OI72wxUp27l4fRlc6GvRBeFf6PiPpH4p3", + lp_fee_bps: Some(7), + }, + StaticPool { + token0: TON_PROXY_JETTON_ADDRESS, + token1: TON_USDT_TOKEN_ID, + pool_address: "EQD8TJ8xEWB1SpnRE4d89YO3jl0W0EiBnNS4IBaHaUmdfizE", + router: V1_ROUTER, + token0_wallet: "EQARULUYsmJq1RiZ-YiH-IJLcAZUVkVff-KBPwEmmaQGH6aC", + token1_wallet: "EQBO7JIbnU1WoNlGdgFtScJrObHXkBp-FT5mAz8UagiG9KQR", + lp_fee_bps: Some(20), + }, + StaticPool { + token0: "EQAvlWFDxGF2lXm67y4yzC17wYKD9A0guwPkMs1gOsM__NOT", + token1: TON_PROXY_JETTON_ADDRESS, + pool_address: "EQD9BmgQQ2_nzk-9LfxthcoLYC3yBHWK5WqEv_FyMU2riRvE", + router: NOT_TON_ROUTER, + token0_wallet: "EQAZMdggoCwOcSVLlT_RyiZtLSMyjYHIttUD9QBVe_NjIHA4", + token1_wallet: "EQDwOyDlewGw8MkeXgZ_oOmPTIhJIlaJwhJmf4ffIPKv-294", + lp_fee_bps: Some(20), + }, +]; + +const fn router(address: &'static str, major_version: u8, minor_version: u8, pton_wallet: &'static str) -> RouterInfo { + RouterInfo { + address, + major_version, + minor_version, + pton_wallet, + } +} diff --git a/core/crates/swapper/src/stonfi/mod.rs b/core/crates/swapper/src/stonfi/mod.rs new file mode 100644 index 0000000000..5f584305e3 --- /dev/null +++ b/core/crates/swapper/src/stonfi/mod.rs @@ -0,0 +1,10 @@ +mod client; +mod constants; +mod model; +mod provider; +mod quote; +#[cfg(test)] +mod testkit; +mod tx_builder; + +pub use provider::Stonfi; diff --git a/core/crates/swapper/src/stonfi/model.rs b/core/crates/swapper/src/stonfi/model.rs new file mode 100644 index 0000000000..ef9c370800 --- /dev/null +++ b/core/crates/swapper/src/stonfi/model.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +use crate::Route; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SwapSimulation { + pub offer_jetton_wallet: String, + pub ask_jetton_wallet: String, + pub router: Router, + pub ask_units: String, + pub min_ask_units: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Router { + pub address: String, + pub major_version: u8, + pub minor_version: u8, +} + +impl Router { + pub(super) fn is_supported_v2(&self) -> bool { + self.major_version == 2 && (self.minor_version == 1 || self.minor_version == 2) + } +} + +#[derive(Debug)] +pub(super) struct QuotePath { + pub(super) to_value: String, + pub(super) routes: Vec, +} diff --git a/core/crates/swapper/src/stonfi/provider.rs b/core/crates/swapper/src/stonfi/provider.rs new file mode 100644 index 0000000000..807572cd35 --- /dev/null +++ b/core/crates/swapper/src/stonfi/provider.rs @@ -0,0 +1,837 @@ +use super::{ + client::StonfiClient, + constants::{FALLBACK_ROUTERS, RouterInfo}, + model::{QuotePath, SwapSimulation}, + quote::{DiscoveredPool, apply_slippage, compute_amount_out, router_model, scaled_next_min_ask_amount, static_candidates, token_address}, + tx_builder::{NextSwapParams, ReferralParams, SwapTransactionParams, build_swap_transaction}, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteAsset, + SwapperQuoteData, + fees::{ReferralFee, default_referral_fees, quote_value_after_reserve_by_chain}, + route_cache::DiscoveryCache, +}; +use async_trait::async_trait; +use futures::future::join_all; +use gem_client::Client; +use gem_ton::{address::Address, rpc::client::TonClient}; +use num_bigint::BigUint; +use primitives::{AssetId, Chain, asset_constants::TON_USDT_ASSET_ID}; +use std::{fmt::Debug, str::FromStr, sync::Arc}; + +#[derive(Debug)] +pub struct Stonfi +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + provider: ProviderType, + client: StonfiClient, + route_cache: DiscoveryCache, +} + +impl Stonfi { + pub fn new(rpc_provider: Arc) -> Self { + let endpoint = rpc_provider.get_endpoint(Chain::Ton).expect("failed to get TON endpoint for STON.fi"); + Self::new_with_client(TonClient::new(RpcClient::new(endpoint, rpc_provider))) + } +} + +impl Stonfi +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new_with_client(ton_client: TonClient) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::StonfiV2), + client: StonfiClient::new(ton_client), + route_cache: DiscoveryCache::default(), + } + } + + fn intermediary_tokens() -> [SwapperQuoteAsset; 2] { + [SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ton)), SwapperQuoteAsset::from(TON_USDT_ASSET_ID.clone())] + } + + async fn quote_path_via_intermediary( + &self, + intermediary: &SwapperQuoteAsset, + from_value: &str, + request: &QuoteRequest, + allow_discovery: bool, + ) -> Result { + if !self.should_quote_intermediary_path(&request.from_asset, intermediary, &request.to_asset, allow_discovery) { + return Err(SwapperError::NoQuoteAvailable); + } + + let to_intermediary = self + .quote_swap(&request.from_asset, from_value, intermediary, request.options.slippage.bps, allow_discovery, true) + .await?; + if !to_intermediary.router.is_supported_v2() { + return Err(SwapperError::InvalidRoute); + } + + let from_intermediary = self + .quote_swap( + intermediary, + &to_intermediary.ask_units, + &request.to_asset, + request.options.slippage.bps, + allow_discovery, + true, + ) + .await?; + if !from_intermediary.router.is_supported_v2() { + return Err(SwapperError::InvalidRoute); + } + + Ok(QuotePath { + to_value: from_intermediary.ask_units.clone(), + routes: vec![ + Route { + input: request.from_asset.asset_id(), + output: intermediary.asset_id(), + route_data: serde_json::to_string(&to_intermediary)?, + }, + Route { + input: intermediary.asset_id(), + output: request.to_asset.asset_id(), + route_data: serde_json::to_string(&from_intermediary)?, + }, + ], + }) + } + + async fn quote_direct(&self, request: &QuoteRequest, from_value: &str, allow_discovery: bool) -> Result { + let simulation = self + .quote_swap(&request.from_asset, from_value, &request.to_asset, request.options.slippage.bps, allow_discovery, false) + .await?; + Ok(QuotePath { + to_value: simulation.ask_units.clone(), + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: serde_json::to_string(&simulation)?, + }], + }) + } + + async fn quote_intermediary_paths(&self, request: &QuoteRequest, from_value: &str, allow_discovery: bool) -> Vec> { + let mut paths = Vec::new(); + for intermediary in Self::intermediary_tokens().into_iter().filter(|x| { + let intermediary_id = x.asset_id(); + intermediary_id != request.from_asset.asset_id() && intermediary_id != request.to_asset.asset_id() + }) { + let path = self.quote_path_via_intermediary(&intermediary, from_value, request, allow_discovery).await; + let is_ok = path.is_ok(); + paths.push(path); + if allow_discovery && is_ok { + break; + } + } + paths + } + + fn has_known_candidates(&self, from_asset: &SwapperQuoteAsset, to_asset: &SwapperQuoteAsset, require_v2: bool) -> bool { + let from_token = token_address(from_asset); + let to_token = token_address(to_asset); + let (cached_candidates, _) = self.route_cache.get(&from_token, &to_token); + self.route_cache + .get_route(&from_token, &to_token) + .is_some_and(|route| route_has_supported_version(&route, require_v2)) + || route_has_supported_version(&cached_candidates, require_v2) + || static_candidates(&from_token, &to_token) + .iter() + .any(|candidate| candidate_has_supported_version(candidate, require_v2)) + } + + fn should_quote_intermediary_path(&self, from_asset: &SwapperQuoteAsset, intermediary: &SwapperQuoteAsset, to_asset: &SwapperQuoteAsset, allow_discovery: bool) -> bool { + let first_known = self.has_known_candidates(from_asset, intermediary, true); + let second_known = self.has_known_candidates(intermediary, to_asset, true); + if allow_discovery { first_known || second_known } else { first_known && second_known } + } + + fn referral_fee() -> ReferralFee { + default_referral_fees().ton + } + + async fn quote_swap( + &self, + from_asset: &SwapperQuoteAsset, + from_value: &str, + to_asset: &SwapperQuoteAsset, + slippage_bps: u32, + allow_discovery: bool, + require_v2: bool, + ) -> Result { + let from_token = token_address(from_asset); + let to_token = token_address(to_asset); + let amount = BigUint::from_str(from_value)?; + if amount == BigUint::from(0u8) { + return Err(SwapperError::InputAmountError { min_amount: Some("1".into()) }); + } + + if let Some(route) = self.route_cache.get_route(&from_token, &to_token) + && let Some(simulation) = self.try_quote_candidates(&from_token, &to_token, route, &amount, slippage_bps, require_v2).await? + { + return Ok(simulation); + } + + if let Some(simulation) = self + .try_quote_candidates(&from_token, &to_token, static_candidates(&from_token, &to_token), &amount, slippage_bps, require_v2) + .await? + { + return Ok(simulation); + } + + let (cached_candidates, _) = self.route_cache.get(&from_token, &to_token); + if let Some(simulation) = self + .try_quote_candidates(&from_token, &to_token, cached_candidates, &amount, slippage_bps, require_v2) + .await? + { + return Ok(simulation); + } + + if !allow_discovery { + return Err(SwapperError::NoQuoteAvailable); + } + + self.discover_and_quote(&from_token, &to_token, &amount, slippage_bps, require_v2).await + } + + async fn discover_and_quote(&self, from_token: &str, to_token: &str, amount: &BigUint, slippage_bps: u32, require_v2: bool) -> Result { + let (_, explored) = self.route_cache.get(from_token, to_token); + let routers = FALLBACK_ROUTERS + .iter() + .filter(|router| !require_v2 || router.is_supported_v2()) + .filter(|router| !explored.iter().any(|address| address == router.address)) + .collect::>(); + let explored_addresses = routers.iter().map(|router| router.address.to_string()).collect::>(); + let discoveries = join_all(routers.iter().map(|router| self.discover_candidate(from_token, to_token, router))).await; + let mut candidates = Vec::new(); + let mut error = SwapperError::NoQuoteAvailable; + + for discovery in discoveries { + let candidate = match discovery { + Ok(candidate) => candidate, + Err(err) if is_retryable_get_method_error(&err) => return Err(err), + Err(err) => { + error = err; + continue; + } + }; + + candidates.push(candidate); + } + + if candidates.is_empty() { + self.route_cache.put(from_token, to_token, &[], &explored_addresses); + return Err(error); + } + + match self.quote_best_candidate(candidates.clone(), from_token, to_token, amount, slippage_bps).await { + Ok((pool, simulation)) => { + self.route_cache.put(from_token, to_token, &candidates, &explored_addresses); + self.route_cache.put_route(from_token, to_token, std::slice::from_ref(&pool)); + Ok(simulation) + } + Err(err) if is_retryable_get_method_error(&err) => Err(err), + Err(err) => { + self.route_cache.put(from_token, to_token, &candidates, &explored_addresses); + Err(err) + } + } + } + + async fn discover_candidate(&self, from_token: &str, to_token: &str, router: &RouterInfo) -> Result { + let (wallet0, wallet1) = futures::try_join!(self.client.router_jetton_wallet(router, from_token), self.client.router_jetton_wallet(router, to_token))?; + let pool_address = self.client.get_pool_address(router, &wallet0, &wallet1).await?; + Ok(DiscoveredPool { + pool_address, + router: router_model(router), + asset0: from_token.to_string(), + asset1: to_token.to_string(), + wallet0, + wallet1, + lp_fee_bps: None, + }) + } + + async fn try_quote_candidates( + &self, + from_token: &str, + to_token: &str, + candidates: Vec, + amount: &BigUint, + slippage_bps: u32, + require_v2: bool, + ) -> Result, SwapperError> { + let candidates = filter_candidates(candidates, require_v2); + if candidates.is_empty() { + return Ok(None); + } + match self.quote_best_candidate(candidates, from_token, to_token, amount, slippage_bps).await { + Ok((pool, simulation)) => { + self.route_cache.put_route(from_token, to_token, std::slice::from_ref(&pool)); + Ok(Some(simulation)) + } + Err(err) if is_retryable_get_method_error(&err) => Err(err), + Err(_) => Ok(None), + } + } + + async fn quote_best_candidate( + &self, + candidates: Vec, + from_token: &str, + to_token: &str, + amount: &BigUint, + slippage_bps: u32, + ) -> Result<(DiscoveredPool, SwapSimulation), SwapperError> { + let quotes = join_all( + candidates + .into_iter() + .map(|candidate| self.quote_candidate(candidate, from_token, to_token, amount, slippage_bps)), + ) + .await; + let mut best_quote: Option<(DiscoveredPool, SwapSimulation)> = None; + for quote in quotes { + let quote = match quote { + Ok(quote) => quote, + Err(err) if is_retryable_get_method_error(&err) => return Err(err), + Err(_) => continue, + }; + let quote_amount = BigUint::from_str("e.1.ask_units)?; + let is_best = match &best_quote { + Some((_, best)) => quote_amount > BigUint::from_str(&best.ask_units)?, + None => true, + }; + if is_best { + best_quote = Some(quote); + } + } + best_quote.ok_or(SwapperError::NoQuoteAvailable) + } + + async fn quote_candidate( + &self, + candidate: DiscoveredPool, + from_token: &str, + to_token: &str, + amount: &BigUint, + slippage_bps: u32, + ) -> Result<(DiscoveredPool, SwapSimulation), SwapperError> { + let pool_data = self.client.get_pool_data(&candidate.pool_address).await?; + if pool_data.is_locked { + return Err(SwapperError::NoQuoteAvailable); + } + if let Some(lp_fee_bps) = candidate.lp_fee_bps + && pool_data.lp_fee != lp_fee_bps + { + return Err(SwapperError::NoQuoteAvailable); + } + let offer_wallet = candidate.wallet_for(from_token).ok_or(SwapperError::InvalidRoute)?; + let ask_wallet = candidate.wallet_for(to_token).ok_or(SwapperError::InvalidRoute)?; + let ask_units = compute_amount_out(&pool_data, offer_wallet, amount)?; + if ask_units == BigUint::from(0u8) { + return Err(SwapperError::NoQuoteAvailable); + } + let min_ask_units = apply_slippage(&ask_units, slippage_bps); + let simulation = SwapSimulation { + offer_jetton_wallet: offer_wallet.to_string(), + ask_jetton_wallet: ask_wallet.to_string(), + router: candidate.router.clone(), + ask_units: ask_units.to_string(), + min_ask_units: min_ask_units.to_string(), + }; + Ok((candidate, simulation)) + } + + async fn get_quotes(&self, request: &QuoteRequest, from_value: &str) -> Result { + if request.from_asset.is_native() || request.to_asset.is_native() { + return self.quote_direct(request, from_value, true).await; + } + + let direct = self.quote_direct(request, from_value, false).await; + let intermediary_paths = self.quote_intermediary_paths(request, from_value, false).await; + if let Some(err) = retryable_path_error(std::iter::once(&direct).chain(intermediary_paths.iter())) { + return Err(err); + } + if let Ok(path) = Self::select_best_quote_path(std::iter::once(direct).chain(intermediary_paths)) { + return Ok(path); + } + + let intermediary_paths = self.quote_intermediary_paths(request, from_value, true).await; + if let Some(err) = retryable_path_error(intermediary_paths.iter()) { + return Err(err); + } + if let Ok(path) = Self::select_best_quote_path(intermediary_paths) { + return Ok(path); + } + + self.quote_direct(request, from_value, true).await + } + + fn select_best_quote_path(paths: impl IntoIterator>) -> Result { + let mut error = None; + let mut best = None; + for result in paths { + match result { + Ok(path) => { + let amount = BigUint::from_str(&path.to_value)?; + let is_best = match &best { + Some((best_amount, _)) => amount > *best_amount, + None => true, + }; + if is_best { + best = Some((amount, path)); + } + } + Err(err) => { + if error.is_none() { + error = Some(err); + } + } + } + } + match best { + Some((_, path)) => Ok(path), + None => match error { + Some(error) => Err(error), + None => Err(SwapperError::NoQuoteAvailable), + }, + } + } +} + +#[async_trait] +impl Swapper for Stonfi +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + vec![SwapperChainAsset::All(Chain::Ton)] + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_value = quote_value_after_reserve_by_chain(request)?; + let path = self.get_quotes(request, &from_value).await?; + + Ok(Quote { + from_value, + min_from_value: None, + to_value: path.to_value, + data: ProviderData { + provider: self.provider().clone(), + routes: path.routes, + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: None, + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let simulations = quote + .data + .routes + .iter() + .map(|route| serde_json::from_str::(&route.route_data).map_err(|_| SwapperError::InvalidRoute)) + .collect::, _>>()?; + if simulations.len() > 2 { + return Err(SwapperError::InvalidRoute); + } + let simulation = simulations.first().ok_or(SwapperError::InvalidRoute)?; + let next_swap = simulations + .get(1) + .map(|next_simulation| { + Ok::<_, SwapperError>(NextSwapParams { + simulation: next_simulation, + min_ask_amount: scaled_next_min_ask_amount(simulation, next_simulation)?, + }) + }) + .transpose()?; + let referral_fee = Self::referral_fee(); + let receiver_address = if quote.request.destination_address.is_empty() { + "e.request.wallet_address + } else { + "e.request.destination_address + }; + let sender_jetton_wallet = self.client.sender_jetton_wallet(quote).await?; + + let tx = build_swap_transaction(SwapTransactionParams { + simulation, + next_swap, + from_native: quote.request.from_asset.is_native(), + to_native: quote.request.to_asset.is_native(), + sender_jetton_wallet: sender_jetton_wallet.as_deref(), + from_value: "e.from_value, + wallet_address: Address::parse("e.request.wallet_address)?, + receiver_address: Address::parse(receiver_address)?, + referral: ReferralParams { + address: Address::parse(&referral_fee.address)?, + bps: referral_fee.bps, + }, + deadline: None, + })?; + + Ok(SwapperQuoteData::new_contract(tx.to, tx.value, tx.data, None, None)) + } +} + +fn is_retryable_get_method_error(err: &SwapperError) -> bool { + match err { + SwapperError::ComputeQuoteError(message) => { + let message = message.to_ascii_lowercase(); + message.contains("ratelimit") || message.contains("rate limit") || message.contains("429") || message.contains("too many requests") + } + _ => false, + } +} + +fn retryable_path_error<'a>(paths: impl IntoIterator>) -> Option { + paths.into_iter().find_map(|path| match path { + Err(err) if is_retryable_get_method_error(err) => Some(err.clone()), + Ok(_) | Err(_) => None, + }) +} + +fn filter_candidates(candidates: Vec, require_v2: bool) -> Vec { + if require_v2 { + candidates.into_iter().filter(|candidate| candidate_has_supported_version(candidate, true)).collect() + } else { + candidates + } +} + +fn route_has_supported_version(route: &[DiscoveredPool], require_v2: bool) -> bool { + !route.is_empty() && route.iter().all(|candidate| candidate_has_supported_version(candidate, require_v2)) +} + +fn candidate_has_supported_version(candidate: &DiscoveredPool, require_v2: bool) -> bool { + !require_v2 || candidate.router.is_supported_v2() +} + +#[cfg(test)] +mod tests { + use super::super::constants::STATIC_POOLS; + use super::*; + use crate::Options; + use gem_ton::constants::TON_PROXY_JETTON_ADDRESS; + use primitives::{asset_constants::TON_USDT_TOKEN_ID, testkit::signer_mock::TEST_TON_SENDER}; + use std::sync::{Arc, Mutex}; + + const PTON_WALLET: &str = "EQCSIMGBps_qzRG3uPYhON8bucyCtu0mYdL1-u4gSz77IBa3"; + const USDT_WALLET: &str = "EQCSLWJ9fY7b0A5OI72wxUp27l4fRlc6GvRBeFf6PiPpH4p3"; + const GRAM_TOKEN_ID: &str = "EQC47093oX5Xhb0xuk2lCr2RhS8rj-vul61u4W2UH5ORmG_O"; + const DISCOVERED_POOL: &str = "EQCGScrZe1xbyWqWDvdI6mzP-GAcAWFv6ZXuaJOuSqemxku4"; + const V1_POOL: &str = "EQD8TJ8xEWB1SpnRE4d89YO3jl0W0EiBnNS4IBaHaUmdfizE"; + + fn discovered_pool(pool_address: &str) -> DiscoveredPool { + DiscoveredPool { + pool_address: pool_address.to_string(), + router: router_model(&FALLBACK_ROUTERS[0]), + asset0: TON_PROXY_JETTON_ADDRESS.to_string(), + asset1: TON_USDT_TOKEN_ID.to_string(), + wallet0: PTON_WALLET.to_string(), + wallet1: USDT_WALLET.to_string(), + lp_fee_bps: None, + } + } + + fn get_method_response(stack: serde_json::Value) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "ok": true, + "result": { + "exit_code": 0, + "stack": stack + } + })) + .unwrap() + } + + fn cell_response(address: &str) -> Vec { + let bytes = Address::parse(address).unwrap().to_boc_base64().unwrap(); + get_method_response(serde_json::json!([["cell", { "bytes": bytes }]])) + } + + fn get_pool_data_response(is_locked: bool, reserve0: u64, reserve1: u64, token0_wallet: &str, token1_wallet: &str, lp_fee_bps: u32) -> Vec { + let token0 = Address::parse(token0_wallet).unwrap().to_boc_base64().unwrap(); + let token1 = Address::parse(token1_wallet).unwrap().to_boc_base64().unwrap(); + get_method_response(serde_json::json!([ + ["num", if is_locked { "0x1" } else { "0x0" }], + ["num", "0x0"], + ["num", "0x0"], + ["num", reserve0.to_string()], + ["num", reserve1.to_string()], + ["cell", { "bytes": token0 }], + ["cell", { "bytes": token1 }], + ["num", lp_fee_bps.to_string()], + ["num", "0x3"], + ["num", "0x0"], + ["num", "0x0"], + ["cell", { "bytes": token1 }] + ])) + } + + fn get_v1_pool_data_response(reserve0: u64, reserve1: u64, token0_wallet: &str, token1_wallet: &str, lp_fee_bps: u32) -> Vec { + let token0 = Address::parse(token0_wallet).unwrap().to_boc_base64().unwrap(); + let token1 = Address::parse(token1_wallet).unwrap().to_boc_base64().unwrap(); + get_method_response(serde_json::json!([ + ["num", reserve0.to_string()], + ["num", reserve1.to_string()], + ["cell", { "bytes": token0 }], + ["cell", { "bytes": token1 }], + ["num", lp_fee_bps.to_string()], + ["num", "0xa"], + ["num", "0xa"], + ["cell", { "bytes": token1 }], + ["num", "0x0"], + ["num", "0x0"] + ])) + } + + fn not_ton_pool() -> &'static super::super::constants::StaticPool { + STATIC_POOLS.iter().find(|pool| pool.token0.ends_with("__NOT")).unwrap() + } + + fn v1_ton_usdt_pool() -> &'static super::super::constants::StaticPool { + STATIC_POOLS.iter().find(|pool| pool.pool_address == V1_POOL).unwrap() + } + + fn provider_with_get_method(handler: F) -> Stonfi + where + F: Fn(&str, &str) -> Vec + Send + Sync + 'static, + { + Stonfi::new_with_client(TonClient::new(gem_client::testkit::MockClient::new().with_post(move |_, body| { + let request: serde_json::Value = serde_json::from_slice(body).unwrap(); + let address = request["address"].as_str().unwrap(); + let method = request["method"].as_str().unwrap(); + Ok(handler(method, address)) + }))) + } + + fn provider_with_pool_data(handler: F) -> Stonfi + where + F: Fn(&str) -> Vec + Send + Sync + 'static, + { + provider_with_get_method(move |_, address| handler(address)) + } + + #[tokio::test] + async fn test_intermediary_discovery_minimizes_calls() { + let not_pool = not_ton_pool(); + let calls = Arc::new(Mutex::new(Vec::::new())); + let calls_ref = calls.clone(); + let provider = provider_with_get_method(move |method, address| { + calls_ref.lock().unwrap().push(method.to_string()); + match method { + "get_wallet_address" => cell_response(USDT_WALLET), + "get_pool_address" => cell_response(DISCOVERED_POOL), + "get_pool_data" if address == DISCOVERED_POOL => get_pool_data_response(false, 3_000_000_000_000, 1_800_000_000_000_000, USDT_WALLET, PTON_WALLET, 7), + "get_pool_data" if address == not_pool.pool_address => { + get_pool_data_response(false, 5_000_000_000_000_000, 2_000_000_000_000, not_pool.token0_wallet, not_pool.token1_wallet, 20) + } + _ => unreachable!("{method} {address}"), + } + }); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, "unknown-token")), + to_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, not_pool.token0)), + wallet_address: TEST_TON_SENDER.to_string(), + destination_address: TEST_TON_SENDER.to_string(), + value: "1000000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = provider.get_quote(&request).await.unwrap(); + assert_eq!(quote.data.routes.len(), 2); + assert_eq!( + calls.lock().unwrap().as_slice(), + ["get_wallet_address", "get_pool_address", "get_pool_data", "get_pool_data"] + ); + + calls.lock().unwrap().clear(); + let quote = provider.get_quote(&request).await.unwrap(); + assert_eq!(quote.data.routes.len(), 2); + assert_eq!(calls.lock().unwrap().as_slice(), ["get_pool_data", "get_pool_data"]); + } + + #[tokio::test] + async fn test_quote_candidate_rejects_locked_pool() { + let provider = provider_with_pool_data(|_| get_pool_data_response(true, 3_809_436_784_065, 1_784_561_670_122_756, USDT_WALLET, PTON_WALLET, 7)); + let amount = BigUint::from(1_000_000_000u64); + + assert_eq!( + provider + .quote_candidate(discovered_pool("pool-a"), TON_PROXY_JETTON_ADDRESS, TON_USDT_TOKEN_ID, &amount, 100) + .await + .unwrap_err(), + SwapperError::NoQuoteAvailable + ); + } + + #[tokio::test] + async fn test_direct_quote_can_select_v1_static_pool() { + let v1_pool = v1_ton_usdt_pool(); + let provider = provider_with_pool_data(move |address| match address { + DISCOVERED_POOL => get_pool_data_response(false, 1_000_000_000_000, 1, PTON_WALLET, USDT_WALLET, 7), + V1_POOL => get_v1_pool_data_response(1_000_000_000, 10_000_000_000, v1_pool.token0_wallet, v1_pool.token1_wallet, 20), + _ => unreachable!("{address}"), + }); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ton)), + to_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, TON_USDT_TOKEN_ID)), + wallet_address: TEST_TON_SENDER.to_string(), + destination_address: TEST_TON_SENDER.to_string(), + value: "100000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let path = provider.quote_direct(&request, &request.value, false).await.unwrap(); + let simulation: SwapSimulation = serde_json::from_str(&path.routes[0].route_data).unwrap(); + + assert_eq!(path.routes.len(), 1); + assert_eq!(simulation.router.major_version, 1); + assert_eq!(simulation.offer_jetton_wallet, v1_pool.token0_wallet); + assert_eq!(simulation.ask_jetton_wallet, v1_pool.token1_wallet); + } + + #[tokio::test] + async fn test_discovered_direct_quote_selects_best_router_pool() { + let calls = Arc::new(Mutex::new(Vec::::new())); + let calls_ref = calls.clone(); + let provider = provider_with_get_method(move |method, address| { + calls_ref.lock().unwrap().push(format!("{method} {address}")); + match method { + "get_wallet_address" if address == TON_USDT_TOKEN_ID => cell_response(USDT_WALLET), + "get_wallet_address" if address == GRAM_TOKEN_ID => cell_response(PTON_WALLET), + "get_pool_address" if address == FALLBACK_ROUTERS[0].address => cell_response(DISCOVERED_POOL), + "get_pool_address" if address == FALLBACK_ROUTERS[1].address => cell_response(V1_POOL), + "get_pool_data" if address == DISCOVERED_POOL => get_pool_data_response(false, 1, 19_811_277, USDT_WALLET, PTON_WALLET, 20), + "get_pool_data" if address == V1_POOL => get_v1_pool_data_response(226_348_366, 194_933_327_038_860, USDT_WALLET, PTON_WALLET, 20), + _ => unreachable!("{method} {address}"), + } + }); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, TON_USDT_TOKEN_ID)), + to_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, GRAM_TOKEN_ID)), + wallet_address: TEST_TON_SENDER.to_string(), + destination_address: TEST_TON_SENDER.to_string(), + value: "233000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let path = provider.quote_direct(&request, &request.value, true).await.unwrap(); + let simulation: SwapSimulation = serde_json::from_str(&path.routes[0].route_data).unwrap(); + let mut pool_calls = calls.lock().unwrap().iter().filter(|call| call.starts_with("get_pool_data ")).cloned().collect::>(); + pool_calls.sort(); + let mut expected_pool_calls = vec![format!("get_pool_data {DISCOVERED_POOL}"), format!("get_pool_data {V1_POOL}")]; + expected_pool_calls.sort(); + + assert_eq!(pool_calls, expected_pool_calls); + assert_eq!(simulation.router.major_version, 1); + assert_eq!(simulation.ask_units, "199854680472"); + } + + #[tokio::test] + async fn test_quote_best_candidate_selects_largest_output() { + let provider = provider_with_pool_data(|address| match address { + "pool-a" => get_pool_data_response(false, 3_000_000_000_000, 1_800_000_000_000_000, USDT_WALLET, PTON_WALLET, 7), + "pool-b" => get_pool_data_response(false, 4_000_000_000_000, 1_800_000_000_000_000, USDT_WALLET, PTON_WALLET, 7), + _ => unreachable!(), + }); + let amount = BigUint::from(1_000_000_000u64); + let (pool, simulation) = provider + .quote_best_candidate( + vec![discovered_pool("pool-a"), discovered_pool("pool-b")], + TON_PROXY_JETTON_ADDRESS, + TON_USDT_TOKEN_ID, + &amount, + 100, + ) + .await + .unwrap(); + + assert_eq!(pool.pool_address, "pool-b"); + assert_eq!(simulation.ask_units, "2219998"); + } + + #[test] + fn test_intermediary_discovery_requires_known_leg() { + let provider = provider_with_pool_data(|_| unreachable!()); + let unknown_a = SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, "unknown-a")); + let unknown_b = SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, "unknown-b")); + let ton = SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ton)); + let usdt = SwapperQuoteAsset::from(TON_USDT_ASSET_ID.clone()); + + assert!(!provider.should_quote_intermediary_path(&unknown_a, &ton, &unknown_b, true)); + assert!(provider.should_quote_intermediary_path(&unknown_a, &ton, &usdt, true)); + assert!(!provider.should_quote_intermediary_path(&unknown_a, &ton, &usdt, false)); + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{Options, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, stonfi::testkit::NOT_TOKEN_ID, testkit::mock_ton}; + use primitives::{AssetId, asset_constants::TON_USDT_ASSET_ID, testkit::signer_mock::TEST_TON_SENDER}; + + #[tokio::test] + async fn test_stonfi_quote_and_quote_data_ton_to_usdt() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = Stonfi::new(rpc_provider); + let request = mock_ton(TEST_TON_SENDER.to_string()); + + let quote = provider.get_quote(&request).await?; + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert_eq!(quote.data.provider, provider.provider().clone()); + assert_eq!(quote.data.routes.len(), 1); + println!("STON.fi TON -> USDT quote: {quote:?}"); + + let quote_data = provider.get_quote_data("e, FetchQuoteData::None).await?; + assert!(!quote_data.to.is_empty()); + assert!(!quote_data.value.is_empty()); + assert!(quote_data.data.starts_with("te6cc")); + println!("STON.fi TON -> USDT quote_data: {quote_data:?}"); + + Ok(()) + } + + #[tokio::test] + async fn test_stonfi_quote_and_quote_data_not_to_usdt() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = Stonfi::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, NOT_TOKEN_ID)), + to_asset: SwapperQuoteAsset::from(TON_USDT_ASSET_ID.clone()), + wallet_address: TEST_TON_SENDER.to_string(), + destination_address: TEST_TON_SENDER.to_string(), + value: "1000000000000".to_string(), + options: Options::new_with_slippage(100.into()), + }; + + let quote = provider.get_quote(&request).await?; + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert_eq!(quote.data.provider, provider.provider().clone()); + assert_eq!(quote.data.routes.len(), 2); + assert_eq!(quote.data.routes[0].input, AssetId::from_token(Chain::Ton, NOT_TOKEN_ID)); + assert_eq!(quote.data.routes[0].output, AssetId::from_chain(Chain::Ton)); + assert_eq!(quote.data.routes[1].input, AssetId::from_chain(Chain::Ton)); + assert_eq!(quote.data.routes[1].output, TON_USDT_ASSET_ID.clone()); + println!("STON.fi NOT -> USDT quote: {quote:?}"); + + let quote_data = provider.get_quote_data("e, FetchQuoteData::None).await?; + assert!(!quote_data.to.is_empty()); + assert!(!quote_data.value.is_empty()); + assert!(quote_data.data.starts_with("te6cc")); + println!("STON.fi NOT -> USDT quote_data: {quote_data:?}"); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/stonfi/quote.rs b/core/crates/swapper/src/stonfi/quote.rs new file mode 100644 index 0000000000..5788725254 --- /dev/null +++ b/core/crates/swapper/src/stonfi/quote.rs @@ -0,0 +1,222 @@ +use super::{ + constants::{RouterInfo, STATIC_POOLS, StaticPool}, + model::{Router, SwapSimulation}, +}; +use crate::{SwapperError, SwapperQuoteAsset}; +use gem_ton::{address::Address, constants::TON_PROXY_JETTON_ADDRESS}; +use num_bigint::BigUint; +use std::str::FromStr; + +const BPS_DENOMINATOR: u32 = 10_000; + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct DiscoveredPool { + pub pool_address: String, + pub router: Router, + pub asset0: String, + pub asset1: String, + pub wallet0: String, + pub wallet1: String, + pub lp_fee_bps: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct PoolData { + pub is_locked: bool, + pub reserve0: BigUint, + pub reserve1: BigUint, + pub token0_wallet: String, + pub token1_wallet: String, + pub lp_fee: u32, + pub protocol_fee: u32, +} + +impl DiscoveredPool { + pub(super) fn from_static(pool: &StaticPool) -> Self { + Self { + pool_address: pool.pool_address.to_string(), + router: router_model(&pool.router), + asset0: pool.token0.to_string(), + asset1: pool.token1.to_string(), + wallet0: pool.token0_wallet.to_string(), + wallet1: pool.token1_wallet.to_string(), + lp_fee_bps: pool.lp_fee_bps, + } + } + + pub(super) fn wallet_for(&self, token: &str) -> Option<&str> { + if token == self.asset0 { + Some(&self.wallet0) + } else if token == self.asset1 { + Some(&self.wallet1) + } else { + None + } + } +} + +pub(super) fn static_candidates(from_token: &str, to_token: &str) -> Vec { + STATIC_POOLS + .iter() + .filter(|pool| static_pool_matches(pool, from_token, to_token)) + .map(DiscoveredPool::from_static) + .collect() +} + +fn static_pool_matches(pool: &StaticPool, from_token: &str, to_token: &str) -> bool { + (pool.token0 == from_token && pool.token1 == to_token) || (pool.token0 == to_token && pool.token1 == from_token) +} + +pub(super) fn router_model(router: &RouterInfo) -> Router { + Router { + address: router.address.to_string(), + major_version: router.major_version, + minor_version: router.minor_version, + } +} + +pub(super) fn token_address(asset: &SwapperQuoteAsset) -> String { + let asset_id = asset.asset_id(); + match asset_id.token_id { + Some(token_id) => token_id, + None => TON_PROXY_JETTON_ADDRESS.to_string(), + } +} + +pub(super) fn compute_amount_out(pool: &PoolData, offer_wallet: &str, amount: &BigUint) -> Result { + let offer = Address::parse(offer_wallet)?; + let token0 = Address::parse(&pool.token0_wallet)?; + let token1 = Address::parse(&pool.token1_wallet)?; + let (reserve_in, reserve_out) = if offer == token0 { + (&pool.reserve0, &pool.reserve1) + } else if offer == token1 { + (&pool.reserve1, &pool.reserve0) + } else { + return Err(SwapperError::InvalidRoute); + }; + let total_fee = pool + .lp_fee + .checked_add(pool.protocol_fee) + .ok_or_else(|| SwapperError::ComputeQuoteError("STON.fi fee overflow".into()))?; + if total_fee >= BPS_DENOMINATOR { + return Err(SwapperError::ComputeQuoteError("STON.fi fee exceeds 100%".into())); + } + let amount_after_fee = (amount * BigUint::from(BPS_DENOMINATOR - total_fee)) / BigUint::from(BPS_DENOMINATOR); + if amount_after_fee == BigUint::from(0u8) { + return Ok(BigUint::from(0u8)); + } + Ok((reserve_out * &amount_after_fee) / (reserve_in + amount_after_fee)) +} + +pub(super) fn apply_slippage(amount: &BigUint, bps: u32) -> BigUint { + let slippage = BPS_DENOMINATOR - bps.min(BPS_DENOMINATOR); + (amount * BigUint::from(slippage)) / BigUint::from(BPS_DENOMINATOR) +} + +pub(super) fn scaled_next_min_ask_amount(first: &SwapSimulation, next: &SwapSimulation) -> Result { + let first_ask = BigUint::from_str(&first.ask_units)?; + if first_ask == BigUint::from(0u8) { + return Err(SwapperError::InvalidRoute); + } + let first_min = BigUint::from_str(&first.min_ask_units)?; + let next_min = BigUint::from_str(&next.min_ask_units)?; + Ok((next_min * first_min) / first_ask) +} + +#[cfg(test)] +mod tests { + use super::super::constants::FALLBACK_ROUTERS; + use super::*; + use primitives::{AssetId, Chain, asset_constants::TON_USDT_TOKEN_ID}; + + const PTON_WALLET: &str = "EQCSIMGBps_qzRG3uPYhON8bucyCtu0mYdL1-u4gSz77IBa3"; + const USDT_WALLET: &str = "EQCSLWJ9fY7b0A5OI72wxUp27l4fRlc6GvRBeFf6PiPpH4p3"; + + fn pool_data() -> PoolData { + PoolData { + is_locked: false, + reserve0: BigUint::from(3_809_436_784_065u64), + reserve1: BigUint::from(1_784_561_670_122_756u64), + token0_wallet: USDT_WALLET.to_string(), + token1_wallet: PTON_WALLET.to_string(), + lp_fee: 7, + protocol_fee: 3, + } + } + + #[test] + fn test_token_address() { + assert_eq!(token_address(&SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ton))), TON_PROXY_JETTON_ADDRESS); + assert_eq!( + token_address(&SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, TON_USDT_TOKEN_ID))), + TON_USDT_TOKEN_ID + ); + } + + #[test] + fn test_compute_amount_out() { + let amount = BigUint::from(1_000_000_000u64); + let out = compute_amount_out(&pool_data(), PTON_WALLET, &amount).unwrap(); + + assert_eq!(out, BigUint::from(2_132_526u64)); + assert_eq!(apply_slippage(&out, 100), BigUint::from(2_111_200u64)); + } + + #[test] + fn test_compute_amount_out_rejects_unknown_offer_wallet() { + let amount = BigUint::from(1_000_000_000u64); + assert_eq!( + compute_amount_out(&pool_data(), "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c", &amount).unwrap_err(), + SwapperError::InvalidRoute + ); + } + + #[test] + fn test_compute_amount_out_selects_pool_side() { + let amount = BigUint::from(2_000_000u64); + let out = compute_amount_out(&pool_data(), USDT_WALLET, &amount).unwrap(); + + assert_eq!(out, BigUint::from(935_978_872u64)); + } + + #[test] + fn test_static_candidates_for_ton_usdt() { + let candidates = static_candidates(TON_PROXY_JETTON_ADDRESS, TON_USDT_TOKEN_ID); + + assert_eq!(candidates.len(), 2); + assert!( + candidates + .iter() + .any(|candidate| candidate.pool_address == "EQCGScrZe1xbyWqWDvdI6mzP-GAcAWFv6ZXuaJOuSqemxku4" && candidate.lp_fee_bps == Some(7)) + ); + assert!( + candidates + .iter() + .any(|candidate| candidate.pool_address == "EQD8TJ8xEWB1SpnRE4d89YO3jl0W0EiBnNS4IBaHaUmdfizE" && candidate.lp_fee_bps == Some(20)) + ); + } + + #[test] + fn test_static_metadata_addresses_parse() { + for router in FALLBACK_ROUTERS { + Address::parse(router.address).unwrap(); + Address::parse(router.pton_wallet).unwrap(); + } + for pool in STATIC_POOLS { + Address::parse(pool.token0).unwrap(); + Address::parse(pool.token1).unwrap(); + Address::parse(pool.pool_address).unwrap(); + Address::parse(pool.router.address).unwrap(); + Address::parse(pool.token0_wallet).unwrap(); + Address::parse(pool.token1_wallet).unwrap(); + } + } + + #[test] + fn test_scaled_next_min_ask_amount() { + let first = SwapSimulation::mock("", "", "260238", "257635"); + let next = SwapSimulation::mock("", "", "709", "702"); + + assert_eq!(scaled_next_min_ask_amount(&first, &next).unwrap(), BigUint::from(694u32)); + } +} diff --git a/core/crates/swapper/src/stonfi/testdata/v1_simulation.json b/core/crates/swapper/src/stonfi/testdata/v1_simulation.json new file mode 100644 index 0000000000..845e3ee723 --- /dev/null +++ b/core/crates/swapper/src/stonfi/testdata/v1_simulation.json @@ -0,0 +1,17 @@ +{ + "offer_address": "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c", + "ask_address": "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", + "offer_jetton_wallet": "EQARULUYsmJq1RiZ-YiH-IJLcAZUVkVff-KBPwEmmaQGH6aC", + "ask_jetton_wallet": "EQBO7JIbnU1WoNlGdgFtScJrObHXkBp-FT5mAz8UagiG9KQR", + "router_address": "EQB3ncyBUTjZUA5EnFKR5_EnOMI9V1tTEAAPaiU71gc4TiUt", + "router": { + "address": "EQB3ncyBUTjZUA5EnFKR5_EnOMI9V1tTEAAPaiU71gc4TiUt", + "major_version": 1, + "minor_version": 0 + }, + "pool_address": "EQD8TJ8xEWB1SpnRE4d89YO3jl0W0EiBnNS4IBaHaUmdfizE", + "offer_units": "1000000000", + "ask_units": "1306866", + "slippage_tolerance": "0.01", + "min_ask_units": "1293797" +} diff --git a/core/crates/swapper/src/stonfi/testdata/v2_simulation.json b/core/crates/swapper/src/stonfi/testdata/v2_simulation.json new file mode 100644 index 0000000000..8d358fa3ef --- /dev/null +++ b/core/crates/swapper/src/stonfi/testdata/v2_simulation.json @@ -0,0 +1,17 @@ +{ + "offer_address": "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c", + "ask_address": "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", + "offer_jetton_wallet": "EQCSIMGBps_qzRG3uPYhON8bucyCtu0mYdL1-u4gSz77IBa3", + "ask_jetton_wallet": "EQCSLWJ9fY7b0A5OI72wxUp27l4fRlc6GvRBeFf6PiPpH4p3", + "router_address": "EQCS4UEa5UaJLzOyyKieqQOQ2P9M-7kXpkO5HnP3Bv250cN3", + "router": { + "address": "EQCS4UEa5UaJLzOyyKieqQOQ2P9M-7kXpkO5HnP3Bv250cN3", + "major_version": 2, + "minor_version": 2 + }, + "pool_address": "EQCGScrZe1xbyWqWDvdI6mzP-GAcAWFv6ZXuaJOuSqemxku4", + "offer_units": "980000000", + "ask_units": "1284000", + "slippage_tolerance": "0.01", + "min_ask_units": "1271160" +} diff --git a/core/crates/swapper/src/stonfi/testkit.rs b/core/crates/swapper/src/stonfi/testkit.rs new file mode 100644 index 0000000000..84cbd0a27f --- /dev/null +++ b/core/crates/swapper/src/stonfi/testkit.rs @@ -0,0 +1,47 @@ +pub use primitives::testkit::signer_mock::TEST_TON_SENDER; + +use super::{ + model::{Router, SwapSimulation}, + tx_builder::{ReferralParams, SwapTransactionParams}, +}; +use gem_ton::address::Address; + +#[cfg(all(test, feature = "swap_integration_tests"))] +pub const NOT_TOKEN_ID: &str = "EQAvlWFDxGF2lXm67y4yzC17wYKD9A0guwPkMs1gOsM__NOT"; +pub const ROUTER_V2_ADDRESS: &str = "EQCS4UEa5UaJLzOyyKieqQOQ2P9M-7kXpkO5HnP3Bv250cN3"; + +impl SwapSimulation { + pub fn mock(offer_jetton_wallet: &str, ask_jetton_wallet: &str, ask_units: &str, min_ask_units: &str) -> Self { + Self { + offer_jetton_wallet: offer_jetton_wallet.to_string(), + ask_jetton_wallet: ask_jetton_wallet.to_string(), + router: Router { + address: ROUTER_V2_ADDRESS.to_string(), + major_version: 2, + minor_version: 2, + }, + ask_units: ask_units.to_string(), + min_ask_units: min_ask_units.to_string(), + } + } +} + +impl<'a> SwapTransactionParams<'a> { + pub fn mock(simulation: &'a SwapSimulation) -> Self { + Self { + simulation, + next_swap: None, + from_native: true, + to_native: false, + sender_jetton_wallet: None, + from_value: "1000000000", + wallet_address: Address::parse(TEST_TON_SENDER).unwrap(), + receiver_address: Address::parse(TEST_TON_SENDER).unwrap(), + referral: ReferralParams { + address: Address::parse(TEST_TON_SENDER).unwrap(), + bps: 50, + }, + deadline: Some(1_700_000_000), + } + } +} diff --git a/core/crates/swapper/src/stonfi/tx_builder/message.rs b/core/crates/swapper/src/stonfi/tx_builder/message.rs new file mode 100644 index 0000000000..17ec06eef7 --- /dev/null +++ b/core/crates/swapper/src/stonfi/tx_builder/message.rs @@ -0,0 +1,27 @@ +use crate::SwapperError; +use gem_ton::{ + address::Address, + constants::JETTON_TRANSFER_OPCODE, + tvm::{Cell, CellArc, CellBuilder}, +}; +use num_bigint::BigUint; + +pub fn build_jetton_transfer_body( + amount: &BigUint, + destination: &Address, + response_destination: Option<&Address>, + forward_ton_amount: &BigUint, + forward_payload: Option<&CellArc>, +) -> Result { + let mut builder = CellBuilder::new(); + builder + .store_u32(32, JETTON_TRANSFER_OPCODE)? + .store_u64(64, 0)? + .store_coins(amount)? + .store_address(destination)?; + builder.store_maybe_address(response_destination)?; + builder.store_maybe_reference(None)?; + builder.store_coins(forward_ton_amount)?; + builder.store_maybe_reference(forward_payload)?; + Ok(builder.build()?) +} diff --git a/core/crates/swapper/src/stonfi/tx_builder/mod.rs b/core/crates/swapper/src/stonfi/tx_builder/mod.rs new file mode 100644 index 0000000000..e8786789e1 --- /dev/null +++ b/core/crates/swapper/src/stonfi/tx_builder/mod.rs @@ -0,0 +1,34 @@ +mod message; +mod model; +mod v1; +mod v2; + +use super::model::Router; +use crate::SwapperError; + +pub use model::{NextSwapParams, ReferralParams, SwapTransactionParams, TxParams}; + +#[derive(Debug, Clone, Copy)] +enum RouterVersion { + V1, + V2, +} + +pub fn build_swap_transaction(params: SwapTransactionParams<'_>) -> Result { + match (router_version(¶ms.simulation.router)?, params.next_swap.is_some()) { + (RouterVersion::V1, false) => v1::build_swap_transaction(params), + (RouterVersion::V1, true) => Err(SwapperError::ComputeQuoteError("STON.fi v1 multi-hop swap is not supported".into())), + (RouterVersion::V2, _) => v2::build_swap_transaction(params), + } +} + +fn router_version(router: &Router) -> Result { + match router.major_version { + 1 => Ok(RouterVersion::V1), + 2 => match router.minor_version { + 1 | 2 => Ok(RouterVersion::V2), + minor => Err(SwapperError::ComputeQuoteError(format!("Unsupported STON.fi v2 router minor version: {minor}"))), + }, + major => Err(SwapperError::ComputeQuoteError(format!("Unsupported STON.fi router major version: {major}"))), + } +} diff --git a/core/crates/swapper/src/stonfi/tx_builder/model.rs b/core/crates/swapper/src/stonfi/tx_builder/model.rs new file mode 100644 index 0000000000..877dc4386b --- /dev/null +++ b/core/crates/swapper/src/stonfi/tx_builder/model.rs @@ -0,0 +1,49 @@ +use super::super::model::SwapSimulation; +use gem_ton::{address::Address, tvm::CellArc}; +use num_bigint::BigUint; + +#[derive(Debug, Clone, Copy)] +pub struct ReferralParams { + pub address: Address, + pub bps: u32, +} + +#[derive(Debug, Clone)] +pub struct NextSwapParams<'a> { + pub simulation: &'a SwapSimulation, + pub min_ask_amount: BigUint, +} + +#[derive(Debug, Clone)] +pub struct SwapTransactionParams<'a> { + pub simulation: &'a SwapSimulation, + pub next_swap: Option>, + pub from_native: bool, + pub to_native: bool, + pub sender_jetton_wallet: Option<&'a str>, + pub from_value: &'a str, + pub wallet_address: Address, + pub receiver_address: Address, + pub referral: ReferralParams, + pub deadline: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TxParams { + pub to: String, + pub value: String, + pub data: String, +} + +pub(super) struct SwapCellParams<'a> { + pub opcode: u32, + pub ask_wallet: Address, + pub refund_address: Address, + pub receiver_address: Address, + pub min_ask_amount: BigUint, + pub forward_gas: u64, + pub next_payload: Option<&'a CellArc>, + pub referral_bps: u32, + pub referral_address: Address, + pub deadline: u64, +} diff --git a/core/crates/swapper/src/stonfi/tx_builder/v1.rs b/core/crates/swapper/src/stonfi/tx_builder/v1.rs new file mode 100644 index 0000000000..ee22fac5ce --- /dev/null +++ b/core/crates/swapper/src/stonfi/tx_builder/v1.rs @@ -0,0 +1,99 @@ +use super::{ + message::build_jetton_transfer_body, + model::{SwapTransactionParams, TxParams}, +}; +use crate::SwapperError; +use gem_ton::{ + address::Address, + tvm::{BagOfCells, Cell, CellArc, CellBuilder}, +}; +use num_bigint::BigUint; +use std::str::FromStr; + +const V1_SWAP_OPCODE: u32 = 0x25938561; +const V1_JETTON_TO_JETTON_GAS: u64 = 220_000_000; +const V1_JETTON_TO_JETTON_FORWARD_GAS: u64 = 175_000_000; +const V1_JETTON_TO_TON_GAS: u64 = 170_000_000; +const V1_JETTON_TO_TON_FORWARD_GAS: u64 = 125_000_000; +const V1_TON_TO_JETTON_FORWARD_GAS: u64 = 185_000_000; + +pub fn build_swap_transaction(params: SwapTransactionParams<'_>) -> Result { + let swap_body = build_swap_body(¶ms)?.into_arc(); + if params.from_native { + return build_ton_to_jetton(params, &swap_body); + } + build_jetton_swap(params, &swap_body) +} + +fn build_ton_to_jetton(params: SwapTransactionParams<'_>, swap_body: &CellArc) -> Result { + let router = Address::parse(¶ms.simulation.router.address)?; + let from_value = BigUint::from_str(params.from_value)?; + let forward_gas = BigUint::from(V1_TON_TO_JETTON_FORWARD_GAS); + let body = build_jetton_transfer_body(&from_value, &router, None, &forward_gas, Some(swap_body))?; + + let mut value = from_value; + value += forward_gas; + + Ok(TxParams { + to: params.simulation.offer_jetton_wallet.clone(), + value: value.to_string(), + data: BagOfCells::from_root(body).to_base64(true)?, + }) +} + +fn build_jetton_swap(params: SwapTransactionParams<'_>, swap_body: &CellArc) -> Result { + let router = Address::parse(¶ms.simulation.router.address)?; + let from_value = BigUint::from_str(params.from_value)?; + let (gas, forward_gas) = if params.to_native { + (V1_JETTON_TO_TON_GAS, V1_JETTON_TO_TON_FORWARD_GAS) + } else { + (V1_JETTON_TO_JETTON_GAS, V1_JETTON_TO_JETTON_FORWARD_GAS) + }; + let body = build_jetton_transfer_body(&from_value, &router, Some(¶ms.wallet_address), &BigUint::from(forward_gas), Some(swap_body))?; + let sender_jetton_wallet = params + .sender_jetton_wallet + .ok_or_else(|| SwapperError::ComputeQuoteError("missing sender jetton wallet".into()))?; + + Ok(TxParams { + to: sender_jetton_wallet.to_string(), + value: gas.to_string(), + data: BagOfCells::from_root(body).to_base64(true)?, + }) +} + +fn build_swap_body(params: &SwapTransactionParams<'_>) -> Result { + let ask_wallet = Address::parse(¶ms.simulation.ask_jetton_wallet)?; + let min_ask_amount = BigUint::from_str(¶ms.simulation.min_ask_units)?; + + let mut builder = CellBuilder::new(); + builder + .store_u32(32, V1_SWAP_OPCODE)? + .store_address(&ask_wallet)? + .store_coins(&min_ask_amount)? + .store_address(¶ms.wallet_address)? + .store_bit(true)? + .store_address(¶ms.referral.address)?; + + Ok(builder.build()?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stonfi::{model::SwapSimulation, tx_builder::SwapTransactionParams}; + + #[test] + fn test_build_v1_swap_transaction() { + let simulation: SwapSimulation = serde_json::from_str(include_str!("../testdata/v1_simulation.json")).unwrap(); + + let transaction = build_swap_transaction(SwapTransactionParams::mock(&simulation)).unwrap(); + + assert_eq!(transaction.to, simulation.offer_jetton_wallet); + assert_eq!(transaction.value, "1185000000"); + assert_eq!( + transaction.data, + "te6cckEBAgEAqAABbQ+KfqUAAAAAAAAAAEO5rKAIAO87mQKicbKgHIk4pSPP4k5xhHqutqYgAB7USnesDnCcECwbgQMBANclk4VhgAndkkNzqarUGyjOwC2pOE1nNjryA0/Cp8zAZ+KNQRDehid7ywAM6FKWpQGl51ZuTKImFkWYLixc2NCsRYy79zJEauQV8/AAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz5OFmmt" + ); + assert!(BagOfCells::parse_base64(&transaction.data).is_ok()); + } +} diff --git a/core/crates/swapper/src/stonfi/tx_builder/v2.rs b/core/crates/swapper/src/stonfi/tx_builder/v2.rs new file mode 100644 index 0000000000..965779509c --- /dev/null +++ b/core/crates/swapper/src/stonfi/tx_builder/v2.rs @@ -0,0 +1,295 @@ +use super::{ + message::build_jetton_transfer_body, + model::{NextSwapParams, SwapCellParams, SwapTransactionParams, TxParams}, +}; +use crate::SwapperError; +use gem_ton::{ + address::Address, + tvm::{BagOfCells, Cell, CellArc, CellBuilder}, +}; +use num_bigint::BigUint; +use primitives::unix_timestamp; +use std::str::FromStr; + +const V2_SWAP_OPCODE: u32 = 0x6664DE2A; +const V2_CROSS_SWAP_OPCODE: u32 = 0x69CF1A5B; +const V2_JETTON_SWAP_GAS: u64 = 300_000_000; +const V2_JETTON_SWAP_FORWARD_GAS: u64 = 240_000_000; +const V2_TON_TO_JETTON_FORWARD_GAS: u64 = 300_000_000; +const V2_DEFAULT_DEADLINE_SECONDS: u64 = 15 * 60; +const V2_TON_TO_JETTON_DEADLINE_SECONDS: u64 = 60; +const PTON_V2_TON_TRANSFER_OPCODE: u32 = 0x01F3835D; +const PTON_V2_TON_TRANSFER_GAS: u64 = 10_000_000; + +pub fn build_swap_transaction(params: SwapTransactionParams<'_>) -> Result { + let swap_body = build_swap_body(¶ms)?.into_arc(); + if params.from_native { + return build_ton_to_jetton(params, &swap_body); + } + build_jetton_swap(params, &swap_body) +} + +fn build_ton_to_jetton(params: SwapTransactionParams<'_>, swap_body: &CellArc) -> Result { + let from_value = BigUint::from_str(params.from_value)?; + let body = build_pton_ton_transfer_body(&from_value, ¶ms.wallet_address, Some(swap_body))?; + let forward_gas = V2_TON_TO_JETTON_FORWARD_GAS + next_swap_forward_gas(¶ms) + PTON_V2_TON_TRANSFER_GAS; + + let value = from_value + BigUint::from(forward_gas); + + Ok(TxParams { + to: params.simulation.offer_jetton_wallet.clone(), + value: value.to_string(), + data: BagOfCells::from_root(body).to_base64(true)?, + }) +} + +fn build_jetton_swap(params: SwapTransactionParams<'_>, swap_body: &CellArc) -> Result { + let router = Address::parse(¶ms.simulation.router.address)?; + let from_value = BigUint::from_str(params.from_value)?; + let extra_forward_gas = next_swap_forward_gas(¶ms); + let body = build_jetton_transfer_body( + &from_value, + &router, + Some(¶ms.wallet_address), + &BigUint::from(V2_JETTON_SWAP_FORWARD_GAS + extra_forward_gas), + Some(swap_body), + )?; + let sender_jetton_wallet = params + .sender_jetton_wallet + .ok_or_else(|| SwapperError::ComputeQuoteError("missing sender jetton wallet".into()))?; + + Ok(TxParams { + to: sender_jetton_wallet.to_string(), + value: (V2_JETTON_SWAP_GAS + extra_forward_gas).to_string(), + data: BagOfCells::from_root(body).to_base64(true)?, + }) +} + +fn build_swap_body(params: &SwapTransactionParams<'_>) -> Result { + let ask_wallet = Address::parse(¶ms.simulation.ask_jetton_wallet)?; + let receiver_address = match params.next_swap.as_ref() { + Some(next_swap) if is_same_router(params, next_swap) => Address::parse(¶ms.simulation.router.address)?, + Some(next_swap) => Address::parse(&next_swap.simulation.offer_jetton_wallet)?, + None => params.receiver_address, + }; + let min_ask_amount = BigUint::from_str(¶ms.simulation.min_ask_units)?; + let referral_bps = if params.next_swap.is_some() { 0 } else { params.referral.bps }; + let default_deadline_seconds = if params.from_native { + V2_TON_TO_JETTON_DEADLINE_SECONDS + } else { + V2_DEFAULT_DEADLINE_SECONDS + }; + let deadline = params.deadline.unwrap_or_else(|| unix_timestamp() + default_deadline_seconds); + + let next_payload = params + .next_swap + .as_ref() + .map(|next_swap| build_next_swap_body(params, next_swap)) + .transpose()? + .map(Cell::into_arc); + + build_swap_cell(SwapCellParams { + opcode: V2_SWAP_OPCODE, + ask_wallet, + refund_address: params.wallet_address, + receiver_address, + min_ask_amount, + forward_gas: next_swap_forward_gas(params), + next_payload: next_payload.as_ref(), + referral_bps, + referral_address: params.referral.address, + deadline, + }) +} + +fn build_next_swap_body(params: &SwapTransactionParams<'_>, next_swap: &NextSwapParams<'_>) -> Result { + let opcode = if is_same_router(params, next_swap) { V2_CROSS_SWAP_OPCODE } else { V2_SWAP_OPCODE }; + let ask_wallet = Address::parse(&next_swap.simulation.ask_jetton_wallet)?; + let deadline = params.deadline.unwrap_or_else(|| unix_timestamp() + V2_DEFAULT_DEADLINE_SECONDS); + + build_swap_cell(SwapCellParams { + opcode, + ask_wallet, + refund_address: params.wallet_address, + receiver_address: params.receiver_address, + min_ask_amount: next_swap.min_ask_amount.clone(), + forward_gas: 0, + next_payload: None, + referral_bps: params.referral.bps, + referral_address: params.referral.address, + deadline, + }) +} + +fn build_swap_cell(params: SwapCellParams<'_>) -> Result { + let mut details = CellBuilder::new(); + details + .store_coins(¶ms.min_ask_amount)? + .store_address(¶ms.receiver_address)? + .store_coins(&BigUint::from(params.forward_gas))?; + details.store_maybe_reference(params.next_payload)?; + details.store_coins(&BigUint::from(0u64))?; + details.store_maybe_reference(None)?; + details.store_u32(16, params.referral_bps)?; + details.store_address(¶ms.referral_address)?; + let details = details.build()?.into_arc(); + + let mut builder = CellBuilder::new(); + builder + .store_u32(32, params.opcode)? + .store_address(¶ms.ask_wallet)? + .store_address(¶ms.refund_address)? + .store_address(¶ms.refund_address)? + .store_u64(64, params.deadline)? + .store_reference(&details)?; + Ok(builder.build()?) +} + +fn next_swap_forward_gas(params: &SwapTransactionParams<'_>) -> u64 { + // Same-router cross-swaps route internally; inter-router hops need extra forward gas. + match params.next_swap.as_ref() { + Some(next_swap) if !is_same_router(params, next_swap) => V2_JETTON_SWAP_FORWARD_GAS, + _ => 0, + } +} + +fn is_same_router(params: &SwapTransactionParams<'_>, next_swap: &NextSwapParams<'_>) -> bool { + params.simulation.router.address == next_swap.simulation.router.address +} + +fn build_pton_ton_transfer_body(amount: &BigUint, refund_address: &Address, forward_payload: Option<&CellArc>) -> Result { + let mut builder = CellBuilder::new(); + builder + .store_u32(32, PTON_V2_TON_TRANSFER_OPCODE)? + .store_u64(64, 0)? + .store_coins(amount)? + .store_address(refund_address)?; + builder.store_maybe_reference(forward_payload)?; + Ok(builder.build()?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stonfi::{ + model::{Router, SwapSimulation}, + tx_builder::{NextSwapParams, SwapTransactionParams}, + }; + + const TEST_SENDER_JETTON_WALLET: &str = "EQAlgB03OjJKdXrlwZiGJD5snSzPKF2VL5bErJn_cqJANGH9"; + + #[test] + fn test_build_v2_ton_to_jetton_swap_transaction() { + let simulation: SwapSimulation = serde_json::from_str(include_str!("../testdata/v2_simulation.json")).unwrap(); + + let transaction = build_swap_transaction(SwapTransactionParams::mock(&simulation)).unwrap(); + + assert_eq!(transaction.to, simulation.offer_jetton_wallet); + assert_eq!(transaction.value, "1310000000"); + assert_eq!( + transaction.data, + "te6cckEBAwEA9QABZAHzg10AAAAAAAAAAEO5rKAIAGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+fAQHhZmTeKoASRaxPr7HbegHJxHe2GKlO3cvD6MrnQ16ILwr/R8R9I/AAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz4AGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+eAAAAAMqn4gEACAJMxNleIAGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+eAAAZQAM6FKWpQGl51ZuTKImFkWYLixc2NCsRYy79zJEauQV8+IF8mPY=" + ); + assert!(BagOfCells::parse_base64(&transaction.data).is_ok()); + } + + #[test] + fn test_build_v2_jetton_to_ton_swap_transaction() { + let simulation: SwapSimulation = serde_json::from_str(include_str!("../testdata/v2_simulation.json")).unwrap(); + + let transaction = build_swap_transaction(SwapTransactionParams { + from_native: false, + to_native: true, + sender_jetton_wallet: Some(TEST_SENDER_JETTON_WALLET), + from_value: "1000000", + ..SwapTransactionParams::mock(&simulation) + }) + .unwrap(); + + assert_eq!(transaction.to, TEST_SENDER_JETTON_WALLET); + assert_eq!(transaction.value, "300000000"); + assert_eq!( + transaction.data, + "te6cckECAwEAARoAAa4Pin6lAAAAAAAAAAAw9CQIASXCgjXKjRJeZ2WRUT1SByGx/pn3ci9Mh3I85+4N+3OjAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzyBycOAEBAeFmZN4qgBJFrE+vsdt6AcnEd7YYqU7dy8PoyudDXogvCv9HxH0j8ADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPgAZ0KUtSgNLzqzcmURMLIswXFi5saFYixl37mSI1cgr54AAAAAyqfiAQAIAkzE2V4gAZ0KUtSgNLzqzcmURMLIswXFi5saFYixl37mSI1cgr54AABlAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz4W/Xfwg==" + ); + assert!(BagOfCells::parse_base64(&transaction.data).is_ok()); + } + + #[test] + fn test_build_v2_2_hop_swap_transactions() { + let swap_to_intermediary = SwapSimulation::mock( + "EQCSIMGBps_qzRG3uPYhON8bucyCtu0mYdL1-u4gSz77IBa3", + "EQCSLWJ9fY7b0A5OI72wxUp27l4fRlc6GvRBeFf6PiPpH4p3", + "260238", + "257635", + ); + let swap_from_intermediary = SwapSimulation::mock( + "EQCSLWJ9fY7b0A5OI72wxUp27l4fRlc6GvRBeFf6PiPpH4p3", + "EQCSIMGBps_qzRG3uPYhON8bucyCtu0mYdL1-u4gSz77IBa3", + "709", + "702", + ); + let cross_transaction = build_swap_transaction(SwapTransactionParams { + next_swap: Some(NextSwapParams { + simulation: &swap_from_intermediary, + min_ask_amount: BigUint::from(694u32), + }), + ..SwapTransactionParams::mock(&swap_to_intermediary) + }) + .unwrap(); + + assert_eq!(cross_transaction.to, swap_to_intermediary.offer_jetton_wallet); + assert_eq!(cross_transaction.value, "1310000000"); + assert_eq!( + cross_transaction.data, + "te6cckECBQEAAbUAAWQB84NdAAAAAAAAAABDuaygCABnQpS1KA0vOrNyZREwsizBcWLmxoViLGXfuZIjVyCvnwEB4WZk3iqAEkWsT6+x23oBycR3thipTt3Lw+jK50NeiC8K/0fEfSPwAM6FKWpQGl51ZuTKImFkWYLixc2NCsRYy79zJEauQV8+ABnQpS1KA0vOrNyZREwsizBcWLmxoViLGXfuZIjVyCvngAAAADKp+IBAAgGTMD7mOAElwoI1yo0SXmdlkVE9Ugchsf6Z93IvTIdyPOfuDftzohAAAEADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPgDAeFpzxpbgBJEGDA02f1Zojb3HsQnG+N3OZBW3aTMOl6/XcQJZ99kEADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPgAZ0KUtSgNLzqzcmURMLIswXFi5saFYixl37mSI1cgr54AAAAAyqfiAQAQAkSAraABnQpS1KA0vOrNyZREwsizBcWLmxoViLGXfuZIjVyCvngAAGUADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPhOi4cX" + ); + assert!(BagOfCells::parse_base64(&cross_transaction.data).is_ok()); + + let swap_from_intermediary_with_minor_mismatch = SwapSimulation { + router: Router { + minor_version: 1, + ..swap_from_intermediary.router.clone() + }, + ..swap_from_intermediary.clone() + }; + let cross_transaction_with_minor_mismatch = build_swap_transaction(SwapTransactionParams { + next_swap: Some(NextSwapParams { + simulation: &swap_from_intermediary_with_minor_mismatch, + min_ask_amount: BigUint::from(694u32), + }), + ..SwapTransactionParams::mock(&swap_to_intermediary) + }) + .unwrap(); + + assert_eq!(cross_transaction_with_minor_mismatch.value, cross_transaction.value); + assert_eq!(cross_transaction_with_minor_mismatch.data, cross_transaction.data); + + let swap_from_intermediary_on_other_router = SwapSimulation { + router: Router { + address: "EQDx--jUU9PUtHltPYZX7wdzIi0SPY3KZ8nvOs0iZvQJd6Ql".to_string(), + ..swap_from_intermediary.router.clone() + }, + ..swap_from_intermediary.clone() + }; + let forward_transaction = build_swap_transaction(SwapTransactionParams { + next_swap: Some(NextSwapParams { + simulation: &swap_from_intermediary_on_other_router, + min_ask_amount: BigUint::from(694u32), + }), + from_native: false, + sender_jetton_wallet: Some(TEST_SENDER_JETTON_WALLET), + from_value: "1000000", + ..SwapTransactionParams::mock(&swap_to_intermediary) + }) + .unwrap(); + + assert_eq!(forward_transaction.to, TEST_SENDER_JETTON_WALLET); + assert_eq!(forward_transaction.value, "540000000"); + assert_eq!( + forward_transaction.data, + "te6cckECBQEAAd4AAa4Pin6lAAAAAAAAAAAw9CQIASXCgjXKjRJeZ2WRUT1SByGx/pn3ci9Mh3I85+4N+3OjAAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXzyDk4cAEBAeFmZN4qgBJFrE+vsdt6AcnEd7YYqU7dy8PoyudDXogvCv9HxH0j8ADOhSlqUBpedWbkyiJhZFmC4sXNjQrEWMu/cyRGrkFfPgAZ0KUtSgNLzqzcmURMLIswXFi5saFYixl37mSI1cgr54AAAAAyqfiAQAIBmzA+5jgBJFrE+vsdt6AcnEd7YYqU7dy8PoyudDXogvCv9HxH0j6BycOAEAAAQAM6FKWpQGl51ZuTKImFkWYLixc2NCsRYy79zJEauQV8+AMB4WZk3iqAEkQYMDTZ/VmiNvcexCcb43c5kFbdpMw6Xr9dxAln32QQAM6FKWpQGl51ZuTKImFkWYLixc2NCsRYy79zJEauQV8+ABnQpS1KA0vOrNyZREwsizBcWLmxoViLGXfuZIjVyCvngAAAADKp+IBABACRICtoAGdClLUoDS86s3JlETCyLMFxYubGhWIsZd+5kiNXIK+eAAAZQAM6FKWpQGl51ZuTKImFkWYLixc2NCsRYy79zJEauQV8+Jp8zgc=" + ); + assert!(BagOfCells::parse_base64(&forward_transaction.data).is_ok()); + } +} diff --git a/core/crates/swapper/src/swapper.rs b/core/crates/swapper/src/swapper.rs new file mode 100644 index 0000000000..5207ac6b4a --- /dev/null +++ b/core/crates/swapper/src/swapper.rs @@ -0,0 +1,368 @@ +use crate::{ + AssetList, FetchQuoteData, Permit2ApprovalData, ProviderType, Quote, QuoteRequest, SwapQuoteError, SwapQuotes, SwapResult, Swapper, SwapperChainAsset, SwapperError, + SwapperProvider, SwapperProviderMode, SwapperQuoteData, across, alien::RpcProvider, cetus_clmm, chainflip, cross_chain::VaultAddresses, hyperliquid, jupiter, mayan, + near_intents, panora, proxy::provider_factory, relay, squid, stonfi, thorchain, uniswap, +}; +use num_bigint::BigInt; +use num_traits::ToPrimitive; +use primitives::{AssetId, Chain, EVMChain}; +use std::{ + collections::{BTreeSet, HashSet}, + fmt::Debug, + sync::Arc, +}; + +#[derive(Debug)] +pub struct GemSwapper { + pub rpc_provider: Arc, + pub swappers: Vec>, +} + +impl GemSwapper { + // filter provider types that does not support cross chain / bridge swaps + fn filter_by_provider_mode(mode: &SwapperProviderMode, from_chain: Chain, to_chain: Chain) -> bool { + match mode { + SwapperProviderMode::OnChain => from_chain == to_chain, + SwapperProviderMode::Bridge | SwapperProviderMode::CrossChain => from_chain != to_chain, + SwapperProviderMode::OmniChain(chains) => chains.contains(&from_chain) || from_chain != to_chain, + } + } + + fn filter_by_supported_chains(supported_chains: Vec, from_chain: Chain, to_chain: Chain) -> bool { + supported_chains.contains(&from_chain) && supported_chains.contains(&to_chain) + } + + fn filter_supported_assets(supported_assets: Vec, asset_id: AssetId) -> bool { + supported_assets.into_iter().any(|x| match x { + SwapperChainAsset::All(chain) => chain == asset_id.chain, + SwapperChainAsset::Assets(chain, assets) => chain == asset_id.chain || assets.contains(&asset_id), + }) + } + + fn get_swapper_by_provider(&self, provider: &SwapperProvider) -> Result<&dyn Swapper, SwapperError> { + self.swappers + .iter() + .find(|x| x.provider().id == *provider) + .map(|v| &**v) + .ok_or(SwapperError::NoAvailableProvider) + } + + fn apply_gas_limit_multiplier(chain: &Chain, gas_limit: String) -> String { + if let Some(evm_chain) = EVMChain::from_chain(*chain) { + let multiplier = if evm_chain.is_zkstack() { 2.0 } else { 1.0 }; + if let Ok(gas_limit_value) = gas_limit.parse::() { + return (gas_limit_value * multiplier).ceil().to_u64().unwrap_or_default().to_string(); + } + } + gas_limit + } + + fn sort_quotes_by_output_amount(quotes: &mut [Quote]) { + quotes.sort_by(Self::compare_quotes_by_output_amount); + } + + fn compare_quotes_by_output_amount(a: &Quote, b: &Quote) -> std::cmp::Ordering { + let a_amount = a.to_value.parse::().unwrap_or_default(); + let b_amount = b.to_value.parse::().unwrap_or_default(); + b_amount.cmp(&a_amount) + } +} + +impl GemSwapper { + pub fn new(rpc_provider: Arc) -> Self { + let swappers: Vec> = vec![ + uniswap::default::boxed_uniswap_v3(rpc_provider.clone()), + uniswap::default::boxed_uniswap_v4(rpc_provider.clone()), + uniswap::default::boxed_pancakeswap(rpc_provider.clone()), + Box::new(thorchain::ThorChain::new(rpc_provider.clone())), + Box::new(jupiter::Jupiter::new(rpc_provider.clone())), + Box::new(provider_factory::new_okx(rpc_provider.clone())), + Box::new(across::Across::new(rpc_provider.clone())), + Box::new(hyperliquid::Hyperliquid::new(rpc_provider.clone())), + uniswap::default::boxed_oku(rpc_provider.clone()), + uniswap::default::boxed_wagmi(rpc_provider.clone()), + Box::new(stonfi::Stonfi::new(rpc_provider.clone())), + Box::new(mayan::Mayan::new(rpc_provider.clone())), + Box::new(panora::Panora::new(rpc_provider.clone())), + Box::new(near_intents::NearIntents::new(rpc_provider.clone())), + Box::new(chainflip::ChainflipProvider::new(rpc_provider.clone())), + Box::new(cetus_clmm::CetusClmm::new(rpc_provider.clone())), + Box::new(relay::Relay::new(rpc_provider.clone())), + Box::new(squid::Squid::new(rpc_provider.clone())), + uniswap::default::boxed_aerodrome(rpc_provider.clone()), + ]; + + Self { rpc_provider, swappers } + } + + pub fn supported_chains(&self) -> Vec { + self.swappers.iter().flat_map(|x| x.supported_chains()).collect::>().into_iter().collect() + } + + pub fn supported_chains_for_from_asset(&self, asset_id: &AssetId) -> AssetList { + let chains: Vec = vec![asset_id.chain]; + let mut asset_ids: Vec = Vec::new(); + + for provider in &self.swappers { + if !Self::filter_supported_assets(provider.supported_assets(), asset_id.clone()) { + continue; + } + provider.supported_assets().into_iter().for_each(|x| match x { + SwapperChainAsset::All(_) => {} + SwapperChainAsset::Assets(chain, assets) => { + asset_ids.push(chain.as_asset_id()); + asset_ids.extend(assets); + } + }); + } + AssetList { chains, asset_ids } + } + + pub fn get_providers(&self) -> Vec { + self.swappers.iter().map(|x| x.provider().clone()).collect() + } + + pub fn get_providers_for_request(&self, request: &QuoteRequest) -> Result, SwapperError> { + if request.from_asset.id == request.to_asset.id { + return Err(SwapperError::NoQuoteAvailable); + } + let from_chain = request.from_asset.chain(); + let to_chain = request.to_asset.chain(); + let providers: Vec = self + .swappers + .iter() + .filter(|x| Self::filter_by_provider_mode(&x.provider().mode, from_chain, to_chain)) + .filter(|x| Self::filter_by_supported_chains(x.supported_chains(), from_chain, to_chain)) + .map(|x| x.provider().clone()) + .collect(); + if providers.is_empty() { + return Err(SwapperError::NoAvailableProvider); + } + Ok(providers) + } + + pub async fn get_quote(&self, request: &QuoteRequest) -> Result, SwapperError> { + let SwapQuotes { quotes, .. } = self.get_quotes(request).await?; + if quotes.is_empty() { + return Err(SwapperError::NoQuoteAvailable); + } + Ok(quotes) + } + + pub async fn get_quotes(&self, request: &QuoteRequest) -> Result { + let provider_ids: BTreeSet<_> = self.get_providers_for_request(request)?.into_iter().map(|p| p.id).collect(); + let providers = self.swappers.iter().filter(|x| provider_ids.contains(&x.provider().id)).collect::>(); + + let quotes_futures = providers.into_iter().map(|x| { + let provider_id = x.provider().id.id().to_string(); + async move { x.get_quote(request).await.map_err(|e| (provider_id, e)) } + }); + + let quote_results = futures::future::join_all(quotes_futures).await; + + let mut quotes = Vec::new(); + let mut errors = Vec::new(); + for result in quote_results { + match result { + Ok(quote) => quotes.push(quote), + Err((provider_id, err)) => errors.push(SwapQuoteError::new(Some(provider_id), err.to_string())), + } + } + + Self::sort_quotes_by_output_amount(&mut quotes); + Ok(SwapQuotes { quotes, errors }) + } + + pub async fn get_quote_by_provider(&self, provider: SwapperProvider, request: QuoteRequest) -> Result { + let provider = self.get_swapper_by_provider(&provider)?; + provider.get_quote(&request).await + } + + pub async fn get_permit2_for_quote(&self, quote: &Quote) -> Result, SwapperError> { + let provider = self.get_swapper_by_provider("e.data.provider.id)?; + provider.get_permit2_for_quote(quote).await + } + + pub async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { + let provider = self.get_swapper_by_provider("e.data.provider.id)?; + let mut quote_data = provider.get_quote_data(quote, data).await?; + if let Some(gas_limit) = quote_data.gas_limit.take() { + quote_data.gas_limit = Some(Self::apply_gas_limit_multiplier("e.request.from_asset.chain(), gas_limit)); + } + Ok(quote_data) + } + + pub async fn get_swap_result(&self, chain: Chain, provider: SwapperProvider, transaction_hash: &str) -> Result { + self.get_swapper_by_provider(&provider)?.get_swap_result(chain, transaction_hash).await + } + + pub async fn get_vault_addresses(&self, provider: &SwapperProvider, from_timestamp: Option) -> Result { + self.get_swapper_by_provider(provider)?.get_vault_addresses(from_timestamp).await + } +} + +#[cfg(all(test, feature = "reqwest_provider"))] +mod tests { + + use std::{collections::BTreeSet, sync::Arc, vec}; + + use primitives::{ + AssetId, Chain, + asset_constants::{ETHEREUM_USDC_ASSET_ID, ETHEREUM_USDT_ASSET_ID}, + }; + + use super::*; + use crate::{ + SwapperChainAsset, SwapperProvider, SwapperQuoteAsset, + alien::reqwest_provider::NativeProvider, + testkit::{MockSwapper, mock_quote}, + uniswap::default::{new_pancakeswap, new_uniswap_v3}, + }; + + #[test] + fn test_filter_by_provider_type() { + let providers = [ + SwapperProvider::UniswapV3, + SwapperProvider::PancakeswapV3, + SwapperProvider::Jupiter, + SwapperProvider::Thorchain, + ]; + + // Cross chain swaps (same chain will be filtered out) + let filtered = providers + .iter() + .filter(|x| GemSwapper::filter_by_provider_mode(&ProviderType::new(**x).mode, Chain::Ethereum, Chain::Optimism)) + .cloned() + .collect::>(); + + assert_eq!(filtered, vec![SwapperProvider::Thorchain]); + } + + #[test] + fn test_filter_by_supported_chains() { + let provider = Arc::new(NativeProvider::default()); + let swappers: Vec> = vec![ + Box::new(new_uniswap_v3(provider.clone())), + Box::new(new_pancakeswap(provider.clone())), + Box::new(thorchain::ThorChain::new(provider.clone())), + Box::new(jupiter::Jupiter::new(provider)), + ]; + + let from_chain = Chain::Ethereum; + let to_chain = Chain::Optimism; + + let filtered = swappers + .iter() + .filter(|x| GemSwapper::filter_by_provider_mode(&x.provider().mode, from_chain, to_chain)) + .filter(|x| GemSwapper::filter_by_supported_chains(x.supported_chains(), from_chain, to_chain)) + .collect::>(); + + assert_eq!(filtered.len(), 0); + + let from_chain = Chain::SmartChain; + let to_chain = Chain::SmartChain; + + let filtered = swappers + .iter() + .filter(|x| GemSwapper::filter_by_provider_mode(&x.provider().mode, from_chain, to_chain)) + .filter(|x| GemSwapper::filter_by_supported_chains(x.supported_chains(), from_chain, to_chain)) + .collect::>(); + + assert_eq!(filtered.len(), 2); + assert_eq!( + filtered.iter().map(|x| x.provider().id).collect::>(), + BTreeSet::from([SwapperProvider::UniswapV3, SwapperProvider::PancakeswapV3]) + ); + + let from_chain = Chain::Solana; + let to_chain = Chain::Solana; + + let filtered = swappers + .iter() + .filter(|x| GemSwapper::filter_by_provider_mode(&x.provider().mode, from_chain, to_chain)) + .filter(|x| GemSwapper::filter_by_supported_chains(x.supported_chains(), from_chain, to_chain)) + .collect::>(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].provider().id, SwapperProvider::Jupiter); + + let from_chain = Chain::SmartChain; + let to_chain = Chain::Bitcoin; + + let filtered = swappers + .iter() + .filter(|x| GemSwapper::filter_by_provider_mode(&x.provider().mode, from_chain, to_chain)) + .filter(|x| GemSwapper::filter_by_supported_chains(x.supported_chains(), from_chain, to_chain)) + .collect::>(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].provider().id, SwapperProvider::Thorchain); + } + + #[test] + fn test_filter_supported_assets() { + let asset_id = AssetId::from_chain(Chain::Ethereum); + let asset_id_usdt: AssetId = ETHEREUM_USDT_ASSET_ID.clone(); + let supported_assets_all = vec![SwapperChainAsset::All(Chain::Ethereum)]; + assert!(GemSwapper::filter_supported_assets(supported_assets_all, asset_id.clone())); + + let supported_assets = vec![ + SwapperChainAsset::All(Chain::Ethereum), + SwapperChainAsset::Assets(Chain::Ethereum, vec![AssetId::from_token(Chain::Ethereum, &asset_id_usdt.clone().token_id.unwrap())]), + ]; + + assert!(GemSwapper::filter_supported_assets(supported_assets.clone(), asset_id_usdt.clone())); + assert!(GemSwapper::filter_supported_assets(supported_assets, asset_id)); + } + + #[tokio::test] + async fn test_get_quotes_collects_per_provider_errors() { + let request = mock_quote( + SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + SwapperQuoteAsset::from(ETHEREUM_USDC_ASSET_ID.clone()), + ); + + let gem_swapper = GemSwapper { + rpc_provider: Arc::new(NativeProvider::default()), + swappers: vec![ + Box::new(MockSwapper::new(SwapperProvider::UniswapV3, || Err(SwapperError::InputAmountError { min_amount: None }))), + Box::new(MockSwapper::new(SwapperProvider::PancakeswapV3, || { + Err(SwapperError::InputAmountError { + min_amount: Some("1264000".into()), + }) + })), + Box::new(MockSwapper::new(SwapperProvider::Jupiter, || Err(SwapperError::NoQuoteAvailable))), + ], + }; + let result = gem_swapper.get_quotes(&request).await.unwrap(); + assert!(result.quotes.is_empty()); + assert_eq!(result.errors.len(), 3); + + let providers: BTreeSet<_> = result.errors.iter().map(|e| e.provider.clone().unwrap()).collect(); + assert_eq!( + providers, + BTreeSet::from([ + SwapperProvider::UniswapV3.id().to_string(), + SwapperProvider::PancakeswapV3.id().to_string(), + SwapperProvider::Jupiter.id().to_string(), + ]) + ); + let pancake_error = result.errors.iter().find(|e| e.provider.as_deref() == Some(SwapperProvider::PancakeswapV3.id())).unwrap(); + assert!(pancake_error.error.contains("1264000")); + } + + #[test] + fn test_sort_quotes_by_output_amount_desc() { + let mut quotes = [ + Quote::mock_with_provider(SwapperProvider::UniswapV3, "101"), + Quote::mock_with_provider(SwapperProvider::UniswapV4, "100"), + Quote::mock_with_provider(SwapperProvider::PancakeswapV3, "102"), + ]; + + GemSwapper::sort_quotes_by_output_amount(&mut quotes); + + assert_eq!(quotes[0].to_value, "102"); + assert_eq!(quotes[1].to_value, "101"); + assert_eq!(quotes[2].to_value, "100"); + } +} diff --git a/core/crates/swapper/src/swapper_trait.rs b/core/crates/swapper/src/swapper_trait.rs new file mode 100644 index 0000000000..d11f807beb --- /dev/null +++ b/core/crates/swapper/src/swapper_trait.rs @@ -0,0 +1,46 @@ +use super::{ + SwapperProviderMode, SwapperQuoteData, + cross_chain::VaultAddresses, + error::SwapperError, + models::{FetchQuoteData, Permit2ApprovalData, ProviderType, Quote, QuoteRequest, SwapResult, SwapperChainAsset}, +}; +use async_trait::async_trait; +use std::fmt::Debug; + +use primitives::{Chain, swap::SwapStatus}; + +#[async_trait] +pub trait Swapper: Send + Sync + Debug { + fn provider(&self) -> &ProviderType; + fn supported_assets(&self) -> Vec; + async fn get_quote(&self, request: &QuoteRequest) -> Result; + async fn get_permit2_for_quote(&self, _quote: &Quote) -> Result, SwapperError> { + Ok(None) + } + async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result; + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + Ok(VaultAddresses { deposit: vec![], send: vec![] }) + } + async fn get_swap_result(&self, _chain: Chain, _transaction_hash: &str) -> Result { + if self.provider().mode == SwapperProviderMode::OnChain { + Ok(SwapResult { + status: SwapStatus::Completed, + metadata: None, + }) + } else { + Err(SwapperError::NotSupportedAsset) + } + } +} + +impl dyn Swapper { + pub fn supported_chains(&self) -> Vec { + self.supported_assets() + .into_iter() + .map(|x| match x.clone() { + SwapperChainAsset::All(chain) => chain, + SwapperChainAsset::Assets(chain, _) => chain, + }) + .collect() + } +} diff --git a/core/crates/swapper/src/testkit.rs b/core/crates/swapper/src/testkit.rs new file mode 100644 index 0000000000..4f2ada4019 --- /dev/null +++ b/core/crates/swapper/src/testkit.rs @@ -0,0 +1,148 @@ +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Route, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteAsset, SwapperQuoteData, SwapperSlippage, + SwapperSlippageMode, +}; +use async_trait::async_trait; +use primitives::{AssetId, Chain, asset_constants::TON_USDT_TOKEN_ID}; + +use super::{Options, Quote, QuoteRequest}; + +impl ProviderData { + pub fn mock() -> Self { + ProviderData { + provider: ProviderType::new(SwapperProvider::Okx), + routes: vec![], + slippage_bps: 50, + } + } +} + +impl Route { + pub fn mock(input: AssetId, output: AssetId) -> Self { + Route { + input, + output, + route_data: serde_json::json!({ + "fee_tier": "100", + "min_amount_out": "1", + }) + .to_string(), + } + } +} + +impl Options { + pub fn mock_exact(bps: u32) -> Self { + Self::new_with_slippage(SwapperSlippage::mock_exact(bps)) + } +} + +impl QuoteRequest { + pub fn mock(chain: Chain, token_id: Option<&str>) -> Self { + QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from(chain, token_id.map(|s| s.to_string()))), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(chain)), + wallet_address: "address".to_string(), + destination_address: "address".to_string(), + value: "1000000".to_string(), + options: Options::default(), + } + } +} + +impl Quote { + pub fn mock(chain: Chain, token_id: Option<&str>) -> Self { + Quote { + from_value: "1000000".to_string(), + min_from_value: None, + to_value: "1000000".to_string(), + data: ProviderData::mock(), + request: QuoteRequest::mock(chain, token_id), + eta_in_seconds: None, + } + } + + pub fn mock_with_provider(provider: SwapperProvider, to_value: &str) -> Self { + Quote { + from_value: "1000000".to_string(), + min_from_value: None, + to_value: to_value.to_string(), + data: ProviderData { + provider: ProviderType::new(provider), + routes: vec![], + slippage_bps: 50, + }, + request: QuoteRequest::mock(Chain::Ethereum, None), + eta_in_seconds: None, + } + } +} + +pub fn mock_quote(from_asset: SwapperQuoteAsset, to_asset: SwapperQuoteAsset) -> QuoteRequest { + QuoteRequest { + from_asset, + to_asset, + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "1000000".into(), + options: Options { + slippage: SwapperSlippage { + mode: SwapperSlippageMode::Auto, + bps: 50, + }, + use_max_amount: false, + }, + } +} + +pub fn mock_ton(wallet_address: String) -> QuoteRequest { + QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ton)), + to_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ton, TON_USDT_TOKEN_ID)), + wallet_address: wallet_address.clone(), + destination_address: wallet_address, + value: "1000000000".to_string(), + options: Options { + slippage: 100.into(), + use_max_amount: false, + }, + } +} + +type MockResponse = fn() -> Result; + +#[derive(Debug)] +pub struct MockSwapper { + provider: ProviderType, + supported_assets: Vec, + response: MockResponse, +} + +impl MockSwapper { + pub fn new(provider: SwapperProvider, response: MockResponse) -> Self { + Self { + provider: ProviderType::new(provider), + supported_assets: vec![SwapperChainAsset::All(Chain::Ethereum)], + response, + } + } +} + +#[async_trait] +impl Swapper for MockSwapper { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + self.supported_assets.clone() + } + + async fn get_quote(&self, _request: &QuoteRequest) -> Result { + (self.response)() + } + + async fn get_quote_data(&self, _quote: &Quote, _data: FetchQuoteData) -> Result { + todo!("MockSwapper fetch_quote_data not implemented") + } +} diff --git a/core/crates/swapper/src/thorchain/asset.rs b/core/crates/swapper/src/thorchain/asset.rs new file mode 100644 index 0000000000..c281a6683c --- /dev/null +++ b/core/crates/swapper/src/thorchain/asset.rs @@ -0,0 +1,260 @@ +use std::str::FromStr; +use std::sync::LazyLock; + +use num_bigint::BigInt; +use primitives::{Asset, AssetId, asset_constants::*, known_assets::*}; + +use super::chain::THORChainName; + +const THORCHAIN_DECIMALS: i32 = 8; + +static ASSETS: &[(THORChainName, &str, &LazyLock)] = &[ + (THORChainName::Ethereum, ETHEREUM_USDT_TOKEN_ID, ÐEREUM_USDT), + (THORChainName::Ethereum, ETHEREUM_USDC_TOKEN_ID, ÐEREUM_USDC), + (THORChainName::Ethereum, ETHEREUM_WBTC_TOKEN_ID, ÐEREUM_WBTC), + (THORChainName::Ethereum, ETHEREUM_DAI_TOKEN_ID, ÐEREUM_DAI), + (THORChainName::SmartChain, SMARTCHAIN_USDT_TOKEN_ID, &SMARTCHAIN_USDT), + (THORChainName::SmartChain, SMARTCHAIN_USDC_TOKEN_ID, &SMARTCHAIN_USDC), + (THORChainName::AvalancheC, AVALANCHE_USDT_TOKEN_ID, &AVALANCHE_USDT), + (THORChainName::AvalancheC, AVALANCHE_USDC_TOKEN_ID, &AVALANCHE_USDC), + (THORChainName::Base, BASE_USDC_TOKEN_ID, &BASE_USDC), + (THORChainName::Base, BASE_CBBTC_TOKEN_ID, &BASE_CBBTC), + (THORChainName::Thorchain, THORCHAIN_TCY_TOKEN_ID, &THORCHAIN_TCY), + (THORChainName::Tron, TRON_USDT_TOKEN_ID, &TRON_USDT), +]; + +pub fn value_from(value: &str, decimals: i32) -> BigInt { + let value = BigInt::from_str(value).unwrap_or_default(); + let diff = decimals - THORCHAIN_DECIMALS; + let factor = BigInt::from(10).pow(diff.unsigned_abs()); + if diff > 0 { value / factor } else { value * factor } +} + +pub fn value_to(value: &str, decimals: i32) -> BigInt { + let value = BigInt::from_str(value).unwrap_or_default(); + let diff = decimals - THORCHAIN_DECIMALS; + let factor = BigInt::from(10).pow(diff.unsigned_abs()); + if diff > 0 { value * factor } else { value / factor } +} + +#[derive(Clone, Debug)] +pub struct THORChainAsset { + pub symbol: String, + pub chain: THORChainName, + pub token_id: Option, + pub decimals: u32, +} + +impl THORChainAsset { + pub fn asset_name(&self) -> String { + if self.token_id.is_some() { + format!("{}.{}", self.chain.long_name(), self.symbol) + } else { + self.chain.short_name().to_string() + } + } + + pub fn is_token(&self) -> bool { + self.token_id.is_some() + } + + pub fn use_evm_router(&self) -> bool { + self.is_token() && self.chain.is_evm_chain() + } + + pub fn from_id(asset_id: &AssetId) -> Option { + let chain = THORChainName::from_chain(&asset_id.chain)?; + if let Some(token_id) = &asset_id.token_id { + THORChainAsset::from(chain, token_id) + } else { + let asset = Asset::from_chain(asset_id.chain); + Some(THORChainAsset { + symbol: asset.symbol, + chain, + token_id: None, + decimals: asset.decimals as u32, + }) + } + } + + pub fn from_asset_id(asset_id: &str) -> Option { + THORChainAsset::from_id(&AssetId::new(asset_id)?) + } + + pub fn from(chain: THORChainName, token_id: &str) -> Option { + ASSETS + .iter() + .find(|(c, id, _)| *c == chain && Self::is_same_token_id(&chain, token_id, id)) + .map(|(c, _, asset)| c.asset((**asset).clone())) + } + + fn is_same_token_id(chain: &THORChainName, lhs: &str, rhs: &str) -> bool { + if chain.is_evm_chain() { + chain.checksum_address(lhs) == chain.checksum_address(rhs) + } else { + lhs.eq_ignore_ascii_case(rhs) + } + } + + // https://dev.thorchain.org/concepts/memos.html#swap + pub fn get_memo(&self, destination_address: String, minimum: i64, interval: i64, quantity: i64, fee_address: String, bps: u32) -> Option { + let address = match self.chain { + THORChainName::BitcoinCash => destination_address.strip_prefix("bitcoincash:").unwrap_or(&destination_address), + _ => &destination_address, + }; + Some(format!("=:{}:{}:{}/{}/{}:{}:{}", self.asset_name(), address, minimum, interval, quantity, fee_address, bps)) + } +} + +impl THORChainName { + pub fn asset(&self, asset: Asset) -> THORChainAsset { + THORChainAsset { + symbol: asset.symbol, + chain: self.clone(), + token_id: asset.id.token_id, + decimals: asset.decimals as u32, + } + } +} + +#[cfg(test)] +mod tests { + use primitives::{ + Chain, + asset_constants::{ETHEREUM_USDT_ASSET_ID, THORCHAIN_TCY_ASSET_ID, TRON_USDT_ASSET_ID}, + }; + + use super::*; + + #[test] + fn test_thorchain_name_token() { + let test_cases = vec![ + (ETHEREUM_USDT_TOKEN_ID, THORChainName::Ethereum, "USDT", 6), + (SMARTCHAIN_USDT_TOKEN_ID, THORChainName::SmartChain, "USDT", 18), + ]; + + for (token_id, chain, expected_symbol, expected_decimals) in test_cases { + let asset = THORChainAsset::from(chain, token_id); + assert!(asset.is_some()); + let asset = asset.unwrap(); + assert_eq!(asset.symbol, expected_symbol); + assert_eq!(asset.decimals, expected_decimals); + } + } + + #[test] + fn test_thorchain_asset_name() { + let asset_with_token = THORChainAsset { + symbol: "USDT".to_string(), + chain: THORChainName::Ethereum, + token_id: Some(ETHEREUM_USDT_TOKEN_ID.to_string()), + decimals: 6, + }; + assert_eq!(asset_with_token.asset_name(), "ETH.USDT"); + + let asset_with_token = THORChainAsset { + symbol: "USDT".to_string(), + chain: THORChainName::SmartChain, + token_id: Some(SMARTCHAIN_USDT_TOKEN_ID.to_string()), + decimals: 6, + }; + assert_eq!(asset_with_token.asset_name(), "BSC.USDT"); + + let asset_without_token = THORChainAsset { + symbol: "RUNE".to_string(), + chain: THORChainName::Thorchain, + token_id: None, + decimals: 8, + }; + assert_eq!(asset_without_token.asset_name(), "r"); + + let zcash = THORChainAsset::from_asset_id(Chain::Zcash.as_ref()).unwrap(); + assert_eq!(zcash.asset_name(), "z"); + } + + #[test] + fn test_get_memo() { + let destination_address = "0x1234567890abcdef".to_string(); + let fee_address = "g1".to_string(); + let bps = 50; + + assert_eq!( + THORChainAsset::from_asset_id(Chain::SmartChain.as_ref()) + .unwrap() + .get_memo(destination_address.clone(), 0, 1, 0, fee_address.clone(), bps), + Some("=:s:0x1234567890abcdef:0/1/0:g1:50".into()) + ); + assert_eq!( + THORChainAsset::from_asset_id(Chain::Ethereum.as_ref()) + .unwrap() + .get_memo(destination_address.clone(), 0, 1, 0, fee_address.clone(), bps), + Some("=:e:0x1234567890abcdef:0/1/0:g1:50".into()) + ); + assert_eq!( + THORChainAsset::from_asset_id(Chain::Doge.as_ref()) + .unwrap() + .get_memo(destination_address.clone(), 0, 1, 0, fee_address.clone(), bps), + Some("=:d:0x1234567890abcdef:0/1/0:g1:50".into()) + ); + assert_eq!( + THORChainAsset::from_id(ÐEREUM_USDT_ASSET_ID) + .unwrap() + .get_memo(destination_address.clone(), 0, 1, 0, fee_address.clone(), bps), + Some("=:ETH.USDT:0x1234567890abcdef:0/1/0:g1:50".into()) + ); + assert_eq!( + THORChainAsset::from_asset_id(Chain::BitcoinCash.as_ref()).unwrap().get_memo( + "bitcoincash:qpcns7lget89x9km0t8ry5fk52e8lhl53q0a64gd65".to_string(), + 0, + 1, + 0, + fee_address.clone(), + bps + ), + Some("=:c:qpcns7lget89x9km0t8ry5fk52e8lhl53q0a64gd65:0/1/0:g1:50".into()) + ); + assert_eq!( + THORChainAsset::from_asset_id(&THORCHAIN_TCY_ASSET_ID.to_string()) + .unwrap() + .get_memo(destination_address.clone(), 0, 1, 0, fee_address.clone(), bps), + Some("=:THOR.TCY:0x1234567890abcdef:0/1/0:g1:50".into()) + ); + assert_eq!( + THORChainAsset::from_asset_id(Chain::Zcash.as_ref()) + .unwrap() + .get_memo("t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML".to_string(), 0, 1, 0, fee_address.clone(), bps), + Some("=:z:t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML:0/1/0:g1:50".into()) + ); + } + + #[test] + fn test_value_from() { + assert_eq!(value_from("1000000000000000000", 18), BigInt::from(100000000)); + assert_eq!(value_from("1000000000", 10), BigInt::from(10000000)); + assert_eq!(value_from("1000000000", 6), BigInt::from_str("100000000000").unwrap()); + assert_eq!(value_from("1000000000", 8), BigInt::from(1000000000)); + } + + #[test] + fn test_value_to() { + assert_eq!(value_to("2509674", 18), BigInt::from_str("25096740000000000").unwrap()); + assert_eq!(value_to("10000000", 10), BigInt::from(1000000000)); + assert_eq!(value_to("79158429", 6), BigInt::from(791584)); + assert_eq!(value_to("160661010", 8), BigInt::from(160661010)); + } + + #[test] + fn test_tron_usdt_memo() { + let tron_destination = "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string(); + let fee_address = "g1".to_string(); + let bps = 50; + + let asset = THORChainAsset::from_id(&TRON_USDT_ASSET_ID); + + assert!(asset.is_some(), "TRON USDT asset should be recognized"); + + let memo = asset.unwrap().get_memo(tron_destination.clone(), 0, 1, 0, fee_address.clone(), bps); + + assert_eq!(memo, Some("=:TRON.USDT:TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb:0/1/0:g1:50".into())); + } +} diff --git a/core/crates/swapper/src/thorchain/chain.rs b/core/crates/swapper/src/thorchain/chain.rs new file mode 100644 index 0000000000..74dba68772 --- /dev/null +++ b/core/crates/swapper/src/thorchain/chain.rs @@ -0,0 +1,173 @@ +use gem_evm::address::ethereum_address_checksum; +use primitives::Chain; +use strum::{EnumIter, IntoEnumIterator}; + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter)] +pub enum THORChainName { + Doge, + Thorchain, + Ethereum, + Cosmos, + Bitcoin, + BitcoinCash, + Litecoin, + SmartChain, + AvalancheC, + Base, + Xrp, + Tron, + Solana, + Zcash, +} + +// https://dev.thorchain.org/concepts/memo-length-reduction.html +impl THORChainName { + pub fn short_name(&self) -> &str { + match self { + THORChainName::Doge => "d", // DOGE.DOGE + THORChainName::Thorchain => "r", // THOR.RUNE + THORChainName::Ethereum => "e", // "ETH.ETH" + THORChainName::Cosmos => "g", // GAIA.ATOM + THORChainName::Bitcoin => "b", // BTC.BTC + THORChainName::BitcoinCash => "c", // BCH.BCH + THORChainName::Litecoin => "l", // LTC.LTC + THORChainName::SmartChain => "s", // BSC.BNB + THORChainName::AvalancheC => "a", // AVAX.AVAX + THORChainName::Base => "f", // BASE.ETH + THORChainName::Xrp => "x", // XRP.XRP + THORChainName::Tron => "tr", // TRON.TRX + THORChainName::Solana => "o", // SOL.SOL + THORChainName::Zcash => "z", // ZEC.ZEC + } + } + + pub fn long_name(&self) -> &str { + match self { + THORChainName::Doge => "DOGE", + THORChainName::Thorchain => "THOR", + THORChainName::Ethereum => "ETH", + THORChainName::Cosmos => "GAIA", + THORChainName::Bitcoin => "BTC", + THORChainName::BitcoinCash => "BCH", + THORChainName::Litecoin => "LTC", + THORChainName::SmartChain => "BSC", + THORChainName::AvalancheC => "AVAX", + THORChainName::Base => "BASE", + THORChainName::Xrp => "XRP", + THORChainName::Tron => "TRON", + THORChainName::Solana => "SOL", + THORChainName::Zcash => "ZEC", + } + } + + pub fn chain(&self) -> Chain { + match self { + THORChainName::Doge => Chain::Doge, + THORChainName::Thorchain => Chain::Thorchain, + THORChainName::Ethereum => Chain::Ethereum, + THORChainName::Cosmos => Chain::Cosmos, + THORChainName::Bitcoin => Chain::Bitcoin, + THORChainName::BitcoinCash => Chain::BitcoinCash, + THORChainName::Litecoin => Chain::Litecoin, + THORChainName::SmartChain => Chain::SmartChain, + THORChainName::AvalancheC => Chain::AvalancheC, + THORChainName::Base => Chain::Base, + THORChainName::Xrp => Chain::Xrp, + THORChainName::Tron => Chain::Tron, + THORChainName::Solana => Chain::Solana, + THORChainName::Zcash => Chain::Zcash, + } + } + + pub fn from_chain(chain: &Chain) -> Option { + match chain { + Chain::Thorchain => Some(THORChainName::Thorchain), + Chain::Doge => Some(THORChainName::Doge), + Chain::Cosmos => Some(THORChainName::Cosmos), + Chain::Bitcoin => Some(THORChainName::Bitcoin), + Chain::Litecoin => Some(THORChainName::Litecoin), + Chain::SmartChain => Some(THORChainName::SmartChain), + Chain::Ethereum => Some(THORChainName::Ethereum), + Chain::AvalancheC => Some(THORChainName::AvalancheC), + Chain::BitcoinCash => Some(THORChainName::BitcoinCash), + Chain::Base => Some(THORChainName::Base), + Chain::Xrp => Some(THORChainName::Xrp), + Chain::Tron => Some(THORChainName::Tron), + Chain::Solana => Some(THORChainName::Solana), + Chain::Zcash => Some(THORChainName::Zcash), + _ => None, + } + } + + pub fn is_evm_chain(&self) -> bool { + match self { + THORChainName::Ethereum | THORChainName::SmartChain | THORChainName::AvalancheC | THORChainName::Base => true, + THORChainName::Doge + | THORChainName::Thorchain + | THORChainName::Cosmos + | THORChainName::Bitcoin + | THORChainName::BitcoinCash + | THORChainName::Litecoin + | THORChainName::Xrp + | THORChainName::Tron + | THORChainName::Solana + | THORChainName::Zcash => false, + } + } + + pub fn from_symbol(symbol: &str) -> Option { + THORChainName::iter().find(|variant| variant.long_name() == symbol || variant.short_name() == symbol) + } + + pub fn checksum_address(&self, address: &str) -> String { + if self.is_evm_chain() { + let address = address.strip_prefix("0X").unwrap_or(address); + ethereum_address_checksum(address).unwrap_or(address.to_string()) + } else { + address.to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_symbol() { + // Ensure from_symbol works with all existing long/short names + for variant in THORChainName::iter() { + // Test that long names can be parsed back + assert_eq!( + THORChainName::from_symbol(variant.long_name()), + Some(variant.clone()), + "Failed to parse long name: {}", + variant.long_name() + ); + + // Test that short names can be parsed back + assert_eq!( + THORChainName::from_symbol(variant.short_name()), + Some(variant.clone()), + "Failed to parse short name: {}", + variant.short_name() + ); + } + } + + #[test] + fn test_zcash_mapping() { + assert_eq!(THORChainName::Zcash.short_name(), "z"); + assert_eq!(THORChainName::Zcash.long_name(), "ZEC"); + assert_eq!(THORChainName::Zcash.chain(), Chain::Zcash); + assert_eq!(THORChainName::from_chain(&Chain::Zcash), Some(THORChainName::Zcash)); + assert_eq!(THORChainName::from_symbol("ZEC"), Some(THORChainName::Zcash)); + assert_eq!(THORChainName::from_symbol("z"), Some(THORChainName::Zcash)); + } + + #[test] + fn test_checksum_address_preserves_non_evm_case() { + let zcash = "t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML"; + assert_eq!(THORChainName::Zcash.checksum_address(zcash), zcash); + } +} diff --git a/core/crates/swapper/src/thorchain/client.rs b/core/crates/swapper/src/thorchain/client.rs new file mode 100644 index 0000000000..3a6212a866 --- /dev/null +++ b/core/crates/swapper/src/thorchain/client.rs @@ -0,0 +1,68 @@ +use super::{ + asset::THORChainAsset, + model::{AsgardVault, InboundAddress, QuoteSwapRequest, QuoteSwapResponse, TransactionStatus}, +}; +use crate::{SwapperError, cache_headers}; +use gem_client::{Client, ClientExt}; +use primitives::duration::MINUTE; +use serde_urlencoded; +use std::fmt::Debug; + +const INBOUND_ADDRESS_CACHE_TTL_SECONDS: u64 = 10 * MINUTE.as_secs(); + +#[derive(Clone, Debug)] +pub struct ThorChainSwapClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + client: C, +} + +impl ThorChainSwapClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_quote( + &self, + from_asset: THORChainAsset, + to_asset: THORChainAsset, + value: String, + streaming_interval: i64, + streaming_quantity: i64, + affiliate: String, + affiliate_bps: i64, + ) -> Result { + let params = QuoteSwapRequest { + from_asset: from_asset.asset_name(), + to_asset: to_asset.asset_name(), + amount: value, + affiliate, + affiliate_bps, + streaming_interval, + streaming_quantity, + }; + let query = serde_urlencoded::to_string(params).map_err(SwapperError::from)?; + let path = format!("/thorchain/quote/swap?{query}"); + self.client.get(&path).await.map_err(SwapperError::from) + } + + pub async fn get_inbound_addresses(&self) -> Result, SwapperError> { + self.client + .get_with_headers("/thorchain/inbound_addresses", cache_headers(INBOUND_ADDRESS_CACHE_TTL_SECONDS)) + .await + .map_err(SwapperError::from) + } + + pub async fn get_asgard_vaults(&self) -> Result, SwapperError> { + self.client.get("/thorchain/vaults/asgard").await.map_err(SwapperError::from) + } + + pub async fn get_transaction_status(&self, hash: &str) -> Result { + let path = format!("/thorchain/tx/status/{hash}"); + self.client.get(&path).await.map_err(SwapperError::from) + } +} diff --git a/core/crates/swapper/src/thorchain/constants.rs b/core/crates/swapper/src/thorchain/constants.rs new file mode 100644 index 0000000000..59d9e1b3d3 --- /dev/null +++ b/core/crates/swapper/src/thorchain/constants.rs @@ -0,0 +1,2 @@ +pub const THORCHAIN_INBOUND_ADDRESS: &str = "thor1v8ppstuf6e3x0r4glqc68d5jqcs2tf38cg2q6y"; +pub const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; diff --git a/core/crates/swapper/src/thorchain/memo.rs b/core/crates/swapper/src/thorchain/memo.rs new file mode 100644 index 0000000000..8a8a30a720 --- /dev/null +++ b/core/crates/swapper/src/thorchain/memo.rs @@ -0,0 +1,128 @@ +use super::chain::THORChainName; +use primitives::Chain; + +#[derive(Debug, Clone, PartialEq)] +pub struct ThorchainMemo { + pub tx_type: String, + pub asset: String, + pub address: String, +} + +impl ThorchainMemo { + pub fn parse(memo: &str) -> Option { + if memo.is_empty() { + return None; + } + + let parts: Vec<&str> = memo.split(':').collect(); + if parts.len() < 3 { + return None; + } + + Some(ThorchainMemo { + tx_type: parts[0].to_string(), + asset: parts[1].to_string(), + address: parts[2].to_string(), + }) + } + + pub fn is_swap(memo: &str) -> bool { + Self::parse(memo).is_some_and(|m| m.tx_type == "=" || m.tx_type == "s") + } + + pub fn destination_chain(&self) -> Option { + let chain_part = match self.asset.find('.') { + Some(dot_pos) => &self.asset[..dot_pos], + None => &self.asset, + }; + THORChainName::from_symbol(chain_part).map(|n| n.chain()) + } +} + +#[cfg(test)] +impl ThorchainMemo { + pub fn token_symbol(&self) -> Option { + self.asset.find('.').map(|dot_pos| self.asset[dot_pos + 1..].to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_complete_memo() { + let memo = "=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0:-_/ll:0/150"; + let parsed = ThorchainMemo::parse(memo).unwrap(); + + assert_eq!(parsed.tx_type, "="); + assert_eq!(parsed.asset, "ETH.USDT"); + assert_eq!(parsed.address, "0x858734a6353C9921a78fB3c937c8E20Ba6f36902"); + assert_eq!(parsed.destination_chain(), Some(Chain::Ethereum)); + assert_eq!(parsed.token_symbol(), Some("USDT".to_string())); + } + + #[test] + fn test_parse_simple_swap() { + let memo = "=:ETH:0x858734a6353C9921a78fB3c937c8E20Ba6f36902"; + let parsed = ThorchainMemo::parse(memo).unwrap(); + + assert_eq!(parsed.tx_type, "="); + assert_eq!(parsed.asset, "ETH"); + assert_eq!(parsed.address, "0x858734a6353C9921a78fB3c937c8E20Ba6f36902"); + assert_eq!(parsed.destination_chain(), Some(Chain::Ethereum)); + assert_eq!(parsed.token_symbol(), None); + } + + #[test] + fn test_parse_short_names() { + let memo = "=:e:0x858734a6353C9921a78fB3c937c8E20Ba6f36902"; + let parsed = ThorchainMemo::parse(memo).unwrap(); + + assert_eq!(parsed.asset, "e"); + assert_eq!(parsed.destination_chain(), Some(Chain::Ethereum)); + } + + #[test] + fn test_parse_bitcoin_memo() { + let memo = "=:BTC:bc1qaddress:0/1/0:affiliate:150"; + let parsed = ThorchainMemo::parse(memo).unwrap(); + + assert_eq!(parsed.destination_chain(), Some(Chain::Bitcoin)); + assert_eq!(parsed.token_symbol(), None); + } + + #[test] + fn test_parse_bsc_token() { + let memo = "=:BSC.USDT:0x123:0/1/0:affiliate:100"; + let parsed = ThorchainMemo::parse(memo).unwrap(); + + assert_eq!(parsed.destination_chain(), Some(Chain::SmartChain)); + assert_eq!(parsed.token_symbol(), Some("USDT".to_string())); + } + + #[test] + fn test_is_swap() { + assert!(ThorchainMemo::is_swap("=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0")); + assert!(ThorchainMemo::is_swap("s:ETH:0x858734a6353C9921a78fB3c937c8E20Ba6f36902")); + assert!(ThorchainMemo::is_swap("=:BTC:bc1qaddress:0/1/0:affiliate:150")); + assert!(!ThorchainMemo::is_swap("")); + assert!(!ThorchainMemo::is_swap("WITHDRAW:ETH.ETH:100")); + assert!(!ThorchainMemo::is_swap("ADD:ETH.ETH:0x123")); + } + + #[test] + fn test_parse_invalid_memos() { + assert!(ThorchainMemo::parse("").is_none()); + assert!(ThorchainMemo::parse("invalid").is_none()); + assert!(ThorchainMemo::parse("=:").is_none()); + assert!(ThorchainMemo::parse("=:ETH").is_none()); + } + + #[test] + fn test_parse_unknown_chain() { + let memo = "=:UNKNOWN.TOKEN:0x123"; + let parsed = ThorchainMemo::parse(memo).unwrap(); + assert_eq!(parsed.destination_chain(), None); + } +} diff --git a/core/crates/swapper/src/thorchain/mod.rs b/core/crates/swapper/src/thorchain/mod.rs new file mode 100644 index 0000000000..b97f7aaf13 --- /dev/null +++ b/core/crates/swapper/src/thorchain/mod.rs @@ -0,0 +1,105 @@ +mod asset; +mod chain; +pub mod client; +mod constants; +pub mod memo; +pub mod model; +mod provider; +mod quote_data_mapper; +mod swap_mapper; + +use primitives::Chain; +use std::sync::Arc; + +use crate::alien::RpcProvider; +use asset::value_to; +use gem_client::Client; + +use super::{ProviderType, SwapperError, SwapperProvider}; + +const QUOTE_MINIMUM: i64 = 0; +const QUOTE_INTERVAL: i64 = 1; +const QUOTE_QUANTITY: i64 = 0; +const DUST_THRESHOLD_MULTIPLIER: i64 = 2; +const OUTBOUND_DELAY_SECONDS: u32 = 60; + +// FIXME: estimate gas limit with memo x bytes +const DEFAULT_DEPOSIT_GAS_LIMIT: u64 = 90_000; + +#[derive(Debug)] +pub struct ThorChain +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + pub provider: ProviderType, + pub rpc_provider: Arc, + pub(crate) client: client::ThorChainSwapClient, +} + +impl ThorChain +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + pub fn with_client(swap_client: client::ThorChainSwapClient, rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Thorchain), + rpc_provider, + client: swap_client, + } + } + + fn get_eta_in_seconds(&self, destination_chain: Chain, total_swap_seconds: Option) -> u32 { + destination_chain.block_time() / 1000 + OUTBOUND_DELAY_SECONDS + total_swap_seconds.unwrap_or(0) + } + + fn map_quote_error(&self, error: SwapperError, decimals: i32) -> SwapperError { + match error { + SwapperError::InputAmountError { min_amount: Some(min) } => SwapperError::InputAmountError { + min_amount: Some(value_to(&min, decimals).to_string()), + }, + other => other, + } + } +} + +#[cfg(all(test, feature = "reqwest_provider"))] +mod tests { + use super::*; + use crate::alien::reqwest_provider::NativeProvider; + use std::sync::Arc; + + #[test] + fn test_get_eta_in_seconds() { + let thorchain = ThorChain::new(Arc::new(NativeProvider::default())); + + assert_eq!(thorchain.get_eta_in_seconds(Chain::Bitcoin, None), 660); + assert_eq!(thorchain.get_eta_in_seconds(Chain::Bitcoin, Some(1200)), 1860); + assert_eq!(thorchain.get_eta_in_seconds(Chain::SmartChain, Some(648)), 709); + } + + #[test] + fn test_map_quote_error() { + let thorchain = ThorChain::new(Arc::new(NativeProvider::default())); + + let cases = vec![(18, "6614750000000000"), (8, "661475"), (6, "6614")]; + + for (decimals, expected) in cases { + let error = SwapperError::InputAmountError { + min_amount: Some("661475".to_string()), + }; + let result = thorchain.map_quote_error(error, decimals); + assert_eq!( + result, + SwapperError::InputAmountError { + min_amount: Some(expected.to_string()) + } + ); + } + + let error = SwapperError::InputAmountError { min_amount: None }; + assert_eq!(thorchain.map_quote_error(error, 18), SwapperError::InputAmountError { min_amount: None }); + + let error = SwapperError::NotSupportedAsset; + assert_eq!(thorchain.map_quote_error(error, 18), SwapperError::NotSupportedAsset); + } +} diff --git a/core/crates/swapper/src/thorchain/model.rs b/core/crates/swapper/src/thorchain/model.rs new file mode 100644 index 0000000000..8da13b8407 --- /dev/null +++ b/core/crates/swapper/src/thorchain/model.rs @@ -0,0 +1,384 @@ +use num_bigint::BigInt; +use primitives::{Asset, AssetId, Chain, swap::SwapStatus}; +use serde::{Deserialize, Serialize}; +use serde_serializers::deserialize_bigint_from_str; + +use super::{ + asset::{THORChainAsset, value_to}, + chain::THORChainName, + constants::{THORCHAIN_INBOUND_ADDRESS, ZERO_HASH}, +}; +use crate::SwapperError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuoteSwapRequest { + pub from_asset: String, + pub to_asset: String, + pub amount: String, + pub affiliate: String, + pub affiliate_bps: i64, + pub streaming_interval: i64, + pub streaming_quantity: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuoteSwapResponse { + pub expected_amount_out: String, + pub inbound_address: Option, + pub router: Option, + pub fees: QuoteFees, + pub total_swap_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuoteFees {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionStatus { + pub tx: Option, + pub stages: TransactionStages, + pub planned_out_txs: Option>, + pub out_txs: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlannedOutTx { + pub chain: String, + pub coin: TransactionCoin, + pub refund: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionStatusTx { + pub chain: String, + pub memo: String, + pub coins: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionCoin { + pub asset: String, + pub amount: String, + pub decimals: Option, +} + +impl TransactionCoin { + pub fn native_value(&self, chain: Chain) -> Option { + let decimals = self + .decimals + .or_else(|| if self.is_native_asset() { Some(Asset::from_chain(chain).decimals) } else { None })?; + Some(value_to(&self.amount, decimals).to_string()) + } + + pub fn resolve_asset_id(&self) -> Option { + let (chain_str, rest) = self.asset.split_once('.')?; + let chain_name = THORChainName::from_symbol(chain_str)?; + let chain = chain_name.chain(); + let key = rest.split_once('-').map_or(rest, |(_, addr)| addr); + match THORChainAsset::from(chain_name, key).and_then(|a| a.token_id) { + Some(token_id) => Some(AssetId::from_token(chain, &token_id)), + None if self.is_native_asset() => Some(AssetId::from_chain(chain)), + None => None, + } + } + + fn is_native_asset(&self) -> bool { + !self.asset.contains('-') + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionStages { + pub swap_status: Option, + pub outbound_signed: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionSwapStage { + pub pending: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionCompletionStage { + pub completed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionStatusOutTx { + pub id: String, + pub chain: String, + pub coins: Vec, +} + +impl TransactionStatus { + pub fn swap_status(&self) -> SwapStatus { + let has_output = self.out_txs.as_ref().is_some_and(|txs| !txs.is_empty()); + let swap_done = self.stages.swap_status.as_ref().is_some_and(|s| !s.pending); + let outbound_done = self.stages.outbound_signed.as_ref().is_none_or(|s| s.completed); + + if swap_done && has_output && outbound_done { + SwapStatus::Completed + } else { + SwapStatus::Pending + } + } + + pub fn destination_coin(&self) -> Option<&TransactionCoin> { + let real_out = self + .out_txs + .as_ref() + .and_then(|txs| txs.iter().find(|x| x.id != ZERO_HASH && !x.id.is_empty())) + .and_then(|tx| tx.coins.first()); + if real_out.is_some() { + return real_out; + } + let planned = self.planned_out_txs.as_ref().and_then(|txs| txs.iter().find(|t| !t.refund)).map(|t| &t.coin); + if planned.is_some() { + return planned; + } + self.out_txs.as_ref().and_then(|txs| txs.first()).and_then(|tx| tx.coins.first()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RouteData { + pub router_address: Option, + pub inbound_address: String, +} + +impl RouteData { + pub fn get_inbound_address(from_asset: &THORChainAsset, quote_inbound_address: Option) -> Result { + if from_asset.chain == THORChainName::Thorchain { + Ok(THORCHAIN_INBOUND_ADDRESS.to_string()) + } else { + quote_inbound_address.ok_or(SwapperError::InvalidRoute) + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct InboundAddress { + pub chain: String, + pub address: String, + pub router: Option, + #[serde(deserialize_with = "deserialize_bigint_from_str")] + pub dust_threshold: BigInt, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AsgardVault { + pub addresses: Vec, + pub routers: Vec, +} + +impl AsgardVault { + pub fn all_addresses(vaults: &[AsgardVault]) -> Vec { + vaults + .iter() + .flat_map(|vault| { + let addrs = vault.addresses.iter().filter_map(|a| { + let chain = THORChainName::from_symbol(&a.chain)?; + Some(chain.checksum_address(&a.address)) + }); + let routers = vault.routers.iter().filter_map(|r| { + let chain = THORChainName::from_symbol(&r.chain)?; + Some(chain.checksum_address(&r.router)) + }); + addrs.chain(routers) + }) + .collect() + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct VaultAddress { + pub chain: String, + pub address: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct VaultRouter { + pub chain: String, + pub router: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ErrorResponse { + pub message: String, +} + +impl ErrorResponse { + const MIN_AMOUNT_PREFIX: &str = "recommended_min_amount_in: "; + const DUST_THRESHOLD_MSG: &str = "amount less than dust threshold"; + const MIN_SWAP_AMOUNT_MSG: &str = "amount less than min swap amount"; + + pub fn is_input_amount_error(&self) -> bool { + self.message.contains(Self::DUST_THRESHOLD_MSG) || self.message.contains(Self::MIN_SWAP_AMOUNT_MSG) + } + + pub fn parse_min_amount(&self) -> Option { + self.message + .find(Self::MIN_AMOUNT_PREFIX) + .map(|start| self.message[start + Self::MIN_AMOUNT_PREFIX.len()..].chars().take_while(|c| c.is_ascii_digit()).collect()) + .filter(|s: &String| !s.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::asset_constants::{ETHEREUM_USDT_ASSET_ID, ETHEREUM_USDT_TOKEN_ID, THORCHAIN_TCY_ASSET_ID, TRON_USDT_ASSET_ID, TRON_USDT_TOKEN_ID}; + + #[test] + fn test_tx_status_completed_ltc_to_tron() { + let status: TransactionStatus = serde_json::from_str(include_str!("testdata/tx_status_ltc_to_tron_usdt.json")).unwrap(); + assert_eq!(status.swap_status(), SwapStatus::Completed); + assert_eq!(status.destination_coin().unwrap().amount, "7915842900"); + } + + #[test] + fn test_tx_status_pending_btc_to_tron() { + let status: TransactionStatus = serde_json::from_str(include_str!("testdata/tx_status_btc_to_tron_pending.json")).unwrap(); + assert_eq!(status.swap_status(), SwapStatus::Pending); + assert!(status.destination_coin().is_none()); + } + + #[test] + fn test_tx_status_completed_tcy_to_eth_usdt() { + let status: TransactionStatus = serde_json::from_str(include_str!("testdata/tx_status_tcy_to_eth_usdt.json")).unwrap(); + assert_eq!(status.swap_status(), SwapStatus::Completed); + assert_eq!(status.destination_coin().unwrap().amount, "380962656200"); + } + + #[test] + fn test_get_inbound_address_thorchain() { + let from_asset = THORChainAsset::from_asset_id("thorchain").unwrap(); + let result = RouteData::get_inbound_address(&from_asset, None); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), THORCHAIN_INBOUND_ADDRESS); + } + + #[test] + fn test_get_inbound_address_other_chain() { + let from_asset = THORChainAsset::from_asset_id("ethereum").unwrap(); + let quote_address = "0x1234567890abcdef".to_string(); + let result = RouteData::get_inbound_address(&from_asset, Some(quote_address.clone())); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), quote_address); + } + + #[test] + fn test_native_value() { + let native = TransactionCoin { + asset: "LTC.LTC".to_string(), + amount: "160661010".to_string(), + decimals: None, + }; + assert_eq!(native.native_value(Chain::Litecoin), Some("160661010".to_string())); + + let native_18 = TransactionCoin { + asset: "ETH.ETH".to_string(), + amount: "2509674".to_string(), + decimals: None, + }; + assert_eq!(native_18.native_value(Chain::Ethereum), Some("25096740000000000".to_string())); + + let token_with_decimals = TransactionCoin { + asset: format!("ETH.USDT-{ETHEREUM_USDT_TOKEN_ID}"), + amount: "380962656200".to_string(), + decimals: Some(6), + }; + assert_eq!(token_with_decimals.native_value(Chain::Ethereum), Some("3809626562".to_string())); + + let token_no_decimals = TransactionCoin { + asset: format!("ETH.USDT-{ETHEREUM_USDT_TOKEN_ID}"), + amount: "380962656200".to_string(), + decimals: None, + }; + assert_eq!(token_no_decimals.native_value(Chain::Ethereum), None); + } + + #[test] + fn test_tx_status_completed_eth_usdt_to_rune() { + let status: TransactionStatus = serde_json::from_str(include_str!("testdata/tx_status_eth_usdt_to_rune.json")).unwrap(); + assert_eq!(status.swap_status(), SwapStatus::Completed); + assert_eq!(status.destination_coin().unwrap().amount, "2096315169517"); + } + + #[test] + fn test_tx_status_completed_bnb_to_tron() { + let status: TransactionStatus = serde_json::from_str(include_str!("testdata/tx_status_bnb_to_tron.json")).unwrap(); + assert_eq!(status.swap_status(), SwapStatus::Completed); + assert_eq!(status.destination_coin().unwrap().amount, "4307055600"); + } + + #[test] + fn test_tx_status_pending_outbound_not_signed() { + let status: TransactionStatus = serde_json::from_str(include_str!("testdata/tx_status_bnb_to_tron_pending.json")).unwrap(); + assert_eq!(status.swap_status(), SwapStatus::Pending); + } + + #[test] + fn test_tx_status_not_observed() { + let json = r#"{"stages":{"inbound_observed":{"started":false,"final_count":0,"completed":false}}}"#; + let status: TransactionStatus = serde_json::from_str(json).unwrap(); + + assert_eq!(status.swap_status(), SwapStatus::Pending); + assert!(status.tx.is_none()); + assert!(status.out_txs.is_none()); + } + + #[test] + fn test_resolve_asset_id() { + fn coin(asset: &str) -> TransactionCoin { + TransactionCoin { + asset: asset.to_string(), + amount: "0".to_string(), + decimals: None, + } + } + + assert_eq!(coin("LTC.LTC").resolve_asset_id(), Some(Chain::Litecoin.as_asset_id())); + assert_eq!(coin("ETH.ETH").resolve_asset_id(), Some(Chain::Ethereum.as_asset_id())); + assert_eq!(coin("BTC.BTC").resolve_asset_id(), Some(Chain::Bitcoin.as_asset_id())); + assert_eq!(coin("THOR.RUNE").resolve_asset_id(), Some(Chain::Thorchain.as_asset_id())); + assert_eq!(coin("ZEC.ZEC").resolve_asset_id(), Some(Chain::Zcash.as_asset_id())); + assert_eq!( + coin("ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7").resolve_asset_id(), + Some(ETHEREUM_USDT_ASSET_ID.clone()) + ); + assert_eq!(coin(&format!("TRON.USDT-{TRON_USDT_TOKEN_ID}")).resolve_asset_id(), Some(TRON_USDT_ASSET_ID.clone())); + assert_eq!(coin("THOR.TCY").resolve_asset_id(), Some(THORCHAIN_TCY_ASSET_ID.clone())); + assert_eq!(coin("ETH.UNKNOWN-0x1234567890abcdef1234567890abcdef12345678").resolve_asset_id(), None); + assert_eq!(coin("INVALID").resolve_asset_id(), None); + } + + #[test] + fn test_asgard_vaults_deserialization() { + let vaults: Vec = serde_json::from_str(include_str!("testdata/asgard_vaults.json")).unwrap(); + assert_eq!(vaults.len(), 2); + assert_eq!(vaults[0].addresses.len(), 12); + assert_eq!(vaults[0].routers.len(), 4); + assert_eq!(vaults[0].addresses[0].chain, "BTC"); + assert_eq!(vaults[0].routers[0].chain, "ETH"); + assert_eq!(vaults[1].addresses.len(), 3); + assert_eq!(vaults[1].routers.len(), 1); + } + + #[test] + fn test_error_response() { + let error = ErrorResponse { + message: "amount less than min swap amount (recommended_min_amount_in: 50570): invalid request".into(), + }; + assert!(error.is_input_amount_error()); + assert_eq!(error.parse_min_amount(), Some("50570".into())); + + let error = ErrorResponse { + message: "amount less than dust threshold: invalid request".into(), + }; + assert!(error.is_input_amount_error()); + assert_eq!(error.parse_min_amount(), None); + } +} diff --git a/core/crates/swapper/src/thorchain/provider.rs b/core/crates/swapper/src/thorchain/provider.rs new file mode 100644 index 0000000000..b0695aa132 --- /dev/null +++ b/core/crates/swapper/src/thorchain/provider.rs @@ -0,0 +1,368 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use alloy_primitives::U256; +use async_trait::async_trait; +use gem_client::Client; +use primitives::{Chain, known_assets::*, swap::ApprovalData}; + +use num_bigint::BigInt; + +use super::{ + DUST_THRESHOLD_MULTIPLIER, QUOTE_INTERVAL, QUOTE_MINIMUM, QUOTE_QUANTITY, ThorChain, + asset::{THORChainAsset, value_to}, + chain::THORChainName, + model::{AsgardVault, RouteData}, + quote_data_mapper, swap_mapper, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperQuoteData, + approval::check_approval_erc20, + cross_chain::VaultAddresses, + fees::{default_referral_fees, quote_value_after_reserve_by_chain}, + thorchain::client::ThorChainSwapClient, +}; + +pub struct ThorchainCrossChain; + +impl ThorchainCrossChain { + fn router_address(chain: &Chain) -> Option<&'static str> { + match chain { + Chain::Ethereum => Some("0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146"), + Chain::SmartChain => Some("0xb30eC53F98ff5947EDe720D32aC2da7e52A5f56b"), + Chain::AvalancheC => Some("0x8F66c4AE756BEbC49Ec8B81966DD8bba9f127549"), + Chain::Base => Some("0x68208D99746b805a1Ae41421950A47b711E35681"), + _ => None, + } + } + + pub fn static_router_addresses() -> Vec<&'static str> { + Chain::all().iter().filter_map(|chain| Self::router_address(chain)).collect() + } +} + +impl ThorChain { + pub fn new(rpc_provider: Arc) -> Self { + let endpoint = rpc_provider.get_endpoint(Chain::Thorchain).expect("Failed to get Thorchain endpoint"); + let swap_client = ThorChainSwapClient::new(RpcClient::new(endpoint, rpc_provider.clone())); + Self::with_client(swap_client, rpc_provider) + } +} + +fn quote_input_value(from_asset: &THORChainAsset, request: &QuoteRequest) -> Result { + if from_asset.use_evm_router() || from_asset.chain.is_evm_chain() { + return quote_value_after_reserve_by_chain(request); + } + Ok(request.value.clone()) +} + +#[async_trait] +impl Swapper for ThorChain +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + Chain::all() + .into_iter() + .filter_map(|chain| THORChainName::from_chain(&chain).map(|name| name.chain())) + .collect::>() + .into_iter() + .map(|chain| match chain { + Chain::Ethereum => SwapperChainAsset::Assets( + chain, + vec![ETHEREUM_USDT.id.clone(), ETHEREUM_USDC.id.clone(), ETHEREUM_DAI.id.clone(), ETHEREUM_WBTC.id.clone()], + ), + Chain::Thorchain => SwapperChainAsset::Assets(chain, vec![THORCHAIN_TCY.id.clone()]), + Chain::SmartChain => SwapperChainAsset::Assets(chain, vec![SMARTCHAIN_USDT.id.clone(), SMARTCHAIN_USDC.id.clone()]), + Chain::AvalancheC => SwapperChainAsset::Assets(chain, vec![AVALANCHE_USDT.id.clone(), AVALANCHE_USDC.id.clone()]), + Chain::Base => SwapperChainAsset::Assets(chain, vec![BASE_USDC.id.clone(), BASE_CBBTC.id.clone()]), + Chain::Tron => SwapperChainAsset::Assets(chain, vec![TRON_USDT.id.clone()]), + _ => SwapperChainAsset::Assets(chain, vec![]), + }) + .collect() + } + + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + let vaults = self.client.get_asgard_vaults().await?; + let asgard_addresses: HashSet = AsgardVault::all_addresses(&vaults).into_iter().collect(); + let router_addresses: HashSet = ThorchainCrossChain::static_router_addresses().into_iter().map(String::from).collect(); + + let deposit: Vec = asgard_addresses.union(&router_addresses).cloned().collect(); + let send: Vec = asgard_addresses.into_iter().collect(); + + Ok(VaultAddresses { deposit, send }) + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_asset = THORChainAsset::from_asset_id(&request.from_asset.id).ok_or(SwapperError::NotSupportedAsset)?; + let to_asset = THORChainAsset::from_asset_id(&request.to_asset.id).ok_or(SwapperError::NotSupportedAsset)?; + + let from_value = quote_input_value(&from_asset, request)?; + let value = super::asset::value_from(&from_value, from_asset.decimals as i32); + + if from_asset.chain != THORChainName::Thorchain { + let inbound_addresses = self.client.get_inbound_addresses().await?; + let from_inbound_address = inbound_addresses + .iter() + .find(|address| address.chain == from_asset.chain.long_name()) + .ok_or(SwapperError::InvalidRoute)?; + + let min_value = min_value(&from_inbound_address.dust_threshold); + if min_value > value { + return Err(SwapperError::InputAmountError { + min_amount: Some(value_to(&min_value.to_string(), from_asset.decimals as i32).to_string()), + }); + } + } + + let fee = default_referral_fees().thorchain; + + let quote = self + .client + .get_quote( + from_asset.clone(), + to_asset.clone(), + value.to_string(), + QUOTE_INTERVAL, + QUOTE_QUANTITY, + fee.address, + fee.bps.into(), + ) + .await + .map_err(|e| self.map_quote_error(e, from_asset.decimals as i32))?; + + let to_value = super::asset::value_to("e.expected_amount_out, to_asset.decimals as i32); + let inbound_address = RouteData::get_inbound_address(&from_asset, quote.inbound_address.clone())?; + let route_data = RouteData { + router_address: quote.router.clone(), + inbound_address, + }; + + let quote = Quote { + from_value, + min_from_value: None, + to_value: to_value.to_string(), + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: serde_json::to_string(&route_data).unwrap_or_default(), + }], + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: Some(self.get_eta_in_seconds(request.to_asset.chain(), quote.total_swap_seconds)), + }; + + Ok(quote) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let fee = default_referral_fees().thorchain; + let from_asset = THORChainAsset::from_asset_id("e.request.from_asset.id).ok_or(SwapperError::NotSupportedAsset)?; + let to_asset = THORChainAsset::from_asset_id("e.request.to_asset.id).ok_or(SwapperError::NotSupportedAsset)?; + + let memo = to_asset + .get_memo( + quote.request.destination_address.clone(), + QUOTE_MINIMUM, + QUOTE_INTERVAL, + QUOTE_QUANTITY, + fee.address, + fee.bps, + ) + .unwrap(); + + let route_data: RouteData = serde_json::from_str("e.data.routes.first().unwrap().route_data).map_err(|_| SwapperError::InvalidRoute)?; + let value = quote.from_value.clone(); + + let approval: Option = { + if from_asset.use_evm_router() { + let router_address = route_data.router_address.clone().ok_or(SwapperError::InvalidRoute)?; + let from_amount: U256 = value.to_string().parse().map_err(SwapperError::from)?; + check_approval_erc20( + quote.request.wallet_address.clone(), + from_asset.token_id.clone().unwrap(), + router_address, + from_amount, + self.rpc_provider.clone(), + &from_asset.chain.chain(), + ) + .await? + .approval_data() + } else { + None + } + }; + + let data = quote_data_mapper::map_quote_data(&from_asset, &route_data, quote.request.from_asset.asset_id().token_id.clone(), value, memo, approval); + + Ok(data) + } + + async fn get_swap_result(&self, _chain: Chain, hash: &str) -> Result { + let hash = hash.strip_prefix("0x").unwrap_or(hash).to_uppercase(); + let response = self.client.get_transaction_status(&hash).await?; + Ok(swap_mapper::map_swap_result(&response)) + } +} + +fn min_value(dust_threshold: &BigInt) -> BigInt { + dust_threshold * DUST_THRESHOLD_MULTIPLIER +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::{Options, SwapperQuoteAsset, alien::mock::ProviderMock}; + + #[test] + fn test_min_value() { + assert_eq!(min_value(&BigInt::from(10000)), BigInt::from(20000)); + assert_eq!(min_value(&BigInt::from(0)), BigInt::from(0)); + assert_eq!(min_value(&BigInt::from(50000)), BigInt::from(100000)); + } + + #[test] + fn test_supported_assets_contains_zcash() { + let provider = Arc::new(ProviderMock::new(String::new())); + let swapper = ThorChain::new(provider); + + let supported = swapper.supported_assets(); + let has_zcash = supported.iter().any(|asset| match asset { + SwapperChainAsset::Assets(chain, _) => *chain == Chain::Zcash, + SwapperChainAsset::All(chain) => *chain == Chain::Zcash, + }); + + assert!(has_zcash); + } + + #[tokio::test] + async fn test_get_quote_data_uses_quote_from_value() { + let provider = Arc::new(ProviderMock::new(String::new())); + let swapper = ThorChain::new(provider); + let route_data = RouteData { + router_address: None, + inbound_address: "t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML".to_string(), + }; + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(Chain::Zcash.as_asset_id()), + to_asset: SwapperQuoteAsset::from(Chain::Bitcoin.as_asset_id()), + wallet_address: "t1sender".to_string(), + destination_address: "bc1qdestination".to_string(), + value: "11000000".to_string(), + options: Options::default(), + }; + let quote = Quote { + from_value: "10000000".to_string(), + min_from_value: None, + to_value: "1".to_string(), + data: ProviderData { + provider: swapper.provider().clone(), + routes: vec![Route { + input: Chain::Zcash.as_asset_id(), + output: Chain::Bitcoin.as_asset_id(), + route_data: serde_json::to_string(&route_data).unwrap(), + }], + slippage_bps: 50, + }, + request, + eta_in_seconds: None, + }; + + let data = swapper.get_quote_data("e, FetchQuoteData::None).await.unwrap(); + + assert_eq!(data.to, "t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML"); + assert_eq!(data.value, "10000000"); + assert_eq!(data.memo, Some("=:b:bc1qdestination:0/1/0:g1:50".to_string())); + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, testkit::mock_quote}; + use primitives::swap::SwapStatus; + use std::sync::Arc; + + #[tokio::test] + async fn test_thorchain_quote_trx_to_bnb() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let swapper = ThorChain::new(provider.clone()); + + let from_asset = SwapperQuoteAsset::from(Chain::Tron.as_asset_id()); + let to_asset = SwapperQuoteAsset::from(Chain::SmartChain.as_asset_id()); + let request = mock_quote(from_asset, to_asset); + + let quote = swapper.get_quote(&request).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert!(quote.eta_in_seconds.is_some()); + assert!(!quote.data.routes.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_thorchain_quote_rune_to_cosmos() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let swapper = ThorChain::new(provider.clone()); + + let from_asset = SwapperQuoteAsset::from(Chain::Thorchain.as_asset_id()); + let to_asset = SwapperQuoteAsset::from(Chain::Cosmos.as_asset_id()); + let mut request = mock_quote(from_asset, to_asset); + request.value = "100000000".to_string(); + + let quote = swapper.get_quote(&request).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert!(quote.eta_in_seconds.is_some()); + assert!(!quote.data.routes.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_thorchain_quote_rejects_below_min_value() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let swapper = ThorChain::new(provider.clone()); + + let from_asset = SwapperQuoteAsset::from(Chain::Xrp.as_asset_id()); + let to_asset = SwapperQuoteAsset::from(Chain::Thorchain.as_asset_id()); + let mut request = mock_quote(from_asset, to_asset); + request.value = "1".to_string(); + + let err = swapper.get_quote(&request).await.expect_err("expected error"); + assert!(matches!(err, SwapperError::InputAmountError { .. })); + + Ok(()) + } + + #[tokio::test] + async fn test_thorchain_get_swap_result() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let swapper = ThorChain::new(provider.clone()); + + let tx_hash = "324c16cf014cceca1b2e1c078417f736c9833197735b71a4e875bbb3b07b2fe4"; + let result = swapper.get_swap_result(Chain::Doge, tx_hash).await?; + + assert_eq!(result.status, SwapStatus::Completed); + + let metadata = result.metadata.unwrap(); + assert_eq!(metadata.from_asset, Chain::Doge.as_asset_id()); + assert!(!metadata.from_value.is_empty()); + assert!(!metadata.to_value.is_empty()); + assert_eq!(metadata.provider.unwrap(), "thorchain"); + + Ok(()) + } +} diff --git a/core/crates/swapper/src/thorchain/quote_data_mapper.rs b/core/crates/swapper/src/thorchain/quote_data_mapper.rs new file mode 100644 index 0000000000..402261381b --- /dev/null +++ b/core/crates/swapper/src/thorchain/quote_data_mapper.rs @@ -0,0 +1,173 @@ +use std::{ + str::FromStr, + time::{SystemTime, UNIX_EPOCH}, +}; + +use alloy_primitives::{Address, U256, hex::encode_prefixed as HexEncode}; +use alloy_sol_types::SolCall; +use gem_evm::thorchain::contracts::RouterInterface; +use num_bigint::BigInt; +use primitives::swap::ApprovalData; + +use super::{DEFAULT_DEPOSIT_GAS_LIMIT, asset::THORChainAsset, model::RouteData}; +use crate::{SwapperQuoteData, approval::get_swap_gas_limit_with_approval}; + +pub fn map_quote_data( + from_asset: &THORChainAsset, + route_data: &RouteData, + token_id: Option, + value: String, + memo: String, + approval: Option, +) -> SwapperQuoteData { + let gas_limit = get_swap_gas_limit_with_approval(&approval, None, DEFAULT_DEPOSIT_GAS_LIMIT); + + if from_asset.use_evm_router() { + let router_address = route_data.router_address.clone().unwrap_or_default(); + let inbound_address = Address::from_str(&route_data.inbound_address).unwrap(); + let token_address = Address::from_str(&token_id.unwrap()).unwrap(); + let amount = U256::from_str(&value).unwrap(); + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 86400; + let expiry = U256::from_str(timestamp.to_string().as_str()).unwrap(); + + let call_data = RouterInterface::depositWithExpiryCall { + inbound_address, + token_address, + amount, + memo: memo.clone(), + expiry, + } + .abi_encode(); + + SwapperQuoteData::new_contract(router_address, BigInt::ZERO.to_string(), HexEncode(call_data), approval, gas_limit) + } else if from_asset.chain.is_evm_chain() { + SwapperQuoteData::new_contract(route_data.inbound_address.clone(), value, HexEncode(memo.as_bytes()), approval, gas_limit) + } else { + SwapperQuoteData::new_tranfer(route_data.inbound_address.clone(), value, Some(memo)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::thorchain::chain::THORChainName; + use primitives::{Chain, asset_constants::ETHEREUM_USDC_TOKEN_ID, swap::ApprovalData}; + + fn asset(chain: Chain, token_id: Option) -> THORChainAsset { + THORChainAsset { + chain: THORChainName::from_chain(&chain).unwrap(), + symbol: "TEST".to_string(), + token_id, + decimals: 18, + } + } + + fn route_data(router: Option, inbound: &str) -> RouteData { + RouteData { + router_address: router, + inbound_address: inbound.to_string(), + } + } + + #[test] + fn evm_router() { + let usdc_eth = ETHEREUM_USDC_TOKEN_ID.to_string(); + let result = map_quote_data( + &asset(Chain::Ethereum, Some(usdc_eth.clone())), + &route_data(Some("0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146".to_string()), "0x1234567890123456789012345678901234567890"), + Some(usdc_eth), + "1000000".to_string(), + "memo".to_string(), + None, + ); + + assert_eq!(result.to, "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146"); + assert_eq!(result.value, "0"); + assert!(result.data.starts_with("0x")); + assert_eq!(result.memo, None); + assert_eq!(result.gas_limit, None); + } + + #[test] + fn evm_native() { + let result = map_quote_data( + &asset(Chain::Ethereum, None), + &route_data(Some("0xrouter".to_string()), "0xinbound"), + None, + "1000".to_string(), + "memo".to_string(), + None, + ); + + assert_eq!(result.to, "0xinbound"); + assert_eq!(result.value, "1000"); + assert_eq!(result.data, "0x6d656d6f"); + assert_eq!(result.memo, None); + assert_eq!(result.gas_limit, None); + } + + #[test] + fn non_evm() { + let result = map_quote_data(&asset(Chain::Bitcoin, None), &route_data(None, "bc1q"), None, "1000".to_string(), "memo".to_string(), None); + + assert_eq!(result.to, "bc1q"); + assert_eq!(result.value, "1000"); + assert_eq!(result.data, ""); + assert_eq!(result.memo, Some("memo".to_string())); + assert_eq!(result.gas_limit, None); + } + + #[test] + fn zcash_native() { + let result = map_quote_data( + &asset(Chain::Zcash, None), + &route_data(None, "t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML"), + None, + "10000000".to_string(), + "=:b:bc1qdestination:0/1/0:g1:50".to_string(), + None, + ); + + assert_eq!(result.to, "t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML"); + assert_eq!(result.value, "10000000"); + assert_eq!(result.data, ""); + assert_eq!(result.memo, Some("=:b:bc1qdestination:0/1/0:g1:50".to_string())); + assert_eq!(result.gas_limit, None); + } + + #[test] + fn evm_router_with_approval() { + let usdc_eth = ETHEREUM_USDC_TOKEN_ID.to_string(); + let approval = Some(ApprovalData::make(&usdc_eth, "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146", "2000", false)); + + let result = map_quote_data( + &asset(Chain::Ethereum, Some(usdc_eth.clone())), + &route_data(Some("0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146".to_string()), "0x1234567890123456789012345678901234567890"), + Some(usdc_eth), + "1000000".to_string(), + "memo".to_string(), + approval.clone(), + ); + + assert_eq!(result.to, "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146"); + assert_eq!(result.value, "0"); + assert_eq!(result.approval, approval); + assert_eq!(result.gas_limit, Some("90000".to_string())); + } + + #[test] + fn evm_native_without_approval() { + let result = map_quote_data( + &asset(Chain::Ethereum, None), + &route_data(Some("0xrouter".to_string()), "0xinbound"), + None, + "1000".to_string(), + "memo".to_string(), + None, + ); + + assert_eq!(result.to, "0xinbound"); + assert_eq!(result.value, "1000"); + assert_eq!(result.gas_limit, None); + } +} diff --git a/core/crates/swapper/src/thorchain/swap_mapper.rs b/core/crates/swapper/src/thorchain/swap_mapper.rs new file mode 100644 index 0000000000..0df276742c --- /dev/null +++ b/core/crates/swapper/src/thorchain/swap_mapper.rs @@ -0,0 +1,198 @@ +use primitives::TransactionSwapMetadata; + +use super::chain::THORChainName; +use super::model::TransactionStatus; +use crate::{SwapResult, SwapperProvider}; + +pub fn map_swap_result(response: &TransactionStatus) -> SwapResult { + let status = response.swap_status(); + + let Some(ref tx) = response.tx else { + return SwapResult { status, metadata: None }; + }; + + let Some(chain) = THORChainName::from_symbol(&tx.chain).map(|n| n.chain()) else { + return SwapResult { status, metadata: None }; + }; + + let from_coin = tx.coins.first(); + let from_asset = from_coin.and_then(|c| c.resolve_asset_id()); + let from_value = from_coin.and_then(|c| c.native_value(chain)); + + let out_coin = response.destination_coin(); + let to_asset = out_coin.and_then(|c| c.resolve_asset_id()); + let to_value = out_coin.and_then(|c| to_asset.as_ref().and_then(|a| c.native_value(a.chain))); + + let metadata = match (from_asset, from_value, to_asset, to_value) { + (Some(from_asset), Some(from_value), Some(to_asset), Some(to_value)) => Some(TransactionSwapMetadata { + from_asset, + from_value, + to_asset, + to_value, + provider: Some(SwapperProvider::Thorchain.as_ref().to_string()), + }), + _ => None, + }; + + SwapResult { status, metadata } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{ + Chain, + asset_constants::{ETHEREUM_USDT_ASSET_ID, THORCHAIN_TCY_ASSET_ID, TRON_USDT_ASSET_ID}, + swap::SwapStatus, + }; + + fn status(json: &str) -> TransactionStatus { + serde_json::from_str(json).unwrap() + } + + #[test] + fn test_map_swap_result_ltc_to_tron_usdt() { + let response = status(include_str!("testdata/tx_status_ltc_to_tron_usdt.json")); + + assert_eq!( + map_swap_result(&response), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: Chain::Litecoin.as_asset_id(), + from_value: "160661010".to_string(), + to_asset: TRON_USDT_ASSET_ID.clone(), + to_value: "79158429".to_string(), + provider: Some("thorchain".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_ltc_to_eth() { + let response = status(include_str!("testdata/tx_status_ltc_to_eth.json")); + + assert_eq!( + map_swap_result(&response), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: Chain::Litecoin.as_asset_id(), + from_value: "5000000".to_string(), + to_asset: Chain::Ethereum.as_asset_id(), + to_value: "1243680000000000".to_string(), + provider: Some("thorchain".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_btc_to_tron_pending() { + let response = status(include_str!("testdata/tx_status_btc_to_tron_pending.json")); + + assert_eq!( + map_swap_result(&response), + SwapResult { + status: SwapStatus::Pending, + metadata: None + } + ); + } + + #[test] + fn test_map_swap_result_bnb_to_tron_pending() { + let response = status(include_str!("testdata/tx_status_bnb_to_tron_pending.json")); + + assert_eq!( + map_swap_result(&response), + SwapResult { + status: SwapStatus::Pending, + metadata: Some(TransactionSwapMetadata { + from_asset: Chain::SmartChain.as_asset_id(), + from_value: "20000000000000000".to_string(), + to_asset: Chain::Tron.as_asset_id(), + to_value: "43070556".to_string(), + provider: Some("thorchain".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_bnb_to_eth_usdt() { + let response = status(include_str!("testdata/tx_status_bnb_to_eth_usdt.json")); + + assert_eq!( + map_swap_result(&response), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: Chain::SmartChain.as_asset_id(), + from_value: "21300000000000000".to_string(), + to_asset: ETHEREUM_USDT_ASSET_ID.clone(), + to_value: "12973781".to_string(), + provider: Some("thorchain".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_bnb_to_tron() { + let response = status(include_str!("testdata/tx_status_bnb_to_tron.json")); + + assert_eq!( + map_swap_result(&response), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: Chain::SmartChain.as_asset_id(), + from_value: "20000000000000000".to_string(), + to_asset: Chain::Tron.as_asset_id(), + to_value: "43070556".to_string(), + provider: Some("thorchain".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_eth_usdt_to_rune() { + let response = status(include_str!("testdata/tx_status_eth_usdt_to_rune.json")); + + assert_eq!( + map_swap_result(&response), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: ETHEREUM_USDT_ASSET_ID.clone(), + from_value: "8366000000".to_string(), + to_asset: Chain::Thorchain.as_asset_id(), + to_value: "2096315169517".to_string(), + provider: Some("thorchain".to_string()), + }), + } + ); + } + + #[test] + fn test_map_swap_result_tcy_to_eth_usdt() { + let response = status(include_str!("testdata/tx_status_tcy_to_eth_usdt.json")); + + assert_eq!( + map_swap_result(&response), + SwapResult { + status: SwapStatus::Completed, + metadata: Some(TransactionSwapMetadata { + from_asset: THORCHAIN_TCY_ASSET_ID.clone(), + from_value: "11921829956942".to_string(), + to_asset: ETHEREUM_USDT_ASSET_ID.clone(), + to_value: "3809626562".to_string(), + provider: Some("thorchain".to_string()), + }), + } + ); + } +} diff --git a/core/crates/swapper/src/thorchain/testdata/asgard_vaults.json b/core/crates/swapper/src/thorchain/testdata/asgard_vaults.json new file mode 100644 index 0000000000..5f460a3d98 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/asgard_vaults.json @@ -0,0 +1,34 @@ +[ + { + "addresses": [ + { "chain": "BTC", "address": "bc1qpvjacsksnqsah43v9klxx9eha0mxuaykr4p6x3" }, + { "chain": "ETH", "address": "0xb30ec53f98ff5947ede720d32ac2da7e52a5f56b" }, + { "chain": "DOGE", "address": "DKcKiAXpFBF6kRDRzitcGigSgzGoEDGSYB" }, + { "chain": "LTC", "address": "ltc1q3kkgaa57scxa8vmymzyretlacnnp2m6dp5ef65" }, + { "chain": "BCH", "address": "qz93s8d9hxhl7jaz69f3ashpwej6vclvnylpk6jqwg" }, + { "chain": "GAIA", "address": "cosmos1exyz7eht2q6p7fptm2d9hyrp5mqjfwcfgfcgqm" }, + { "chain": "AVAX", "address": "0xb30ec53f98ff5947ede720d32ac2da7e52a5f56b" }, + { "chain": "BSC", "address": "0xb30ec53f98ff5947ede720d32ac2da7e52a5f56b" }, + { "chain": "BASE", "address": "0xb30ec53f98ff5947ede720d32ac2da7e52a5f56b" }, + { "chain": "XRP", "address": "rN5RKUMxVLJmp5SVqzTh4EsmJPMhx5CgDi" }, + { "chain": "TRON", "address": "TGLFy29V8jPdpCFG1gKAowg4EKp7kRHprH" }, + { "chain": "SOL", "address": "5n3VkMUZGCzk4Jr8Z6Z2jMadWBj5VjP1XfGK3EJchsi2" } + ], + "routers": [ + { "chain": "ETH", "router": "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146" }, + { "chain": "AVAX", "router": "0x8F66c4AE756BEbC49Ec8B81966DD8bba9f127549" }, + { "chain": "BSC", "router": "0xb30eC53F98ff5947EDe720D32aC2da7e52A5f56b" }, + { "chain": "BASE", "router": "0x68208D99746b805a1Ae41421950A47b711E35681" } + ] + }, + { + "addresses": [ + { "chain": "BTC", "address": "bc1qd4jc4kp9jps56vng9ljhxntw9gqp6e2a7r5f22" }, + { "chain": "ETH", "address": "0xa1f3e7b3e5c1d8f9a0b4c7d6e2f9a3b5c8d1e4f7" }, + { "chain": "DOGE", "address": "DKcKiAXpFBF6kRDRzitcGigSgzGoEDGSYB" } + ], + "routers": [ + { "chain": "ETH", "router": "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146" } + ] + } +] diff --git a/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_eth_usdt.json b/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_eth_usdt.json new file mode 100644 index 0000000000..c379139500 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_eth_usdt.json @@ -0,0 +1,105 @@ +{ + "tx": { + "id": "F87B68976E5EF68C2C8BED031A44566F9977FFC0BE90EAC45C807CC84C56D4C5", + "chain": "BSC", + "from_address": "0xb1dfff6762b9a5edd945e7fa55ee15288e2e294f", + "to_address": "0x54eb90bcd8ab035440647cf290e74394201bb933", + "coins": [ + { + "asset": "BSC.BNB", + "amount": "2130000" + } + ], + "gas": [ + { + "asset": "BSC.BNB", + "amount": "119" + } + ], + "memo": "=:ETH.USDT:0xb1Dfff6762b9A5Edd945e7fA55EE15288e2E294F:0/1/0:g1:50" + }, + "planned_out_txs": [ + { + "chain": "ETH", + "to_address": "0xb1Dfff6762b9A5Edd945e7fA55EE15288e2E294F", + "coin": { + "asset": "ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7", + "amount": "1297378100" + }, + "refund": false + }, + { + "chain": "THOR", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coin": { + "asset": "THOR.RUNE", + "amount": "15742200" + }, + "refund": false + } + ], + "out_txs": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "THOR", + "from_address": "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coins": [ + { + "asset": "THOR.RUNE", + "amount": "15742200" + } + ], + "gas": [ + { + "asset": "THOR.RUNE", + "amount": "2000000" + } + ], + "memo": "OUT:F87B68976E5EF68C2C8BED031A44566F9977FFC0BE90EAC45C807CC84C56D4C5" + }, + { + "id": "2FF066BC2688F4CF099E1FEFD560ABADC731FCF25E2F2FAE432469DFCA7C0DFC", + "chain": "ETH", + "from_address": "0x29c2fdd217ff6ab439bddca4f8c7718689f00235", + "to_address": "0xb1Dfff6762b9A5Edd945e7fA55EE15288e2E294F", + "coins": [ + { + "asset": "ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7", + "amount": "1297378100", + "decimals": 6 + } + ], + "gas": [ + { + "asset": "ETH.ETH", + "amount": "15581" + } + ], + "memo": "OUT:F87B68976E5EF68C2C8BED031A44566F9977FFC0BE90EAC45C807CC84C56D4C5" + } + ], + "stages": { + "inbound_observed": { + "pre_confirmation_count": 29, + "final_count": 103, + "completed": true + }, + "inbound_confirmation_counted": { + "remaining_confirmation_seconds": 0, + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": false + }, + "swap_finalised": { + "completed": true + }, + "outbound_signed": { + "completed": true + } + } +} diff --git a/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_tron.json b/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_tron.json new file mode 100644 index 0000000000..3d81afba45 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_tron.json @@ -0,0 +1,106 @@ +{ + "tx": { + "id": "BB189F94566D89A96092D986DDC51938EED45D15ECE48AB82E38EB9F9073C467", + "chain": "BSC", + "from_address": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "to_address": "0x41dde5173b3394be9e1dd78f69fe933fcb2eb411", + "coins": [ + { + "asset": "BSC.BNB", + "amount": "2000000" + } + ], + "gas": [ + { + "asset": "BSC.BNB", + "amount": "130" + } + ], + "memo": "=:tr:TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb:0/1/0:g1:50" + }, + "planned_out_txs": [ + { + "chain": "TRON", + "to_address": "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb", + "coin": { + "asset": "TRON.TRX", + "amount": "4307055600" + }, + "refund": false + }, + { + "chain": "THOR", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coin": { + "asset": "THOR.RUNE", + "amount": "15555500" + }, + "refund": false + } + ], + "out_txs": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "THOR", + "from_address": "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coins": [ + { + "asset": "THOR.RUNE", + "amount": "15555500" + } + ], + "gas": [ + { + "asset": "THOR.RUNE", + "amount": "2000000" + } + ], + "memo": "OUT:BB189F94566D89A96092D986DDC51938EED45D15ECE48AB82E38EB9F9073C467" + }, + { + "id": "52591A72036B18604C540F3340E151F2BC7EBBD631C51B1250A3D86C74E1EAD7", + "chain": "TRON", + "from_address": "TYoxBLUbihVZL6iXPPYCNWdszjU2d7yuog", + "to_address": "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb", + "coins": [ + { + "asset": "TRON.TRX", + "amount": "4307055600", + "decimals": 6 + } + ], + "gas": [ + { + "asset": "TRON.TRX", + "amount": "133800000", + "decimals": 6 + } + ], + "memo": "OUT:BB189F94566D89A96092D986DDC51938EED45D15ECE48AB82E38EB9F9073C467" + } + ], + "stages": { + "inbound_observed": { + "pre_confirmation_count": 22, + "final_count": 101, + "completed": true + }, + "inbound_confirmation_counted": { + "remaining_confirmation_seconds": 0, + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": false + }, + "swap_finalised": { + "completed": true + }, + "outbound_signed": { + "completed": true + } + } +} diff --git a/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_tron_pending.json b/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_tron_pending.json new file mode 100644 index 0000000000..0cf578f957 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/tx_status_bnb_to_tron_pending.json @@ -0,0 +1,85 @@ +{ + "tx": { + "id": "BB189F94566D89A96092D986DDC51938EED45D15ECE48AB82E38EB9F9073C467", + "chain": "BSC", + "from_address": "0xba4d1d35bce0e8f28e5a3403e7a0b996c5d50ac4", + "to_address": "0x41dde5173b3394be9e1dd78f69fe933fcb2eb411", + "coins": [ + { + "asset": "BSC.BNB", + "amount": "2000000" + } + ], + "gas": [ + { + "asset": "BSC.BNB", + "amount": "130" + } + ], + "memo": "=:tr:TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb:0/1/0:g1:50" + }, + "planned_out_txs": [ + { + "chain": "TRON", + "to_address": "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb", + "coin": { + "asset": "TRON.TRX", + "amount": "4307055600" + }, + "refund": false + }, + { + "chain": "THOR", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coin": { + "asset": "THOR.RUNE", + "amount": "15555500" + }, + "refund": false + } + ], + "out_txs": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "THOR", + "from_address": "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coins": [ + { + "asset": "THOR.RUNE", + "amount": "15555500" + } + ], + "gas": [ + { + "asset": "THOR.RUNE", + "amount": "2000000" + } + ], + "memo": "OUT:BB189F94566D89A96092D986DDC51938EED45D15ECE48AB82E38EB9F9073C467" + } + ], + "stages": { + "inbound_observed": { + "pre_confirmation_count": 22, + "final_count": 101, + "completed": true + }, + "inbound_confirmation_counted": { + "remaining_confirmation_seconds": 0, + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": false + }, + "swap_finalised": { + "completed": true + }, + "outbound_signed": { + "completed": false + } + } +} diff --git a/core/crates/swapper/src/thorchain/testdata/tx_status_btc_to_tron_pending.json b/core/crates/swapper/src/thorchain/testdata/tx_status_btc_to_tron_pending.json new file mode 100644 index 0000000000..9ed06688b5 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/tx_status_btc_to_tron_pending.json @@ -0,0 +1,40 @@ +{ + "tx": { + "id": "2BC884C03438AAD437ABE2C18D97B5A7CAB9312F68C59CECBB3AD5715FE4C15D", + "chain": "BTC", + "from_address": "bc1qatt33ste4xywa97skmt9tmxwnpzrvqszwpm6pu", + "to_address": "bc1q2llgpmptppvuahaz9av983t5dypl9gehewjwud", + "coins": [ + { + "asset": "BTC.BTC", + "amount": "23516479" + } + ], + "gas": [ + { + "asset": "BTC.BTC", + "amount": "830" + } + ], + "memo": "=:TRON.USDT:TMwHWhFuEqSoicouuxgNqy3Z8yTiUoPetA:0/5/0:ej:75" + }, + "stages": { + "inbound_observed": { + "final_count": 103, + "completed": true + }, + "inbound_confirmation_counted": { + "remaining_confirmation_seconds": 0, + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": true + }, + "swap_finalised": { + "completed": false + } + } +} diff --git a/core/crates/swapper/src/thorchain/testdata/tx_status_eth_usdt_to_rune.json b/core/crates/swapper/src/thorchain/testdata/tx_status_eth_usdt_to_rune.json new file mode 100644 index 0000000000..090730d958 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/tx_status_eth_usdt_to_rune.json @@ -0,0 +1,46 @@ +{ + "tx": { + "id": "160BC649AA77D0DC359199E32AFD0A2D9EB60563AB938A1BEAFFA9EEA1C0FC18", + "chain": "ETH", + "memo": "=:r:thor14f64wnrz5lkm8fqtq7z55wf02xjz56htmjs2nf:0/1/0:g1:50", + "coins": [ + { + "asset": "ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7", + "amount": "836600000000", + "decimals": 6 + } + ] + }, + "stages": { + "inbound_observed": { + "pre_confirmation_count": 103, + "final_count": 103, + "completed": true + }, + "inbound_confirmation_counted": { + "remaining_confirmation_seconds": 0, + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": false + }, + "swap_finalised": { + "completed": true + } + }, + "out_txs": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "THOR", + "coins": [ + { + "asset": "THOR.RUNE", + "amount": "2096315169517" + } + ] + } + ] +} diff --git a/core/crates/swapper/src/thorchain/testdata/tx_status_ltc_to_eth.json b/core/crates/swapper/src/thorchain/testdata/tx_status_ltc_to_eth.json new file mode 100644 index 0000000000..33bbc43dc7 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/tx_status_ltc_to_eth.json @@ -0,0 +1,103 @@ +{ + "tx": { + "id": "D62DF92A6A0EEB68411BC0A594CBA178D2AA8BE9239893BE1ECAB0A43BEFE497", + "chain": "LTC", + "from_address": "ltc1qdlwy6k2u0jqyvj4y5sawm6mstlkxkgfxyu5rls", + "to_address": "ltc1qdp0tnjam9zzfevepjdjpgjyrk4t33wrerh2yxp", + "coins": [ + { + "asset": "LTC.LTC", + "amount": "5000000" + } + ], + "gas": [ + { + "asset": "LTC.LTC", + "amount": "1050" + } + ], + "memo": "=:e:0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a:0/1/0:g1:50" + }, + "planned_out_txs": [ + { + "chain": "ETH", + "to_address": "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a", + "coin": { + "asset": "ETH.ETH", + "amount": "124368" + }, + "refund": false + }, + { + "chain": "THOR", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coin": { + "asset": "THOR.RUNE", + "amount": "3352043" + }, + "refund": false + } + ], + "out_txs": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "THOR", + "from_address": "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coins": [ + { + "asset": "THOR.RUNE", + "amount": "3352043" + } + ], + "gas": [ + { + "asset": "THOR.RUNE", + "amount": "2000000" + } + ], + "memo": "OUT:D62DF92A6A0EEB68411BC0A594CBA178D2AA8BE9239893BE1ECAB0A43BEFE497" + }, + { + "id": "68F5EBD1ED3F00118A58C177D403ECF132971D93F0C9E0B1086663F4F99CEDA3", + "chain": "ETH", + "from_address": "0x0cc2aea12d4fd98fa181e0ad6fe912f9b150bbca", + "to_address": "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a", + "coins": [ + { + "asset": "ETH.ETH", + "amount": "124368" + } + ], + "gas": [ + { + "asset": "ETH.ETH", + "amount": "13182" + } + ], + "memo": "OUT:D62DF92A6A0EEB68411BC0A594CBA178D2AA8BE9239893BE1ECAB0A43BEFE497" + } + ], + "stages": { + "inbound_observed": { + "final_count": 98, + "completed": true + }, + "inbound_confirmation_counted": { + "remaining_confirmation_seconds": 0, + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": false + }, + "swap_finalised": { + "completed": true + }, + "outbound_signed": { + "completed": true + } + } +} diff --git a/core/crates/swapper/src/thorchain/testdata/tx_status_ltc_to_tron_usdt.json b/core/crates/swapper/src/thorchain/testdata/tx_status_ltc_to_tron_usdt.json new file mode 100644 index 0000000000..c2b89d9e50 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/tx_status_ltc_to_tron_usdt.json @@ -0,0 +1,105 @@ +{ + "tx": { + "id": "FBEEBA21CB7503AA64809249B08050BD591F1A9AD0C0F94B09D666BC410DED66", + "chain": "LTC", + "from_address": "ltc1qp0esmcpjymjahn3aptwtpf82axyq2g36kzgrng", + "to_address": "ltc1q2w4h6e0pwlucnugce4rshpw3z52kv9etx7fpu5", + "coins": [ + { + "asset": "LTC.LTC", + "amount": "160661010" + } + ], + "gas": [ + { + "asset": "LTC.LTC", + "amount": "1790" + } + ], + "memo": "=:TRON.USDT:TMazs4f2ybMjGf7WXAx4uiRf2T7XtMC6qt:0/1/0:g1:50" + }, + "planned_out_txs": [ + { + "chain": "TRON", + "to_address": "TMazs4f2ybMjGf7WXAx4uiRf2T7XtMC6qt", + "coin": { + "asset": "TRON.USDT-TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T", + "amount": "7915842900" + }, + "refund": false + }, + { + "chain": "THOR", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coin": { + "asset": "THOR.RUNE", + "amount": "107275600" + }, + "refund": false + } + ], + "out_txs": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "THOR", + "from_address": "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coins": [ + { + "asset": "THOR.RUNE", + "amount": "107275600" + } + ], + "gas": [ + { + "asset": "THOR.RUNE", + "amount": "2000000" + } + ], + "memo": "OUT:FBEEBA21CB7503AA64809249B08050BD591F1A9AD0C0F94B09D666BC410DED66" + }, + { + "id": "544827704F9AD53F2D33209F73F7CC39C3AA5068481D87316ED189B322784222", + "chain": "TRON", + "from_address": "TDn2JApQgDumRE6MnKbxS5PyS4wtAzf6Dz", + "to_address": "TMazs4f2ybMjGf7WXAx4uiRf2T7XtMC6qt", + "coins": [ + { + "asset": "TRON.USDT-TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T", + "amount": "7915842900", + "decimals": 6 + } + ], + "gas": [ + { + "asset": "TRON.TRX", + "amount": "1444450000", + "decimals": 6 + } + ], + "memo": "OUT:FBEEBA21CB7503AA64809249B08050BD591F1A9AD0C0F94B09D666BC410DED66" + } + ], + "stages": { + "inbound_observed": { + "final_count": 103, + "completed": true + }, + "inbound_confirmation_counted": { + "remaining_confirmation_seconds": 0, + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": false + }, + "swap_finalised": { + "completed": true + }, + "outbound_signed": { + "completed": true + } + } +} diff --git a/core/crates/swapper/src/thorchain/testdata/tx_status_tcy_to_eth_usdt.json b/core/crates/swapper/src/thorchain/testdata/tx_status_tcy_to_eth_usdt.json new file mode 100644 index 0000000000..fdd86cae35 --- /dev/null +++ b/core/crates/swapper/src/thorchain/testdata/tx_status_tcy_to_eth_usdt.json @@ -0,0 +1,127 @@ +{ + "tx": { + "id": "E58A582CD7EB3A83F32B5B4AF76F6EF9E78250DC06213DB9D5618B184C1CEC48", + "chain": "THOR", + "from_address": "thor104epn66u38fy6lkjyk9p3dcns7mxdfh06qkupl", + "to_address": "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", + "coins": [ + { + "asset": "THOR.TCY", + "amount": "11921829956942" + } + ], + "gas": null, + "memo": "=:ETH.USDT:0x4dbc7e789455390640fb88b80058ef896798203a:1093333440253/1/0:wr:125" + }, + "planned_out_txs": [ + { + "chain": "ETH", + "to_address": "0x4dbc7e789455390640fb88b80058ef896798203a", + "coin": { + "asset": "ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7", + "amount": "380962656200" + }, + "refund": false + }, + { + "chain": "THOR", + "to_address": "thor104epn66u38fy6lkjyk9p3dcns7mxdfh06qkupl", + "coin": { + "asset": "THOR.TCY", + "amount": "7749189472011" + }, + "refund": true + }, + { + "chain": "THOR", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coin": { + "asset": "THOR.RUNE", + "amount": "12636775600" + }, + "refund": false + } + ], + "out_txs": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "THOR", + "from_address": "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", + "to_address": "thor104epn66u38fy6lkjyk9p3dcns7mxdfh06qkupl", + "coins": [ + { + "asset": "THOR.TCY", + "amount": "7749189472011" + } + ], + "gas": [ + { + "asset": "THOR.RUNE", + "amount": "2000000" + } + ], + "memo": "REFUND:E58A582CD7EB3A83F32B5B4AF76F6EF9E78250DC06213DB9D5618B184C1CEC48" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "THOR", + "from_address": "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", + "to_address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "coins": [ + { + "asset": "THOR.RUNE", + "amount": "12636775600" + } + ], + "gas": [ + { + "asset": "THOR.RUNE", + "amount": "2000000" + } + ], + "memo": "OUT:E58A582CD7EB3A83F32B5B4AF76F6EF9E78250DC06213DB9D5618B184C1CEC48" + }, + { + "id": "1D8300FDC5A47ACA3E7D59791180229AE314C86ABA32C14E4975464491865576", + "chain": "ETH", + "from_address": "0x29c2fdd217ff6ab439bddca4f8c7718689f00235", + "to_address": "0x4DbC7e789455390640FB88b80058Ef896798203a", + "coins": [ + { + "asset": "ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7", + "amount": "380962656200", + "decimals": 6 + } + ], + "gas": [ + { + "asset": "ETH.ETH", + "amount": "13005" + } + ], + "memo": "OUT:E58A582CD7EB3A83F32B5B4AF76F6EF9E78250DC06213DB9D5618B184C1CEC48" + } + ], + "stages": { + "inbound_observed": { + "final_count": 0, + "completed": true + }, + "inbound_confirmation_counted": { + "remaining_confirmation_seconds": 0, + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": false + }, + "swap_finalised": { + "completed": true + }, + "outbound_signed": { + "completed": true + } + } +} diff --git a/core/crates/swapper/src/uniswap/deadline.rs b/core/crates/swapper/src/uniswap/deadline.rs new file mode 100644 index 0000000000..4133533abd --- /dev/null +++ b/core/crates/swapper/src/uniswap/deadline.rs @@ -0,0 +1,7 @@ +use primitives::unix_timestamp; + +const DEFAULT_DEADLINE: u64 = 3600; + +pub fn get_sig_deadline() -> u64 { + unix_timestamp() + DEFAULT_DEADLINE +} diff --git a/core/crates/swapper/src/uniswap/default.rs b/core/crates/swapper/src/uniswap/default.rs new file mode 100644 index 0000000000..42e7e13dcd --- /dev/null +++ b/core/crates/swapper/src/uniswap/default.rs @@ -0,0 +1,51 @@ +use super::{universal_router, v3::UniswapV3, v4::UniswapV4}; +use crate::{Swapper, alien::RpcProvider}; +use std::sync::Arc; + +pub fn new_uniswap_v3(rpc_provider: Arc) -> UniswapV3 { + universal_router::new_uniswap_v3(rpc_provider) +} + +pub fn new_pancakeswap(rpc_provider: Arc) -> UniswapV3 { + universal_router::new_pancakeswap(rpc_provider) +} + +pub fn new_aerodrome(rpc_provider: Arc) -> UniswapV3 { + universal_router::new_aerodrome(rpc_provider) +} + +pub fn new_oku(rpc_provider: Arc) -> UniswapV3 { + universal_router::new_oku(rpc_provider) +} + +pub fn new_wagmi(rpc_provider: Arc) -> UniswapV3 { + universal_router::new_wagmi(rpc_provider) +} + +pub fn new_uniswap_v4(rpc_provider: Arc) -> UniswapV4 { + universal_router::new_uniswap_v4(rpc_provider) +} + +pub fn boxed_uniswap_v3(rpc_provider: Arc) -> Box { + Box::new(new_uniswap_v3(rpc_provider)) +} + +pub fn boxed_pancakeswap(rpc_provider: Arc) -> Box { + Box::new(new_pancakeswap(rpc_provider)) +} + +pub fn boxed_aerodrome(rpc_provider: Arc) -> Box { + Box::new(new_aerodrome(rpc_provider)) +} + +pub fn boxed_oku(rpc_provider: Arc) -> Box { + Box::new(new_oku(rpc_provider)) +} + +pub fn boxed_wagmi(rpc_provider: Arc) -> Box { + Box::new(new_wagmi(rpc_provider)) +} + +pub fn boxed_uniswap_v4(rpc_provider: Arc) -> Box { + Box::new(new_uniswap_v4(rpc_provider)) +} diff --git a/core/crates/swapper/src/uniswap/fee_token.rs b/core/crates/swapper/src/uniswap/fee_token.rs new file mode 100644 index 0000000000..7ad2286a7f --- /dev/null +++ b/core/crates/swapper/src/uniswap/fee_token.rs @@ -0,0 +1,73 @@ +use crate::{ + QuoteRequest, + fee_token::{FeeToken, FeeTokenPriority}, + fees::is_stablecoin_symbol, +}; +use alloy_primitives::Address; +use gem_evm::uniswap::path::BasePair; + +fn fee_token_priority(base_pair: Option<&BasePair>, token: &FeeToken) -> FeeTokenPriority { + if base_pair.is_some_and(|pair| token.address == pair.native) { + return FeeTokenPriority::Native; + } + if is_stablecoin_symbol(token.symbol) || base_pair.is_some_and(|pair| pair.stables.contains(&token.address)) { + return FeeTokenPriority::Stable; + } + FeeTokenPriority::Other +} + +pub(crate) fn is_input_fee_token(base_pair: Option<&BasePair>, input: &FeeToken, output: &FeeToken) -> bool { + fee_token_priority(base_pair, input).rank() > fee_token_priority(base_pair, output).rank() +} + +pub(crate) fn is_quote_input_fee_token(base_pair: Option<&BasePair>, request: &QuoteRequest, token_in: Address, token_out: Address) -> bool { + let input = FeeToken::new(token_in, request.from_asset.symbol.as_str()); + let output = FeeToken::new(token_out, request.to_asset.symbol.as_str()); + is_input_fee_token(base_pair, &input, &output) +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_evm::uniswap::path::get_base_pair; + use primitives::{ + EVMChain, + asset_constants::{ETHEREUM_UNI_TOKEN_ID, ETHEREUM_USDC_TOKEN_ID, ETHEREUM_WETH_TOKEN_ID}, + }; + + #[test] + fn test_is_input_fee_token() { + let evm_chain = EVMChain::Ethereum; + let base_pair = get_base_pair(&evm_chain, true); + + let weth = FeeToken::new(ETHEREUM_WETH_TOKEN_ID.parse().unwrap(), "WETH"); + let uni = FeeToken::new(ETHEREUM_UNI_TOKEN_ID.parse().unwrap(), "UNI"); + let usdc = FeeToken::new(ETHEREUM_USDC_TOKEN_ID.parse().unwrap(), "USDC"); + + // WETH -> UNI (fee_token is WETH) + assert!(is_input_fee_token(base_pair.as_ref(), &weth, &uni)); + + // WETH -> USDC (fee_token is WETH) + assert!(is_input_fee_token(base_pair.as_ref(), &weth, &usdc)); + + // USDC -> WETH (fee_token is WETH) + assert!(!is_input_fee_token(base_pair.as_ref(), &usdc, &weth)); + + // USDC -> UNI (fee_token is USDC) + assert!(is_input_fee_token(base_pair.as_ref(), &usdc, &uni)); + } + + #[test] + fn test_is_input_fee_token_uses_stable_symbol() { + let evm_chain = EVMChain::SmartChain; + let v_usdt = FeeToken::new("0xfD5840Cd36d94D7229439859C0112a4185BC0255".parse().unwrap(), "vUSDT"); + let bnb_tiger = FeeToken::new("0xAc68475a88DA0fbAdB73fBF4Cc157EA137dbdC2D".parse().unwrap(), "BNBTiger"); + let base_pair = get_base_pair(&evm_chain, true); + + let native_bnb = FeeToken::new(evm_chain.weth_contract().unwrap().parse().unwrap(), "BNB"); + + assert!(is_input_fee_token(base_pair.as_ref(), &v_usdt, &bnb_tiger)); + + assert!(is_input_fee_token(base_pair.as_ref(), &native_bnb, &v_usdt)); + } +} diff --git a/core/crates/swapper/src/uniswap/mod.rs b/core/crates/swapper/src/uniswap/mod.rs new file mode 100644 index 0000000000..065a7f5d6a --- /dev/null +++ b/core/crates/swapper/src/uniswap/mod.rs @@ -0,0 +1,12 @@ +mod deadline; +mod fee_token; +mod native_asset; +mod quote_result; +mod swap_route; + +pub mod default; +pub mod universal_router; +pub mod v3; +pub mod v4; + +pub(crate) use native_asset::{is_native_erc20, requires_native_wrapping}; diff --git a/core/crates/swapper/src/uniswap/native_asset.rs b/core/crates/swapper/src/uniswap/native_asset.rs new file mode 100644 index 0000000000..69e4270a95 --- /dev/null +++ b/core/crates/swapper/src/uniswap/native_asset.rs @@ -0,0 +1,9 @@ +use primitives::{AssetId, Chain}; + +pub fn requires_native_wrapping(asset_id: &AssetId) -> bool { + asset_id.is_native() && !is_native_erc20(asset_id.chain) +} + +pub fn is_native_erc20(chain: Chain) -> bool { + chain == Chain::Celo +} diff --git a/core/crates/swapper/src/uniswap/quote_result.rs b/core/crates/swapper/src/uniswap/quote_result.rs new file mode 100644 index 0000000000..44edb492ca --- /dev/null +++ b/core/crates/swapper/src/uniswap/quote_result.rs @@ -0,0 +1,39 @@ +use crate::SwapperError; +use alloy_primitives::U256; +use gem_jsonrpc::types::{JsonRpcError, JsonRpcResponse, JsonRpcResult, JsonRpcResults}; + +#[derive(Debug)] +pub struct QuoteResult { + pub amount_out: U256, + pub fee_tier_idx: usize, + pub batch_idx: usize, +} + +pub fn get_best_quote(batch_results: &[Result, JsonRpcError>], decoder: F) -> Result +where + F: Fn(&JsonRpcResponse) -> Result<(U256, U256), SwapperError>, +{ + batch_results + .iter() + .enumerate() + .filter_map(|(batch_idx, batch_result)| { + batch_result.as_ref().ok().map(|results| { + results + .0 + .iter() + .enumerate() + .filter_map(|(fee_idx, result)| match result { + JsonRpcResult::Value(value) => decoder(value).ok().map(|quoter_tuple| QuoteResult { + amount_out: quoter_tuple.0, + fee_tier_idx: fee_idx, + batch_idx, + }), + _ => None, + }) + .max_by_key(|quote| quote.amount_out) + }) + }) + .flatten() + .max_by_key(|quote| quote.amount_out) + .ok_or(SwapperError::NoQuoteAvailable) +} diff --git a/core/crates/swapper/src/uniswap/swap_route.rs b/core/crates/swapper/src/uniswap/swap_route.rs new file mode 100644 index 0000000000..ed11809cef --- /dev/null +++ b/core/crates/swapper/src/uniswap/swap_route.rs @@ -0,0 +1,48 @@ +use crate::Route; +use alloy_primitives::Address; +use gem_evm::uniswap::path::BasePair; +use primitives::AssetId; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RouteData { + pub fee_tier: String, + pub min_amount_out: String, +} + +pub fn get_intermediaries(token_in: &Address, token_out: &Address, base_pair: &BasePair) -> Vec
{ + let array = base_pair.path_building_array(); + get_intermediaries_by_array(token_in, token_out, &array) +} + +pub fn get_intermediaries_by_array(token_in: &Address, token_out: &Address, array: &[Address]) -> Vec
{ + array + .iter() + .filter(|intermediary| *intermediary != token_in && *intermediary != token_out) + .cloned() + .collect() +} + +pub fn build_swap_route(token_in: &AssetId, intermediary: Option<&AssetId>, token_out: &AssetId, route_data: &RouteData) -> Vec { + let data = serde_json::to_string(route_data).unwrap(); + if let Some(intermediary) = intermediary { + vec![ + Route { + input: token_in.clone(), + output: intermediary.clone(), + route_data: data.clone(), + }, + Route { + input: intermediary.clone(), + output: token_out.clone(), + route_data: data, + }, + ] + } else { + vec![Route { + input: token_in.clone(), + output: token_out.clone(), + route_data: data, + }] + } +} diff --git a/core/crates/swapper/src/uniswap/universal_router/mod.rs b/core/crates/swapper/src/uniswap/universal_router/mod.rs new file mode 100644 index 0000000000..32852d6728 --- /dev/null +++ b/core/crates/swapper/src/uniswap/universal_router/mod.rs @@ -0,0 +1,99 @@ +use crate::{ + ProviderType, SwapperProvider, + alien::RpcProvider, + uniswap::{ + v3::{UniswapV3, UniversalRouterProvider}, + v4::UniswapV4, + }, +}; +use gem_evm::uniswap::{ + FeeTier, + deployment::v3::{ + V3Deployment, get_aerodrome_router_deployment_by_chain, get_oku_deployment_by_chain, get_pancakeswap_router_deployment_by_chain, get_uniswap_router_deployment_by_chain, + get_wagmi_router_deployment_by_chain, + }, +}; +use primitives::Chain; +use std::sync::Arc; + +type DeploymentFn = fn(&Chain) -> Option; + +#[derive(Debug)] +struct UniversalRouter { + provider: ProviderType, + tiers: Vec, + deployment_fn: DeploymentFn, +} + +impl UniversalRouter { + fn new(id: SwapperProvider, tiers: Vec, deployment_fn: DeploymentFn) -> Self { + Self { + provider: ProviderType::new(id), + tiers, + deployment_fn, + } + } +} + +impl UniversalRouterProvider for UniversalRouter { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn get_tiers(&self) -> Vec { + self.tiers.clone() + } + + fn get_deployment_by_chain(&self, chain: &Chain) -> Option { + (self.deployment_fn)(chain) + } +} + +pub fn new_uniswap_v3(rpc_provider: Arc) -> UniswapV3 { + let router = UniversalRouter::new( + SwapperProvider::UniswapV3, + vec![FeeTier::Hundred, FeeTier::FiveHundred, FeeTier::ThreeThousand, FeeTier::TenThousand], + get_uniswap_router_deployment_by_chain, + ); + UniswapV3::new(Box::new(router), rpc_provider) +} + +pub fn new_pancakeswap(rpc_provider: Arc) -> UniswapV3 { + let router = UniversalRouter::new( + SwapperProvider::PancakeswapV3, + vec![FeeTier::Hundred, FeeTier::FiveHundred, FeeTier::TwoThousandFiveHundred, FeeTier::TenThousand], + get_pancakeswap_router_deployment_by_chain, + ); + UniswapV3::new(Box::new(router), rpc_provider) +} + +pub fn new_aerodrome(rpc_provider: Arc) -> UniswapV3 { + let router = UniversalRouter::new( + SwapperProvider::Aerodrome, + vec![FeeTier::Hundred, FeeTier::FourHundred, FeeTier::FiveHundred, FeeTier::ThreeThousand, FeeTier::TenThousand], + get_aerodrome_router_deployment_by_chain, + ); + UniswapV3::new(Box::new(router), rpc_provider) +} + +pub fn new_oku(rpc_provider: Arc) -> UniswapV3 { + let router = UniversalRouter::new( + SwapperProvider::Oku, + vec![FeeTier::Hundred, FeeTier::FiveHundred, FeeTier::ThreeThousand, FeeTier::TenThousand], + get_oku_deployment_by_chain, + ); + UniswapV3::new(Box::new(router), rpc_provider) +} + +pub fn new_wagmi(rpc_provider: Arc) -> UniswapV3 { + let router = UniversalRouter::new( + SwapperProvider::Wagmi, + vec![FeeTier::FiveHundred, FeeTier::ThousandFiveHundred, FeeTier::ThreeThousand, FeeTier::TenThousand], + get_wagmi_router_deployment_by_chain, + ); + UniswapV3::new(Box::new(router), rpc_provider) +} + +pub fn new_uniswap_v4(rpc_provider: Arc) -> UniswapV4 { + UniswapV4::new(rpc_provider) +} diff --git a/core/crates/swapper/src/uniswap/v3/commands.rs b/core/crates/swapper/src/uniswap/v3/commands.rs new file mode 100644 index 0000000000..4cee90c671 --- /dev/null +++ b/core/crates/swapper/src/uniswap/v3/commands.rs @@ -0,0 +1,386 @@ +use crate::{ + SwapperError, eth_address, + fees::{apply_slippage_in_bp, default_referral_fees}, + models::*, + uniswap::requires_native_wrapping, +}; +use gem_evm::uniswap::command::{ADDRESS_THIS, PayPortion, Permit2Permit, Sweep, Transfer, UniversalRouterCommand, UnwrapWeth, V3SwapExactIn, WrapEth}; + +use alloy_primitives::{Address, Bytes, U256}; +use std::str::FromStr; + +pub fn build_commands( + request: &QuoteRequest, + token_in: &Address, + token_out: &Address, + amount_in: U256, + quote_amount: U256, + path: &Bytes, + permit: Option, + fee_token_is_input: bool, +) -> Result, SwapperError> { + let options = request.options.clone(); + let fee_options = default_referral_fees().evm; + let recipient = eth_address::parse_str(&request.wallet_address)?; + + let wrap_input_eth = requires_native_wrapping(&request.from_asset.asset_id()); + let unwrap_output_weth = requires_native_wrapping(&request.to_asset.asset_id()); + let pay_fees = fee_options.bps > 0; + + let mut commands: Vec = vec![]; + + let amount_out = apply_slippage_in_bp("e_amount, options.slippage.bps + fee_options.bps); + if wrap_input_eth { + // Wrap ETH, recipient is this_address + commands.push(UniversalRouterCommand::WRAP_ETH(WrapEth { + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount_min: amount_in, + })); + } else if let Some(permit) = permit { + commands.push(UniversalRouterCommand::PERMIT2_PERMIT(permit)); + } + + // payer_is_user: is true when swapping tokens + let payer_is_user = !wrap_input_eth; + if pay_fees { + if fee_token_is_input { + // insert TRANSFER fee first + let fee = amount_in * U256::from(fee_options.bps) / U256::from(10000); + let fee_recipient = Address::from_str(fee_options.address.as_str()).unwrap(); + if wrap_input_eth { + // if input is native ETH, we can transfer directly because of WRAP_ETH command + commands.push(UniversalRouterCommand::TRANSFER(Transfer { + token: *token_in, + recipient: fee_recipient, + value: fee, + })); + } else { + // call permit2 transfer instead + commands.push(UniversalRouterCommand::PERMIT2_TRANSFER_FROM(Transfer { + token: *token_in, + recipient: fee_recipient, + value: fee, + })); + }; + + // insert V3_SWAP_EXACT_IN with amount - fee, recipient is user address + commands.push(UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient, + amount_in: amount_in - fee, + amount_out_min: amount_out, + path: path.clone(), + payer_is_user, + })); + } else { + // insert V3_SWAP_EXACT_IN + // amount_out_min: if needs to pay fees, amount_out_min set to 0 and we will sweep the rest + commands.push(UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient: Address::from_str(ADDRESS_THIS).unwrap(), + amount_in, + amount_out_min: if pay_fees { U256::from(0) } else { amount_out }, + path: path.clone(), + payer_is_user, + })); + + // insert PAY_PORTION to fee_address + commands.push(UniversalRouterCommand::PAY_PORTION(PayPortion { + token: *token_out, + recipient: Address::from_str(fee_options.address.as_str()).unwrap(), + bips: U256::from(fee_options.bps), + })); + + if !unwrap_output_weth { + // MSG_SENDER should be the address of the caller + commands.push(UniversalRouterCommand::SWEEP(Sweep { + token: *token_out, + recipient, + amount_min: U256::from(amount_out), + })); + } + } + } else { + // insert V3_SWAP_EXACT_IN + commands.push(UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient, + amount_in, + amount_out_min: amount_out, + path: path.clone(), + payer_is_user, + })); + } + + if unwrap_output_weth { + // insert UNWRAP_WETH + commands.push(UniversalRouterCommand::UNWRAP_WETH(UnwrapWeth { + recipient, + amount_min: U256::from(amount_out), + })); + } + Ok(commands) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::permit2_data::*; + use alloy_primitives::aliases::U256; + use gem_evm::uniswap::{FeeTier, path::build_direct_pair}; + use primitives::{ + AssetId, Chain, + asset_constants::{CELO_USDT_TOKEN_ID, CELO_WETH_TOKEN_ID}, + asset_constants::{ETHEREUM_USDC_TOKEN_ID, ETHEREUM_WETH_TOKEN_ID, OPTIMISM_USDC_E_TOKEN_ID, OPTIMISM_USDC_TOKEN_ID, OPTIMISM_USDT_TOKEN_ID, OPTIMISM_WETH_TOKEN_ID}, + contract_constants::OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT, + }; + + #[test] + fn test_build_commands_eth_to_token() { + let request = QuoteRequest { + // ETH -> USDC + from_asset: AssetId::from(Chain::Ethereum, None).into(), + to_asset: AssetId::from(Chain::Ethereum, Some(ETHEREUM_USDC_TOKEN_ID.into())).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "10000000000000000".into(), + options: Options::default(), + }; + + let token_in = eth_address::parse_str(ETHEREUM_WETH_TOKEN_ID).unwrap(); + let token_out = eth_address::parse_str(ETHEREUM_USDC_TOKEN_ID).unwrap(); + let amount_in = U256::from(1000000000000000u64); + + let path = build_direct_pair(&token_in, &token_out, FeeTier::FiveHundred); + let commands = super::build_commands(&request, &token_in, &token_out, amount_in, U256::from(0), &path, None, false).unwrap(); + + assert_eq!(commands.len(), 4); + assert!(matches!(commands[0], UniversalRouterCommand::WRAP_ETH(_))); + assert!(matches!(commands[1], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); + assert!(matches!(commands[2], UniversalRouterCommand::PAY_PORTION(_))); + assert!(matches!(commands[3], UniversalRouterCommand::SWEEP(_))); + } + + #[test] + fn test_build_commands_usdc_to_usdt() { + let request = QuoteRequest { + // USDC -> USDT + from_asset: AssetId::from(Chain::Optimism, Some(OPTIMISM_USDC_TOKEN_ID.into())).into(), + to_asset: AssetId::from(Chain::Optimism, Some(OPTIMISM_USDT_TOKEN_ID.into())).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "6500000".into(), + options: Options::default(), + }; + + let token_in = eth_address::parse_str(request.from_asset.asset_id().token_id.as_ref().unwrap()).unwrap(); + let token_out = eth_address::parse_str(request.to_asset.asset_id().token_id.as_ref().unwrap()).unwrap(); + let amount_in = U256::from_str(&request.value).unwrap(); + + let permit2_data = Permit2Data { + permit_single: PermitSingle { + details: Permit2Detail { + token: OPTIMISM_USDC_TOKEN_ID.into(), + amount: "1461501637330902918203684832716283019655932542975".into(), + expiration: 1732667593, + nonce: 0, + }, + spender: OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT.into(), + sig_deadline: 1730077393, + }, + signature: hex::decode("8f32d2e66506a4f424b1b23309ed75d338534d0912129a8aa3381fab4eb8032f160e0988f10f512b19a58c2a689416366c61cc0c483c3b5322dc91f8b60107671b").unwrap(), + }; + + let path = build_direct_pair(&token_in, &token_out, FeeTier::FiveHundred); + let commands = super::build_commands(&request, &token_in, &token_out, amount_in, U256::from(6507936), &path, Some(permit2_data.into()), false).unwrap(); + + assert_eq!(commands.len(), 4); + assert!(matches!(commands[0], UniversalRouterCommand::PERMIT2_PERMIT(_))); + assert!(matches!(commands[1], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); + assert!(matches!(commands[2], UniversalRouterCommand::PAY_PORTION(_))); + assert!(matches!(commands[3], UniversalRouterCommand::SWEEP(_))); + } + + #[test] + fn test_build_commands_usdc_to_aave() { + let request = QuoteRequest { + // USDC -> AAVE + from_asset: AssetId::from(Chain::Optimism, Some("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85".into())).into(), + to_asset: AssetId::from(Chain::Optimism, Some("0x76fb31fb4af56892a25e32cfc43de717950c9278".into())).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "5064985".into(), + options: Options { + slippage: 100.into(), + use_max_amount: false, + }, + }; + + let token_in = eth_address::parse_str(request.from_asset.asset_id().token_id.as_ref().unwrap()).unwrap(); + let token_out = eth_address::parse_str(request.to_asset.asset_id().token_id.as_ref().unwrap()).unwrap(); + let amount_in = U256::from_str(&request.value).unwrap(); + + let path = build_direct_pair(&token_in, &token_out, FeeTier::FiveHundred); + // fee token is output token + let commands = super::build_commands(&request, &token_in, &token_out, amount_in, U256::from(33377662359182269u64), &path, None, false).unwrap(); + + assert_eq!(commands.len(), 3); + + assert!(matches!(commands[0], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); + assert!(matches!(commands[1], UniversalRouterCommand::PAY_PORTION(_))); + assert!(matches!(commands[2], UniversalRouterCommand::SWEEP(_))); + + // fee token is input token + let commands = super::build_commands(&request, &token_in, &token_out, amount_in, U256::from(33377662359182269u64), &path, None, true).unwrap(); + + assert_eq!(commands.len(), 2); + + assert!(matches!(commands[0], UniversalRouterCommand::PERMIT2_TRANSFER_FROM(_))); + assert!(matches!(commands[1], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); + } + + #[test] + fn test_build_commands_usdce_to_eth() { + let request = QuoteRequest { + // USDCE -> ETH + from_asset: AssetId::from(Chain::Optimism, Some(OPTIMISM_USDC_E_TOKEN_ID.into())).into(), + to_asset: AssetId::from(Chain::Ethereum, None).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "10000000".into(), + options: Options { + slippage: 100.into(), + use_max_amount: false, + }, + }; + + let token_in = eth_address::parse_str(request.from_asset.asset_id().token_id.as_ref().unwrap()).unwrap(); + let token_out = eth_address::parse_str(OPTIMISM_WETH_TOKEN_ID).unwrap(); + let amount_in = U256::from_str(&request.value).unwrap(); + + let permit2_data = Permit2Data { + permit_single: PermitSingle { + details: Permit2Detail { + token: request.from_asset.asset_id().token_id.clone().unwrap(), + amount: "1461501637330902918203684832716283019655932542975".into(), + expiration: 1732667502, + nonce: 0, + }, + spender: OPTIMISM_UNISWAP_V3_UNIVERSAL_ROUTER_CONTRACT.into(), + sig_deadline: 1730077302, + }, + signature: hex::decode("00e96ed0f5bf5cca62dc9d9753960d83c8be83224456559a1e93a66d972a019f6f328a470f8257d3950b4cb7cd0024d789b4fcd9e80c4eb43d82a38d9e5332f31b").unwrap(), + }; + + let path = build_direct_pair(&token_in, &token_out, FeeTier::FiveHundred); + let commands = super::build_commands( + &request, + &token_in, + &token_out, + amount_in, + U256::from(3997001989341576u64), + &path, + Some(permit2_data.into()), + false, + ) + .unwrap(); + + assert_eq!(commands.len(), 4); + + assert!(matches!(commands[0], UniversalRouterCommand::PERMIT2_PERMIT(_))); + assert!(matches!(commands[1], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); + assert!(matches!(commands[2], UniversalRouterCommand::PAY_PORTION(_))); + assert!(matches!(commands[3], UniversalRouterCommand::UNWRAP_WETH(_))); + } + + #[test] + fn test_build_commands_eth_to_uni_with_input_fee() { + // Replicate https://optimistic.etherscan.io/tx/0x18277deea3e273a7fb9abc985269dcdabe3d34c2b604fbd82dcd0a5a5204f72c + let request = QuoteRequest { + // ETH -> UNI + from_asset: AssetId::from(Chain::Optimism, None).into(), + to_asset: AssetId::from(Chain::Optimism, Some("0x6fd9d7ad17242c41f7131d257212c54a0e816691".into())).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "1000000000000000".into(), + options: Options { + slippage: 100.into(), + use_max_amount: false, + }, + }; + + let token_in = eth_address::parse_str(OPTIMISM_WETH_TOKEN_ID).unwrap(); + let token_out = eth_address::parse_str(&request.to_asset.asset_id().token_id.unwrap()).unwrap(); + let amount_in = U256::from_str(request.value.as_str()).unwrap(); + + let path = build_direct_pair(&token_in, &token_out, FeeTier::ThreeThousand); + let commands = super::build_commands(&request, &token_in, &token_out, amount_in, U256::from(244440440678888410_u64), &path, None, true).unwrap(); + + assert_eq!(commands.len(), 3); + + assert!(matches!(commands[0], UniversalRouterCommand::WRAP_ETH(_))); + assert!(matches!(commands[1], UniversalRouterCommand::TRANSFER(_))); + assert!(matches!(commands[2], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); + } + + #[test] + fn test_build_commands_celo_tokenized_native() { + let token_celo = eth_address::parse_str(CELO_WETH_TOKEN_ID).unwrap(); + let token_usdt = eth_address::parse_str(CELO_USDT_TOKEN_ID).unwrap(); + let wallet = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"; + + // CELO -> USDT: no wrap, just a direct swap through token path + let request = QuoteRequest { + from_asset: AssetId::from(Chain::Celo, None).into(), + to_asset: AssetId::from(Chain::Celo, Some(CELO_USDT_TOKEN_ID.into())).into(), + wallet_address: wallet.into(), + destination_address: wallet.into(), + value: "22000000000000000000".into(), + options: Options::default(), + }; + let path = build_direct_pair(&token_celo, &token_usdt, FeeTier::Hundred); + let commands = super::build_commands( + &request, + &token_celo, + &token_usdt, + U256::from_str(&request.value).unwrap(), + U256::from(14804757u64), + &path, + None, + false, + ) + .unwrap(); + + assert_eq!(commands.len(), 3); + assert!(matches!(commands[0], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); + assert!(matches!(commands[1], UniversalRouterCommand::PAY_PORTION(_))); + assert!(matches!(commands[2], UniversalRouterCommand::SWEEP(_))); + + // USDT -> CELO with fees: sweep instead of unwrap + let request = QuoteRequest { + from_asset: AssetId::from(Chain::Celo, Some(CELO_USDT_TOKEN_ID.into())).into(), + to_asset: AssetId::from(Chain::Celo, None).into(), + wallet_address: wallet.into(), + destination_address: wallet.into(), + value: "900000".into(), + options: Options { + slippage: 50.into(), + use_max_amount: false, + }, + }; + let path = build_direct_pair(&token_usdt, &token_celo, FeeTier::Hundred); + let commands = super::build_commands( + &request, + &token_usdt, + &token_celo, + U256::from_str(&request.value).unwrap(), + U256::from(10752991111111111170u128), + &path, + None, + false, + ) + .unwrap(); + + assert_eq!(commands.len(), 3); + assert!(matches!(commands[0], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); + assert!(matches!(commands[1], UniversalRouterCommand::PAY_PORTION(_))); + assert!(matches!(commands[2], UniversalRouterCommand::SWEEP(_))); + } +} diff --git a/core/crates/swapper/src/uniswap/v3/mod.rs b/core/crates/swapper/src/uniswap/v3/mod.rs new file mode 100644 index 0000000000..06165a2bfb --- /dev/null +++ b/core/crates/swapper/src/uniswap/v3/mod.rs @@ -0,0 +1,19 @@ +mod commands; +mod path; +mod quoter_v2; + +pub mod provider; +pub use provider::UniswapV3; + +use crate::ProviderType; +use gem_evm::uniswap::{FeeTier, deployment::v3::V3Deployment}; +use primitives::Chain; +use std::fmt::Debug; + +pub trait UniversalRouterProvider: Send + Sync + Debug { + fn provider(&self) -> &ProviderType; + fn get_tiers(&self) -> Vec; + fn get_deployment_by_chain(&self, chain: &Chain) -> Option; +} + +const DEFAULT_SWAP_GAS_LIMIT: u64 = 500_000; // gwei diff --git a/core/crates/swapper/src/uniswap/v3/path.rs b/core/crates/swapper/src/uniswap/v3/path.rs new file mode 100644 index 0000000000..3b03e3de1e --- /dev/null +++ b/core/crates/swapper/src/uniswap/v3/path.rs @@ -0,0 +1,57 @@ +use alloy_primitives::{Address, Bytes}; +use gem_evm::uniswap::{ + FeeTier, + path::{BasePair, TokenPair, build_direct_pair, build_pairs}, +}; + +use crate::{ + Route, SwapperError, eth_address, + uniswap::swap_route::{RouteData, get_intermediaries}, +}; + +pub fn build_paths(token_in: &Address, token_out: &Address, fee_tiers: &[FeeTier], base_pair: &BasePair) -> Vec, Bytes)>> { + let mut paths: Vec, Bytes)>> = vec![]; + let direct_paths: Vec<_> = fee_tiers + .iter() + .map(|fee_tier| { + ( + vec![TokenPair { + token_in: *token_in, + token_out: *token_out, + fee_tier: *fee_tier, + }], + build_direct_pair(token_in, token_out, *fee_tier), + ) + }) + .collect(); + paths.push(direct_paths); + + let intermediaries = get_intermediaries(token_in, token_out, base_pair); + intermediaries.iter().for_each(|intermediary| { + let token_pairs: Vec> = fee_tiers + .iter() + .map(|fee_tier| TokenPair::new_two_hop(token_in, intermediary, token_out, *fee_tier)) + .collect(); + let pair_paths: Vec<_> = token_pairs.iter().map(|token_pairs| (token_pairs.to_vec(), build_pairs(token_pairs))).collect(); + paths.push(pair_paths); + }); + paths +} + +pub fn build_paths_with_routes(routes: &[Route]) -> Result { + if routes.is_empty() { + return Err(SwapperError::InvalidRoute); + } + let route_data: RouteData = serde_json::from_str(&routes[0].route_data).map_err(|_| SwapperError::InvalidRoute)?; + let fee_tier = FeeTier::try_from(route_data.fee_tier.as_str()).map_err(|_| SwapperError::ComputeQuoteError("invalid fee tier".into()))?; + let token_pairs: Vec = routes + .iter() + .map(|route| TokenPair { + token_in: eth_address::parse_asset_id(&route.input).unwrap(), + token_out: eth_address::parse_asset_id(&route.output).unwrap(), + fee_tier, + }) + .collect(); + let paths = build_pairs(&token_pairs); + Ok(paths) +} diff --git a/core/crates/swapper/src/uniswap/v3/provider.rs b/core/crates/swapper/src/uniswap/v3/provider.rs new file mode 100644 index 0000000000..5594d1a77d --- /dev/null +++ b/core/crates/swapper/src/uniswap/v3/provider.rs @@ -0,0 +1,252 @@ +use crate::{ + FetchQuoteData, Permit2ApprovalData, ProviderData, ProviderType, Quote, QuoteRequest, Swapper, SwapperError, SwapperQuoteData, + alien::{RpcClient, RpcProvider}, + approval::{check_approval_erc20_with_client, check_approval_permit2_with_client, get_swap_gas_limit_with_approval}, + eth_address, + fees::{apply_slippage_in_bp, default_referral_fees}, + models::*, + uniswap::{ + deadline::get_sig_deadline, + fee_token::is_quote_input_fee_token, + quote_result::get_best_quote, + requires_native_wrapping, + swap_route::{RouteData, build_swap_route}, + }, +}; +use alloy_primitives::{Address, Bytes, U256, hex::encode_prefixed as HexEncode}; +use async_trait::async_trait; +use gem_evm::{ + jsonrpc::EthereumRpc, + uniswap::{command::encode_commands, path::get_base_pair}, +}; +use gem_jsonrpc::client::JsonRpcClient; +use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; +use std::{fmt, str::FromStr, sync::Arc}; + +use super::{DEFAULT_SWAP_GAS_LIMIT, UniversalRouterProvider, commands::build_commands, path::build_paths_with_routes}; + +pub struct UniswapV3 { + provider: Box, + rpc_provider: Arc, +} + +impl UniswapV3 { + pub fn new(provider: Box, rpc_provider: Arc) -> Self { + Self { provider, rpc_provider } + } + + pub fn support_chain(&self, chain: &Chain) -> bool { + self.provider.get_deployment_by_chain(chain).is_some() + } + + fn client_for(&self, chain: Chain) -> Result, SwapperError> { + let endpoint = self.rpc_provider.get_endpoint(chain).map_err(SwapperError::from)?; + let client = RpcClient::new(endpoint, self.rpc_provider.clone()); + Ok(JsonRpcClient::new(client)) + } + + fn get_asset_address(asset_id: &str, evm_chain: EVMChain) -> Result { + let asset_id = AssetId::new(asset_id).ok_or(SwapperError::NotSupportedAsset)?; + eth_address::parse_or_weth_address(&asset_id, evm_chain) + } + + fn parse_request(request: &QuoteRequest) -> Result<(EVMChain, Address, Address, U256), SwapperError> { + let evm_chain = EVMChain::from_chain(request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + let token_in = Self::get_asset_address(&request.from_asset.id, evm_chain)?; + let token_out = Self::get_asset_address(&request.to_asset.id, evm_chain)?; + let amount_in = U256::from_str(&request.value).map_err(SwapperError::from)?; + + Ok((evm_chain, token_in, token_out, amount_in)) + } + + async fn check_erc20_approval( + &self, + client: &JsonRpcClient, + wallet_address: Address, + token: &str, + amount: U256, + chain: &Chain, + ) -> Result { + let deployment = self.provider.get_deployment_by_chain(chain).ok_or(SwapperError::NotSupportedChain)?; + let spender = deployment.permit2.to_string(); + check_approval_erc20_with_client(wallet_address.to_string(), token.to_string(), spender, amount, client).await + } + + async fn check_permit2_approval( + &self, + client: &JsonRpcClient, + wallet_address: Address, + token: &str, + amount: U256, + chain: &Chain, + ) -> Result, SwapperError> { + let deployment = self.provider.get_deployment_by_chain(chain).ok_or(SwapperError::NotSupportedChain)?; + + Ok(check_approval_permit2_with_client( + deployment.permit2, + wallet_address.to_string(), + token.to_string(), + deployment.universal_router.to_string(), + amount, + client, + ) + .await? + .permit2_data()) + } +} + +impl fmt::Debug for UniswapV3 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UniswapV3").finish() + } +} + +#[async_trait] +impl Swapper for UniswapV3 { + fn provider(&self) -> &ProviderType { + self.provider.provider() + } + + fn supported_assets(&self) -> Vec { + Chain::all().iter().filter(|x| self.support_chain(x)).map(|x| SwapperChainAsset::All(*x)).collect() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_chain = request.from_asset.chain(); + let to_chain = request.to_asset.chain(); + let deployment = self.provider.get_deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; + let (evm_chain, token_in, token_out, from_value) = Self::parse_request(request)?; + if requires_native_wrapping(&request.from_asset.asset_id()) || requires_native_wrapping(&request.to_asset.asset_id()) { + _ = evm_chain.weth_contract().ok_or(SwapperError::NotSupportedChain)?; + } + + let client = Arc::new(self.client_for(from_chain)?); + + let fee_tiers = self.provider.get_tiers(); + let use_weth = evm_chain.weth_contract().is_some(); + let base_pair = get_base_pair(&evm_chain, use_weth).ok_or(SwapperError::ComputeQuoteError("base pair not found".into()))?; + + let fee_token_is_input = is_quote_input_fee_token(Some(&base_pair), request, token_in, token_out); + let fee_bps = default_referral_fees().evm.bps; + + let quote_amount_in = if fee_token_is_input && fee_bps > 0 { + apply_slippage_in_bp(&from_value, fee_bps) + } else { + from_value + }; + + let paths_array = super::path::build_paths(&token_in, &token_out, &fee_tiers, &base_pair); + let requests: Vec<_> = paths_array + .iter() + .map(|paths| { + let client = client.clone(); + let calls: Vec = paths + .iter() + .map(|path| super::quoter_v2::build_quoter_request(&request.wallet_address, deployment.quoter_v2, quote_amount_in, &path.1)) + .collect(); + async move { client.batch_call_requests(calls).await } + }) + .collect(); + + let batch_results = futures::future::join_all(requests).await; + + let quote_result = get_best_quote(&batch_results, super::quoter_v2::decode_quoter_response)?; + + let to_value = if fee_token_is_input { + quote_result.amount_out + } else { + apply_slippage_in_bp("e_result.amount_out, fee_bps) + }; + let to_min_value = apply_slippage_in_bp(&to_value, request.options.slippage.bps); + + let fee_tier_idx = quote_result.fee_tier_idx; + let batch_idx = quote_result.batch_idx; + + let fee_tier: u32 = fee_tiers[fee_tier_idx % fee_tiers.len()] as u32; + let asset_id_in = AssetId::from(from_chain, Some(token_in.to_checksum(None))); + let asset_id_out = AssetId::from(to_chain, Some(token_out.to_checksum(None))); + let asset_id_intermediary: Option = match batch_idx { + 0 => None, + _ => { + let first_token_out = &paths_array[batch_idx][0].0[0].token_out; + Some(AssetId::from(to_chain, Some(first_token_out.to_checksum(None)))) + } + }; + let route_data = RouteData { + fee_tier: fee_tier.to_string(), + min_amount_out: to_min_value.to_string(), + }; + let routes = build_swap_route(&asset_id_in, asset_id_intermediary.as_ref(), &asset_id_out, &route_data); + + Ok(Quote { + from_value: request.value.clone(), + min_from_value: None, + to_value: to_value.to_string(), + data: ProviderData { + provider: self.provider().clone(), + routes: routes.clone(), + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: None, + }) + } + + async fn get_permit2_for_quote(&self, quote: &Quote) -> Result, SwapperError> { + let from_asset = quote.request.from_asset.asset_id(); + if requires_native_wrapping(&from_asset) { + return Ok(None); + } + let client = self.client_for(from_asset.chain)?; + let wallet_address = eth_address::parse_str("e.request.wallet_address)?; + let (_, token_in, _, amount_in) = Self::parse_request("e.request)?; + self.check_permit2_approval(&client, wallet_address, &token_in.to_checksum(None), amount_in, &from_asset.chain) + .await + } + + async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { + let request = "e.request; + let from_chain = request.from_asset.chain(); + let (_, token_in, token_out, amount_in) = Self::parse_request(request)?; + let deployment = self.provider.get_deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; + + let client = self.client_for(from_chain)?; + + let route_data: RouteData = serde_json::from_str("e.data.routes.first().unwrap().route_data).map_err(|_| SwapperError::InvalidRoute)?; + let to_amount = U256::from_str(&route_data.min_amount_out).map_err(SwapperError::from)?; + + let wallet_address = eth_address::parse_str(&request.wallet_address)?; + let permit = data.permit2_data().map(|data| data.into()); + let wrap_input_eth = requires_native_wrapping(&request.from_asset.asset_id()); + + let approval: Option = if wrap_input_eth { + None + } else { + self.check_erc20_approval(&client, wallet_address, &token_in.to_checksum(None), amount_in, &from_chain) + .await? + .approval_data() + }; + let gas_limit = get_swap_gas_limit_with_approval(&approval, None, DEFAULT_SWAP_GAS_LIMIT); + + let sig_deadline = get_sig_deadline(); + + let evm_chain = EVMChain::from_chain(from_chain).ok_or(SwapperError::NotSupportedChain)?; + let use_weth = evm_chain.weth_contract().is_some(); + let base_pair = get_base_pair(&evm_chain, use_weth); + let fee_token_is_input = is_quote_input_fee_token(base_pair.as_ref(), request, token_in, token_out); + + let path: Bytes = build_paths_with_routes("e.data.routes)?; + let commands = build_commands(request, &token_in, &token_out, amount_in, to_amount, &path, permit, fee_token_is_input)?; + let encoded = encode_commands(&commands, U256::from(sig_deadline)); + + let value = if wrap_input_eth { request.value.clone() } else { String::from("0") }; + + Ok(SwapperQuoteData::new_contract( + deployment.universal_router.into(), + value, + HexEncode(encoded), + approval, + gas_limit, + )) + } +} diff --git a/core/crates/swapper/src/uniswap/v3/quoter_v2.rs b/core/crates/swapper/src/uniswap/v3/quoter_v2.rs new file mode 100644 index 0000000000..0508741524 --- /dev/null +++ b/core/crates/swapper/src/uniswap/v3/quoter_v2.rs @@ -0,0 +1,44 @@ +use alloy_primitives::{Bytes, U256, hex::decode as HexDecode}; +use alloy_sol_types::SolCall; +use gem_evm::{ + jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, + uniswap::contracts::v3::IQuoterV2, +}; +use gem_jsonrpc::types::JsonRpcResponse; + +use crate::SwapperError; + +pub fn build_quoter_request(wallet_address: &str, quoter_v2: &str, amount_in: U256, path: &Bytes) -> EthereumRpc { + let call_data: Vec = IQuoterV2::quoteExactInputCall { + path: path.clone(), + amountIn: amount_in, + } + .abi_encode(); + + EthereumRpc::Call(TransactionObject::new_call_with_from(wallet_address, quoter_v2, call_data), BlockParameter::Latest) +} + +// Returns (amountOut, gasEstimate) +pub fn decode_quoter_response(response: &JsonRpcResponse) -> Result<(U256, U256), SwapperError> { + let decoded = HexDecode(&response.result).map_err(|_| SwapperError::ComputeQuoteError("Failed to decode quoter response".into()))?; + let quoter_return = IQuoterV2::quoteExactInputCall::abi_decode_returns(&decoded).map_err(SwapperError::from)?; + + Ok((quoter_return.amountOut, quoter_return.gasEstimate)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::hex::decode as HexDecode; + use gem_evm::uniswap::contracts::v3::IQuoterV2; + + #[test] + fn test_decode_quoter_v2_response() { + let result = "0x0000000000000000000000000000000000000000000000000000000001884eee000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000014b1e00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000004d04db53840b0aec247bb9bd3ffc00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001"; + let decoded = HexDecode(result).unwrap(); + let quote = IQuoterV2::quoteExactInputCall::abi_decode_returns(&decoded).unwrap(); + + assert_eq!(quote.amountOut, U256::from(25710318)); + assert_eq!(quote.gasEstimate, U256::from(84766)); + } +} diff --git a/core/crates/swapper/src/uniswap/v4/commands.rs b/core/crates/swapper/src/uniswap/v4/commands.rs new file mode 100644 index 0000000000..d6d7a6c966 --- /dev/null +++ b/core/crates/swapper/src/uniswap/v4/commands.rs @@ -0,0 +1,188 @@ +use std::str::FromStr; + +use crate::{ + QuoteRequest, Route, SwapperError, eth_address, + fees::{apply_slippage_in_bp, default_referral_fees}, + uniswap::requires_native_wrapping, +}; +use alloy_primitives::{Address, U256}; +use gem_evm::uniswap::{ + actions::V4Action::{SETTLE, SWAP_EXACT_IN, TAKE}, + command::{ADDRESS_THIS, PayPortion, Permit2Permit, Sweep, Transfer, UniversalRouterCommand}, + contracts::v4::{IV4Router::ExactInputParams, PathKey}, +}; + +pub fn build_commands( + request: &QuoteRequest, + token_in: &Address, + token_out: &Address, + amount_in: u128, + quote_amount: u128, + swap_routes: &[Route], + permit: Option, + fee_token_is_input: bool, +) -> Result, SwapperError> { + let options = request.options.clone(); + let fee_options = default_referral_fees().evm; + let recipient = eth_address::parse_str(&request.wallet_address)?; + + let input_is_native = requires_native_wrapping(&request.from_asset.asset_id()); + let pay_fees = fee_options.bps > 0; + + let mut commands: Vec = vec![]; + + let amount_out = apply_slippage_in_bp("e_amount, options.slippage.bps + fee_options.bps); + // Insert permit2 if needed + if let Some(permit) = permit { + commands.push(UniversalRouterCommand::PERMIT2_PERMIT(permit)); + } + + if pay_fees { + if fee_token_is_input { + // insert TRANSFER fee first + let fee = amount_in * (fee_options.bps as u128) / 10000_u128; + let fee_recipient = Address::from_str(fee_options.address.as_str()).unwrap(); + if input_is_native { + // if input is native ETH, we can transfer directly + commands.push(UniversalRouterCommand::TRANSFER(Transfer { + token: *token_in, + recipient: fee_recipient, + value: U256::from(fee), + })); + } else { + // call permit2 transfer instead + commands.push(UniversalRouterCommand::PERMIT2_TRANSFER_FROM(Transfer { + token: *token_in, + recipient: fee_recipient, + value: U256::from(fee), + })); + }; + // insert V4_SWAP with amount - fee + // fee charged in token_in, so we need to use recipient as recipient + let command = build_v4_swap_command(token_in, token_out, amount_in - fee, amount_out, swap_routes, &recipient)?; + commands.push(command); + } else { + // insert V4 SWAP + // if needs to pay fees, amount_out_min set to 0 and we will sweep the rest + let address_this = ADDRESS_THIS.parse().unwrap(); + let amount_out_min = if pay_fees { 0 } else { amount_out }; + let command = build_v4_swap_command(token_in, token_out, amount_in, amount_out_min, swap_routes, &address_this)?; + commands.push(command); + + // insert PAY_PORTION to fee_address + commands.push(UniversalRouterCommand::PAY_PORTION(PayPortion { + token: *token_out, + recipient: Address::from_str(fee_options.address.as_str()).unwrap(), + bips: U256::from(fee_options.bps), + })); + + commands.push(UniversalRouterCommand::SWEEP(Sweep { + token: *token_out, + recipient, + amount_min: U256::from(amount_out), + })); + } + } else { + let command = build_v4_swap_command(token_in, token_out, amount_in, amount_out, swap_routes, &recipient)?; + commands.push(command); + } + Ok(commands) +} + +fn build_v4_swap_command( + token_in: &Address, + token_out: &Address, + amount_in: u128, + amount_out_min: u128, + swap_routes: &[Route], + recipient: &Address, +) -> Result { + if swap_routes.is_empty() { + return Err(SwapperError::InvalidRoute); + } + // V4_SWAP {actions} + // Dispatcher -> BaseActionsRouter::_executeActions -> PoolManager::_executeActionsWithoutUnlock -> V4Router::_handleAction + let path: Vec = swap_routes + .iter() + .map(|route| PathKey::try_from(route).map_err(|_| SwapperError::InvalidRoute)) + .collect::, SwapperError>>()?; + let actions = vec![ + SWAP_EXACT_IN(ExactInputParams { + currencyIn: *token_in, + path, + amountIn: amount_in, + amountOutMinimum: amount_out_min, + }), + SETTLE { + currency: *token_in, + amount: U256::from(0), + payer_is_user: true, + }, + TAKE { + currency: *token_out, + recipient: recipient.to_owned(), + amount: U256::from(0), + }, + ]; + Ok(UniversalRouterCommand::V4_SWAP { actions }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Options; + use primitives::{ + AssetId, Chain, + asset_constants::{CELO_USDT_TOKEN_ID, CELO_WETH_TOKEN_ID}, + }; + + #[test] + fn test_build_commands_celo_tokenized_native() { + let token_celo = Address::from_str(CELO_WETH_TOKEN_ID).unwrap(); + let token_usdt = Address::from_str(CELO_USDT_TOKEN_ID).unwrap(); + let wallet = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"; + let routes = vec![Route::mock( + AssetId::from(Chain::Celo, Some(CELO_WETH_TOKEN_ID.into())), + AssetId::from(Chain::Celo, Some(CELO_USDT_TOKEN_ID.into())), + )]; + + // CELO -> USDT: no wrap, direct swap through token path + let request = QuoteRequest { + from_asset: AssetId::from(Chain::Celo, None).into(), + to_asset: AssetId::from(Chain::Celo, Some(CELO_USDT_TOKEN_ID.into())).into(), + wallet_address: wallet.into(), + destination_address: wallet.into(), + value: "22000000000000000000".into(), + options: Options::default(), + }; + let commands = build_commands(&request, &token_celo, &token_usdt, 22_000_000_000_000_000_000, 14_804_757, &routes, None, false).unwrap(); + + assert_eq!(commands.len(), 3); + assert!(matches!(commands[0], UniversalRouterCommand::V4_SWAP { .. })); + assert!(matches!(commands[1], UniversalRouterCommand::PAY_PORTION(_))); + assert!(matches!(commands[2], UniversalRouterCommand::SWEEP(_))); + + // USDT -> CELO with fees: sweep instead of unwrap + let request = QuoteRequest { + from_asset: AssetId::from(Chain::Celo, Some(CELO_USDT_TOKEN_ID.into())).into(), + to_asset: AssetId::from(Chain::Celo, None).into(), + wallet_address: wallet.into(), + destination_address: wallet.into(), + value: "900000".into(), + options: Options { + slippage: 50.into(), + use_max_amount: false, + }, + }; + let routes = vec![Route::mock( + AssetId::from(Chain::Celo, Some(CELO_USDT_TOKEN_ID.into())), + AssetId::from(Chain::Celo, Some(CELO_WETH_TOKEN_ID.into())), + )]; + let commands = build_commands(&request, &token_usdt, &token_celo, 900_000, 10_752_991_111_111_111_170, &routes, None, false).unwrap(); + + assert_eq!(commands.len(), 3); + assert!(matches!(commands[0], UniversalRouterCommand::V4_SWAP { .. })); + assert!(matches!(commands[1], UniversalRouterCommand::PAY_PORTION(_))); + assert!(matches!(commands[2], UniversalRouterCommand::SWEEP(_))); + } +} diff --git a/core/crates/swapper/src/uniswap/v4/mod.rs b/core/crates/swapper/src/uniswap/v4/mod.rs new file mode 100644 index 0000000000..60446b8686 --- /dev/null +++ b/core/crates/swapper/src/uniswap/v4/mod.rs @@ -0,0 +1,8 @@ +mod commands; +mod path; +mod quoter; + +pub mod provider; +pub use provider::UniswapV4; + +const DEFAULT_SWAP_GAS_LIMIT: u64 = 300_000; // gwei diff --git a/core/crates/swapper/src/uniswap/v4/path.rs b/core/crates/swapper/src/uniswap/v4/path.rs new file mode 100644 index 0000000000..1ff66b2c2e --- /dev/null +++ b/core/crates/swapper/src/uniswap/v4/path.rs @@ -0,0 +1,107 @@ +use alloy_primitives::{Address, Bytes}; +use gem_evm::uniswap::{ + FeeTier, + contracts::v4::{IV4Quoter::QuoteExactParams, PathKey, PoolKey}, + path::TokenPair, +}; + +use crate::{Route, SwapperError, error::INVALID_ADDRESS, eth_address, uniswap::swap_route::RouteData}; + +// return (currency0, currency1) +fn sort_addresses(token_in: &Address, token_out: &Address) -> (Address, Address) { + if token_in.0 < token_out.0 { (*token_in, *token_out) } else { (*token_out, *token_in) } +} + +pub fn build_pool_key(token_in: &Address, token_out: &Address, fee_tier: &FeeTier) -> (PoolKey, bool) { + let (currency0, currency1) = sort_addresses(token_in, token_out); + let zero_for_one = currency0.0 == token_in.0; + let fee = fee_tier.as_u24(); + let tick_spacing = fee_tier.default_tick_spacing(); + ( + PoolKey { + currency0, + currency1, + fee, + tickSpacing: tick_spacing, + hooks: Address::ZERO, + }, + zero_for_one, + ) +} + +pub fn build_pool_keys(token_in: &Address, token_out: &Address, fee_tiers: &[FeeTier]) -> Vec<(Vec, PoolKey)> { + fee_tiers + .iter() + .map(|fee_tier| { + let (pool_key, _) = build_pool_key(token_in, token_out, fee_tier); + ( + vec![TokenPair { + token_in: *token_in, + token_out: *token_out, + fee_tier: *fee_tier, + }], + pool_key, + ) + }) + .collect() +} + +pub fn build_quote_exact_params( + amount_in: u128, + token_in: &Address, + token_out: &Address, + fee_tiers: &[FeeTier], + intermediaries: &[Address], +) -> Vec, QuoteExactParams)>> { + intermediaries + .iter() + .map(|intermediary| { + fee_tiers + .iter() + .map(|fee_tier| TokenPair::new_two_hop(token_in, intermediary, token_out, *fee_tier)) + .filter(|token_pairs| token_pairs.len() >= 2) + .map(|token_pairs| { + let quote_exact_params = QuoteExactParams { + exactCurrency: token_pairs[0].token_in, + path: token_pairs + .iter() + .map(|token_pair| PathKey { + intermediateCurrency: token_pair.token_out, + fee: token_pair.fee_tier.as_u24(), + tickSpacing: token_pair.fee_tier.default_tick_spacing(), + hooks: Address::ZERO, + hookData: Bytes::new(), + }) + .collect(), + exactAmount: amount_in, + }; + + (token_pairs, quote_exact_params) + }) + .collect() + }) + .collect() +} + +impl TryFrom<&Route> for PathKey { + type Error = SwapperError; + + fn try_from(value: &Route) -> Result { + let token_id = value + .output + .token_id + .as_ref() + .ok_or_else(|| SwapperError::ComputeQuoteError(format!("{}: {}", INVALID_ADDRESS, value.output)))?; + let currency = eth_address::parse_str(token_id)?; + + let route_data: RouteData = serde_json::from_str(&value.route_data).map_err(|_| SwapperError::InvalidRoute)?; + let fee_tier = FeeTier::try_from(route_data.fee_tier.as_str()).map_err(|_| SwapperError::ComputeQuoteError("invalid fee tier".into()))?; + Ok(PathKey { + intermediateCurrency: currency, + fee: fee_tier.as_u24(), + tickSpacing: fee_tier.default_tick_spacing(), + hooks: Address::ZERO, + hookData: Bytes::new(), + }) + } +} diff --git a/core/crates/swapper/src/uniswap/v4/provider.rs b/core/crates/swapper/src/uniswap/v4/provider.rs new file mode 100644 index 0000000000..37ec8756b9 --- /dev/null +++ b/core/crates/swapper/src/uniswap/v4/provider.rs @@ -0,0 +1,326 @@ +use alloy_primitives::{Address, U256, hex::encode_prefixed as HexEncode}; +use async_trait::async_trait; +use std::{collections::HashSet, fmt, str::FromStr, sync::Arc, vec}; + +use crate::{ + FetchQuoteData, Permit2ApprovalData, ProviderData, ProviderType, Quote, QuoteRequest, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData, + alien::{RpcClient, RpcProvider}, + approval::evm::{check_approval_erc20_with_client, check_approval_permit2_with_client}, + approval::get_swap_gas_limit_with_approval, + eth_address, + fees::{apply_slippage_in_bp, default_referral_fees}, + uniswap::{ + deadline::get_sig_deadline, + fee_token::is_quote_input_fee_token, + is_native_erc20, + quote_result::get_best_quote, + requires_native_wrapping, + swap_route::{RouteData, build_swap_route, get_intermediaries}, + }, +}; +use futures::future::{BoxFuture, join_all}; +use gem_evm::{ + jsonrpc::EthereumRpc, + uniswap::{ + FeeTier, + command::encode_commands, + contracts::v4::IV4Quoter::QuoteExactParams, + deployment::v4::get_uniswap_deployment_by_chain, + path::{TokenPair, get_base_pair}, + }, +}; +use gem_jsonrpc::client::JsonRpcClient; +use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; + +use super::{ + DEFAULT_SWAP_GAS_LIMIT, + commands::build_commands, + path::{build_pool_keys, build_quote_exact_params}, + quoter::{build_quote_exact_requests, build_quote_exact_single_request}, +}; + +pub struct UniswapV4 { + pub provider: ProviderType, + rpc_provider: Arc, +} + +impl UniswapV4 { + pub fn new(rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::UniswapV4), + rpc_provider, + } + } + + fn support_chain(&self, chain: &Chain) -> bool { + get_uniswap_deployment_by_chain(chain).is_some() + } + + fn get_tiers(&self) -> Vec { + vec![FeeTier::Hundred, FeeTier::FiveHundred, FeeTier::ThreeThousand, FeeTier::TenThousand] + } + + fn client_for(&self, chain: Chain) -> Result, SwapperError> { + let endpoint = self.rpc_provider.get_endpoint(chain).map_err(SwapperError::from)?; + let client = RpcClient::new(endpoint, self.rpc_provider.clone()); + Ok(JsonRpcClient::new(client)) + } + + fn is_base_pair(token_in: &Address, token_out: &Address, evm_chain: &EVMChain) -> bool { + let base_set: HashSet
= HashSet::from_iter(get_base_pair(evm_chain, is_native_erc20(evm_chain.to_chain())).unwrap().path_building_array()); + base_set.contains(token_in) || base_set.contains(token_out) + } + + fn parse_asset_address(asset_id: &str, evm_chain: EVMChain) -> Result { + let asset_id = AssetId::new(asset_id).ok_or(SwapperError::NotSupportedAsset)?; + if requires_native_wrapping(&asset_id) { + Ok(Address::ZERO) + } else { + eth_address::parse_or_weth_address(&asset_id, evm_chain) + } + } + + fn parse_request(request: &QuoteRequest) -> Result<(EVMChain, Address, Address, u128), SwapperError> { + let evm_chain = EVMChain::from_chain(request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + let token_in = Self::parse_asset_address(&request.from_asset.id, evm_chain)?; + let token_out = Self::parse_asset_address(&request.to_asset.id, evm_chain)?; + let amount_in = u128::from_str(&request.value).map_err(SwapperError::from)?; + + Ok((evm_chain, token_in, token_out, amount_in)) + } +} + +impl fmt::Debug for UniswapV4 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UniswapV4").finish() + } +} + +#[async_trait] +impl Swapper for UniswapV4 { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + Chain::all().iter().filter(|x| self.support_chain(x)).map(|x| SwapperChainAsset::All(*x)).collect() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_chain = request.from_asset.chain(); + let to_chain = request.to_asset.chain(); + let deployment = get_uniswap_deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; + let (evm_chain, token_in, token_out, from_value) = Self::parse_request(request)?; + let fee_tiers = self.get_tiers(); + let base_pair = get_base_pair(&evm_chain, is_native_erc20(from_chain)).ok_or(SwapperError::ComputeQuoteError("base pair not found".into()))?; + let fee_token_is_input = is_quote_input_fee_token(Some(&base_pair), request, token_in, token_out); + let fee_bps = default_referral_fees().evm.bps; + let quote_amount_in = if fee_token_is_input && fee_bps > 0 { + apply_slippage_in_bp(&from_value, fee_bps) + } else { + from_value + }; + + let pool_keys = build_pool_keys(&token_in, &token_out, &fee_tiers); + let client = Arc::new(self.client_for(from_chain)?); + + let mut requests: Vec> = Vec::new(); + let initial_client = Arc::clone(&client); + let direct_calls: Vec = pool_keys + .iter() + .map(|pool_key| build_quote_exact_single_request(&token_in, deployment.quoter, quote_amount_in, &pool_key.1)) + .collect(); + requests.push(Box::pin(async move { initial_client.batch_call_requests(direct_calls).await })); + + let quote_exact_params: Vec, QuoteExactParams)>>; + if !Self::is_base_pair(&token_in, &token_out, &evm_chain) { + let intermediaries = get_intermediaries(&token_in, &token_out, &base_pair); + quote_exact_params = build_quote_exact_params(quote_amount_in, &token_in, &token_out, &fee_tiers, &intermediaries); + build_quote_exact_requests(deployment.quoter, "e_exact_params).iter().for_each(|call_array| { + let client = Arc::clone(&client); + let calls = call_array.clone(); + requests.push(Box::pin(async move { client.batch_call_requests(calls).await })); + }); + } else { + quote_exact_params = vec![]; + } + + let batch_results = join_all(requests).await; + + let quote_result = get_best_quote(&batch_results, super::quoter::decode_quoter_response)?; + + let fee_tier_idx = quote_result.fee_tier_idx; + let batch_idx = quote_result.batch_idx; + + let to_value = if fee_token_is_input { + quote_result.amount_out + } else { + apply_slippage_in_bp("e_result.amount_out, fee_bps) + }; + let to_min_value = apply_slippage_in_bp(&to_value, request.options.slippage.bps); + + let fee_tier: u32 = fee_tiers[fee_tier_idx % fee_tiers.len()] as u32; + let asset_id_in = AssetId::from(from_chain, Some(token_in.to_checksum(None))); + let asset_id_out = AssetId::from(to_chain, Some(token_out.to_checksum(None))); + let asset_id_intermediary: Option = match batch_idx { + 0 => None, + _ => { + let first_token_out = "e_exact_params[batch_idx][0].0[0].token_out; + Some(AssetId::from(to_chain, Some(first_token_out.to_checksum(None)))) + } + }; + let route_data = RouteData { + fee_tier: fee_tier.to_string(), + min_amount_out: to_min_value.to_string(), + }; + let routes = build_swap_route(&asset_id_in, asset_id_intermediary.as_ref(), &asset_id_out, &route_data); + + Ok(Quote { + from_value: request.value.clone(), + min_from_value: None, + to_value: to_value.to_string(), + data: ProviderData { + provider: self.provider().clone(), + routes: routes.clone(), + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: None, + }) + } + + async fn get_permit2_for_quote(&self, quote: &Quote) -> Result, SwapperError> { + let from_asset = quote.request.from_asset.asset_id(); + if requires_native_wrapping(&from_asset) { + return Ok(None); + } + let (_, token_in, _, amount_in) = Self::parse_request("e.request)?; + let deployment = get_uniswap_deployment_by_chain(&from_asset.chain).ok_or(SwapperError::NotSupportedChain)?; + + let client = self.client_for(from_asset.chain)?; + let permit2_data = check_approval_permit2_with_client( + deployment.permit2, + quote.request.wallet_address.clone(), + token_in.to_string(), + deployment.universal_router.to_string(), + U256::from(amount_in), + &client, + ) + .await? + .permit2_data(); + + Ok(permit2_data) + } + + async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { + let request = "e.request; + let from_asset = request.from_asset.asset_id(); + let (_, token_in, token_out, amount_in) = Self::parse_request(request)?; + let deployment = get_uniswap_deployment_by_chain(&from_asset.chain).ok_or(SwapperError::NotSupportedChain)?; + let route_data: RouteData = serde_json::from_str("e.data.routes.first().unwrap().route_data).map_err(|_| SwapperError::InvalidRoute)?; + let to_amount = u128::from_str(&route_data.min_amount_out).map_err(SwapperError::from)?; + + let client = self.client_for(from_asset.chain)?; + let permit = data.permit2_data().map(|data| data.into()); + let wrap_input_eth = requires_native_wrapping(&request.from_asset.asset_id()); + + let approval: Option = if wrap_input_eth { + None + } else { + check_approval_erc20_with_client( + request.wallet_address.clone(), + token_in.to_string(), + deployment.permit2.to_string(), + U256::from(amount_in), + &client, + ) + .await? + .approval_data() + }; + let gas_limit = get_swap_gas_limit_with_approval(&approval, None, DEFAULT_SWAP_GAS_LIMIT); + + let sig_deadline = get_sig_deadline(); + let evm_chain = EVMChain::from_chain(from_asset.chain).ok_or(SwapperError::NotSupportedChain)?; + let base_pair = get_base_pair(&evm_chain, is_native_erc20(from_asset.chain)); + let fee_token_is_input = is_quote_input_fee_token(base_pair.as_ref(), request, token_in, token_out); + + let commands = build_commands(request, &token_in, &token_out, amount_in, to_amount, "e.data.routes, permit, fee_token_is_input)?; + let encoded = encode_commands(&commands, U256::from(sig_deadline)); + + let value = if wrap_input_eth { request.value.clone() } else { String::from("0") }; + + Ok(SwapperQuoteData::new_contract( + deployment.universal_router.into(), + value, + HexEncode(encoded), + approval, + gas_limit, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Options, alien::mock::ProviderMock}; + use std::sync::Arc; + + #[test] + fn test_is_base_pair() { + let provider = Arc::new(ProviderMock::new("{}".to_string())); + let swapper = UniswapV4::new(provider); + let request = QuoteRequest { + from_asset: AssetId::from(Chain::SmartChain, Some("0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82".to_string())).into(), + to_asset: AssetId::from_chain(Chain::SmartChain).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "40000000000000000".into(), // 0.04 Cake + options: Options::default(), + }; + + let (evm_chain, token_in, token_out, _) = UniswapV4::parse_request(&request).unwrap(); + + assert!(UniswapV4::is_base_pair(&token_in, &token_out, &evm_chain)); + // Ensure provider field is used to avoid warnings + assert_eq!(swapper.provider.id, SwapperProvider::UniswapV4); + } + + #[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] + mod swap_integration_tests { + use crate::{FetchQuoteData, NativeProvider, Options, QuoteRequest, SwapperError, uniswap}; + use primitives::{AssetId, Chain}; + use std::{sync::Arc, time::SystemTime}; + + #[tokio::test] + async fn test_v4_quoter() -> Result<(), SwapperError> { + let network_provider = Arc::new(NativeProvider::default()); + let swap_provider = uniswap::default::boxed_uniswap_v4(network_provider.clone()); + let options = Options { + slippage: 100.into(), + use_max_amount: false, + }; + + let request = QuoteRequest { + from_asset: AssetId::from_chain(Chain::Unichain).into(), + to_asset: AssetId::from(Chain::Unichain, Some("0x078D782b760474a361dDA0AF3839290b0EF57AD6".to_string())).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "10000000000000000".into(), // 0.01 ETH + options, + }; + + let now = SystemTime::now(); + let quote = swap_provider.get_quote(&request).await?; + let elapsed = SystemTime::now().duration_since(now).unwrap(); + + println!("<== elapsed: {:?}", elapsed); + println!("<== quote: {:?}", quote); + assert!(quote.to_value.parse::().unwrap() > 0); + + let quote_data = swap_provider.get_quote_data("e, FetchQuoteData::EstimateGas).await?; + println!("<== quote_data: {:?}", quote_data); + + Ok(()) + } + } +} diff --git a/core/crates/swapper/src/uniswap/v4/quoter.rs b/core/crates/swapper/src/uniswap/v4/quoter.rs new file mode 100644 index 0000000000..4690bbd043 --- /dev/null +++ b/core/crates/swapper/src/uniswap/v4/quoter.rs @@ -0,0 +1,104 @@ +use crate::SwapperError; +use alloy_primitives::{Address, Bytes, U256, hex::decode as HexDecode}; +use alloy_sol_types::SolCall; +use gem_evm::{ + jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, + uniswap::{ + contracts::v4::{IV4Quoter, PoolKey}, + path::TokenPair, + }, +}; +use gem_jsonrpc::types::JsonRpcResponse; + +pub fn build_quote_exact_single_request(token_in: &Address, v4_quoter: &str, amount_in: u128, pool: &PoolKey) -> EthereumRpc { + let zero_for_one = *token_in == pool.currency0; + let params = IV4Quoter::QuoteExactSingleParams { + poolKey: pool.clone(), + zeroForOne: zero_for_one, + exactAmount: amount_in, + hookData: Bytes::new(), + }; + let quote_single = IV4Quoter::quoteExactInputSingleCall { params }; + let call_data: Vec = quote_single.abi_encode(); + EthereumRpc::Call(TransactionObject::new_call(v4_quoter, call_data), BlockParameter::Latest) +} + +pub fn build_quote_exact_requests(v4_quoter: &str, quote_params: &[Vec<(Vec, IV4Quoter::QuoteExactParams)>]) -> Vec> { + quote_params + .iter() + .map(|quote_array| quote_array.iter().map(|x| build_quote_exact_request(v4_quoter, &x.1).clone()).collect::>()) + .collect() +} + +pub fn build_quote_exact_request(v4_quoter: &str, params: &IV4Quoter::QuoteExactParams) -> EthereumRpc { + let quote = IV4Quoter::quoteExactInputCall { params: params.clone() }; + let call_data: Vec = quote.abi_encode(); + EthereumRpc::Call(TransactionObject::new_call(v4_quoter, call_data), BlockParameter::Latest) +} + +// Returns (amountOut, gasEstimate) +pub fn decode_quoter_response(response: &JsonRpcResponse) -> Result<(U256, U256), SwapperError> { + let decoded = HexDecode(&response.result).map_err(SwapperError::compute_quote_error)?; + let quoter_return = IV4Quoter::quoteExactInputSingleCall::abi_decode_returns(&decoded).map_err(SwapperError::from)?; + + Ok((quoter_return.amountOut, quoter_return.gasEstimate)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::uniswap::v4::path::{build_pool_keys, build_quote_exact_params}; + use alloy_primitives::{address, hex::encode_prefixed as HexEncode}; + use alloy_sol_types::SolValue; + use gem_evm::uniswap::{FeeTier, path::get_base_pair}; + use gem_hash::keccak::keccak256; + use primitives::{ + EVMChain, + asset_constants::UNICHAIN_USDC_TOKEN_ID, + contract_constants::{OPTIMISM_UNISWAP_V4_QUOTER_CONTRACT, UNICHAIN_UNISWAP_V4_QUOTER_CONTRACT}, + }; + + #[test] + fn test_build_quote_exact_single_request() { + let token_in = address!("0x0000000000000000000000000000000000000000"); + let token_out = UNICHAIN_USDC_TOKEN_ID.parse().unwrap(); + let fee_tiers = vec![FeeTier::ThreeThousand]; + + let v4_quoter = UNICHAIN_UNISWAP_V4_QUOTER_CONTRACT; + let amount_in = 10000000000000000_u128; + let pool_keys = build_pool_keys(&token_in, &token_out, &fee_tiers); + + assert_eq!(pool_keys.len(), 1); + + let pool_key = &pool_keys[0].1; + let pool_key_bytes = pool_key.abi_encode(); + let pool_id = keccak256(&pool_key_bytes); + + assert_eq!(HexEncode(pool_id), "0x25939956ef14a098d95051d86c75890cfd623a9eeba055e46d8dd9135980b37c"); + + let rpc = build_quote_exact_single_request(&token_in, v4_quoter, amount_in, pool_key); + + if let EthereumRpc::Call(call, _) = rpc { + assert!(call.data.starts_with("0xaa9d21cb")); + } + } + + #[test] + fn test_build_quote_exact_request() { + let token_in = address!("0x6fd9d7AD17242c41f7131d257212c54A0e816691"); // UNI + let token_out = address!("0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6"); // LINK + let fee_tiers = vec![FeeTier::ThreeThousand, FeeTier::FiveHundred, FeeTier::Hundred]; + let base_pair = get_base_pair(&EVMChain::Optimism, false).unwrap(); + + let v4_quoter = OPTIMISM_UNISWAP_V4_QUOTER_CONTRACT; + let amount_in = 10000000000000000_u128; + + let quote_params = build_quote_exact_params(amount_in, &token_in, &token_out, &fee_tiers, &base_pair.path_building_array()); + let rpc_calls = build_quote_exact_requests(v4_quoter, "e_params); + + assert_eq!(rpc_calls.len(), 3); // 3 intermediaries (ETH, USDC, USDT) + + // 3 fee tiers + rpc_calls.iter().for_each(|call_array| assert_eq!(call_array.len(), 3)); + } +} diff --git a/core/crates/swapper/testdata/squid/status_response.json b/core/crates/swapper/testdata/squid/status_response.json new file mode 100644 index 0000000000..ccf73aaa0d --- /dev/null +++ b/core/crates/swapper/testdata/squid/status_response.json @@ -0,0 +1,7 @@ +{ + "id": "D68723CEADAB65795B176FAE0B84B0ED5923DA9AAEC69502F8D30554431250A9", + "status": "destination_executed", + "squidTransactionStatus": "success", + "gasStatus": "", + "isGMPTransaction": false +} diff --git a/core/crates/tracing/Cargo.toml b/core/crates/tracing/Cargo.toml new file mode 100644 index 0000000000..191f817866 --- /dev/null +++ b/core/crates/tracing/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "gem_tracing" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/core/crates/tracing/src/lib.rs b/core/crates/tracing/src/lib.rs new file mode 100644 index 0000000000..273ae51335 --- /dev/null +++ b/core/crates/tracing/src/lib.rs @@ -0,0 +1,128 @@ +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::FmtSubscriber; + +pub use tracing; + +static TRACING_SUBSCRIBER: OnceLock> = OnceLock::new(); + +pub fn get_subscriber() -> Arc { + TRACING_SUBSCRIBER.get_or_init(|| Arc::new(tracing_subscriber::fmt().with_target(false).finish())).clone() +} + +pub fn init_tracing(default_filter: &str) { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)); + let _ = tracing_subscriber::fmt().with_env_filter(filter).with_target(false).try_init(); +} + +pub fn human_duration(duration: Duration) -> String { + if duration.is_zero() { + return "0s".to_string(); + } + + let mut parts = Vec::new(); + let mut remaining = duration.as_secs(); + const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)]; + + for (label, unit) in UNITS { + if remaining >= unit { + let value = remaining / unit; + remaining %= unit; + parts.push(format!("{value}{label}")); + if parts.len() == 2 { + break; + } + } + } + + if parts.is_empty() { format!("{}ms", duration.subsec_millis()) } else { parts.join(" ") } +} + +pub struct DurationMs(pub Duration); + +impl std::fmt::Display for DurationMs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&human_duration(self.0)) + } +} + +fn format_fields(fields: &[(&str, &dyn std::fmt::Display)]) -> String { + fields.iter().map(|(key, value)| format!("{key}={value}")).collect::>().join(" ") +} + +pub fn info_with_fields_impl(message: &str, fields: &[(&str, &dyn std::fmt::Display)]) { + let subscriber = get_subscriber(); + tracing::subscriber::with_default(subscriber, || { + let pairs = format_fields(fields); + if pairs.is_empty() { + tracing::info!("{}", message); + } else { + tracing::info!("{} {}", message, pairs); + } + }); +} + +pub fn error_fields_impl(message: &str, fields: &[(&str, &dyn std::fmt::Display)]) { + let subscriber = get_subscriber(); + tracing::subscriber::with_default(subscriber, || { + let pairs = format_fields(fields); + if pairs.is_empty() { + tracing::error!("{}", message); + } else { + tracing::error!("{} {}", message, pairs); + } + }); +} + +pub fn error_with_fields_impl(message: &str, error: &E, fields: &[(&str, &dyn std::fmt::Display)]) { + let subscriber = get_subscriber(); + tracing::subscriber::with_default(subscriber, || { + let pairs = format_fields(fields); + if pairs.is_empty() { + tracing::error!("{} error={}", message, error); + } else { + tracing::error!("{} {} error={}", message, pairs, error); + } + }); +} + +#[macro_export] +macro_rules! info_with_fields { + ($message:expr $(, $field:ident = $value:expr)* $(,)?) => { + { + let fields: &[(&str, &dyn std::fmt::Display)] = &[ + $((stringify!($field), &$value),)* + ]; + $crate::info_with_fields_impl($message, fields); + } + }; +} + +#[macro_export] +macro_rules! error_fields { + ($message:expr $(, $field:ident = $value:expr)* $(,)?) => { + { + let fields: &[(&str, &dyn std::fmt::Display)] = &[ + $((stringify!($field), &$value),)* + ]; + $crate::error_fields_impl($message, fields); + } + }; +} + +#[macro_export] +macro_rules! error_with_fields { + ($message:expr, $error:expr $(, $field:ident = $value:expr)* $(,)?) => { + { + let fields: &[(&str, &dyn std::fmt::Display)] = &[ + $((stringify!($field), &$value),)* + ]; + $crate::error_with_fields_impl($message, $error, fields); + } + }; +} + +pub fn error(message: &str, error: &E) { + error_with_fields_impl(message, error, &[]); +} diff --git a/core/crates/yielder/Cargo.toml b/core/crates/yielder/Cargo.toml new file mode 100644 index 0000000000..8e49f80334 --- /dev/null +++ b/core/crates/yielder/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "yielder" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = [] +yield_integration_tests = [ + "gem_jsonrpc/reqwest", + "gem_client/reqwest", + "tokio/rt-multi-thread", +] + +[dependencies] +alloy-primitives = { workspace = true } +alloy-sol-types = { workspace = true } +num-bigint = { workspace = true } +gem_client = { path = "../gem_client" } +gem_evm = { path = "../gem_evm", features = ["rpc"] } +gem_jsonrpc = { path = "../gem_jsonrpc" } +primitives = { path = "../primitives" } +async-trait = { workspace = true } +futures = { workspace = true } + +[dev-dependencies] +gem_client = { path = "../gem_client", features = ["reqwest"] } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["reqwest"] } +reqwest = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/core/crates/yielder/src/client_factory.rs b/core/crates/yielder/src/client_factory.rs new file mode 100644 index 0000000000..a3da2fa71c --- /dev/null +++ b/core/crates/yielder/src/client_factory.rs @@ -0,0 +1,17 @@ +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::alien::{self, RpcClient, RpcProvider}; +use gem_jsonrpc::client::JsonRpcClient; +use primitives::{Chain, EVMChain}; +use std::sync::Arc; + +use crate::YielderError; + +pub fn create_client(provider: Arc, chain: Chain) -> Result, YielderError> { + alien::create_client(provider, chain).map_err(|_| YielderError::NotSupportedChain) +} + +pub fn create_eth_client(provider: Arc, chain: Chain) -> Result, YielderError> { + let evm_chain = EVMChain::from_chain(chain).ok_or(YielderError::NotSupportedChain)?; + let client = create_client(provider, chain)?; + Ok(EthereumClient::new(client, evm_chain)) +} diff --git a/core/crates/yielder/src/error.rs b/core/crates/yielder/src/error.rs new file mode 100644 index 0000000000..5e5494ec66 --- /dev/null +++ b/core/crates/yielder/src/error.rs @@ -0,0 +1,42 @@ +use std::error::Error; +use std::fmt::{self, Formatter}; + +use alloy_primitives::hex::FromHexError; +use alloy_primitives::ruint::ParseError; + +#[derive(Debug, Clone)] +pub enum YielderError { + NetworkError(String), + NotSupportedChain, + NotSupportedAsset, +} + +impl fmt::Display for YielderError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::NetworkError(msg) => write!(f, "{msg}"), + Self::NotSupportedChain => write!(f, "Not supported chain"), + Self::NotSupportedAsset => write!(f, "Not supported asset"), + } + } +} + +impl Error for YielderError {} + +impl From for YielderError { + fn from(err: FromHexError) -> Self { + Self::NetworkError(err.to_string()) + } +} + +impl From for YielderError { + fn from(err: ParseError) -> Self { + Self::NetworkError(err.to_string()) + } +} + +impl From> for YielderError { + fn from(err: Box) -> Self { + Self::NetworkError(err.to_string()) + } +} diff --git a/core/crates/yielder/src/lib.rs b/core/crates/yielder/src/lib.rs new file mode 100644 index 0000000000..1b93dee11d --- /dev/null +++ b/core/crates/yielder/src/lib.rs @@ -0,0 +1,9 @@ +mod client_factory; +mod error; +mod provider; +mod yielder; +mod yo; + +pub use error::YielderError; +pub use provider::EarnProvider; +pub use yielder::Yielder; diff --git a/core/crates/yielder/src/provider.rs b/core/crates/yielder/src/provider.rs new file mode 100644 index 0000000000..18c76e7251 --- /dev/null +++ b/core/crates/yielder/src/provider.rs @@ -0,0 +1,13 @@ +use async_trait::async_trait; +use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationValidator, EarnType}; + +use crate::error::YielderError; + +#[async_trait] +pub trait EarnProvider: Send + Sync { + fn get_provider(&self, asset_id: &AssetId) -> Option; + + async fn get_position(&self, address: &str, asset_id: &AssetId) -> Result, YielderError>; + async fn get_balance(&self, chain: Chain, address: &str, token_ids: &[String]) -> Result, YielderError>; + async fn get_data(&self, asset_id: &AssetId, address: &str, value: &str, earn_type: &EarnType) -> Result; +} diff --git a/core/crates/yielder/src/yielder.rs b/core/crates/yielder/src/yielder.rs new file mode 100644 index 0000000000..8001cdacc9 --- /dev/null +++ b/core/crates/yielder/src/yielder.rs @@ -0,0 +1,60 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use gem_jsonrpc::alien::RpcProvider; +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationValidator, EarnType}; + +use crate::error::YielderError; +use crate::provider::EarnProvider; +use crate::yo::YoEarnProvider; + +pub struct Yielder { + providers: Vec>, +} + +impl Yielder { + pub fn new(rpc_provider: Arc) -> Self { + Self::with_providers(vec![Arc::new(YoEarnProvider::new(rpc_provider))]) + } + + pub fn with_providers(providers: Vec>) -> Self { + Self { providers } + } + + pub fn get_providers(&self, asset_id: &AssetId) -> Vec { + self.providers.iter().filter_map(|p| p.get_provider(asset_id)).collect() + } + + pub async fn get_positions(&self, address: &str, asset_id: &AssetId) -> Vec { + let futures: Vec<_> = self.providers.iter().map(|p| p.get_position(address, asset_id)).collect(); + futures::future::join_all(futures).await.into_iter().filter_map(|r| r.ok().flatten()).collect() + } + + pub async fn get_balance(&self, chain: Chain, address: &str, token_ids: &[String]) -> Vec { + let futures: Vec<_> = self.providers.iter().map(|p| p.get_balance(chain, address, token_ids)).collect(); + let balances = futures::future::join_all(futures).await.into_iter().filter_map(|r| r.ok()).flatten().collect(); + Self::map_earn_balances(balances) + } + + pub async fn get_data(&self, asset_id: &AssetId, address: &str, value: &str, earn_type: &EarnType) -> Result { + self.providers + .iter() + .find(|p| p.get_provider(asset_id).is_some_and(|v| v.id == earn_type.provider_id())) + .ok_or(YielderError::NotSupportedAsset)? + .get_data(asset_id, address, value, earn_type) + .await + } + + fn map_earn_balances(balances: Vec) -> Vec { + balances + .into_iter() + .fold(HashMap::::new(), |mut acc, b| { + *acc.entry(b.asset_id).or_default() += b.balance.earn; + acc + }) + .into_iter() + .map(|(id, earn)| AssetBalance::new_earn(id, earn)) + .collect() + } +} diff --git a/core/crates/yielder/src/yo/assets.rs b/core/crates/yielder/src/yo/assets.rs new file mode 100644 index 0000000000..14c551ae99 --- /dev/null +++ b/core/crates/yielder/src/yo/assets.rs @@ -0,0 +1,31 @@ +use alloy_primitives::{Address, address}; +use primitives::{AssetId, Chain}; + +#[derive(Debug, Clone, Copy)] +pub struct YoAsset { + pub chain: Chain, + pub asset_token: Address, + pub yo_token: Address, +} + +impl YoAsset { + pub fn asset_id(&self) -> AssetId { + AssetId::from_token(self.chain, &self.asset_token.to_string()) + } +} + +pub const YO_USDC: YoAsset = YoAsset { + chain: Chain::Base, + asset_token: address!("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + yo_token: address!("0x0000000f2eB9f69274678c76222B35eEc7588a65"), +}; + +pub const YO_USDT: YoAsset = YoAsset { + chain: Chain::Ethereum, + asset_token: address!("0xdAC17F958D2ee523a2206206994597C13D831ec7"), + yo_token: address!("0xb9a7da9e90D3B428083BAe04b860faA6325b721e"), +}; + +pub fn supported_assets() -> &'static [YoAsset] { + &[YO_USDC, YO_USDT] +} diff --git a/core/crates/yielder/src/yo/client.rs b/core/crates/yielder/src/yo/client.rs new file mode 100644 index 0000000000..ea413ce45e --- /dev/null +++ b/core/crates/yielder/src/yo/client.rs @@ -0,0 +1,150 @@ +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolCall; +use gem_evm::contracts::IERC20; +use gem_evm::contracts::erc4626::IERC4626; +use gem_evm::jsonrpc::TransactionObject; +use gem_evm::multicall3::{create_call3, decode_call3_return}; +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::alien::RpcClient; +use primitives::swap::ApprovalData; + +use super::assets::YoAsset; +use super::contract::IYoGateway; +use crate::error::YielderError; + +#[derive(Debug, Clone)] +pub struct PositionData { + pub share_balance: U256, + pub asset_balance: U256, +} + +pub struct YoGatewayClient { + ethereum_client: EthereumClient, + contract_address: Address, +} + +impl YoGatewayClient { + pub fn new(ethereum_client: EthereumClient, contract_address: Address) -> Self { + Self { + ethereum_client, + contract_address, + } + } + + pub fn build_deposit_transaction(&self, from: Address, yo_token: Address, assets: U256, min_shares_out: U256, receiver: Address, partner_id: u32) -> TransactionObject { + let data = IYoGateway::depositCall { + yoVault: yo_token, + assets, + minSharesOut: min_shares_out, + receiver, + partnerId: partner_id, + } + .abi_encode(); + TransactionObject::new_call_with_from(&from.to_string(), &self.contract_address.to_string(), data) + } + + pub fn build_redeem_transaction(&self, from: Address, yo_token: Address, shares: U256, min_assets_out: U256, receiver: Address, partner_id: u32) -> TransactionObject { + let data = IYoGateway::redeemCall { + yoVault: yo_token, + shares, + minAssetsOut: min_assets_out, + receiver, + partnerId: partner_id, + } + .abi_encode(); + TransactionObject::new_call_with_from(&from.to_string(), &self.contract_address.to_string(), data) + } + + pub async fn get_positions(&self, assets: &[YoAsset], owner: Address) -> Result, YielderError> { + Ok(self + .ethereum_client + .multicall3_map( + assets, + |a| { + let vault = a.yo_token.to_string(); + [ + create_call3(&vault, IERC4626::balanceOfCall { account: owner }), + create_call3(&vault, IERC4626::totalAssetsCall {}), + create_call3(&vault, IERC4626::totalSupplyCall {}), + ] + }, + |c| { + let shares = decode_call3_return::(&c[0])?; + let total_assets = decode_call3_return::(&c[1])?; + let total_supply = decode_call3_return::(&c[2])?; + Ok(PositionData { + share_balance: shares, + asset_balance: convert_to_assets_ceil(shares, total_assets, total_supply), + }) + }, + ) + .await?) + } + + pub async fn check_token_allowance(&self, token: Address, owner: Address, amount: U256) -> Result, YielderError> { + let spender = self.contract_address; + let allowance = self.ethereum_client.call_contract(token, IERC20::allowanceCall { owner, spender }).await?; + + if allowance < amount { + Ok(Some(build_token_approval_data(token, spender, amount))) + } else { + Ok(None) + } + } + + pub async fn get_quote_shares(&self, yo_token: Address, assets: U256) -> Result { + let call = IYoGateway::quoteConvertToSharesCall { yoVault: yo_token, assets }; + Ok(self.ethereum_client.call_contract(self.contract_address, call).await?) + } +} + +/// ERC4626 ceiling division: rounds up instead of down so display matches deposited amount. +fn convert_to_assets_ceil(shares: U256, total_assets: U256, total_supply: U256) -> U256 { + if shares.is_zero() || total_supply.is_zero() { + return U256::ZERO; + } + (shares * total_assets + total_supply - U256::from(1)) / total_supply +} + +fn build_token_approval_data(token: Address, spender: Address, amount: U256) -> ApprovalData { + ApprovalData { + token: token.to_string(), + spender: spender.to_string(), + value: amount.to_string(), + is_unlimited: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_to_assets_ceil() { + assert_eq!(convert_to_assets_ceil(U256::from(100), U256::from(1000), U256::from(500)), U256::from(200)); + assert_eq!(convert_to_assets_ceil(U256::from(1), U256::from(1000), U256::from(500)), U256::from(2)); + assert_eq!(convert_to_assets_ceil(U256::from(500), U256::from(1000), U256::from(500)), U256::from(1000)); + assert_eq!(convert_to_assets_ceil(U256::from(1), U256::from(10), U256::from(3)), U256::from(4)); + assert_eq!(convert_to_assets_ceil(U256::from(2), U256::from(10), U256::from(3)), U256::from(7)); + assert_eq!(convert_to_assets_ceil(U256::ZERO, U256::from(1000), U256::from(500)), U256::ZERO); + assert_eq!(convert_to_assets_ceil(U256::from(100), U256::from(1000), U256::ZERO), U256::ZERO); + assert_eq!(convert_to_assets_ceil(U256::ZERO, U256::ZERO, U256::ZERO), U256::ZERO); + } + + #[test] + fn test_build_token_approval_data() { + let token = Address::from([1; 20]); + let spender = Address::from([2; 20]); + let amount = U256::from(1234); + + assert_eq!( + build_token_approval_data(token, spender, amount), + ApprovalData { + token: token.to_string(), + spender: spender.to_string(), + value: "1234".to_string(), + is_unlimited: true, + } + ); + } +} diff --git a/core/crates/yielder/src/yo/contract.rs b/core/crates/yielder/src/yo/contract.rs new file mode 100644 index 0000000000..237df6ff87 --- /dev/null +++ b/core/crates/yielder/src/yo/contract.rs @@ -0,0 +1,9 @@ +use alloy_sol_types::sol; + +sol! { + interface IYoGateway { + function quoteConvertToShares(address yoVault, uint256 assets) external view returns (uint256 shares); + function deposit(address yoVault, uint256 assets, uint256 minSharesOut, address receiver, uint32 partnerId) external returns (uint256 sharesOut); + function redeem(address yoVault, uint256 shares, uint256 minAssetsOut, address receiver, uint32 partnerId) external returns (uint256 assetsOrRequestId); + } +} diff --git a/core/crates/yielder/src/yo/mapper.rs b/core/crates/yielder/src/yo/mapper.rs new file mode 100644 index 0000000000..d822bb358a --- /dev/null +++ b/core/crates/yielder/src/yo/mapper.rs @@ -0,0 +1,118 @@ +use alloy_primitives::U256; +use gem_evm::jsonrpc::TransactionObject; +use gem_evm::u256::u256_to_biguint; +use num_bigint::BigUint; +use primitives::swap::ApprovalData; +use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationState, DelegationValidator, StakeProviderType, YieldProvider}; + +use super::assets::YoAsset; +use super::client::PositionData; + +pub fn map_to_delegation(asset_id: AssetId, data: &PositionData, provider_id: &str) -> DelegationBase { + DelegationBase { + delegation_id: format!("{}-{}", provider_id, asset_id), + validator_id: provider_id.to_string(), + asset_id, + state: DelegationState::Active, + balance: u256_to_biguint(&data.asset_balance), + shares: u256_to_biguint(&data.share_balance), + rewards: BigUint::ZERO, + completion_date: None, + } +} + +pub fn map_to_asset_balance(asset: &YoAsset, data: &PositionData) -> AssetBalance { + let balance = if data.share_balance != U256::ZERO { + u256_to_biguint(&data.asset_balance) + } else { + BigUint::ZERO + }; + AssetBalance::new_earn(asset.asset_id(), balance) +} + +pub fn map_to_contract_call_data(transaction: TransactionObject, approval: Option, gas_limit: u64) -> ContractCallData { + ContractCallData { + contract_address: transaction.to, + call_data: transaction.data, + approval, + gas_limit: Some(gas_limit.to_string()), + } +} + +pub fn map_to_earn_provider(chain: Chain, provider: YieldProvider) -> DelegationValidator { + DelegationValidator { + chain, + id: provider.as_ref().to_string(), + name: provider.name().to_string(), + is_active: true, + commission: 0.0, + apr: 0.0, + provider_type: StakeProviderType::Earn, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::yo::assets::YO_USDC; + + #[test] + fn test_map_to_delegation() { + let data = PositionData { + share_balance: U256::from(1_000_000), + asset_balance: U256::from(1_050_000), + }; + + let result = map_to_delegation(YO_USDC.asset_id(), &data, "yo"); + + assert_eq!(result.delegation_id, format!("yo-{}", YO_USDC.asset_id())); + assert_eq!(result.balance, BigUint::from(1_050_000u64)); + assert_eq!(result.shares, BigUint::from(1_000_000u64)); + } + + #[test] + fn test_map_to_earn_provider() { + let result = map_to_earn_provider(Chain::Base, YieldProvider::Yo); + + assert_eq!(result.id, "yo"); + assert_eq!(result.name, "Yo"); + assert_eq!(result.chain, Chain::Base); + assert_eq!(result.apr, 0.0); + assert_eq!(result.provider_type, StakeProviderType::Earn); + } + + #[test] + fn test_map_to_asset_balance() { + assert_eq!( + map_to_asset_balance( + &YO_USDC, + &PositionData { + share_balance: U256::from(1_000_000), + asset_balance: U256::from(1_050_000) + } + ) + .balance + .earn, + BigUint::from(1_050_000u64) + ); + assert_eq!( + map_to_asset_balance( + &YO_USDC, + &PositionData { + share_balance: U256::ZERO, + asset_balance: U256::from(1_050_000) + } + ) + .balance + .earn, + BigUint::ZERO + ); + } + + #[test] + fn test_map_to_contract_call_data() { + let result = map_to_contract_call_data(TransactionObject::new_call("0xcontract", vec![]), None, 300_000); + assert_eq!(result.contract_address, "0xcontract"); + assert_eq!(result.gas_limit, Some("300000".to_string())); + } +} diff --git a/core/crates/yielder/src/yo/mod.rs b/core/crates/yielder/src/yo/mod.rs new file mode 100644 index 0000000000..c06a7f7e88 --- /dev/null +++ b/core/crates/yielder/src/yo/mod.rs @@ -0,0 +1,13 @@ +mod assets; +mod client; +mod contract; +mod mapper; +mod provider; + +use assets::{YoAsset, supported_assets}; +pub use provider::YoEarnProvider; + +use alloy_primitives::{Address, address}; + +const YO_GATEWAY: Address = address!("0xF1EeE0957267b1A474323Ff9CfF7719E964969FA"); +const YO_PARTNER_ID_GEM: u32 = 6548; diff --git a/core/crates/yielder/src/yo/provider.rs b/core/crates/yielder/src/yo/provider.rs new file mode 100644 index 0000000000..db5b014a5d --- /dev/null +++ b/core/crates/yielder/src/yo/provider.rs @@ -0,0 +1,116 @@ +use std::slice::from_ref; +use std::sync::Arc; + +use alloy_primitives::{Address, U256}; +use async_trait::async_trait; +use gem_evm::slippage::apply_slippage_in_bp; +use gem_evm::u256::biguint_to_u256; +use gem_jsonrpc::alien::RpcProvider; +use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationValidator, EarnType, YieldProvider}; + +use crate::client_factory::create_eth_client; +use crate::error::YielderError; +use crate::provider::EarnProvider; + +use super::client::YoGatewayClient; +use super::mapper::{map_to_asset_balance, map_to_contract_call_data, map_to_delegation, map_to_earn_provider}; +use super::{YO_GATEWAY, YO_PARTNER_ID_GEM, YoAsset, supported_assets}; + +const GAS_LIMIT: u64 = 300_000; +const SLIPPAGE_BPS: u32 = 50; + +pub struct YoEarnProvider { + assets: &'static [YoAsset], + rpc_provider: Arc, +} + +impl YoEarnProvider { + pub fn new(rpc_provider: Arc) -> Self { + Self { + assets: supported_assets(), + rpc_provider, + } + } + + fn get_assets(&self, chain: Chain, token_ids: &[String]) -> Vec { + self.assets + .iter() + .filter(|a| a.chain == chain && token_ids.contains(&a.asset_token.to_string())) + .copied() + .collect() + } + + fn get_asset(&self, asset_id: &AssetId) -> Result { + self.assets.iter().find(|a| a.asset_id() == *asset_id).copied().ok_or(YielderError::NotSupportedAsset) + } + + fn get_client(&self, chain: Chain) -> Result { + let client = create_eth_client(self.rpc_provider.clone(), chain)?; + Ok(YoGatewayClient::new(client, YO_GATEWAY)) + } + + async fn get_positions(&self, chain: Chain, address: &str, assets: &[YoAsset]) -> Result, YielderError> { + let client = self.get_client(chain)?; + let owner: Address = address.parse()?; + client.get_positions(assets, owner).await + } +} + +#[async_trait] +impl EarnProvider for YoEarnProvider { + fn get_provider(&self, asset_id: &AssetId) -> Option { + self.get_asset(asset_id).ok().map(|a| map_to_earn_provider(a.chain, YieldProvider::Yo)) + } + + async fn get_position(&self, address: &str, asset_id: &AssetId) -> Result, YielderError> { + let asset = self.get_asset(asset_id)?; + let positions = self.get_positions(asset.chain, address, from_ref(&asset)).await?; + let delegation = positions + .into_iter() + .find(|d| d.share_balance != U256::ZERO) + .map(|data| map_to_delegation(asset.asset_id(), &data, YieldProvider::Yo.as_ref())); + Ok(delegation) + } + + async fn get_balance(&self, chain: Chain, address: &str, token_ids: &[String]) -> Result, YielderError> { + let assets = self.get_assets(chain, token_ids); + if assets.is_empty() { + return Ok(vec![]); + } + let positions = self.get_positions(chain, address, &assets).await?; + let balances = assets.iter().zip(positions).map(|(asset, data)| map_to_asset_balance(asset, &data)).collect(); + Ok(balances) + } + + async fn get_data(&self, asset_id: &AssetId, address: &str, value: &str, earn_type: &EarnType) -> Result { + let asset = self.get_asset(asset_id)?; + let client = self.get_client(asset.chain)?; + let wallet: Address = address.parse()?; + let amount: U256 = value.parse()?; + + let (approval, transaction) = match earn_type { + EarnType::Deposit(_) => { + let approval = client.check_token_allowance(asset.asset_token, wallet, amount).await?; + let expected_shares = client.get_quote_shares(asset.yo_token, amount).await?; + let min_shares_out = apply_slippage_in_bp(&expected_shares, SLIPPAGE_BPS); + let transaction = client.build_deposit_transaction(wallet, asset.yo_token, amount, min_shares_out, wallet, YO_PARTNER_ID_GEM); + (approval, transaction) + } + EarnType::Withdraw(delegation) => { + let total_shares = biguint_to_u256(&delegation.base.shares).ok_or_else(|| YielderError::NetworkError("Invalid shares".to_string()))?; + let computed_shares = client.get_quote_shares(asset.yo_token, amount).await?; + let redeem_shares = if total_shares > computed_shares && total_shares - computed_shares <= U256::from(1) { + total_shares + } else { + computed_shares.min(total_shares) + }; + let approval = client.check_token_allowance(asset.yo_token, wallet, redeem_shares).await?; + let min_assets_out = apply_slippage_in_bp(&amount, SLIPPAGE_BPS); + let transaction = client.build_redeem_transaction(wallet, asset.yo_token, redeem_shares, min_assets_out, wallet, YO_PARTNER_ID_GEM); + (approval, transaction) + } + }; + + Ok(map_to_contract_call_data(transaction, approval, GAS_LIMIT)) + } +} diff --git a/core/diesel.toml b/core/diesel.toml new file mode 100644 index 0000000000..85e3d8d6d5 --- /dev/null +++ b/core/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "crates/storage/src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId"] + +[migrations_directory] +dir = "crates/storage/src/migrations" \ No newline at end of file diff --git a/core/docker-compose.yml b/core/docker-compose.yml new file mode 100644 index 0000000000..a424ca8bb7 --- /dev/null +++ b/core/docker-compose.yml @@ -0,0 +1,152 @@ +services: + app_build: + image: app + container_name: app_build + build: + context: . + dockerfile: Dockerfile + + setup: + image: app + container_name: setup + environment: + REDIS_URL: redis://default:@redis:6379 + POSTGRES_URL: postgres://username:password@postgres/api + MEILISEARCH_URL: http://meilisearch:7700 + RABBITMQ_URL: amqp://username:password@rabbitmq:5672 + command: ["sh", "-c", "/app/setup"] + depends_on: + - app_build + - redis + - postgres + - meilisearch + + api: + image: app + container_name: api + environment: + ROCKET_ADDRESS: 0.0.0.0 + ROCKET_PORT: 8000 + BINARY: api + REDIS_URL: redis://default:@redis:6379 + POSTGRES_URL: postgres://username:password@postgres/api + MEILISEARCH_URL: http://meilisearch:7700 + RABBITMQ_URL: amqp://username:password@rabbitmq:5672 + ports: + - 8000:8000 + restart: always + command: ["sh", "-c", "/app/api"] + depends_on: + - app_build + - redis + - postgres + + dynode: + container_name: dynode + build: + context: . + dockerfile: Dockerfile + target: dynode + environment: + ADDRESS: 0.0.0.0 + PORT: 8002 + ports: + - 8002:8002 + restart: always + command: ["sh", "-c", "/app/dynode"] + depends_on: + - redis + - postgres + volumes: + - ./apps/dynode/chains.yml:/app/chains.yml + networks: + default: {} + dynode_internal: + aliases: + - ethereum.dynode.internal + - bitcoin.dynode.internal + + daemon: + image: app + container_name: daemon + environment: + REDIS_URL: redis://default:@redis:6379 + POSTGRES_URL: postgres://username:password@postgres/api + MEILISEARCH_URL: http://meilisearch:7700 + RABBITMQ_URL: amqp://username:password@rabbitmq:5672 + command: ["sh", "-c", "/app/daemon search"] + depends_on: + - app_build + - setup + - redis + - postgres + networks: + default: {} + dynode_internal: {} + + parser: + image: app + container_name: parser + environment: + POSTGRES_URL: postgres://username:password@postgres/api + RABBITMQ_URL: amqp://username:password@rabbitmq:5672 + CHAINS_BITCOIN_URL: http://bitcoin.dynode.internal:8002 + command: ["sh", "-c", "/app/parser bitcoin"] + depends_on: + - app_build + - setup + - postgres + - redis + - meilisearch + networks: + default: {} + dynode_internal: {} + + redis: + image: redis:8.6.3-alpine + container_name: redis + restart: always + ports: + - 6379:6379 + + postgres: + image: postgres:18.4-trixie + container_name: postgres + ports: + - 5432:5432 + restart: always + environment: + POSTGRES_USER: username + POSTGRES_PASSWORD: password + POSTGRES_DB: api + + rabbitmq: + image: rabbitmq:4.3.0-management + container_name: rabbitmq + restart: unless-stopped + ports: + - 5672:5672 + - 15672:15672 + environment: + RABBITMQ_DEFAULT_USER: username + RABBITMQ_DEFAULT_PASS: password + + meilisearch: + image: getmeili/meilisearch:v1.44.0 + container_name: meilisearch + restart: always + ports: + - 7700:7700 + environment: + MEILI_NO_ANALYTICS: true + MEILI_EXPERIMENTAL_ENABLE_METRICS: true + volumes: + - meilisearch:/meili_data + +volumes: + postgres: + + meilisearch: + +networks: + dynode_internal: diff --git a/core/docs/DEVICE_AUTHENTICATION.md b/core/docs/DEVICE_AUTHENTICATION.md new file mode 100644 index 0000000000..23db87e18c --- /dev/null +++ b/core/docs/DEVICE_AUTHENTICATION.md @@ -0,0 +1,85 @@ +# Device Authentication + +## Overview + +All `/v2/devices/*` endpoints require Ed25519 request signing. New clients should use the Gem `Authorization` header. Individual `x-device-*` headers remain supported for existing clients and should be treated as legacy compatibility. + +## Gem Authorization Header + +``` +Authorization: Gem base64(....) +``` + +The decoded payload is always 5 dot-separated parts: +- `device_id_hex` - 64-character hex Ed25519 public key +- `timestamp_ms` - Unix timestamp in milliseconds +- `wallet_id` - wallet identifier, or an empty string for non-wallet endpoints +- `body_hash_hex` - 64-character hex SHA256 hash of the request body +- `signature_hex` - 128-character hex Ed25519 signature + +When `wallet_id` is empty, the payload contains `..` between timestamp and body hash. + +**Signed message:** + +``` +{timestamp}.{method}.{path}.{walletId}.{bodyHash} +``` + +Examples: + +``` +1706000000000.GET./v2/devices..e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +1706000000000.GET./v2/devices/assets.multicoin_0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +## Legacy Individual Headers + +The server still accepts individual headers for compatibility: + +- `x-device-id`: 64-character hex Ed25519 public key +- `x-device-signature`: Ed25519 signature, hex or base64 +- `x-device-timestamp`: Unix timestamp in milliseconds +- `x-device-body-hash`: 64-character hex SHA256 hash of the request body +- `x-wallet-id`: wallet identifier for wallet-scoped endpoints + +**Signed message:** + +``` +v1.{timestamp}.{method}.{path}.{bodyHash} +``` + +The legacy signed message does not include `walletId`; new clients should not add new dependencies on this format. + +## Request Examples + +### Gem Wallet-scoped Endpoint + +```http +GET /v2/devices/assets?from_timestamp=1234567890 +Authorization: Gem base64(abc123...def456.1706000000000.multicoin_0x742d...f0bEb.e3b0c44...b855.aabb11...) +``` + +### Gem Non-wallet Endpoint + +```http +GET /v2/devices +Authorization: Gem base64(abc123...def456.1706000000000..e3b0c44...b855.aabb11...) +``` + +### Legacy Individual Headers + +```http +GET /v2/devices/assets?from_timestamp=1234567890 +x-device-id: abc123...def456 +x-wallet-id: multicoin_0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb +x-device-signature: aabb11... +x-device-timestamp: 1706000000000 +x-device-body-hash: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +## Implementation + +- Request signature verification: [`apps/api/src/devices/signature.rs`](../apps/api/src/devices/signature.rs) +- Cryptographic verification: [`crates/gem_auth/src/device_signature.rs`](../crates/gem_auth/src/device_signature.rs) +- Request guards: [`apps/api/src/devices/guard/`](../apps/api/src/devices/guard/) +- Error handling: [`apps/api/src/devices/error.rs`](../apps/api/src/devices/error.rs) diff --git a/core/docs/DEVICE_WEBSOCKETS.md b/core/docs/DEVICE_WEBSOCKETS.md new file mode 100644 index 0000000000..3109dfab65 --- /dev/null +++ b/core/docs/DEVICE_WEBSOCKETS.md @@ -0,0 +1,123 @@ +# Device WebSocket Streaming + +## Overview + +Real-time price, balance, and transaction updates via authenticated WebSocket connection using device authentication. + +## Endpoint + +``` +wss://api.gemwallet.com/v2/devices/stream +``` + +## Authentication + +Uses the same device authentication as all `/v2/devices/*` endpoints. + +**For complete authentication details, see:** [Device Authentication](DEVICE_AUTHENTICATION.md) + +## Protocol + +### Client → Server Messages + +**Subscribe to Prices:** +```json +{ + "type": "subscribePrices", + "data": { + "assets": ["bitcoin", "ethereum"] + } +} +``` + +**Get Current Prices Once:** +```json +{ + "type": "getPrices", + "data": { + "assets": ["bitcoin", "ethereum"] + } +} +``` + +**Add More Assets:** +```json +{ + "type": "addPrices", + "data": { + "assets": ["solana"] + } +} +``` + +**Unsubscribe from Prices:** +```json +{ + "type": "unsubscribePrices", + "data": { + "assets": ["bitcoin"] + } +} +``` + +### Server → Client Messages + +**Price Update:** +```json +{ + "event": "prices", + "data": { + "prices": [ + { + "assetId": "bitcoin", + "price": 45000.50, + "priceChangePercentage24h": 2.5, + "updatedAt": "2024-01-23T12:00:00Z" + } + ], + "rates": [ + { + "symbol": "USD", + "rate": 1.0 + } + ] + } +} +``` + +**Balance Update:** +```json +{ + "event": "balances", + "data": [ + { + "walletId": "multicoin_0x742d35...", + "assetId": "ethereum" + } + ] +} +``` + +**Transactions Update:** +```json +{ + "event": "transactions", + "data": { + "walletId": "multicoin_0x742d35...", + "transactions": ["0xabc123...", "0xdef456..."] + } +} +``` + +## Notes + +- Authentication happens once during WebSocket upgrade +- Price updates are batched every 5 seconds +- Run as separate service: `api websocket_stream` + +## Implementation + +- Stream handler: [`apps/api/src/websocket_stream/stream.rs`](../apps/api/src/websocket_stream/stream.rs) +- Client logic: [`apps/api/src/websocket_stream/client.rs`](../apps/api/src/websocket_stream/client.rs) +- Message types: [`crates/primitives/src/stream.rs`](../crates/primitives/src/stream.rs) +- Price payload: [`crates/primitives/src/websocket.rs`](../crates/primitives/src/websocket.rs) diff --git a/core/docs/REWARDS_AND_REFERRALS.md b/core/docs/REWARDS_AND_REFERRALS.md new file mode 100644 index 0000000000..b6d50122e4 --- /dev/null +++ b/core/docs/REWARDS_AND_REFERRALS.md @@ -0,0 +1,84 @@ +# Rewards and Referrals + +## Activation Flow + +``` +[New User] ─> create_username ─> [Unverified] ─> worker checks activity ─> [Verified] or [Trusted] +``` + +## Referral Flow + +``` +User1 (Verified/Trusted): shares code + ─> User2 redeems (POST /devices/rewards/referrals/use) + ─> delay = compute_verification_delay(base, multiplier, referrer_status) + Trusted referrer: no delay (immediate verification) + Verified referrer: base_delay / verified_multiplier + Other: base_delay + ─> if delay: status = Pending, verify_after = now + delay + ─> if no delay: verified immediately, both get rewards + ─> delay passes + ─> User2 calls same endpoint again + ─> status reset to Unverified, verify_after cleared + ─> referral marked verified_at = now + ─> both get reward events (InviteNew / Joined) + ─> worker later promotes User2 Unverified ─> Verified +``` + +## Validation Pipeline + +Two paths depending on whether the referred user is confirming a pending referral: + +**Pending confirmation path** (user already redeemed, delay passed, calling again): +1. `is_pending_referral` — checks Pending status, matching referrer+device+unverified referral +2. `get_referrer_info` — verifies referrer is still Verified/Trusted (rejects if Disabled) +3. `use_or_verify_referral` — confirms the referral, creates reward events + +**New referral path** (first-time redemption): +1. `get_referrer_info` — fetches referrer status, referral_count, wallet_id (single query) +2. Referrer rate limits — cooldown, hourly, daily, weekly (multiplied by status tier) +3. `validate_referral_use` — device/wallet eligibility, subscription age, self-refer check +4. DB connection released +5. Android device token validation (async) +6. IP check + geo restrictions (async, tor, ineligible countries) +7. New DB connection acquired +8. Global rate limits — daily total, per-device, per-IP (daily + weekly), per-country +9. Risk scoring — fingerprint, abuse patterns, device model rings +10. Signal storage + threshold check + +## Statuses + +| Status | Can Invite | Description | +|--------|-----------|-------------| +| `Unverified` | No | Default after username creation. Awaiting promotion by worker. | +| `Pending` | No | Used a referral code, awaiting `verify_after` delay. | +| `Verified` | Yes | Promoted by worker. Can share referral code. | +| `Trusted` | Yes | Higher-tier verified. Higher referral limits, no verification delay. | +| `Disabled` | No | Account disabled. | + +## Worker Promotion + +`RewardsEligibilityChecker` promotes `Unverified` users to `Verified` when activity thresholds are met (`RewardsEligibilityActiveDuration`, `RewardsEligibilityTransactionsCount`). No explicit user action needed. + +## Client UI States + +| State | UI | +|-------|----| +| No username | "Get Started" button | +| `Unverified`, no `verify_after` | Rewards not active yet message | +| `verify_after` in future | "Bonus Pending" + countdown, confirm disabled | +| `verify_after` in past | "Your bonus is ready!", confirm enabled | +| `Verified`/`Trusted` | Invite Friends + share button | +| `Disabled` | Error with `disable_reason` | + +## Key Config + +| Key | Purpose | +|-----|---------| +| `RewardsEligibilityActiveDuration` | Min activity duration for promotion | +| `RewardsEligibilityTransactionsCount` | Min confirmed transactions for promotion | +| `RewardsTimerEligibilityChecker` | Worker check interval | +| `RewardsEligibilityPromotionLimit` | Max users promoted per worker run | +| `ReferralVerificationDelay` | Base delay before referral confirmation | +| `ReferralVerifiedMultiplier` | Divides delay for Verified referrers (also scales rate limits) | +| `ReferralTrustedMultiplier` | Scales rate limits for Trusted referrers (delay = 0) | diff --git a/core/docs/WALLET_AUTHENTICATION.md b/core/docs/WALLET_AUTHENTICATION.md new file mode 100644 index 0000000000..d45e8f840b --- /dev/null +++ b/core/docs/WALLET_AUTHENTICATION.md @@ -0,0 +1,103 @@ +# Wallet Authentication + +## Overview + +Wallet authentication endpoints require proof of wallet ownership via blockchain-native signatures (ECDSA for Ethereum). Used for referral/rewards operations and other authenticated wallet actions. + +## Authentication Flow + +1. Client requests nonce from `/v2/devices/auth/nonce` +2. Client signs `AuthMessage` with wallet private key +3. Client sends authenticated request with signature +4. Server processes request + +## Authentication Request Structure + +**Request Body:** +```json +{ + "auth": { + "deviceId": "abc123-device-id", + "chain": "ethereum", + "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + "nonce": "550e8400-e29b-41d4-a716-446655440000", + "signature": "0x1234567890abcdef..." + }, + "data": { + // Endpoint-specific payload + } +} +``` + +Wallet-authenticated requests are still device-authenticated requests. Use the Gem `Authorization` header for device authentication where possible; existing clients may still use the legacy individual headers documented in [Device Authentication](DEVICE_AUTHENTICATION.md). + +For the current `WalletSigned` guard, include: +- `x-device-body-hash`: SHA256 hash of request body (hex) + +This binds the wallet-signed JSON body to the request body read by the guard. Moving this check fully into the Gem `Authorization` payload should be done with the legacy-removal PR. + +## Nonce Request + +**Endpoint:** +``` +GET /v2/devices/auth/nonce +``` + +**Response:** +```json +{ + "nonce": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": 1706000000 +} +``` + +## Signature Generation + +**AuthMessage Structure:** +```json +{ + "chain": "ethereum", + "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + "authNonce": { + "nonce": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": 1706000000 + } +} +``` + +**Signing Process:** +1. Serialize `AuthMessage` to JSON string +2. Compute Keccak256 hash (for Ethereum) +3. Sign hash with wallet private key (ECDSA) +4. Encode as hex with `0x` prefix + +## Request Example + +``` +POST https://api.gemwallet.com/v2/devices/rewards/referrals/create +Content-Type: application/json +Authorization: Gem base64(....) +x-device-body-hash: a1b2c3d4e5f6... + +{ + "auth": { + "deviceId": "abc123-device-id", + "chain": "ethereum", + "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + "nonce": "550e8400-e29b-41d4-a716-446655440000", + "signature": "0xf8e7d6c5b4a3..." + }, + "data": { + "code": "myusername" + } +} +``` + +## Implementation + +References for implementation details: +- Wallet signature verification: `crates/gem_auth/src/signature.rs` +- Authentication guards: `apps/api/src/auth/guard.rs` +- Nonce management: `crates/gem_auth/src/client.rs` +- Auth primitives: `crates/primitives/src/auth.rs` +- Tests: `crates/gem_auth/src/signature.rs#L48` diff --git a/core/gemstone/.cargo/config.toml b/core/gemstone/.cargo/config.toml new file mode 100644 index 0000000000..eb170152bf --- /dev/null +++ b/core/gemstone/.cargo/config.toml @@ -0,0 +1,6 @@ +[profile.release] +# https://github.com/johnthagen/min-sized-rust +strip = true +codegen-units = 1 +lto = "fat" +opt-level = "z" diff --git a/core/gemstone/.gitignore b/core/gemstone/.gitignore new file mode 100644 index 0000000000..068511152a --- /dev/null +++ b/core/gemstone/.gitignore @@ -0,0 +1,13 @@ +*~ +target/ +generated/ +xcuserdata/ +jniLibs/ +.kotlin/ +gradle-daemon-jvm.properties +tests/ios/**/.build/ +tests/ios/**/.swiftpm/ +tests/ios/GemTest/Package.resolved +tests/ios/build/ +tests/ios/Packages/Gemstone/Sources/Gemstone/ +tests/ios/Packages/Gemstone/Sources/GemstoneFFI/include/ diff --git a/core/gemstone/Cargo.toml b/core/gemstone/Cargo.toml new file mode 100644 index 0000000000..8830c12d7d --- /dev/null +++ b/core/gemstone/Cargo.toml @@ -0,0 +1,73 @@ +[package] +edition = { workspace = true } +name = "gemstone" +version = "1.0.2" + +[lib] +crate-type = [ + "staticlib", # iOS + "rlib", # for Other crate + "cdylib", # Android +] + +name = "gemstone" + +[features] +default = [] +reqwest_provider = ["dep:reqwest", "swapper/reqwest_provider"] +swap_integration_tests = ["reqwest_provider"] + +[dependencies] +swapper = { path = "../crates/swapper" } +primitives = { path = "../crates/primitives" } +gem_cosmos = { path = "../crates/gem_cosmos", features = ["rpc", "signer"] } +gem_solana = { path = "../crates/gem_solana", features = ["rpc", "signer"] } +gem_ton = { path = "../crates/gem_ton", features = ["rpc", "signer"] } +gem_tron = { path = "../crates/gem_tron", features = ["rpc", "signer"] } +gem_evm = { path = "../crates/gem_evm", features = ["rpc", "signer"] } +simulation = { path = "../crates/simulation", features = ["rpc"] } +gem_sui = { path = "../crates/gem_sui", features = ["rpc", "signer"] } +gem_aptos = { path = "../crates/gem_aptos", features = ["rpc"] } +gem_auth = { path = "../crates/gem_auth" } +gem_jsonrpc = { path = "../crates/gem_jsonrpc", features = ["client"] } +gem_client = { path = "../crates/gem_client" } +gem_hypercore = { path = "../crates/gem_hypercore", features = ["signer"] } +gem_bitcoin = { path = "../crates/gem_bitcoin", features = ["rpc"] } +gem_hash = { path = "../crates/gem_hash" } +gem_cardano = { path = "../crates/gem_cardano", features = ["rpc", "signer"] } +gem_algorand = { path = "../crates/gem_algorand", features = ["rpc", "signer"] } +gem_stellar = { path = "../crates/gem_stellar", features = ["rpc", "signer"] } +gem_xrp = { path = "../crates/gem_xrp", features = ["rpc", "signer"] } +gem_near = { path = "../crates/gem_near", features = ["rpc", "signer"] } +gem_polkadot = { path = "../crates/gem_polkadot", features = ["rpc", "signer"] } +gem_wallet_connect = { path = "../crates/gem_wallet_connect" } +chain_traits = { path = "../crates/chain_traits" } +signer = { path = "../crates/signer" } +number_formatter = { path = "../crates/number_formatter" } +yielder = { path = "../crates/yielder" } + +reqwest = { workspace = true, optional = true } +sui-types = { workspace = true } +base64 = { workspace = true } + +# uniffi +uniffi.workspace = true + +chrono = { workspace = true } + +serde.workspace = true +serde_json.workspace = true +async-trait.workspace = true +alloy-primitives.workspace = true +hex.workspace = true +num-bigint.workspace = true +futures.workspace = true +bs58 = { workspace = true } +url = { workspace = true } +zeroize = { workspace = true } + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } + +[dev-dependencies] +primitives = { path = "../crates/primitives", features = ["testkit"] } diff --git a/core/gemstone/README.md b/core/gemstone/README.md new file mode 100644 index 0000000000..cc56ac32d4 --- /dev/null +++ b/core/gemstone/README.md @@ -0,0 +1,22 @@ +# Gemstone + +Gemstone is the essential cross platform library used by Gem Wallet clients (mainly iOS and Android). + +## Build + +iOS example + +```bash +just build-ios +just test-ios +``` + +`just build-ios` and `just test-ios` create the local Swift package sources, build the native Gemstone static library, and build the SwiftPM-based iOS test harness. + +Android + +```bash +just bindgen-kotlin && just build-android +``` + +you can check out `tests` folder to see how to use it. diff --git a/core/gemstone/android/.gitignore b/core/gemstone/android/.gitignore new file mode 100644 index 0000000000..aa724b7707 --- /dev/null +++ b/core/gemstone/android/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/core/gemstone/android/build.gradle.kts b/core/gemstone/android/build.gradle.kts new file mode 100644 index 0000000000..72ee103b60 --- /dev/null +++ b/core/gemstone/android/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.1.0" apply false +} diff --git a/core/gemstone/android/gemstone/.gitignore b/core/gemstone/android/gemstone/.gitignore new file mode 100644 index 0000000000..398c7c4c7f --- /dev/null +++ b/core/gemstone/android/gemstone/.gitignore @@ -0,0 +1,2 @@ +/build +src/main/java/uniffi diff --git a/core/gemstone/android/gemstone/build.gradle.kts b/core/gemstone/android/gemstone/build.gradle.kts new file mode 100644 index 0000000000..5dbad3ba8c --- /dev/null +++ b/core/gemstone/android/gemstone/build.gradle.kts @@ -0,0 +1,127 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.library") + id("maven-publish") +} + +val gemstoneRoot = project.projectDir.resolve("../..") +val rustSrcDir = gemstoneRoot.resolve("src") +val cratesDir = gemstoneRoot.resolve("../crates") +val jniLibsDir = project.projectDir.resolve("src/main/jniLibs") +val generatedKotlinDir = project.projectDir.resolve("src/main/java") + +android { + namespace = "com.gemwallet.gemstone" + compileSdk = 37 + + defaultConfig { + minSdk = 28 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + singleVariant("debug") { + withSourcesJar() + withJavadocJar() + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs(generatedKotlinDir) + jniLibs.srcDirs(jniLibsDir) + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +val bindgenKotlin = tasks.register("bindgenKotlin") { + description = "Generate Kotlin bindings from gemstone via uniffi" + workingDir = gemstoneRoot + inputs.dir(rustSrcDir) + inputs.dir(cratesDir) + inputs.file(gemstoneRoot.resolve("Cargo.toml")) + outputs.dir(generatedKotlinDir.resolve("uniffi")) + commandLine("just", "bindgen-kotlin") +} + +val buildCargoNdk = tasks.register("buildCargoNdk") { + description = "Build gemstone native libraries using cargo-ndk" + workingDir = gemstoneRoot + inputs.dir(rustSrcDir) + inputs.dir(cratesDir) + inputs.file(gemstoneRoot.resolve("Cargo.toml")) + outputs.dir(jniLibsDir) + commandLine( + "cargo", "ndk", + "-t", "arm64-v8a", + "-t", "armeabi-v7a", + "-t", "x86_64", + "-o", jniLibsDir.absolutePath, + "build", "--lib" + ) +} + +tasks.configureEach { + if (name.matches(Regex("(compile|extract|source|javaDoc).*(Debug|Release).*"))) { + dependsOn(bindgenKotlin) + } + if (name.matches(Regex("merge.*(Debug|Release).*JniLib.*"))) { + dependsOn(buildCargoNdk) + } +} + +dependencies { + api("net.java.dev.jna:jna:5.18.1@aar") + + implementation("androidx.core:core-ktx:1.17.0") + + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") +} + +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + groupId = "com.gemwallet.gemstone" + artifactId = "gemstone" + version = System.getenv("VER_NAME") ?: "1.0.0" + } + create("debug") { + from(components["debug"]) + groupId = "com.gemwallet.gemstone" + artifactId = "gemstone-debug" + version = System.getenv("VER_NAME") ?: "1.0.0-debug" + } + } + } +} diff --git a/core/gemstone/android/gemstone/consumer-rules.pro b/core/gemstone/android/gemstone/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/gemstone/android/gemstone/proguard-rules.pro b/core/gemstone/android/gemstone/proguard-rules.pro new file mode 100644 index 0000000000..414f0bc196 --- /dev/null +++ b/core/gemstone/android/gemstone/proguard-rules.pro @@ -0,0 +1,27 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class uniffi.** { *; } +-keep class uniffi.Gemstone.** { *; } +-keep class com.sun.jna.** { *; } +-keep class * implements java.nio.** { *; } +-keep class java.nio.** { *; } diff --git a/core/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt b/core/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt new file mode 100644 index 0000000000..b5b461350b --- /dev/null +++ b/core/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt @@ -0,0 +1,146 @@ +package com.gemwallet.gemstone + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import uniffi.gemstone.* +import java.net.SocketTimeoutException + +/** + * Mock provider for testing exception handling in AlienProvider implementations. + */ +class MockProvider( + private val onRequest: suspend (AlienTarget) -> AlienResponse = { + AlienResponse(status = 200u, data = ByteArray(0)) + } +) : AlienProvider { + override suspend fun request(target: AlienTarget): AlienResponse = onRequest(target) + override fun getEndpoint(chain: Chain): String = "https://mock.endpoint" +} + +/** + * Mock preferences for GemGateway. + */ +class MockPreferences : GemPreferences { + override fun get(key: String): String? = null + override fun set(key: String, value: String) {} + override fun remove(key: String) {} +} + +sealed class CustomAppException : Exception() { + class DustError(override val message: String) : CustomAppException() +} + +/** + * Mock fee estimator for testing GatewayException.PlatformException. + */ +class MockFeeEstimator( + private val onGetFee: suspend (Chain, GemTransactionLoadInput) -> GemTransactionLoadFee? = { _, _ -> null } +) : GemGatewayEstimateFee { + override suspend fun getFee(chain: Chain, input: GemTransactionLoadInput): GemTransactionLoadFee? = onGetFee(chain, input) + override suspend fun getFeeData(chain: Chain, input: GemTransactionLoadInput): String? = null +} + +@RunWith(AndroidJUnit4::class) +class GemstoneTest { + + init { + System.loadLibrary("gemstone") + } + + private fun createGateway(provider: AlienProvider): GemGateway { + return GemGateway(provider, MockPreferences(), MockPreferences(), "https://api.example.com") + } + + @Test + fun testLibVersion() { + assertTrue(libVersion().isNotEmpty()) + } + + /** + * Test 1: UniFFI-defined exception (AlienException) is caught as GatewayException. + */ + @Test + fun testProviderThrowsAlienException() = runBlocking { + val errorMessage = "Request failed" + val provider = MockProvider { throw AlienException.RequestException(errorMessage) } + val gateway = createGateway(provider) + + try { + gateway.getBalanceCoin("ethereum", "0x1234") + fail("Expected GatewayException.NetworkException to be thrown") + } catch (e: GatewayException.NetworkException) { + assertTrue(e.msg.contains(errorMessage)) + } + } + + /** + * Test 2: Standard Java exception becomes InternalException. + * + * Note: Unlike AlienException which is properly mapped to GatewayException, + * standard Java exceptions result in InternalException with UnexpectedUniFFICallbackError. + */ + @Test + fun testProviderThrowsStandardException() = runBlocking { + val errorMessage = "Network timeout" + val provider = MockProvider { throw SocketTimeoutException(errorMessage) } + val gateway = createGateway(provider) + + try { + gateway.getBalanceCoin("ethereum", "0x1234") + fail("Expected InternalException to be thrown") + } catch (e: InternalException) { + assertTrue(e.message?.contains(errorMessage) ?: false) + } + } + + /** + * Test 3: Custom exceptions must be wrapped to avoid native crash. + * + * Throwing custom exceptions directly causes native crash + * like UnexpectedUniFFICallbackError(reason: "...BlockchainError$DustError") + * Use GatewayException.PlatformException to wrap custom exceptions, allowing + * clients to distinguish them from network errors. + */ + @Test + fun testFeeEstimatorThrowsPlatformException() = runBlocking { + val errorMessage = "Amount too small" + val feeEstimator = MockFeeEstimator { _, _ -> + // Custom exceptions must be wrapped to avoid native crash + try { + throw CustomAppException.DustError(errorMessage) + } catch (e: CustomAppException) { + throw GatewayException.PlatformException("${e::class.simpleName}: ${e.message}") + } + } + val gateway = createGateway(MockProvider()) + val asset = GemAsset( + id = "ethereum", + chain = "ethereum", + tokenId = null, + name = "Ethereum", + symbol = "ETH", + decimals = 18, + assetType = GemAssetType.NATIVE + ) + val input = GemTransactionLoadInput( + inputType = GemTransactionInputType.Transfer(asset), + senderAddress = "0x1234", + destinationAddress = "0x5678", + value = "1000000000000000000", + gasPrice = GemGasPriceType.Regular("20000000000"), + memo = null, + isMaxValue = false, + metadata = GemTransactionLoadMetadata.None + ) + + try { + gateway.getFee("ethereum", input, feeEstimator) + fail("Expected GatewayException.PlatformException to be thrown") + } catch (e: GatewayException.PlatformException) { + assertTrue(e.msg.contains(errorMessage)) + } + } +} diff --git a/core/gemstone/android/gemstone/src/main/AndroidManifest.xml b/core/gemstone/android/gemstone/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/core/gemstone/android/gemstone/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/gemstone/android/gradle.properties b/core/gemstone/android/gradle.properties new file mode 100644 index 0000000000..45536e5d1a --- /dev/null +++ b/core/gemstone/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.builtInKotlin=true diff --git a/core/gemstone/android/gradle/gradle-daemon-jvm.properties b/core/gemstone/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000000..b6249a3c20 --- /dev/null +++ b/core/gemstone/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/0b98aec810298c2c1d7fdac5dac37910/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/core/gemstone/android/gradle/wrapper/gradle-wrapper.jar b/core/gemstone/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 zcma&O1CT9Y(k9%tZQHhO+qUh#ZQHhO+qmuS+qP|E@9xZO?0h@l{(r>DQ>P;GjjD{w zH}lENr;dU&FbEU?00aa80D$0M0RRB{U*7-#kbjS|qAG&4l5%47zyJ#WrfA#1$1Ctx zf&Z_d{GW=lf^w2#qRJ|CvSJUi(^E3iv~=^Z(zH}F)3Z%V3`@+rNB7gTVU{Bb~90p|f+0(v;nz01EG7yDMX9@S~__vVgv%rS$+?IH+oZ03D5zYrv|^ zC1J)SruYHmCki$jLBlTaE5&dFG9-kq3!^i>^UQL`%gn6)jz54$WDmeYdsBE9;PqZ_ zoGd=P4+|(-u4U1dbAVQrFWoNgNd;0nrghPFbQrJctO>nwDdI`Q^i0XJDUYm|T|RWc zZ3^Qgo_Qk$%Fvjj-G}1NB#ZJqIkh;kX%V{THPqOyiq)d)0+(r9o(qKlSp*hmK#iIY zA^)Vr$-Hz<#SF=0@tL@;dCQsm`V9s1vYNq}K1B)!XSK?=I1)tX+bUV52$YQu*0%fnWEukW>mxkz+%3-S!oguE8u#MGzST8_Dy^#U?fA@S#K$S@9msUiX!gd_ow>08w5)nX{-KxqMOo7d?k2&?Vf z&diGDtZr(0cwPe9z9FAUSD9KC)7(n^lMWuayCfxzy8EZsns%OEblHFSzP=cL6}?J| z0U$H!4S_TVjj<`6dy^2j`V`)mC;cB%* z8{>_%E1^FH!*{>4a7*C1v>~1*@TMcLK{7nEQ!_igZC}ikJ$*<$yHy>7)oy79A~#xE zWavoJOIOC$5b6*q*F_qN1>2#MY)AXVyr$6x4b=$x^*aqF*L?vmj>Mgv+|ITnw_BoW zO?jwHvNy^prH{9$rrik1#fhyU^MpFqF2fYEt(;4`Q&XWOGDH8k6M=%@fics4ajI;st# zCU^r1CK&|jzUhRMv;+W~6N;u<;#DI6cCw-otsc@IsN3MoSD^O`eNflIoR~l4*&-%RBYk@gb^|-JXs&~KuSEmMxB}xSb z@K76cXD=Y|=I&SNC2E+>Zg?R6E%DGCH5J1nU!A|@eX9oS(WPaMm==k2s_ueCqdZw| z&hqHp)47`c{BgwgvY2{xz%OIkY1xDwkw!<0veB#yF4ZKJyabhyyVS`gZepcFIk%e2 zTcrmt2@-8`7i-@5Nz>oQWFuMC_KlroCl(PLSodswHqJ3fn<;gxg9=}~3x_L3P`9Sn zChIf}8vCHvTriz~T2~FamRi?rh?>3bX1j}%bLH+uFX+p&+^aXbOK7clZxdU~6Uxgy z8R=obwO4dL%pmVo*Ktf=lH6hnlz_5k3cG;m8lgaPp~?eD!Yn2kf)tU6PF{kLyn|oI@eQ`F z3IF7~Blqg8-uwUuWZScRKn%c2_}dXB6Dx_&xR*n9M9LXasJhtZdr$vBY!rP{c@=)& z#!?L$2UrkvClwQO>U*fSMs67oSj2mxiJ$t;E|>q%Kh_GzzWWO&3;ufU%2z%ucBU8H z3WIwr$n)cfCXR&>tyB7BcSInK>=ByZA%;cVEJhcg<#6N{aZC4>K41XF>ZgjG`z_u& zGY?;Ad?-sgiOnI`oppF1o1Gurqbi*;#x2>+SSV6|1^G@ooVy@fg?wyf@0Y!UZ4!}nGuLeC^l)6pwkh|oRY`s1Pm$>zZ3u-83T|9 zGaKJIV3_x+u1>cRibsaJpJqhcm%?0-L;2 zitBrdRxNmb0OO2J%Y&Ym(6*`_P3&&5Bw157{o7LFguvxC$4&zTy#U=W*l&(Q2MNO} zfaUwYm{XtILD$3864IA_nn34oVa_g^FRuHL5wdUd)+W-p-iWCKe8m_cMHk+=? zeKX)M?Dt(|{r5t7IenkAXo%&EXIb-i^w+0CX0D=xApC=|Xy(`xy+QG^UyFe z+#J6h_&T5i#sV)hj3D4WN%z;2+jJcZxcI3*CHXGmOF3^)JD5j&wfX)e?-|V0GPuA+ zQFot%aEqGNJJHn$!_}#PaAvQ^{3-Ye7b}rWwrUmX53(|~i0v{}G_sI9uDch_brX&6 zWl5Ndj-AYg(W9CGfQf<6!YmY>Ey)+uYd_JNXH=>|`OH-CDCmcH(0%iD_aLlNHKH z7bcW-^5+QV$jK?R*)wZ>r9t}loM@XN&M-Pw=F#xn(;u3!(3SXXY^@=aoj70;_=QE9 zGghsG3ekq#N||u{4We_25U=y#T*S{4I{++Ku)> zQ!DZW;pVcn>b;&g2;YE#+V`v*Bl&Y-i@X6D*OpNA{G@JAXho&aOk(_j^weW{#3X5Y z%$q_wpb07EYPdmyH(1^09i$ca{O<}7) zRWncXdSPgBE%BM#by!E>tdnc$8RwUJg1*x($6$}ae$e9Knj8gvVZe#bLi!<+&BkFj zg@nOpDneyc+hU9P-;jmOSMN|*H#>^Ez#?;%C3hg_65leSUm;iz)UkW)jX#p)e&S&M z1|a?wDzV5NVnlhRBCd_;F87wp>6c<&nkgvC+!@KGiIqWY4l}=&1w7|r6{oBN8xyzh zG$b#2=RJp_iq6)#t5%yLkKx(0@D=C3w+oiXtSuaQ%I1WIb-eiE$d~!)b@|4XLy!CZ z9p=t=%3ad@Ep+<9003D2KZ5VyP~_n$=;~r&YUg5UZ0KVD&tR1DHy9x)qWtKJp#Kq# zP*8p#W(8JJ_*h_3W}FlvRam?<4Z+-H77^$Lvi+#vmhL9J zJ<1SV45xi;SrO2f=-OB(7#iNA5)x1uNC-yNxUw|!00vcW2PufRm>e~toH;M0Q85MQLWd?3O{i8H+5VkR@l9Dg-ma ze2fZ%>G(u5(k9EHj2L6!;(KZ8%8|*-1V|B#EagbF(rc+5iL_5;Eu)L4Z-V;0HfK4d z*{utLse_rvHZeQ>V5H=f78M3Ntg1BPxFCVD{HbNA6?9*^YIq;B-DJd{Ca2L#)qWP? zvX^NhFmX?CTWw&Ns}lgs;r3i+Bq@y}Ul+U%pzOS0Fcv9~aB(0!>GT0)NO?p=25LjN z2bh>6RhgqD7bQj#k-KOm@JLgMa6>%-ok1WpOe)FS^XOU{c?d5shG(lIn3GiVBxmg`u%-j=)^v&pX1JecJics3&jvPI)mDut52? z3jEA)DM%}BYbxxKrizVYwq?(P&19EXlwD9^-6J+4!}9{ywR9Gk42jjAURAF&EO|~N z)?s>$Da@ikI4|^z0e{r`J8zIs>SpM~Vn^{3fArRu;?+43>lD+^XtUcY1HidJwnR6+ z!;oG2=B6Z_=M%*{z-RaHc(n|1RTKQdNjjV!Pn9lFt^4w|AeN06*j}ZyhqZ^!-=cyGP_ShV1rGxkx8t zB;8`h!S{LD%ot``700d0@Grql(DTt4Awgmi+Yr0@#jbe=2#UkK%rv=OLqF)9D7D1j z!~McAwMYkeaL$~kI~90)5vBhBzWYc3Cj1WI0RS`z000R8-@ET0dA~*r(gSiCJmQMN&4%1D zyVNf0?}sBH8zNbBLn>~(W{d3%@kL_eQ6jEcR{l>C|JK z(R-fA!z|TTRG40|zv}7E@PqCAXP3n`;%|SCQ|ZS%ym$I{`}t3KPL&^l5`3>yah4*6 zifO#{VNz3)?ZL$be;NEaAk9b#{tV?V7 zP|wf5YA*1;s<)9A4~l3BHzG&HH`1xNr#%){4xZ!jq%o=7nN*wMuXlFV{HaiQLJ`5G zBhDi#D(m`Q1pLh@Tq+L;OwuC52RdW7b8}~60WCOK5iYMUad9}7aWBuILb({5=z~YF zt?*Jr5NG+WadM{mDL>GyiByCuR)hd zA=HM?J6l1Xv0Dl+LW@w$OTcEoOda^nFCw*Sy^I@$sSuneMl{4ys)|RY#9&NxW4S)9 zq|%83IpslTLoz~&vTo!Ga@?rj_kw{|k{nv+w&Ku?fyk4Ki4I?);M|5Axm)t+BaE)D zm(`AQ#k^DWrjbuXoJf2{Aj^KT zFb1zMSqxq|vceV+Mf-)$oPflsO$@*A0n0Z!R{&(xh8s}=;t(lIy zv$S8x>m;vQNHuRzoaOo?eiWFe{0;$s`Bc+Osz~}Van${u;g(su`3lJ^TEfo~nERfP z)?aFzpDgnLYiERsKPu|0tq4l2wT)Atr6Qb%m-AUn6HnCue*yWICp7TjW$@sO zm5rm4aTcPQ(rfi7a`xP7cKCFrJD}*&_~xgLyr^-bmsL}y;A5P|al8J3WUoBSjqu%v zxC;mK!g(7r6RRJ852Z~feoC&sD3(6}^5-uLK8o)9{8L_%%rItZK9C){UxB|;G>JbP zsRRtS4-3B*5c+K2kvmgZK8472%l>3cntWUOVHxB|{Ay~aOg5RN;{PJgeVD*H%ac+y!h#wi%o2bF2Ca8IyMyH{>4#{E_8u^@+l-+n=V}Sq?$O z{091@v%Bd*3pk0^2UtiF9Z+(a@wy6 zUdw8J*ze$K#=$48IBi1U%;hmhO>lu!uU;+RS}p&6@rQila7WftH->*A4=5W|Fmtze z)7E}jh@cbmr9iup^i%*(uF%LG&!+Fyl@LFA-}Ca#bxRfDJAiR2dt6644TaYw1Ma79 zt8&DYj31j^5WPNf5P&{)J?WlCe@<3u^78wnd(Ja4^a>{^Tw}W>|Cjt^If|7l^l)^Q zbz|7~CF(k_9~n|h;ysZ+jHzkXf(*O*@5m zLzUmbHp=x!Q|!9NVXyipZ3)^GuIG$k;D)EK!a5=8MFLI_lpf`HPKl=-Ww%z8H_0$j ztJ||IfFG1lE9nmQ0+jPQy zCBdKkjArH@K7jVcMNz);Q(Q^R{d5G?-kk;Uu_IXSyWB)~KGIizZL(^&qF;|1PI7!E zTP`%l)gpX|OFn&)M%txpQ2F!hdA~hX1Cm5)IrdljqzRg!f{mN%G~H1&oqe`5eJCIF zHdD7O;AX-{XEV(a`gBFJ9ews#CVS2y!&>Cm_dm3C8*n3MA*e67(WC?uP@8TXuMroq z{#w$%z@CBIkRM7?}Xib+>hRjy?%G!fiw8! z8(gB+8J~KOU}yO7UGm&1g_MDJ$IXS!`+*b*QW2x)9>K~Y*E&bYMnjl6h!{17_8d!%&9D`a7r&LKZjC<&XOvTRaKJ1 zUY@hl5^R&kZl3lU3njk`3dPzxj$2foOL26r(9zsVF3n_F#v)s5vv3@dgs|lP#eylq62{<-vczqP!RpVBTgI>@O6&sU>W|do17+#OzQ7o5A$ICH z?GqwqnK^n2%LR;$^oZM;)+>$X3s2n}2jZ7CdWIW0lnGK-b#EG01)P@aU`pg}th&J-TrU`tIpb5t((0eu|!u zQz+3ZiOQ^?RxxK4;zs=l8q!-n7X{@jSwK(iqNFiRColuEOg}!7cyZi`iBX4g1pNBj zAPzL?P^Ljhn;1$r8?bc=#n|Ed7wB&oHcw()&*k#SS#h}jO?ZB246EGItsz*;^&tzp zu^YJ0=lwsi`eP_pU8}6JA7MS;9pfD;DsSsLo~ogzMNP70@@;Fm8f0^;>$Z>~}GWRw!W5J3tNX*^2+1f3hz{~rIzJo z6W%J(H!g-eI_J1>0juX$X4Cl6i+3wbc~k146UIX&G22}WE>0ga#WLsn9tY(&29zBvH1$`iWtTe zG2jYl@P!P)eb<5DsR72BdI7-zP&cZNI{7q3e@?N8IKc4DE#UVr->|-ryuJXk^u^>4 z$3wE~=q390;XuOQP~TNoDR?#|NSPJ%sTMInA6*rJ%go|=YjGe!B>z6u$IhgQSwoV* zjy3F2#I>uK{42{&IqP59)Y(1*Z>>#W8rCf4_eVsH)`v!P#^;BgzKDR`ARGEZzkNX+ zJUQu=*-ol=Xqqt5=`=pA@BIn@6a9G8C{c&`i^(i+BxQO9?YZ3iu%$$da&Kb?2kCCo zo7t$UpSFWqmydXf@l3bVJ=%K?SSw)|?srhJ-1ZdFu*5QhL$~-IQS!K1s@XzAtv6*Y zl8@(5BlWYLt1yAWy?rMD&bwze8bC3-GfNH=p zynNFCdxyX?K&G(ZZ)afguQ2|r;XoV^=^(;Cku#qYn4Lus`UeKt6rAlFo_rU`|Rq z&G?~iWMBio<78of-2X(ZYHx~=U0Vz4btyXkctMKdc9UM!vYr~B-(>)(Hc|D zMzkN4!PBg%tZoh+=Gba!0++d193gbMk2&krfDgcbx0jI92cq?FFESVg0D$>F+bil} zY~$)|>1HZsX=5sAZ2WgPB5P=8X#TI+NQ(M~GqyVB53c6IdX=k>Wu@A0Svf5#?uHaF zsYn|koIi3$(%GZ2+G+7Fv^lHTb#5b8sAHSTnL^qWZLM<(1|9|QFw9pnRU{svj}_Al zL)b9>fN{QiA($8peNEJyy`(a{&uh-T4_kdZFIVsKKVM(?05}76EEz?#W za^fiZOAd14IJ4zLX-n7Lq0qlQ^lW8Cvz4UKkV9~P}>sq0?xD3vg+$4vLm~C(+ zM{-3Z#qnZ09bJ>}j?6ry^h+@PfaD7*jZxBEY4)UG&daWb??6)TP+|3#Z&?GL?1i+280CFsE|vIXQbm| zM}Pk!U`U5NsNbyKzkrul-DzwB{X?n3E6?TUHr{M&+R*2%yOiXdW-_2Yd6?38M9Vy^ z*lE%gA{wwoSR~vN0=no}tP2Ul5Gk5M(Xq`$nw#ndFk`tcpd5A=Idue`XZ!FS>Q zG^0w#>P4pPG+*NC9gLP4x2m=cKP}YuS!l^?sHSFftZy{4CoQrb_ z^20(NnG`wAhMI=eq)SsIE~&Gp9Ne0nD4%Xiu|0Fj1UFk?6avDqjdXz{O1nKao*46y zT8~iA%Exu=G#{x=KD;_C&M+Zx4+n`sHT>^>=-1YM;H<72k>$py1?F3#T1*ef9mLZw z5naLQr?n7K;2l+{_uIw*_1nsTn~I|kkCgrn;|G~##hM;9l7Jy$yJfmk+&}W@JeKcF zx@@Woiz8qdi|D%aH3XTx5*wDlbs?dC1_nrFpm^QbG@wM=i2?Zg;$VK!c^Dp8<}BTI zyRhAq@#%2pGV49*Y5_mV4+OICP|%I(dQ7x=6Ob}>EjnB_-_18*xrY?b%-yEDT(wrO z9RY2QT0`_OpGfMObKHV;QLVnrK%mc?$WAdIT`kJQT^n%GuzE7|9@k3ci5fYOh(287 zuIbg!GB3xLg$YN=n)^pHGB0jH+_iIiC=nUcD;G6LuJsjn2VI1cyZx=a?ShCsF==QK z;q~*m&}L<-cb+mDDXzvvrRsybcgQ;Vg21P(uLv5I+eGc7o7tc6`;OA9{soHFOz zT~2?>Ts}gprIX$wRBb4yE>ot<8+*Bv`qbSDv*VtRi|cyWS>)Fjs>fkNOH-+PX&4(~ z&)T8Zam2L6puQl?;5zg9h<}k4#|yH9czHw;1jw-pwBM*O2hUR6yvHATrI%^mvs9q_ z&ccT0>f#eDG<^WG^q@oVqlJrhxH)dcq2cty@l3~|5#UDdExyXUmLQ}f4#;6fI{f^t zDCsgIJ~0`af%YR%Ma5VQq-p21k`vaBu6WE?66+5=XUd%Ay%D$irN>5LhluRWt7 zov-=f>QbMk*G##&DTQyou$s7UqjjW@k6=!I@!k+S{pP8R(2=e@io;N8E`EOB;OGoI zw6Q+{X1_I{OO0HPpBz!X!@`5YQ2)t{+!?M_iH25X(d~-Zx~cXnS9z>u?+If|iNJbx zyFU2d1!ITX64D|lE0Z{dLRqL1Ajj=CCMfC4lD3&mYR_R_VZ>_7_~|<^o*%_&jevU+ zQ4|qzci=0}Jydw|LXLCrOl1_P6Xf@c0$ieK2^7@A9UbF{@V_0p%lqW|L?5k>bVM8|p5v&2g;~r>B8uo<4N+`B zH{J)h;SYiIVx@#jI&p-v3dwL5QNV1oxPr8J%ooezTnLW>i*3Isb49%5i!&ac_dEXv zvXmVUck^QHmyrF8>CGXijC_R-y(Qr{3Zt~EmW)-nC!tiH`wlw5D*W7Pip;T?&j%kX z6DkZX4&}iw>hE(boLyjOoupf6JpvBG8}jIh!!VhnD0>}KSMMo{1#uU6kiFcA04~|7 zVO8eI&x1`g4CZ<2cYUI(n#wz2MtVFHx47yE5eL~8bot~>EHbevSt}LLMQX?odD{Ux zJMnam{d)W4da{l7&y-JrgiU~qY3$~}_F#G7|MxT)e;G{U`In&?`j<5D->}cb{}{T(4DF0BOk-=1195KB-E*o@c?`>y#4=dMtYtSY=&L{!TAjFVcq0y@AH`vH! z$41+u!Ld&}F^COPgL(EE{0X7LY&%D7-(?!kjFF7=qw<;`V{nwWBq<)1QiGJgUc^Vz ztMUlq1bZqKn17|6x6iAHbWc~l1HcmAxr%$Puv!znW)!JiukwIrqQ00|H$Z)OmGG@= zv%A8*4cq}(?qn4rN6o`$Y))(MyXr8R<2S^J+v(wmFmtac!%VOfN?&(8Nr!T@kV`N; z*Q33V3t`^rN&aBiHet)18wy{*wi1=W!B%B-Q6}SCrUl$~Hl{@!95ydml@FK8P=u4s z4e*7gV2s=YxEvskw2Ju!2%{8h01rx-3`NCPc(O zH&J0VH5etNB2KY6k4R@2Wvl^Ck$MoR3=)|SEclT2ccJ!RI9Nuter7u9@;sWf-%um;GfI!=eEIQ2l2p_YWUd{|6EG ze{yO6;lMc>;2tPrsNdi@&1K6(1;|$xe8vLgiouj%QD%gYk`4p{Ktv9|j+!OF-P?@p z;}SV|oIK)iwlBs+`ROXkhd&NK zzo__r!B>tOXpBJMDcv!Mq54P+n4(@dijL^EpO1wdg~q+!DT3lB<>9AANSe!T1XgC=J^)IP0XEZ()_vpu!!3HQyJhwh?r`Ae%Yr~b% zO*NY9t9#qWa@GCPYOF9aron7thfWT`eujS4`t2uG6)~JRTI;f(ZuoRQwjZjp5Pg34 z)rp$)Kr?R+KdJ;IO;pM{$6|2y=k_siqvp%)2||cHTe|b5Ht8&A{wazGNca zX$Ol?H)E_R@SDi~4{d-|8nGFhZPW;Cts1;08TwUvLLv&_2$O6Vt=M)X;g%HUr$&06 zISZb(6)Q3%?;3r~*3~USIg=HcJhFtHhIV(siOwV&QkQe#J%H9&E21!C*d@ln3E@J* zVqRO^<)V^ky-R|%{(9`l-(JXq9J)1r$`uQ8a}$vr9E^nNiI*thK8=&UZ0dsFN_eSl z(q~lnD?EymWLsNa3|1{CRPW60>DSkY9YQ;$4o3W7Ms&@&lv9eH!tk~N&dhqX&>K@} zi1g~GqglxkZ5pEFkllJ)Ta1I^c&Bt6#r(QLQ02yHTaJB~- zCcE=5tmi`UA>@P=1LBfBiqk)HB4t8D?02;9eXj~kVPwv?m{5&!&TFYhu>3=_ zsGmYZ^mo*-j69-42y&Jj0cBLLEulNRZ9vXE)8~mt9C#;tZs;=#M=1*hebkS;7(aGf zcs7zH(I8Eui9UU4L--))yy`&d&$In&VA2?DAEss4LAPCLd>-$i?lpXvn!gu^JJ$(DoUlc6wE98VLZ*z`QGQov5l4Fm_h?V-;mHLYDVOwKz7>e4+%AzeO>P6v}ndPW| zM>m#6Tnp7K?0mbK=>gV}=@k*0Mr_PVAgGMu$j+pWxzq4MAa&jpCDU&-5eH27Iz>m^ zax1?*HhG%pJ((tkR(V(O(L%7v7L%!_X->IjS3H5kuXQT2!ow(;%FDE>16&3r){!ex zhf==oJ!}YU89C9@mfDq!P3S4yx$aGB?rbtVH?sHpg?J5C->!_FHM%Hl3#D4eplxzQ zRA+<@LD%LKSkTk2NyWCg7u=$%F#;SIL44~S_OGR}JqX}X+=bc@swpiClB`Zbz|f!4 z7Ysah7OkR8liXfI`}IIwtEoL}(URrGe;IM8%{>b1SsqXh)~w}P>yiFRaE>}rEnNkT z!HXZUtxUp1NmFm)Dm@-{FI^aRQqpSkz}ZSyKR%Y}YHNzBk)ZIp} zMtS=aMvkgWKm9&oTcU0?S|L~CDqA+sHpOxwnswF-fEG)cXCzUR?ps@tZa$=O)=L+5 zf%m58cq8g_o}3?Bhh+c!w4(7AjxwQ3>WnVi<{{38g7yFboo>q|+7qs<$8CPXUFAN< zG&}BHbbyQ5n|qqSr?U~GY{@GJ{(Jny{bMaOG{|IkUj7tj^9pa9|FB_<+KHLxSxR;@ zHpS$4V)PP+tx}22fWx(Ku9y+}Ap;VZqD0AZW4gCDTPCG=zgJmF{|x;(rvdM|2|9a}cex6xrMkERnkE;}jvU-kmzd%_J50$M`lIPCKf+^*zL=@LW`1SaEc%=m zQ+lT06Gw+wVwvQ9fZ~#qd430v2HndFsBa9WjD0P}K(rZYdAt^5WQIvb%D^Q|pkVE^ zte$&#~zmULFACGfS#g=2OLOnIf2Of-k!(BIHjs77nr!5Q1*I9 z1%?=~#Oss!rV~?-6Gm~BWJiA4mJ5TY&iPm_$)H1_rTltuU1F3I(qTQ^U$S>%$l z)Wx1}R?ij0idp@8w-p!Oz{&*W;v*IA;JFHA9%nUvVDy7Q8woheC#|8QuDZb-L_5@R zOqHwrh|mVL9b=+$nJxM`3eE{O$sCt$UK^2@L$R(r^-_+z?lOo+me-VW=Zw z-Bn>$4ovfWd%SPY`ab-u9{INc*k2h+yH%toDHIyqQ zO68=u`N}RIIs7lsn1D){)~%>ByF<>i@qFb<-axvu(Z+6t7v<^z&gm9McRB~BIaDn$ z#xSGT!rzgad8o>~kyj#h1?7g96tOcCJniQ+*#=b7wPio>|6a1Z?_(TS{)KrPe}(8j z!#&A=k(&Pj^F;r)CI=Z{LVu>uj!_W1q4b`N1}E(i%;BWjbEcnD=mv$FL$l?zS6bW!{$7j1GR5ocn94P2u{ z70tAAcpqtQo<@cXw~@i-@6B23;317|l~S>CB?hR5qJ%J3EFgyBdJd^fHZu7AzHF(BQ!tyAz^L0`X z23S4Fe{2X$W0$zu9gm%rg~A>ijaE#GlYlrF9$ds^QtaszE#4M(OLVP2O-;XdT(XIC zatwzF*)1c+t~c{L=fMG8Z=k5lv>U0;C{caN1NItnuSMp)6G3mbahu>E#sj&oy94KC zpH}8oEw{G@N3pvHhp{^-YaZeH;K+T_1AUv;IKD<=mv^&Ueegrb!yf`4VlRl$M?wsl zZyFol(2|_QM`e_2lYSABpKR{{NlxlDSYQNkS;J66aT#MSiTx~;tUmvs-b*CrR4w=f z8+0;*th6kfZ3|5!Icx3RV11sp=?`0Jy3Fs0N4GZQMN=8HmT6%x9@{Dza)k}UwL6JT zHRDh;%!XwXr6yuuy`4;Xsn0zlR$k%r%9abS1;_v?`HX_hI|+EibVnlyE@3aL5vhQq zlIG?tN^w@0(v9M*&L+{_+RQZw=o|&BRPGB>e5=ys7H`nc8nx)|-g;s7mRc7hg{GJC zAe^vCIJhajmm7C6g! zL&!WAQ~5d_5)00?w_*|*H>3$loHrvFbitw#WvLB!JASO?#5Ig5$Ys10n>e4|3d;tS zELJ0|R4n3Az(Fl3-r^QiV_C;)lQ1_CW{5bKS15U|E9?ZgLec@%kXr84>5jV2a5v=w z?pB1GPdxD$IQL4)G||B_lI+A=08MUFFR4MxfGOu07vfIm+j=z9tp~5i_6jb`tR>qV z$#`=BQ*jpCjm$F0+F)L%xRlnS%#&gro6PiRfu^l!EVan|r3y}AHJQOORGx4~ z&<)3=K-tx518DZyp%|!EqpU!+X3Et7n2AaC5(AtrkW>_57i}$eqs$rupubg0a1+WO zGHZKLN2L0D;ab%{_S1Plm|hx8R?O14*w*f&2&bB050n!R2by zw!@XOQx$SqZ5I<(Qu$V6g>o#A!JVwErWv#(Pjx=KeS0@hxr4?13zj#oWwPS(7Ro|v z>Mp@Kmxo79q|}!5qtX2-O@U&&@6s~!I&)1WQIl?lTnh6UdKT_1R640S4~f=_xoN3- zI+O)$R@RjV$F=>Ti7BlnG1-cFKCC(t|Qjm{SalS~V-tX#+2ekRhwmN zZr`8{QF6y~Z!D|{=1*2D-JUa<(1Z=;!Ei!KiRNH?o{p5o3crFF=_pX9O-YyJchr$~ zRC`+G+8kx~fD2k*ZIiiIGR<8r&M@3H?%JVOfE>)})7ScOd&?OjgAGT@WVNSCZ8N(p zuQG~76GE3%(%h1*vUXg$vH{ua0b`sQ4f0*y=u~lgyb^!#CcPJa2mkSEHGLsnO^kb$ zru5_l#nu=Y{rSMWiYx?nO{8I!gH+?wEj~UM?IrG}E|bRIBUM>UlY<`T1EHpRr36vv zBi&dG8oxS|J$!zoaq{+JpJy+O^W(nt*|#g32bd&K^w-t>!Vu9N!k9eA8r!Xc{utY> zg9aZ(D2E0gL#W0MdjwES-7~Wa8iubPrd?8-$C4BP?*wok&O8+ykOx{P=Izx+G~hM8 z*9?BYz!T8~dzcZr#ux8kS7u7r@A#DogBH8km8Ry4slyie^n|GrTbO|cLhpqgMdsjX zJ_LdmM#I&4LqqsOUIXK8gW;V0B(7^$y#h3h>J0k^WJfAMeYek%Y-Dcb_+0zPJez!GM zAmJ1u;*rK=FNM0Nf}Y!!P9c4)HIkMnq^b;JFd!S3?_Qi2G#LIQ)TF|iHl~WKK6JmK zbv7rPE6VkYr_%_BT}CK8h=?%pk@3cz(UrZ{@h40%XgThP*-Oeo`T0eq9 zA8BnWZKzCy5e&&_GEsU4*;_k}(8l_&al5K-V*BFM=O~;MgRkYsOs%9eOY6s6AtE*<7GQAR2ulC3RAJrG_P1iQK5Z~&B z&f8X<>yJV6)oDGIlS$Y*D^Rj(cszTy5c81a5IwBr`BtnC6_e`ArI8CaTX_%rx7;cn zR-0?J_LFg*?(#n~G8cXut(1nVF0Oka$A$1FGcERU<^ggx;p@CZc?3UB41RY+wLS`LWFNSs~YP zuw1@DNN3lTd|jDL7gjBsd9}wIw}4xT2+8dBQzI00m<@?c2L%>}QLfK5%r!a-iII`p zX@`VEUH)uj^$;7jVUYdADQ2k*!1O3WdfgF?OMtUXNpQ1}QINamBTKDuv19^{$`8A1 zeq%q*O0mi@(%sZU>Xdb0Ru96CFqk9-L3pzLVsMQ`Xpa~N6CR{9Rm2)A|CI21L(%GW zh&)Y$BNHa=FD+=mBw3{qTgw)j0b!Eahs!rZnpu)z!!E$*eXE~##yaXz`KE5(nQM`s zD!$vW9XH)iMxu9R>r$VlLk9oIR%HxpUiW=BK@4U)|1WNQ=mz9a z^!KkO=>GaJ!GBXm{KJj^;kh-MkUlEQ%lza`-G&}C5y1>La1sR6hT=d*NeCnuK%_LV zOXt$}iP6(YJKc9j-Fxq~*ItVUqljQ8?oaysB-EYtFQp9oxZ|5m0^Hq(qV!S+hq#g( z?|i*H2MIr^Kxgz+3vIljQ*Feejy6S4v~jKEPTF~Qhq!(ms5>NGtRgO5vfPPc4Z^AM zTj!`5xEreIN)vaNxa|q6qWdg>+T`Ol0Uz)ckXBXEGvPNEL3R8hB3=C5`@=SYgAju1 z!)UBr{2~=~xa{b8>x2@C7weRAEuatC)3pkRhT#pMPTpSbA|tan%U7NGMvzmF?c!V8 z=pEWxbdXbTAGtWTyI?Fml%lEr-^AE}w#l(<7OIw;ctw}imYax&vR4UYNJZK6P7ZOd zP87XfhnUHxCUHhM@b*NbTi#(-8|wcv%3BGNs#zRCVV(W?1Qj6^PPQa<{yaBwZ`+<`w|;rqUY_C z&AeyKwwf*q#OW-F()lir=T^<^wjK65Lif$puuU5+tk$;e_EJ;Lu+pH>=-8=PDhkBg z8cWt%@$Sc#C6F$Vd+0507;{OOyT7Hs%nKS88q-W!$f~9*WGBpHGgNp}=C*7!RiZ5s zn1L_DbKF@B8kwhDiLKRB@lsXVVLK|ph=w%_`#owlf@s@V(pa`GY$8h%;-#h@TsO|Y8V=n@*!Rog7<7Cid%apR|x zOjhHCyfbIt%+*PCveTEcuiDi%Wx;O;+K=W?OFUV%)%~6;gl?<0%)?snDDqIvkHF{ zyI02)+lI9ov42^hL>ZRrh*HhjF9B$A@=H94iaBESBF=eC_KT$8A@uB^6$~o?3Wm5t1OIaqF^~><2?4e3c&)@wKn9bD? zoeCs;H>b8DL^F&>Xw-xjZEUFFTv>JD^O#1E#)CMBaG4DX9bD(Wtc8Rzq}9soQ8`jf zeSnHOL}<+WVSKp4kkq&?SbETjq6yr@4%SAqOG=9E(3YeLG9dtV+8vmzq+6PFPk{L; z(&d++iu=^F%b+ea$i2UeTC{R*0Isk;vFK!no<;L+(`y`3&H-~VTdKROkdyowo1iqR zbVW(3`+(PQ2>TKY>N!jGmGo7oeoB8O|P_!Ic@ zZ^;3dnuXo;WJ?S+)%P>{Hcg!Jz#2SI(s&dY4QAy_vRlmOh)QHvs_7c&zkJCmJGVvV zX;Mtb>QE+xp`KyciG$Cn*0?AK%-a|=o!+7x&&yzHQOS>8=B*R=niSnta^Pxp1`=md z#;$pS$4WCT?mbiCYU?FcHGZ#)kHVJTTBt^%XE(Q};aaO=Zik0UgLcc0I(tUpt(>|& zcxB_|fxCF7>&~5eJ=Dpn&5Aj{A^cV^^}(7w#p;HG&Q)EaN~~EqrE1qKrMAc&WXIE;>@<&)5;gD2?={Xf@Mvn@OJKw=8Mgn z!JUFMwD+s==JpjhroT&d{$kQAy%+d`a*XxDEVxy3`NHzmITrE`o!;5ClXNPb4t*8P zzAivdr{j_v!=9!^?T3y?gzmqDWX6mkzhIzJ-3S{T5bcCFMr&RPDryMcdwbBuZbsgN zGrp@^i?rcfN7v0NKGzDPGE#4yszxu=I_`MI%Z|10nFjU-UjQXXA?k8Pk|OE<(?ae) zE%vG#eZAlj*E7_3dx#Zz4kMLj>H^;}33UAankJiDy5ZvEhrjr`!9eMD8COp}U*hP+ zF}KIYx@pkccIgyxFm#LNw~G&`;o&5)2`5aogs`1~7cMZQ7zj!%L4E`2yzlQN6REX20&O<9 zKV6fyr)TScJPPzNTC2gL+0x#=u>(({{D7j)c-%tvqls3#Y?Z1m zV5WUE)zdJ{$p>yX;^P!UcXP?UD~YM;IRa#Rs5~l+*$&nO(;Ers`G=0D!twR(0GF@c zHl9E5DQI}Oz74n zfKP>&$q0($T4y$6w(p=ERAFh+>n%iaeRA%!T%<^+pg?M)@ucY<&59$x9M#n+V&>}=nO9wCV{O~lg&v#+jcUj(tQ z`0u1YH)-`U$15a{pBkGyPL0THv1P|4e@pf@3IBZS4dVJPo#H>pWq%Lr0YS-SeWash z8R7=jb28KPMI|_lo#GEO|5B?N_e``H*23{~a!AmUJ+fb4HX-%QI@lSEUxKlGV7z7Q zSKw@-TR>@1RL%w{x}dW#k1NgW+q4yt2Xf1J62Bx*O^WG8OJ|FqI4&@d3_o8Id@*)4 zYrk=>@!wv~mh7YWv*bZhxqSmFh2Xq)o=m;%n$I?GSz49l1$xRpPu_^N(vZ>*>Z<04 z2+rP70oM=NDysd!@fQdM2OcyT?3T^Eb@lIC-UG=Bw{BjQ&P`KCv$AcJ;?`vdZ4){d z&gkoUK{$!$$K`3*O-jyM1~p-7T*qb)Ys>Myt^;#1&a%O@x8A+E>! zY8=eD`ZG)LVagDLBeHg>=atOG?Kr%h4B%E6m@J^C+U|y)XX@f z8oyJDW|9g=<#f<{JRr{y#~euMnv)`7j=%cHWLc}ngjq~7k**6%4u>Px&W%4D94(r* z+akunK}O0DC2A%Xo9jyF;DobX?!1I(7%}@7F>i%&nk*LMO)bMGg2N+1iqtg+r(70q zF5{Msgsm5GS7DT`kBsjMvOrkx&|EU!{{~gL4d2MWrAT=KBQ-^zQCUq{5PD1orxlIL zq;CvlWx#f1NWvh`hg011I%?T_s!e38l*lWVt|~z-PO4~~1g)SrJ|>*tXh=QfXT)%( z+ex+inPvD&O4Ur;JGz>$sUOnWdpSLcm1X%aQDw4{dB!cnj`^muI$CJ2%p&-kULVCE z>$eMR36kN$wCPR+OFDM3-U(VOrp9k3)lI&YVFqd;Kpz~K)@Fa&FRw}L(SoD z9B4a+hQzZT-BnVltst&=kq6Y(f^S4hIGNKYBgMxGJ^;2yrO}P3;r)(-I-CZ)26Y6? z&rzHI_1GCvGkgy-t1E;r^3Le30|%$ebDRu2+gdLG)r=A~Qz`}~&L@aGJ{}vVs_GE* zVUjFnzHiXfKQbpv&bR&}l2bzIjAooB)=-XNcYmrGmBh(&iu@o!^hn0^#}m2yZZUK8 zufVm7Gq0y`Mj;9b>`c?&PZkU0j4>IL=UL&-Lp3j&47B5pAW4JceG{!XCA)kT<%2nqCxj<)uy6XR_uws~>_MEKPOpAQ!H zkn>FKh)<9DwwS*|Y(q?$^N!6(51O0 z^JM~Ax{AI1Oj$fs-S5d4T7Z_i1?{%0SsIuQ&r8#(JA=2iLcTN+?>wOL532%&dMYkT z*T5xepC+V6zxhS@vNbMoi|i)=rpli@R9~P!39tWbSSb904ekv7D#quKbgFEMTb48P zuq(VJ+&L8aWU(_FCD$3^uD!YM%O^K(dvy~Wm2hUuh6bD|#(I39Xt>N1Y{ZqXL`Fg6 zKQ?T2htHN!(Bx;tV2bfTtIj7e)liN-29s1kew>v(D^@)#v;}C4-G=7x#;-dM4yRWm zyY`cS21ulzMK{PoaQ6xChEZ}o_#}X-o}<&0)$1#3we?+QeLt;aVCjeA)hn!}UaKt< zat1fHEx13y-rXNMvpUUmCVzocPmN~-Y4(YJvQ#db)4|%B!rBsgAe+*yor~}FrNH08 z3V!97S}D7d$zbSD{$z;@IYMxM6aHdypIuS*pr_U6;#Y!_?0i|&yU*@16l z*dcMqDQgfNBf}?quiu4e>H)yTVfsp#f+Du0@=Kc41QockXkCkvu>FBd6Q+@FL!(Yx z2`YuX#eMEiLEDhp+9uFqME_E^faV&~9qjBHJkIp~%$x^bN=N)K@kvSVEMdDuzA0sn z88CBG?`RX1@#hQNd`o^V{37)!w|nA)QfiYBE^m=yQKv-fQF+UCMcuEe1d4BH7$?>b zJl-r9@0^Ie=)guO1vOd=i$_4sz>y3x^R7n4ED!5oXL3@5**h(xr%Hv)_gILarO46q+MaDOF%ChaymKoI6JU5Pg;7#2n9-18|S1;AK+ zgsn6;k6-%!QD>D?cFy}8F;r@z8H9xN1jsOBw2vQONVqBVEbkiNUqgw~*!^##ht>w0 zUOykwH=$LwX2j&nLy=@{hr)2O&-wm-NyjW7n~Zs9UlH;P7iP3 zI}S(r0YFVYacnKH(+{*)Tbw)@;6>%=&Th=+Z6NHo_tR|JCI8TJiXv2N7ei7M^Q+RM z?9o`meH$5Yi;@9XaNR#jIK^&{N|DYNNbtdb)XW1Lv2k{E>;?F`#Pq|&_;gm~&~Zc9 zf+6ZE%{x4|{YdtE?a^gKyzr}dA>OxQv+pq|@IXL%WS0CiX!V zm$fCePA%lU{%pTKD7|5NJHeXg=I0jL@$tOF@K*MI$)f?om)D63K*M|r`gb9edD1~Y zc|w7N)Y%do7=0{RC|AziW7#am$)9jciRJ?IWl9PE{G3U+$%FcyKs_0Cgq`=K3@ttV z9g;M!3z~f_?P%y3-ph%vBMeS@p7P&Ea8M@97+%XEj*(1E6vHj==d zjsoviB>j^$_^OI_DEPvFkVo(BGRo%cJeD){6Uckei=~1}>sp299|IRjhXe)%?uP0I zF5+>?0#Ye}T^Y$u_rc4=lPcq4K^D(TZG-w30-YiEM=dcK+4#o*>lJ8&JLi+3UcpZk z!^?95S^C0ja^jwP`|{<+3cBVog$(mRdQmadS+Vh~z zS@|P}=|z3P6uS+&@QsMp0no9Od&27O&14zHXGAOEy zh~OKpymK5C%;LLb467@KgIiVwYbYd6wFxI{0-~MOGfTq$nBTB!{SrWmL9Hs}C&l&l#m?s*{tA?BHS4mVKHAVMqm63H<|c5n0~k)-kbg zXidai&9ZUy0~WFYYKT;oe~rytRk?)r8bptITsWj(@HLI;@=v5|XUnSls7$uaxFRL+ zRVMGuL3w}NbV1`^=Pw*0?>bm8+xfeY(1PikW*PB>>Tq(FR`91N0c2&>lL2sZo5=VD zQY{>7dh_TX98L2)n{2OV=T10~*YzX27i2Q7W86M4$?gZIXZaBq#sA*{PH8){|GUi;oM>e?ua7eF4WFuFYZSG| zze?srg|5Ti8Og{O zeFxuw9!U+zhyk?@w zjsA6(oKD=Ka;A>Ca)oPORxK+kxH#O@zhC!!XS4@=swnuMk>t+JmLmFiE^1aX3f<)D@`%K0FGK^gg1a1j>zi z2KhV>sjU7AX3F$SEqrXSC}fRx64GDoc%!u2Yag68Lw@w9v;xOONf@o)Lc|Uh3<21ctTYu-mFZuHk*+R{GjXHIGq3p)tFtQp%TYqD=j1&y)>@zxoxUJ!G@ zgI0XKmP6MNzw>nRxK$-Gbzs}dyfFzt>#5;f6oR27ql!%+{tr+(`(>%51|k`ML} zY4eE)Lxq|JMas(;JibNQds1bUB&r}ydMQXBY4x(^&fY_&LlQC)3hylc$~8&~|06-D z#T+%66rYbHX%^KuqJED_wuGB+=h`nWA!>1n0)3wZrBG3%`b^Ozv6__dNa@%V14|!D zQ?o$z5u0^8`giv%qE!BzZ!3j;BlDlJDk)h@9{nSQeEk!z9RGW) z${RSF3phEM*ce*>Xdp}585vj$|40=&S{S-GTiE?Op*vY&Lvr9}BO$XWy80IF+6@%n z5*2ueT_g@ofP#u5pxb7n*fv^Xtt7&?SRc{*2Ka-*!BuOpf}neHGCiHy$@Ka1^Dint z;DkmIL$-e)rj4o2WQV%Gy;Xg(_Bh#qeOsTM2f@KEe~4kJ8kNLQ+;(!j^bgJMcNhvklP5Z6I+9Fq@c&D~8Fb-4rmDT!MB5QC{Dsb;BharP*O;SF4& zc$wj-7Oep7#$WZN!1nznc@Vb<_Dn%ga-O#J(l=OGB`dy=Sy&$(5-n3zzu%d7E#^8`T@}V+5B;PP8J14#4cCPw-SQTdGa2gWL0*zKM z#DfSXs_iWOMt)0*+Y>Lkd=LlyoHjublNLefhKBv@JoC>P7N1_#> zv=mLWe96%EY;!ZGSQDbZWb#;tzqAGgx~uk+-$+2_8U`!ypbwXl z^2E-FkM1?lY@yt8=J3%QK+xaZ6ok=-y%=KXCD^0r!5vUneW>95PzCkOPO*t}p$;-> ze5j-BLT_;)cZQzR2CEsm@rU7GZfFtdp*a|g4wDr%8?2QkIGasRfDWT-Dvy*U{?IHT z*}wGnzdlSptl#ZF^sf)KT|BJs&kLG91^A6ls{CzFprZ6-Y!V0Xysh%9p%iMd7HLsS zN+^Un$tDV)T@i!v?3o0Fsx2qI(AX_$dDkBzQ@fRM%n zRXk6hb9Py#JXUs+7)w@eo;g%QQ95Yq!K_d=z{0dGS+pToEI6=Bo8+{k$7&Z zo4>PH(`ce8E-Ps&uv`NQ;U$%t;w~|@E3WVOCi~R4oj5wP?%<*1C%}Jq%a^q~T7u>K zML5AKfQDv6>PuT`{SrKHRAF+^&edg6+5R_#H?Lz3iGoWo#PCEd0DS;)2U({{X#zU^ zw_xv{4x7|t!S)>44J;KfA|DC?;uQ($l+5Vp7oeqf7{GBF9356nx|&B~gs+@N^gSdd zvb*>&W)|u#F{Z_b`f#GVtQ`pYv3#||N{xj1NgB<#=Odt6{eB%#9RLt5v zIi|0u70`#ai}9fJjKv7dE!9ZrOIX!3{$z_K5FBd-Kp-&e4(J$LD-)NMTp^_pB`RT; zftVVlK2g@+1Ahv2$D){@Y#cL#dUj9*&%#6 zd2m9{1NYp>)6=oAvqdCn5#cx{AJ%S8skUgMglu2*IAtd+z1>B&`MuEAS(D(<6X#Lj z?f4CFx$)M&$=7*>9v1ER4b6!SIz-m0e{o0BfkySREchp?WdVPpQCh!q$t>?rL!&Jg zd#heM;&~A}VEm8Dvy&P|J*eAV&w!&Nx6HFV&B8jJFVTmgLaswn!cx$&%JbTsloz!3 zMEz1d`k==`Ueub_JAy_&`!ogbwx27^ZXgFNAbx=g_I~5nO^r)}&myw~+yY*cJl4$I znNJ32M&K=0(2Dj_>@39`3=FX!v3nZHno_@q^!y}%(yw0PqOo=);6Y@&ylVe>nMOZ~ zd>j#QQSBn3oaWd;qy$&5(5H$Ayi)0haAYO6TH>FR?rhqHmNOO+(})NB zLI@B@v0)eq!ug`>G<@htRlp3n!EpU|n+G+AvXFrWSUsLMBfL*ZB`CRsIVHNTR&b?K zxBgsN0BjfB>UVcJ|x%=-zb%OV7lmZc& zxiupadZVF7)6QuhoY;;FK2b*qL0J-Rn-8!X4ZY$-ZSUXV5DFd7`T41c(#lAeLMoeT z4%g655v@7AqT!i@)Edt5JMbN(=Q-6{=L4iG8RA%}w;&pKmtWvI4?G9pVRp|RTw`g0 zD5c12B&A2&P6Ng~8WM2eIW=wxd?r7A*N+&!Be7PX3s|7~z=APxm=A?5 zt>xB4WG|*Td@VX{Rs)PV0|yK`oI3^xn(4c_j&vgxk_Y3o(-`_5o`V zRTghg6%l@(qodXN;dB#+OKJEEvhfcnc#BeO2|E(5df-!fKDZ!%9!^BJ_4)9P+9Dq5 zK1=(v?KmIp34r?z{NEWnLB3Px{XYwy-akun4F7xTRr2^zeYW{gcK9)>aJDdU5;w5@ zak=<+-PLH-|04pelTb%ULpuuuJC7DgyT@D|p{!V!0v3KpDnRjANN12q6SUR3mb9<- z>2r~IApQGhstZ!3*?5V z8#)hJ0TdZg0M-BK#nGFP>$i=qk82DO z7h;Ft!D5E15OgW)&%lej*?^1~2=*Z5$2VX>V{x8SC+{i10BbtUk9@I#Vi&hX)q
Q!LwySI{Bnv%Sm)yh{^sSVJ8&h_D-BJ_YZe5eCaAWU9b$O2c z$T|{vWVRtOL!xC0DTc(Qbe`ItNtt5hr<)VijD0{U;T#bUEp381_y`%ZIav?kuYG{iyYdEBPW=*xNSc;Rlt6~F4M`5G+VtOjc z*0qGzCb@gME5udTjJA-9O<&TWd~}ysBd(eVT1-H82-doyH9RST)|+Pb{o*;$j9Tjs zhU!IlsPsj8=(x3bAKJTopW3^6AKROHR^7wZ185wJGVhA~hEc|LP;k7NEz-@4p5o}F z`AD6naG3(n=NF9HTH81=F+Q|JOz$7wm9I<+#BSmB@o_cLt2GkW9|?7mM;r!JZp89l zbo!Hp8=n!XH1{GwaDU+k)pGp`C|cXkCU5%vcH)+v@0eK>%7gWxmuMu9YLlChA|_D@ zi#5zovN_!a-0?~pUV-Rj*1P)KwdU-LguR>YM&*Nen+ln8Q$?WFCJg%DY%K}2!!1FE zDv-A%Cbwo^p(lzac&_TZ-l#9kq`mhLcY3h9ZTUVCM(Ad&=EriQY5{jJv<5K&g|*Lk zgV%ILnf1%8V2B0E&;Sp4sYbYOvvMebLwYwzkRQ#F8GpTQq#uv=J`uaSJ34OWITeSGo6+-8Xw znCk*n{kdDEi)Hi&u^)~cs@iyCkFWB2SWZU|Uc%^43ZIZQ-vWNExCCtDWjqHs;;tWf$v{}0{p0Rvxkq``)*>+Akq%|Na zA`@~-Vfe|+(AIlqru+7Ceh4nsVmO9p9jc8}HX^W&ViBDXT+uXbT#R#idPn&L>+#b6 zflC-4C5-X;kUnR~L>PSLh*gvL68}RBsu#2l`s_9KjUWRhiqF`j)`y`2`YU(>3bdBj z?>iyjEhe-~$^I5!nn%B6Wh+I`FvLNvauve~eX<+Ipl&04 zT}};W&1a3%W?dJ2=N#0t?e+aK+%t}5q%jSLvp3jZ%?&F}nOOWr>+{GFIa%wO_2`et z=JzoRR~}iKuuR+azPI8;Gf9)z3kyA4EIOSl!sRR$DlW}0>&?GbgPojmjmnln;cTqCt=ADbE zZ8GAnoM+S1(5$i8^O4t`ue;vO4i}z0wz-QEIVe5_u03;}-!G1NyY8;h^}y;tzY}i5 zqQr#Ur3Fy8sSa$Q0ys+f`!`+>9WbvU_I`Sj;$4{S>O3?#inLHCrtLy~!s#WXV=oVP zeE93*Nc`PBi4q@%Ao$x4lw9vLHM!6mn3-b_cebF|n-2vt-zYVF_&sDE--J-P;2WHo z+@n2areE0o$LjvjlV2X7ZU@j+`{*8zq`JR3gKF#EW|#+{nMyo-a>nFFTg&vhyT=b} zDa8+v0(Dgx0yRL@ZXOYIlVSZ0|MFizy0VPW8;AfA5|pe!#j zX}Py^8fl5SyS4g1WSKKtnyP+_PoOwMMwu`(i@Z)diJp~U54*-miOchy7Z35eL>^M z4p<-aIxH4VUZgS783@H%M7P9hX>t{|RU7$n4T(brCG#h9e9p! z+o`i;EGGq3&pF;~5V~eBD}lC)>if$w%Vf}AFxGqO88|ApfHf&Bvu+xdG)@vuF}Yvk z)o;~k-%+0K0g+L`Wala!$=ZV|z$e%>f0%XoLib%)!R^RoS+{!#X?h-6uu zF&&KxORdZU&EwQFITIRLo(7TA3W}y6X{?Y%y2j0It!ekU#<)$qghZtpcS>L3uh`Uj z7GY;6f$9qKynP#oS3$$a{p^{D+0oJQ71`1?OAn_m8)UGZmj3l*ZI)`V-a>MKGGFG< z&^jg#Ok%(hhm>hSrZ5;Qga4u(?^i>GiW_j9%_7M>j(^|Om$#{k+^*ULnEgzW_1gCICtAD^WpC`A z{9&DXkG#01Xo)U$OC(L5Y$DQ|Q4C6CjUKk1UkPj$nXH##J{c8e#K|&{mA*;b$r0E4 zUNo0jthwA(c&N1l=PEe8Rw_8cEl|-eya9z&H3#n`B$t#+aJ03RFMzrV@gowbe8v(c zIFM60^0&lCFO10NU4w@|61xiZ4CVXeaKjd;d?sv52XM*lS8XiVjgWpRB;&U_C0g+`6B5V&w|O6B*_q zsATxL!M}+$He)1eOWECce#eS@2n^xhlB4<_Nn?yCVEQWDs(r`|@2GqLe<#(|&P0U? z$7V5IgpWf09uIf_RazRwC?qEqRaHyL?iiS05UiGesJy%^>-C{{ypTBI&B0-iUYhk> zIk<5xpsuV@g|z(AZD+C-;A!fTG=df1=<%nxy(a(IS+U{ME4ZbDEBtcD_3V=icT6*_ z)>|J?>&6%nvHhZERBtjK+s4xnut*@>GAmA5m*OTp$!^CHTr}vM4n(X1Q*;{e-Rd2BCF-u@1ZGm z!S8hJ6L=Gl4T_SDa7Xx|-{4mxveJg=ctf`BJ*fy!yF6Dz&?w(Q_6B}WQVtNI!BVBC zKfX<>7vd6C96}XAQmF-Jd?1Q4eTfRB3q7hCh0f!(JkdWT5<{iAE#dKy*Jxq&3a1@~ z8C||Dn2mFNyrUV|<-)C^_y7@8c2Fz+2jrae9deBDu;U}tJ{^xAdxCD248(k;dCJ%o z`y3sADe>U%suxwwv~8A1+R$VB=Q?%U?4joI$um;aH+eCrBqpn- z%79D_7rb;R-;-9RTrwi9dPlg8&@tfWhhZ(Vx&1PQ+6(huX`;M9x~LrW~~#3{j0Bh2kDU$}@!fFQej4VGkJv?M4rU^x!RU zEwhu$!CA_iDjFjrJa`aocySDX16?~;+wgav;}Zut6Mg%C4>}8FL?8)Kgwc(Qlj{@#2Pt0?G`$h7P#M+qoXtlV@d}%c&OzO+QYKK`kyXaK{U(O^2DyIXCZlNQjt0^8~8JzNGrIxhj}}M z&~QZlbx%t;MJ(Vux;2tgNKGlAqphLq%pd}JG9uoVHUo?|hN{pLQ6Em%r*+7t^<);X zm~6=qChlNAVXNN*Sow->*4;}T;l;D1I-5T{Bif@4_}=>l`tK;qqDdt5zvisCKhMAH z#r}`)7VW?LZqfdmXQ%zo5bJ00{Xb9^YKrk0Nf|oIW*K@(=`o2Vndz}ZDyk{!u}PVx zzd--+_WC*U{~DH3{?GI64IB+@On&@9X>EUAo&L+G{L^dozaI4C3G#2wr~hseW@K&g zKWs{uHu-9Je!3;4pE>eBltKUXb^*hG8I&413)$J&{D4N%7PcloU6bn%jPxJyQL?g* z9g+YFFEDiE`8rW^laCNzQmi7CTnPfwyg3VDHRAl>h=In6jeaVOP@!-CP60j3+#vpL zEYmh_oP0{-gTe7Or`L6x)6w?77QVi~jD8lWN@3RHcm80iV%M1A!+Y6iHM)05iC64tb$X2lV_%Txk@0l^hZqi^%Z?#- zE;LE0uFx)R08_S-#(wC=dS&}vj6P4>5ZWjhthP=*Hht&TdLtKDR;rXEX4*z0h74FA zMCINqrh3Vq;s%3MC1YL`{WjIAPkVL#3rj^9Pj9Ss7>7duy!9H0vYF%>1jh)EPqvlr6h%R%CxDsk| z!BACz7E%j?bm=pH6Eaw{+suniuY7C9Ut~1cWfOX9KW9=H><&kQlinPV3h9R>3nJvK z4L9(DRM=x;R&d#a@oFY7mB|m8h4692U5eYfcw|QKwqRsshN(q^v$4$)HgPpAJDJ`I zkqjq(8Cd!K!+wCd=d@w%~e$=gdUgD&wj$LQ1r>-E=O@c ze+Z$x{>6(JA-fNVr)X;*)40Eym1TtUZI1Pwwx1hUi+G1Jlk~vCYeXMNYtr)1?qwyg zsX_e*$h?380O00ou?0R@7-Fc59o$UvyVs4cUbujHUA>sH!}L54>`e` zHUx#Q+Hn&Og#YVOuo*niy*GU3rH;%f``nk#NN5-xrZ34NeH$l`4@t);4(+0|Z#I>Y z)~Kzs#exIAaf--65L0UHT_SvV8O2WYeD>Mq^Y6L!Xu8%vnpofG@w!}R7M28?i1*T&zp3X4^OMCY6(Dg<-! zXmcGQrRgHXGYre7GfTJ)rhl|rs%abKT_Nt24_Q``XH{88NVPW+`x4ZdrMuO0iZ0g` z%p}y};~T5gbb9SeL8BSc`SO#ixC$@QhXxZ=B}L`tP}&k?1oSPS=4%{UOHe0<_XWln zwbl5cn(j-qK`)vGHY5B5C|QZd5)W7c@{bNVXqJ!!n$^ufc?N9C-BF2QK1(kv++h!>$QbAjq)_b$$PcJdV+F7hz0Hu@ zqj+}m0qn{t^tD3DfBb~0B36|Q`bs*xs|$i^G4uNUEBl4g;op-;Wl~iThgga?+dL7s zUP(8lMO?g{GcYpDS{NM!UA8Hco?#}eNEioRBHy4`mq!Pd-9@-97|k$hpEX>xoX+dY zDr$wfm^P&}Wu{!%?)U_(%Mn79$(ywvu*kJ9r4u|MyYLI_67U7%6Gd_vb##Nerf@>& z8W11z$$~xEZt$dPG}+*IZky+os5Ju2eRi;1=rUEeIn>t-AzC_IGM-IXWK3^6QNU+2pe=MBn4I*R@A%-iLDCOHTE-O^wo$sL_h{dcPl=^muAQb`_BRm};=cy{qSkui;`WSsj9%c^+bIDQ z0`_?KX0<-=o!t{u(Ln)v>%VGL z0pC=GB7*AQ?N7N{ut*a%MH-tdtNmNC+Yf$|KS)BW(gQJ*z$d{+{j?(e&hgTy^2|AR9vx1Xre2fagGv0YXWqtNkg*v%40v?BJBt|f9wX5 z{QTlCM}b-0{mV?IG>TW_BdviUKhtosrBqdfq&Frdz>cF~yK{P@(w{Vr7z2qKFwLhc zQuogKO@~YwyS9%+d-zD7mJG~@?EFJLSn!a&mhE5$_4xBl&6QHMzL?CdzEnC~C3$X@ zvY!{_GR06ep5;<#cKCSJ%srxX=+pn?ywDwtJ2{TV;0DKBO2t++B(tIO4)Wh`rD13P z4fE$#%zkd=UzOB74gi=-*CuID&Z3zI^-`4U^S?dHxK8fP*;fE|a(KYMgMUo`THIS1f!*6dOI2 zFjC3O=-AL`6=9pp;`CYPTdVX z8(*?V&%QoipuH0>WKlL8A*zTKckD!paN@~hh zmXzm~qZhMGVdQGd=AG8&20HW0RGV8X{$9LldFZYm zE?}`Q3i?xJRz43S?VFMmqRyvWaS#(~Lempg9nTM$EFDP(Gzx#$r)W&lpFKqcAoJh-AxEw$-bjW>`_+gEi z2w`99#UbFZGiQjS8kj~@PGqpsPX`T{YOj`CaEqTFag;$jY z8_{Wzz>HXx&G*Dx<5skhpETxIdhKH?DtY@b9l8$l?UkM#J-Snmts7bd7xayKTFJ(u zyAT&@6cAYcs{PBfpqZa%sxhJ5nSZBPji?Zlf&}#L?t)vC4X5VLp%~fz2Sx<*oN<7` z?ge=k<=X7r<~F7Tvp9#HB{!mA!QWBOf%EiSJ6KIF8QZNjg&x~-%e*tflL(ji_S^sO ztmib1rp09uon}RcsFi#k)oLs@$?vs(i>5k3YN%$T(5Or(TZ5JW9mA6mIMD08=749$ z!d+l*iu{Il7^Yu}H;lgw=En1sJpCKPSqTCHy4(f&NPelr31^*l%KHq^QE>z>Ks_bH zjbD?({~8Din7IvZeJ>8Ey=e;I?thpzD=zE5UHeO|neioJwG;IyLk?xOz(yO&0DTU~ z^#)xcs|s>Flgmp;SmYJ4g(|HMu3v7#;c*Aa8iF#UZo7CvDq4>8#qLJ|YdZ!AsH%^_7N1IQjCro

K7UpUK$>l@ zw`1S}(D?mUXu_C{wupRS-jiX~w=Uqqhf|Vb3Cm9L=T+w91Cu^ z*&Ty%sN?x*h~mJc4g~k{xD4ZmF%FXZNC;oVDwLZ_WvrnzY|{v8hc1nmx4^}Z;yriXsAf+Lp+OFLbR!&Ox?xABwl zu8w&|5pCxmu#$?Cv2_-Vghl2LZ6m7}VLEfR5o2Ou$x02uA-%QB2$c(c1rH3R9hesc zfpn#oqpbKuVsdfV#cv@5pV4^f_!WS+F>SV6N0JQ9E!T90EX((_{bSSFv9ld%I0&}9 zH&Jd4MEX1e0iqDtq~h?DBrxQX1iI0lIs<|kB$Yrh&cpeK0-^K%=FBsCBT46@h#yi!AyDq1V(#V}^;{{V*@T4WJ&U-NTq43w=|K>z8%pr_nC>%C(Wa_l78Ufib$r8Od)IIN=u>417 z`Hl{9A$mI5A(;+-Q&$F&h-@;NR>Z<2U;Y21>>Z;s@0V@SbkMQQj%_;~+qTuQ?c|AV zcWm3XZQHhP&R%QWarS%mJ!9R^&!_)*s(v+VR@I#QrAT}`17Y+l<`b-nvmDNW`De%y zrwTZ9EJrj1AFA>B`1jYDow}~*dfPs}IZMO3=a{Fy#IOILc8F0;JS4x(k-NSpbN@qM z`@aE_e}5{!$v3+qVs7u?sOV(y@1Os*Fgu`fCW9=G@F_#VQ%xf$hj0~wnnP0$hFI+@ zkQj~v#V>xn)u??YutKsX>pxKCl^p!C-o?+9;!Nug^ z{rP!|+KsP5%uF;ZCa5F;O^9TGac=M|=V z_H(PfkV1rz4jl?gJ(ArXMyWT4y(86d3`$iI4^l9`vLdZkzpznSd5Ikfrs8qcSy&>z zTIZgWZGXw0n9ibQxYWE@gI0(3#KA-dAdPcsL_|hg2@~C!VZDM}5;v_Nykfq!*@*Zf zE_wVgx82GMDryKO{U{D>vSzSc%B~|cjDQrt5BN=Ugpsf8H8f1lR4SGo#hCuXPL;QQ z#~b?C4MoepT3X`qdW2dNn& zo8)K}%Lpu>0tQei+{>*VGErz|qjbK#9 zvtd8rcHplw%YyQCKR{kyo6fgg!)6tHUYT(L>B7er5)41iG`j$qe*kSh$fY!PehLcD zWeKZHn<492B34*JUQh=CY1R~jT9Jt=k=jCU2=SL&&y5QI2uAG2?L8qd2U(^AW#{(x zThSy=C#>k+QMo^7caQcpU?Qn}j-`s?1vXuzG#j8(A+RUAY})F@=r&F(8nI&HspAy4 z4>(M>hI9c7?DCW8rw6|23?qQMSq?*Vx?v30U%luBo)B-k2mkL)Ljk5xUha3pK>EEj z@(;tH|M@xkuN?gsz;*bygizwYR!6=(Xgcg^>WlGtRYCozY<rFX2E>kaZo)O<^J7a`MX8Pf`gBd4vrtD|qKn&B)C&wp0O-x*@-|m*0egT=-t@%dD zgP2D+#WPptnc;_ugD6%zN}Z+X4=c61XNLb7L1gWd8;NHrBXwJ7s0ce#lWnnFUMTR& z1_R9Fin4!d17d4jpKcfh?MKRxxQk$@)*hradH2$3)nyXep5Z;B z?yX+-Bd=TqO2!11?MDtG0n(*T^!CIiF@ZQymqq1wPM_X$Iu9-P=^}v7npvvPBu!d$ z7K?@CsA8H38+zjA@{;{kG)#AHME>Ix<711_iQ@WWMObXyVO)a&^qE1GqpP47Q|_AG zP`(AD&r!V^MXQ^e+*n5~Lp9!B+#y3#f8J^5!iC@3Y@P`;FoUH{G*pj*q7MVV)29+j z>BC`a|1@U_v%%o9VH_HsSnM`jZ-&CDvbiqDg)tQEnV>b%Ptm)T|1?TrpIl)Y$LnG_ zzKi5j2Fx^K^PG1=*?GhK;$(UCF-tM~^=Z*+Wp{FSuy7iHt9#4n(sUuHK??@v+6*|10Csdnyg9hAsC5_OrSL;jVkLlf zHXIPukLqbhs~-*oa^gqgvtpgTk_7GypwH><53riYYL*M=Q@F-yEPLqQ&1Sc zZB%w}T~RO|#jFjMWcKMZccxm-SL)s_ig?OC?y_~gLFj{n8D$J_Kw%{r0oB8?@dWzn zB528d-wUBQzrrSSLq?fR!K%59Zv9J4yCQhhDGwhptpA5O5U?Hjqt>8nOD zi{)0CI|&Gu%zunGI*XFZh(ix)q${jT8wnnzbBMPYVJc4HX*9d^mz|21$=R$J$(y7V zo0dxdbX3N#=F$zjstTf*t8vL)2*{XH!+<2IJ1VVFa67|{?LP&P41h$2i2;?N~RA30LV`BsUcj zfO9#Pg1$t}7zpv#&)8`mis3~o+P(DxOMgz-V*(?wWaxi?R=NhtW}<#^Z?(BhSwyar zG|A#Q7wh4OfK<|DAcl9THc-W4*>J4nTevsD%dkj`U~wSUCh15?_N@uMdF^Kw+{agk zJ`im^wDqj`Ev)W3k3stasP`88-M0ZBs7;B6{-tSm3>I@_e-QfT?7|n0D~0RRqDb^G zyHb=is;IwuQ&ITzL4KsP@Z`b$d%B0Wuhioo1CWttW8yhsER1ZUZzA{F*K=wmi-sb#Ju+j z-l@In^IKnb{bQG}Ps>+Vu_W#grNKNGto+yjA)?>0?~X`4I3T@5G1)RqGUZuP^NJCq&^HykuYtMDD8qq+l8RcZNJsvN(10{ zQ1$XcGt}QH-U^WU!-wRR1d--{B$%vY{JLWIV%P4-KQuxxDeJaF#{eu&&r!3Qu{w}0f--8^H|KwE>)ORrcR+2Qf zb})DRcH>k0zWK8@{RX}NYvTF;E~phK{+F;MkIP$)T$93Ba2R2TvKc>`D??#mv9wg$ zd~|-`Qx5LwwsZ2hb*Rt4S9dsF%Cny5<1fscy~)d;0m2r$f=83<->c~!GNyb!U)PA; zq^!`@@)UaG)Ew(9V?5ZBq#c%dCWZrplmuM`o~TyHjAIMh0*#1{B>K4po-dx$Tk-Cq z=WZDkP5x2W&Os`N8KiYHRH#UY*n|nvd(U>yO=MFI-2BEp?x@=N<~CbLJBf6P)}vLS?xJXYJ2^<3KJUdrwKnJnTp{ zjIi|R=L7rn9b*D#Xxr4*R<3T5AuOS+#U8hNlfo&^9JO{VbH!v9^JbK=TCGR-5EWR@ zN8T-_I|&@A}(hKeL4_*eb!1G8p~&_Im8|wc>Cdir+gg90n1dw?QaXcx6Op_W1r=axRw>4;rM*UOpT#Eb9xU1IiWo@h?|5uP zka>-XW0Ikp@dIe;MN8B01a7+5V@h3WN{J=HJ*pe0uwQ3S&MyWFni47X32Q7SyCTNQ z+sR!_9IZa5!>f&V$`q!%H8ci!a|RMx5}5MA_kr+bhtQy{-^)(hCVa@I!^TV4RBi zAFa!Nsi3y37I5EK;0cqu|9MRj<^r&h1lF}u0KpKQD^5Y+LvFEwM zLU@@v4_Na#Axy6tn3P%sD^5P#<7F;sd$f4a7LBMk zGU^RZHBcxSA%kCx*eH&wgA?Qwazm8>9SCSz_!;MqY-QX<1@p$*T8lc?@`ikEqJ>#w zcG``^CoFMAhdEXT9qt47g0IZkaU)4R7wkGs^Ax}usqJ5HfDYAV$!=6?>J6+Ha1I<5 z|6=9soU4>E))tW$<#>F ziZ$6>KJf0bPfbx_)7-}tMINlc=}|H+$uX)mhC6-Hz+XZxsKd^b?RFB6et}O#+>Wmw9Ec9) z{q}XFWp{3@qmyK*Jvzpyqv57LIR;hPXKsrh{G?&dRjF%Zt5&m20Ll?OyfUYC3WRn{cgQ?^V~UAv+5 z&_m#&nIwffgX1*Z2#5^Kl4DbE#NrD&Hi4|7SPqZ}(>_+JMz=s|k77aEL}<=0Zfb)a z%F(*L3zCA<=xO)2U3B|pcTqDbBoFp>QyAEU(jMu8(jLA61-H!ucI804+B!$E^cQQa z)_ERrW3g!B9iLb3nn3dlkvD7KsY?sRvls3QC0qPi>o<)GHx%4Xb$5a3GBTJ(k@`e@ z$RUa^%S15^1oLEmA=sayrP5;9qtf!Z1*?e$ORVPsXpL{jL<6E)0sj&swP3}NPmR%FM?O>SQgN5XfHE< zo(4#Cv11(%Nnw_{_Ro}r6=gKd{k?NebJ~<~Kv0r(r0qe4n3LFx$5%x(BKvrz$m?LG zjLIc;hbj0FMdb9aH9Lpsof#yG$(0sG2%RL;d(n>;#jb!R_+dad+K;Ccw!|RY?uS(a zj~?=&M!4C(5LnlH6k%aYvz@7?xRa^2gml%vn&eKl$R_lJ+e|xsNfXzr#xuh(>`}9g zLHSyiFwK^-p!;p$yt7$F|3*IfO3Mlu9e>Dpx8O`37?fA`cj`C0B-m9uRhJjs^mRp# zWB;Aj6|G^1V6`jg7#7V9UFvnB4((nIwG?k%c7h`?0tS8J3Bn0t#pb#SA}N-|45$-j z$R>%7cc2ebAClXc(&0UtHX<>pd)akR3Kx_cK+n<}FhzmTx!8e9^u2e4%x{>T6pQ`6 zO182bh$-W5A3^wos0SV_TgPmF4WUP-+D25KjbC{y_6W_9I2_vNKwU(^qSdn&>^=*t z&uvp*@c8#2*paD!ZMCi3;K{Na;I4Q35zw$YrW5U@Kk~)&rw;G?d7Q&c9|x<Hg|CNMsxovmfth*|E*GHezPTWa^Hd^F4!B3sF;)? z(NaPyAhocu1jUe(!5Cy|dh|W2=!@fNmuNOzxi^tE_jAtzNJ0JR-avc_H|ve#KO}#S z#a(8secu|^Tx553d4r@3#6^MHbH)vmiBpn0X^29xEv!Vuh1n(Sr5I0V&`jA2;WS|Y zbf0e}X|)wA-Pf5gBZ>r4YX3Mav1kKY(ulAJ0Q*jB)YhviHK)w!TJsi3^dMa$L@^{` z_De`fF4;M87vM3Ph9SzCoCi$#Fsd38u!^0#*sPful^p5oI(xGU?yeYjn;Hq1!wzFk zG&2w}W3`AX4bxoVm03y>ts{KaDf!}b&7$(P4KAMP=vK5?1In^-YYNtx1f#}+2QK@h zeSeAI@E6Z8a?)>sZ`fbq9_snl6LCu6g>o)rO;ijp3|$vig+4t} zylEo7$SEW<_U+qgVcaVhk+4k+C9THI5V10qV*dOV6pPtAI$)QN{!JRBKh-D zk2^{j@bZ}yqW?<#VVuI_27*cI-V~sJiqQv&m07+10XF+#ZnIJdr8t`9s_EE;T2V;B z4UnQUH9EdX%zwh-5&wflY#ve!IWt0UE-My3?L#^Bh%kcgP1q{&26eXLn zTkjJ*w+(|_>Pq0v8{%nX$QZbf)tbJaLY$03;MO=Ic-uqYUmUCuXD>J>o6BCRF=xa% z3R4SK9#t1!K4I_d>tZgE>&+kZ?Q}1qo4&h%U$GfY058s%*=!kac{0Z+4Hwm!)pFLR zJ+5*OpgWUrm0FPI2ib4NPJ+Sk07j(`diti^i#kh&f}i>P4~|d?RFb#!JN)~D@)beox}bw?4VCf^y*`2{4`-@%SFTry2h z>9VBc9#JxEs1+0i2^LR@B1J`B9Ac=#FW=(?2;5;#U$0E0UNag_!jY$&2diQk_n)bT zl5Me_SUvqUjwCqmVcyb`igygB_4YUB*m$h5oeKv3uIF0sk}~es!{D>4r%PC*F~FN3owq5e0|YeUTSG#Vq%&Gk7uwW z0lDo#_wvflqHeRm*}l?}o;EILszBt|EW*zNPmq#?4A+&i0xx^?9obLyY4xx=Y9&^G;xYXYPxG)DOpPg!i_Ccl#3L}6xAAZzNhPK1XaC_~ z!A|mlo?Be*8Nn=a+FhgpOj@G7yYs(Qk(8&|h@_>w8Y^r&5nCqe0V60rRz?b5%J;GYeBqSAjo|K692GxD4` zRZyM2FdI+-jK2}WAZTZ()w_)V{n5tEb@>+JYluDozCb$fA4H)$bzg(Ux{*hXurjO^ zwAxc+UXu=&JV*E59}h3kzQPG4M)X8E*}#_&}w*KEgtX)cU{vm9b$atHa;s>| z+L6&cn8xUL*OSjx4YGjf6{Eq+Q3{!ZyhrL&^6Vz@jGbI%cAM9GkmFlamTbcQGvOlL zmJ?(FI)c86=JEs|*;?h~o)88>12nXlpMR4@yh%qdwFNpct;vMlc=;{FSo*apJ;p}! zAX~t;3tb~VuP|ZW;z$=IHf->F@Ml)&-&Bnb{iQyE#;GZ@C$PzEf6~q}4D>9jic@mTO5x76ulDz@+XAcm35!VSu zT*Gs>;f0b2TNpjU_BjHZ&S6Sqk6V1370+!eppV2H+FY!q*n=GHQ!9Rn6MjY!Jc77A zG7Y!lFp8?TIHN!LXO?gCnsYM-gQxsm=Ek**VmZu7vnuufD7K~GIxfxbsQ@qv2T zPa`tvHB$fFCyZl>3oYg?_wW)C>^_iDOc^B7klnTOoytQH18WkOk)L2BSD0r%xgRSW zQS9elF^?O=_@|58zKLK;(f77l-Zzu}4{fXed2saq!5k#UZAoDBqYQS{sn@j@Vtp|$ zG%gnZ$U|9@u#w1@11Sjl8ze^Co=)7yS(}=;68a3~g;NDe_X^}yJj;~s8xq9ahQ5_r zxAlTMnep*)w1e(TG%tWsjo3RR;yVGPEO4V{Zp?=a_0R#=V^ioQu4YL=BO4r0$$XTX zZfnw#_$V}sDAIDrezGQ+h?q24St0QNug_?{s-pI(^jg`#JRxM1YBV;a@@JQvH8*>> zIJvku74E0NlXkYe_624>znU0J@L<-c=G#F3k4A_)*;ky!C(^uZfj%WB3-*{*B$?9+ zDm$WFp=0(xnt6`vDQV3Jl5f&R(Mp};;q8d3I%Kn>Kx=^;uSVCw0L=gw53%Bp==8Sw zxtx=cs!^-_+i{2OK`Q;913+AXc_&Z5$@z3<)So0CU3;JAv=H?@Zpi~riQ{z-zLtVL z!oF<}@IgJp)Iyz1zVJ42!SPHSkjYNS4%ulVVIXdRuiZ@5Mx8LJS}J#qD^Zi_xQ@>DKDr-_e#>5h3dtje*NcwH_h;i{Sx7}dkdpuW z(yUCjckQsagv*QGMSi9u1`Z|V^}Wjf7B@q%j2DQXyd0nOyqg%m{CK_lAoKlJ7#8M} z%IvR?Vh$6aDWK2W!=i?*<77q&B8O&3?zP(Cs@kapc)&p7En?J;t-TX9abGT#H?TW? ztO5(lPKRuC7fs}zwcUKbRh=7E8wzTsa#Z{a`WR}?UZ%!HohN}d&xJ=JQhpO1PI#>X zHkb>pW04pU%Bj_mf~U}1F1=wxdBZu1790>3Dm44bQ#F=T4V3&HlOLsGH)+AK$cHk6 zia$=$kog?)07HCL*PI6}DRhpM^*%I*kHM<#1Se+AQ!!xyhcy6j7`iDX7Z-2i73_n# zas*?7LkxS-XSqv;YBa zW_n*32D(HTYQ0$feV_Fru1ZxW0g&iwqixPX3=9t4o)o|kOo79V$?$uh?#8Q8e>4e)V6;_(x&ViUVxma+i25qea;d-oK7ouuDsB^ab{ zu1qjQ%`n56VtxBE#0qAzb7lph`Eb-}TYpXB!H-}3Ykqyp`otprp7{VEuW*^IR2n$Fb99*nAtqT&oOFIf z@w*6>YvOGw@Ja?Pp1=whZqydzx@9X4n^2!n83C5{C?G@|E?&$?p*g68)kNvUTJ)I6 z1Q|(#UuP6pj78GUxq11m-GSszc+)X{C2eo-?8ud9sB=3(D47v?`JAa{V(IF zPZQ_0AY*9M97>Jf<o%#O_%Wq}8>YM=q0|tGY+hlXcpE=Z4Od z`NT7Hu2hnvRoqOw@g1f=bv`+nba{GwA$Ak0INlqI1k<9!x_!sL()h?hEWoWrdU3w` zZ%%)VR+Bc@_v!C#koM1p-3v_^L6)_Ktj4HE>aUh%2XZE@JFMOn)J~c`_7VWNb9c-N z2b|SZMR4Z@E7j&q&9(6H3yjEu6HV7{2!1t0lgizD;mZ9$r(r7W5G$ky@w(T_dFnOD z*p#+z$@pKE+>o@%eT(2-p_C}wbQ5s(%Sn_{$HDN@MB+Ev?t@3dPy`%TZ!z}AThZSu zN<1i$siJhXFdjV zP*y|V<`V8t=h#XTRUR~5`c`Z9^-`*BZf?WAehGdg)E2Je)hqFa!k{V(u+(hTf^Yq& zoruUh2(^3pe)2{bvt4&4Y9CY3js)PUHtd4rVG57}uFJL)D(JfSIo^{P=7liFXG zq5yqgof0V8paQcP!gy+;^pp-DA5pj=gbMN0eW=-eY+N8~y+G>t+x}oa!5r>tW$xhI zPQSv=pi;~653Gvf6~*JcQ%t1xOrH2l3Zy@8AoJ+wz@daW@m7?%LXkr!bw9GY@ns3e zSfuWF_gkWnesv?s3I`@}NgE2xwgs&rj?kH-FEy82=O8`+szN ziHch`vvS`zNfap14!&#i9H@wF7}yIPm=UB%(o(}F{wsZ(wA0nJ2aD^@B41>>o-_U6 zUqD~vdo48S8~FTb^+%#zcbQiiYoDKYcj&$#^;Smmb+Ljp(L=1Kt_J!;0s%1|JK}Wi z;={~oL!foo5n8=}rs6MmUW~R&;SIJO3TL4Ky?kh+b2rT9B1Jl4>#Uh-Bec z`Hsp<==#UEW6pGPhNk8H!!DUQR~#F9jEMI6T*OWfN^Ze&X(4nV$wa8QUJ>oTkruH# zm~O<`J7Wxseo@FqaZMl#Y(mrFW9AHM9Kb|XBMqaZ2a)DvJgYipkDD_VUF_PKd~dT7 z#02}bBfPn9a!X!O#83=lbJSK#E}K&yx-HI#T6ua)6o0{|={*HFusCkHzs|Fn&|C3H zBck1cmfcWVUN&i>X$YU^Sn6k2H;r3zuXbJFz)r5~3$d$tUj(l1?o={MM){kjgqXRO zc5R*#{;V7AQh|G|)jLM@wGAK&rm2~@{Pewv#06pHbKn#wL0P6F1!^qw9g&cW3Z=9} zj)POhOlwsh@eF=>z?#sIs*C-Nl(yU!#DaiaxhEs#iJqQ8w%(?+6lU02MYSeDkr!B- zPjMv+on6OLXgGnAtl(ao>|X2Y8*Hb}GRW5}-IzXnoo-d0!m4Vy$GS!XOLy>3_+UGs z2D|YcQx@M#M|}TDOetGi{9lGo9m-=0-^+nKE^*?$^uHkxZh}I{#UTQd;X!L+W@jm( zDg@N4+lUqI92o_rNk{3P>1gxAL=&O;x)ZT=q1mk0kLlE$WeWuY_$0`0jY-Kkt zP*|m3AF}Ubd=`<>(Xg0har*_@x2YH}bn0Wk*OZz3*e5;Zc;2uBdnl8?&XjupbkOeNZsNh6pvsq_ydmJI+*z**{I{0K)-;p1~k8cpJXL$^t!-`E}=*4G^-E8>H!LjTPxSx zcF+cS`ommfKMhNSbas^@YbTpH1*RFrBuATUR zt{oFWSk^$xU&kbFQ;MCX22RAN5F6eq9UfR$ut`Jw--p2YX)A*J69m^!oYfj2y7NYcH6&r+0~_sH^c^nzeN1AU4Ga7=FlR{S|Mm~MpzY0$Z+p2W(a={b-pR9EO1Rs zB%KY|@wLcAA@)KXi!d2_BxrkhDn`DT1=Dec}V!okd{$+wK z4E{n8R*xKyci1(CnNdhf$Dp2(Jpof0-0%-38X=Dd9PQgT+w%Lshx9+loPS~MOm%ZT zt%2B2iL_KU_ita%N>xjB!#71_3=3c}o zgeW~^U_ZTJQ2!PqXulQd=3b=XOQhwATK$y(9$#1jOQ4}4?~l#&nek)H(04f(Sr=s| zWv7Lu1=%WGk4FSw^;;!8&YPM)pQDCY9DhU`hMty1@sq1=Tj7bFsOOBZOFlpR`W>-J$-(kezWJj;`?x-v>ev{*8V z8p|KXJPV$HyQr1A(9LVrM47u-XpcrIyO`yWvx1pVYc&?154aneRpLqgx)EMvRaa#|9?Wwqs2+W8n5~79G z(}iCiLk;?enn}ew`HzhG+tu+Ru@T+K5juvZN)wY;x6HjvqD!&!)$$;1VAh~7fg0K| zEha#aN=Yv|3^~YFH}cc38ovVb%L|g@9W6fo(JtT6$fa?zf@Ct88e}m?i)b*Jgc{fl zExfdvw-BYDmH6>(4QMt#p0;FUIQqkhD}aH?a7)_%JtA~soqj{ppP_82yi9kaxuK>~ ze_)Zt>1?q=ZH*kF{1iq9sr*tVuy=u>Zev}!gEZx@O6-fjyu9X00gpIl-fS_pzjpqJ z1yqBmf9NF!jaF<+YxgH6oXBdK)sH(>VZ)1siyA$P<#KDt;8NT*l_0{xit~5j1P)FN zI8hhYKhQ)i z37^aP13B~u65?sg+_@2Kr^iWHN=U;EDSZ@2W2!5ALhGNWXnFBY%7W?1 z=HI9JzQ-pLKZDYTv<0-lt|6c-RwhxZ)mU2Os{bsX_i^@*fKUj8*aDO5pks=qn3Dv6 zwggpKLuyRCTVPwmw1r}B#AS}?X7b837UlXwp~E2|PJw2SGVueL7){Y&z!jL!XN=0i zU^Eig`S2`{+gU$68aRdWx?BZ{sU_f=8sn~>s~M?GU~`fH5kCc; z8ICp+INM3(3{#k32RZdv6b9MQYdZXNuk7ed8;G?S2nT+NZBG=Tar^KFl2SvhW$bGW#kdWL-I)s_IqVnCDDM9fm8g;P;8 z7t4yZn3^*NQfx7SwmkzP$=fwdC}bafQSEF@pd&P8@H#`swGy_rz;Z?Ty5mkS%>m#% zp_!m9e<()sfKiY(nF<1zBz&&`ZlJf6QLvLhl`_``%RW&{+O>Xhp;lwSsyRqGf=RWd zpftiR`={2(siiPAS|p}@q=NhVc0ELprt%=fMXO3B)4ryC2LT(o=sLM7hJC!}T1@)E zA3^J$3&1*M6Xq>03FX`R&w*NkrZE?FwU+Muut;>qNhj@bX17ZJxnOlPSZ=Zeiz~T_ zOu#yc3t6ONHB;?|r4w+pI)~KGN;HOGC)txxiUN8#mexj+W(cz%9a4sx|IRG=}ia zuEBuba3AHsV2feqw-3MvuL`I+2|`Ud4~7ZkN=JZ;L20|Oxna5vx1qbIh#k2O4$RQF zo`tL()zxaqibg^GbB+BS5#U{@K;WWQj~GcB1zb}zJkPwH|5hZ9iH2308!>_;%msji zJHSL~s)YHBR=Koa1mLEOHos*`gp=s8KA-C zu0aE+W!#iJ*0xqKm3A`fUGy#O+X+5W36myS>Uh2!R*s$aCU^`K&KKLCCDkejX2p=5 z%o7-fl03x`gaSNyr?3_JLv?2RLS3F*8ub>Jd@^Cc17)v8vYEK4aqo?OS@W9mt%ITJ z9=S2%R8M){CugT@k~~0x`}Vl!svYqX=E)c_oU6o}#Hb^%G1l3BudxA{F*tbjG;W_>=xV73pKY53v%>I)@D36I_@&p$h|Aw zonQS`07z_F#@T-%@-Tb|)7;;anoD_WH>9ewFy(ZcEOM$#Y)8>qi7rCnsH9GO-_7zF zu*C87{Df1P4TEOsnzZ@H%&lvV(3V@;Q!%+OYRp`g05PjY^gL$^$-t0Y>H*CDDs?FZly*oZ&dxvsxaUWF!{em4{A>n@vpXg$dwvt@_rgmHF z-MER`ABa8R-t_H*kv>}CzOpz;!>p^^9ztHMsHL|SRnS<-y5Z*r(_}c4=fXF`l^-i}>e7v!qs_jv zqvWhX^F=2sDNWA9c@P0?lUlr6ecrTKM%pNQ^?*Lq?p-0~?_j50xV%^(+H>sMul#Tw zeciF*1=?a7cI(}352%>LO96pD+?9!fNyl^9v3^v&Y4L)mNGK0FN43&Xf8jUlxW1Bw zyiu2;qW-aGNhs=zbuoxnxiwZ3{PFZM#Kw)9H@(hgX23h(`Wm~m4&TvoZoYp{plb^> z_#?vXcxd>r7K+1HKJvhed>gtK`TAbJUazUWQY6T~t2af%#<+Veyr%7-#*A#@&*;@g58{i|E%6yC_InGXCOd{L0;$)z#?n7M`re zh!kO{6=>7I?*}czyF7_frt#)s1CFJ_XE&VrDA?Dp3XbvF{qsEJgb&OLSNz_5g?HpK z9)8rsr4JN!Af3G9!#Qn(6zaUDqLN(g2g8*M)Djap?WMK9NKlkC)E2|-g|#-rp%!Gz zAHd%`iq|81efi93m3yTBw3g0j#;Yb2X{mhRAI?&KDmbGqou(2xiRNb^sV}%%Wu0?< z?($L>(#BO*)^)rSgyNRni$i`R4v;GhlCZ8$@e^ROX(p=2_v6Y!%^As zu022)fHdv_-~Yu_H6WVPLpHQx!W%^6j)cBhS`O3QBW#x(eX54d&I22op(N59b*&$v zFiSRY6rOc^(dgSV1>a7-5C;(5S5MvKcM2Jm-LD9TGqDpP097%52V+0>Xqq!! zq4e3vj53SE6i8J`XcQB|MZPP8j;PAOnpGnllH6#Ku~vS42xP*Nz@~y%db7Xi8s09P z1)e%8ys6&M8D=Dt6&t`iKG_4X=!kgRQoh%Z`dc&mlOUqXk-k`jKv9@(a^2-Upw>?< zt5*^DV~6Zedbec4NVl($2T{&b)zA@b#dUyd>`2JC0=xa_fIm8{5um zr-!ApXZhC8@=vC2WyxO|!@0Km)h8ep*`^he92$@YwP>VcdoS5OC^s38e#7RPsg4j+ zbVGG}WRSET&ZfrcR(x~k8n1rTP%CnfUNKUonD$P?FtNFF#cn!wEIab-;jU=B1dHK@ z(;(yAQJ`O$sMn>h;pf^8{JISW%d+@v6@CnXh9n5TXGC}?FI9i-D0OMaIg&mAg=0Kn zNJ7oz5*ReJukD55fUsMuaP+H4tDN&V9zfqF@ zr=#ecUk9wu{0;!+gl;3Bw=Vn^)z$ahVhhw)io!na&9}LmWurLb0zubxK=UEnU*{5P z+SP}&*(iBKSO4{alBHaY^)5Q=mZ+2OwIooJ7*Q5XJ+2|q`9#f?6myq!&oz?klihLq z4C)$XP!BNS0G_Z1&TM>?Jk{S~{F3n83ioli=IO6f%wkvCl(RFFw~j0tb{GvXTx>*sB0McY0s&SNvj4+^h`9nJ_wM>F!Uc>X}9PifQekn0sKI2SAJP!a4h z5cyGTuCj3ZBM^&{dRelIlT^9zcfaAuL5Y~bl!ppSf`wZbK$z#6U~rdclk``e+!qhe z6Qspo*%<)eu6?C;Bp<^VuW6JI|Ncvyn+LlSl;Mp22Bl7ARQ0Xc24%29(ZrdsIPw&-=yHQ7_Vle|5h>AST0 zUGX2Zk34vp?U~IHT|;$U86T+UUHl_NE4m|}>E~6q``7hccCaT^#y+?wD##Q%HwPd8 zV3x4L4|qqu`B$4(LXqDJngNy-{&@aFBvVsywt@X^}iH7P%>bR?ciC$I^U-4Foa`YKI^qDyGK7k%E%c_P=yzAi`YnxGA%DeNd++j3*h^ z=rn>oBd0|~lZ<6YvmkKY*ZJlJ;Im0tqgWu&E92eqt;+NYdxx`eS(4Hw_Jb5|yVvBg z*tbdY^!AN;luEyN4VRhS@-_DC{({ziH{&Z}iGElSV~qvT>L-8G%+yEL zX#MFOhj{InyKG=mvW-<1B@c-}x$vA(nU?>S>0*eN#!SLzQ)Ex7fvQ)S4D<8|I#N$3 zT5Ei`Z?cxBODHX8(Xp73v`IsAYC@9b;t}z0wxVuQSY1J^GRwDPN@qbM-ZF48T$GZ< z8WU+;Pqo?{ghI-KZ-i*ydXu`Ep0Xw^McH_KE9J0S7G;x8Fe`DVG?j3Pv=0YzJ}yZR z%2=oqHiUjvuk0~Ca>Kol4CFi0_xQT~;_F?=u+!kIDl-9g`#ZNZ9HCy17Ga1v^Jv9# z{T4Kb1-AzUxq*MutfOWWZgD*HnFfyYg0&e9f(5tZ>krPF6{VikNeHoc{linPPt#Si z&*g>(c54V8rT_AX!J&bNm-!umPvOR}vDai#`CX___J#=zeB*{4<&2WpaDncZsOkp* zsg<%@@rbrMkR_ux9?LsQxzoBa1s%$BBn6vk#{&&zUwcfzeCBJUwFYSF$08qDsB;gWQN*g!p8pxjofWbqNSZOEKOaTx@+* zwdt5*Q47@EOZ~EZL9s?1o?A%9TJT=Ob_13yyugvPg*e&ZU(r6^k4=2+D-@n=Hv5vu zSXG|hM(>h9^zn=eQ=$6`JO&70&2|%V5Lsx>)(%#;pcOfu>*nk_3HB_BNaH$`jM<^S zcSftDU1?nL;jy)+sfonQN}(}gUW?d_ikr*3=^{G)=tjBtEPe>TO|0ddVB zTklrSHiW+!#26frPXQQ(YN8DG$PZo?(po(QUCCf_OJC`pw*uey00%gmH!`WJkrKXj2!#6?`T25mTu9OJp2L8z3! z=arrL$ZqxuE{%yV)14Kd>k}j7pxZ6#$Dz8$@WV5p8kTqN<-7W)Q7Gt2{KoOPK_tZ| zf2WG~O5@{qPI+W<4f_;reuFVdO^5`ADC1!JQE|N`s3cq@(0WB!n0uh@*c{=LAd;~} zyGK@hbF-Oo+!nN)@i*O(`@FA#u?o=~e{`4O#5}z&=UkU*50fOrzi11D^&FOqe>wii z?*k+2|EcUs;Gx{!@KBT~>PAwLrIDT7Th=Utu?~?np@t^gFs?zgX=D${RwOY^WGh-+ z+#4$066ISh8eYW#FXWp~S`<*%O^ZuItL1Tyqt8#tZ zY120E;^VG`!lZn&3sPd$RkdHpU#|w+bYV)pJC|SH9g%|5IkxVTQcBA4CL0}$&}ef@ zW^Vtj%M;;_1xxP9x#ex17&4N*{ksO*_4O}xYu(p*JkL#yr}@7b)t5X?%CY<+s5_MJ zuiqt+N_;A(_)%lumoyRFixWa-M7qK_9s6<1X?JDa9fP!+_6u~~M$5L=ipB=7(j#f< zZ34J%=bs549%~_mA(|={uZNs_0?o7;-LBP(ZRnkd{-^|2|=4vUTmtByHL8 zEph`(LSEzQj68a+`d$V<45J7cyv^#|^|%fD#si1Nx!4NW*`l*{->HEWNh6-|g>-=r zXmQ|-i}Ku$ndUeHQ^&ieT!Lf}vf6GaqW9$DJ2NWrqwPY%%4nip$@vK$nRp*_C-v<| zuKz~ZyN&<%!NS26&x?jhy+@awJipMQ-8(X4#Ae5??U<1QMt1l9R=w9fAnEF}NYu$2 z>6}Vkc zIb*A?G*z8^IvibmBKn_u^5&T_1oey0gZS2~obf(#xk=erZGTEdQnt3DMGM+0oPwss zj5zXD;(oWhB_T@~Ig#9@v)AKtXu3>Inmgf@A|-lD-1U>cNyl3h?ADD9)GG4}zUGPk zZzaXe!~Kf?<~@$G?Uql3t8jy9{2!doq4=J}j9ktTxss{p6!9UdjyDERlA*xZ!=Q)KDs5O)phz>Vq3BNGoM(H|=1*Q4$^2fTZw z(%nq1P|5Rt81}SYJpEEzMPl5VJsV5&4e)ZWKDyoZ>1EwpkHx-AQVQc8%JMz;{H~p{=FXV>jIxvm4X*qv52e?Y-f%DJ zxEA165GikEASQ^fH6K#d!Tpu2HP{sFs%E=e$gYd$aj$+xue6N+Wc(rAz~wUsk2`(b z8Kvmyz%bKQxpP}~baG-rwYcYCvkHOi zlkR<=>ZBTU*8RF_d#Bl@zZsRIhx<%~Z@Z=ik z>adw3!DK(8R|q$vy{FTxw%#xliD~6qXmY^7_9kthVPTF~Xy1CfBqbU~?1QmxmU=+k z(ggxvEuA;0e&+ci-zQR{-f7aO{O(Pz_OsEjLh_K>MbvoZ4nxtk5u{g@nPv)cgW_R} z9}EA4K4@z0?7ue}Z(o~R(X&FjejUI2g~08PH1E4w>9o{)S(?1>Z0XMvTb|;&EuyOE zGvWNpYX)Nv<8|a^;1>bh#&znEcl-r!T#pn= z4$?Yudha6F%4b>*8@=BdtXXY4N+`U4Dmx$}>HeVJk-QdTG@t!tVT#0(LeV0gvqyyw z2sEp^9eY0N`u10Tm4n8No&A=)IeEC|gnmEXoNSzu!1<4R<%-9kY_8~5Ej?zRegMn78wuMs#;i&eUA0Zk_RXQ3b&TT} z;SCI=7-FUB@*&;8|n>(_g^HGf3@QODE3LpmX~ELnymQm{Sx9xrKS zK29p~?v@R$0=v6Dr5aW>-!{+h@?Q58|Kz8{{W`%J+lDAdb&M5VHrX_mDY;1-JLnf)ezmPau$)1;=`-FU=-r-83tX=C`S#}GZufju zQ>sXNT0Ny=k@nc%cFnvA_i4SC)?_ORXHq8B4D%el1uPX`c~uG#S1M7C+*MMqLw78E zhY2dI8@+N^qrMI1+;TUda(vGqGSRyU{Fnm`aqrr7bz42c5xsOO-~oZpkzorD1g}Y<6rk&3>PsSGy}W?MtqFky@A(X# zIuNZK0cK?^=;PUAu>j0#HtjbHCV*6?jzA&OoE$*Jlga*}LF`SF?WLhv1O|zqC<>*> zYB;#lsYKx0&kH@BFpW8n*yDcc6?;_zaJs<-jPSkCsSX-!aV=P5kUgF@Nu<{a%#K*F z134Q{9|YX7X(v$62_cY3^G%t~rD>Q0z@)1|zs)vjJ6Jq9;7#Ki`w+eS**En?7;n&7 zu==V3T&eFboN3ZiMx3D8qYc;VjFUk_H-WWCau(VFXSQf~viH0L$gwD$UfFHqNcgN`x}M+YQ6RnN<+@t>JUp#)9YOkqst-Ga?{FsDpEeX0(5v{0J~SEbWiL zXC2}M4?UH@u&|;%0y`eb33ldo4~z-x8zY!oVmV=c+f$m?RfDC35mdQ2E>Pze7KWP- z>!Bh<&57I+O_^s}9Tg^k)h7{xx@0a0IA~GAOt2yy!X%Q$1rt~LbTB6@Du!_0%HV>N zlf)QI1&gvERKwso23mJ!Ou6ZS#zCS5W`gxE5T>C#E|{i<1D35C222I33?Njaz`On7 zi<+VWFP6D{e-{yiN#M|Jgk<44u1TiMI78S5W`Sdb5f+{zu34s{CfWN7a3Cf^@L%!& zN$?|!!9j2c)j$~+R6n#891w-z8(!oBpL2K=+%a$r2|~8-(vQj5_XT`<0Ksf;oP+tz z9CObS!0m)Tgg`K#xBM8B(|Z)Wb&DYL{WTYv`;A=q6~Nnx2+!lTIXtj8J7dZE!P_{z z#f8w6F}^!?^KE#+ZDv+xd5O&3EmomZzsv?>E-~ygGum45fk!SBN&|eo1rKw^?aZJ4 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/core/gemstone/android/gradle/wrapper/gradle-wrapper.properties b/core/gemstone/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..e88a195314 --- /dev/null +++ b/core/gemstone/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Nov 12 12:30:06 JST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/core/gemstone/android/gradlew b/core/gemstone/android/gradlew new file mode 100755 index 0000000000..4f906e0c81 --- /dev/null +++ b/core/gemstone/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/core/gemstone/android/gradlew.bat b/core/gemstone/android/gradlew.bat new file mode 100644 index 0000000000..ac1b06f938 --- /dev/null +++ b/core/gemstone/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/core/gemstone/android/settings.gradle.kts b/core/gemstone/android/settings.gradle.kts new file mode 100644 index 0000000000..6cc1f4ca45 --- /dev/null +++ b/core/gemstone/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "gemstone-android" +include(":gemstone") diff --git a/core/gemstone/justfile b/core/gemstone/justfile new file mode 100644 index 0000000000..c7ff39b1d1 --- /dev/null +++ b/core/gemstone/justfile @@ -0,0 +1,162 @@ +list: + @just --list + +build: + cargo build + +lint: + @cargo clippy --version + cargo clippy -- -D warnings + +test: + cargo test --lib + +export ANDROID_HOME := env("ANDROID_HOME", "~/Library/Android/sdk") + +install-ndk: + #!/usr/bin/env bash + SDK_MANAGER=${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager + NDK="ndk;28.1.13356709" + + echo "Installing ndk: $NDK" + $SDK_MANAGER --install $NDK + +install-ios-targets: + #!/usr/bin/env bash + rustup target add aarch64-apple-ios-sim aarch64-apple-ios aarch64-apple-ios-macabi aarch64-apple-darwin + +install-android-targets: + rustup target add x86_64-linux-android aarch64-linux-android armv7-linux-androideabi + cargo install cargo-ndk@4.1.2 + +build-ios: prepare-ios-package build-ios-lib + #!/usr/bin/env bash + set -o pipefail + DEVICE_ID=$(xcrun simctl list devices available | grep iPhone | head -1 | sed 's/.*(\([A-F0-9-]*\)).*/\1/') + DERIVED_DATA_PATH="$PWD/${IOS_DERIVED_DATA}" + PRODUCTS_DIR="$DERIVED_DATA_PATH/Build/Products/$XCODE_CONFIGURATION-iphonesimulator" + echo "Using Simulator: $DEVICE_ID" + cd ${IOS_PROJECT_FOLDER} + xcodebuild \ + -scheme GemTest \ + -configuration "$XCODE_CONFIGURATION" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + -destination "platform=iOS Simulator,id=$DEVICE_ID,arch=arm64" \ + "OTHER_LDFLAGS=\$(inherited) -L$PRODUCTS_DIR -lgemstone" \ + build | xcbeautify + +test-ios: prepare-ios-package build-ios-simulator-lib + #!/usr/bin/env bash + set -o pipefail + DEVICE_ID=$(xcrun simctl list devices available | grep iPhone | head -1 | sed 's/.*(\([A-F0-9-]*\)).*/\1/') + DERIVED_DATA_PATH="$PWD/${IOS_DERIVED_DATA}" + PRODUCTS_DIR="$DERIVED_DATA_PATH/Build/Products/$XCODE_CONFIGURATION-iphonesimulator" + echo "Using Simulator: $DEVICE_ID" + cd ${IOS_PROJECT_FOLDER} + xcodebuild \ + -scheme GemTest \ + -configuration "$XCODE_CONFIGURATION" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + -destination "platform=iOS Simulator,id=$DEVICE_ID,arch=arm64" \ + "OTHER_LDFLAGS=\$(inherited) -L$PRODUCTS_DIR -lgemstone" \ + test | xcbeautify + +export LIB_NAME := "gemstone" +export STATIC_LIB_NAME := "lib" + LIB_NAME + ".a" +export DY_LIB_NAME := if os() == "macos" { "libgemstone.dylib" } else { "libgemstone.so" } + +export BUILD_MODE := if env("BUILD_MODE", "debug") == "release" { "release" } else { "debug" } +export XCODE_CONFIGURATION := if BUILD_MODE == "release" { "Release" } else { "Debug" } +CARGO_BUILD_FLAG := if BUILD_MODE == "release" { "--release" } else { "" } + +export TARGET_DIR := "../target" +export GEN_SWIFT_FOLDER := "generated/swift" +export GEN_KOTLIN_FOLDER := "generated/kotlin" +export ANDROID_MODULE_FOLDER := "android/gemstone/src/main/java" +export IOS_PROJECT_FOLDER := "tests/ios/GemTest" +export IOS_PACKAGE_FOLDER := "tests/ios/Packages/Gemstone" +export IOS_DERIVED_DATA := "tests/ios/build/DerivedData" +export IOS_SIMULATOR_RUST_TARGET := "aarch64-apple-ios-sim" +export IOS_RUST_TARGETS := IOS_SIMULATOR_RUST_TARGET + " aarch64-apple-ios aarch64-apple-ios-macabi aarch64-apple-darwin" + +export LIB_OUTPUT_PATH := TARGET_DIR + "/" + BUILD_MODE + "/" + DY_LIB_NAME + +build-ios-lib targets=IOS_RUST_TARGETS: + #!/usr/bin/env bash + set -euo pipefail + + targets="{{targets}}" + profile="${BUILD_MODE}" + build_flag="{{CARGO_BUILD_FLAG}}" + + products_dir="$PWD/${IOS_DERIVED_DATA}/Build/Products/$XCODE_CONFIGURATION-iphonesimulator" + mkdir -p "$products_dir" + built_simulator=0 + + for rust_target in $targets; do + echo "note: Building Gemstone Rust library for $rust_target ($profile)" + case "$rust_target" in + aarch64-apple-ios | aarch64-apple-ios-sim) + IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build --target "$rust_target" --lib ${build_flag} + ;; + aarch64-apple-ios-macabi | aarch64-apple-darwin) + env -u IPHONEOS_DEPLOYMENT_TARGET cargo build --target "$rust_target" --lib ${build_flag} + ;; + *) + echo "error: unsupported Gemstone iOS Rust target: $rust_target" >&2 + exit 1 + ;; + esac + + if [ "$rust_target" = "${IOS_SIMULATOR_RUST_TARGET}" ]; then + built_simulator=1 + fi + done + + if [ "$built_simulator" -eq 1 ]; then + cp "${TARGET_DIR}/${IOS_SIMULATOR_RUST_TARGET}/$profile/libgemstone.a" "$products_dir/libgemstone.a" + echo "note: Gemstone Rust library ready at $products_dir/libgemstone.a" + fi + +build-ios-simulator-lib: (build-ios-lib IOS_SIMULATOR_RUST_TARGET) + +bindgen-swift: + #!/usr/bin/env bash + echo "Bindgen swift $STATIC_LIB_NAME" + mkdir -p ${GEN_SWIFT_FOLDER} + if [ "${BUILD_MODE}" == "release" ]; then \ + export RUSTFLAGS="${RUSTFLAGS:-} -C strip=none"; \ + fi + cargo build {{CARGO_BUILD_FLAG}} + if [ ! -f "${LIB_OUTPUT_PATH}" ]; then \ + echo "Failed to locate ${DY_LIB_NAME} at ${LIB_OUTPUT_PATH} (mode ${BUILD_MODE})"; \ + exit 1; \ + fi; \ + cargo run {{CARGO_BUILD_FLAG}} -p uniffi-bindgen -- generate --library --language swift --crate ${LIB_NAME} -o ${GEN_SWIFT_FOLDER} "${LIB_OUTPUT_PATH}" + +prepare-ios-package: bindgen-swift + #!/usr/bin/env bash + mkdir -p ${IOS_PACKAGE_FOLDER}/Sources/Gemstone ${IOS_PACKAGE_FOLDER}/Sources/GemstoneFFI/include + cp ${GEN_SWIFT_FOLDER}/gemstone.swift ${IOS_PACKAGE_FOLDER}/Sources/Gemstone/Gemstone.swift + cp ${GEN_SWIFT_FOLDER}/GemstoneFFI.h ${IOS_PACKAGE_FOLDER}/Sources/GemstoneFFI/include/GemstoneFFI.h + +bindgen-kotlin: + #!/usr/bin/env bash + echo "Bindgen kotlin BUILD_MODE: ${BUILD_MODE}" + rm -rf ${GEN_KOTLIN_FOLDER} ${ANDROID_MODULE_FOLDER}/uniffi + mkdir -p ${GEN_KOTLIN_FOLDER} ${ANDROID_MODULE_FOLDER} + if [ "${BUILD_MODE}" == "release" ]; then \ + export RUSTFLAGS="${RUSTFLAGS:-} -C strip=none"; \ + fi + cargo build {{CARGO_BUILD_FLAG}} + if [ ! -f "${LIB_OUTPUT_PATH}" ]; then \ + echo "Failed to locate ${DY_LIB_NAME} at ${LIB_OUTPUT_PATH} (mode ${BUILD_MODE})"; \ + exit 1; \ + fi; \ + cargo run {{CARGO_BUILD_FLAG}} -p uniffi-bindgen -- generate --library --language kotlin --crate ${LIB_NAME} --no-format -o ${GEN_KOTLIN_FOLDER} "${LIB_OUTPUT_PATH}" + cp -Rf ${GEN_KOTLIN_FOLDER}/uniffi ${ANDROID_MODULE_FOLDER} + +build-android: bindgen-kotlin + #!/usr/bin/env bash + rm -rf ~/.m2/repository/com/gemwallet/gemstone/gemstone + cd android && touch local.properties && ./gradlew publishDebugPublicationToMavenLocal diff --git a/core/gemstone/src/address.rs b/core/gemstone/src/address.rs new file mode 100644 index 0000000000..4667d9416f --- /dev/null +++ b/core/gemstone/src/address.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use crate::GemstoneError; +use gem_bitcoin::models::address::Address as BitcoinAddress; +use primitives::{Chain, ChainAddress, ChainType}; + +#[derive(uniffi::Object)] +pub struct GemChainAddress { + inner: ChainAddress, +} + +#[uniffi::export] +impl GemChainAddress { + #[uniffi::constructor] + pub fn new(address: String, chain: Chain) -> Result, GemstoneError> { + if !validate_address(&address, chain) { + return Err(GemstoneError::AnyError { + msg: format!("Invalid address for {chain}"), + }); + } + Ok(Arc::new(Self { + inner: ChainAddress::new(chain, address), + })) + } + + pub fn address(&self) -> String { + self.inner.address().to_string() + } +} + +#[uniffi::export] +pub fn validate_address(address: &str, chain: Chain) -> bool { + match chain.chain_type() { + ChainType::Ethereum | ChainType::HyperCore => gem_evm::validate_address(address), + ChainType::Solana => gem_solana::validate_address(address), + ChainType::Cosmos => gem_cosmos::validate_address(address, chain), + ChainType::Ton => gem_ton::validate_address(address), + ChainType::Tron => gem_tron::validate_address(address), + ChainType::Aptos => gem_aptos::validate_address(address), + ChainType::Sui => gem_sui::validate_address(address), + ChainType::Near => gem_near::validate_address(address), + ChainType::Stellar => gem_stellar::validate_address(address), + ChainType::Algorand => gem_algorand::validate_address(address), + ChainType::Xrp => gem_xrp::validate_address(address), + ChainType::Polkadot => gem_polkadot::validate_address(address), + ChainType::Bitcoin => false, + ChainType::Cardano => gem_cardano::validate_address(address), + } +} + +#[uniffi::export] +pub fn short_address(address: &str, chain: Chain) -> String { + match chain { + Chain::BitcoinCash => BitcoinAddress::new(address, Chain::BitcoinCash).short().to_string(), + _ => address.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chain_address_validation() { + assert!(validate_address("0x5615e8ab93b9d695b6d4d6545f7792aa59e1069a", Chain::Ethereum)); + assert!(!validate_address("0X5615e8ab93b9d695b6d4d6545f7792aa59e1069a", Chain::Ethereum)); + assert!(validate_address("cosmos1h3laqcrmul79zwtw6j63ncsl0adfj07wgupylj", Chain::Cosmos)); + assert!(validate_address("GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2", Chain::Solana)); + assert!(validate_address("rnBFvgZphmN39GWzUJeUitaP22Fr9be75H", Chain::Xrp)); + assert!(!validate_address("rnBFvgZphmN39GWzUJeUitaP22Fr9be75J", Chain::Xrp)); + assert!(validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXo", Chain::Polkadot)); + assert!(!validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXj", Chain::Polkadot)); + assert!(validate_address( + "addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk23", + Chain::Cardano + )); + assert!(!validate_address( + "addr_test1qr4p6f6mm0q9kfyyd9u30umk9cc6gk0nxu25k5rsc4fp7ls7k0qqxslcwwj4gvn4yfmdyrfgwjt3ztuz4zpy4242u0m95r0n", + Chain::Cardano + )); + } + + #[test] + fn test_new_returns_err_for_invalid() { + assert!(GemChainAddress::new("invalid".to_string(), Chain::Ethereum).is_err()); + assert!(GemChainAddress::new("0x5615e8ab93b9d695b6d4d6545f7792aa59e1069a".to_string(), Chain::Ethereum).is_ok()); + } + + #[test] + fn test_short_address_bitcoincash() { + let prefixed = "bitcoincash:qpzl3jxkzgvfd9flnd26leud5duv795fnv7vuaha70"; + let stripped = "qpzl3jxkzgvfd9flnd26leud5duv795fnv7vuaha70"; + + for input in [prefixed, stripped] { + assert_eq!(short_address(input, Chain::BitcoinCash), stripped); + } + } + + #[test] + fn test_short_address_passthrough() { + let eth = "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a"; + assert_eq!(short_address(eth, Chain::Ethereum), eth); + } +} diff --git a/core/gemstone/src/address_formatter.rs b/core/gemstone/src/address_formatter.rs new file mode 100644 index 0000000000..573c60039c --- /dev/null +++ b/core/gemstone/src/address_formatter.rs @@ -0,0 +1,15 @@ +use primitives::{AddressFormatStyle, AddressFormatter, Chain}; + +pub type GemAddressFormatStyle = AddressFormatStyle; + +#[uniffi::remote(Enum)] +pub enum GemAddressFormatStyle { + Short, + Full, + Extra { extra: u32 }, +} + +#[uniffi::export] +pub fn format_address(address: &str, chain: Option, style: GemAddressFormatStyle) -> String { + AddressFormatter::format(address, chain, style) +} diff --git a/core/gemstone/src/alien/client.rs b/core/gemstone/src/alien/client.rs new file mode 100644 index 0000000000..bdf8194cef --- /dev/null +++ b/core/gemstone/src/alien/client.rs @@ -0,0 +1,10 @@ +use super::AlienProvider; +use super::provider::AlienProviderWrapper; +use std::sync::Arc; +use swapper::RpcClient; + +pub type AlienClient = RpcClient; + +pub fn new_alien_client(base_url: String, provider: Arc) -> AlienClient { + RpcClient::new(base_url, Arc::new(AlienProviderWrapper::new(provider))) +} diff --git a/core/gemstone/src/alien/error.rs b/core/gemstone/src/alien/error.rs new file mode 100644 index 0000000000..10b168bb81 --- /dev/null +++ b/core/gemstone/src/alien/error.rs @@ -0,0 +1,8 @@ +pub type AlienError = swapper::AlienError; + +#[uniffi::remote(Enum)] +pub enum AlienError { + RequestError { msg: String }, + ResponseError { msg: String }, + Http { status: u16, len: u32 }, +} diff --git a/core/gemstone/src/alien/mod.rs b/core/gemstone/src/alien/mod.rs new file mode 100644 index 0000000000..e5254dc271 --- /dev/null +++ b/core/gemstone/src/alien/mod.rs @@ -0,0 +1,11 @@ +pub mod client; +pub mod error; +pub mod provider; +#[cfg(feature = "reqwest_provider")] +pub mod reqwest_provider; +pub mod target; + +pub use client::{AlienClient, new_alien_client}; +pub use error::AlienError; +pub use provider::{AlienProvider, AlienProviderWrapper}; +pub use target::{AlienHttpMethod, AlienResponse, AlienTarget, X_CACHE_TTL}; diff --git a/core/gemstone/src/alien/provider.rs b/core/gemstone/src/alien/provider.rs new file mode 100644 index 0000000000..eb872b6fb5 --- /dev/null +++ b/core/gemstone/src/alien/provider.rs @@ -0,0 +1,37 @@ +use super::{AlienError, AlienResponse, AlienTarget}; + +use async_trait::async_trait; +use gem_jsonrpc::rpc::RpcProvider as GenericRpcProvider; +use primitives::Chain; +use std::{fmt::Debug, sync::Arc}; + +#[uniffi::export(with_foreign)] +#[async_trait] +pub trait AlienProvider: Send + Sync + Debug { + async fn request(&self, target: AlienTarget) -> Result; + fn get_endpoint(&self, chain: Chain) -> Result; +} + +#[derive(Debug)] +pub struct AlienProviderWrapper { + provider: Arc, +} + +impl AlienProviderWrapper { + pub fn new(provider: Arc) -> Self { + Self { provider } + } +} + +#[async_trait] +impl GenericRpcProvider for AlienProviderWrapper { + type Error = AlienError; + + async fn request(&self, target: AlienTarget) -> Result { + self.provider.request(target).await + } + + fn get_endpoint(&self, chain: Chain) -> Result { + self.provider.get_endpoint(chain) + } +} diff --git a/core/gemstone/src/alien/reqwest_provider.rs b/core/gemstone/src/alien/reqwest_provider.rs new file mode 100644 index 0000000000..6ef081ebac --- /dev/null +++ b/core/gemstone/src/alien/reqwest_provider.rs @@ -0,0 +1,17 @@ +use super::{AlienError, AlienProvider, AlienTarget}; +use async_trait::async_trait; +use gem_jsonrpc::{RpcProvider as GenericRpcProvider, RpcResponse}; +use primitives::Chain; + +pub use swapper::NativeProvider; + +#[async_trait] +impl AlienProvider for NativeProvider { + async fn request(&self, target: AlienTarget) -> Result { + ::request(self, target).await + } + + fn get_endpoint(&self, chain: Chain) -> Result { + ::get_endpoint(self, chain) + } +} diff --git a/core/gemstone/src/alien/target.rs b/core/gemstone/src/alien/target.rs new file mode 100644 index 0000000000..dfb53865c9 --- /dev/null +++ b/core/gemstone/src/alien/target.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; + +pub use gem_client::X_CACHE_TTL; +pub use gem_jsonrpc::RpcResponse as AlienResponse; +pub type AlienTarget = swapper::Target; +pub type AlienHttpMethod = swapper::HttpMethod; + +#[uniffi::remote(Record)] +pub struct AlienTarget { + pub url: String, + pub method: AlienHttpMethod, + pub headers: Option>, + pub body: Option>, +} + +#[uniffi::remote(Record)] +pub struct AlienResponse { + pub status: Option, + pub data: Vec, +} + +#[uniffi::remote(Enum)] +pub enum AlienHttpMethod { + Get, + Post, + Put, + Delete, + Head, + Options, + Patch, +} + +#[uniffi::export] +fn alien_method_to_string(method: AlienHttpMethod) -> String { + method.into() +} diff --git a/core/gemstone/src/api_client/mod.rs b/core/gemstone/src/api_client/mod.rs new file mode 100644 index 0000000000..725717cf13 --- /dev/null +++ b/core/gemstone/src/api_client/mod.rs @@ -0,0 +1,22 @@ +use crate::alien::{AlienProvider, AlienTarget}; +use primitives::{ScanTransaction, ScanTransactionPayload}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct GemApiClient { + api_url: String, + provider: Arc, +} + +impl GemApiClient { + pub fn new(api_url: String, provider: Arc) -> Self { + Self { api_url, provider } + } + + pub async fn scan_transaction(&self, payload: ScanTransactionPayload) -> Result { + let url = format!("{}/v1/scan/transaction", self.api_url); + let target = AlienTarget::post_json(&url, &payload); + let response = self.provider.request(target).await.map_err(|e| e.to_string())?; + serde_json::from_slice(&response.data).map_err(|e| format!("Failed to parse response: {}", e)) + } +} diff --git a/core/gemstone/src/auth.rs b/core/gemstone/src/auth.rs new file mode 100644 index 0000000000..6f6937609b --- /dev/null +++ b/core/gemstone/src/auth.rs @@ -0,0 +1,85 @@ +use crate::GemstoneError; +use gem_auth::create_auth_hash; +use primitives::{AuthMessage, AuthNonce, Chain, hex::encode_with_0x}; +use signer::Signer; +use zeroize::Zeroizing; + +const AUTH_SIGNING_BYTES_LENGTH: usize = 32; + +pub type GemAuthNonce = AuthNonce; + +#[uniffi::remote(Record)] +pub struct GemAuthNonce { + pub nonce: String, + pub timestamp: u32, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemAuthMessage { + pub message: String, + pub hash: Vec, +} + +#[uniffi::export] +pub fn create_auth_message(address: &str, auth_nonce: GemAuthNonce) -> GemAuthMessage { + let auth_message = AuthMessage { + chain: Chain::Ethereum, + address: address.to_string(), + auth_nonce, + }; + let data = create_auth_hash(&auth_message); + GemAuthMessage { + message: data.message, + hash: data.hash.to_vec(), + } +} + +#[uniffi::export] +pub fn sign_auth_message_hash(hash: Vec, private_key: Vec) -> Result { + let private_key = Zeroizing::new(private_key); + if hash.len() != AUTH_SIGNING_BYTES_LENGTH || private_key.len() != AUTH_SIGNING_BYTES_LENGTH { + return Err(GemstoneError::from("Invalid auth message signing input")); + } + let signature = Signer::sign_ethereum_digest(&hash, private_key.as_slice())?; + Ok(encode_with_0x(&signature)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Address, keccak256}; + use gem_auth::verify_auth_signature; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use signer::secp256k1_uncompressed_public_key; + + #[test] + fn test_sign_auth_message_hash() { + let address = address_from_private_key(&TEST_PRIVATE_KEY); + let auth_nonce = AuthNonce { + nonce: "test-nonce-123".to_string(), + timestamp: 1734100000, + }; + let auth_message = AuthMessage { + chain: Chain::Ethereum, + address: address.clone(), + auth_nonce: auth_nonce.clone(), + }; + let message = create_auth_message(&address, auth_nonce); + + let signature = sign_auth_message_hash(message.hash, TEST_PRIVATE_KEY.to_vec()).unwrap(); + + assert!(verify_auth_signature(&auth_message, &signature)); + } + + #[test] + fn test_sign_auth_message_hash_rejects_invalid_input_length() { + assert!(sign_auth_message_hash(vec![0; 31], TEST_PRIVATE_KEY.to_vec()).is_err()); + assert!(sign_auth_message_hash(vec![0; 32], vec![0; 31]).is_err()); + } + + fn address_from_private_key(private_key: &[u8]) -> String { + let public_key = secp256k1_uncompressed_public_key(private_key).unwrap(); + let hash = keccak256(&public_key[1..]); + Address::from_slice(&hash[12..]).to_checksum(None) + } +} diff --git a/core/gemstone/src/block_explorer/explorer.rs b/core/gemstone/src/block_explorer/explorer.rs new file mode 100644 index 0000000000..a7a711ef31 --- /dev/null +++ b/core/gemstone/src/block_explorer/explorer.rs @@ -0,0 +1,353 @@ +use primitives::{block_explorer::get_block_explorer, chain::Chain}; +use std::str::FromStr; +use swapper::SwapperProvider; + +use super::remote_types::GemExplorerInput; + +#[derive(uniffi::Object)] +pub struct Explorer { + pub chain: Chain, +} + +#[derive(Debug, uniffi::Record)] +pub struct ExplorerURL { + pub name: String, + pub url: String, +} + +impl ExplorerURL { + pub fn new(name: &str, url: &str) -> Self { + Self { + name: name.to_string(), + url: url.to_string(), + } + } +} + +#[uniffi::export] +impl Explorer { + #[uniffi::constructor] + fn new(chain: &str) -> Self { + Self { + chain: Chain::from_str(chain).unwrap(), + } + } + + pub fn get_transaction_url(&self, explorer_name: &str, transaction_id: &str) -> String { + get_block_explorer(self.chain, explorer_name).get_tx_url(transaction_id) + } + + pub fn get_transaction_swap_url(&self, explorer_name: &str, input: GemExplorerInput, provider_id: &str) -> Option { + let provider = SwapperProvider::from_str(provider_id).ok()?; + let explorer = provider.swap_explorer(self.chain).unwrap_or_else(|| get_block_explorer(self.chain, explorer_name)); + Some(ExplorerURL::new(&explorer.name(), &explorer.get_swap_tx_url(&input))) + } + + pub fn get_address_url(&self, explorer_name: &str, address: &str) -> String { + get_block_explorer(self.chain, explorer_name).get_address_url(address) + } + + pub fn get_token_url(&self, explorer_name: &str, address: &str) -> Option { + get_block_explorer(self.chain, explorer_name).get_token_url(address) + } + + pub fn get_nft_url(&self, explorer_name: &str, contract_address: &str, token_id: &str) -> Option { + get_block_explorer(self.chain, explorer_name).get_nft_url(contract_address, token_id) + } + + pub fn get_validator_url(&self, explorer_name: &str, address: &str) -> Option { + get_block_explorer(self.chain, explorer_name).get_validator_url(address) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gem_solana::USDT_TOKEN_MINT; + use primitives::{asset_constants::TON_USDT_TOKEN_ID, block_explorer::get_block_explorers}; + + #[test] + fn test_bitcoin_explorers() { + let chain = Chain::Bitcoin; + let explorers = get_block_explorers(chain); + + assert_eq!(explorers.len(), 3); + assert_eq!(explorers[0].name(), "Blockchair"); + assert_eq!(explorers[1].name(), "Mempool"); + + let explorer = Explorer::new(chain.as_ref()); + let transaction_url = explorer.get_transaction_url(&explorers[0].name(), "813d80363c09b1c4d3f0c6ce3382a048b320edefb573a8aedbc7ddd4c65cf7e4"); + + assert_eq!( + transaction_url, + "https://blockchair.com/bitcoin/transaction/813d80363c09b1c4d3f0c6ce3382a048b320edefb573a8aedbc7ddd4c65cf7e4" + ); + + let transaction_url = explorer.get_transaction_url(&explorers[1].name(), "813d80363c09b1c4d3f0c6ce3382a048b320edefb573a8aedbc7ddd4c65cf7e4"); + + assert_eq!(transaction_url, "https://mempool.space/tx/813d80363c09b1c4d3f0c6ce3382a048b320edefb573a8aedbc7ddd4c65cf7e4"); + } + + #[test] + fn test_ethereum_explorers() { + let chain = Chain::Ethereum; + let explorers = get_block_explorers(chain); + + assert_eq!(explorers.len(), 3); + assert_eq!(explorers[0].name(), "Etherscan"); + assert_eq!(explorers[1].name(), "Blockchair"); + + let explorer = Explorer::new(chain.as_ref()); + let account_url = explorer.get_address_url(&explorers[0].name(), "0x1f9090aae28b8a3dceadf281b0f12828e676c326"); + let transaction_url = explorer.get_transaction_url(&explorers[0].name(), "0xfd96a9ee20a7440bf65a5b8ecf7f884289ed78e28f82d45343a70f459e7a42a0"); + let token_url = explorer.get_token_url(&explorers[0].name(), "0xdac17f958d2ee523a2206206994597c13d831ec7"); + let nft_url = explorer.get_nft_url(&explorers[0].name(), "0x47A00fC8590C11bE4c419D9Ae50DEc267B6E24ee", "11871"); + + assert_eq!(account_url, "https://etherscan.io/address/0x1f9090aae28b8a3dceadf281b0f12828e676c326"); + assert_eq!( + transaction_url, + "https://etherscan.io/tx/0xfd96a9ee20a7440bf65a5b8ecf7f884289ed78e28f82d45343a70f459e7a42a0" + ); + assert_eq!(token_url, Some("https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7".to_string())); + assert_eq!(nft_url, Some("https://etherscan.io/nft/0x47A00fC8590C11bE4c419D9Ae50DEc267B6E24ee/11871".to_string())); + } + + #[test] + fn test_ton_explorer() { + let chain = Chain::Ton; + let explorers = get_block_explorers(chain); + + assert_eq!(explorers.len(), 3); + assert_eq!(explorers[0].name(), "TonViewer"); + assert_eq!(explorers[1].name(), "Tonscan"); + assert_eq!(explorers[2].name(), "Blockchair"); + + let explorer = Explorer::new(chain.as_ref()); + let account_url = explorer.get_address_url(&explorers[0].name(), TON_USDT_TOKEN_ID); + let token_url = explorer.get_token_url(&explorers[0].name(), TON_USDT_TOKEN_ID).unwrap(); + + assert_eq!(account_url, format!("https://tonviewer.com/{TON_USDT_TOKEN_ID}")); + assert_eq!(token_url, account_url); + + let transaction_url = explorer.get_transaction_url(&explorers[0].name(), "cefe5c6d145976c434280648fae28dfdfee58002e8c4e36195550ed6cdb22aa0"); + + assert_eq!( + transaction_url, + "https://tonviewer.com/transaction/cefe5c6d145976c434280648fae28dfdfee58002e8c4e36195550ed6cdb22aa0" + ); + } + + #[test] + fn test_solana_explorer() { + let chain = Chain::Solana; + let explorers = get_block_explorers(chain); + + assert_eq!(explorers.len(), 3); + assert_eq!(explorers[1].name(), "SolanaFM"); + assert_eq!(explorers[0].name(), "Solscan"); + + let explorer = Explorer::new(chain.as_ref()); + + assert_eq!( + explorer.get_address_url(&explorers[1].name(), "5x38Kp4hvdomTCnCrAny4UtMUt5rQBdB6px2K1Ui45Wq",), + "https://solana.fm/address/5x38Kp4hvdomTCnCrAny4UtMUt5rQBdB6px2K1Ui45Wq" + ); + assert_eq!( + explorer.get_transaction_url( + &explorers[1].name(), + "58UdzFXAz6Vk58jEM6UsWmNb7kcJ1YvR2nQmkp8YQSW2gabmGra1u67SEjNZzTHCyuAn8NqzcQcn6qBLKx7uhVK7", + ), + "https://solana.fm/tx/58UdzFXAz6Vk58jEM6UsWmNb7kcJ1YvR2nQmkp8YQSW2gabmGra1u67SEjNZzTHCyuAn8NqzcQcn6qBLKx7uhVK7" + ); + assert_eq!( + explorer.get_transaction_url( + &explorers[0].name(), + "58UdzFXAz6Vk58jEM6UsWmNb7kcJ1YvR2nQmkp8YQSW2gabmGra1u67SEjNZzTHCyuAn8NqzcQcn6qBLKx7uhVK7", + ), + "https://solscan.io/tx/58UdzFXAz6Vk58jEM6UsWmNb7kcJ1YvR2nQmkp8YQSW2gabmGra1u67SEjNZzTHCyuAn8NqzcQcn6qBLKx7uhVK7" + ); + assert_eq!( + explorer.get_token_url(&explorers[1].name(), USDT_TOKEN_MINT,).unwrap(), + format!("https://solana.fm/address/{USDT_TOKEN_MINT}") + ); + } + + #[test] + fn test_cosmos_explorers() { + let chain = Chain::Cosmos; + let explorers = get_block_explorers(chain); + + assert_eq!(explorers.len(), 1); + assert_eq!(explorers[0].name(), "Mintscan"); + + let explorer = Explorer::new(chain.as_ref()); + let account_url = explorer.get_address_url(&explorers[0].name(), "cosmos1fxygpgus4nd5jmfl5j7fh5y8hyy53z8u95dzx7"); + let transaction_url = explorer.get_transaction_url(&explorers[0].name(), "CFB4B38D75DB9D9055A7D4A2A76C67B8A27C37124C4E5663BEE104589E726763"); + let asset_url = explorer + .get_token_url(&explorers[0].name(), "ibc/0025F8A87464A471E66B234C4F93AEC5B4DA3D42D7986451A059273426290DD5") + .unwrap(); + + assert_eq!(account_url, "https://www.mintscan.io/cosmos/address/cosmos1fxygpgus4nd5jmfl5j7fh5y8hyy53z8u95dzx7"); + assert_eq!( + transaction_url, + "https://www.mintscan.io/cosmos/tx/CFB4B38D75DB9D9055A7D4A2A76C67B8A27C37124C4E5663BEE104589E726763" + ); + assert_eq!( + asset_url, + "https://www.mintscan.io/cosmos/assets/ibc/0025F8A87464A471E66B234C4F93AEC5B4DA3D42D7986451A059273426290DD5" + ) + } + + #[test] + fn test_noble_explorer() { + let chain = Chain::Noble; + let explorers = get_block_explorers(chain); + + assert_eq!(explorers.len(), 1); + assert_eq!(explorers[0].name(), "Mintscan"); + + let explorer = Explorer::new(chain.as_ref()); + let account_url = explorer.get_address_url(&explorers[0].name(), "noble17w8y9eujrz4m08nn0h349s5h2rs8uz5hqe02z4"); + let transaction_url = explorer.get_transaction_url(&explorers[0].name(), "22F0B4F48A85925A668D64134B7377476DC5BAE3CF7CC38AFC0E17E5F7D90001"); + + assert_eq!(account_url, "https://www.mintscan.io/noble/address/noble17w8y9eujrz4m08nn0h349s5h2rs8uz5hqe02z4"); + assert_eq!( + transaction_url, + "https://www.mintscan.io/noble/tx/22F0B4F48A85925A668D64134B7377476DC5BAE3CF7CC38AFC0E17E5F7D90001" + ); + } + + #[test] + fn test_sui_vision() { + let chain = Chain::Sui; + let explorers = get_block_explorers(chain); + + assert_eq!(explorers.len(), 2); + assert_eq!(explorers[0].name(), "SuiScan"); + assert_eq!(explorers[1].name(), "SuiVision"); + + let explorer = Explorer::new(chain.as_ref()); + + assert_eq!( + explorer.get_address_url(&explorers[0].name(), "0x6f02af629f66a13c5b8cb857cddf43804422d205b0bb9bda9db98b2635fe59bb",), + "https://suiscan.xyz/mainnet/account/0x6f02af629f66a13c5b8cb857cddf43804422d205b0bb9bda9db98b2635fe59bb" + ); + assert_eq!( + explorer.get_transaction_url(&explorers[0].name(), "ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos",), + "https://suiscan.xyz/mainnet/tx/ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos" + ); + assert_eq!( + explorer + .get_validator_url(&explorers[0].name(), "0x61953ea72709eed72f4441dd944eec49a11b4acabfc8e04015e89c63be81b6ab",) + .unwrap(), + "https://suiscan.xyz/mainnet/validator/0x61953ea72709eed72f4441dd944eec49a11b4acabfc8e04015e89c63be81b6ab" + ); + + assert_eq!( + explorer.get_address_url(&explorers[1].name(), "0x6f02af629f66a13c5b8cb857cddf43804422d205b0bb9bda9db98b2635fe59bb",), + "https://suivision.xyz/account/0x6f02af629f66a13c5b8cb857cddf43804422d205b0bb9bda9db98b2635fe59bb" + ); + assert_eq!( + explorer.get_transaction_url(&explorers[1].name(), "ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos",), + "https://suivision.xyz/txblock/ArS7DzeHUA54ccRG12SqEZwt7snQePcanZ77Mkm2KRos" + ); + } + + #[test] + fn test_tronscan() { + let chain = Chain::Tron; + let explorers = get_block_explorers(chain); + + assert_eq!(explorers.len(), 2); + assert_eq!(explorers[0].name(), "TRONSCAN"); + + let explorer = Explorer::new(chain.as_ref()); + let account_url = explorer.get_address_url(&explorers[0].name(), "TJApZYJwPKuQR7tL6FmvD6jDjbYpHESZGH"); + let transaction_url = explorer.get_transaction_url(&explorers[0].name(), "4e55fe0a528240152ab566dc11ce593a30c1d2cfd0fc91f0c555887639eab2db"); + + assert_eq!(account_url, "https://tronscan.org/#/address/TJApZYJwPKuQR7tL6FmvD6jDjbYpHESZGH"); + assert_eq!( + transaction_url, + "https://tronscan.org/#/transaction/4e55fe0a528240152ab566dc11ce593a30c1d2cfd0fc91f0c555887639eab2db" + ); + } + + #[test] + fn test_runescan() { + let explorers = get_block_explorers(Chain::Thorchain); + + assert_eq!(explorers.len(), 2); + assert_eq!(explorers[0].name(), "RuneScan"); + + let explorer = Explorer::new(Chain::Thorchain.as_ref()); + let account_url: String = explorer.get_address_url(&explorers[0].name(), "thor166n4w5039meulfa3p6ydg60ve6ueac7tlt0jws"); + let transaction_url = explorer.get_transaction_url(&explorers[0].name(), "FF82C517ECFDCA71A6CD3501063D76995C67509B2AFC012D2BCE61C130C05E98"); + + assert_eq!(account_url, "https://runescan.io/address/thor166n4w5039meulfa3p6ydg60ve6ueac7tlt0jws"); + assert_eq!(transaction_url, "https://runescan.io/tx/FF82C517ECFDCA71A6CD3501063D76995C67509B2AFC012D2BCE61C130C05E98"); + } + + #[test] + fn test_transaction_swap_url() { + let explorer = Explorer::new(Chain::Thorchain.as_ref()); + let transaction_url = explorer + .get_transaction_swap_url( + "runescan", + "0x0299923c9a0a40e3a296058ac2c5c3a7b41f91803ea36ad9645492ccca0f8631".into(), + SwapperProvider::Thorchain.as_ref(), + ) + .unwrap(); + + assert_eq!( + transaction_url.url, + "https://runescan.io/tx/0299923c9a0a40e3a296058ac2c5c3a7b41f91803ea36ad9645492ccca0f8631" + ); + + let explorer = Explorer::new(Chain::Solana.as_ref()); + let transaction_url = explorer + .get_transaction_swap_url( + "solscan", + "0x56acc6a58fc0bdd9e9be5cc2a3ff079b91b933f562cf0fe760f1d8d6b76f4876".into(), + SwapperProvider::Mayan.as_ref(), + ) + .unwrap(); + + assert_eq!( + transaction_url.url, + "https://explorer.mayan.finance/tx/0x56acc6a58fc0bdd9e9be5cc2a3ff079b91b933f562cf0fe760f1d8d6b76f4876" + ); + } + + #[test] + fn test_near_intents_swap_url() { + let explorer = Explorer::new(Chain::Near.as_ref()); + let recipient = "aec8de30ed03c5e6f9d0dc90ae39d865f1b4f6f77c990f2ad16c93e873ea67de"; + + let url = explorer + .get_transaction_swap_url( + "Near", + GemExplorerInput { + hash: String::new(), + recipient: Some(recipient.into()), + memo: None, + }, + SwapperProvider::NearIntents.as_ref(), + ) + .unwrap(); + assert_eq!(url.name, "NEAR Intents"); + assert_eq!(url.url, format!("https://explorer.near-intents.org/transactions/{recipient}")); + + let url = explorer + .get_transaction_swap_url( + "Near", + GemExplorerInput { + hash: String::new(), + recipient: Some(recipient.into()), + memo: Some("48694126".into()), + }, + SwapperProvider::NearIntents.as_ref(), + ) + .unwrap(); + assert_eq!(url.url, format!("https://explorer.near-intents.org/transactions/{recipient}?depositMemo=48694126")); + } +} diff --git a/core/gemstone/src/block_explorer/mod.rs b/core/gemstone/src/block_explorer/mod.rs new file mode 100644 index 0000000000..bdbfeb46f6 --- /dev/null +++ b/core/gemstone/src/block_explorer/mod.rs @@ -0,0 +1,5 @@ +mod explorer; +mod remote_types; + +pub use explorer::{Explorer, ExplorerURL}; +pub use remote_types::GemExplorerInput; diff --git a/core/gemstone/src/block_explorer/remote_types.rs b/core/gemstone/src/block_explorer/remote_types.rs new file mode 100644 index 0000000000..958db98b2b --- /dev/null +++ b/core/gemstone/src/block_explorer/remote_types.rs @@ -0,0 +1,10 @@ +use primitives::block_explorer::ExplorerInput; + +pub type GemExplorerInput = ExplorerInput; + +#[uniffi::remote(Record)] +pub struct GemExplorerInput { + pub hash: String, + pub recipient: Option, + pub memo: Option, +} diff --git a/core/gemstone/src/config/chain.rs b/core/gemstone/src/config/chain.rs new file mode 100644 index 0000000000..623001b945 --- /dev/null +++ b/core/gemstone/src/config/chain.rs @@ -0,0 +1,76 @@ +use primitives::{Chain, ChainType, FeeUnitType, chain_transaction_timeout}; + +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct ChainConfig { + pub network_id: String, + pub transaction_timeout: u32, + pub slip_44: i32, + pub rank: i32, + pub denom: Option, + pub chain_type: String, + pub fee_unit_type: String, + pub default_asset_type: Option, + pub account_activation_fee: Option, + pub account_activation_fee_url: Option, + pub token_activation_fee: Option, + pub minimum_account_balance: Option, + pub block_time: u32, + pub is_swap_supported: bool, + pub is_stake_supported: bool, + pub is_nft_supported: bool, + pub is_memo_supported: bool, +} + +pub fn get_chain_config(chain: Chain) -> ChainConfig { + ChainConfig { + network_id: chain.network_id().to_string(), + transaction_timeout: chain_transaction_timeout(chain), + slip_44: chain.as_slip44() as i32, + rank: chain.rank(), + denom: chain.as_denom().map(|x| x.to_string()), + chain_type: chain.chain_type().as_ref().to_string(), + fee_unit_type: fee_unit_type(chain).as_ref().to_string(), + default_asset_type: chain.default_asset_type().map(|x| x.as_ref().to_string()), + account_activation_fee: chain.account_activation_fee(), + account_activation_fee_url: account_activation_fee_url(chain).map(|x| x.to_string()), + token_activation_fee: chain.token_activation_fee(), + minimum_account_balance: chain.minimum_account_balance(), + block_time: chain.block_time(), + is_swap_supported: chain.is_swap_supported(), + is_stake_supported: chain.is_stake_supported(), + is_nft_supported: chain.is_nft_supported(), + is_memo_supported: is_memo_supported(chain), + } +} + +pub fn is_memo_supported(chain: Chain) -> bool { + match chain.chain_type() { + ChainType::Solana | ChainType::Cosmos | ChainType::Ton | ChainType::Xrp | ChainType::Stellar | ChainType::Algorand => true, + ChainType::Ethereum + | ChainType::Bitcoin + | ChainType::Near + | ChainType::Tron + | ChainType::Aptos + | ChainType::Sui + | ChainType::Polkadot + | ChainType::Cardano + | ChainType::HyperCore => false, + } +} + +pub fn account_activation_fee_url(chain: Chain) -> Option { + match chain { + Chain::Xrp => Some("https://xrpl.org/docs/concepts/accounts/reserves#base-reserve-and-owner-reserve".into()), + Chain::Stellar => Some("https://developers.stellar.org/docs/learn/fundamentals/lumens#minimum-balance".into()), + Chain::Algorand => Some("https://developer.algorand.org/docs/features/accounts/#minimum-balance".into()), + _ => None, + } +} + +pub fn fee_unit_type(chain: Chain) -> FeeUnitType { + match chain.chain_type() { + ChainType::Bitcoin => FeeUnitType::SatVb, + ChainType::Ethereum => FeeUnitType::Gwei, + _ => FeeUnitType::Native, + } +} diff --git a/core/gemstone/src/config/docs.rs b/core/gemstone/src/config/docs.rs new file mode 100644 index 0000000000..a15fb08603 --- /dev/null +++ b/core/gemstone/src/config/docs.rs @@ -0,0 +1,91 @@ +use crate::models::GemStakeChain; +use primitives::Asset; + +#[derive(uniffi::Enum, Clone)] +pub enum DocsUrl { + Start, + WhatIsWatchWallet, + WhatIsSecretPhrase, + WhatIsPrivateKey, + HowToSecureSecretPhrase, + TransactionStatus, + NetworkFees, + StakingLockTime, + TronMultiSignature, + RootedDevice, + PriceImpact, + TokenApproval, + Slippage, + SwapProvider, + FiatProvider, + StakingAPR, + StakingStatus, + StakingValidator, + AccountMinimalBalance, + TokenVerification, + AddCustomToken, + WalletConnect, + HowStoreSecretPhrase, + NoQuotes, + Staking(GemStakeChain), + PerpetualsFundingRate, + PerpetualsLiquidationPrice, + PerpetualsOpenInterest, + PerpetualsFundingPayments, + PerpetualsAutoclose, + Dust, +} +const DOCS_URL: &str = "https://docs.gemwallet.com"; + +pub fn get_docs_url(item: DocsUrl) -> String { + let path = match item { + DocsUrl::Start => "/", + DocsUrl::WhatIsWatchWallet => "/faq/watch-wallet/", + DocsUrl::WhatIsSecretPhrase => "/faq/secret-recovery-phrase/", + DocsUrl::WhatIsPrivateKey => "/faq/private-key/", + DocsUrl::HowToSecureSecretPhrase => "/faq/secure-recovery-phrase/", + DocsUrl::TransactionStatus => "/faq/transaction-status/", + DocsUrl::NetworkFees => "/faq/network-fees/", + DocsUrl::StakingLockTime => "/faq/lock-time/", + DocsUrl::TronMultiSignature => "/guides/trx-multisig-scam/", + DocsUrl::RootedDevice => "/guides/secure-wallet/rooted-device/", + DocsUrl::PriceImpact => "/faq/price-impact/", + DocsUrl::TokenApproval => "/faq/token-approval/", + DocsUrl::Slippage => "/faq/slippage/", + DocsUrl::SwapProvider => "/faq/swap-provider/", + DocsUrl::FiatProvider => "/faq/fiat-provider/", + DocsUrl::StakingAPR => "/faq/staking-apr/", + DocsUrl::StakingStatus => "/faq/staking-status/", + DocsUrl::StakingValidator => "/faq/staking-validator/", + DocsUrl::AccountMinimalBalance => "/faq/account-minimal-balance/", + DocsUrl::TokenVerification => "/faq/token-verification/", + DocsUrl::AddCustomToken => "/guides/add-token/", + DocsUrl::WalletConnect => "/guides/walletconnect/", + DocsUrl::HowStoreSecretPhrase => "/faq/secure-recovery-phrase/#how-to-secure-my-secret-phrase/", + DocsUrl::NoQuotes => "/troubleshoot/quote-error/", + DocsUrl::Staking(chain) => &format!("/defi/stake-{}/", Asset::from_chain(chain.chain()).symbol.to_lowercase()), + DocsUrl::PerpetualsFundingRate => "/defi/perps/perps-terms/#what-is-perpetual-funding/", + DocsUrl::PerpetualsLiquidationPrice => "/defi/perps/liquidation-price/", + DocsUrl::PerpetualsOpenInterest => "/defi/perps/open-interest/", + DocsUrl::PerpetualsFundingPayments => "/defi/perps/funding-payment/", + DocsUrl::PerpetualsAutoclose => "/defi/perps/auto-close/", + DocsUrl::Dust => "/blockchains/bitcoin/dust/", + }; + format!("{DOCS_URL}{path}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_docs_url() { + assert_eq!(get_docs_url(DocsUrl::WhatIsSecretPhrase), "https://docs.gemwallet.com/faq/secret-recovery-phrase/"); + } + + #[test] + fn test_get_docs_url_staking() { + use primitives::StakeChain; + assert_eq!(get_docs_url(DocsUrl::Staking(StakeChain::Solana)), "https://docs.gemwallet.com/defi/stake-sol/"); + } +} diff --git a/core/gemstone/src/config/mod.rs b/core/gemstone/src/config/mod.rs new file mode 100644 index 0000000000..e2dce0f548 --- /dev/null +++ b/core/gemstone/src/config/mod.rs @@ -0,0 +1,116 @@ +pub mod chain; +pub mod docs; +pub mod node; +pub mod perpetual_config; +pub mod public; +pub mod rewards; +pub mod social; +pub mod stake; +pub mod swap_config; +pub mod validators; +pub mod wallet_connect; + +use crate::config::chain::ChainConfig; +use primitives::{ + Chain, StakeChain, + node_config::{self, Node}, +}; +use std::{collections::HashMap, str::FromStr}; + +use { + docs::{DocsUrl, get_docs_url}, + perpetual_config::{PerpetualConfig, get_perpetual_config, select_leverage}, + public::{ASSETS_URL, PublicUrl, get_public_url}, + rewards::{RewardsUrl, get_rewards_url}, + social::{SocialUrl, get_social_url, get_social_url_deeplink}, + stake::{StakeChainConfig, get_stake_config}, + swap_config::{SwapConfig, get_swap_config}, + validators::get_validators, + wallet_connect::{WalletConnectConfig, get_wallet_connect_config}, +}; + +/// Config +#[derive(uniffi::Object)] +struct Config {} +#[uniffi::export] +impl Config { + #[uniffi::constructor] + fn new() -> Self { + Self {} + } + + fn get_validators(&self) -> HashMap> { + get_validators() + } + + fn get_stake_config(&self, chain: &str) -> StakeChainConfig { + let chain = StakeChain::from_str(chain).unwrap(); + get_stake_config(chain) + } + + fn get_swap_config(&self) -> SwapConfig { + get_swap_config() + } + + fn get_perpetual_config(&self) -> PerpetualConfig { + get_perpetual_config() + } + + fn select_leverage(&self, desired: u8, options: Vec) -> u8 { + select_leverage(desired, &options) + } + + fn get_docs_url(&self, item: DocsUrl) -> String { + get_docs_url(item) + } + + fn get_rewards_url(&self, item: RewardsUrl, locale: Option) -> String { + get_rewards_url(item, locale) + } + + fn get_social_url(&self, item: SocialUrl) -> Option { + get_social_url(item).map(|x| x.to_string()) + } + + fn get_social_url_deeplink(&self, item: SocialUrl) -> Option { + get_social_url_deeplink(item) + } + + fn get_public_url(&self, item: PublicUrl) -> String { + get_public_url(item) + } + + fn get_chain_config(&self, chain: String) -> ChainConfig { + let chain = Chain::from_str(&chain).unwrap(); + crate::config::chain::get_chain_config(chain) + } + + fn get_wallet_connect_config(&self) -> WalletConnectConfig { + get_wallet_connect_config() + } + + fn get_nodes(&self) -> HashMap> { + node_config::get_nodes() + } + + fn get_nodes_for_chain(&self, chain: &str) -> Vec { + let chain = Chain::from_str(chain).unwrap(); + node_config::get_nodes_for_chain(chain) + } + + fn image_formatter_asset_url(&self, chain: &str, token_id: Option) -> String { + primitives::ImageFormatter::get_asset_url(ASSETS_URL, chain, token_id.as_deref()) + } + + fn image_formatter_validator_url(&self, chain: &str, id: &str) -> String { + primitives::ImageFormatter::get_validator_url(ASSETS_URL, chain, id) + } + + fn image_formatter_nft_asset_url(&self, url: &str, id: &str) -> String { + primitives::ImageFormatter::get_nft_asset_url(url, id) + } + + fn get_block_explorers(&self, chain: &str) -> Vec { + primitives::block_explorer::get_block_explorers_by_chain(chain).into_iter().map(|x| x.name()).collect() + } +} diff --git a/core/gemstone/src/config/node.rs b/core/gemstone/src/config/node.rs new file mode 100644 index 0000000000..9b95f8a650 --- /dev/null +++ b/core/gemstone/src/config/node.rs @@ -0,0 +1,18 @@ +use primitives::node_config::{Node, NodePriority}; + +// Sources: +// https://chainlist.org + +#[uniffi::remote(Record)] +pub struct Node { + pub url: String, + pub priority: NodePriority, +} + +#[uniffi::remote(Enum)] +pub enum NodePriority { + High, + Medium, + Low, + Inactive, +} diff --git a/core/gemstone/src/config/perpetual_config.rs b/core/gemstone/src/config/perpetual_config.rs new file mode 100644 index 0000000000..be9f5a2e8f --- /dev/null +++ b/core/gemstone/src/config/perpetual_config.rs @@ -0,0 +1,53 @@ +pub const DEFAULT_LEVERAGE: u8 = 5; +pub const LEVERAGE_OPTIONS: &[u8] = &[1, 2, 3, 5, 10, 20, 25, 30, 40, 50]; + +#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)] +pub struct PerpetualConfig { + pub default_leverage: u8, + pub leverage_options: Vec, +} + +pub fn get_perpetual_config() -> PerpetualConfig { + PerpetualConfig { + default_leverage: DEFAULT_LEVERAGE, + leverage_options: LEVERAGE_OPTIONS.to_vec(), + } +} + +pub fn select_leverage(desired: u8, options: &[u8]) -> u8 { + options + .iter() + .copied() + .filter(|&value| value <= desired) + .max() + .or_else(|| options.iter().copied().min()) + .unwrap_or(DEFAULT_LEVERAGE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_select_leverage() { + assert_eq!(select_leverage(0, LEVERAGE_OPTIONS), 1); + assert_eq!(select_leverage(4, LEVERAGE_OPTIONS), 3); + assert_eq!(select_leverage(5, LEVERAGE_OPTIONS), 5); + assert_eq!(select_leverage(7, LEVERAGE_OPTIONS), 5); + assert_eq!(select_leverage(50, LEVERAGE_OPTIONS), 50); + assert_eq!(select_leverage(100, LEVERAGE_OPTIONS), 50); + + let constrained: &[u8] = &[1, 2, 3]; + assert_eq!(select_leverage(10, constrained), 3); + + let empty: &[u8] = &[]; + assert_eq!(select_leverage(5, empty), DEFAULT_LEVERAGE); + } + + #[test] + fn test_get_perpetual_config() { + let config = get_perpetual_config(); + assert_eq!(config.default_leverage, DEFAULT_LEVERAGE); + assert_eq!(config.leverage_options, LEVERAGE_OPTIONS); + } +} diff --git a/core/gemstone/src/config/public.rs b/core/gemstone/src/config/public.rs new file mode 100644 index 0000000000..e9465a7d15 --- /dev/null +++ b/core/gemstone/src/config/public.rs @@ -0,0 +1,32 @@ +use primitives::GEM_ANDROID_PACKAGE_ID; + +#[derive(uniffi::Enum, Clone)] +pub enum PublicUrl { + Website, + Assets, + PrivacyPolicy, + TermsOfService, + Support, + CodebaseIos, + CodebaseAndroid, + AppStore, + PlayStore, + APK, +} + +pub const ASSETS_URL: &str = "https://assets.gemwallet.com"; + +pub fn get_public_url(item: PublicUrl) -> String { + match item { + PublicUrl::Website => "https://gemwallet.com".to_string(), + PublicUrl::Assets => ASSETS_URL.to_string(), + PublicUrl::PrivacyPolicy => "https://gemwallet.com/privacy".to_string(), + PublicUrl::TermsOfService => "https://gemwallet.com/terms".to_string(), + PublicUrl::Support => "https://gemwallet.com/support".to_string(), + PublicUrl::CodebaseIos => "https://github.com/gemwalletcom/gem-ios/".to_string(), + PublicUrl::CodebaseAndroid => "https://github.com/gemwalletcom/gem-android/".to_string(), + PublicUrl::AppStore => "https://apps.apple.com/app/apple-store/id6448712670".to_string(), + PublicUrl::PlayStore => format!("https://play.google.com/store/apps/details?id={GEM_ANDROID_PACKAGE_ID}"), + PublicUrl::APK => "https://apk.gemwallet.com/gem_wallet_latest.apk".to_string(), + } +} diff --git a/core/gemstone/src/config/rewards.rs b/core/gemstone/src/config/rewards.rs new file mode 100644 index 0000000000..0cdb873aa4 --- /dev/null +++ b/core/gemstone/src/config/rewards.rs @@ -0,0 +1,78 @@ +#[derive(uniffi::Enum, Clone)] +pub enum RewardsUrl { + Rewards, +} + +const WEBSITE_URL: &str = "https://gemwallet.com"; + +pub fn get_rewards_url(item: RewardsUrl, locale: Option) -> String { + let path = match item { + RewardsUrl::Rewards => "/rewards", + }; + + let website_locale = normalize_locale(locale); + let locale_prefix = if website_locale.is_empty() || website_locale == "en" { + String::new() + } else { + format!("/{}", website_locale) + }; + + format!("{WEBSITE_URL}{locale_prefix}{path}") +} + +fn normalize_locale(locale: Option) -> String { + let Some(loc) = locale else { + return String::from("en"); + }; + + let lower = loc.to_lowercase(); + let parts: Vec<&str> = lower.split(&['-', '_'][..]).collect(); + + match parts.first() { + Some(&"zh") => { + // Check for script or region + if parts.iter().any(|p| p == &"hant" || p == &"tw" || p == &"hk") { + String::from("zh-tw") + } else { + String::from("zh-cn") + } + } + Some(&"pt") => { + if parts.iter().any(|p| p == &"br") { + String::from("pt-br") + } else { + String::from("pt") + } + } + Some(lang) => String::from(*lang), + None => String::from("en"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_locale() { + assert_eq!(normalize_locale(None), "en"); + assert_eq!(normalize_locale(Some("en".to_string())), "en"); + assert_eq!(normalize_locale(Some("ru".to_string())), "ru"); + assert_eq!(normalize_locale(Some("zh-Hans".to_string())), "zh-cn"); + assert_eq!(normalize_locale(Some("zh-Hant".to_string())), "zh-tw"); + assert_eq!(normalize_locale(Some("zh_CN".to_string())), "zh-cn"); + assert_eq!(normalize_locale(Some("zh_TW".to_string())), "zh-tw"); + assert_eq!(normalize_locale(Some("pt-BR".to_string())), "pt-br"); + assert_eq!(normalize_locale(Some("pt_PT".to_string())), "pt"); + } + + #[test] + fn test_get_rewards_url() { + assert_eq!(get_rewards_url(RewardsUrl::Rewards, Some("en".to_string())), "https://gemwallet.com/rewards"); + assert_eq!(get_rewards_url(RewardsUrl::Rewards, None), "https://gemwallet.com/rewards"); + assert_eq!(get_rewards_url(RewardsUrl::Rewards, Some("ru".to_string())), "https://gemwallet.com/ru/rewards"); + assert_eq!(get_rewards_url(RewardsUrl::Rewards, Some("zh-Hans".to_string())), "https://gemwallet.com/zh-cn/rewards"); + assert_eq!(get_rewards_url(RewardsUrl::Rewards, Some("zh-Hant".to_string())), "https://gemwallet.com/zh-tw/rewards"); + assert_eq!(get_rewards_url(RewardsUrl::Rewards, Some("pt-BR".to_string())), "https://gemwallet.com/pt-br/rewards"); + } +} diff --git a/core/gemstone/src/config/social.rs b/core/gemstone/src/config/social.rs new file mode 100644 index 0000000000..3d6d39ae34 --- /dev/null +++ b/core/gemstone/src/config/social.rs @@ -0,0 +1,73 @@ +use std::str::FromStr; + +use primitives::LinkType; + +#[derive(uniffi::Enum, Clone)] +pub enum SocialUrl { + X, + Discord, + Reddit, + Telegram, + GitHub, + YouTube, + Facebook, + Website, + Coingecko, +} + +pub fn get_social_url(item: SocialUrl) -> Option<&'static str> { + match item { + SocialUrl::X => Some("https://x.com/GemWallet"), + SocialUrl::Discord => Some("https://discord.gg/aWkq5sj7SY"), + SocialUrl::Telegram => Some("https://t.me/gemwallet"), + SocialUrl::GitHub => Some("https://github.com/gemwalletcom"), + SocialUrl::YouTube => Some("https://www.youtube.com/@gemwallet"), + SocialUrl::Reddit | SocialUrl::Facebook | SocialUrl::Website | SocialUrl::Coingecko => None, + } +} + +pub fn get_social_url_deeplink(item: SocialUrl) -> Option { + match item { + SocialUrl::X => build_social_url_deeplink(item, "GemWallet"), + SocialUrl::Discord => build_social_url_deeplink(item, "aWkq5sj7SY"), + SocialUrl::Telegram => build_social_url_deeplink(item, "gemwallet"), + SocialUrl::GitHub => build_social_url_deeplink(item, "gemwalletcom"), + SocialUrl::YouTube => build_social_url_deeplink(item, "gemwallet"), + SocialUrl::Reddit | SocialUrl::Facebook | SocialUrl::Website | SocialUrl::Coingecko => None, + } +} + +pub fn build_social_url_deeplink(item: SocialUrl, value: &str) -> Option { + match item { + SocialUrl::X => Some(format!("twitter://user?screen_name={value}")), + SocialUrl::Discord => Some(format!("https://discord.gg/{value}")), + SocialUrl::Telegram => Some(format!("tg://resolve?domain={value}")), + SocialUrl::GitHub => Some(format!("https://github.com/{value}")), + SocialUrl::YouTube => Some(format!("youtube://www.youtube.com/@{value}")), + SocialUrl::Reddit | SocialUrl::Facebook | SocialUrl::Website | SocialUrl::Coingecko => None, + } +} + +#[uniffi::export] +fn link_type_order(link_type: String) -> i32 { + let link_type = LinkType::from_str(link_type.as_str()).ok(); + match link_type { + Some(value) => match value { + LinkType::Website => 120, + LinkType::X => 110, + LinkType::Coingecko => 105, + LinkType::CoinMarketCap => 104, + LinkType::OpenSea => 103, + LinkType::MagicEden => 102, + LinkType::Telegram => 90, + LinkType::Reddit => 60, + LinkType::Instagram => 50, + LinkType::Facebook => 40, + LinkType::TikTok => 35, + LinkType::Discord => 1, + LinkType::GitHub => 20, + LinkType::YouTube => 30, + }, + None => 0, + } +} diff --git a/core/gemstone/src/config/stake.rs b/core/gemstone/src/config/stake.rs new file mode 100644 index 0000000000..e0969c0fb6 --- /dev/null +++ b/core/gemstone/src/config/stake.rs @@ -0,0 +1,48 @@ +use primitives::StakeChain; + +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct StakeChainConfig { + pub time_lock: u64, + pub min_amount: u64, + pub change_amount_on_unstake: bool, + pub can_redelegate: bool, + pub can_withdraw: bool, + pub can_claim_rewards: bool, + pub can_claim_all_rewards: bool, + pub reserved_for_fees: u64, +} + +pub fn get_stake_config(chain: StakeChain) -> StakeChainConfig { + StakeChainConfig { + time_lock: chain.get_lock_time(), + min_amount: chain.get_min_stake_amount(), + change_amount_on_unstake: chain.get_change_amount_on_unstake(), + can_redelegate: chain.get_can_redelegate(), + can_withdraw: chain.get_can_withdraw(), + can_claim_rewards: chain.get_can_claim_rewards(), + can_claim_all_rewards: chain.get_can_claim_all_rewards(), + reserved_for_fees: chain.get_reserved_for_fees(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_stake_config() { + assert_eq!( + get_stake_config(StakeChain::Sui), + StakeChainConfig { + time_lock: 86400, + min_amount: 1000000000, + change_amount_on_unstake: false, + can_redelegate: false, + can_withdraw: false, + can_claim_rewards: false, + can_claim_all_rewards: false, + reserved_for_fees: 100000000, + } + ); + } +} diff --git a/core/gemstone/src/config/swap_config.rs b/core/gemstone/src/config/swap_config.rs new file mode 100644 index 0000000000..bec9cb3036 --- /dev/null +++ b/core/gemstone/src/config/swap_config.rs @@ -0,0 +1,17 @@ +use primitives::Chain; +use swapper::{SwapperSlippage, config as swap_config}; + +pub use swap_config::{Config as SwapConfig, get_swap_config}; + +#[uniffi::remote(Record)] +pub struct SwapConfig { + pub default_slippage: SwapperSlippage, + pub permit2_expiration: u64, + pub permit2_sig_deadline: u64, + pub high_price_impact_percent: u32, +} + +#[uniffi::export] +pub fn get_default_slippage(chain: &Chain) -> SwapperSlippage { + swap_config::get_default_slippage(chain) +} diff --git a/core/gemstone/src/config/validators.rs b/core/gemstone/src/config/validators.rs new file mode 100644 index 0000000000..e251df5375 --- /dev/null +++ b/core/gemstone/src/config/validators.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; + +use primitives::Chain; + +pub fn get_validators() -> HashMap> { + [ + ( + Chain::Cosmos.to_string(), + vec![ + "cosmosvaloper1tflk30mq5vgqjdly92kkhhq3raev2hnz6eete3".to_string(), // everstake + "cosmosvaloper1fhr7e04ct0zslmkzqt9smakg3sxrdve6ulclj2".to_string(), // stakin + "cosmosvaloper1hjadhj9nqzpye2vkmkz4thahhd0z8dh3udhq74".to_string(), // stakeshark + ], + ), + ( + Chain::Osmosis.to_string(), + vec![ + "osmovaloper1wgmdcxzp49vjgrqusgcagq6qefk4mtjv5c0k7q".to_string(), // everstake + "osmovaloper1e893vrtzzp6zyzs80tqg52j2vdckzqrdjrjva5".to_string(), // stakeshark + ], + ), + ( + Chain::Celestia.to_string(), + vec![ + "celestiavaloper1eualhqh07w7p45g45hvrjagkcxsfnflzdw5jzg".to_string(), // everstake + "celestiavaloper1dlsl4u42ycahzjfwc6td6upgsup9tt7cz8vqm4".to_string(), // stakin + ], + ), + ( + Chain::Injective.to_string(), + vec![ + "injvaloper134dct56cq5v7uerxcy2cn4m06mqf4dxrlgpp24".to_string(), // everstake + ], + ), + ( + Chain::Sei.to_string(), + vec![ + "seivaloper1ummny4p645xraxc4m7nphf7vxawfzt3p5hn47t".to_string(), // everstake + "seivaloper1eqgnd7ey0hnha8rrfukjrsawulhna0zagcg6a4".to_string(), // stakin + ], + ), + ( + Chain::Sui.to_string(), + vec![ + "0xbba318294a51ddeafa50c335c8e77202170e1f272599a2edc40592100863f638".to_string(), // everstake + "0x9b8b11c9b2336d35f2db8d5318ff32de51b85857f0e53a5c31242cf3797f4be4".to_string(), // stakin + ], + ), + ( + Chain::Solana.to_string(), + vec![ + "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF".to_string(), // everstake + "4PsiLMyoUQ7QRn1FFiFCvej4hsUTFzfvJnyN4bj1tmSN".to_string(), // stakin + "2Y2opv8Kq8zFATg6ipqb2AjgCf18tkv1CLMLXQGif2NH".to_string(), // stakeshark + ], + ), + ( + Chain::Monad.to_string(), + vec![ + "9".to_string(), // Everstake (validator id) + "10".to_string(), // Stakin (validator id) + ], + ), + ] + .iter() + .cloned() + .collect::>() +} diff --git a/core/gemstone/src/config/wallet_connect.rs b/core/gemstone/src/config/wallet_connect.rs new file mode 100644 index 0000000000..5a72e39711 --- /dev/null +++ b/core/gemstone/src/config/wallet_connect.rs @@ -0,0 +1,18 @@ +use primitives::{Chain, EVMChain}; + +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct WalletConnectConfig { + pub chains: Vec, +} + +pub fn get_wallet_connect_config() -> WalletConnectConfig { + let chains: Vec = [ + vec![Chain::Solana, Chain::Sui, Chain::Ton, Chain::Tron], + EVMChain::all().iter().map(|x| x.to_chain()).collect(), + ] + .concat(); + + WalletConnectConfig { + chains: chains.into_iter().map(|x| x.to_string()).collect(), + } +} diff --git a/core/gemstone/src/deeplink.rs b/core/gemstone/src/deeplink.rs new file mode 100644 index 0000000000..a1262a4dfa --- /dev/null +++ b/core/gemstone/src/deeplink.rs @@ -0,0 +1,32 @@ +use primitives::{AssetId, Deeplink}; + +#[uniffi::remote(Enum)] +pub enum Deeplink { + Asset { asset_id: AssetId }, + Perpetuals, + Rewards { code: Option }, +} + +#[uniffi::export] +pub fn deeplink_build_url(deeplink: Deeplink) -> String { + deeplink.to_url() +} + +#[uniffi::export] +pub fn deeplink_build_gem_url(deeplink: Deeplink) -> String { + deeplink.to_gem_url() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deeplink() { + let rewards = Deeplink::Rewards { + code: Some("gemcoder".to_string()), + }; + assert_eq!(deeplink_build_url(rewards), "https://gemwallet.com/rewards?code=gemcoder"); + assert_eq!(deeplink_build_gem_url(Deeplink::Perpetuals), "gem://perpetuals"); + } +} diff --git a/core/gemstone/src/ethereum/decoder.rs b/core/gemstone/src/ethereum/decoder.rs new file mode 100644 index 0000000000..bd3ef8eec7 --- /dev/null +++ b/core/gemstone/src/ethereum/decoder.rs @@ -0,0 +1,39 @@ +use crate::GemstoneError; +use gem_evm::call_decoder; + +pub type GemDecodedCall = call_decoder::DecodedCall; +pub type GemDecodedCallParam = call_decoder::DecodedCallParam; + +#[uniffi::remote(Record)] +pub struct GemDecodedCall { + pub function: String, + pub params: Vec, +} + +#[uniffi::remote(Record)] +pub struct GemDecodedCallParam { + pub name: String, + pub r#type: String, + pub value: String, +} + +#[derive(Debug, Default, uniffi::Object)] +pub struct EthereumDecoder; + +impl EthereumDecoder { + pub fn decode_call_internal(calldata: &str, abi: Option<&str>) -> Result> { + call_decoder::decode_call(calldata, abi) + } +} + +#[uniffi::export] +impl EthereumDecoder { + #[uniffi::constructor] + pub fn new() -> Self { + Self {} + } + + pub fn decode_call(&self, calldata: String, abi: Option) -> Result { + Self::decode_call_internal(&calldata, abi.as_deref()).map_err(GemstoneError::from) + } +} diff --git a/core/gemstone/src/ethereum/mod.rs b/core/gemstone/src/ethereum/mod.rs new file mode 100644 index 0000000000..4a9df29e28 --- /dev/null +++ b/core/gemstone/src/ethereum/mod.rs @@ -0,0 +1,3 @@ +pub mod decoder; + +pub use decoder::{EthereumDecoder, GemDecodedCall, GemDecodedCallParam}; diff --git a/core/gemstone/src/ethereum/test/fee_history.json b/core/gemstone/src/ethereum/test/fee_history.json new file mode 100644 index 0000000000..247e80056e --- /dev/null +++ b/core/gemstone/src/ethereum/test/fee_history.json @@ -0,0 +1,64 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "baseFeePerBlobGas": [ + "0x1", + "0x1", + "0x1", + "0x1", + "0x1", + "0x1" + ], + "baseFeePerGas": [ + "0x9bc4e8b6", + "0x9af135fd", + "0x935d02e8", + "0x94a68cee", + "0x9c580b74", + "0x976c661f" + ], + "blobGasUsedRatio": [ + 1, + 0.4444444444444444, + 0, + 0.8888888888888888, + 0.7777777777777778 + ], + "gasUsedRatio": [ + 0.4787648265769147, + 0.30434244444444447, + 0.5349411706429458, + 0.707018, + 0.37411107986145914 + ], + "oldestBlock": "0x15a2da9", + "reward": [ + [ + "0x54e0840", + "0x31e7fe5d", + "0x3b9aca04" + ], + [ + "0x4b571c0", + "0x18bf8474", + "0x3b9aca00" + ], + [ + "0x18e20bb9", + "0x32324960", + "0x3b9aca00" + ], + [ + "0x38444c0", + "0x7bf60c0", + "0x31e7fe5d" + ], + [ + "0x5f5e100", + "0x29b92700", + "0x39fbe24e" + ] + ] + } + } \ No newline at end of file diff --git a/core/gemstone/src/gateway/chain_factory.rs b/core/gemstone/src/gateway/chain_factory.rs new file mode 100644 index 0000000000..32a4a0798c --- /dev/null +++ b/core/gemstone/src/gateway/chain_factory.rs @@ -0,0 +1,107 @@ +use super::{GatewayError, GemPreferences, PreferencesWrapper}; +use crate::alien::{AlienProvider, AlienProviderWrapper, new_alien_client}; +use crate::network::JsonRpcClient; +use chain_traits::ChainTraits; +use gem_algorand::rpc::AlgorandClientIndexer; +use gem_algorand::rpc::client::AlgorandClient; +use gem_aptos::rpc::client::AptosClient; +use gem_bitcoin::rpc::client::BitcoinClient; +use gem_cardano::rpc::client::CardanoClient; +use gem_cosmos::rpc::client::CosmosClient; +use gem_evm::rpc::EthereumClient; +use gem_hypercore::rpc::client::HyperCoreClient; +use gem_jsonrpc::grpc::AlienGrpcTransport; +use gem_near::rpc::client::NearClient; +use gem_polkadot::rpc::client::PolkadotClient; +use gem_solana::rpc::client::SolanaClient; +use gem_stellar::rpc::client::StellarClient; +use gem_sui::rpc::client::SuiClient; +use gem_ton::rpc::client::TonClient; +use gem_tron::rpc::{client::TronClient, trongrid::client::TronGridClient}; +use gem_xrp::rpc::client::XRPClient; +use primitives::{BitcoinChain, Chain, EVMChain, chain_cosmos::CosmosChain}; +use std::sync::Arc; + +pub struct ChainClientFactory { + alien: Arc, + preferences: Arc, + secure_preferences: Arc, +} + +impl ChainClientFactory { + pub fn new(alien: Arc, preferences: Arc, secure_preferences: Arc) -> Self { + Self { + alien, + preferences, + secure_preferences, + } + } + + pub async fn create(&self, chain: Chain) -> Result, GatewayError> { + let url = self.alien.get_endpoint(chain).map_err(|e| GatewayError::PlatformError { msg: e.to_string() })?; + self.create_with_url(chain, url).await + } + + pub async fn create_with_url(&self, chain: Chain, url: String) -> Result, GatewayError> { + let alien_client = new_alien_client(url.clone(), self.alien.clone()); + match chain { + Chain::HyperCore => { + let preferences = Arc::new(PreferencesWrapper { + preferences: self.preferences.clone(), + }); + let secure_preferences = Arc::new(PreferencesWrapper { + preferences: self.secure_preferences.clone(), + }); + Ok(Arc::new(HyperCoreClient::new_with_preferences(alien_client, preferences, secure_preferences))) + } + Chain::Bitcoin | Chain::BitcoinCash | Chain::Litecoin | Chain::Doge | Chain::Zcash => { + Ok(Arc::new(BitcoinClient::new(alien_client, BitcoinChain::from_chain(chain).unwrap()))) + } + Chain::Cardano => Ok(Arc::new(CardanoClient::new(alien_client))), + Chain::Stellar => Ok(Arc::new(StellarClient::new(alien_client))), + Chain::Sui => Ok(Arc::new(SuiClient::new_with_transport( + url, + Arc::new(AlienGrpcTransport::new(Arc::new(AlienProviderWrapper::new(self.alien.clone())))), + ))), + Chain::Xrp => Ok(Arc::new(XRPClient::new(JsonRpcClient::new(alien_client.clone())))), + Chain::Algorand => Ok(Arc::new(AlgorandClient::new(alien_client.clone(), AlgorandClientIndexer::new(alien_client.clone())))), + Chain::Near => Ok(Arc::new(NearClient::new(JsonRpcClient::new(alien_client.clone())))), + Chain::Aptos => Ok(Arc::new(AptosClient::new(alien_client))), + Chain::Cosmos | Chain::Osmosis | Chain::Celestia | Chain::Thorchain | Chain::Injective | Chain::Sei | Chain::Noble => { + Ok(Arc::new(CosmosClient::new(CosmosChain::from_chain(chain).unwrap(), alien_client))) + } + Chain::Ton => Ok(Arc::new(TonClient::new(alien_client))), + Chain::Tron => Ok(Arc::new(TronClient::new(alien_client.clone(), TronGridClient::new(alien_client.clone(), String::new())))), + Chain::Polkadot => Ok(Arc::new(PolkadotClient::new(alien_client))), + Chain::Solana => Ok(Arc::new(SolanaClient::new(JsonRpcClient::new(alien_client.clone())))), + Chain::Ethereum + | Chain::Arbitrum + | Chain::SmartChain + | Chain::Polygon + | Chain::Optimism + | Chain::Base + | Chain::AvalancheC + | Chain::OpBNB + | Chain::Fantom + | Chain::Gnosis + | Chain::Manta + | Chain::Blast + | Chain::ZkSync + | Chain::Linea + | Chain::Mantle + | Chain::Celo + | Chain::World + | Chain::Sonic + | Chain::SeiEvm + | Chain::Abstract + | Chain::Berachain + | Chain::Ink + | Chain::Unichain + | Chain::Hyperliquid + | Chain::Plasma + | Chain::Monad + | Chain::XLayer + | Chain::Stable => Ok(Arc::new(EthereumClient::new(JsonRpcClient::new(alien_client), EVMChain::from_chain(chain).unwrap()))), + } + } +} diff --git a/core/gemstone/src/gateway/error.rs b/core/gemstone/src/gateway/error.rs new file mode 100644 index 0000000000..56c33c1a8b --- /dev/null +++ b/core/gemstone/src/gateway/error.rs @@ -0,0 +1,128 @@ +use crate::alien::AlienError; +use crate::transaction_state::TransactionStatusError; +use gem_jsonrpc::types::{ERROR_CLIENT_ERROR, JsonRpcError}; +use std::{error::Error, fmt::Display}; + +/// Errors that can occur during gateway operations. +#[derive(Debug, Clone, uniffi::Error)] +pub enum GatewayError { + /// Network-related errors such as timeouts, connection failures, or HTTP errors. + NetworkError { msg: String }, + /// Non-network errors from platform code (Kotlin/Swift), allowing clients to + /// distinguish and map back to original error types (e.g., BlockchainError.DustError). + PlatformError { msg: String }, +} + +impl Display for GatewayError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NetworkError { msg } => write!(f, "Network error: {}", msg), + Self::PlatformError { msg } => write!(f, "Platform error: {}", msg), + } + } +} + +impl Error for GatewayError {} + +impl From for GatewayError { + fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { + GatewayError::PlatformError { msg: e.reason } + } +} + +impl From for GatewayError { + fn from(err: TransactionStatusError) -> Self { + match err { + TransactionStatusError::NetworkError(msg) => Self::NetworkError { msg }, + TransactionStatusError::PlatformError(msg) => Self::PlatformError { msg }, + } + } +} + +pub(crate) fn map_network_error(error: Box) -> GatewayError { + if let Some(jsonrpc_error) = error.downcast_ref::() + && jsonrpc_error.code == ERROR_CLIENT_ERROR + { + return GatewayError::NetworkError { + msg: jsonrpc_error.message.clone(), + }; + } + + let message = if let Some(status) = http_status_from_error(error.as_ref()) { + let error_message = error.to_string(); + if error_message.is_empty() { + format!("HTTP error: status {}", status) + } else { + error_message + } + } else { + error.to_string() + }; + + GatewayError::NetworkError { msg: message } +} + +fn http_status_from_error(error: &(dyn Error + 'static)) -> Option { + let mut current_error: Option<&(dyn Error + 'static)> = Some(error); + + while let Some(err) = current_error { + if let Some(alien_error) = err.downcast_ref::() + && let AlienError::Http { status, .. } = alien_error + { + return Some(*status); + } + + if let Some(client_error) = err.downcast_ref::() + && let gem_client::ClientError::Http { status, .. } = client_error + { + return Some(*status); + } + + current_error = err.source(); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_network_error_with_status_code() { + let error = AlienError::Http { status: 404, len: 0 }; + let mapped = map_network_error(Box::new(error)); + + match mapped { + GatewayError::NetworkError { msg } => assert_eq!(msg, "HTTP error: status 404"), + GatewayError::PlatformError { .. } => panic!("Expected NetworkError"), + } + } + + #[test] + fn test_unexpected_callback_error_converts_to_platform_error() { + let unexpected = uniffi::UnexpectedUniFFICallbackError { + reason: "SomeSwiftError: something went wrong".to_string(), + }; + let gateway_error: GatewayError = unexpected.into(); + + match gateway_error { + GatewayError::PlatformError { msg } => assert_eq!(msg, "SomeSwiftError: something went wrong"), + GatewayError::NetworkError { .. } => panic!("Expected PlatformError"), + } + } + + #[test] + fn test_map_network_error_with_jsonrpc_status_code() { + let error = JsonRpcError { + code: ERROR_CLIENT_ERROR, + message: "HTTP error: status 404".to_string(), + }; + let mapped = map_network_error(Box::new(error)); + + match mapped { + GatewayError::NetworkError { msg } => assert_eq!(msg, "HTTP error: status 404"), + GatewayError::PlatformError { .. } => panic!("Expected NetworkError"), + } + } +} diff --git a/core/gemstone/src/gateway/mod.rs b/core/gemstone/src/gateway/mod.rs new file mode 100644 index 0000000000..34b84a71c3 --- /dev/null +++ b/core/gemstone/src/gateway/mod.rs @@ -0,0 +1,279 @@ +mod chain_factory; +mod error; +mod preferences; + +pub use chain_factory::ChainClientFactory; +pub use error::GatewayError; +use error::map_network_error; +#[cfg(test)] +pub use preferences::EmptyPreferences; +pub use preferences::GemPreferences; +pub(crate) use preferences::PreferencesWrapper; + +use crate::alien::{AlienProvider, AlienProviderWrapper}; +use crate::api_client::GemApiClient; +use crate::models::*; +use crate::transaction_state::StatusProvider; +use chain_traits::ChainTraits; +use std::future::Future; +use std::sync::Arc; +use swapper::swapper::GemSwapper as Swapper; +use yielder::Yielder; + +use primitives::{AssetId, Chain, ChartPeriod, ScanAddressTarget, ScanTransactionPayload, TransactionPreloadInput}; + +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait GemGatewayEstimateFee: Send + Sync { + async fn get_fee(&self, chain: Chain, input: GemTransactionLoadInput) -> Result, GatewayError>; + async fn get_fee_data(&self, chain: Chain, input: GemTransactionLoadInput) -> Result, GatewayError>; +} + +#[derive(uniffi::Object)] +pub struct GemGateway { + pub api_client: GemApiClient, + chain_factory: Arc, + yielder: Yielder, + status_provider: StatusProvider, +} + +impl std::fmt::Debug for GemGateway { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GemGateway").field("api_client", &self.api_client).finish() + } +} + +impl GemGateway { + async fn with_provider(&self, chain: Chain, call: F) -> Result + where + F: FnOnce(Arc) -> Fut, + Fut: Future>>, + { + let provider = self.chain_factory.create(chain).await?; + call(provider).await.map_err(|e| GatewayError::NetworkError { msg: e.to_string() }) + } +} + +#[async_trait::async_trait] +impl GemGatewayEstimateFee for GemGateway { + async fn get_fee(&self, _chain: Chain, _input: GemTransactionLoadInput) -> Result, GatewayError> { + Ok(None) + } + + async fn get_fee_data(&self, _chain: Chain, _input: GemTransactionLoadInput) -> Result, GatewayError> { + Ok(None) + } +} + +#[uniffi::export] +impl GemGateway { + #[uniffi::constructor] + pub fn new(provider: Arc, preferences: Arc, secure_preferences: Arc, api_url: String) -> Self { + let api_client = GemApiClient::new(api_url, provider.clone()); + let chain_factory = Arc::new(ChainClientFactory::new(provider.clone(), preferences, secure_preferences)); + let alien_wrapper = Arc::new(AlienProviderWrapper::new(provider)); + let yielder = Yielder::new(alien_wrapper.clone()); + let swapper = Swapper::new(alien_wrapper); + let status_provider = StatusProvider::new(chain_factory.clone(), swapper); + Self { + api_client, + chain_factory, + yielder, + status_provider, + } + } + + pub async fn get_balance_coin(&self, chain: Chain, address: String) -> Result { + self.with_provider(chain, |provider| async move { provider.get_balance_coin(address).await }).await + } + + pub async fn get_balance_tokens(&self, chain: Chain, address: String, token_ids: Vec) -> Result, GatewayError> { + self.with_provider(chain, |provider| async move { provider.get_balance_tokens(address, token_ids).await }) + .await + } + + pub async fn get_balance_staking(&self, chain: Chain, address: String) -> Result, GatewayError> { + self.with_provider(chain, |provider| async move { provider.get_balance_staking(address).await }).await + } + + pub async fn get_staking_validators(&self, chain: Chain, apy: Option) -> Result, GatewayError> { + self.with_provider(chain, |provider| async move { provider.get_staking_validators(apy).await }).await + } + + pub async fn get_staking_delegations(&self, chain: Chain, address: String) -> Result, GatewayError> { + self.with_provider(chain, |provider| async move { provider.get_staking_delegations(address).await }).await + } + + pub async fn transaction_broadcast(&self, chain: Chain, data: String, options: GemBroadcastOptions) -> Result { + self.with_provider(chain, |provider| async move { provider.transaction_broadcast(data, options).await }) + .await + } + + pub async fn get_transaction_status(&self, chain: Chain, request: GemTransactionStateRequest) -> Result { + Ok(self.status_provider.get(chain, request).await?) + } + + pub async fn get_transaction_swap_status(&self, chain: Chain, request: GemTransactionSwapStateRequest) -> Result { + Ok(self.status_provider.get_swap_status(chain, request).await?) + } + + pub async fn get_chain_id(&self, chain: Chain) -> Result { + self.with_provider(chain, |provider| async move { provider.get_chain_id().await }).await + } + + pub async fn get_block_number(&self, chain: Chain) -> Result { + self.with_provider(chain, |provider| async move { provider.get_block_latest_number().await }).await + } + + pub async fn get_fee_rates(&self, chain: Chain, input: GemTransactionInputType) -> Result, GatewayError> { + let fees = self + .with_provider(chain, |provider| async move { provider.get_transaction_fee_rates(input.into()).await }) + .await?; + Ok(fees.into_iter().map(|f| f.into()).collect()) + } + + pub async fn get_utxos(&self, chain: Chain, address: String) -> Result, GatewayError> { + self.with_provider(chain, |provider| async move { provider.get_utxos(address).await }).await + } + + pub async fn get_transaction_preload(&self, chain: Chain, input: GemTransactionPreloadInput) -> Result { + let preload_input: primitives::TransactionPreloadInput = input.into(); + let metadata = self + .with_provider(chain, |provider| async move { provider.get_transaction_preload(preload_input).await }) + .await?; + Ok(metadata.into()) + } + + pub async fn get_transaction_scan(&self, _chain: Chain, input: GemTransactionPreloadInput) -> Result, GatewayError> { + let preload_input: TransactionPreloadInput = input.into(); + + let Some(scan_type) = preload_input.scan_type() else { + return Ok(None); + }; + + let payload = ScanTransactionPayload { + origin: ScanAddressTarget { + asset_id: preload_input.input_type.get_asset().id.clone(), + address: preload_input.sender_address.clone(), + }, + target: ScanAddressTarget { + asset_id: preload_input.input_type.get_recipient_asset().id.clone(), + address: preload_input.destination_address.clone(), + }, + website: preload_input.get_website(), + transaction_type: scan_type, + }; + + self.api_client.scan_transaction(payload).await.map(Some).map_err(|e| GatewayError::NetworkError { msg: e }) + } + + pub async fn get_fee(&self, chain: Chain, input: GemTransactionLoadInput, provider: Arc) -> Result, GatewayError> { + let fee = provider.get_fee(chain, input.clone()).await?; + if let Some(fee) = fee { + return Ok(Some(fee)); + } + if let Some(fee_data) = provider.get_fee_data(chain, input.clone()).await? { + let data = self + .with_provider(chain, |chain_provider| async move { chain_provider.get_transaction_fee_from_data(fee_data).await }) + .await?; + return Ok(Some(data.into())); + } + Ok(None) + } + + pub async fn get_transaction_load(&self, chain: Chain, input: GemTransactionLoadInput, provider: Arc) -> Result { + let fee = self.get_fee(chain, input.clone(), provider.clone()).await?; + + let load_data = self + .with_provider(chain, |chain_provider| async move { chain_provider.get_transaction_load(input.clone().into()).await }) + .await?; + + let data = if let Some(fee) = fee { load_data.new_from(fee.into()) } else { load_data }; + + Ok(GemTransactionData { + fee: data.fee.into(), + metadata: data.metadata.into(), + }) + } + + pub async fn get_positions(&self, chain: Chain, address: String) -> Result { + self.with_provider(chain, |provider| async move { provider.get_positions(address).await }).await + } + + pub async fn get_perpetuals_data(&self, chain: Chain) -> Result, GatewayError> { + self.with_provider(chain, |provider| async move { provider.get_perpetuals_data().await }).await + } + + pub async fn get_perpetual_candlesticks(&self, chain: Chain, symbol: String, period: String) -> Result, GatewayError> { + let chart_period = ChartPeriod::new(period).unwrap(); + self.with_provider(chain, |provider| async move { provider.get_perpetual_candlesticks(symbol, chart_period).await }) + .await + } + + pub async fn get_perpetual_portfolio(&self, chain: Chain, address: String) -> Result { + self.with_provider(chain, |provider| async move { provider.get_perpetual_portfolio(address).await }).await + } + + pub async fn get_token_data(&self, chain: Chain, token_id: String) -> Result { + self.with_provider(chain, |provider| async move { provider.get_token_data(token_id).await }).await + } + + pub async fn get_is_token_address(&self, chain: Chain, token_id: String) -> Result { + Ok(self.chain_factory.create(chain).await?.get_is_token_address(&token_id)) + } + + pub async fn get_balance_earn(&self, chain: Chain, address: String, token_ids: Vec) -> Result, GatewayError> { + Ok(self.yielder.get_balance(chain, &address, &token_ids).await) + } + + pub async fn get_earn_data(&self, asset_id: AssetId, address: String, value: String, earn_type: GemEarnType) -> Result { + self.yielder + .get_data(&asset_id, &address, &value, &earn_type) + .await + .map_err(|e| GatewayError::NetworkError { msg: e.to_string() }) + } + + pub fn get_earn_providers(&self, asset_id: AssetId) -> Vec { + self.yielder.get_providers(&asset_id) + } + + pub async fn get_earn_positions(&self, address: String, asset_id: AssetId) -> Vec { + self.yielder.get_positions(&address, &asset_id).await + } + + pub async fn get_node_status(&self, chain: Chain, url: &str) -> Result { + let start_time = std::time::Instant::now(); + let provider = self.chain_factory.create_with_url(chain, url.to_string()).await?; + + let (chain_id, latest_block_number) = futures::try_join!(provider.get_chain_id(), provider.get_block_latest_number()).map_err(map_network_error)?; + + let latency_ms = start_time.elapsed().as_millis() as u64; + + Ok(GemNodeStatus { + chain_id, + latest_block_number, + latency_ms, + }) + } +} + +#[cfg(all(test, feature = "reqwest_provider"))] +mod tests { + use super::*; + use crate::testkit::TestAlienProvider; + + #[test] + fn test_get_node_status_http_404_error() { + let provider: Arc = Arc::new(TestAlienProvider::with_status(404)); + let preferences: Arc = Arc::new(EmptyPreferences {}); + let gateway = GemGateway::new(provider, preferences.clone(), preferences.clone(), "https://example.invalid".to_string()); + + let result = futures::executor::block_on(gateway.get_node_status(Chain::Bitcoin, "https://httpbin.org/status/404")); + + match result { + Ok(status) => panic!("expected network error for 404 response, got {:?}", status), + Err(GatewayError::NetworkError { msg }) => assert_eq!(msg, "HTTP error: status 404"), + Err(GatewayError::PlatformError { .. }) => panic!("expected NetworkError, got PlatformError"), + } + } +} diff --git a/core/gemstone/src/gateway/preferences.rs b/core/gemstone/src/gateway/preferences.rs new file mode 100644 index 0000000000..ac48656a9b --- /dev/null +++ b/core/gemstone/src/gateway/preferences.rs @@ -0,0 +1,46 @@ +use super::GatewayError; +use std::sync::Arc; + +#[uniffi::export(with_foreign)] +pub trait GemPreferences: Send + Sync { + fn get(&self, key: String) -> Result, GatewayError>; + fn set(&self, key: String, value: String) -> Result<(), GatewayError>; + fn remove(&self, key: String) -> Result<(), GatewayError>; +} + +pub(crate) struct PreferencesWrapper { + pub(crate) preferences: Arc, +} + +impl primitives::Preferences for PreferencesWrapper { + fn get(&self, key: String) -> Result, Box> { + self.preferences.get(key).map_err(Into::into) + } + + fn set(&self, key: String, value: String) -> Result<(), Box> { + self.preferences.set(key, value).map_err(Into::into) + } + + fn remove(&self, key: String) -> Result<(), Box> { + self.preferences.remove(key).map_err(Into::into) + } +} + +#[cfg(test)] +#[derive(Debug, Default)] +pub struct EmptyPreferences; + +#[cfg(test)] +impl GemPreferences for EmptyPreferences { + fn get(&self, _key: String) -> Result, GatewayError> { + Ok(None) + } + + fn set(&self, _key: String, _value: String) -> Result<(), GatewayError> { + Ok(()) + } + + fn remove(&self, _key: String) -> Result<(), GatewayError> { + Ok(()) + } +} diff --git a/core/gemstone/src/gem_swapper/error.rs b/core/gemstone/src/gem_swapper/error.rs new file mode 100644 index 0000000000..f022fad229 --- /dev/null +++ b/core/gemstone/src/gem_swapper/error.rs @@ -0,0 +1,13 @@ +pub type SwapperError = swapper::SwapperError; + +#[uniffi::remote(Enum)] +pub enum SwapperError { + NotSupportedChain, + NotSupportedAsset, + NoAvailableProvider, + InputAmountError { min_amount: Option }, + InvalidRoute, + ComputeQuoteError(String), + TransactionError(String), + NoQuoteAvailable, +} diff --git a/core/gemstone/src/gem_swapper/mod.rs b/core/gemstone/src/gem_swapper/mod.rs new file mode 100644 index 0000000000..1cab80ce14 --- /dev/null +++ b/core/gemstone/src/gem_swapper/mod.rs @@ -0,0 +1,62 @@ +mod error; +mod permit2; +use error::SwapperError; +use permit2::*; +mod remote_types; +use remote_types::*; +type Swapper = swapper::swapper::GemSwapper; + +use crate::alien::{AlienProvider, AlienProviderWrapper}; +use primitives::{AssetId, Chain}; +use std::sync::Arc; + +#[derive(Debug, uniffi::Object)] +pub struct GemSwapper { + inner: Swapper, +} + +#[uniffi::export] +impl GemSwapper { + #[uniffi::constructor] + pub fn new(rpc_provider: Arc) -> Self { + Self { + inner: Swapper::new(Arc::new(AlienProviderWrapper::new(rpc_provider))), + } + } + + pub fn supported_chains(&self) -> Vec { + self.inner.supported_chains() + } + + pub fn supported_chains_for_from_asset(&self, asset_id: &AssetId) -> SwapperAssetList { + self.inner.supported_chains_for_from_asset(asset_id) + } + + pub fn get_providers(&self) -> Vec { + self.inner.get_providers() + } + + pub fn get_providers_for_request(&self, request: &SwapperQuoteRequest) -> Result, SwapperError> { + self.inner.get_providers_for_request(request) + } + + pub async fn get_quote(&self, request: &SwapperQuoteRequest) -> Result, SwapperError> { + self.inner.get_quote(request).await + } + + pub async fn get_quote_by_provider(&self, provider: SwapperProvider, request: SwapperQuoteRequest) -> Result { + self.inner.get_quote_by_provider(provider, request).await + } + + pub async fn get_permit2_for_quote(&self, quote: &SwapperQuote) -> Result, SwapperError> { + self.inner.get_permit2_for_quote(quote).await + } + + pub async fn get_quote_data(&self, quote: &SwapperQuote, data: FetchQuoteData) -> Result { + self.inner.get_quote_data(quote, data).await + } + + pub async fn get_swap_result(&self, chain: Chain, provider: SwapperProvider, transaction_hash: &str) -> Result { + self.inner.get_swap_result(chain, provider, transaction_hash).await + } +} diff --git a/core/gemstone/src/gem_swapper/permit2.rs b/core/gemstone/src/gem_swapper/permit2.rs new file mode 100644 index 0000000000..72dd9f56a3 --- /dev/null +++ b/core/gemstone/src/gem_swapper/permit2.rs @@ -0,0 +1,43 @@ +use primitives::Chain; +use swapper::SwapperError; + +type Permit2Data = swapper::permit2_data::Permit2Data; +type PermitSingle = swapper::permit2_data::PermitSingle; +type Permit2Detail = swapper::permit2_data::Permit2Detail; + +pub type Permit2ApprovalData = swapper::models::Permit2ApprovalData; + +#[uniffi::remote(Record)] +pub struct Permit2Detail { + pub token: String, + pub amount: String, + pub expiration: u64, + pub nonce: u64, +} + +#[uniffi::remote(Record)] +pub struct PermitSingle { + pub details: Permit2Detail, + pub spender: String, + pub sig_deadline: u64, +} + +#[uniffi::remote(Record)] +pub struct Permit2Data { + pub permit_single: PermitSingle, + pub signature: Vec, +} + +#[uniffi::remote(Record)] +pub struct Permit2ApprovalData { + pub token: String, + pub spender: String, + pub value: String, + pub permit2_contract: String, + pub permit2_nonce: u64, +} + +#[uniffi::export] +pub fn permit2_data_to_eip712_json(chain: Chain, data: PermitSingle, contract: &str) -> Result { + swapper::permit2_data::permit2_data_to_eip712_json(chain, data, contract) +} diff --git a/core/gemstone/src/gem_swapper/remote_types.rs b/core/gemstone/src/gem_swapper/remote_types.rs new file mode 100644 index 0000000000..b9a3a1c197 --- /dev/null +++ b/core/gemstone/src/gem_swapper/remote_types.rs @@ -0,0 +1,174 @@ +use primitives::{AssetId, Chain}; +use std::str::FromStr; +pub use swapper::{ + AssetList as SwapperAssetList, FetchQuoteData, Options as SwapperOptions, ProviderData as SwapperProviderData, ProviderType as SwapperProviderType, Quote as SwapperQuote, + QuoteRequest as SwapperQuoteRequest, Route as SwapperRoute, SwapperProvider, SwapperProviderMode, SwapperQuoteAsset, SwapperSlippage, SwapperSlippageMode, SwapperSwapResult, + SwapperSwapStatus, SwapperTransactionSwapMetadata, permit2_data::Permit2Data, +}; + +pub use crate::models::swap::GemSwapQuoteData; + +#[derive(Debug, Clone, PartialEq, uniffi::Object)] +pub struct SwapProviderConfig(SwapperProviderType); + +#[uniffi::export] +impl SwapProviderConfig { + #[uniffi::constructor] + pub fn new(id: SwapperProvider) -> Self { + Self(SwapperProviderType::new(id)) + } + #[uniffi::constructor] + pub fn from_string(id: String) -> Self { + let id = SwapperProvider::from_str(&id).unwrap(); + Self(SwapperProviderType::new(id)) + } + pub fn inner(&self) -> SwapperProviderType { + self.0.clone() + } +} + +#[uniffi::remote(Enum)] +pub enum FetchQuoteData { + Permit2(Permit2Data), + EstimateGas, + None, +} + +#[uniffi::remote(Record)] +pub struct SwapperTransactionSwapMetadata { + pub from_asset: AssetId, + pub from_value: String, + pub to_asset: AssetId, + pub to_value: String, + pub provider: Option, +} + +#[uniffi::remote(Record)] +pub struct SwapperSwapResult { + pub status: SwapperSwapStatus, + pub metadata: Option, +} + +#[uniffi::remote(Record)] +pub struct SwapperAssetList { + pub chains: Vec, + pub asset_ids: Vec, +} + +#[uniffi::remote(Record)] +pub struct SwapperProviderType { + pub id: SwapperProvider, + pub name: String, + pub protocol: String, + pub protocol_id: String, + pub mode: SwapperProviderMode, +} + +#[uniffi::remote(Record)] +pub struct SwapperOptions { + pub slippage: SwapperSlippage, + pub use_max_amount: bool, +} + +#[uniffi::remote(Record)] +pub struct SwapperQuoteRequest { + pub from_asset: SwapperQuoteAsset, + pub to_asset: SwapperQuoteAsset, + pub wallet_address: String, + pub destination_address: String, + pub value: String, + pub options: SwapperOptions, +} + +#[uniffi::remote(Record)] +pub struct SwapperRoute { + pub input: AssetId, + pub output: AssetId, + pub route_data: String, +} + +#[uniffi::remote(Record)] +pub struct SwapperProviderData { + pub provider: SwapperProviderType, + pub slippage_bps: u32, + pub routes: Vec, +} + +#[uniffi::remote(Record)] +pub struct SwapperQuote { + pub from_value: String, + pub min_from_value: Option, + pub to_value: String, + pub data: SwapperProviderData, + pub request: SwapperQuoteRequest, + pub eta_in_seconds: Option, +} + +#[uniffi::remote(Enum)] +pub enum SwapperProvider { + UniswapV3, + UniswapV4, + PancakeswapV3, + Aerodrome, + Panora, + Thorchain, + Jupiter, + Okx, + Across, + Oku, + Wagmi, + StonfiV2, + Mayan, + Chainflip, + NearIntents, + CetusAggregator, + CetusClmm, + Relay, + Hyperliquid, + Orca, + Squid, +} + +#[uniffi::remote(Enum)] +pub enum SwapperProviderMode { + OnChain, + CrossChain, + Bridge, + OmniChain(Vec), +} + +#[uniffi::remote(Record)] +pub struct SwapperSlippage { + pub bps: u32, + pub mode: SwapperSlippageMode, +} + +#[uniffi::remote(Enum)] +pub enum SwapperSlippageMode { + Auto, + Exact, +} + +#[uniffi::remote(Record)] +pub struct SwapperQuoteAsset { + pub id: String, + pub symbol: String, + pub decimals: u32, +} + +#[uniffi::remote(Enum)] +pub enum SwapperSwapStatus { + Pending, + Completed, + Failed, +} + +#[uniffi::export] +fn swapper_provider_from_str(s: &str) -> Option { + SwapperProvider::from_str(s).ok() +} + +#[uniffi::export] +fn swapper_provider_to_str(provider: SwapperProvider) -> String { + provider.as_ref().to_string() +} diff --git a/core/gemstone/src/lib.rs b/core/gemstone/src/lib.rs new file mode 100644 index 0000000000..d6209cd074 --- /dev/null +++ b/core/gemstone/src/lib.rs @@ -0,0 +1,110 @@ +pub mod address; +pub mod address_formatter; +pub mod alien; +pub mod api_client; +pub mod auth; +pub mod block_explorer; +pub mod config; +pub mod deeplink; +pub mod ethereum; +pub mod gateway; +pub mod gem_swapper; +pub mod message; +pub mod models; +pub mod network; +pub mod payment; +pub mod perpetual; +pub mod price_alert_formatter; +pub mod signer; +pub mod siwe; +#[cfg(all(test, feature = "reqwest_provider"))] +pub(crate) mod testkit; +pub mod transaction_state; +pub mod url_action; +pub mod wallet_connect; + +use alien::AlienError; + +uniffi::setup_scaffolding!("gemstone"); +const LIB_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[uniffi::export] +pub fn lib_version() -> String { + String::from(LIB_VERSION) +} + +/// GemstoneError +#[derive(Debug, uniffi::Error)] +pub enum GemstoneError { + AnyError { msg: String }, +} + +impl std::fmt::Display for GemstoneError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AnyError { msg } => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for GemstoneError {} + +impl From> for GemstoneError { + fn from(error: Box) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From<&str> for GemstoneError { + fn from(error: &str) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From for GemstoneError { + fn from(error: number_formatter::NumberFormatterError) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From for GemstoneError { + fn from(error: String) -> Self { + Self::AnyError { msg: error } + } +} + +impl From> for GemstoneError { + fn from(error: Box) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From for GemstoneError { + fn from(error: primitives::payment_decoder::PaymentDecoderError) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From for GemstoneError { + fn from(error: AlienError) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From for GemstoneError { + fn from(error: primitives::SignerError) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From for GemstoneError { + fn from(error: serde_json::Error) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From for GemstoneError { + fn from(error: std::string::FromUtf8Error) -> Self { + Self::AnyError { msg: error.to_string() } + } +} diff --git a/core/gemstone/src/message/eip712.rs b/core/gemstone/src/message/eip712.rs new file mode 100644 index 0000000000..0cbb0b4766 --- /dev/null +++ b/core/gemstone/src/message/eip712.rs @@ -0,0 +1,155 @@ +use gem_evm::{EIP712Domain, EIP712TypedValue, eip712::parse_eip712_json}; +use primitives::hex; + +type GemEIP712MessageDomain = EIP712Domain; + +#[uniffi::remote(Record)] +pub struct GemEIP712MessageDomain { + pub name: Option, + pub version: Option, + pub chain_id: Option, + pub verifying_contract: Option, + pub salts: Option>, +} + +#[derive(Debug, PartialEq, uniffi::Record)] +pub struct GemEIP712Message { + pub domain: GemEIP712MessageDomain, + pub message: Vec, +} + +#[derive(Debug, PartialEq, uniffi::Record)] +pub struct GemEIP712Section { + pub name: String, + pub values: Vec, +} + +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum GemEIP712ValueType { + Text, + Address, + Timestamp, +} + +#[derive(Debug, PartialEq, uniffi::Record)] +pub struct GemEIP712Value { + pub name: String, + pub value: String, + pub value_type: GemEIP712ValueType, +} + +const TIMESTAMP_FIELDS: &[&str] = &["deadline", "sigdeadline", "expiration", "validto", "validuntil", "expiry", "timestamp"]; + +impl GemEIP712Message { + pub fn from_json(json_str: &str) -> Result { + let value = serde_json::from_str(json_str).map_err(|error| error.to_string())?; + let message = parse_eip712_json(&value)?; + + let mut section = GemEIP712Section { + name: message.primary_type, + values: vec![], + }; + for field in &message.message { + flatten_field(&field.name, &field.value, &mut section.values); + } + + Ok(Self { + domain: message.domain, + message: vec![section], + }) + } +} + +fn flatten_field(name: &str, value: &EIP712TypedValue, out: &mut Vec) { + match value { + EIP712TypedValue::Address { value } => { + out.push(GemEIP712Value { + name: name.to_string(), + value: value.clone(), + value_type: GemEIP712ValueType::Address, + }); + } + EIP712TypedValue::Uint256 { value } | EIP712TypedValue::Int256 { value } | EIP712TypedValue::String { value } => { + let value_type = if is_timestamp_field(name) { + GemEIP712ValueType::Timestamp + } else { + GemEIP712ValueType::Text + }; + out.push(GemEIP712Value { + name: name.to_string(), + value: value.clone(), + value_type, + }); + } + EIP712TypedValue::Bool { value } => { + out.push(GemEIP712Value { + name: name.to_string(), + value: value.to_string(), + value_type: GemEIP712ValueType::Text, + }); + } + EIP712TypedValue::Bytes { value } => { + out.push(GemEIP712Value { + name: name.to_string(), + value: hex::encode_with_0x(value), + value_type: GemEIP712ValueType::Text, + }); + } + EIP712TypedValue::Struct { fields } => { + for field in fields { + let field_name = format!("{name}.{}", field.name); + flatten_field(&field_name, &field.value, out); + } + } + EIP712TypedValue::Array { items } => { + let use_index = items.len() > 1; + for (i, item) in items.iter().enumerate() { + let index = i + 1; + match item { + EIP712TypedValue::Struct { fields } => { + for field in fields { + let field_name = if use_index { + format!("{name}[{index}].{}", field.name) + } else { + format!("{name}.{}", field.name) + }; + flatten_field(&field_name, &field.value, out); + } + } + _ => { + let item_name = if use_index { format!("{name}[{index}]") } else { name.to_string() }; + flatten_field(&item_name, item, out); + } + } + } + } + } +} + +fn is_timestamp_field(name: &str) -> bool { + let base = name.rsplit('.').next().unwrap_or(name); + let base = base.split('[').next().unwrap_or(base); + let lower = base.to_lowercase(); + TIMESTAMP_FIELDS.iter().any(|&f| lower == f) +} + +#[cfg(test)] +mod tests { + use super::GemEIP712Message; + + #[test] + fn from_json_returns_error_for_malformed_json() { + assert!(GemEIP712Message::from_json("{").is_err()); + } + + #[test] + fn from_json_supports_missing_domain_chain_id() { + let message = GemEIP712Message::from_json(include_str!("../../../crates/gem_evm/testdata/ens_upload_avatar.json")).unwrap(); + + assert_eq!(message.domain.name.as_deref(), Some("Ethereum Name Service")); + assert_eq!(message.domain.chain_id, None); + assert_eq!(message.message.len(), 1); + assert_eq!(message.message[0].name, "Upload"); + assert_eq!(message.message[0].values.len(), 4); + } +} diff --git a/core/gemstone/src/message/mod.rs b/core/gemstone/src/message/mod.rs new file mode 100644 index 0000000000..195f846d56 --- /dev/null +++ b/core/gemstone/src/message/mod.rs @@ -0,0 +1,4 @@ +pub mod eip712; +pub mod payload; +pub mod sign_type; +pub mod signer; diff --git a/core/gemstone/src/message/payload.rs b/core/gemstone/src/message/payload.rs new file mode 100644 index 0000000000..2cbebe9947 --- /dev/null +++ b/core/gemstone/src/message/payload.rs @@ -0,0 +1,475 @@ +use primitives::{SimulationPayloadField, SimulationPayloadFieldDisplay, SimulationPayloadFieldKind, SimulationPayloadFieldType, promote_single_secondary_payload_field}; +use std::borrow::Cow; +use std::collections::HashSet; + +use crate::{ + message::eip712::{GemEIP712Message, GemEIP712Value, GemEIP712ValueType}, + siwe::SiweMessage, +}; + +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct MessagePayloadPreview { + pub primary: Vec, + pub secondary: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +struct MessagePayloadField { + kind: Option, + label: Option, + value: String, + field_type: SimulationPayloadFieldType, + display: SimulationPayloadFieldDisplay, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum PayloadMergeKey { + Kind(SimulationPayloadFieldKind), + Label(String), +} + +enum CanonicalPayloadLabel<'a> { + Kind(SimulationPayloadFieldKind), + Custom(Cow<'a, str>), +} + +pub fn eip712_payload_preview(message: &GemEIP712Message, simulation_payload: Vec) -> MessagePayloadPreview { + grouped_payload_preview(eip712_preview_fields(message), simulation_payload) +} + +pub fn siwe_payload_preview(message: &SiweMessage, simulation_payload: Vec) -> MessagePayloadPreview { + grouped_payload_preview(siwe_preview_fields(message), simulation_payload) +} + +fn grouped_payload_preview(preview_fields: Vec, simulation_payload: Vec) -> MessagePayloadPreview { + let merged_payload = merge_payload(simulation_payload.clone(), preview_fields); + let grouped_payload = if simulation_payload.is_empty() { + apply_preview_display_grouping(merged_payload) + } else { + merged_payload + }; + + let grouped_payload = promote_single_secondary_payload_field(grouped_payload); + let grouped_payload = promote_secondary_payload_when_primary_is_empty(grouped_payload); + + MessagePayloadPreview { + primary: grouped_payload + .iter() + .filter(|field| field.display == SimulationPayloadFieldDisplay::Primary) + .cloned() + .collect(), + secondary: grouped_payload + .iter() + .filter(|field| field.display == SimulationPayloadFieldDisplay::Secondary) + .cloned() + .collect(), + } +} + +fn promote_secondary_payload_when_primary_is_empty(payload: Vec) -> Vec { + if payload.iter().any(|field| field.display == SimulationPayloadFieldDisplay::Primary) { + return payload; + } + + payload + .into_iter() + .map(|field| SimulationPayloadField { + display: SimulationPayloadFieldDisplay::Primary, + ..field + }) + .collect() +} + +fn merge_payload(mut simulation_payload: Vec, preview_fields: Vec) -> Vec { + let mut seen_field_keys: HashSet<_> = simulation_payload.iter().map(payload_merge_key).collect(); + + for field in preview_fields.into_iter().filter(|field| !field.value.is_empty()) { + let merge_key = field.merge_key(); + if !seen_field_keys.insert(merge_key) { + continue; + } + simulation_payload.push(field.into_public_field()); + } + + simulation_payload +} + +fn apply_preview_display_grouping(payload: Vec) -> Vec { + let primary_keys = preview_primary_keys(&payload); + + payload + .into_iter() + .map(|field| SimulationPayloadField { + display: if primary_keys.contains(&payload_merge_key(&field)) { + SimulationPayloadFieldDisplay::Primary + } else { + SimulationPayloadFieldDisplay::Secondary + }, + ..field + }) + .collect() +} + +fn preview_primary_keys(payload: &[SimulationPayloadField]) -> HashSet { + let keys: HashSet<_> = payload.iter().map(payload_merge_key).collect(); + let has_contract_action_payload = keys.iter().any(PayloadMergeKey::is_contract_action_key); + + if has_contract_action_payload { + let mut primary_keys = [ + PayloadMergeKey::Kind(SimulationPayloadFieldKind::Contract), + PayloadMergeKey::Kind(SimulationPayloadFieldKind::Method), + ] + .into_iter() + .filter(|key| keys.contains(key)) + .collect::>(); + + if keys.contains(&PayloadMergeKey::Kind(SimulationPayloadFieldKind::Token)) { + primary_keys.insert(PayloadMergeKey::Kind(SimulationPayloadFieldKind::Token)); + } else if keys.contains(&PayloadMergeKey::Kind(SimulationPayloadFieldKind::Spender)) { + primary_keys.insert(PayloadMergeKey::Kind(SimulationPayloadFieldKind::Spender)); + } + + return primary_keys; + } + + [PayloadMergeKey::Label("domain".to_string()), PayloadMergeKey::Label("address".to_string())] + .into_iter() + .filter(|key| keys.contains(key)) + .collect() +} + +fn eip712_preview_fields(message: &GemEIP712Message) -> Vec { + let mut fields = vec![primary_type_payload_field(message)]; + + if let Some(domain_name) = message.domain.name.as_ref() { + fields.insert( + 0, + MessagePayloadField::custom("domain", domain_name.clone(), SimulationPayloadFieldType::Text, SimulationPayloadFieldDisplay::Secondary), + ); + } + + if let Some(verifying_contract) = message.domain.verifying_contract.as_ref() { + fields.push(MessagePayloadField::standard( + SimulationPayloadFieldKind::Contract, + verifying_contract.clone(), + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Secondary, + )); + } + + fields.extend(message.message.iter().flat_map(|section| section.values.iter().map(payload_field_from_eip712_value))); + fields +} + +fn primary_type_payload_field(message: &GemEIP712Message) -> MessagePayloadField { + let primary_type = message.message.first().map(|section| section.name.clone()).unwrap_or_default(); + + MessagePayloadField::standard( + SimulationPayloadFieldKind::Method, + primary_type, + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Secondary, + ) +} + +fn payload_field_from_eip712_value(field: &GemEIP712Value) -> MessagePayloadField { + let field_type = match field.value_type { + GemEIP712ValueType::Address => SimulationPayloadFieldType::Address, + GemEIP712ValueType::Timestamp => SimulationPayloadFieldType::Timestamp, + GemEIP712ValueType::Text => SimulationPayloadFieldType::Text, + }; + + match canonical_payload_label(&field.name) { + Some(CanonicalPayloadLabel::Kind(kind)) => MessagePayloadField::standard(kind, field.value.clone(), field_type, SimulationPayloadFieldDisplay::Secondary), + Some(CanonicalPayloadLabel::Custom(label)) => MessagePayloadField::custom(label, field.value.clone(), field_type, SimulationPayloadFieldDisplay::Secondary), + None => MessagePayloadField::custom(&field.name, field.value.clone(), field_type, SimulationPayloadFieldDisplay::Secondary), + } +} + +fn siwe_preview_fields(message: &SiweMessage) -> Vec { + vec![ + MessagePayloadField::custom("domain", message.domain.clone(), SimulationPayloadFieldType::Text, SimulationPayloadFieldDisplay::Secondary), + MessagePayloadField::custom( + "address", + message.address.clone(), + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Secondary, + ), + MessagePayloadField::custom("uri", message.uri.clone(), SimulationPayloadFieldType::Text, SimulationPayloadFieldDisplay::Secondary), + MessagePayloadField::custom( + "chainId", + message.chain_id.to_string(), + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Secondary, + ), + MessagePayloadField::custom("nonce", message.nonce.clone(), SimulationPayloadFieldType::Text, SimulationPayloadFieldDisplay::Secondary), + MessagePayloadField::custom( + "issuedAt", + message.issued_at.clone(), + SimulationPayloadFieldType::Timestamp, + SimulationPayloadFieldDisplay::Secondary, + ), + MessagePayloadField::custom( + "version", + message.version.clone(), + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Secondary, + ), + ] +} + +fn payload_merge_key(field: &SimulationPayloadField) -> PayloadMergeKey { + match field.kind { + SimulationPayloadFieldKind::Custom => PayloadMergeKey::Label(canonical_merge_label(field.label.as_deref().unwrap_or_default()).into_owned()), + _ => PayloadMergeKey::Kind(field.kind.clone()), + } +} + +fn canonical_payload_label(label: &str) -> Option> { + if label.chars().all(is_identifier_separator) { + return None; + } + + let kind = canonical_payload_kind(label); + + match kind { + Some(kind) => Some(CanonicalPayloadLabel::Kind(kind)), + None => Some(CanonicalPayloadLabel::Custom(canonical_merge_label(label))), + } +} + +fn canonical_payload_kind(label: &str) -> Option { + if identifier_eq(label, "contract") || identifier_eq(label, "verifyingContract") { + return Some(SimulationPayloadFieldKind::Contract); + } + if identifier_eq(label, "method") || identifier_eq(label, "action") { + return Some(SimulationPayloadFieldKind::Method); + } + if identifier_eq(label, "token") { + return Some(SimulationPayloadFieldKind::Token); + } + if identifier_eq(label, "spender") { + return Some(SimulationPayloadFieldKind::Spender); + } + if identifier_eq(label, "value") || identifier_eq(label, "amount") { + return Some(SimulationPayloadFieldKind::Value); + } + None +} + +fn canonical_merge_label(label: &str) -> Cow<'_, str> { + if identifier_eq(label, "domain") { + return Cow::Borrowed("domain"); + } + if identifier_eq(label, "address") { + return Cow::Borrowed("address"); + } + if identifier_eq(label, "uri") { + return Cow::Borrowed("uri"); + } + if identifier_eq(label, "chainId") { + return Cow::Borrowed("chainId"); + } + Cow::Borrowed(label) +} + +fn identifier_eq(left: &str, right: &str) -> bool { + let mut left = left.chars().filter(|character| !is_identifier_separator(*character)); + let mut right = right.chars().filter(|character| !is_identifier_separator(*character)); + + loop { + match (left.next(), right.next()) { + (Some(left), Some(right)) if left.eq_ignore_ascii_case(&right) => {} + (None, None) => return true, + _ => return false, + } + } +} + +fn is_identifier_separator(character: char) -> bool { + character == ' ' || character == '_' || character == '-' || character == '.' +} + +impl MessagePayloadField { + fn standard(kind: SimulationPayloadFieldKind, value: String, field_type: SimulationPayloadFieldType, display: SimulationPayloadFieldDisplay) -> Self { + Self { + kind: Some(kind), + label: None, + value, + field_type, + display, + } + } + + fn custom(label: impl Into, value: String, field_type: SimulationPayloadFieldType, display: SimulationPayloadFieldDisplay) -> Self { + Self { + kind: None, + label: Some(label.into()), + value, + field_type, + display, + } + } + + fn into_public_field(self) -> SimulationPayloadField { + match self.kind { + Some(kind) => SimulationPayloadField::standard(kind, self.value, self.field_type, self.display), + None => SimulationPayloadField::custom(self.label.unwrap_or_default(), self.value, self.field_type, self.display), + } + } + + fn merge_key(&self) -> PayloadMergeKey { + match self.kind.as_ref() { + Some(kind) => PayloadMergeKey::Kind(kind.clone()), + None => PayloadMergeKey::Label(canonical_merge_label(self.label.as_deref().unwrap_or_default()).into_owned()), + } + } +} + +impl PayloadMergeKey { + fn is_contract_action_key(&self) -> bool { + match self { + Self::Kind(SimulationPayloadFieldKind::Contract) + | Self::Kind(SimulationPayloadFieldKind::Method) + | Self::Kind(SimulationPayloadFieldKind::Token) + | Self::Kind(SimulationPayloadFieldKind::Spender) => true, + Self::Kind(SimulationPayloadFieldKind::Value) | Self::Kind(SimulationPayloadFieldKind::Custom) | Self::Label(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::{eip712_payload_preview, siwe_payload_preview}; + use crate::message::eip712::{GemEIP712Message, GemEIP712Section, GemEIP712Value, GemEIP712ValueType}; + use crate::siwe::SiweMessage; + use gem_evm::EIP712Domain; + use primitives::{SimulationPayloadField, SimulationPayloadFieldDisplay, SimulationPayloadFieldKind, SimulationPayloadFieldType}; + + #[test] + fn siwe_preview_groups_domain_and_address_as_primary() { + let preview = siwe_payload_preview( + &SiweMessage { + domain: "login.xyz".into(), + address: "0x123".into(), + uri: "https://login.xyz".into(), + chain_id: 1, + nonce: "nonce".into(), + version: "1".into(), + issued_at: "2026-03-09T15:48:34.458Z".into(), + }, + vec![], + ); + + assert_eq!(preview.primary.len(), 2); + assert_eq!(preview.secondary.len(), 5); + } + + #[test] + fn eip712_preview_keeps_simulation_primary_payload() { + let preview = eip712_payload_preview( + &GemEIP712Message { + domain: EIP712Domain { + name: Some("Permit2".into()), + version: None, + chain_id: Some(1), + verifying_contract: Some("0xContract".into()), + salts: None, + }, + message: vec![GemEIP712Section { + name: "PermitSingle".into(), + values: vec![ + GemEIP712Value { + name: "spender".into(), + value: "0xSpender".into(), + value_type: GemEIP712ValueType::Address, + }, + GemEIP712Value { + name: "amount".into(), + value: "100".into(), + value_type: GemEIP712ValueType::Text, + }, + ], + }], + }, + vec![ + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Contract, + "0xContract", + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + ), + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Method, + "Permit Single", + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Primary, + ), + SimulationPayloadField::standard( + SimulationPayloadFieldKind::Spender, + "0xSpender", + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + ), + ], + ); + + assert_eq!(preview.primary.len(), 3); + assert_eq!(preview.secondary.len(), 2); + assert_eq!(preview.secondary[0].label.as_deref(), Some("domain")); + assert_eq!(preview.secondary[1].kind, SimulationPayloadFieldKind::Value); + } + + #[test] + fn preview_promotes_single_secondary_field() { + let preview = eip712_payload_preview( + &GemEIP712Message { + domain: EIP712Domain { + name: None, + version: None, + chain_id: Some(1), + verifying_contract: None, + salts: None, + }, + message: vec![GemEIP712Section { + name: "Action".into(), + values: vec![], + }], + }, + vec![SimulationPayloadField::standard( + SimulationPayloadFieldKind::Contract, + "0xContract", + SimulationPayloadFieldType::Address, + SimulationPayloadFieldDisplay::Primary, + )], + ); + + assert_eq!(preview.primary.len(), 2); + assert!(preview.secondary.is_empty()); + } + + #[test] + fn preview_promotes_secondary_fields_when_primary_is_empty() { + let preview = siwe_payload_preview( + &SiweMessage { + domain: "login.xyz".into(), + address: "0x123".into(), + uri: "https://login.xyz".into(), + chain_id: 1, + nonce: "nonce".into(), + version: "1".into(), + issued_at: "2026-03-09T15:48:34.458Z".into(), + }, + vec![SimulationPayloadField::custom( + "customField", + "value", + SimulationPayloadFieldType::Text, + SimulationPayloadFieldDisplay::Secondary, + )], + ); + + assert_eq!(preview.primary.len(), 8); + assert!(preview.primary.iter().any(|field| field.label.as_deref() == Some("customField"))); + assert!(preview.secondary.is_empty()); + } +} diff --git a/core/gemstone/src/message/sign_type.rs b/core/gemstone/src/message/sign_type.rs new file mode 100644 index 0000000000..03fe78fc39 --- /dev/null +++ b/core/gemstone/src/message/sign_type.rs @@ -0,0 +1,19 @@ +use primitives::Chain; + +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum SignDigestType { + Eip191, + Eip712, + Base58, + SuiPersonal, + Siwe, + TonPersonal, + TronPersonal, +} + +#[derive(Debug, uniffi::Record)] +pub struct SignMessage { + pub chain: Chain, + pub sign_type: SignDigestType, + pub data: Vec, +} diff --git a/core/gemstone/src/message/signer.rs b/core/gemstone/src/message/signer.rs new file mode 100644 index 0000000000..f03a9a03b1 --- /dev/null +++ b/core/gemstone/src/message/signer.rs @@ -0,0 +1,692 @@ +use std::borrow::Cow; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use bs58; +use gem_evm::message::eip191_hash_message; +use gem_solana::signer::SolanaChainSigner; +use gem_sui::signer as sui_signer; +use gem_ton::address::base64_to_hex_address; +use gem_ton::signer::{TonSignDataResponse, TonSignMessageData, TonSignResult, TonSigner}; +use primitives::hex::encode_with_0x; +use signer::{SIGNATURE_LENGTH, Signer, ensure_ethereum_signature_recovery_id_offset, hash_eip712}; +use std::time::{SystemTime, UNIX_EPOCH}; +use sui_types::PersonalMessage; + +use super::{ + eip712::GemEIP712Message, + payload::{MessagePayloadPreview, eip712_payload_preview, siwe_payload_preview}, + sign_type::{SignDigestType, SignMessage}, +}; +use crate::{GemstoneError, siwe::SiweMessage}; +use gem_tron::signer::tron_hash_message; +use primitives::{Chain, ChainSigner, SimulationPayloadField}; +use zeroize::Zeroizing; + +fn siwe_or_text_preview(chain: primitives::Chain, data: &[u8]) -> MessagePreview { + match String::from_utf8(data.to_vec()) { + Ok(string) => match SiweMessage::try_parse(&string) { + Some(message) if message.validate(chain).is_ok() => MessagePreview::Siwe(message), + Some(_) | None => MessagePreview::Text(string), + }, + Err(_) => MessagePreview::Text(encode_with_0x(data)), + } +} + +#[derive(Debug, PartialEq, uniffi::Enum)] +pub enum MessagePreview { + Text(String), + EIP712(GemEIP712Message), + Siwe(SiweMessage), +} + +#[derive(Debug, uniffi::Object)] +pub struct MessageSigner { + pub message: SignMessage, + timestamp: u64, +} + +#[uniffi::export] +impl MessageSigner { + #[uniffi::constructor] + pub fn new(message: SignMessage) -> Self { + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + Self { message, timestamp } + } + + pub fn preview(&self) -> Result { + match self.message.sign_type { + SignDigestType::Eip191 | SignDigestType::Siwe => Ok(siwe_or_text_preview(self.message.chain, &self.message.data)), + SignDigestType::SuiPersonal | SignDigestType::TronPersonal => Ok(MessagePreview::Text( + String::from_utf8(self.message.data.clone()).unwrap_or(encode_with_0x(&self.message.data)), + )), + SignDigestType::TonPersonal => { + let string = String::from_utf8(self.message.data.clone())?; + let Ok(ton_data) = TonSignMessageData::from_bytes(string.as_bytes()) else { + return Ok(MessagePreview::Text(string)); + }; + Ok(MessagePreview::Text(ton_data.payload.data().to_string())) + } + SignDigestType::Eip712 => { + let string = String::from_utf8(self.message.data.clone())?; + if string.is_empty() { + return Err(GemstoneError::from("Empty EIP712 message string")); + } + let message = GemEIP712Message::from_json(&string).map_err(|e| GemstoneError::from(format!("Invalid EIP712 message: {e}")))?; + Ok(MessagePreview::EIP712(message)) + } + SignDigestType::Base58 => { + let decoded = bs58::decode(&self.message.data).into_vec().unwrap_or_default(); + Ok(MessagePreview::Text(String::from_utf8_lossy(&decoded).to_string())) + } + } + } + + pub fn payload_preview(&self, simulation_payload: Vec) -> Result, GemstoneError> { + let payload_preview = match self.preview()? { + MessagePreview::Text(_) => match self.message.sign_type { + SignDigestType::Eip191 | SignDigestType::Siwe => self.siwe_payload_preview(simulation_payload), + _ => None, + }, + MessagePreview::EIP712(message) => Some(eip712_payload_preview(&message, simulation_payload)), + MessagePreview::Siwe(message) => Some(siwe_payload_preview(&message, simulation_payload)), + }; + + Ok(payload_preview) + } + + pub fn plain_preview(&self) -> String { + match self.message.sign_type { + SignDigestType::SuiPersonal | SignDigestType::Eip191 | SignDigestType::TronPersonal => { + String::from_utf8(self.message.data.clone()).unwrap_or_else(|_| encode_with_0x(&self.message.data)) + } + SignDigestType::Base58 => match self.preview() { + Ok(MessagePreview::Text(preview)) => preview, + _ => "".to_string(), + }, + SignDigestType::TonPersonal => match self.preview() { + Ok(MessagePreview::Text(preview)) => preview, + _ => String::from_utf8(self.message.data.clone()).unwrap_or_else(|_| encode_with_0x(&self.message.data)), + }, + SignDigestType::Siwe => String::from_utf8(self.message.data.clone()).unwrap_or_else(|_| encode_with_0x(&self.message.data)), + SignDigestType::Eip712 => { + let value: serde_json::Value = serde_json::from_slice(&self.message.data).unwrap_or_default(); + serde_json::to_string_pretty(&value).unwrap_or_default() + } + } + } + + pub fn hash(&self) -> Result, GemstoneError> { + match &self.message.sign_type { + SignDigestType::SuiPersonal => { + let message = PersonalMessage(Cow::Borrowed(&self.message.data)); + Ok(message.signing_digest().to_vec()) + } + SignDigestType::TonPersonal => { + let string = String::from_utf8(self.message.data.clone())?; + let ton_data = TonSignMessageData::from_bytes(string.as_bytes())?; + Ok(ton_data.hash(self.timestamp)?) + } + SignDigestType::TronPersonal => Ok(tron_hash_message(&self.message.data).to_vec()), + SignDigestType::Eip191 | SignDigestType::Siwe => Ok(eip191_hash_message(&self.message.data).to_vec()), + SignDigestType::Eip712 => { + let json = String::from_utf8(self.message.data.clone())?; + let digest = hash_eip712(&json)?; + Ok(digest.to_vec()) + } + SignDigestType::Base58 => bs58::decode(&self.message.data).into_vec().map_err(|e| GemstoneError::from(e.to_string())), + } + } + + pub fn get_result(&self, data: &[u8]) -> String { + match &self.message.sign_type { + SignDigestType::Eip191 | SignDigestType::Eip712 | SignDigestType::Siwe | SignDigestType::TronPersonal => { + if data.len() < SIGNATURE_LENGTH { + return encode_with_0x(data); + } + let mut signature = data.to_vec(); + ensure_ethereum_signature_recovery_id_offset(&mut signature); + encode_with_0x(&signature) + } + SignDigestType::SuiPersonal | SignDigestType::TonPersonal => BASE64.encode(data), + SignDigestType::Base58 => bs58::encode(data).into_string(), + } + } + + pub fn sign(&self, private_key: Vec) -> Result { + let private_key = Zeroizing::new(private_key); + match &self.message.sign_type { + SignDigestType::SuiPersonal => { + let hash = self.hash()?; + sui_signer::sign_digest(&hash, &private_key).map_err(GemstoneError::from) + } + SignDigestType::TonPersonal => { + let result = TonSigner::new(&private_key)?.sign_personal(&self.message.data, self.timestamp)?; + self.get_ton_result(&result) + } + SignDigestType::Eip191 | SignDigestType::Eip712 | SignDigestType::Siwe | SignDigestType::TronPersonal => { + let hash = self.hash()?; + let signature = Signer::sign_ethereum_digest(&hash, &private_key)?; + Ok(encode_with_0x(&signature)) + } + SignDigestType::Base58 => { + if self.message.chain != Chain::Solana { + return Err(GemstoneError::from(format!("Base58 sign message is not supported for {:?}", self.message.chain))); + } + let message = self.hash()?; + SolanaChainSigner.sign_message(&message, private_key.as_slice()).map_err(GemstoneError::from) + } + } + } +} + +impl MessageSigner { + fn siwe_payload_preview(&self, simulation_payload: Vec) -> Option { + let string = String::from_utf8(self.message.data.clone()).ok()?; + let message = SiweMessage::try_parse(&string)?; + Some(siwe_payload_preview(&message, simulation_payload)) + } + + fn get_ton_result(&self, result: &TonSignResult) -> Result { + let string = String::from_utf8(self.message.data.clone())?; + let data = TonSignMessageData::from_bytes(string.as_bytes())?; + let raw_address = base64_to_hex_address(&data.address).ok_or_else(|| GemstoneError::from("Invalid TON address"))?; + + let response = TonSignDataResponse { + signature: BASE64.encode(&result.signature), + address: raw_address, + timestamp: result.timestamp, + domain: data.domain, + payload: data.payload, + }; + + serde_json::to_string(&response).map_err(|e| GemstoneError::from(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::{ + eip712::{GemEIP712Section, GemEIP712Value, GemEIP712ValueType}, + sign_type::SignDigestType, + }; + use gem_evm::EIP712Domain; + use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + use primitives::{Address, Chain}; + + #[test] + fn test_eip191() { + let data = b"hello world".to_vec(); + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data, + }); + match decoder.preview() { + Ok(MessagePreview::Text(preview)) => assert_eq!(preview, "hello world"), + _ => panic!("Unexpected preview result"), + } + + let hash = decoder.hash().unwrap(); + assert_eq!(encode_with_0x(&hash), "0xd9eba16ed0ecae432b71fe008c98cc872bb4cc214d3220a36f365326cf807d68"); + } + + #[test] + fn test_eip191_hex_value() { + // 0x74657374 corresponds to "test" in UTF-8 + let data = hex::decode("74657374").expect("Invalid hex string"); + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data, + }); + match decoder.preview() { + Ok(MessagePreview::Text(preview)) => assert_eq!(preview, "test"), + _ => panic!("Unexpected preview result"), + } + } + + #[test] + fn test_eip191_non_utf8_hex_value() { + // 0xdeadbeef is not valid UTF-8 + let data = hex::decode("deadbeef").expect("Invalid hex string"); + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data, + }); + match decoder.preview() { + // Since 0xdeadbeef is not valid UTF-8, preview should show the hex representation + Ok(MessagePreview::Text(preview)) => assert_eq!(preview, "0xdeadbeef"), + _ => panic!("Unexpected preview result"), + } + } + + #[test] + fn test_eip191_plain_preview_for_siwe() { + let message = r#"thepoc.xyz wants you to sign in with your Ethereum account: +0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4 + +Sign in with different chain ID + +URI: https://thepoc.xyz +Version: 1 +Chain ID: 137 +Nonce: byjof9dwrao97skautdxhb +Issued At: 2026-03-09T15:48:34.458Z"#; + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data: message.as_bytes().to_vec(), + }); + + assert_eq!(decoder.plain_preview(), message); + } + + #[test] + fn test_get_result_eip191() { + let data = hex::decode("d80c5ffe75fcbac0706c5c5d3b8884ae3588c30065a95075e07fa6ebc24e56433e5030992ef438b1d23437ec8d66d3197b1ad92f85222af1624d8f295907a65800") + .expect("Invalid hex string"); + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data: data.clone(), + }); + let result = decoder.get_result(data.as_slice()); + assert_eq!( + result, + "0xd80c5ffe75fcbac0706c5c5d3b8884ae3588c30065a95075e07fa6ebc24e56433e5030992ef438b1d23437ec8d66d3197b1ad92f85222af1624d8f295907a6581b" + ); + } + + #[test] + fn test_get_result_recovery_id_conversion() { + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data: b"test".to_vec(), + }); + + // Raw recovery ID 0 -> 27 (0x1b) + let mut sig = vec![0u8; 65]; + sig[64] = 0; + assert!(decoder.get_result(&sig).ends_with("1b")); + + // Raw recovery ID 1 -> 28 (0x1c) + sig[64] = 1; + assert!(decoder.get_result(&sig).ends_with("1c")); + + // Already converted IDs stay unchanged + sig[64] = 27; + assert!(decoder.get_result(&sig).ends_with("1b")); + + sig[64] = 28; + assert!(decoder.get_result(&sig).ends_with("1c")); + } + + #[test] + fn test_sui_personal_message_hash() { + let data = b"Hello, world!".to_vec(); + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Sui, + sign_type: SignDigestType::SuiPersonal, + data: data.clone(), + }); + + let hash = decoder.hash().unwrap(); + let expected_hash = PersonalMessage(Cow::Owned(data)).signing_digest().to_vec(); + assert_eq!(hash, expected_hash); + + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Sui, + sign_type: SignDigestType::SuiPersonal, + data: b"Hello, world!".to_vec(), + }); + let mut signature = vec![0u8; 97]; + signature[0] = 0; + signature[96] = 1; + let expected = BASE64.encode(&signature); + assert_eq!(decoder.get_result(&signature), expected); + } + + #[test] + fn test_base58() { + let message = "X3CUgCGzyn43DTAbUKnTMDzcGWMooJT2hPSZinjfN1QUgVNYYfeoJ5zg6i4Nd5coKGUrNpEYVoD"; + let data = message.as_bytes().to_vec(); + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Solana, + sign_type: SignDigestType::Base58, + data: data.clone(), + }); + + match decoder.preview() { + Ok(MessagePreview::Text(preview)) => assert_eq!(preview, "This is an example message to be signed - 1747125759060"), + _ => panic!("Unexpected preview result for base58"), + } + let hash = decoder.hash().unwrap(); + + assert_eq!( + hex::encode(&hash), + "5468697320697320616e206578616d706c65206d65737361676520746f206265207369676e6564202d2031373437313235373539303630" + ); + + let result_data = b"StV1DL6CwTryKyV"; // Data to pass to get_result, mimicking Swift test + let result = decoder.get_result(result_data); + + assert_eq!(result, "3LRFsmWKLfsR7G5PqjytR"); + } + + #[test] + fn test_base58_sign_rejects_non_solana_chain() { + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Base58, + data: b"hello".to_vec(), + }); + + assert_eq!( + decoder.sign(TEST_PRIVATE_KEY.to_vec()).unwrap_err().to_string(), + "Base58 sign message is not supported for ethereum" + ); + } + + #[test] + fn test_eip712_hash() { + let json_str = include_str!("./test/eip712_seaport.json"); + let hash = hash_eip712(json_str).unwrap(); + + assert_eq!(hex::encode(hash), "0b8aa9f3712df0034bc29fe5b24dd88cfdba02c7f499856ab24632e2969709a8",); + + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip712, + data: json_str.as_bytes().to_vec(), + }); + let preview = decoder.preview().unwrap(); + assert_eq!( + preview, + MessagePreview::EIP712(GemEIP712Message { + domain: EIP712Domain { + name: Some("Seaport".to_string()), + version: Some("1.1".to_string()), + chain_id: Some(1), + verifying_contract: Some("0x00000000006c3852cbEf3e08E8dF289169EdE581".to_string()), + salts: None, + }, + message: vec![GemEIP712Section { + name: "OrderComponents".to_string(), + values: vec![ + GemEIP712Value { + name: "offerer".to_string(), + value: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_string(), + value_type: GemEIP712ValueType::Address, + }, + GemEIP712Value { + name: "zone".to_string(), + value: "0x004C00500000aD104D7DBd00e3ae0A5C00560C00".to_string(), + value_type: GemEIP712ValueType::Address, + }, + GemEIP712Value { + name: "offer.token".to_string(), + value: "0xA604060890923Ff400e8c6f5290461A83AEDACec".to_string(), + value_type: GemEIP712ValueType::Address, + }, + GemEIP712Value { + name: "startTime".to_string(), + value: "1658645591".to_string(), + value_type: GemEIP712ValueType::Text, + }, + GemEIP712Value { + name: "endTime".to_string(), + value: "1659250386".to_string(), + value_type: GemEIP712ValueType::Text, + }, + GemEIP712Value { + name: "zoneHash".to_string(), + value: "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), + value_type: GemEIP712ValueType::Text, + }, + GemEIP712Value { + name: "salt".to_string(), + value: "16178208897136618".to_string(), + value_type: GemEIP712ValueType::Text, + }, + GemEIP712Value { + name: "conduitKey".to_string(), + value: "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000".to_string(), + value_type: GemEIP712ValueType::Text, + }, + GemEIP712Value { + name: "counter".to_string(), + value: "0".to_string(), + value_type: GemEIP712ValueType::Text, + }, + ], + }], + }) + ); + } + + #[test] + fn test_eip712_hyperliquid_approve_agent_hash() { + let json_str = include_str!("../../../crates/gem_hypercore/testdata/hl_eip712_approve_agent.json"); + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip712, + data: json_str.as_bytes().to_vec(), + }); + + let digest = decoder.hash().unwrap(); + assert_eq!(hex::encode(digest), "480af9fd3cdc70c2f8a521388be13620d16a0f643d9cffdfbb65cd019cc27537"); + } + + #[test] + fn test_eip712_ploymarket_hash() { + let json_str = include_str!("./test/eip712_polymarket.json"); + + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Polygon, + sign_type: SignDigestType::Eip712, + data: json_str.as_bytes().to_vec(), + }); + let preview = decoder.preview().unwrap(); + assert_eq!( + preview, + MessagePreview::EIP712(GemEIP712Message { + domain: EIP712Domain { + name: Some("ClobAuthDomain".to_string()), + version: Some("1".to_string()), + chain_id: Some(137), + verifying_contract: None, + salts: None, + }, + message: vec![GemEIP712Section { + name: "ClobAuth".to_string(), + values: vec![ + GemEIP712Value { + name: "address".to_string(), + value: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value_type: GemEIP712ValueType::Address, + }, + GemEIP712Value { + name: "timestamp".to_string(), + value: "1752326774".to_string(), + value_type: GemEIP712ValueType::Timestamp, + }, + GemEIP712Value { + name: "nonce".to_string(), + value: "0".to_string(), + value_type: GemEIP712ValueType::Text, + }, + GemEIP712Value { + name: "message".to_string(), + value: "This message attests that I control the given wallet".to_string(), + value_type: GemEIP712ValueType::Text, + }, + ], + }], + }) + ); + } + + #[test] + fn test_siwe_preview() { + let message = [ + "login.xyz wants you to sign in with your Ethereum account:", + "0x6dD7802E6d44bE89a789C4bD60bD511B68F41c7c", + "", + "URI: https://login.xyz", + "Version: 1", + "Chain ID: 1", + "Nonce: 8hK9pX32", + "Issued At: 2024-04-01T12:00:00Z", + ] + .join("\n"); + + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Siwe, + data: message.as_bytes().to_vec(), + }); + + match decoder.preview() { + Ok(MessagePreview::Siwe(siwe)) => { + assert_eq!(siwe.domain, "login.xyz"); + assert_eq!(siwe.chain_id, 1); + } + other => panic!("Unexpected result: {other:?}"), + } + + assert_eq!(decoder.plain_preview(), message); + } + + #[test] + fn test_siwe_preview_falls_back_to_text_when_validation_fails() { + let message = [ + "login.xyz wants you to sign in with your Ethereum account:", + "0x6dD7802E6d44bE89a789C4bD60bD511B68F41c7c", + "", + "URI: https://login.xyz", + "Version: 1", + "Chain ID: 137", + "Nonce: 8hK9pX32", + "Issued At: 2024-04-01T12:00:00Z", + ] + .join("\n"); + + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Siwe, + data: message.as_bytes().to_vec(), + }); + + match decoder.preview() { + Ok(MessagePreview::Text(preview)) => assert_eq!(preview, message), + other => panic!("Unexpected result: {other:?}"), + } + } + + #[test] + fn test_eip191_siwe_preview_falls_back_to_text_on_chain_mismatch() { + let message = [ + "thepoc.xyz wants you to sign in with your Ethereum account:", + "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4", + "", + "Sign in with different chain ID", + "", + "URI: https://thepoc.xyz", + "Version: 1", + "Chain ID: 137", + "Nonce: byjof9dwrao97skautdxhb", + "Issued At: 2026-03-09T15:48:34.458Z", + ] + .join("\n"); + + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data: message.as_bytes().to_vec(), + }); + + match decoder.preview() { + Ok(MessagePreview::Text(preview)) => assert_eq!(preview, message), + other => panic!("Expected text preview, got {other:?}"), + } + } + + #[test] + fn test_eip191_siwe_payload_preview_keeps_structured_fields_on_chain_mismatch() { + let message = [ + "thepoc.xyz wants you to sign in with your Ethereum account:", + "0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4", + "", + "Sign in with different chain ID", + "", + "URI: https://thepoc.xyz", + "Version: 1", + "Chain ID: 137", + "Nonce: byjof9dwrao97skautdxhb", + "Issued At: 2026-03-09T15:48:34.458Z", + ] + .join("\n"); + + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip191, + data: message.as_bytes().to_vec(), + }); + + let payload_preview = decoder.payload_preview(vec![]).unwrap().expect("expected SIWE payload preview"); + assert_eq!(payload_preview.primary.len(), 2); + assert_eq!(payload_preview.primary[0].label.as_deref(), Some("domain")); + assert_eq!(payload_preview.primary[1].label.as_deref(), Some("address")); + } + + #[test] + fn test_ton_personal_preview() { + let ton_data = TonSignMessageData::from_value( + serde_json::json!({"type": "text", "text": "Hello TON"}), + "example.com".to_string(), + "UQBY1cVPu4SIr36q0M3HWcqPb_efyVVRBsEzmwN-wKQDR6zg".to_string(), + ) + .unwrap(); + let data = String::from_utf8(ton_data.to_bytes()).unwrap(); + let decoder = MessageSigner::new(SignMessage { + chain: Chain::Ton, + sign_type: SignDigestType::TonPersonal, + data: data.as_bytes().to_vec(), + }); + + match decoder.preview() { + Ok(MessagePreview::Text(preview)) => assert_eq!(preview, "Hello TON"), + other => panic!("Unexpected result: {other:?}"), + } + + assert_eq!(decoder.plain_preview(), "Hello TON"); + } + + #[test] + fn test_ton_hash_and_sign_share_timestamp() { + let sender_address = TonSigner::new(&TEST_PRIVATE_KEY).unwrap().address().encode(); + let ton_data = TonSignMessageData::from_value( + serde_json::json!({"type": "text", "text": "timestamp consistency"}), + "example.com".to_string(), + sender_address, + ) + .unwrap(); + let data = ton_data.to_bytes(); + let signer = MessageSigner::new(SignMessage { + chain: Chain::Ton, + sign_type: SignDigestType::TonPersonal, + data: data.clone(), + }); + + let previewed = signer.hash().unwrap(); + let response: serde_json::Value = serde_json::from_str(&signer.sign(TEST_PRIVATE_KEY.to_vec()).unwrap()).unwrap(); + let signed_timestamp = response["timestamp"].as_u64().unwrap(); + + let re_hashed = TonSignMessageData::from_bytes(&data).unwrap().hash(signed_timestamp).unwrap(); + assert_eq!(previewed, re_hashed); + } +} diff --git a/core/gemstone/src/message/test/eip712_polymarket.json b/core/gemstone/src/message/test/eip712_polymarket.json new file mode 100644 index 0000000000..8299d3afe6 --- /dev/null +++ b/core/gemstone/src/message/test/eip712_polymarket.json @@ -0,0 +1,48 @@ +{ + "types": { + "ClobAuth": [ + { + "name": "address", + "type": "address" + }, + { + "name": "timestamp", + "type": "string" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "message", + "type": "string" + } + ], + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + } + ] + }, + "domain": { + "name": "ClobAuthDomain", + "version": "1", + "chainId": "137" + }, + "primaryType": "ClobAuth", + "message": { + "address": "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7", + "timestamp": "1752326774", + "nonce": "0", + "message": "This message attests that I control the given wallet" + } +} \ No newline at end of file diff --git a/core/gemstone/src/message/test/eip712_seaport.json b/core/gemstone/src/message/test/eip712_seaport.json new file mode 100644 index 0000000000..6da37480d9 --- /dev/null +++ b/core/gemstone/src/message/test/eip712_seaport.json @@ -0,0 +1,111 @@ +{ + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OrderComponents": [ + { + "name": "offerer", + "type": "address" + }, + { + "name": "zone", + "type": "address" + }, + { + "name": "offer", + "type": "OfferItem[]" + }, + { + "name": "startTime", + "type": "uint256" + }, + { + "name": "endTime", + "type": "uint256" + }, + { + "name": "zoneHash", + "type": "bytes32" + }, + { + "name": "salt", + "type": "uint256" + }, + { + "name": "conduitKey", + "type": "bytes32" + }, + { + "name": "counter", + "type": "uint256" + } + ], + "OfferItem": [ + { + "name": "token", + "type": "address" + } + ], + "ConsiderationItem": [ + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ] + }, + "primaryType": "OrderComponents", + "domain": { + "name": "Seaport", + "version": "1.1", + "chainId": "1", + "verifyingContract": "0x00000000006c3852cbEf3e08E8dF289169EdE581" + }, + "message": { + "offerer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "offer": [ + { + "token": "0xA604060890923Ff400e8c6f5290461A83AEDACec" + } + ], + "startTime": "1658645591", + "endTime": "1659250386", + "zone": "0x004C00500000aD104D7DBd00e3ae0A5C00560C00", + "zoneHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "salt": "16178208897136618", + "conduitKey": "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + "totalOriginalConsiderationItems": "2", + "counter": "0" + } +} \ No newline at end of file diff --git a/core/gemstone/src/models/asset.rs b/core/gemstone/src/models/asset.rs new file mode 100644 index 0000000000..5091aa133c --- /dev/null +++ b/core/gemstone/src/models/asset.rs @@ -0,0 +1,57 @@ +use primitives::{Asset, AssetId, AssetScore, AssetType, Chain}; + +pub type GemAsset = Asset; +pub type GemAssetId = AssetId; +pub type GemAssetType = AssetType; + +#[allow(non_camel_case_types)] +#[uniffi::remote(Enum)] +pub enum GemAssetType { + NATIVE, + ERC20, + BEP20, + SPL, + SPL2022, + TRC20, + TOKEN, + IBC, + JETTON, + SYNTH, + ASA, + PERPETUAL, + SPOT, +} + +#[uniffi::remote(Record)] +pub struct GemAsset { + pub id: GemAssetId, + pub chain: Chain, + pub token_id: Option, + pub name: String, + pub symbol: String, + pub decimals: i32, + pub asset_type: GemAssetType, +} + +pub fn get_default_rank(chain: Chain) -> i32 { + chain.rank() +} + +pub fn get_asset(chain: Chain) -> GemAsset { + Asset::from_chain(chain) +} + +#[uniffi::export] +pub fn asset_default_rank(chain: Chain) -> i32 { + get_default_rank(chain) +} + +#[uniffi::export] +pub fn default_token_rank() -> i32 { + AssetScore::default().rank +} + +#[uniffi::export] +pub fn asset_wrapper(chain: Chain) -> GemAsset { + get_asset(chain) +} diff --git a/core/gemstone/src/models/balance.rs b/core/gemstone/src/models/balance.rs new file mode 100644 index 0000000000..0af403cc3b --- /dev/null +++ b/core/gemstone/src/models/balance.rs @@ -0,0 +1,37 @@ +use crate::models::custom_types::GemBigUint; +use primitives::{AssetBalance, AssetId, Balance, asset_balance::BalanceMetadata}; + +pub type GemAssetBalance = AssetBalance; +pub type GemBalance = Balance; +pub type GemBalanceMetadata = BalanceMetadata; + +#[uniffi::remote(Record)] +pub struct GemAssetBalance { + pub asset_id: AssetId, + pub balance: GemBalance, + pub is_active: bool, +} + +#[uniffi::remote(Record)] +pub struct GemBalance { + pub available: GemBigUint, + pub frozen: GemBigUint, + pub locked: GemBigUint, + pub staked: GemBigUint, + pub pending: GemBigUint, + pub pending_unconfirmed: GemBigUint, + pub rewards: GemBigUint, + pub reserved: GemBigUint, + pub earn: GemBigUint, + pub withdrawable: GemBigUint, + pub metadata: Option, +} + +#[uniffi::remote(Record)] +pub struct GemBalanceMetadata { + pub votes: u32, + pub energy_available: u32, + pub energy_total: u32, + pub bandwidth_available: u32, + pub bandwidth_total: u32, +} diff --git a/core/gemstone/src/models/custom_types.rs b/core/gemstone/src/models/custom_types.rs new file mode 100644 index 0000000000..1eadce17f7 --- /dev/null +++ b/core/gemstone/src/models/custom_types.rs @@ -0,0 +1,62 @@ +use chrono::{DateTime, Utc}; +use num_bigint::{BigInt, BigUint}; +use primitives::{AssetId, Chain, NFTAssetId, NFTCollectionId, PerpetualId}; +use std::str::FromStr; + +uniffi::custom_type!(Chain, String, { + remote, + lower: |s| s.to_string(), + try_lift: |s| Chain::from_str(&s).map_err(|_| uniffi::deps::anyhow::Error::msg("Invalid Chain")), +}); + +uniffi::custom_type!(AssetId, String, { + remote, + lower: |s| s.to_string(), + try_lift: |s| AssetId::new(&s).ok_or_else(|| uniffi::deps::anyhow::Error::msg("Invalid AssetId")), +}); + +uniffi::custom_type!(NFTAssetId, String, { + remote, + lower: |s| s.to_string(), + try_lift: |s| NFTAssetId::from_str(&s).map_err(|_| uniffi::deps::anyhow::Error::msg("Invalid NFTAssetId")), +}); + +uniffi::custom_type!(NFTCollectionId, String, { + remote, + lower: |s| s.to_string(), + try_lift: |s| NFTCollectionId::from_str(&s).map_err(|_| uniffi::deps::anyhow::Error::msg("Invalid NFTCollectionId")), +}); + +uniffi::custom_type!(PerpetualId, String, { + remote, + lower: |s| s.to_string(), + try_lift: |s| PerpetualId::from_str(&s).map_err(|_| uniffi::deps::anyhow::Error::msg("Invalid PerpetualId")), +}); + +pub type GemBigInt = BigInt; +pub type GemBigUint = BigUint; + +uniffi::custom_type!(GemBigInt, String, { + remote, + lower: |value| value.to_string(), + try_lift: |s| BigInt::from_str(&s) + .map_err(|_| uniffi::deps::anyhow::Error::msg("Invalid BigInt")), +}); + +uniffi::custom_type!(GemBigUint, String, { + remote, + lower: |value| value.to_string(), + try_lift: |s| BigUint::from_str(&s) + .map_err(|_| uniffi::deps::anyhow::Error::msg("Invalid BigUint")), +}); + +pub type DateTimeUtc = DateTime; + +uniffi::custom_type!(DateTimeUtc, i64, { + remote, + lower: |value: DateTimeUtc| value.timestamp(), + try_lift: |timestamp| { + DateTime::::from_timestamp(timestamp, 0) + .ok_or_else(|| uniffi::deps::anyhow::Error::msg("Invalid timestamp")) + }, +}); diff --git a/core/gemstone/src/models/gateway.rs b/core/gemstone/src/models/gateway.rs new file mode 100644 index 0000000000..069bdbfd70 --- /dev/null +++ b/core/gemstone/src/models/gateway.rs @@ -0,0 +1,89 @@ +use crate::models::GemTransactionInputType; +use primitives::{BroadcastOptions, FeeRate, GasPriceType, TransactionPreloadInput, UTXO}; + +pub type GemUTXO = UTXO; + +#[uniffi::remote(Record)] +pub struct GemUTXO { + pub transaction_id: String, + pub vout: i32, + pub value: String, + pub address: String, +} + +pub type GemBroadcastOptions = BroadcastOptions; + +#[uniffi::remote(Record)] +pub struct BroadcastOptions { + pub skip_preflight: bool, +} + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum GemGasPriceType { + Regular { gas_price: String }, + Eip1559 { gas_price: String, priority_fee: String }, + Solana { gas_price: String, priority_fee: String, unit_price: String }, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemFeeRate { + pub priority: String, + pub gas_price_type: GemGasPriceType, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemTransactionPreloadInput { + pub input_type: GemTransactionInputType, + pub sender_address: String, + pub destination_address: String, +} + +impl From for GemGasPriceType { + fn from(value: GasPriceType) -> Self { + match value { + GasPriceType::Regular { gas_price } => GemGasPriceType::Regular { gas_price: gas_price.to_string() }, + GasPriceType::Eip1559 { gas_price, priority_fee } => GemGasPriceType::Eip1559 { + gas_price: gas_price.to_string(), + priority_fee: priority_fee.to_string(), + }, + GasPriceType::Solana { + gas_price, + priority_fee, + unit_price, + } => GemGasPriceType::Solana { + gas_price: gas_price.to_string(), + priority_fee: priority_fee.to_string(), + unit_price: unit_price.to_string(), + }, + } + } +} + +impl From for GemFeeRate { + fn from(fee: FeeRate) -> Self { + Self { + priority: fee.priority.as_ref().to_string(), + gas_price_type: fee.gas_price_type.into(), + } + } +} + +impl From for GemTransactionPreloadInput { + fn from(input: TransactionPreloadInput) -> Self { + Self { + input_type: input.input_type.into(), + sender_address: input.sender_address, + destination_address: input.destination_address, + } + } +} + +impl From for TransactionPreloadInput { + fn from(input: GemTransactionPreloadInput) -> Self { + Self { + input_type: input.input_type.into(), + sender_address: input.sender_address, + destination_address: input.destination_address, + } + } +} diff --git a/core/gemstone/src/models/mod.rs b/core/gemstone/src/models/mod.rs new file mode 100644 index 0000000000..2cf7d42b6e --- /dev/null +++ b/core/gemstone/src/models/mod.rs @@ -0,0 +1,27 @@ +pub mod asset; +pub mod balance; +mod custom_types; +pub mod gateway; +pub mod nft; +pub mod node; +pub mod perpetual; +pub mod portfolio; +pub mod scan; +pub mod simulation; +pub mod stake; +pub mod swap; +pub mod token; +pub mod transaction; + +pub use asset::*; +pub use balance::*; +pub use gateway::*; +pub use nft::*; +pub use node::*; +pub use perpetual::*; +pub use portfolio::*; +pub use scan::*; +pub use simulation::*; +pub use stake::*; +pub use token::*; +pub use transaction::*; diff --git a/core/gemstone/src/models/nft.rs b/core/gemstone/src/models/nft.rs new file mode 100644 index 0000000000..c2d4cf1e1a --- /dev/null +++ b/core/gemstone/src/models/nft.rs @@ -0,0 +1,57 @@ +use primitives::Chain; +use primitives::nft::{NFTAsset, NFTAttribute, NFTAttributeType, NFTImages, NFTResource, NFTType}; + +pub type GemNFTAttribute = NFTAttribute; +pub type GemNFTAttributeType = NFTAttributeType; +pub type GemNFTType = NFTType; +pub type GemNFTResource = NFTResource; +pub type GemNFTImages = NFTImages; +pub type GemNFTAsset = NFTAsset; + +#[uniffi::remote(Enum)] +pub enum GemNFTAttributeType { + String, + Timestamp, +} + +#[uniffi::remote(Enum)] +pub enum GemNFTType { + ERC721, + ERC1155, + SPL, + JETTON, +} + +#[uniffi::remote(Record)] +pub struct GemNFTResource { + pub url: String, + pub mime_type: String, +} + +#[uniffi::remote(Record)] +pub struct GemNFTImages { + pub preview: GemNFTResource, +} + +#[uniffi::remote(Record)] +pub struct GemNFTAttribute { + pub name: String, + pub value: String, + pub value_type: Option, + pub percentage: Option, +} + +#[uniffi::remote(Record)] +pub struct GemNFTAsset { + pub id: primitives::NFTAssetId, + pub collection_id: primitives::NFTCollectionId, + pub contract_address: Option, + pub token_id: String, + pub token_type: GemNFTType, + pub name: String, + pub description: Option, + pub chain: Chain, + pub resource: GemNFTResource, + pub images: GemNFTImages, + pub attributes: Vec, +} diff --git a/core/gemstone/src/models/node.rs b/core/gemstone/src/models/node.rs new file mode 100644 index 0000000000..c6fb4f008f --- /dev/null +++ b/core/gemstone/src/models/node.rs @@ -0,0 +1,10 @@ +use primitives::NodeStatus; + +pub type GemNodeStatus = NodeStatus; + +#[uniffi::remote(Record)] +pub struct NodeStatus { + pub chain_id: String, + pub latest_block_number: u64, + pub latency_ms: u64, +} diff --git a/core/gemstone/src/models/perpetual.rs b/core/gemstone/src/models/perpetual.rs new file mode 100644 index 0000000000..91222f4f0f --- /dev/null +++ b/core/gemstone/src/models/perpetual.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use gem_hypercore::models::order::OpenOrder; +use gem_hypercore::models::websocket::{HyperliquidSocketMessage, PositionsDiff}; +use primitives::{ + Asset, AssetId, PerpetualDirection, PerpetualId, PerpetualMarginType, PerpetualMarketData, PerpetualOrderType, PerpetualPosition, PerpetualProvider, PerpetualTriggerOrder, + chart::{ChartCandleStick, ChartCandleUpdate, ChartDateValue}, + perpetual::{Perpetual, PerpetualBalance, PerpetualData, PerpetualMetadata, PerpetualPositionsSummary}, +}; + +pub type GemHyperliquidOpenOrder = OpenOrder; +pub type GemPositionsDiff = PositionsDiff; +pub type GemPerpetualMarginType = PerpetualMarginType; +pub type GemPerpetualOrderType = PerpetualOrderType; +pub type GemPerpetualPositionsSummary = PerpetualPositionsSummary; +pub type GemPerpetualBalance = PerpetualBalance; +pub type GemPerpetualPosition = PerpetualPosition; +pub type GemPerpetual = Perpetual; +pub type GemPerpetualMetadata = PerpetualMetadata; +pub type GemChartCandleStick = ChartCandleStick; +pub type GemChartCandleUpdate = ChartCandleUpdate; +pub type GemChartDateValue = ChartDateValue; +pub type GemPerpetualData = PerpetualData; +pub type GemPerpetualMarketData = PerpetualMarketData; + +#[uniffi::remote(Enum)] +pub enum GemPerpetualMarginType { + Cross, + Isolated, +} + +#[uniffi::remote(Enum)] +pub enum GemPerpetualOrderType { + Market, + Limit, +} + +pub type GemPerpetualTriggerOrder = PerpetualTriggerOrder; + +#[uniffi::remote(Record)] +pub struct GemPerpetualTriggerOrder { + pub price: f64, + pub order_type: PerpetualOrderType, + pub order_id: String, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetualPositionsSummary { + pub positions: Vec, + pub balance: PerpetualBalance, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetualBalance { + pub available: f64, + pub reserved: f64, + pub withdrawable: f64, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetualPosition { + pub id: String, + pub perpetual_id: PerpetualId, + pub asset_id: AssetId, + pub size: f64, + pub size_value: f64, + pub leverage: u8, + pub entry_price: f64, + pub liquidation_price: Option, + pub margin_type: PerpetualMarginType, + pub direction: PerpetualDirection, + pub margin_amount: f64, + pub take_profit: Option, + pub stop_loss: Option, + pub pnl: f64, + pub funding: Option, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetualData { + pub perpetual: Perpetual, + pub asset: Asset, + pub metadata: PerpetualMetadata, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetualMarketData { + pub coin: String, + pub price: f64, + pub price_percent_change_24h: f64, + pub open_interest: f64, + pub volume_24h: f64, + pub funding: f64, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetual { + pub id: PerpetualId, + pub name: String, + pub provider: PerpetualProvider, + pub asset_id: AssetId, + pub identifier: String, + pub price: f64, + pub price_percent_change_24h: f64, + pub open_interest: f64, + pub volume_24h: f64, + pub funding: f64, + pub max_leverage: u8, + pub is_isolated_only: bool, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetualMetadata { + pub is_pinned: bool, +} + +#[uniffi::remote(Record)] +pub struct GemChartCandleStick { + pub date: DateTime, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: f64, +} + +#[uniffi::remote(Record)] +pub struct GemChartCandleUpdate { + pub coin: String, + pub interval: String, + pub candle: ChartCandleStick, +} + +#[uniffi::remote(Record)] +pub struct GemChartDateValue { + pub date: DateTime, + pub value: f64, +} + +#[uniffi::remote(Record)] +pub struct GemPositionsDiff { + pub delete_position_ids: Vec, + pub positions: Vec, +} + +#[uniffi::remote(Record)] +pub struct GemHyperliquidOpenOrder { + pub coin: String, + pub oid: u64, + pub trigger_px: Option, + pub limit_px: Option, + pub is_position_tpsl: bool, + pub order_type: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, uniffi::Enum)] +pub enum GemSubscriptionMethod { + Subscribe, + Unsubscribe, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, uniffi::Enum)] +pub enum GemPerpetualSubscription { + AccountState { address: String }, + OpenOrders { address: String }, + Candle { symbol: String, interval: String }, + MarketData { symbol: String }, + MarketPrices, +} + +pub type GemHyperliquidSocketMessage = HyperliquidSocketMessage; + +#[uniffi::remote(Enum)] +pub enum GemHyperliquidSocketMessage { + AccountState { balance: PerpetualBalance, positions: Vec }, + OpenOrders { orders: Vec }, + Candle { candle: ChartCandleUpdate }, + MarketData { market: GemPerpetualMarketData }, + MarketPrices { prices: HashMap }, + SubscriptionResponse { subscription_type: String }, + Unknown, +} diff --git a/core/gemstone/src/models/portfolio.rs b/core/gemstone/src/models/portfolio.rs new file mode 100644 index 0000000000..3cfc172bfa --- /dev/null +++ b/core/gemstone/src/models/portfolio.rs @@ -0,0 +1,32 @@ +use primitives::{ + chart::ChartDateValue, + portfolio::{PerpetualAccountSummary, PerpetualPortfolio, PerpetualPortfolioTimeframeData}, +}; + +pub type GemPerpetualPortfolio = PerpetualPortfolio; +pub type GemPerpetualPortfolioTimeframeData = PerpetualPortfolioTimeframeData; +pub type GemPerpetualAccountSummary = PerpetualAccountSummary; + +#[uniffi::remote(Record)] +pub struct GemPerpetualPortfolioTimeframeData { + pub account_value_history: Vec, + pub pnl_history: Vec, + pub volume: f64, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetualAccountSummary { + pub account_value: f64, + pub account_leverage: f64, + pub margin_usage: f64, + pub unrealized_pnl: f64, +} + +#[uniffi::remote(Record)] +pub struct GemPerpetualPortfolio { + pub day: Option, + pub week: Option, + pub month: Option, + pub all_time: Option, + pub account_summary: Option, +} diff --git a/core/gemstone/src/models/scan.rs b/core/gemstone/src/models/scan.rs new file mode 100644 index 0000000000..cdf77565ba --- /dev/null +++ b/core/gemstone/src/models/scan.rs @@ -0,0 +1,26 @@ +use primitives::{AssetId, ScanAddressTarget, ScanTransaction, ScanTransactionPayload, TransactionType}; + +pub type GemScanTransaction = ScanTransaction; +pub type GemScanTransactionPayload = ScanTransactionPayload; +pub type GemScanAddressTarget = ScanAddressTarget; + +#[uniffi::remote(Record)] +pub struct ScanTransaction { + pub is_malicious: bool, + pub is_memo_required: bool, +} + +#[uniffi::remote(Record)] +pub struct ScanTransactionPayload { + pub origin: ScanAddressTarget, + pub target: ScanAddressTarget, + pub website: Option, + #[serde(rename = "type")] + pub transaction_type: TransactionType, +} + +#[uniffi::remote(Record)] +pub struct ScanAddressTarget { + pub asset_id: AssetId, + pub address: String, +} diff --git a/core/gemstone/src/models/simulation.rs b/core/gemstone/src/models/simulation.rs new file mode 100644 index 0000000000..c81ffbf428 --- /dev/null +++ b/core/gemstone/src/models/simulation.rs @@ -0,0 +1,89 @@ +use crate::models::custom_types::GemBigInt; +use primitives::{ + AssetId, SimulationBalanceChange, SimulationHeader, SimulationPayloadField, SimulationPayloadFieldDisplay, SimulationPayloadFieldKind, SimulationPayloadFieldType, + SimulationResult, SimulationSeverity, SimulationWarning, SimulationWarningApproval, SimulationWarningType, +}; + +#[uniffi::remote(Enum)] +pub enum SimulationSeverity { + Low, + Warning, + Critical, +} + +#[uniffi::remote(Record)] +pub struct SimulationWarningApproval { + pub asset_id: AssetId, + pub value: Option, +} + +#[uniffi::remote(Enum)] +pub enum SimulationWarningType { + TokenApproval(SimulationWarningApproval), + SuspiciousSpender, + ExternallyOwnedSpender, + NftCollectionApproval(AssetId), + PermitApproval(SimulationWarningApproval), + PermitBatchApproval(Option), + ValidationError, +} + +#[uniffi::remote(Record)] +pub struct SimulationWarning { + pub severity: SimulationSeverity, + pub warning: SimulationWarningType, + pub message: Option, +} + +#[uniffi::remote(Record)] +pub struct SimulationBalanceChange { + pub asset_id: AssetId, + pub value: String, +} + +#[uniffi::remote(Enum)] +pub enum SimulationPayloadFieldType { + Text, + Address, + Timestamp, +} + +#[uniffi::remote(Enum)] +pub enum SimulationPayloadFieldDisplay { + Primary, + Secondary, +} + +#[uniffi::remote(Enum)] +pub enum SimulationPayloadFieldKind { + Contract, + Method, + Token, + Spender, + Value, + Custom, +} + +#[uniffi::remote(Record)] +pub struct SimulationPayloadField { + pub kind: SimulationPayloadFieldKind, + pub label: Option, + pub value: String, + pub field_type: SimulationPayloadFieldType, + pub display: SimulationPayloadFieldDisplay, +} + +#[uniffi::remote(Record)] +pub struct SimulationHeader { + pub asset_id: AssetId, + pub value: String, + pub is_unlimited: bool, +} + +#[uniffi::remote(Record)] +pub struct SimulationResult { + pub warnings: Vec, + pub balance_changes: Vec, + pub payload: Vec, + pub header: Option, +} diff --git a/core/gemstone/src/models/stake.rs b/core/gemstone/src/models/stake.rs new file mode 100644 index 0000000000..21593321eb --- /dev/null +++ b/core/gemstone/src/models/stake.rs @@ -0,0 +1,98 @@ +use crate::models::custom_types::{DateTimeUtc, GemBigUint}; +use primitives::stake_type::Resource; +use primitives::{AssetId, Chain, Delegation, DelegationBase, DelegationState, DelegationValidator, Price, PriceProvider, StakeChain, StakeProviderType}; + +pub type GemResource = Resource; +pub type GemDelegation = Delegation; +pub type GemDelegationBase = DelegationBase; +pub type GemDelegationValidator = DelegationValidator; +pub type GemDelegationState = DelegationState; +pub type GemStakeProviderType = StakeProviderType; +pub type GemPrice = Price; +pub type GemPriceProvider = PriceProvider; +pub type GemStakeChain = StakeChain; + +#[uniffi::remote(Enum)] +pub enum GemResource { + Bandwidth, + Energy, +} + +#[uniffi::remote(Enum)] +pub enum GemStakeChain { + Cosmos, + Osmosis, + Injective, + Sei, + Celestia, + Ethereum, + Solana, + Sui, + SmartChain, + Monad, + Tron, + Aptos, + HyperCore, +} + +#[uniffi::remote(Enum)] +pub enum GemDelegationState { + Active, + Pending, + Inactive, + Activating, + Deactivating, + AwaitingWithdrawal, +} + +#[uniffi::remote(Enum)] +pub enum GemStakeProviderType { + Stake, + Earn, +} + +#[uniffi::remote(Record)] +pub struct GemDelegationValidator { + pub chain: Chain, + pub id: String, + pub name: String, + pub is_active: bool, + pub commission: f64, + pub apr: f64, + pub provider_type: GemStakeProviderType, +} + +#[uniffi::remote(Record)] +pub struct GemDelegationBase { + pub asset_id: AssetId, + pub state: GemDelegationState, + pub balance: GemBigUint, + pub shares: GemBigUint, + pub rewards: GemBigUint, + pub completion_date: Option, + pub delegation_id: String, + pub validator_id: String, +} + +#[uniffi::remote(Enum)] +pub enum GemPriceProvider { + Coingecko, + Pyth, + Jupiter, + DefiLlama, +} + +#[uniffi::remote(Record)] +pub struct GemPrice { + pub price: f64, + pub price_change_percentage_24h: f64, + pub updated_at: DateTimeUtc, + pub provider: PriceProvider, +} + +#[uniffi::remote(Record)] +pub struct GemDelegation { + pub base: GemDelegationBase, + pub validator: GemDelegationValidator, + pub price: Option, +} diff --git a/core/gemstone/src/models/swap.rs b/core/gemstone/src/models/swap.rs new file mode 100644 index 0000000000..20e47e0367 --- /dev/null +++ b/core/gemstone/src/models/swap.rs @@ -0,0 +1,167 @@ +use crate::config::swap_config::get_swap_config; +use primitives::swap::SwapQuoteDataType; +pub use primitives::swap::{ApprovalData, SwapData, SwapProviderData, SwapQuote, SwapQuoteData}; +pub use swapper::SwapperProvider; + +pub type GemApprovalData = ApprovalData; +pub type GemSwapData = SwapData; +pub type GemSwapProviderData = SwapProviderData; +pub type GemSwapQuote = SwapQuote; +pub type GemSwapQuoteData = SwapQuoteData; +pub type GemSwapQuoteDataType = SwapQuoteDataType; + +#[uniffi::remote(Record)] +pub struct GemApprovalData { + pub token: String, + pub spender: String, + pub value: String, + pub is_unlimited: bool, +} + +#[uniffi::remote(Enum)] +pub enum GemSwapQuoteDataType { + Contract, + Transfer, +} + +#[uniffi::remote(Record)] +pub struct GemSwapData { + pub quote: GemSwapQuote, + pub data: GemSwapQuoteData, +} + +#[uniffi::remote(Record)] +pub struct GemSwapQuote { + pub from_address: String, + pub from_value: String, + pub min_from_value: Option, + pub to_address: String, + pub to_value: String, + pub provider_data: GemSwapProviderData, + pub slippage_bps: u32, + pub eta_in_seconds: Option, + pub use_max_amount: Option, +} + +#[uniffi::remote(Record)] +pub struct GemSwapQuoteData { + pub to: String, + pub data_type: GemSwapQuoteDataType, + pub value: String, + pub data: String, + pub memo: Option, + pub approval: Option, + pub gas_limit: Option, +} + +#[uniffi::remote(Record)] +pub struct GemSwapProviderData { + pub provider: SwapperProvider, + pub name: String, + pub protocol_name: String, +} + +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum SwapPriceImpactType { + Positive, + Low, + Medium, + High, +} + +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct SwapPriceImpact { + pub percentage: f64, + pub impact_type: SwapPriceImpactType, + pub is_high: bool, +} + +#[uniffi::export] +pub fn calculate_swap_price_impact(pay_fiat_value: f64, receive_fiat_value: f64) -> Option { + if pay_fiat_value <= 0.0 || receive_fiat_value <= 0.0 || !pay_fiat_value.is_finite() || !receive_fiat_value.is_finite() { + return None; + } + + let percentage = ((receive_fiat_value / pay_fiat_value) - 1.0) * 100.0; + let rounded_percentage = round_to_places(percentage, 2); + let impact_type = match rounded_percentage { + value if value > 0.0 => SwapPriceImpactType::Positive, + value if value >= -1.0 => SwapPriceImpactType::Low, + value if value >= -5.0 => SwapPriceImpactType::Medium, + _ => SwapPriceImpactType::High, + }; + + Some(SwapPriceImpact { + percentage, + impact_type, + is_high: rounded_percentage.abs() >= get_swap_config().high_price_impact_percent as f64, + }) +} + +fn round_to_places(value: f64, places: i32) -> f64 { + let factor = 10_f64.powi(places); + (value * factor).round() / factor +} + +#[cfg(test)] +mod tests { + use super::{SwapPriceImpact, SwapPriceImpactType, calculate_swap_price_impact, round_to_places}; + + #[test] + fn test_calculate_swap_price_impact() { + assert_eq!(calculate_swap_price_impact(0.0, 100.0), None); + assert_eq!(calculate_swap_price_impact(100.0, 0.0), None); + + assert_eq!( + calculate_swap_price_impact(100.0, 100.5).map(|impact| SwapPriceImpact { + percentage: round_to_places(impact.percentage, 2), + impact_type: impact.impact_type, + is_high: impact.is_high, + }), + Some(SwapPriceImpact { + percentage: 0.5, + impact_type: SwapPriceImpactType::Positive, + is_high: false, + }) + ); + + assert_eq!( + calculate_swap_price_impact(100.0, 99.0).map(|impact| SwapPriceImpact { + percentage: round_to_places(impact.percentage, 2), + impact_type: impact.impact_type, + is_high: impact.is_high, + }), + Some(SwapPriceImpact { + percentage: -1.0, + impact_type: SwapPriceImpactType::Low, + is_high: false, + }) + ); + + assert_eq!( + calculate_swap_price_impact(100.0, 95.0).map(|impact| SwapPriceImpact { + percentage: round_to_places(impact.percentage, 2), + impact_type: impact.impact_type, + is_high: impact.is_high, + }), + Some(SwapPriceImpact { + percentage: -5.0, + impact_type: SwapPriceImpactType::Medium, + is_high: false, + }) + ); + + assert_eq!( + calculate_swap_price_impact(100.0, 89.0).map(|impact| SwapPriceImpact { + percentage: round_to_places(impact.percentage, 2), + impact_type: impact.impact_type, + is_high: impact.is_high, + }), + Some(SwapPriceImpact { + percentage: -11.0, + impact_type: SwapPriceImpactType::High, + is_high: true, + }) + ); + } +} diff --git a/core/gemstone/src/models/token.rs b/core/gemstone/src/models/token.rs new file mode 100644 index 0000000000..8bd6a4c12f --- /dev/null +++ b/core/gemstone/src/models/token.rs @@ -0,0 +1,18 @@ +use primitives::solana_nft::SolanaNftStandard; +use primitives::solana_token_program::SolanaTokenProgramId; + +pub type GemSolanaTokenProgramId = SolanaTokenProgramId; +pub type GemSolanaNftStandard = SolanaNftStandard; + +#[uniffi::remote(Enum)] +pub enum SolanaTokenProgramId { + Token, + Token2022, +} + +#[uniffi::remote(Enum)] +pub enum SolanaNftStandard { + NonFungible, + ProgrammableNonFungible { rule_set: Option }, + Core { collection: Option }, +} diff --git a/core/gemstone/src/models/transaction.rs b/core/gemstone/src/models/transaction.rs new file mode 100644 index 0000000000..05c52731ed --- /dev/null +++ b/core/gemstone/src/models/transaction.rs @@ -0,0 +1,893 @@ +use crate::models::*; +use chrono::{DateTime, Utc}; +use num_bigint::BigInt; +use primitives::contract_call_data::ContractCallData; +use primitives::{ + AccountDataType, Asset, Chain, EarnType, FeeOption, GasPriceType, HyperliquidOrder, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType, PerpetualProvider, + PerpetualType, Resource, SignerInput, StakeType, TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransactionMetadata, + TransactionPerpetualMetadata, TransactionState, TransactionStateRequest, TransactionSwapMetadata, TransactionType, TransactionUpdate, TransferDataExtra, + TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, UInt64, WalletConnectionSessionAppMetadata, + perpetual::{CancelOrderData, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualReduceData, TPSLOrderData}, +}; +use std::collections::HashMap; +use swap::{GemApprovalData, GemSwapData}; +use swapper::SwapperProvider; + +pub type GemPerpetualDirection = PerpetualDirection; +pub type GemPerpetualProvider = PerpetualProvider; +pub type GemPerpetualConfirmData = PerpetualConfirmData; +pub type GemPerpetualReduceData = PerpetualReduceData; +pub type GemFeeOption = FeeOption; +pub type GemTransferDataOutputType = TransferDataOutputType; +pub type GemTransferDataOutputAction = TransferDataOutputAction; +pub type GemTransactionPerpetualMetadata = TransactionPerpetualMetadata; +pub type GemTransactionMetadata = TransactionMetadata; +pub type GemTransactionState = TransactionState; +pub type GemTransactionChange = TransactionChange; +pub type GemTransactionUpdate = TransactionUpdate; +pub type GemTransactionType = TransactionType; +pub type GemTronVote = TronVote; +pub type GemTronUnfreeze = TronUnfreeze; +pub type GemTronStakeData = TronStakeData; + +#[uniffi::remote(Record)] +pub struct TronVote { + pub validator: String, + pub count: u64, +} + +#[uniffi::remote(Record)] +pub struct TronUnfreeze { + pub resource: Resource, + pub amount: u64, +} + +#[uniffi::remote(Enum)] +pub enum TronStakeData { + Votes(Vec), + Unfreeze(Vec), +} + +#[uniffi::remote(Enum)] +pub enum PerpetualDirection { + Short, + Long, +} + +#[uniffi::remote(Enum)] +pub enum PerpetualProvider { + Hypercore, +} + +#[uniffi::remote(Enum)] +pub enum FeeOption { + TokenAccountCreation, +} + +#[uniffi::remote(Enum)] +pub enum TransferDataOutputType { + EncodedTransaction, + Signature, +} + +#[uniffi::remote(Enum)] +pub enum TransferDataOutputAction { + Sign, + Send, +} + +#[uniffi::remote(Record)] +pub struct TransactionPerpetualMetadata { + pub pnl: f64, + pub price: f64, + pub direction: PerpetualDirection, + pub is_liquidation: Option, + pub provider: Option, +} + +#[uniffi::remote(Enum)] +pub enum TransactionMetadata { + Perpetual(TransactionPerpetualMetadata), + Swap(TransactionSwapMetadata), +} + +#[uniffi::remote(Enum)] +pub enum TransactionState { + Pending, + Confirmed, + InTransit, + Failed, + Reverted, +} + +#[uniffi::remote(Enum)] +pub enum TransactionChange { + HashChange { old: String, new: String }, + Metadata(TransactionMetadata), + BlockNumber(String), + NetworkFee(BigInt), +} + +#[uniffi::remote(Record)] +pub struct TransactionUpdate { + pub state: TransactionState, + pub changes: Vec, +} + +#[uniffi::remote(Enum)] +pub enum TransactionType { + Transfer, + TransferNFT, + Swap, + TokenApproval, + StakeDelegate, + StakeUndelegate, + StakeRewards, + StakeRedelegate, + StakeWithdraw, + StakeFreeze, + StakeUnfreeze, + AssetActivation, + SmartContractCall, + PerpetualOpenPosition, + PerpetualClosePosition, + PerpetualModifyPosition, + EarnDeposit, + EarnWithdraw, +} + +pub type GemAccountDataType = AccountDataType; + +#[uniffi::remote(Enum)] +pub enum GemAccountDataType { + Activate, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemTransactionStateRequest { + pub id: String, + pub sender_address: String, + pub created_at: DateTime, + pub block_number: UInt64, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemTransactionSwapStateRequest { + pub transaction: GemTransactionStateRequest, + pub state: TransactionState, + pub swap_provider: SwapperProvider, + pub destination_chain: Chain, +} + +pub type GemHyperliquidOrder = HyperliquidOrder; + +#[uniffi::remote(Record)] +pub struct GemHyperliquidOrder { + pub approve_agent_required: bool, + pub approve_referral_required: bool, + pub approve_builder_required: bool, + pub builder_fee_bps: u32, + pub agent_address: String, + pub agent_private_key: String, +} + +pub type GemContractCallData = ContractCallData; + +#[uniffi::remote(Record)] +pub struct GemContractCallData { + pub contract_address: String, + pub call_data: String, + pub approval: Option, + pub gas_limit: Option, +} + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum GemStakeType { + Delegate { validator: GemDelegationValidator }, + Undelegate { delegation: GemDelegation }, + Redelegate { delegation: GemDelegation, to_validator: GemDelegationValidator }, + WithdrawRewards { validators: Vec }, + Withdraw { delegation: GemDelegation }, + Freeze { resource: GemResource }, + Unfreeze { resource: GemResource }, +} + +pub type GemEarnType = EarnType; + +#[uniffi::remote(Enum)] +pub enum GemEarnType { + Deposit(GemDelegationValidator), + Withdraw(GemDelegation), +} + +pub type GemWalletConnectionSessionAppMetadata = WalletConnectionSessionAppMetadata; + +#[uniffi::remote(Record)] +pub struct GemWalletConnectionSessionAppMetadata { + pub name: String, + pub description: String, + pub url: String, + pub icon: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemTransferDataExtra { + pub to: String, + pub gas_limit: Option, + pub gas_price: Option, + pub data: Option>, + pub output_type: GemTransferDataOutputType, + pub output_action: GemTransferDataOutputAction, +} + +#[uniffi::remote(Record)] +pub struct PerpetualConfirmData { + pub direction: PerpetualDirection, + pub margin_type: PerpetualMarginType, + pub base_asset: Asset, + pub asset_index: i32, + pub price: String, + pub fiat_value: f64, + pub size: String, + pub slippage: f64, + pub leverage: u8, + pub pnl: Option, + pub entry_price: Option, + pub market_price: f64, + pub margin_amount: f64, + pub take_profit: Option, + pub stop_loss: Option, +} + +#[uniffi::remote(Record)] +pub struct CancelOrderData { + pub asset_index: i32, + pub order_id: u64, +} + +#[uniffi::remote(Record)] +pub struct TPSLOrderData { + pub direction: PerpetualDirection, + pub take_profit: Option, + pub stop_loss: Option, + pub size: String, +} + +#[uniffi::remote(Enum)] +pub enum PerpetualModifyPositionType { + Tpsl(TPSLOrderData), + Cancel(Vec), +} + +#[uniffi::remote(Record)] +pub struct PerpetualModifyConfirmData { + pub base_asset: Asset, + pub asset_index: i32, + pub modify_types: Vec, + pub take_profit_order_id: Option, + pub stop_loss_order_id: Option, +} +#[uniffi::remote(Record)] +pub struct PerpetualReduceData { + pub data: PerpetualConfirmData, + pub position_direction: PerpetualDirection, +} + +pub type GemPerpetualType = PerpetualType; + +#[uniffi::remote(Enum)] +pub enum PerpetualType { + Open(PerpetualConfirmData), + Close(PerpetualConfirmData), + Modify(PerpetualModifyConfirmData), + Increase(PerpetualConfirmData), + Reduce(PerpetualReduceData), +} + +#[derive(Debug, Clone, uniffi::Enum)] +#[allow(clippy::large_enum_variant)] +pub enum GemTransactionInputType { + Transfer { + asset: GemAsset, + }, + Deposit { + asset: GemAsset, + }, + Swap { + from_asset: GemAsset, + to_asset: GemAsset, + swap_data: GemSwapData, + }, + Stake { + asset: GemAsset, + stake_type: GemStakeType, + }, + TokenApprove { + asset: GemAsset, + approval_data: GemApprovalData, + }, + Generic { + asset: GemAsset, + metadata: GemWalletConnectionSessionAppMetadata, + extra: GemTransferDataExtra, + }, + TransferNft { + asset: GemAsset, + nft_asset: GemNFTAsset, + }, + Account { + asset: GemAsset, + account_type: GemAccountDataType, + }, + Perpetual { + asset: GemAsset, + perpetual_type: GemPerpetualType, + }, + Earn { + asset: GemAsset, + earn_type: GemEarnType, + data: GemContractCallData, + }, +} + +impl GemTransactionInputType { + pub fn asset(&self) -> &GemAsset { + match self { + Self::Transfer { asset } + | Self::Deposit { asset } + | Self::Stake { asset, .. } + | Self::TokenApprove { asset, .. } + | Self::Generic { asset, .. } + | Self::TransferNft { asset, .. } + | Self::Account { asset, .. } + | Self::Perpetual { asset, .. } + | Self::Earn { asset, .. } => asset, + Self::Swap { from_asset, .. } => from_asset, + } + } + + pub fn swap_data(&self) -> Result<&GemSwapData, String> { + match self { + Self::Swap { swap_data, .. } => Ok(swap_data), + _ => Err("Expected Swap".to_string()), + } + } + + pub fn earn_data(&self) -> Result<&GemContractCallData, String> { + match self { + Self::Earn { data, .. } => Ok(data), + _ => Err("Expected Earn".to_string()), + } + } + + pub fn stake_type(&self) -> Result<&GemStakeType, String> { + match self { + Self::Stake { stake_type, .. } => Ok(stake_type), + _ => Err("Expected Stake".to_string()), + } + } + + pub fn perpetual_type(&self) -> Result<&GemPerpetualType, String> { + match self { + Self::Perpetual { perpetual_type, .. } => Ok(perpetual_type), + _ => Err("Expected Perpetual".to_string()), + } + } +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemTransactionLoadInput { + pub input_type: GemTransactionInputType, + pub sender_address: String, + pub destination_address: String, + pub value: String, + pub gas_price: GemGasPriceType, + pub memo: Option, + pub is_max_value: bool, + pub metadata: GemTransactionLoadMetadata, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemSignerInput { + pub input: GemTransactionLoadInput, + pub fee: GemTransactionLoadFee, +} + +#[derive(Debug, Default, Clone, uniffi::Record)] +pub struct GemFeeOptions { + pub options: HashMap, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemTransactionLoadFee { + pub fee: String, + pub gas_price_type: GemGasPriceType, + pub gas_limit: String, + pub options: GemFeeOptions, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemTransactionData { + pub fee: GemTransactionLoadFee, + pub metadata: GemTransactionLoadMetadata, +} + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum GemTransactionLoadMetadata { + None, + Solana { + sender_token_address: Option, + recipient_token_address: Option, + token_program: Option, + nft: Option, + block_hash: String, + }, + Ton { + sender_token_address: Option, + recipient_token_address: Option, + sequence: u64, + }, + Cosmos { + account_number: u64, + sequence: u64, + chain_id: String, + }, + Bitcoin { + utxos: Vec, + }, + Zcash { + utxos: Vec, + branch_id: String, + }, + Cardano { + utxos: Vec, + block_number: u64, + }, + Evm { + nonce: u64, + chain_id: u64, + contract_call: Option, + }, + Near { + sequence: u64, + block_hash: String, + }, + Stellar { + sequence: u64, + is_destination_address_exist: bool, + }, + Xrp { + sequence: u64, + block_number: u64, + }, + Algorand { + sequence: u64, + block_hash: String, + chain_id: String, + }, + Aptos { + sequence: u64, + data: Option, + }, + Polkadot { + sequence: u64, + genesis_hash: String, + block_hash: String, + block_number: u64, + spec_version: u64, + transaction_version: u64, + period: u64, + }, + Tron { + block_number: u64, + block_version: u64, + block_timestamp: u64, + transaction_tree_root: String, + parent_hash: String, + witness_address: String, + stake_data: GemTronStakeData, + }, + Sui { + message_bytes: String, + }, + Hyperliquid { + order: Option, + }, +} + +impl From for GemTransactionLoadMetadata { + fn from(value: TransactionLoadMetadata) -> Self { + match value { + TransactionLoadMetadata::None => GemTransactionLoadMetadata::None, + TransactionLoadMetadata::Solana { + sender_token_address, + recipient_token_address, + token_program, + nft, + block_hash, + } => GemTransactionLoadMetadata::Solana { + sender_token_address, + recipient_token_address, + token_program, + nft, + block_hash, + }, + TransactionLoadMetadata::Ton { + sender_token_address, + recipient_token_address, + sequence, + } => GemTransactionLoadMetadata::Ton { + sender_token_address, + recipient_token_address, + sequence, + }, + TransactionLoadMetadata::Cosmos { + account_number, + sequence, + chain_id, + } => GemTransactionLoadMetadata::Cosmos { + account_number, + sequence, + chain_id, + }, + TransactionLoadMetadata::Bitcoin { utxos } => GemTransactionLoadMetadata::Bitcoin { utxos }, + TransactionLoadMetadata::Zcash { utxos, branch_id } => GemTransactionLoadMetadata::Zcash { utxos, branch_id }, + TransactionLoadMetadata::Cardano { utxos, block_number } => GemTransactionLoadMetadata::Cardano { utxos, block_number }, + TransactionLoadMetadata::Evm { nonce, chain_id, contract_call } => GemTransactionLoadMetadata::Evm { nonce, chain_id, contract_call }, + TransactionLoadMetadata::Near { sequence, block_hash } => GemTransactionLoadMetadata::Near { sequence, block_hash }, + TransactionLoadMetadata::Stellar { + sequence, + is_destination_address_exist, + } => GemTransactionLoadMetadata::Stellar { + sequence, + is_destination_address_exist, + }, + TransactionLoadMetadata::Xrp { sequence, block_number } => GemTransactionLoadMetadata::Xrp { sequence, block_number }, + TransactionLoadMetadata::Algorand { sequence, block_hash, chain_id } => GemTransactionLoadMetadata::Algorand { sequence, block_hash, chain_id }, + TransactionLoadMetadata::Aptos { sequence, data } => GemTransactionLoadMetadata::Aptos { sequence, data }, + TransactionLoadMetadata::Polkadot { + sequence, + genesis_hash, + block_hash, + block_number, + spec_version, + transaction_version, + period, + } => GemTransactionLoadMetadata::Polkadot { + sequence, + genesis_hash, + block_hash, + block_number, + spec_version, + transaction_version, + period, + }, + TransactionLoadMetadata::Tron { + block_number, + block_version, + block_timestamp, + transaction_tree_root, + parent_hash, + witness_address, + stake_data, + } => GemTransactionLoadMetadata::Tron { + block_number, + block_version, + block_timestamp, + transaction_tree_root, + parent_hash, + witness_address, + stake_data, + }, + TransactionLoadMetadata::Sui { message_bytes } => GemTransactionLoadMetadata::Sui { message_bytes }, + TransactionLoadMetadata::Hyperliquid { order } => GemTransactionLoadMetadata::Hyperliquid { order }, + } + } +} + +impl From for TransactionLoadMetadata { + fn from(value: GemTransactionLoadMetadata) -> Self { + match value { + GemTransactionLoadMetadata::None => TransactionLoadMetadata::None, + GemTransactionLoadMetadata::Solana { + sender_token_address, + recipient_token_address, + token_program, + nft, + block_hash, + } => TransactionLoadMetadata::Solana { + sender_token_address, + recipient_token_address, + token_program, + nft, + block_hash, + }, + GemTransactionLoadMetadata::Ton { + sender_token_address, + recipient_token_address, + sequence, + } => TransactionLoadMetadata::Ton { + sender_token_address, + recipient_token_address, + sequence, + }, + GemTransactionLoadMetadata::Cosmos { + account_number, + sequence, + chain_id, + } => TransactionLoadMetadata::Cosmos { + account_number, + sequence, + chain_id, + }, + GemTransactionLoadMetadata::Bitcoin { utxos } => TransactionLoadMetadata::Bitcoin { utxos }, + GemTransactionLoadMetadata::Zcash { utxos, branch_id } => TransactionLoadMetadata::Zcash { utxos, branch_id }, + GemTransactionLoadMetadata::Cardano { utxos, block_number } => TransactionLoadMetadata::Cardano { utxos, block_number }, + GemTransactionLoadMetadata::Evm { nonce, chain_id, contract_call } => TransactionLoadMetadata::Evm { nonce, chain_id, contract_call }, + GemTransactionLoadMetadata::Near { sequence, block_hash } => TransactionLoadMetadata::Near { sequence, block_hash }, + GemTransactionLoadMetadata::Stellar { + sequence, + is_destination_address_exist, + } => TransactionLoadMetadata::Stellar { + sequence, + is_destination_address_exist, + }, + GemTransactionLoadMetadata::Xrp { sequence, block_number } => TransactionLoadMetadata::Xrp { sequence, block_number }, + GemTransactionLoadMetadata::Algorand { sequence, block_hash, chain_id } => TransactionLoadMetadata::Algorand { sequence, block_hash, chain_id }, + GemTransactionLoadMetadata::Aptos { sequence, data } => TransactionLoadMetadata::Aptos { sequence, data }, + GemTransactionLoadMetadata::Polkadot { + sequence, + genesis_hash, + block_hash, + block_number, + spec_version, + transaction_version, + period, + } => TransactionLoadMetadata::Polkadot { + sequence, + genesis_hash, + block_hash, + block_number, + spec_version, + transaction_version, + period, + }, + GemTransactionLoadMetadata::Tron { + block_number, + block_version, + block_timestamp, + transaction_tree_root, + parent_hash, + witness_address, + stake_data, + } => TransactionLoadMetadata::Tron { + block_number, + block_version, + block_timestamp, + transaction_tree_root, + parent_hash, + witness_address, + stake_data, + }, + GemTransactionLoadMetadata::Sui { message_bytes } => TransactionLoadMetadata::Sui { message_bytes }, + GemTransactionLoadMetadata::Hyperliquid { order } => TransactionLoadMetadata::Hyperliquid { order }, + } + } +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemSuiCoin { + pub coin_type: String, + pub balance: String, +} + +impl From for TransactionStateRequest { + fn from(value: GemTransactionStateRequest) -> Self { + TransactionStateRequest { + id: value.id, + sender_address: value.sender_address, + created_at: value.created_at, + block_number: value.block_number, + } + } +} + +impl From for TransactionLoadInput { + fn from(value: GemTransactionLoadInput) -> Self { + TransactionLoadInput { + input_type: value.input_type.into(), + sender_address: value.sender_address, + destination_address: value.destination_address, + value: value.value, + gas_price: value.gas_price.into(), + memo: value.memo, + is_max_value: value.is_max_value, + metadata: value.metadata.into(), + } + } +} + +impl From for SignerInput { + fn from(value: GemSignerInput) -> Self { + SignerInput::new(value.input.into(), value.fee.into()) + } +} + +impl From for GemTransactionInputType { + fn from(value: TransactionInputType) -> Self { + match value { + TransactionInputType::Transfer(asset) => GemTransactionInputType::Transfer { asset }, + TransactionInputType::Deposit(asset) => GemTransactionInputType::Deposit { asset }, + TransactionInputType::Swap(from_asset, to_asset, swap_data) => GemTransactionInputType::Swap { from_asset, to_asset, swap_data }, + TransactionInputType::Stake(asset, stake_type) => GemTransactionInputType::Stake { + asset, + stake_type: stake_type.into(), + }, + TransactionInputType::TokenApprove(asset, approval_data) => GemTransactionInputType::TokenApprove { asset, approval_data }, + TransactionInputType::Generic(asset, metadata, extra) => GemTransactionInputType::Generic { + asset, + metadata, + extra: extra.into(), + }, + TransactionInputType::TransferNft(asset, nft_asset) => GemTransactionInputType::TransferNft { asset, nft_asset }, + TransactionInputType::Account(asset, account_type) => GemTransactionInputType::Account { asset, account_type }, + TransactionInputType::Perpetual(asset, perpetual_type) => GemTransactionInputType::Perpetual { asset, perpetual_type }, + TransactionInputType::Earn(asset, earn_type, data) => GemTransactionInputType::Earn { asset, earn_type, data }, + } + } +} + +impl From for StakeType { + fn from(value: GemStakeType) -> Self { + match value { + GemStakeType::Delegate { validator } => StakeType::Stake(validator), + GemStakeType::Undelegate { delegation } => StakeType::Unstake(delegation), + GemStakeType::Redelegate { delegation, to_validator } => StakeType::Redelegate(primitives::RedelegateData { delegation, to_validator }), + GemStakeType::WithdrawRewards { validators } => StakeType::Rewards(validators.into_iter().collect()), + GemStakeType::Withdraw { delegation } => StakeType::Withdraw(delegation), + GemStakeType::Freeze { resource } => StakeType::Freeze(resource), + GemStakeType::Unfreeze { resource } => StakeType::Unfreeze(resource), + } + } +} + +impl From for GemStakeType { + fn from(value: StakeType) -> Self { + match value { + StakeType::Stake(validator) => GemStakeType::Delegate { validator }, + StakeType::Unstake(delegation) => GemStakeType::Undelegate { delegation }, + StakeType::Redelegate(data) => GemStakeType::Redelegate { + delegation: data.delegation, + to_validator: data.to_validator, + }, + StakeType::Rewards(validators) => GemStakeType::WithdrawRewards { validators }, + StakeType::Withdraw(delegation) => GemStakeType::Withdraw { delegation }, + StakeType::Freeze(resource) => GemStakeType::Freeze { resource }, + StakeType::Unfreeze(resource) => GemStakeType::Unfreeze { resource }, + } + } +} + +impl From for TransferDataExtra { + fn from(value: GemTransferDataExtra) -> Self { + TransferDataExtra { + to: value.to, + gas_limit: value.gas_limit.map(|s| s.parse().unwrap_or_default()), + gas_price: value.gas_price.map(|gp| gp.into()), + data: value.data, + output_type: value.output_type, + output_action: value.output_action, + } + } +} + +impl From for GemTransferDataExtra { + fn from(value: TransferDataExtra) -> Self { + GemTransferDataExtra { + to: value.to, + gas_limit: value.gas_limit.map(|x| x.to_string()), + gas_price: value.gas_price.map(|x| x.into()), + data: value.data, + output_type: value.output_type, + output_action: value.output_action, + } + } +} + +impl From for GasPriceType { + fn from(value: GemGasPriceType) -> Self { + match value { + GemGasPriceType::Regular { gas_price } => GasPriceType::Regular { + gas_price: gas_price.parse().unwrap_or_default(), + }, + GemGasPriceType::Eip1559 { gas_price, priority_fee } => GasPriceType::Eip1559 { + gas_price: gas_price.parse().unwrap_or_default(), + priority_fee: priority_fee.parse().unwrap_or_default(), + }, + GemGasPriceType::Solana { + gas_price, + priority_fee, + unit_price, + } => GasPriceType::Solana { + gas_price: gas_price.parse().unwrap_or_default(), + priority_fee: priority_fee.parse().unwrap_or_default(), + unit_price: unit_price.parse().unwrap_or_default(), + }, + } + } +} + +impl GemFeeOptions { + pub fn with_option(mut self, option: GemFeeOption, value: String) -> Self { + self.options.insert(option, value); + self + } + + pub fn get(&self, option: &GemFeeOption) -> Option<&String> { + self.options.get(option) + } + + pub fn is_empty(&self) -> bool { + self.options.is_empty() + } + + pub fn from_primitives(options: HashMap) -> Self { + GemFeeOptions { + options: options.into_iter().map(|(key, value)| (key, value.to_string())).collect(), + } + } +} + +impl From for TransactionFee { + fn from(value: GemTransactionLoadFee) -> Self { + TransactionFee { + fee: value.fee.parse().unwrap_or_default(), + gas_price_type: value.gas_price_type.into(), + gas_limit: value.gas_limit.parse().unwrap_or_default(), + options: value.options.options.into_iter().map(|(key, value)| (key, value.parse().unwrap_or_default())).collect(), + } + } +} + +impl From for GemTransactionLoadFee { + fn from(value: TransactionFee) -> Self { + GemTransactionLoadFee { + fee: value.fee.to_string(), + gas_price_type: value.gas_price_type.into(), + gas_limit: value.gas_limit.to_string(), + options: GemFeeOptions::from_primitives(value.options), + } + } +} + +impl From for TransactionInputType { + fn from(value: GemTransactionInputType) -> Self { + match value { + GemTransactionInputType::Transfer { asset } => TransactionInputType::Transfer(asset), + GemTransactionInputType::Deposit { asset } => TransactionInputType::Deposit(asset), + GemTransactionInputType::Swap { from_asset, to_asset, swap_data } => TransactionInputType::Swap( + from_asset, + to_asset, + GemSwapData { + quote: swap_data.quote, + data: swap_data.data, + }, + ), + GemTransactionInputType::Stake { asset, stake_type } => TransactionInputType::Stake(asset, stake_type.into()), + GemTransactionInputType::TokenApprove { asset, approval_data } => TransactionInputType::TokenApprove( + asset, + GemApprovalData { + token: approval_data.token, + spender: approval_data.spender, + value: approval_data.value, + is_unlimited: approval_data.is_unlimited, + }, + ), + GemTransactionInputType::Generic { asset, metadata, extra } => TransactionInputType::Generic(asset, metadata, extra.into()), + GemTransactionInputType::TransferNft { asset, nft_asset } => TransactionInputType::TransferNft(asset, nft_asset), + GemTransactionInputType::Account { asset, account_type } => TransactionInputType::Account(asset, account_type), + GemTransactionInputType::Perpetual { asset, perpetual_type } => TransactionInputType::Perpetual(asset, perpetual_type), + GemTransactionInputType::Earn { asset, earn_type, data } => TransactionInputType::Earn(asset, earn_type, data), + } + } +} diff --git a/core/gemstone/src/network/mod.rs b/core/gemstone/src/network/mod.rs new file mode 100644 index 0000000000..6e27309203 --- /dev/null +++ b/core/gemstone/src/network/mod.rs @@ -0,0 +1,4 @@ +pub use gem_jsonrpc::{ + JsonRpcClient, + types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, JsonRpcResult, JsonRpcResults}, +}; diff --git a/core/gemstone/src/payment/mod.rs b/core/gemstone/src/payment/mod.rs new file mode 100644 index 0000000000..feb4124d31 --- /dev/null +++ b/core/gemstone/src/payment/mod.rs @@ -0,0 +1,43 @@ +use crate::GemstoneError; +use primitives::PaymentURLDecoder; + +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct PaymentWrapper { + pub address: String, + pub amount: Option, + pub memo: Option, + pub asset_id: Option, + pub payment_link: Option, +} + +/// Exports functions +#[uniffi::export] +pub fn payment_decode_url(string: &str) -> Result { + let payment = PaymentURLDecoder::decode(string)?; + Ok(PaymentWrapper { + address: payment.address, + amount: payment.amount, + memo: payment.memo, + asset_id: payment.asset_id.map(|c| c.to_string()), + payment_link: payment.link.map(|c| c.to_string()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_address() { + assert_eq!( + payment_decode_url("solana:3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw?amount=0.42301").unwrap(), + PaymentWrapper { + address: "3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw".to_string(), + amount: Some("0.42301".to_string()), + memo: None, + asset_id: Some("solana".to_string()), + payment_link: None, + } + ); + } +} diff --git a/core/gemstone/src/perpetual.rs b/core/gemstone/src/perpetual.rs new file mode 100644 index 0000000000..a5977e300f --- /dev/null +++ b/core/gemstone/src/perpetual.rs @@ -0,0 +1,121 @@ +use gem_hypercore::{ + models::websocket::{HyperliquidMethod, HyperliquidRequest, HyperliquidSubscription}, + perpetual_formatter::PerpetualFormatter, + provider::websocket_mapper::{diff_clearinghouse_positions, diff_open_orders_positions, parse_websocket_data}, +}; +use primitives::{PerpetualPosition, PerpetualProvider}; + +use crate::models::perpetual::{GemHyperliquidOpenOrder, GemHyperliquidSocketMessage, GemPerpetualSubscription, GemPositionsDiff, GemSubscriptionMethod}; + +#[derive(Debug, uniffi::Object)] +pub struct Perpetual { + provider: PerpetualProvider, +} + +#[uniffi::export] +impl Perpetual { + #[uniffi::constructor] + pub fn new(provider: PerpetualProvider) -> Self { + Self { provider } + } + + pub fn minimum_order_usd_amount(&self, price: f64, decimals: i32, leverage: u8) -> u64 { + match self.provider { + PerpetualProvider::Hypercore => PerpetualFormatter::minimum_order_usd_amount(price, decimals, leverage), + } + } + + pub fn format_price(&self, price: f64, decimals: i32) -> String { + match self.provider { + PerpetualProvider::Hypercore => PerpetualFormatter::format_price(price, decimals), + } + } + + pub fn format_size(&self, size: f64, decimals: i32) -> String { + match self.provider { + PerpetualProvider::Hypercore => PerpetualFormatter::format_size(size, decimals), + } + } +} + +#[derive(Debug, uniffi::Object)] +pub struct Hyperliquid {} + +impl Default for Hyperliquid { + fn default() -> Self { + Self::new() + } +} + +#[uniffi::export] +impl Hyperliquid { + #[uniffi::constructor] + pub fn new() -> Self { + Self {} + } + + pub fn parse_websocket_data(&self, data: Vec) -> Result { + Ok(parse_websocket_data(&data)?) + } + + pub fn websocket_request(&self, method: GemSubscriptionMethod, subscription: GemPerpetualSubscription) -> Result { + Ok(serde_json::to_string(&HyperliquidRequest { + method: method.map(), + subscription: subscription.map(), + })?) + } + + pub fn diff_clearinghouse_positions(&self, new_positions: Vec, existing_positions: Vec) -> GemPositionsDiff { + diff_clearinghouse_positions(new_positions, existing_positions) + } + + pub fn diff_open_orders_positions(&self, orders: Vec, existing_positions: Vec) -> GemPositionsDiff { + diff_open_orders_positions(&orders, existing_positions) + } +} + +impl GemSubscriptionMethod { + fn map(self) -> HyperliquidMethod { + match self { + Self::Subscribe => HyperliquidMethod::Subscribe, + Self::Unsubscribe => HyperliquidMethod::Unsubscribe, + } + } +} + +impl GemPerpetualSubscription { + fn map(self) -> HyperliquidSubscription { + match self { + Self::AccountState { address } => HyperliquidSubscription::AccountState { address }, + Self::OpenOrders { address } => HyperliquidSubscription::OpenOrders { address }, + Self::Candle { symbol, interval } => HyperliquidSubscription::Candle { symbol, interval }, + Self::MarketData { symbol } => HyperliquidSubscription::MarketData { symbol }, + Self::MarketPrices => HyperliquidSubscription::MarketPrices, + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn test_websocket_request_maps_generic_subscription() { + let request = Hyperliquid::new() + .websocket_request(GemSubscriptionMethod::Subscribe, GemPerpetualSubscription::AccountState { address: "0x123".to_string() }) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&request).unwrap(), + json!({ + "method": "subscribe", + "subscription": { + "type": "clearinghouseState", + "user": "0x123", + }, + }) + ); + } +} diff --git a/core/gemstone/src/price_alert_formatter.rs b/core/gemstone/src/price_alert_formatter.rs new file mode 100644 index 0000000000..085ebcf683 --- /dev/null +++ b/core/gemstone/src/price_alert_formatter.rs @@ -0,0 +1,20 @@ +use number_formatter::price_suggestion; + +#[derive(Default, uniffi::Object)] +pub struct PriceAlertFormatter {} + +#[uniffi::export] +impl PriceAlertFormatter { + #[uniffi::constructor] + pub fn new() -> Self { + Self {} + } + + pub fn percentage_suggestions(&self, price: f64) -> Vec { + price_suggestion::percentage_suggestions(price) + } + + pub fn rounded_values(&self, price: f64, by_percent: f64) -> Vec { + price_suggestion::price_rounded_values(price, by_percent) + } +} diff --git a/core/gemstone/src/signer/chain.rs b/core/gemstone/src/signer/chain.rs new file mode 100644 index 0000000000..397cc89cab --- /dev/null +++ b/core/gemstone/src/signer/chain.rs @@ -0,0 +1,160 @@ +use crate::{GemstoneError, models::transaction::GemSignerInput}; +use gem_algorand::AlgorandChainSigner; +use gem_aptos::AptosChainSigner; +use gem_cardano::signer::CardanoChainSigner; +use gem_cosmos::signer::CosmosChainSigner; +use gem_evm::signer::EvmChainSigner; +use gem_hypercore::signer::HyperCoreSigner; +use gem_near::NearChainSigner; +use gem_polkadot::signer::PolkadotChainSigner; +use gem_solana::signer::SolanaChainSigner; +use gem_stellar::StellarChainSigner; +use gem_sui::signer::SuiChainSigner; +use gem_ton::signer::TonChainSigner; +use gem_tron::signer::TronChainSigner; +use gem_xrp::signer::XrpChainSigner; +use primitives::{Chain, ChainSigner, ChainType, EVMChain, SignerError, SignerInput}; +use zeroize::Zeroizing; + +#[derive(uniffi::Object)] +pub struct GemChainSigner { + chain: Chain, + signer: Box, +} + +#[uniffi::export] +impl GemChainSigner { + #[uniffi::constructor] + pub fn new(chain: Chain) -> Self { + let signer: Box = match chain.chain_type() { + ChainType::Ethereum => Box::new(EvmChainSigner::new(EVMChain::from_chain(chain).unwrap())), + ChainType::Aptos => Box::new(AptosChainSigner), + ChainType::HyperCore => Box::new(HyperCoreSigner), + ChainType::Sui => Box::new(SuiChainSigner), + ChainType::Solana => Box::new(SolanaChainSigner), + ChainType::Ton => Box::new(TonChainSigner), + ChainType::Tron => Box::new(TronChainSigner), + ChainType::Cosmos => Box::new(CosmosChainSigner), + ChainType::Near => Box::new(NearChainSigner), + ChainType::Algorand => Box::new(AlgorandChainSigner), + ChainType::Stellar => Box::new(StellarChainSigner), + ChainType::Xrp => Box::new(XrpChainSigner), + ChainType::Polkadot => Box::new(PolkadotChainSigner), + ChainType::Cardano => Box::new(CardanoChainSigner), + _ => todo!("Signer not implemented for chain {:?}", chain), + }; + + Self { chain, signer } + } + + pub fn sign_transfer(&self, input: GemSignerInput, private_key: Vec) -> Result { + self.dispatch(input, private_key, "transfer", |signer, signer_input, key| signer.sign_transfer(signer_input, key)) + } + + pub fn sign_token_transfer(&self, input: GemSignerInput, private_key: Vec) -> Result { + self.dispatch(input, private_key, "token transfer", |signer, signer_input, key| { + signer.sign_token_transfer(signer_input, key) + }) + } + + pub fn sign_nft_transfer(&self, input: GemSignerInput, private_key: Vec) -> Result { + self.dispatch(input, private_key, "nft transfer", |signer, signer_input, key| signer.sign_nft_transfer(signer_input, key)) + } + + pub fn sign_swap(&self, input: GemSignerInput, private_key: Vec) -> Result, GemstoneError> { + self.dispatch(input, private_key, "swap", |signer, signer_input, key| signer.sign_swap(signer_input, key)) + } + + pub fn sign_token_approval(&self, input: GemSignerInput, private_key: Vec) -> Result { + self.dispatch(input, private_key, "token approval", |signer, signer_input, key| { + signer.sign_token_approval(signer_input, key) + }) + } + + pub fn sign_stake(&self, input: GemSignerInput, private_key: Vec) -> Result, GemstoneError> { + self.dispatch(input, private_key, "stake", |signer, signer_input, key| signer.sign_stake(signer_input, key)) + } + + pub fn sign_account_action(&self, input: GemSignerInput, private_key: Vec) -> Result { + self.dispatch(input, private_key, "account action", |signer, signer_input, key| { + signer.sign_account_action(signer_input, key) + }) + } + + pub fn sign_perpetual(&self, input: GemSignerInput, private_key: Vec) -> Result, GemstoneError> { + self.dispatch(input, private_key, "perpetual", |signer, signer_input, key| signer.sign_perpetual(signer_input, key)) + } + + pub fn sign_withdrawal(&self, input: GemSignerInput, private_key: Vec) -> Result { + self.dispatch(input, private_key, "withdrawal", |signer, signer_input, key| signer.sign_withdrawal(signer_input, key)) + } + + pub fn sign_data(&self, input: GemSignerInput, private_key: Vec) -> Result { + self.dispatch(input, private_key, "data", |signer, signer_input, key| signer.sign_data(signer_input, key)) + } + + pub fn sign_earn(&self, input: GemSignerInput, private_key: Vec) -> Result, GemstoneError> { + self.dispatch(input, private_key, "earn", |signer, signer_input, key| signer.sign_earn(signer_input, key)) + } + + pub fn sign_message(&self, message: Vec, private_key: Vec) -> Result { + let private_key = Zeroizing::new(private_key); + self.dispatch_message(&message, private_key.as_slice(), "message", |signer, msg, key| signer.sign_message(msg, key)) + } +} + +impl GemChainSigner { + fn dispatch(&self, input: GemSignerInput, private_key: Vec, action: &'static str, method: F) -> Result + where + F: Fn(&dyn ChainSigner, &SignerInput, &[u8]) -> Result, + { + let signer_input: SignerInput = input.into(); + let private_key = Zeroizing::new(private_key); + + method(self.signer.as_ref(), &signer_input, private_key.as_slice()).map_err(|err| map_signer_error(self.chain, action, err)) + } + + fn dispatch_message(&self, message: &[u8], private_key: &[u8], action: &'static str, method: F) -> Result + where + F: Fn(&dyn ChainSigner, &[u8], &[u8]) -> Result, + { + method(self.signer.as_ref(), message, private_key).map_err(|err| map_signer_error(self.chain, action, err)) + } +} + +fn map_signer_error(chain: Chain, action: &str, error: SignerError) -> GemstoneError { + match error { + SignerError::SigningError(message) if message == format!("sign_{} not implemented", action.replace(' ', "_")) => unsupported_error(chain, action), + error => GemstoneError::from(error), + } +} + +fn unsupported_error(chain: Chain, action: &str) -> GemstoneError { + SignerError::SigningError(format!("{action} not supported for chain {:?}", chain)).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_signer_error() { + assert_eq!( + map_signer_error(Chain::Solana, "stake", SignerError::SigningError("sign_stake not implemented".to_string())).to_string(), + "Signing error: stake not supported for chain solana" + ); + assert_eq!( + map_signer_error( + Chain::Solana, + "token transfer", + SignerError::SigningError("sign_token_transfer not implemented".to_string()) + ) + .to_string(), + "Signing error: token transfer not supported for chain solana" + ); + assert_eq!( + map_signer_error(Chain::Solana, "stake", SignerError::signing_error("sign: invalid private key")).to_string(), + "Signing error: sign: invalid private key" + ); + } +} diff --git a/core/gemstone/src/signer/decode.rs b/core/gemstone/src/signer/decode.rs new file mode 100644 index 0000000000..417d32271e --- /dev/null +++ b/core/gemstone/src/signer/decode.rs @@ -0,0 +1,20 @@ +use crate::GemstoneError; +use primitives::Chain; +use zeroize::Zeroizing; + +#[uniffi::export] +pub fn decode_private_key(chain: Chain, value: String) -> Result, GemstoneError> { + let mut private_key = signer::decode_private_key(&chain, &value)?; + Ok(std::mem::take(private_key.as_mut())) +} + +#[uniffi::export] +pub fn encode_private_key(chain: Chain, private_key: Vec) -> Result { + let private_key = Zeroizing::new(private_key); + signer::encode_private_key(&chain, &private_key).map_err(GemstoneError::from) +} + +#[uniffi::export] +pub fn supports_private_key_import(chain: Chain) -> bool { + signer::supports_private_key_import(&chain) +} diff --git a/core/gemstone/src/signer/mod.rs b/core/gemstone/src/signer/mod.rs new file mode 100644 index 0000000000..9c16970bf4 --- /dev/null +++ b/core/gemstone/src/signer/mod.rs @@ -0,0 +1,4 @@ +mod chain; +mod decode; + +pub use decode::{decode_private_key, encode_private_key}; diff --git a/core/gemstone/src/siwe.rs b/core/gemstone/src/siwe.rs new file mode 100644 index 0000000000..6079594631 --- /dev/null +++ b/core/gemstone/src/siwe.rs @@ -0,0 +1,24 @@ +use crate::GemstoneError; +pub use gem_evm::siwe::SiweMessage; +use primitives::Chain; + +#[uniffi::remote(Record)] +pub struct SiweMessage { + pub domain: String, + pub address: String, + pub uri: String, + pub chain_id: u64, + pub nonce: String, + pub version: String, + pub issued_at: String, +} + +#[uniffi::export] +pub fn siwe_try_parse(raw: String) -> Option { + SiweMessage::try_parse(&raw) +} + +#[uniffi::export] +pub fn siwe_validate(message: SiweMessage, chain: Chain) -> Result<(), crate::GemstoneError> { + message.validate(chain).map_err(|e| GemstoneError::AnyError { msg: e }) +} diff --git a/core/gemstone/src/testkit.rs b/core/gemstone/src/testkit.rs new file mode 100644 index 0000000000..1d313a22e2 --- /dev/null +++ b/core/gemstone/src/testkit.rs @@ -0,0 +1,39 @@ +use crate::alien::{AlienError, AlienProvider, AlienResponse, AlienTarget}; +use async_trait::async_trait; +use primitives::Chain; + +#[derive(Debug)] +pub struct TestAlienProvider { + endpoint: String, + response: AlienResponse, +} + +impl TestAlienProvider { + pub fn new(endpoint: impl Into, response: AlienResponse) -> Self { + Self { + endpoint: endpoint.into(), + response, + } + } + + pub fn with_status(status: u16) -> Self { + Self::new( + "https://example.invalid", + AlienResponse { + status: Some(status), + data: Vec::new(), + }, + ) + } +} + +#[async_trait] +impl AlienProvider for TestAlienProvider { + async fn request(&self, _target: AlienTarget) -> Result { + Ok(self.response.clone()) + } + + fn get_endpoint(&self, _chain: Chain) -> Result { + Ok(self.endpoint.clone()) + } +} diff --git a/core/gemstone/src/transaction_state/config.rs b/core/gemstone/src/transaction_state/config.rs new file mode 100644 index 0000000000..6a72af73e3 --- /dev/null +++ b/core/gemstone/src/transaction_state/config.rs @@ -0,0 +1,18 @@ +use primitives::{Chain, JobConfiguration}; + +#[derive(Debug, Clone, Copy, PartialEq, uniffi::Record)] +pub struct GemJobConfiguration { + pub initial_interval_ms: u32, + pub max_interval_ms: u32, + pub step_factor: f32, +} + +#[uniffi::export] +pub fn transaction_state_config(chain: Chain) -> GemJobConfiguration { + let config = JobConfiguration::transaction_state(chain); + GemJobConfiguration { + initial_interval_ms: config.initial_interval_ms, + max_interval_ms: config.max_interval_ms, + step_factor: config.step_factor, + } +} diff --git a/core/gemstone/src/transaction_state/error.rs b/core/gemstone/src/transaction_state/error.rs new file mode 100644 index 0000000000..74714dee66 --- /dev/null +++ b/core/gemstone/src/transaction_state/error.rs @@ -0,0 +1,29 @@ +use std::error::Error; +use std::fmt::{self, Formatter}; + +use crate::gateway::GatewayError; + +#[derive(Debug, Clone)] +pub enum TransactionStatusError { + NetworkError(String), + PlatformError(String), +} + +impl fmt::Display for TransactionStatusError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::NetworkError(msg) | Self::PlatformError(msg) => write!(f, "{msg}"), + } + } +} + +impl Error for TransactionStatusError {} + +impl From for TransactionStatusError { + fn from(err: GatewayError) -> Self { + match err { + GatewayError::NetworkError { msg } => Self::NetworkError(msg), + GatewayError::PlatformError { msg } => Self::PlatformError(msg), + } + } +} diff --git a/core/gemstone/src/transaction_state/mod.rs b/core/gemstone/src/transaction_state/mod.rs new file mode 100644 index 0000000000..59dbe44a5d --- /dev/null +++ b/core/gemstone/src/transaction_state/mod.rs @@ -0,0 +1,7 @@ +mod config; +mod error; +mod status_provider; + +pub use config::transaction_state_config; +pub use error::TransactionStatusError; +pub use status_provider::StatusProvider; diff --git a/core/gemstone/src/transaction_state/status_provider.rs b/core/gemstone/src/transaction_state/status_provider.rs new file mode 100644 index 0000000000..6699b0f445 --- /dev/null +++ b/core/gemstone/src/transaction_state/status_provider.rs @@ -0,0 +1,219 @@ +use chrono::{DateTime, Utc}; +use primitives::{Chain, TransactionChange, TransactionMetadata, TransactionState, TransactionUpdate, chain_transaction_timeout, swap_transaction_timeout}; +use std::sync::Arc; +use swapper::{SwapperProvider, swapper::GemSwapper}; + +use crate::gateway::ChainClientFactory; +use crate::models::{GemTransactionStateRequest, GemTransactionSwapStateRequest}; + +use super::TransactionStatusError; + +pub struct StatusProvider { + chain_factory: Arc, + swapper: GemSwapper, +} + +impl StatusProvider { + pub fn new(chain_factory: Arc, swapper: GemSwapper) -> Self { + Self { chain_factory, swapper } + } + + pub async fn get(&self, chain: Chain, request: GemTransactionStateRequest) -> Result { + let created_at = request.created_at; + let result = self.chain_status(chain, request).await; + get_transaction_update(chain, None, created_at, result) + } + + pub async fn get_swap_status(&self, chain: Chain, request: GemTransactionSwapStateRequest) -> Result { + let created_at = request.transaction.created_at; + let destination_chain = request.destination_chain; + let result = self.swap_transaction_status(chain, request).await; + get_transaction_update(chain, Some(destination_chain), created_at, result) + } + + async fn swap_transaction_status(&self, chain: Chain, request: GemTransactionSwapStateRequest) -> Result { + if !request.swap_provider.is_cross_chain() { + return self.chain_status(chain, request.transaction).await; + } + self.cross_chain_swap_status(chain, request).await + } + + async fn cross_chain_swap_status(&self, chain: Chain, request: GemTransactionSwapStateRequest) -> Result { + match request.state { + TransactionState::Pending => { + let source_chain_update = self.chain_status(chain, request.transaction).await?; + Ok(pending_cross_chain_swap_update(source_chain_update)) + } + TransactionState::InTransit => { + let swap_update = self.swap_provider_status(chain, request.swap_provider, &request.transaction.id).await?; + Ok(in_transit_swap_update(swap_update)) + } + state @ (TransactionState::Confirmed | TransactionState::Failed | TransactionState::Reverted) => Ok(TransactionUpdate::new_state(state)), + } + } + + async fn chain_status(&self, chain: Chain, request: GemTransactionStateRequest) -> Result { + let provider = self.chain_factory.create(chain).await?; + provider + .get_transaction_status(request.into()) + .await + .map_err(|e| TransactionStatusError::NetworkError(e.to_string())) + } + + async fn swap_provider_status(&self, chain: Chain, provider: SwapperProvider, transaction_hash: &str) -> Result { + let result = self + .swapper + .get_swap_result(chain, provider, transaction_hash) + .await + .map_err(|e| TransactionStatusError::NetworkError(e.to_string()))?; + + let state = result.status.transaction_state().unwrap_or(TransactionState::InTransit); + let changes = result.metadata.map(|m| vec![TransactionChange::Metadata(TransactionMetadata::Swap(m))]).unwrap_or_default(); + Ok(TransactionUpdate::new(state, changes)) + } +} + +fn pending_cross_chain_swap_update(source_update: TransactionUpdate) -> TransactionUpdate { + match source_update.state { + TransactionState::Confirmed => TransactionUpdate::new(TransactionState::InTransit, source_update.changes), + _ => source_update, + } +} + +fn in_transit_swap_update(swap_update: TransactionUpdate) -> TransactionUpdate { + let changes = swap_update.changes; + TransactionUpdate::new(cross_chain_swap_state(swap_update.state), changes) +} + +fn cross_chain_swap_state(swap_state: TransactionState) -> TransactionState { + match swap_state { + TransactionState::Confirmed | TransactionState::Failed | TransactionState::Reverted => swap_state, + TransactionState::Pending | TransactionState::InTransit => TransactionState::InTransit, + } +} + +fn transaction_timeout(chain: Chain, destination_chain: Option, state: TransactionState) -> Option { + match state { + TransactionState::Pending => Some(i64::from(chain_transaction_timeout(chain))), + TransactionState::InTransit => Some(swap_transaction_timeout(chain, destination_chain.unwrap_or(chain)) as i64), + TransactionState::Confirmed | TransactionState::Failed | TransactionState::Reverted => None, + } +} + +fn get_transaction_update( + chain: Chain, + destination_chain: Option, + created_at: DateTime, + result: Result, +) -> Result { + let elapsed = (Utc::now() - created_at).num_milliseconds(); + let pending_expired = elapsed > i64::from(chain_transaction_timeout(chain)); + + match result { + Ok(update) => Ok(if transaction_timeout(chain, destination_chain, update.state).is_some_and(|timeout| elapsed > timeout) { + TransactionUpdate::new_state(TransactionState::Failed) + } else { + update + }), + err @ Err(TransactionStatusError::NetworkError(_)) => err, + Err(_) if pending_expired => Ok(TransactionUpdate::new_state(TransactionState::Failed)), + Err(err) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use num_bigint::BigInt; + use primitives::TransactionSwapMetadata; + + #[test] + fn test_get_transaction_update() { + let chain = Chain::Ethereum; + let now = Utc::now; + let pending = || Ok(TransactionUpdate::new_state(TransactionState::Pending)); + let in_transit = || Ok(TransactionUpdate::new_state(TransactionState::InTransit)); + let confirmed = || Ok(TransactionUpdate::new_state(TransactionState::Confirmed)); + + assert_eq!(get_transaction_update(chain, None, now(), pending()).unwrap().state, TransactionState::Pending); + assert_eq!( + get_transaction_update(chain, None, DateTime::::UNIX_EPOCH, pending()).unwrap().state, + TransactionState::Failed + ); + assert_eq!( + get_transaction_update(chain, Some(Chain::Solana), now() - chrono::Duration::hours(3), in_transit()) + .unwrap() + .state, + TransactionState::InTransit + ); + assert_eq!( + get_transaction_update(chain, Some(chain), now() - chrono::Duration::hours(3), in_transit()).unwrap().state, + TransactionState::Failed + ); + assert_eq!( + get_transaction_update(chain, Some(Chain::Solana), DateTime::::UNIX_EPOCH, confirmed()).unwrap().state, + TransactionState::Confirmed + ); + } + + #[test] + fn test_pending_cross_chain_swap_update_moves_confirmed_source_to_in_transit() { + let source_update = TransactionUpdate::new( + TransactionState::Confirmed, + vec![ + TransactionChange::HashChange { + old: "broadcast_hash".into(), + new: "source_hash".into(), + }, + TransactionChange::NetworkFee(BigInt::from(123_u32)), + ], + ); + + let update = pending_cross_chain_swap_update(source_update); + + assert_eq!(update.state, TransactionState::InTransit); + assert!(matches!(update.changes.first(), Some(TransactionChange::HashChange { old, new }) if old == "broadcast_hash" && new == "source_hash")); + assert!(update.changes.iter().any(|change| matches!(change, TransactionChange::NetworkFee(_)))); + } + + #[test] + fn test_pending_cross_chain_swap_update_keeps_non_confirmed_source_state() { + let source_update = TransactionUpdate::new_state(TransactionState::Pending); + + let update = pending_cross_chain_swap_update(source_update); + + assert_eq!(update.state, TransactionState::Pending); + assert!(update.changes.is_empty()); + } + + #[test] + fn test_in_transit_swap_update_keeps_final_swap_metadata() { + let metadata = TransactionSwapMetadata { + from_asset: Chain::Ton.as_asset_id(), + from_value: "1000000".into(), + to_asset: Chain::Solana.as_asset_id(), + to_value: "966847".into(), + provider: Some("near_intents".into()), + }; + let swap_update = TransactionUpdate::new(TransactionState::Confirmed, vec![TransactionChange::Metadata(TransactionMetadata::Swap(metadata.clone()))]); + + let update = in_transit_swap_update(swap_update); + + assert_eq!(update.state, TransactionState::Confirmed); + assert!( + update + .changes + .iter() + .any(|change| matches!(change, TransactionChange::Metadata(TransactionMetadata::Swap(value)) if value == &metadata)) + ); + } + + #[test] + fn test_cross_chain_swap_state_maps_non_terminal_to_in_transit() { + assert_eq!(cross_chain_swap_state(TransactionState::Pending), TransactionState::InTransit); + assert_eq!(cross_chain_swap_state(TransactionState::InTransit), TransactionState::InTransit); + assert_eq!(cross_chain_swap_state(TransactionState::Confirmed), TransactionState::Confirmed); + assert_eq!(cross_chain_swap_state(TransactionState::Failed), TransactionState::Failed); + assert_eq!(cross_chain_swap_state(TransactionState::Reverted), TransactionState::Reverted); + } +} diff --git a/core/gemstone/src/url_action.rs b/core/gemstone/src/url_action.rs new file mode 100644 index 0000000000..8a6d187d1d --- /dev/null +++ b/core/gemstone/src/url_action.rs @@ -0,0 +1,24 @@ +use primitives::{Deeplink, UrlAction, WalletConnectLink}; + +#[uniffi::remote(Enum)] +pub enum UrlAction { + Deeplink { deeplink: Deeplink }, + WalletConnect { link: WalletConnectLink }, +} + +#[uniffi::export] +pub fn url_action(url: &str) -> Option { + UrlAction::from_url(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_action() { + assert!(matches!(url_action("https://gemwallet.com/tokens/bitcoin"), Some(UrlAction::Deeplink { .. }))); + assert!(matches!(url_action("gem://wc?sessionTopic=abc"), Some(UrlAction::WalletConnect { .. }))); + assert_eq!(url_action("https://example.com"), None); + } +} diff --git a/core/gemstone/src/wallet_connect/mod.rs b/core/gemstone/src/wallet_connect/mod.rs new file mode 100644 index 0000000000..1447c546dc --- /dev/null +++ b/core/gemstone/src/wallet_connect/mod.rs @@ -0,0 +1,426 @@ +use gem_wallet_connect::{ + SignDigestType as WcSignDigestType, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectAction as WcWalletConnectAction, + WalletConnectChainOperation as WcWalletConnectChainOperation, WalletConnectRequestHandler, WalletConnectResponseHandler, + WalletConnectResponseType as WcWalletConnectResponseType, WalletConnectTransaction as WcWalletConnectTransaction, + WalletConnectTransactionType as WcWalletConnectTransactionType, WalletConnectVerifier, config_session_properties, +}; +use primitives::{Chain, TransferDataOutputType, WCEthereumTransaction, WalletConnectCAIP2, WalletConnectLink, WalletConnectRequest, WalletConnectionVerificationStatus}; +use std::collections::HashMap; +use std::str::FromStr; + +use crate::{ + GemstoneError, + message::sign_type::{SignDigestType, SignMessage}, +}; + +mod simulation; +mod simulation_client; +pub use simulation_client::WalletConnectSimulationClient; + +// UniFFI remote enum declaration +#[uniffi::remote(Enum)] +pub enum WalletConnectionVerificationStatus { + Verified, + Unknown, + Invalid, + Malicious, +} + +#[uniffi::remote(Enum)] +pub enum WalletConnectLink { + Connect { uri: String }, + Request, + Session { topic: String }, +} + +// UniFFI types + +#[derive(Debug, Clone, uniffi::Record)] +pub struct WCEthereumTransactionData { + pub chain_id: Option, + pub from: String, + pub to: String, + pub value: Option, + pub gas: Option, + pub gas_limit: Option, + pub gas_price: Option, + pub max_fee_per_gas: Option, + pub max_priority_fee_per_gas: Option, + pub nonce: Option, + pub data: Option, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct WCSolanaTransactionData { + pub transaction: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct WCSuiTransactionData { + pub transaction: String, + pub wallet_address: String, +} + +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum WalletConnectAction { + SignMessage { + chain: Chain, + sign_type: SignDigestType, + data: String, + }, + SignTransaction { + chain: Chain, + transaction_type: WalletConnectTransactionType, + data: String, + }, + SignAllTransactions { + chain: Chain, + transaction_type: WalletConnectTransactionType, + transactions: Vec, + }, + SendTransaction { + chain: Chain, + transaction_type: WalletConnectTransactionType, + data: String, + }, + ChainOperation { + operation: WalletConnectChainOperation, + }, + Unsupported { + method: String, + }, +} + +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum WalletConnectTransactionType { + Ethereum, + Solana { output_type: TransferDataOutputType }, + Sui { output_type: TransferDataOutputType }, + Ton { output_type: TransferDataOutputType }, + Tron { output_type: TransferDataOutputType }, +} + +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum WalletConnectChainOperation { + AddChain, + SwitchChain { chain: Chain }, + GetChainId, +} + +#[derive(Debug, Clone, uniffi::Enum)] +#[allow(clippy::large_enum_variant)] +pub enum WalletConnectTransaction { + Ethereum { + data: WCEthereumTransactionData, + }, + Solana { + data: WCSolanaTransactionData, + output_type: TransferDataOutputType, + }, + Sui { + data: WCSuiTransactionData, + output_type: TransferDataOutputType, + }, + Ton { + messages: String, + output_type: TransferDataOutputType, + }, + Tron { + data: String, + output_type: TransferDataOutputType, + }, +} + +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum WalletConnectResponseType { + String { value: String }, + Object { json: String }, +} + +// From conversions: primitives -> UniFFI + +impl From for WCEthereumTransactionData { + fn from(transaction: WCEthereumTransaction) -> Self { + Self { + chain_id: transaction.chain_id, + from: transaction.from, + to: transaction.to, + value: transaction.value, + gas: transaction.gas, + gas_limit: transaction.gas_limit, + gas_price: transaction.gas_price, + max_fee_per_gas: transaction.max_fee_per_gas, + max_priority_fee_per_gas: transaction.max_priority_fee_per_gas, + nonce: transaction.nonce, + data: transaction.data, + } + } +} + +// From conversions: gem_wallet_connect -> UniFFI + +impl From for SignDigestType { + fn from(t: WcSignDigestType) -> Self { + match t { + WcSignDigestType::Eip191 => Self::Eip191, + WcSignDigestType::Eip712 => Self::Eip712, + WcSignDigestType::Base58 => Self::Base58, + WcSignDigestType::SuiPersonal => Self::SuiPersonal, + WcSignDigestType::Siwe => Self::Siwe, + WcSignDigestType::TonPersonal => Self::TonPersonal, + WcSignDigestType::TronPersonal => Self::TronPersonal, + } + } +} + +impl From for WcSignDigestType { + fn from(t: SignDigestType) -> Self { + match t { + SignDigestType::Eip191 => Self::Eip191, + SignDigestType::Eip712 => Self::Eip712, + SignDigestType::Base58 => Self::Base58, + SignDigestType::SuiPersonal => Self::SuiPersonal, + SignDigestType::Siwe => Self::Siwe, + SignDigestType::TonPersonal => Self::TonPersonal, + SignDigestType::TronPersonal => Self::TronPersonal, + } + } +} + +impl From for WalletConnectTransactionType { + fn from(t: WcWalletConnectTransactionType) -> Self { + match t { + WcWalletConnectTransactionType::Ethereum => Self::Ethereum, + WcWalletConnectTransactionType::Solana { output_type } => Self::Solana { output_type }, + WcWalletConnectTransactionType::Sui { output_type } => Self::Sui { output_type }, + WcWalletConnectTransactionType::Ton { output_type } => Self::Ton { output_type }, + WcWalletConnectTransactionType::Tron { output_type } => Self::Tron { output_type }, + } + } +} + +impl From for WcWalletConnectTransactionType { + fn from(t: WalletConnectTransactionType) -> Self { + match t { + WalletConnectTransactionType::Ethereum => Self::Ethereum, + WalletConnectTransactionType::Solana { output_type } => Self::Solana { output_type }, + WalletConnectTransactionType::Sui { output_type } => Self::Sui { output_type }, + WalletConnectTransactionType::Ton { output_type } => Self::Ton { output_type }, + WalletConnectTransactionType::Tron { output_type } => Self::Tron { output_type }, + } + } +} + +impl From for WalletConnectChainOperation { + fn from(op: WcWalletConnectChainOperation) -> Self { + match op { + WcWalletConnectChainOperation::AddChain => Self::AddChain, + WcWalletConnectChainOperation::SwitchChain { chain } => Self::SwitchChain { chain }, + WcWalletConnectChainOperation::GetChainId => Self::GetChainId, + } + } +} + +impl From for WalletConnectAction { + fn from(action: WcWalletConnectAction) -> Self { + match action { + WcWalletConnectAction::SignMessage { chain, sign_type, data } => Self::SignMessage { + chain, + sign_type: sign_type.into(), + data, + }, + WcWalletConnectAction::SignTransaction { chain, transaction_type, data } => Self::SignTransaction { + chain, + transaction_type: transaction_type.into(), + data, + }, + WcWalletConnectAction::SignAllTransactions { + chain, + transaction_type, + transactions, + } => Self::SignAllTransactions { + chain, + transaction_type: transaction_type.into(), + transactions, + }, + WcWalletConnectAction::SendTransaction { chain, transaction_type, data } => Self::SendTransaction { + chain, + transaction_type: transaction_type.into(), + data, + }, + WcWalletConnectAction::ChainOperation { operation } => Self::ChainOperation { operation: operation.into() }, + WcWalletConnectAction::Unsupported { method } => Self::Unsupported { method }, + } + } +} + +impl From for WCEthereumTransactionData { + fn from(d: WcEthereumTransactionData) -> Self { + Self { + chain_id: d.chain_id, + from: d.from, + to: d.to, + value: d.value, + gas: d.gas, + gas_limit: d.gas_limit, + gas_price: d.gas_price, + max_fee_per_gas: d.max_fee_per_gas, + max_priority_fee_per_gas: d.max_priority_fee_per_gas, + nonce: d.nonce, + data: d.data, + } + } +} + +impl From for WalletConnectTransaction { + fn from(t: WcWalletConnectTransaction) -> Self { + match t { + WcWalletConnectTransaction::Ethereum { data } => Self::Ethereum { data: data.into() }, + WcWalletConnectTransaction::Solana { data, output_type } => Self::Solana { + data: WCSolanaTransactionData { transaction: data.transaction }, + output_type, + }, + WcWalletConnectTransaction::Sui { data, output_type } => Self::Sui { + data: WCSuiTransactionData { + transaction: data.transaction, + wallet_address: data.wallet_address, + }, + output_type, + }, + WcWalletConnectTransaction::Ton { messages, output_type } => Self::Ton { messages, output_type }, + WcWalletConnectTransaction::Tron { data, output_type } => Self::Tron { data, output_type }, + } + } +} + +impl From for WalletConnectResponseType { + fn from(r: WcWalletConnectResponseType) -> Self { + match r { + WcWalletConnectResponseType::String { value } => Self::String { value }, + WcWalletConnectResponseType::Object { json } => Self::Object { json }, + } + } +} + +// WalletConnect UniFFI object + +#[derive(uniffi::Object)] +pub struct WalletConnect {} + +impl Default for WalletConnect { + fn default() -> Self { + Self::new() + } +} + +#[uniffi::export] +impl WalletConnect { + #[uniffi::constructor] + pub fn new() -> Self { + Self {} + } + + pub fn get_namespace(&self, chain: String) -> Option { + let chain = Chain::from_str(&chain).ok()?; + primitives::WalletConnectCAIP2::get_namespace(chain) + } + + pub fn get_reference(&self, chain: String) -> Option { + let chain = Chain::from_str(&chain).ok()?; + primitives::WalletConnectCAIP2::get_reference(chain) + } + + pub fn get_chain(&self, caip2: String, caip10: String) -> Option { + Some(primitives::WalletConnectCAIP2::get_chain(caip2, caip10)?.to_string()) + } + + pub fn parse_request(&self, topic: String, method: String, params: String, chain_id: String, domain: String) -> Result { + let request = WalletConnectRequest { + topic, + method, + params, + chain_id: Some(chain_id), + domain, + }; + let action = WalletConnectRequestHandler::parse_request(request).map_err(|e| GemstoneError::AnyError { msg: e })?; + Ok(action.into()) + } + + pub fn validate_origin(&self, metadata_url: String, origin: Option, validation: WalletConnectionVerificationStatus) -> WalletConnectionVerificationStatus { + WalletConnectVerifier::validate_origin(metadata_url, origin, validation) + } + + pub fn encode_sign_message(&self, chain: Chain, signature: String) -> WalletConnectResponseType { + WalletConnectResponseHandler::encode_sign_message(chain.chain_type(), signature).into() + } + + pub fn encode_sign_transaction(&self, chain: Chain, transaction_id: String) -> WalletConnectResponseType { + WalletConnectResponseHandler::encode_sign_transaction(chain.chain_type(), transaction_id).into() + } + + pub fn encode_sign_all_transactions(&self, signed_transactions: Vec) -> WalletConnectResponseType { + WalletConnectResponseHandler::encode_sign_all_transactions(signed_transactions).into() + } + + pub fn encode_send_transaction(&self, chain: Chain, transaction_id: String) -> WalletConnectResponseType { + WalletConnectResponseHandler::encode_send_transaction(chain.chain_type(), transaction_id).into() + } + + pub fn decode_sign_message(&self, chain: Chain, sign_type: SignDigestType, data: String) -> SignMessage { + simulation::decode_message(chain, sign_type, data) + } + + pub fn config_session_properties(&self, properties: HashMap, caip2_chains: Vec) -> HashMap { + let chains: Vec = caip2_chains.into_iter().filter_map(|caip2| WalletConnectCAIP2::resolve_chain(Some(caip2)).ok()).collect(); + config_session_properties(properties, &chains) + } + + pub fn decode_send_transaction(&self, transaction_type: WalletConnectTransactionType, data: String) -> Result { + let wc_type: WcWalletConnectTransactionType = transaction_type.into(); + let wc_result = WalletConnectRequestHandler::decode_send_transaction(wc_type, data).map_err(|e| GemstoneError::AnyError { msg: e })?; + Ok(wc_result.into()) + } +} + +#[uniffi::export] +pub fn wallet_connect_app_short_name(metadata: primitives::WalletConnectionSessionAppMetadata) -> String { + metadata.short_name() +} + +#[cfg(test)] +mod tests { + use primitives::{Chain, SimulationWarning, SimulationWarningType}; + + #[test] + fn short_name_strips_separators() { + use primitives::WalletConnectionSessionAppMetadata; + let meta = |name: &str| WalletConnectionSessionAppMetadata { + name: name.to_string(), + description: String::new(), + url: String::new(), + icon: String::new(), + }; + assert_eq!(meta("Polymarket - Buy & Sell").short_name(), "Polymarket"); + assert_eq!(meta("Uniswap: Trade Crypto").short_name(), "Uniswap"); + assert_eq!(meta("OpenSea | NFT Marketplace").short_name(), "OpenSea"); + assert_eq!(meta(" Compound ").short_name(), "Compound"); + assert_eq!(meta("Sushiswap").short_name(), "Sushiswap"); + assert_eq!(meta(&"A".repeat(100)).short_name(), "A".repeat(80)); + } + + #[test] + fn permit2_sign_message_simulation_matches_permit_warning_behavior() { + let data = include_str!("../../../crates/gem_evm/testdata/uniswap_permit2.json").to_string(); + let message = super::simulation::parse_eip712_message(&data).unwrap(); + let result = simulation::evm::simulate_eip712_message(Chain::Ethereum, &message); + + assert_eq!(result.warnings.len(), 1); + assert!(matches!( + result.warnings.first(), + Some(SimulationWarning { + warning: SimulationWarningType::PermitApproval(a), + .. + }) if a.value.is_none() + )); + } +} diff --git a/core/gemstone/src/wallet_connect/simulation.rs b/core/gemstone/src/wallet_connect/simulation.rs new file mode 100644 index 0000000000..cd26d1eb1f --- /dev/null +++ b/core/gemstone/src/wallet_connect/simulation.rs @@ -0,0 +1,61 @@ +use gem_wallet_connect::{ + SignDigestType as WcSignDigestType, SignMessageValidation, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectRequestHandler, + WalletConnectTransaction as WcWalletConnectTransaction, WalletConnectTransactionType as WcWalletConnectTransactionType, decode_sign_message, validate_send_transaction, + validate_sign_message, +}; +use primitives::{Chain, SimulationSeverity, SimulationWarning, SimulationWarningType, hex}; + +use crate::message::sign_type::{SignDigestType, SignMessage}; + +pub fn decode_message(chain: Chain, sign_type: SignDigestType, data: String) -> SignMessage { + let sign_type: WcSignDigestType = sign_type.into(); + let result = decode_sign_message(chain, sign_type, data); + + SignMessage { + chain: result.chain, + sign_type: result.sign_type.into(), + data: result.data, + } +} + +pub(super) fn parse_eip712_message(data: &str) -> Option { + serde_json::from_str(data).ok().and_then(|value| gem_evm::eip712::parse_eip712_json(&value).ok()) +} + +pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &WcSignDigestType, data: &str, session_domain: &str) -> Vec { + let input = SignMessageValidation { + chain, + sign_type, + data, + session_domain, + }; + + validate_sign_message(&input).err().into_iter().map(|error| validation_warning(&error)).collect() +} + +pub(super) fn send_transaction_validation_warnings(transaction_type: &WcWalletConnectTransactionType, data: &str) -> Vec { + validate_send_transaction(transaction_type, data) + .err() + .into_iter() + .map(|error| validation_warning(&error)) + .collect() +} + +fn decode_ethereum_transaction_data(data: &str) -> Result { + let transaction = WalletConnectRequestHandler::decode_send_transaction(WcWalletConnectTransactionType::Ethereum, data.to_string())?; + match transaction { + WcWalletConnectTransaction::Ethereum { data } => Ok(data), + _ => Err("Invalid Ethereum transaction".to_string()), + } +} + +pub(super) fn decode_ethereum_calldata(data: &str) -> Option<(WcEthereumTransactionData, Vec)> { + let transaction = decode_ethereum_transaction_data(data).ok()?; + let calldata = transaction.data.as_deref()?; + let bytes = hex::decode_hex(calldata).ok()?; + Some((transaction, bytes)) +} + +pub(super) fn validation_warning(error: &str) -> SimulationWarning { + SimulationWarning::new(SimulationSeverity::Critical, SimulationWarningType::ValidationError, Some(error.to_string())) +} diff --git a/core/gemstone/src/wallet_connect/simulation_client.rs b/core/gemstone/src/wallet_connect/simulation_client.rs new file mode 100644 index 0000000000..a0d2f9bf7c --- /dev/null +++ b/core/gemstone/src/wallet_connect/simulation_client.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use ::simulation::evm::SimulationClient; +use gem_evm::rpc::EthereumClient; +use gem_wallet_connect::{SignDigestType as WcSignDigestType, WalletConnectTransactionType as WcWalletConnectTransactionType}; +use primitives::{Chain, EVMChain, SimulationResult}; + +use crate::{ + GemstoneError, + alien::{AlienClient, AlienProvider, new_alien_client}, + message::sign_type::SignDigestType, + network::JsonRpcClient, +}; + +use super::{WalletConnectTransactionType, simulation}; + +#[derive(uniffi::Object)] +pub struct WalletConnectSimulationClient { + provider: Arc, +} + +#[uniffi::export] +impl WalletConnectSimulationClient { + #[uniffi::constructor] + pub fn new(provider: Arc) -> Self { + Self { provider } + } + + pub async fn simulate_sign_message(&self, chain: Chain, sign_type: SignDigestType, data: String, session_domain: String) -> Result { + let sign_type: WcSignDigestType = sign_type.into(); + let validation_warnings = simulation::sign_message_validation_warnings(chain, &sign_type, &data, &session_domain); + + let simulation = match sign_type { + WcSignDigestType::Eip712 => match simulation::parse_eip712_message(&data) { + Some(message) => self.simulate_eip712_message(chain, &message).await?, + None => SimulationResult::default(), + }, + _ => SimulationResult::default(), + }; + + Ok(simulation.prepend_warnings(validation_warnings)) + } + + pub async fn simulate_send_transaction(&self, chain: Chain, transaction_type: WalletConnectTransactionType, data: String) -> Result { + let transaction_type: WcWalletConnectTransactionType = transaction_type.into(); + let validation_warnings = simulation::send_transaction_validation_warnings(&transaction_type, &data); + + let simulation = match transaction_type { + WcWalletConnectTransactionType::Ethereum => self.simulate_ethereum_transaction(chain, &data).await?, + _ => SimulationResult::default(), + }; + + Ok(simulation.prepend_warnings(validation_warnings)) + } +} + +impl WalletConnectSimulationClient { + async fn simulate_eip712_message(&self, chain: Chain, message: &gem_evm::eip712::EIP712Message) -> Result { + let client = self.ethereum_client(chain).ok_or("No RPC client available")?; + Ok(SimulationClient::new(&client).simulate_eip712_message(chain, message).await?) + } + + async fn simulate_ethereum_transaction(&self, chain: Chain, data: &str) -> Result { + let (transaction, bytes) = simulation::decode_ethereum_calldata(data).ok_or("Failed to decode transaction")?; + let client = self.ethereum_client(chain).ok_or("No RPC client available")?; + Ok(SimulationClient::new(&client).simulate_evm_calldata(chain, &bytes, &transaction.to).await?) + } + + fn ethereum_client(&self, chain: Chain) -> Option> { + let chain = EVMChain::from_chain(chain)?; + let url = self.provider.get_endpoint(chain.to_chain()).ok()?; + let client = new_alien_client(url, self.provider.clone()); + Some(EthereumClient::new(JsonRpcClient::new(client), chain)) + } +} diff --git a/core/gemstone/tests/android/GemTest/.gitignore b/core/gemstone/tests/android/GemTest/.gitignore new file mode 100644 index 0000000000..aa724b7707 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/core/gemstone/tests/android/GemTest/app/.gitignore b/core/gemstone/tests/android/GemTest/app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/build.gradle b/core/gemstone/tests/android/GemTest/app/build.gradle new file mode 100644 index 0000000000..6c2348ad45 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/build.gradle @@ -0,0 +1,63 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" + id 'org.jetbrains.kotlin.plugin.compose' +} + +android { + namespace "com.example.gemtest" + compileSdk 35 + ndkVersion = "26.1.10909125" + defaultConfig { + applicationId "com.example.gemtest" + minSdk 28 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary true + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose true + } +} + +dependencies { + api "net.java.dev.jna:jna:5.18.1@aar" + api "com.gemwallet.gemstone:gemstone:1.0.3@aar" + + implementation("io.ktor:ktor-client-core:3.0.0") + implementation("io.ktor:ktor-client-cio:3.0.0") + + implementation "androidx.core:core-ktx:1.16.0" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.9.0" + implementation "androidx.activity:activity-compose:1.10.1" + implementation platform("androidx.compose:compose-bom:2025.05.00") + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-graphics" + implementation "androidx.compose.ui:ui-tooling-preview" + implementation "androidx.compose.material3:material3" + + androidTestImplementation "androidx.test.ext:junit:1.2.1" + androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1" + androidTestImplementation platform("androidx.compose:compose-bom:2025.05.00") + androidTestImplementation "androidx.compose.ui:ui-test-junit4" + debugImplementation "androidx.compose.ui:ui-tooling" + debugImplementation "androidx.compose.ui:ui-test-manifest" +} diff --git a/core/gemstone/tests/android/GemTest/app/proguard-rules.pro b/core/gemstone/tests/android/GemTest/app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/AndroidManifest.xml b/core/gemstone/tests/android/GemTest/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9ae7203295 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/MainActivity.kt b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/MainActivity.kt new file mode 100644 index 0000000000..8f962774db --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/MainActivity.kt @@ -0,0 +1,78 @@ +package com.example.gemtest + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.gemtest.ui.theme.GemTestTheme +import kotlinx.coroutines.runBlocking +import uniffi.gemstone.* + +private val nativeProvider = NativeProvider() + +class MainActivity : ComponentActivity() { + + init { + System.loadLibrary("gemstone") + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + GemTestTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + ContentView("Gemstone lib version: " + libVersion()) + } + } + } + } +} + +@Composable +fun ContentView(text: String, modifier: Modifier = Modifier) { + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = text, + modifier = modifier + ) + Button( + onClick = { fetchData() }, + modifier = Modifier.size(width = 120.dp, height = 80.dp) + ) { + Text(text = "Fetch Data") + } + } +} + +fun fetchData() { + println("Kotlin <> Rust") + runBlocking { + val target = AlienTarget( + url = "https://httpbin.org/get?foo=bar", + method = AlienHttpMethod.GET, + headers = mapOf( + "X-Header" to "X-Value" + ), + body = null + ) + val response = nativeProvider.request(target) + println("status: ${response.status}, body: ${String(response.data)}") + } +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + GemTestTheme { + ContentView("Android") + } +} diff --git a/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/NativeProvider.kt b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/NativeProvider.kt new file mode 100644 index 0000000000..a718466790 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/NativeProvider.kt @@ -0,0 +1,44 @@ +package com.example.gemtest + +import io.ktor.client.* +import io.ktor.client.call.body +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.http.* +import uniffi.gemstone.* + +class NativeProvider: AlienProvider { + val client = HttpClient(CIO) { + expectSuccess = true + } + + fun close() { + client.close() + } + + override fun getEndpoint(chain: Chain): String { + return "http://localhost:8080" + } + + override suspend fun request(target: AlienTarget): AlienResponse { + val parsedUrl = try { + Url(target.url) + } catch (e: Throwable) { + throw AlienException.RequestException("invalid url: ${target.url}") + } + + val response = client.request { + method = HttpMethod(alienMethodToString(target.method)) + url.takeFrom(parsedUrl) + headers { + target.headers?.forEach { (key, value) -> append(key, value) } + } + target.body?.let { setBody(it) } + } + + val bytes: ByteArray = response.body() + val status = response.status.value + + return AlienResponse(status.toUShort(), bytes) + } +} diff --git a/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Color.kt b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Color.kt new file mode 100644 index 0000000000..9ff6b1b7e9 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.gemtest.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Theme.kt b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Theme.kt new file mode 100644 index 0000000000..dfc335c371 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Theme.kt @@ -0,0 +1,70 @@ +package com.example.gemtest.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun GemTestTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Type.kt b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Type.kt new file mode 100644 index 0000000000..30cc0a77d2 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.gemtest.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/drawable/ic_launcher_background.xml b/core/gemstone/tests/android/GemTest/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..07d5da9cbf --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/drawable/ic_launcher_foreground.xml b/core/gemstone/tests/android/GemTest/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 zcmV-Y3$650Nk&FW3jhFDMM6+kP&il$0000G0000-002h-06|PpNWB9900E$G+qN-D z+81ABX7q?;bwx%xBg?kcwr$(C-Tex-ZCkHUw(Y9#+`E5-zuONG5fgw~E2WDng@Bc@ z24xy+R1n%~6xI#u9vJ8zREI)sb<&Il(016}Z~V1n^PU3-_H17A*Bf^o)&{_uBv}Py zulRfeE8g(g6HFhk_?o_;0@tz?1I+l+Y#Q*;RVC?(ud`_cU-~n|AX-b`JHrOIqn(-t&rOg-o`#C zh0LPxmbOAEb;zHTu!R3LDh1QO zZTf-|lJNUxi-PpcbRjw3n~n-pG;$+dIF6eqM5+L();B2O2tQ~|p{PlpNcvDbd1l%c zLtXn%lu(3!aNK!V#+HNn_D3lp z2%l+hK-nsj|Bi9;V*WIcQRTt5j90A<=am+cc`J zTYIN|PsYAhJ|=&h*4wI4ebv-C=Be#u>}%m;a{IGmJDU`0snWS&$9zdrT(z8#{OZ_Y zxwJx!ZClUi%YJjD6Xz@OP8{ieyJB=tn?>zaI-4JN;rr`JQbb%y5h2O-?_V@7pG_+y z(lqAsqYr!NyVb0C^|uclHaeecG)Sz;WV?rtoqOdAAN{j%?Uo%owya(F&qps@Id|Of zo@~Y-(YmfB+chv^%*3g4k3R0WqvuYUIA+8^SGJ{2Bl$X&X&v02>+0$4?di(34{pt* zG=f#yMs@Y|b&=HyH3k4yP&goF2LJ#tBLJNNDo6lG06r}ghC-pC4Q*=x3;|+W04zte zAl>l4kzUBQFYF(E`KJy?ZXd1tnfbH+Z~SMmA21KokJNs#eqcXWKUIC>{TuoKe^vhF z);H)o`t9j~`$h1D`#bxe@E`oE`cM9w(@)5Bp8BNukIwM>wZHfd0S;5bcXA*5KT3bj zc&_~`&{z7u{Et!Z_k78H75gXf4g8<_ul!H$eVspPeU3j&&Au=2R*Zp#M9$9s;fqwgzfiX=E_?BwVcfx3tG9Q-+<5fw z%Hs64z)@Q*%s3_Xd5>S4dg$s>@rN^ixeVj*tqu3ZV)biDcFf&l?lGwsa zWj3rvK}?43c{IruV2L`hUU0t^MemAn3U~x3$4mFDxj=Byowu^Q+#wKRPrWywLjIAp z9*n}eQ9-gZmnd9Y0WHtwi2sn6n~?i#n9VN1B*074_VbZZ=WrpkMYr{RsI ztM_8X1)J*DZejxkjOTRJ&a*lrvMKBQURNP#K)a5wIitfu(CFYV4FT?LUB$jVwJSZz zNBFTWg->Yk0j&h3e*a5>B=-xM7dE`IuOQna!u$OoxLlE;WdrNlN)1 z7**de7-hZ!(%_ZllHBLg`Ir#|t>2$*xVOZ-ADZKTN?{(NUeLU9GbuG-+Axf*AZ-P1 z0ZZ*fx+ck4{XtFsbcc%GRStht@q!m*ImssGwuK+P@%gEK!f5dHymg<9nSCXsB6 zQ*{<`%^bxB($Z@5286^-A(tR;r+p7B%^%$N5h%lb*Vlz-?DL9x;!j<5>~kmXP$E}m zQV|7uv4SwFs0jUervsxVUm>&9Y3DBIzc1XW|CUZrUdb<&{@D5yuLe%Xniw^x&{A2s z0q1+owDSfc3Gs?ht;3jw49c#mmrViUfX-yvc_B*wY|Lo7; zGh!t2R#BHx{1wFXReX*~`NS-LpSX z#TV*miO^~B9PF%O0huw!1Zv>^d0G3$^8dsC6VI!$oKDKiXdJt{mGkyA`+Gwd4D-^1qtNTUK)`N*=NTG-6}=5k6suNfdLt*dt8D| z%H#$k)z#ZRcf|zDWB|pn<3+7Nz>?WW9WdkO5(a^m+D4WRJ9{wc>Y}IN)2Kbgn;_O? zGqdr&9~|$Y0tP=N(k7^Eu;iO*w+f%W`20BNo)=Xa@M_)+o$4LXJyiw{F?a633SC{B zl~9FH%?^Rm*LVz`lkULs)%idDX^O)SxQol(3jDRyBVR!7d`;ar+D7do)jQ}m`g$TevUD5@?*P8)voa?kEe@_hl{_h8j&5eB-5FrYW&*FHVt$ z$kRF9Nstj%KRzpjdd_9wO=4zO8ritN*NPk_9avYrsF(!4))tm{Ga#OY z(r{0buexOzu7+rw8E08Gxd`LTOID{*AC1m*6Nw@osfB%0oBF5sf<~wH1kL;sd zo)k6^VyRFU`)dt*iX^9&QtWbo6yE8XXH?`ztvpiOLgI3R+=MOBQ9=rMVgi<*CU%+d1PQQ0a1U=&b0vkF207%xU0ssI2 literal 0 HcmV?d00001 diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/core/gemstone/tests/android/GemTest/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} zs4%E?srLW`CJh0GCIK@hTkrW7A15Iu%N&?Q^$0+!{Tv&|t^Y@u%!L zglTg&?Q5q#ijZ;&HBQ?FNPp;k3J5!&{^+SGq?AX~SiOM9jJMRpyP?RCr@z38AQyy&WRMaC;n4una$~nJKSp?q|s8F00c9?Q! zY_ovvjTFm+DeQM^LXJ#v0}6HRt3R1%5PT*}W!k8BEM;Jrj8dIceFo2fhzTqaB3KKk zGlCLI)gU25(#u6ch6GeB1k@eHq7l{EHXv0n6xE#ws#ri}08kkCf8hUt{|Ejb`2YW* zvg}0nSSX1m=76s?sZhRY$K=3dpJ+y*eDULGnL2}4>4nvW^7_<~wIM_5fjvwt4h1|g z)g0Z6ZFq9j<~9~b8((~TN{Z?ZQfw|is&Xp~AC61sj;xItKyCHdI|tCMC_LbXF>~vR z=w6V3^H=W4CbAgR4#xw}ETTwu2guW~=Crl@SMXv85jQ=%y!s^?m4PI0My7MWICO;- z175jm%&PcPWh8QdOU(#8bp4!N7ET-+)N}N2zk2)8ch|4Q&lPFNQgT-thu053`r*h3 z_8dI@G;`zn;lH$zX3RzIk`E8~`J=BBdR}qD%n@vVG1834)!pS1Y?zVkJGtsa(sB~y zNfMYKsOJb%5J(0ivK8d+l2D2y&5X!cg3BG!AJ}910|_${nF}sC1QF^nLIhzXk-Y#x z0)&1iK!O;Og0Ky!;`b~v%b$`S4E&fB)1NB4v@8wr( z&+NX4e^&o)ecb=)dd~C!{(1e6t?&9j{l8%U*k4)?`(L3;Qjw z#w7FS+U(94MaJKS!J9O8^$)36_J8;thW#2$y9i{bB{?M{QS_inZIJ!jwqAbfXYVd$ zQ5fC$6Nc9hFi8m^;oI-%C#BS|c8vy+@{jx6hFcf^_;2VRgkoN(0h!_VSGmgNPRsxI z8$rTo0LaYq-H5i&gtj81=&xU?H-Y2==G@uQV7E`@+2E9XQW@{&j`?EOktk|Ho{HU>ZqDzvgjwBmdex z&uZNd2C1h{{}2k6Ys9$*nFP3;K%u!MhW`uZy7Sn`1M1zs@Es&;z*Z>Gsh@-3Fe6pE zQD2@cqF((NrRevgvLsvM_8;;iNyJ5nyPyy?e!kvKjGj`6diRFBEe49Oa7wwkJFV7Z z$YT&DWloYu-H?3<0BKn9L&JYDT-SK~*6c5pi18P26$JESKRYj{T7Zk6KiRJcbvOO*{P56Q6s8msbeI3>|j>K9}Q9UBeq*inXKemCm`-<5|-$ZyN4u$(3 z&HcvqehFD%5Yrmykg-^d`=BSa8(i=>ZoC77^mWY{evp(km@aHqhUECBz76YiR+VYK zY_avFC~V3$=`6C4JhfHAQ@DZtUOwH`L;oYX6zK0-uI^?hS$ALfq}A7evR;ohJHij} zHSZdW?EKv9U1s4oD*<(0oQ*;MaQ6@cvGL zuHCPgm_NhVsgp^sfr*ia^Db}swo1?O(_Q2)y+S$CBm+g=9wCOUPbz(x)_GbaKa@A7 zuI&!ynLiZRT#V%_y_-D`0Z5lT*auoe{(U5NylTzFSJW()W-#F6*&A`LNO1bV#Y;QJ zSbLBnp|B^dtK|KIWC|No>JjWBWE@n7O)x{&^E(WMeMvp57#qA8m* zeTow*U@_86B#Fm*rxyYu5PRWaWHx8y> z*qmHEp(AMDl0v)ij(AY8fnH=~ZwwjVAbu*m5;xPfidh@ov6d8g zfJsi&!QyK53Es%sC39ts;54V68koALD4b|%tNHW0bIkZAJKa=W&FomJSEDT>W1xIX z1x%Z>AvNIsSPLcn3RTcHXb@KB?cuM)=x6fcIx>&(GxqZ8w3p#jJ(GVgc*`c0HG}dv zIop&Qim!K1NFwic%07KcjWgHBPUkq7f~lj;TPqVGTiT#cUeim>;nY`>h@a*S{qQex zQ`z62WK|Mj)Y{tfF{;T4P;c8$Q|KU?Joh zIkA^z%X7z|r>4aTh@|StTi!-r1D!g=zb#3d#{{&K3CqE$Iz-UH<%37c zRfkO`&uM%#AD3PHv`g5t0e^O%nVL0d{Xlx^EjEC3#skF@`zl-7PF^0oxW)1!C!JxR zWvuAHH?)61FKA1QeT*_sY7;_Id#!GmV4n`MO{~sv}VLSK` zXRw=Y=Clz*00B(5y^K;gCZMAzjT5+c3IC=)l(9VIDdatpxj3y89WwI|bH&$!ZEvp` zPR!T@#!(|KfI-w?!&+7$N3F6>tD{YO4Qg$d_`nNEdfVCha9vaPn0jI0`)`@*72hq! zpU5ND^P*RoEkbD5o#az(-g=Y)L>HH>Oc%}$ zT3Rs_ih0;4+Lv4Y;@Iv(;fUbQ=i-G(#>vghec~*j(I#r|5mqFiJBpzi&hzEcD{u$< zRsm0BVYn=pT;0>R(itW|*D&;O%bOc7et9ACaH#J>z3A1A~6fdP>pmbM%xzm4>|;c_?B+%sl;Qs2{t!60$^u zH1t@9^6>;?!FuusnISi$f5CL&;z?EqJN$FBuWDA#D5`cy_UvCFIVvf{c?4N0teh;d zET$7aVbj08KTQS!x?Nd1Is8q8qFzs}a=!@nJ;7FSfCY^T@D-gpw`w<6e#X3+;O}1h z$%I!M)0bg|EKUA04Qjn@+x{Rj8vt6Wn!R|3A92z}^$KfF5(#CWr4y#~re1CN4i4w0 z#GsypBR{xA3Er7sgAi(|}1-W?s~n$7?K|9WL8kpVfw-;#b9 z+mn;=ep!162U5R>_t}fOt~tE?s#m( zO-S$7>Ay6*hHdZ)7_oU915WYYCIX;hFI-U2EWYX!pllONr@Q--2o~`!isi6vTPLJ4@(|o=%NHYjo0_S&q*UQIROw@*N-By@PaQ&;YxFZ0aR zX&}LeOEz);#m~Hwm^VAY8DK}b$F4bo{jMN?d!lxKPhNklzr^Cd`0f4oJr^z=I|l`* zm8AHm*fPV`0=lF3Pnnp}&J0N1X@}-D94YvmUabFrLGSnTz7Mu^21F#O5tN#CuY9Vh zUZBH=ez%h*wkf0hBtXJh1SN3d+IF{gzT7lp)j}n?03lt;XSQRAh7qd&v;RwTYDuQ# zbI2*r<>?x-G0@hM{;%{VBD7nLKt~D`T~-HAt5;h%i0_=Ifs=yHma5dhJ+QMG?Ux(a z|E?1CMy1!~oA`FP!k~iG=t&5#>bVdz=peT8HMB6Y)#7PpETtNryT^+Rv3vpJaF^zP z{H}0-LyV9Fu21ID%wO9f1IKlFr1p4c{o-?03vyB-tr5duk^&L$;m_|f$vs`^Sl{j2 z95}oY{LlY+=ZS%J+tZoXCd0*sSU7w^gjovXn+g7uyra5{cU49@yHf#Z^Jl-$9cIfo z+AJuxH$VLb=#+uBbVmUjnx zxb1pZ@-O9=AIk4@S)m6fJ2?{HrNYwwnL3a45muuNjr;6$O`bGEM0T4A2_S$t=86*- zcO+0mywg*j#A4mU}enR_!cGmIYQ;qwfchWtFEXL)AK%*;=j znYne+hS4EMy3S)C*mZ1KI>!+)0V@9!N6H$Y}~MJ{rYuf zz^KljIWvFi-?#?V@LPR&c6Nn{!=XM z>}-h$S76;$H{E{Y%@^zlmOl^efBwa%UU+jJD9UVukQ3ti_kH-?H*RC0?M1W%FCvMB zM_+v6fk$6X2sx)-p~B3&Kl{nscK}pNLM*qjtpaf9>AU{-iPKQZR8yCg!TY}Qg*(;) z)gdvCcB%kppZc$VdvsK@)3l1{&DG!d_6OHOS`y=ITLEVu`unSKA2E%JD*DVX{LJ}K z9l>hMRDqxQh0lnpGHpVYneX}eA3Pt|2v%=q;rt)``R|#bDyB)OXY&vI_@|*}h}G?^ z@aZ4_!7cQPX`!fW_?{oT1NTwHs#l5L-0`E|y@48<3Q^HFf8=Idi zpJYD%1MkII!~|7I^WGo)IF=?{>ACnjJ_WUi39C}!Q{QnheVJqeKKqq5^o5CBde(g9 zvw$X6^jz_^E2$wSw4!q5*RG(C2_^XO$HBn_55vbl44OnTTRwRaePP0vo{K)U1#99& z<>rq7V&V(<&@I%MFoN5zrY}sz=(*-L&}1QQ*a%`u25h{cFj===17eB_uGuzG&byQ< zrm8BJZl4r_E$3k|Wo6FW0-6M7>qac5uFQsQcmkLWGfeH74S3Z_rJ!jgN++!@i=HW8 zkyjI(oPH-+-N#Qc^-mpNO`bc6r=2-<%&Wy5K1vfFJB(L_IkpS6fY^NmuL8qsgj>MD zn~BHH9WM~32_3vd=W&B)k7F9q%stJx+b_L_X-4zr^LVUMCmyCTA3sWtkvsmME?Xiy z?xOSfB=_$oY06~J-HcCq&)qcW{j;uP;?Dm}=hkq?zh&n!;m((-G-u_t|6x399Q;>A zgNpxoJNj{u|MFDH7Rhq@FCAl0dE|ddnl!oh9{Lq?@JDoR6L;C941IK`ISfdE$4S zE0AUQ8+2|Ncl_q5QkSp#AODp~(^mfP&%Au@@|TBQwoP`UU+V{6u8|)6ZA{~uKmQ*M zmrMTDU8S~8Eqi{^v0Ug&5Upcm#y7Z1(RbgZAG8jB$eRwCspQ)>5;U)oGZ&E5aeR*K z8Yt`Y0$G))Yd(Y3KH}tA4`-_QmNke5hU_|nq=xtyjwW(_o?itz>B>WM&^63bNdQ)k@-IgDHW*RW$Xo9#RzrTrCn7L2H{9Amq|qNg@#eZY=|P zCoI?2s+L)zsM%WX(NbVEY^`C>lFjIBYmJ6@DKJ0ZT4&F&WHW!dwa%QzOG!?jY_2(S zDcEzZbz*2Q!43|z))9yOP9X1Xt%DXzwY(3tl-TR=Qb_MbZYRrooh;dYYmS!U_as1(=YVB?Q_A|tNu5Ut&_q3jbfDM zoFxT^uEuH`nX3*sB%K?GuHUkweYReBwnHqh3P)~`+s3+Tj!rDA1e)8vuBv5J*IsxC zkd^~b(aGzArj08{>cnzOuy04C+C`}gb|Yz-1avxeWzev3NzcHbz_&4W@QCr$z3~w=8Ua- z`;vfG1~BP8CyLb=F7t1am~ph_#|O%$khSJ9%Vtcn)YmpgQxF?xM^_Vb+5fnpB^W0I`f%X8gb9#X{Q-yJG0{Z56aWeI&zPxnf5pdJA38bM`cYnS#x)% z`n1tFf$i)W-hGm(f9mde^=X@NcV_lFb=P`4&CI&H=IArijGwdCk&X@uQ$5xmj!~^? z#$ROCI)V-~t%L%GS#wo@U27ddR`4`3)WoB{R-4snfNrfee|kI8^bu#yDgYqOwas9# zmcb`3!kRJ`Cr=_tq)8aMt{aGtUZsqwVlj6DgCGre>AEt&x8H_in!x@uwgExIh|-mA zjdaC(29~CTVSaaF7HPbql&*9Uo8P@f)>LqCXclr}peS7_1BQ28u9PO8Eq1@`l3q9o zkfKCaO2?T?ZyA6loW<#9_c^O=m<&h}CA!ineAD@=(gbq`vyT|tiJ6#^B1$P;;qax` z55k&Q?wEh#87niLo*+n4L@65J(Nz~=Ya%7^(miLb(E>A3B@|Jjl;FU&D>o|9#7PJH z?|ago!o;WC^h=|T7PVBg(DAB}72cyUS zb(f>Bwbr!F1eTCO5fpj<{PqhY5>143p?~5ZA5H40);=@M#MYvrB6gqHbU_!GSY??i z%s=>-ciA4*zOOZHds0a(kWewZ4h(k8h(ua7HX)Au&mY~H8KY6(_cb$_&fA@QjIW-*heP3%$d!m5^AdnT}`12qA^c@!g3DOwZ5WwE2?)-yU z!)Vx#Mtxt?FzFTwK!77sy7)sMzUd->w4^bxtpM2j!b1pjgyk zGKwWGeb4)^zjy{9Es&PU1}gwg?|J#L$KJB7ett9@4M%-nGtIQr0>Fl@8-yh`-+1ed zS6r}(MeSvgSoFmH*_WPu@i?}!AB~2?;i&IxrkNg~cQ9Som98tcq)k^|eeER|Zl77t za-TVUc;DNvzVXJ%w52+#weN?+;i#{f#!Oc&z?81*N>^e~ltRS%ZI@lR{rs()HmqG! zx*}ZrI-EZ}ckJMiy>A^oofwDfC~IH)z8{VHKGT@#E5I(Ll&+MnMCl>~AV7+>Gi%mF zkU1QlKASdR0B80!YhP<$Ywi0?W2Ux45oPfxv9QolWzJPD^weBfvo4SONxP35106sAmh(e+vAs0GboFD@PvNs)jNPvarhW}0YliZEg{Gazv z+JDIpoojRVPr<*C|BTq<`6ga{5q^8^!|0cxe=rZ!zxH3%f5ZO0cQ*Z<^$Yt2{|Ek0 zyT|*F+CO@K;(owBKtGg!S^xj-Z~rga2m6nxKl9J=fBSuNKW_dLKWhJKeg^-Xe`^1? z`TyJj)8E!#>_3Y?uKrwqq3LJ#SGU>AzUO|6`nR^u&3FNN_jGOc zw)Nw`wr3yIKhgcee6IaN=ws>M{6677%)hPwx&HzC(f&u~&)6@b2kNRzBDQAP0*H73 zq%McOmRk{B3i47qRe=DA*$&odrbEJZ*pV9XXa&p@wlW~@Yfs>V{yiTtplMhgM*-Bz zsSnlq&pG;z0OUN%$~$3=g1UF+G*>+17eRbBf3=y79J}KR8owon@$1Z7MIrvvWWH)34nK2SD)GsrJ{l z1Cl#oVo3A8qY3e=aF)qzms~FG#2$LzT=gs&aVMOj>(%{y<&O0cG!nCiESl~x=^dF{ zKvj8F1K8Ng171wwM5Fh4KoQw`_c6#y$(5cAm7e}~nJ#A*fx+c9;y#&W!#VukR)ugk zKp3=+;Ut+IYn%m+r4d*<`L2h%aDnX5}^!5R|H;(34AoVWjRx(msBZvk;rCI*|~ zdOijqI@9Z{Vu!~jvHW{lBa$rnl4+!s_5sfK3bCGk-B%iDe&@-}+%fOKU|(9?V1 zHE8&@4z)Kx!RAvAs z!Wic9=o#(bg?kc-G68-m(jZ`^=XGUXb)}t(%&~sjFnV^sEX%hSy6UKC4iOhgV=BHV z2w`4g7Y=s#Vu2B_?#VQ|hP39@eArgfX>-0S+dd&^mx0*wp}>)x;c4RUgxz%;oNe?& z-7-lJ@Y^2^C;=qJsxx5|xF)*pTGhch2B&kxtn;f!7=gznk}I3}Dh}(CoMXgA5-p&kS202!l?!fT3t|HG*rIP~mS* z$Wjo}jq3}z$Qq!9yrtd3fM0N629ZM?LU$nv@Tv9b7I;D|;0H2dsA~g7Z7zp1| zB)XmrkMgF6OQr|R)HHD^TE{Y#j!~SR?b`Xt3Qs`B+x<hxexYeAjMUWdZ-*n9%(1)Wb(n2U<><7&9dwGJmrob)4%H? zlQ%z+L-^$dFhhH|@u$%97Qz?*Ynh2VG@q|?8vY&L74&fs&_b&3$x&Oyjl~LQDRRap zJU4U*R+(2Dd!G+lh8!V{pT_UJn+^1Qg6$` zqkNm(a#hWyc6SP+p5=C4HL8-m`pO`5o~`-LI?_h5CsH?F_%?nDodmz&pWR20WTpJE z?N|wSzLjMUK8E)a2tI}Lf;+;*M|h3Y(U#>)g1>zk9|Hd}oZAa2 zLYBWBoSW!Ts!RwXr^8h+U*@{9{zqS^iH)Op<;r`Uw~nc}<^$V~_i%$GFjaG?X1@E|M`h)nekvFKt`Dh-f>@|0-`Xoq)o` zx;JmzDfOV9qCx|EVpogEe0LK~tGS?5$$L_i6P$P6wIsCQaP_;d{{N=iV@+8LI}o#( zvo*Ejy=IIn{rdIQh1&q-{EuohpVOjJ^Q3lD*YTp37$^RRgn8ihpdu5{Ct%5-KO!VL zcNB6dUajXI9jkm-P|i3~GB-A(X`P1Oqqb$tcku)UJw0w3GeUijb__#QT4j%64z%EeB7S?jlWwx_7&+EEvB|6N=kV}DwnyAlX=?j`) zmU#!$*^@NIu#n_d7;WoJV@*Fbv9|yJO4;n|BNF2xy(54RyB>t~8lUOUW$&2%Nwi1y zx6JxW88>U2$#qhl^6KUbtmg9}D0o5vYDT7kWJthLGkpGnN4T>{St^_EU>4;DmLF9o zr|LqsA8_MoNLQ=}w?8u!ziSZ@PC#Y<#9uJFo-ozVo6D;<8j^1$c|qAE3ZTE5i~zmE z$BU5lw6l=EWsg^y^;8>r9qH{xfL|~PZYK#md$zZ0?o11gV<*WSW~cgy2GYGQir%wf zt4iW8D+;s*;RGrmd(-T<@2&j(Cb9xhV*l-x`TpK`xq|7p?5R%5*s!69?2c!cC*VY* z2DE^9pvOPLU!1e}wA8S8opcTJ3`NB>hY=JQnL~QFXR4K8A$BqJnoEB$wn-%u@E6Mh zCfMF4kusv3N!(aHC}4)Xs^xoOwXd%e^6pi5|DZo=Q25j+6HlJ^7FodH6y1bMROR^q zGu6)fopS`h%Sw<;ZH%TEPf+#81-#_v+@8nlR0jLcIDKQtLleOC)6yLZgC!D9X3GgS zohwU{v$jl=quD#Go^hB{`@Qw*a%`(^jyT~=q^bWgGzRj;|12J55HWdCWV}EB|K=%N z3Nq-qxJJ`>^|1MNN+q}zTB&ooE3j==AgK@^UW<^oSbeALa2peF)Th6{@sj0KyMNHZ zksk1+MXN2tv+22A%cQOGpS9)77(uP9mh+!5T5ERLvF@b}$+WvXM45Z?-kCa)fb~f1 znVbTD$Gx-0Zxc`0D@YgHakge6SL0H`-vN_x?AP0>iGH0_EE&=v83hMJgaKAI0jJXm zVxVz;X<$v6WW7}fxROO7vr#YLP;;lij5VrX{;>7kK6TtOH&6|Ar^xo>00%+u$C4@# z>!jOt6*3><171+WxoZnKDTzJtDRw+T030;yI}~uV@9fCnei^I*j>Bp&mzP2d=FPb_ zCM*l_+$LDR3B*a!A$g#>xsrZvw0lckxmMg>0aQd7tPyN=t{dgXb;Ie+T8{fZH=gdu zM7Rg9c(kg(Jg0?ARRRl=AONFKrvFj)lTY$KfT%6^6s`mk*ABGhsce*LsoD>K{z_M2 ziPpnu+lw22PfF!CoId^6n*G4H(Ix+#+N{C(da7t1BYMGEaE#PdpOLxsVD5riQXHp@OX;`S`8VnpM~)I920w~<3|mo0 zf8~Az`*?2?H&gZ&*K&bRkV@qzvMlRHXys8*Ze2+1c?5o!^+$&MHxB@4Ee5cke52R! zmn7AZtY6ST%ixgU5)%$%QcwHj7Es-Qu^kLAPwy%7pGBw_4Q9#da^W2$}axNHr03)_nw z5?yuNmXrI5HgS46)c5&}B)Tts49oU92>3xBLLy}FMUW=84DQbVq^;7_e7|(Sdz|&J z73N+M`rc2rt*oSWu#7S{*s~nH6HRHJS1SmzeXk|;CA)FI4bat3<%}nkB%;;?=F>B7ms9QSxv#@+69;@>QaR?REYX4&)=itG>rM{<{A79Rmk)`5ON#GL`*KX%}Ihk3w(RtM-WLt z?f&FLF}4N^yE!(pZ&Yj&Bc`~K0@4_}*0Om?wN|}4WJ>WL;G^H2*QpgEkGA~OET-Km zkwz|5{6dnz1U<2Pe9DNL>3g5FEIvp1jzP&2K#z~j%g6!7B;^zF+o95?fV{3mnB8*RMhCDNp>Am-3e@jNfMj?jHV$MWjk!DDKP zkAz$Y?Sr)!GUOX}qTQ5aMh|wq1uq}~joWyKl=b_LboM#wi{CMuz5x6BKlA-qy++cM01D3b7`uD z#l6M4pI;JCypO8JZ6?U&wNxR!{4oB_ zlV!x9+-&Qy6{%MQ{~yoZGkKiTSC`YS_j22~G;xUV855g2&C(zm^V!(wpcm@zn{%!g z4}JGo(sGZ1O~to-}le

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/values/colors.xml b/core/gemstone/tests/android/GemTest/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..f8c6127d32 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/values/strings.xml b/core/gemstone/tests/android/GemTest/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..84249e7889 --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + GemTest + \ No newline at end of file diff --git a/core/gemstone/tests/android/GemTest/app/src/main/res/values/themes.xml b/core/gemstone/tests/android/GemTest/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..344be712cd --- /dev/null +++ b/core/gemstone/tests/android/GemTest/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +